RustImplementation #25

Merged
mysticmomba merged 13 commits from RustImplementation into master 2025-11-07 11:39:51 -05:00
5 changed files with 423 additions and 105 deletions
Showing only changes of commit 26199c72cb - Show all commits
+3 -3
View File
@@ -21,18 +21,18 @@ from models.policy import Policy
@dataclass @dataclass
class Agent: class Agent:
hostname: str
agentid: str agentid: str
clientversion: str clientversion: str
domain: str domain: str
freespace: int freespace: int
groupid: str # Changed to str to match UUID-style IDs groupid: str
hostname: str
ip: str ip: str
localip: str localip: str
lastcheckin: str lastcheckin: str
os: str os: str
policyversion: str policyversion: str
status: int # raw status code status: int
username: str username: str
groupname: Optional[str] = field(default=None) groupname: Optional[str] = field(default=None)
status_text: Optional[str] = field(default=None) status_text: Optional[str] = field(default=None)
+41 -100
View File
@@ -5,7 +5,7 @@ import sys
import dotenv import dotenv
from dotenv import set_key from dotenv import set_key
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical from textual.containers import Vertical
from textual.reactive import reactive from textual.reactive import reactive
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import ( from textual.widgets import (
@@ -16,7 +16,6 @@ from textual.widgets import (
Static, Static,
Tab, Tab,
Tabs, Tabs,
Tree,
) )
from flows.otp import otp_activities_by_agent, otp_generate, otp_revoke from flows.otp import otp_activities_by_agent, otp_generate, otp_revoke
@@ -28,6 +27,9 @@ from services.policyhandler import confirmUpdateAfromE
from utils.configmanager import load_env from utils.configmanager import load_env
from utils.setup import get_base_directory, load_user_config from utils.setup import get_base_directory, load_user_config
from utils.utils import open_directory from utils.utils import open_directory
from widgets.multiagentselector import MultiAgentSelector
from widgets.policytreewidget import PolicyTreeWidget
from widgets.themeselector import ThemeSelector
dotenv.load_dotenv() dotenv.load_dotenv()
@@ -115,21 +117,6 @@ class MainMenuScreen(Screen):
], ],
} }
# textual themes to expose
THEME_BUTTONS = [
("textual-dark", "textual-dark"),
("textual-light", "textual-light"),
("nord", "nord"),
("gruvbox", "gruvbox"),
("catppuccin-mocha", "catppuccin-mocha"),
("dracula", "dracula"),
("tokyo-night", "tokyo-night"),
("monokai", "monokai"),
("flexoki", "flexoki"),
("catppuccin-latte", "catppuccin-latte"),
("solarized-light", "solarized-light"),
]
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.extras = load_env("EXTRAS") self.extras = load_env("EXTRAS")
@@ -143,7 +130,7 @@ class MainMenuScreen(Screen):
buttons = [] buttons = []
for label, btn_id in defs: for label, btn_id in defs:
btn = Button(label, id=btn_id) btn = Button(label, id=btn_id)
btn.styles.width = "100%" # Make button span full width of parent btn.styles.width = "100%"
buttons.append(btn) buttons.append(btn)
return Vertical(*buttons) return Vertical(*buttons)
@@ -157,6 +144,7 @@ class MainMenuScreen(Screen):
Tab("OTP", id="otp"), Tab("OTP", id="otp"),
Tab("Directory", id="dir"), Tab("Directory", id="dir"),
Tab("Settings", id="settings"), Tab("Settings", id="settings"),
Tab("Multi Select", id="multi_select"),
] ]
if self.extras == "POLICYPREP": if self.extras == "POLICYPREP":
@@ -216,88 +204,36 @@ class MainMenuScreen(Screen):
elif tab_id == "dir": elif tab_id == "dir":
content.mount(DirectoryTree(self.working_dir, id="dir_tree")) content.mount(DirectoryTree(self.working_dir, id="dir_tree"))
elif tab_id == "p_tree": elif tab_id == "p_tree":
layout = Horizontal() content.mount(PolicyTreeWidget(self.app.policies, self.app.devices))
content.mount(layout)
# Left: Policy Tree
policy_tree = Tree("Policies", id="policy_tree")
policy_tree.styles.width = "2fr"
layout.mount(policy_tree)
# Right: Details pane
details_pane = Static(
"Select a policy or device to view details", id="details-pane"
)
details_pane.styles.width = "3fr"
layout.mount(details_pane)
# Build the tree
node_map = {}
# Top-level policies
for _, policy in self.app.policies.iterrows():
if policy["parent"] == "global-policy-settings":
node = policy_tree.root.add(
label=policy["name"], data=policy.to_dict()
)
node_map[policy["groupid"]] = node
# Child policies
for _, policy in self.app.policies.iterrows():
parent_id = policy["parent"]
if parent_id in node_map:
parent_node = node_map[parent_id]
node = parent_node.add(label=policy["name"], data=policy.to_dict())
node_map[policy["groupid"]] = node
# Devices under policies
for _, device in self.app.devices.iterrows():
group_id = device["groupid"]
if group_id in node_map:
parent_node = node_map[group_id]
label = device["hostname"] # Keep tree clean
parent_node.add(label=label, data=device.to_dict())
elif tab_id == "settings": elif tab_id == "settings":
# Create and mount the horizontal container content.mount(ThemeSelector())
horizontal_container = Horizontal(id="settings_grid") elif tab_id == "multi_select":
horizontal_container.styles.layout = "horizontal" content.mount(MultiAgentSelector(self.app.devices.to_dict("records")))
horizontal_container.styles.height = "auto"
content.mount(Static("Theme Options"))
content.mount(horizontal_container) # Mount the horizontal container first
# Create 3 columns
for i in range(1):
column = Vertical()
column.styles.width = "1fr"
column.styles.height = "auto"
horizontal_container.mount(column) # Mount each column
for j in range(i, len(self.THEME_BUTTONS), 1):
if j < len(self.THEME_BUTTONS):
label, btn_id = self.THEME_BUTTONS[j]
button = Button(label, id=f"set_theme_{btn_id}", compact=True)
# button.styles.width = "100%"
column.mount(button) # Mount each button
else: else:
content.mount(Static(f"Unknown tab: {tab_id}")) content.mount(Static(f"Unknown tab: {tab_id}"))
def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
self.switch_tab(event.tab.id) self.switch_tab(event.tab.id)
def on_tree_node_selected(self, message: Tree.NodeSelected) -> None: def on_multi_agent_selector_agents_selected(
node = message.node self, message: MultiAgentSelector.AgentsSelected
data = node.data ) -> None:
"""Handle selected agents from MultiAgentSelector."""
global _PENDING_JOB
selected_agents = message.selected_agents
logger.info("Selected agents: %s", selected_agents)
# TODO: Implement actual handling of selected agents
_PENDING_JOB = ("multi_agent_action", selected_agents)
self.app.exit()
details_pane = self.query_one("#details-pane", Static) def on_theme_selector_theme_selected(
self, message: ThemeSelector.ThemeSelected
if data: ) -> None:
details = "\n".join(f"{key}: {value}" for key, value in data.items()) """Handle theme selection from ThemeSelector."""
else: global _PENDING_JOB
details = f"Selected: {node.label}" _persist_user_theme(message.theme_name)
_PENDING_JOB = ("restart",)
details_pane.update(details) self.app.exit()
def on_directory_tree_file_selected( def on_directory_tree_file_selected(
self, event: DirectoryTree.FileSelected self, event: DirectoryTree.FileSelected
@@ -315,14 +251,6 @@ class MainMenuScreen(Screen):
button_id = event.button.id button_id = event.button.id
logger.debug("Button pressed: %s", button_id) logger.debug("Button pressed: %s", button_id)
# theme selection → user config
if button_id.startswith("set_theme_"):
theme_name = button_id.replace("set_theme_", "")
_persist_user_theme(theme_name)
_PENDING_JOB = ("restart",)
self.app.exit()
return
match button_id: match button_id:
case "find_device_button": case "find_device_button":
_PENDING_JOB = ("legacy", findAgents, (self.app.api, False), {}) _PENDING_JOB = ("legacy", findAgents, (self.app.api, False), {})
@@ -383,8 +311,15 @@ class Loxide(App):
if not os.path.isdir(wd): if not os.path.isdir(wd):
wd = os.getcwd() wd = os.getcwd()
self.working_dir = wd self.working_dir = wd
# Add error handling for API calls
try:
self.policies = api.policy_find_all() self.policies = api.policy_find_all()
self.devices = api.agent_find_all() self.devices = api.agent_find_all()
except Exception as exc:
logger.error("Failed to load policies/devices: %s", exc)
self.policies = None
self.devices = None
def on_mount(self) -> None: def on_mount(self) -> None:
self.theme = self._textual_theme self.theme = self._textual_theme
@@ -473,6 +408,12 @@ def run_Loxide(api: AirlockAPIWrapper) -> None:
# just loop again; fresh .env was already loaded at the top # just loop again; fresh .env was already loaded at the top
continue continue
if job[0] == "multi_agent_action":
# Handle multi-agent selection
# TODO: Implement actual multi-agent action handling
logger.info("Multi-agent action with selected agents: %s", job[1])
continue
break break
+133
View File
@@ -0,0 +1,133 @@
import difflib
from textual.containers import Horizontal
from textual.css.query import NoMatches
from textual.message import Message
from textual.widget import Widget
from textual.widgets import Button, SelectionList, Static, Switch, TextArea
class MultiAgentSelector(Widget):
class AgentsSelected(Message):
def __init__(self, selected_agents):
super().__init__()
self.selected_agents = selected_agents
def __init__(self, all_agents: list[dict]):
super().__init__()
self.all_agents = all_agents
self._match_type = "fuzzy"
@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 Static("🔍 Multi-Agent Selector", id="selector_title")
text_area = TextArea(
id="device_input",
placeholder="Paste device names here (one per line)",
)
text_area.styles.height = 10
text_area.styles.overflow_y = "auto"
yield text_area
with Horizontal() as switch_container:
switch_container.styles.height = "auto"
switch_container.styles.align = ("left", "middle")
switch_label = Static("Match Type: Fuzzy", id="match_switch_label")
switch_label.styles.width = "auto"
switch_label.styles.padding = (0, 1)
yield switch_label
switch = Switch(value=False, id="match_switch")
switch.styles.width = "auto"
yield switch
yield Button("Search", id="search_button")
yield SelectionList(id="match_results")
yield Static(id="unmatched_label")
yield Horizontal(
Button("Select All", id="select_all"),
Button("Select None", id="select_none"),
)
yield Button("Continue with Selected", id="submit_selection", variant="primary")
def on_switch_changed(self, event: Switch.Changed):
self.match_type = "exact" if event.value else "fuzzy"
self.query_one("#match_switch_label", Static).update(
f"Match Type: {self.match_type.capitalize()}"
)
def on_button_pressed(self, event: Button.Pressed):
btn_id = event.button.id # can be None for internal buttons
# Only query when needed
try:
match_list = self.query_one("#match_results", SelectionList)
except NoMatches:
# UI not mounted yet or id changed—just ignore gracefully
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":
selected = list(match_list.selected)
self.post_message(self.AgentsSelected(selected))
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:
if 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
+199
View File
@@ -0,0 +1,199 @@
import logging
from rich.text import Text
from textual.containers import Horizontal, Vertical
from textual.widget import Widget
from textual.widgets import Input, OptionList, Static, Tree
from textual.widgets.option_list import Option
logger = logging.getLogger(__name__)
class PolicyTreeWidget(Widget):
"""Widget for displaying and searching a hierarchical policy tree."""
def __init__(self, policies, devices):
super().__init__()
self.policies = policies
self.devices = devices
self.last_highlighted_node = None
def compose(self):
# Left: Policy Tree
policy_tree = Tree("Policies", id="policy_tree")
policy_tree.styles.width = "2fr"
policy_tree.styles.height = "100%"
# Right: Search + Details
label = Static("Device Search:")
search_box = Input(
placeholder="Search policies or devices...", id="tree_search"
)
details_pane = Static("", id="details_pane")
with Horizontal():
yield policy_tree
with Vertical() as right_pane:
right_pane.styles.width = "3fr"
yield label
yield search_box
yield details_pane
def on_mount(self) -> None:
"""Build the tree after mounting."""
self._build_tree()
def _build_tree(self) -> None:
"""Build the policy tree structure."""
policy_tree = self.query_one("#policy_tree", Tree)
node_map = {}
# Top-level policies
for _, policy in self.policies.iterrows():
if policy["parent"] == "global-policy-settings":
node = policy_tree.root.add(label=policy["name"], data=policy.to_dict())
node_map[policy["groupid"]] = node
# Child policies
for _, policy in self.policies.iterrows():
parent_id = policy["parent"]
if parent_id in node_map:
parent_node = node_map[parent_id]
node = parent_node.add(label=policy["name"], data=policy.to_dict())
node_map[policy["groupid"]] = node
# Devices under policies
for _, device in self.devices.iterrows():
group_id = device["groupid"]
if group_id in node_map:
parent_node = node_map[group_id]
label = device["hostname"]
parent_node.add(label=label, data=device.to_dict())
def _collect_tree_nodes(self, node, all_nodes):
"""Helper to recursively collect all nodes from a tree."""
all_nodes.append(node)
for child in node.children:
self._collect_tree_nodes(child, all_nodes)
def _remove_match_selector(self):
"""Safely remove match selector widgets."""
try:
existing = self.query("#match_selector")
for widget in existing:
if widget.is_attached:
widget.remove()
except Exception as exc:
logger.debug("Failed to remove match_selector: %s", exc)
def on_tree_node_selected(self, message: Tree.NodeSelected) -> None:
"""Handle tree node selection."""
node = message.node
data = node.data
details_pane = self.query_one("#details_pane", Static)
# Reset previous highlight
if self.last_highlighted_node is not None:
original_label = str(self.last_highlighted_node.label).strip()
# Remove any styling
if isinstance(self.last_highlighted_node.label, Text):
original_label = self.last_highlighted_node.label.plain
self.last_highlighted_node.set_label(original_label)
# Apply highlight to current node
label_text = str(node.label).strip()
if isinstance(node.label, Text):
label_text = node.label.plain
highlighted_label = Text(label_text, style="reverse bold")
node.set_label(highlighted_label)
self.last_highlighted_node = node
# Update details pane
if data:
details = "\n".join(f"{key}: {value}" for key, value in data.items())
else:
details = f"Selected: {node.label}"
details_pane.update(details)
# Stop event from bubbling
message.stop()
def on_input_submitted(self, message: Input.Submitted) -> None:
"""Handle search input submission."""
# Remove existing match selector FIRST
self._remove_match_selector()
query = message.value.strip().lower()
tree = self.query_one("#policy_tree", Tree)
details_pane = self.query_one("#details_pane", Static)
all_nodes = []
self._collect_tree_nodes(tree.root, all_nodes)
label_to_node = {}
for node in all_nodes:
label_text = str(node.label).lower()
label_to_node[label_text] = node
if node.data:
for key, value in node.data.items():
if isinstance(value, str):
label_to_node[value.lower()] = node
# Wildcard-style substring match
matches = sorted([label for label in label_to_node if query in label])
if matches:
# Try to reuse existing match_selector or create new one
try:
option_list = self.query_one("#match_selector", OptionList)
option_list.clear_options()
option_list.display = True # Ensure it's visible
except:
option_list = OptionList(id="match_selector")
# Mount to the details pane's parent (the Vertical container)
details_pane.parent.mount(option_list)
for label in matches:
option_list.add_option(Option(label, id=f"match_{label}"))
details_pane.update(f"Found {len(matches)} matches. Select one below.")
else:
# Hide or remove the match_selector when no matches
self._remove_match_selector()
details_pane.update("No matches found.")
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
"""Handle selection from search results."""
selected_id = event.option.id.replace("match_", "")
tree = self.query_one("#policy_tree", Tree)
details_pane = self.query_one("#details_pane", Static)
# Find the node
all_nodes = []
self._collect_tree_nodes(tree.root, all_nodes)
label_to_node = {str(node.label).lower(): node for node in all_nodes}
match_node = label_to_node.get(selected_id.lower())
if match_node:
# Expand path (original working logic)
node = match_node
path = []
while node:
path.insert(0, node)
node = node.parent
for node in path:
node.expand()
tree.select_node(match_node)
tree.scroll_to_node(match_node)
match_node.set_label(Text(str(match_node.label), style="reverse bold"))
details_pane.update(f"Selected: {match_node.label}")
# Remove the match_selector after selection
try:
option_list = self.query_one("#match_selector", OptionList)
option_list.remove()
except:
pass
+45
View File
@@ -0,0 +1,45 @@
from textual.containers import Vertical
from textual.message import Message
from textual.widget import Widget
from textual.widgets import Button, Static
class ThemeSelector(Widget):
"""Widget for selecting and applying Textual themes."""
class ThemeSelected(Message):
"""Message posted when a theme is selected."""
def __init__(self, theme_name: str):
super().__init__()
self.theme_name = theme_name
AVAILABLE_THEMES = [
("textual-dark", "textual-dark"),
("textual-light", "textual-light"),
("nord", "nord"),
("gruvbox", "gruvbox"),
("catppuccin-mocha", "catppuccin-mocha"),
("dracula", "dracula"),
("tokyo-night", "tokyo-night"),
("monokai", "monokai"),
("flexoki", "flexoki"),
("catppuccin-latte", "catppuccin-latte"),
("solarized-light", "solarized-light"),
]
def compose(self):
yield Static("Theme Options", id="theme_title")
with Vertical() as column:
column.styles.width = "1fr"
column.styles.height = "auto"
for label, btn_id in self.AVAILABLE_THEMES:
yield Button(label, id=f"set_theme_{btn_id}", compact=True)
def on_button_pressed(self, event: Button.Pressed) -> None:
button_id = event.button.id
if button_id and button_id.startswith("set_theme_"):
theme_name = button_id.replace("set_theme_", "")
self.post_message(self.ThemeSelected(theme_name))