200 lines
7.2 KiB
Python
200 lines
7.2 KiB
Python
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
|