Major step towards unification of the UI Implementation of the Back Feature, splitting of TUI files back into subfolders
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
# Copyright (C) 2025 James Brotosky, Brandon Wickline
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import difflib
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.css.query import NoMatches
|
||||
from textual.message import Message
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import (
|
||||
Button,
|
||||
Footer,
|
||||
Header,
|
||||
SelectionList,
|
||||
Static,
|
||||
Switch,
|
||||
TextArea,
|
||||
)
|
||||
|
||||
from models.agent import Agent
|
||||
|
||||
|
||||
class MultiAgentSelector(Widget):
|
||||
"""Widget for selecting multiple agents from a list."""
|
||||
|
||||
class AgentsSelected(Message):
|
||||
def __init__(self, selected_agents: List[Agent]):
|
||||
super().__init__()
|
||||
self.selected_agents = selected_agents
|
||||
|
||||
def __init__(self, all_agents: Optional[List[Agent]]):
|
||||
super().__init__()
|
||||
self.all_agents = all_agents
|
||||
self._match_type = "exact"
|
||||
|
||||
@property
|
||||
def match_type(self):
|
||||
return self._match_type
|
||||
|
||||
@match_type.setter
|
||||
def match_type(self, value):
|
||||
self._match_type = value
|
||||
|
||||
def compose(self):
|
||||
yield Header(show_clock=True, icon="⚙")
|
||||
title_text = Static("🖥️ Agent Selector", id="selector_title")
|
||||
title_text.styles.margin = (0, 0, 0, 1)
|
||||
yield title_text
|
||||
|
||||
with Horizontal() as main_layout:
|
||||
main_layout.styles.height = "auto"
|
||||
|
||||
# Left side - Input and controls
|
||||
with Vertical() as left_pane:
|
||||
left_pane.styles.width = "1fr"
|
||||
left_pane.styles.height = "auto"
|
||||
|
||||
text_area = TextArea(
|
||||
id="device_input",
|
||||
placeholder="Paste device names here (one per line). Supports wildcards: * and ?",
|
||||
)
|
||||
text_area.styles.height = 10
|
||||
text_area.styles.overflow_y = "auto"
|
||||
yield text_area
|
||||
|
||||
with Horizontal(id="switch_search_container"):
|
||||
switch = Switch(value=False, id="match_switch")
|
||||
switch.styles.width = "auto"
|
||||
switch.styles.margin = (1, 0, 0, 0)
|
||||
switch.styles.padding = (0, 0, 0, 0)
|
||||
yield switch
|
||||
|
||||
switch_label = Static("Match: Exact", id="match_switch_label")
|
||||
switch_label.styles.width = "auto"
|
||||
switch_label.styles.margin = (2, 1, 0, 0)
|
||||
yield switch_label
|
||||
|
||||
search = Button("🔍 Search", id="search_button")
|
||||
search.styles.margin = (1, 0, 0, 0)
|
||||
yield search
|
||||
|
||||
with Horizontal() as select_buttons:
|
||||
select_buttons.styles.margin = (0, 0, 0, 0)
|
||||
|
||||
select_none_button = Button("🚫 Select None", id="select_none")
|
||||
select_none_button.styles.margin = (1, 1, 0, 1)
|
||||
yield select_none_button
|
||||
|
||||
select_all_button = Button("✅ Select All", id="select_all")
|
||||
select_all_button.styles.margin = (1, 0, 0, 1)
|
||||
yield select_all_button
|
||||
|
||||
with Horizontal() as button_row:
|
||||
button_row.styles.height = "auto"
|
||||
button_row.styles.margin = (1, 0, 0, 0)
|
||||
|
||||
submit_button = Button(
|
||||
"▶ Select & Continue", id="submit_selection", variant="primary"
|
||||
)
|
||||
submit_button.styles.margin = (0, 5, 2, 1)
|
||||
submit_button.styles.padding = (0, 6, 0, 0)
|
||||
yield submit_button
|
||||
|
||||
# Right side - Results
|
||||
with Vertical() as right_pane:
|
||||
right_pane.styles.width = "2fr"
|
||||
yield SelectionList(id="match_results")
|
||||
yield Static(id="unmatched_label")
|
||||
yield Footer()
|
||||
|
||||
def on_switch_changed(self, event: Switch.Changed):
|
||||
self.match_type = "fuzzy" if event.value else "exact"
|
||||
self.query_one("#match_switch_label", Static).update(
|
||||
f"Match: {self.match_type.capitalize()}"
|
||||
)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed):
|
||||
btn_id = event.button.id
|
||||
|
||||
try:
|
||||
match_list = self.query_one("#match_results", SelectionList)
|
||||
except NoMatches:
|
||||
return
|
||||
if btn_id == "select_all":
|
||||
match_list.select_all()
|
||||
event.stop()
|
||||
elif btn_id == "select_none":
|
||||
match_list.deselect_all()
|
||||
event.stop()
|
||||
elif btn_id == "submit_selection":
|
||||
# Get selected hostnames
|
||||
selected_hostnames = list(match_list.selected)
|
||||
# Convert back to Agent objects
|
||||
selected_agents = [
|
||||
agent
|
||||
for agent in self.all_agents
|
||||
if agent.hostname in selected_hostnames
|
||||
]
|
||||
self.post_message(self.AgentsSelected(selected_agents))
|
||||
event.stop()
|
||||
elif btn_id == "search_button":
|
||||
self.update_matches()
|
||||
event.stop()
|
||||
|
||||
def update_matches(self):
|
||||
raw_input = self.query_one("#device_input", TextArea).text.strip()
|
||||
device_names = [line.strip() for line in raw_input.split("\n") if line.strip()]
|
||||
matched, unmatched = self.match_devices(device_names)
|
||||
match_list = self.query_one("#match_results", SelectionList)
|
||||
match_list.clear_options()
|
||||
for name in matched:
|
||||
match_list.add_option((name, name))
|
||||
unmatched_label = self.query_one("#unmatched_label", Static)
|
||||
if unmatched:
|
||||
unmatched_label.update(f"⚠️ No matches for: {', '.join(unmatched)}")
|
||||
else:
|
||||
unmatched_label.update("")
|
||||
|
||||
def match_devices(self, device_names: list[str]) -> tuple[list[str], list[str]]:
|
||||
if not self.all_agents or not device_names:
|
||||
return [], device_names
|
||||
agent_names = [agent.hostname for agent in self.all_agents]
|
||||
matched = set()
|
||||
unmatched = []
|
||||
|
||||
for name in device_names:
|
||||
# Check if the name contains wildcards
|
||||
has_wildcards = "*" in name or "?" in name
|
||||
|
||||
if has_wildcards:
|
||||
# Use regex for wildcard matching
|
||||
pattern = re.escape(name)
|
||||
pattern = pattern.replace(r"\*", ".*").replace(r"\?", ".")
|
||||
regex = re.compile(f"^{pattern}$", re.IGNORECASE)
|
||||
|
||||
wildcard_matches = [
|
||||
agent_name for agent_name in agent_names if regex.match(agent_name)
|
||||
]
|
||||
if wildcard_matches:
|
||||
matched.update(wildcard_matches)
|
||||
else:
|
||||
unmatched.append(name)
|
||||
elif self.match_type == "exact":
|
||||
# Case-insensitive exact match
|
||||
name_lower = name.lower()
|
||||
exact_match = None
|
||||
for agent_name in agent_names:
|
||||
if agent_name.lower() == name_lower:
|
||||
exact_match = agent_name
|
||||
break
|
||||
if exact_match:
|
||||
matched.add(exact_match)
|
||||
else:
|
||||
unmatched.append(name)
|
||||
else:
|
||||
# Fuzzy match
|
||||
matches = difflib.get_close_matches(name, agent_names, n=5, cutoff=0.5)
|
||||
if matches:
|
||||
matched.update(matches)
|
||||
else:
|
||||
unmatched.append(name)
|
||||
return sorted(matched), unmatched
|
||||
Reference in New Issue
Block a user