492 lines
18 KiB
Python
492 lines
18 KiB
Python
"""
|
|
Policy Selector Widget Module
|
|
|
|
Provides a Textual widget for selecting target policies for bulk agent operations.
|
|
Allows users to browse available policies and select one as the destination for
|
|
moving agents. Automatically excludes parent/logical policies.
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
from typing import Optional
|
|
|
|
import pandas as pd
|
|
from textual.containers import Horizontal, Vertical
|
|
from textual.message import Message
|
|
from textual.widget import Widget
|
|
from textual.widgets import Button, DataTable, Footer, Header, Static, TextArea
|
|
|
|
from models.policy import Policy
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class PolicySelector(Widget):
|
|
"""
|
|
A Textual widget for selecting a target policy for agent operations.
|
|
|
|
This widget displays available policies in a table and allows users to select
|
|
one policy as the destination for bulk agent movements. It automatically excludes:
|
|
- Parent/logical policies (where parent == "global-policy-settings")
|
|
- Specified policy IDs (e.g., the current policy)
|
|
|
|
Features:
|
|
- Wildcard filtering (* and ?)
|
|
- Interactive table for policy browsing
|
|
- Explicit confirm button for selection
|
|
- Cancel/back button to dismiss
|
|
|
|
Attributes:
|
|
policies (list[Policy]): List of available Policy objects to display.
|
|
excluded_policy_ids (set[str]): Set of policy IDs to exclude from selection.
|
|
selected_policy (Optional[Policy]): The currently selected policy (if any).
|
|
|
|
Automatically Filtered Out:
|
|
- Policies with parent == "global-policy-settings" (parent policies for organization)
|
|
- Any policies in excluded_policy_ids set
|
|
|
|
Example:
|
|
```python
|
|
policies = [policy1, policy2, policy3]
|
|
widget = PolicySelector(policies, excluded_policy_ids={current_policy.groupid})
|
|
```
|
|
"""
|
|
|
|
class PolicySelected(Message):
|
|
"""
|
|
Message posted when a policy is selected.
|
|
|
|
Attributes:
|
|
policy (Policy): The selected policy object.
|
|
"""
|
|
|
|
def __init__(self, policy: Policy):
|
|
super().__init__()
|
|
self.policy = policy
|
|
|
|
def __init__(self, policies: list):
|
|
"""
|
|
Initialize the PolicySelector widget.
|
|
|
|
Args:
|
|
policies (list): List of Policy objects or DataFrame rows to display.
|
|
Can be a list of Policy objects or a pandas DataFrame of policy data.
|
|
"""
|
|
super().__init__()
|
|
self.policies = policies
|
|
self.selected_policy: Optional[Policy] = None
|
|
self._filtered_policies = []
|
|
self._displayed_policies = [] # Track what's currently shown in the table
|
|
self._filter_text = ""
|
|
|
|
def compose(self):
|
|
"""
|
|
Build the UI layout for the PolicySelector widget.
|
|
|
|
The layout includes:
|
|
- Title indicating policy selection
|
|
- Search/filter text area with wildcard support
|
|
- Filter help text showing wildcard options
|
|
- Apply Filter button
|
|
- Clear Filter button
|
|
- Confirm Selection button
|
|
- Policy table displaying available policies
|
|
- Back buttons for navigation
|
|
"""
|
|
yield Header(show_clock=True, icon="⚙")
|
|
title_text = Static(
|
|
"🎯 Select Target Policy",
|
|
id="policy_selector_title",
|
|
)
|
|
title_text.styles.margin = (0, 0, 1, 0)
|
|
yield title_text
|
|
|
|
with Horizontal() as main_layout:
|
|
main_layout.styles.height = "auto"
|
|
|
|
# Left side - Filter and controls
|
|
with Vertical() as left_side:
|
|
left_side.styles.width = "1fr"
|
|
left_side.styles.height = "auto"
|
|
left_side.styles.margin = (0, 1, 0, 1)
|
|
|
|
filter_label = Static("Filter Policies:")
|
|
filter_label.styles.margin = (0, 0, 0, 0)
|
|
yield filter_label
|
|
|
|
filter_input = TextArea(
|
|
id="policy_filter",
|
|
text="",
|
|
)
|
|
filter_input.styles.height = 3
|
|
filter_input.styles.margin = (0, 0, 1, 0)
|
|
yield filter_input
|
|
|
|
filter_help = Static("(Use * and ? for wildcards)", id="filter_help")
|
|
filter_help.styles.margin = (0, 0, 1, 0)
|
|
yield filter_help
|
|
|
|
apply_button = Button("✓ Apply Filter", id="filter_button")
|
|
apply_button.styles.width = "100%"
|
|
apply_button.styles.margin = (0, 0, 1, 0)
|
|
yield apply_button
|
|
|
|
clear_button = Button("Clear Filter", id="clear_filter_button")
|
|
clear_button.styles.width = "100%"
|
|
clear_button.styles.margin = (0, 0, 1, 0)
|
|
yield clear_button
|
|
|
|
confirm_button = Button("✅ Confirm Selection", id="confirm_button")
|
|
confirm_button.styles.width = "100%"
|
|
confirm_button.styles.margin = (1, 0, 1, 0)
|
|
yield confirm_button
|
|
|
|
selected_label = Static("", id="selected_policy_label")
|
|
selected_label.styles.margin = (2, 0, 1, 0)
|
|
yield selected_label
|
|
|
|
cancel_button = Button("← Back", id="back_button")
|
|
cancel_button.styles.width = "100%"
|
|
cancel_button.styles.margin = (1, 0, 1, 0)
|
|
yield cancel_button
|
|
|
|
# Right side - Policy table
|
|
with Vertical() as right_side:
|
|
right_side.styles.width = "2fr"
|
|
right_side.styles.height = "auto"
|
|
|
|
table_label = Static("Available Policies:")
|
|
table_label.styles.margin = (0, 0, 0, 0)
|
|
yield table_label
|
|
|
|
policy_table = DataTable(id="policy_table", cursor_type="row")
|
|
policy_table.styles.height = "1fr"
|
|
policy_table.styles.margin = (1, 0, 1, 0)
|
|
yield policy_table
|
|
|
|
yield Footer()
|
|
|
|
def on_mount(self) -> None:
|
|
"""
|
|
Initialize the policy table when the widget is mounted.
|
|
|
|
Populates the table with column (Policy Name) and rows for each
|
|
available policy (excluding those in excluded_policy_ids and parent policies).
|
|
Sets up event handlers for table row selection.
|
|
|
|
Filters out:
|
|
- Parent policies (where parent == "global-policy-settings")
|
|
"""
|
|
table = self.query_one("#policy_table", DataTable)
|
|
|
|
# Configure table for row selection
|
|
table.cursor_type = "row"
|
|
table.zebra_stripes = True
|
|
|
|
# Only add Policy Name column
|
|
table.add_columns("Policy Name")
|
|
|
|
# Filter out excluded policies and convert to list if DataFrame
|
|
if isinstance(self.policies, pd.DataFrame):
|
|
policies_list = self.policies.to_dict("records")
|
|
else:
|
|
policies_list = self.policies
|
|
policies_list = sorted(policies_list)
|
|
|
|
self._filtered_policies = []
|
|
self._displayed_policies = [] # Initialize displayed list
|
|
|
|
for policy_data in policies_list:
|
|
# Handle both Policy objects and dict/DataFrame rows
|
|
if isinstance(policy_data, Policy):
|
|
policy_id = policy_data.groupid
|
|
policy_name = policy_data.name
|
|
parent = policy_data.parent
|
|
else:
|
|
policy_id = policy_data.get("groupid", "Unknown")
|
|
policy_name = policy_data.get("name", "Unknown")
|
|
parent = policy_data.get("parent", None)
|
|
|
|
# Skip parent policies (logical policies that shouldn't have devices)
|
|
if parent == "global-policy-settings":
|
|
logger.debug(f"Skipping parent policy: {policy_name}")
|
|
continue
|
|
|
|
self._filtered_policies.append(policy_data)
|
|
self._displayed_policies.append(policy_data) # Add to displayed list
|
|
|
|
table.add_row(
|
|
policy_name,
|
|
key=policy_id,
|
|
)
|
|
|
|
def on_button_pressed(self, event: Button.Pressed):
|
|
"""
|
|
Handle button press events from the widget.
|
|
|
|
Routes to:
|
|
- back_button (Cancel): Pop screen without selecting
|
|
- filter_button (Apply Filter): Filter policies with wildcard support
|
|
- clear_filter_button: Clear filter and show all policies
|
|
- confirm_button: Confirm selection and post message
|
|
|
|
Args:
|
|
event (Button.Pressed): The button press event.
|
|
"""
|
|
btn_id = event.button.id
|
|
|
|
if btn_id == "back_button":
|
|
while len(self.app.screen_stack) > 2:
|
|
self.app.pop_screen()
|
|
event.stop()
|
|
|
|
elif btn_id == "filter_button":
|
|
self._apply_filter()
|
|
event.stop()
|
|
|
|
elif btn_id == "clear_filter_button":
|
|
self._clear_filter()
|
|
event.stop()
|
|
|
|
elif btn_id == "confirm_button":
|
|
self._confirm_selection()
|
|
event.stop()
|
|
|
|
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
"""
|
|
Handle row selection in the policy table.
|
|
|
|
Updates the selected_policy and displays the selection in the UI.
|
|
|
|
Args:
|
|
event: DataTable.RowSelected event containing the selected row data.
|
|
"""
|
|
try:
|
|
# Get the row key from the event
|
|
row_key = event.row_key
|
|
if row_key is None:
|
|
return
|
|
|
|
# Find the policy with matching groupid
|
|
for policy_data in self._displayed_policies:
|
|
if isinstance(policy_data, Policy):
|
|
if policy_data.groupid == row_key.value:
|
|
self.selected_policy = policy_data
|
|
break
|
|
else:
|
|
if policy_data.get("groupid") == row_key.value:
|
|
self.selected_policy = Policy(
|
|
groupid=policy_data.get("groupid"),
|
|
hidden=policy_data.get("hidden", False),
|
|
name=policy_data.get("name"),
|
|
parent=policy_data.get("parent"),
|
|
)
|
|
break
|
|
|
|
if self.selected_policy:
|
|
# Update selection display
|
|
label = self.query_one("#selected_policy_label", Static)
|
|
label.update(f"✓ Selected: {self.selected_policy.name}")
|
|
|
|
# Log for debugging
|
|
logger.debug(
|
|
f"Selected policy: {self.selected_policy.name} (ID: {self.selected_policy.groupid})"
|
|
)
|
|
self.app.notify(
|
|
f"Selected: {self.selected_policy.name}",
|
|
severity="information",
|
|
timeout=1,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error handling row selection: {e}")
|
|
self.app.notify(f"Selection error: {str(e)}", severity="error")
|
|
|
|
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
"""
|
|
Handle row highlighting (cursor movement) in the table.
|
|
|
|
This provides immediate visual feedback when navigating rows.
|
|
"""
|
|
try:
|
|
# Get the row key from the event
|
|
row_key = event.row_key
|
|
if row_key is None:
|
|
return
|
|
|
|
# Find the highlighted policy
|
|
highlighted_name = None
|
|
for policy_data in self._displayed_policies:
|
|
if isinstance(policy_data, Policy):
|
|
if policy_data.groupid == row_key.value:
|
|
highlighted_name = policy_data.name
|
|
break
|
|
else:
|
|
if policy_data.get("groupid") == row_key.value:
|
|
highlighted_name = policy_data.get("name")
|
|
break
|
|
|
|
if highlighted_name:
|
|
label = self.query_one("#selected_policy_label", Static)
|
|
label.update(f"→ Highlighting: {highlighted_name}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error handling row highlight: {e}")
|
|
|
|
def _apply_filter(self) -> None:
|
|
"""
|
|
Apply filter text to policy list with wildcard support.
|
|
|
|
Supports wildcards:
|
|
- * matches any sequence of characters
|
|
- ? matches a single character
|
|
|
|
Examples:
|
|
- "policy*" matches "policy_prod", "policy_dev", etc.
|
|
- "policy?" matches "policy1", "policy2", etc.
|
|
- "*audit*" matches anything containing "audit"
|
|
- "*test*" matches "AT Testing", "test_policy", etc.
|
|
|
|
Filters policies by name or ID (case-insensitive) and refreshes the table display
|
|
with only matching policies. Only filters from already-filtered list
|
|
(which excludes parent policies and excluded IDs).
|
|
"""
|
|
try:
|
|
filter_input = self.query_one("#policy_filter", TextArea)
|
|
filter_text = filter_input.text.strip()
|
|
|
|
table = self.query_one("#policy_table", DataTable)
|
|
table.clear()
|
|
|
|
# Clear the displayed policies list
|
|
self._displayed_policies = []
|
|
|
|
# Compile wildcard pattern if filter text is provided
|
|
pattern = None
|
|
if filter_text:
|
|
# Escape special regex chars but preserve wildcards
|
|
pattern_text = re.escape(filter_text.lower())
|
|
pattern_text = pattern_text.replace(r"\*", ".*").replace(r"\?", ".")
|
|
# Use search() for partial matching
|
|
pattern = re.compile(pattern_text, re.IGNORECASE)
|
|
|
|
# Filter policies based on search text
|
|
for policy_data in self._filtered_policies:
|
|
# Handle both Policy objects and dict/DataFrame rows
|
|
if isinstance(policy_data, Policy):
|
|
policy_name = policy_data.name.lower()
|
|
policy_id = policy_data.groupid.lower()
|
|
display_name = policy_data.name
|
|
key_id = policy_data.groupid
|
|
else:
|
|
policy_name = str(policy_data.get("name", "")).lower()
|
|
policy_id = str(policy_data.get("groupid", "Unknown")).lower()
|
|
display_name = policy_data.get("name")
|
|
key_id = policy_data.get("groupid")
|
|
|
|
# Match against filter text with wildcard support
|
|
if pattern:
|
|
# Use search() for partial matching
|
|
matches = pattern.search(policy_name) or pattern.search(policy_id)
|
|
else:
|
|
matches = True
|
|
|
|
if matches:
|
|
# Add to displayed policies list
|
|
self._displayed_policies.append(policy_data)
|
|
|
|
# Add row to table
|
|
table.add_row(
|
|
display_name,
|
|
key=key_id,
|
|
)
|
|
|
|
displayed_count = len(self._displayed_policies)
|
|
status_text = f"📊 Showing {displayed_count} of {len(self._filtered_policies)} policies"
|
|
self.app.notify(status_text, severity="information", timeout=2)
|
|
|
|
# Clear selection when filter is applied
|
|
self.selected_policy = None
|
|
label = self.query_one("#selected_policy_label", Static)
|
|
label.update("")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error applying filter: {e}")
|
|
self.app.notify(f"❌ Filter error: {str(e)}", severity="error")
|
|
|
|
def _clear_filter(self) -> None:
|
|
"""
|
|
Clear the filter and display all available policies.
|
|
|
|
Resets the filter text and refreshes the table to show all policies
|
|
(already excluding parent policies and excluded IDs).
|
|
"""
|
|
try:
|
|
filter_input = self.query_one("#policy_filter", TextArea)
|
|
filter_input.text = ""
|
|
|
|
table = self.query_one("#policy_table", DataTable)
|
|
table.clear()
|
|
|
|
# Reset displayed policies to all filtered policies
|
|
self._displayed_policies = list(self._filtered_policies)
|
|
|
|
# Reload all policies
|
|
for policy_data in self._filtered_policies:
|
|
if isinstance(policy_data, Policy):
|
|
policy_id = policy_data.groupid
|
|
policy_name = policy_data.name
|
|
else:
|
|
policy_id = policy_data.get("groupid", "Unknown")
|
|
policy_name = policy_data.get("name", "Unknown")
|
|
|
|
# Add row with only policy name
|
|
table.add_row(
|
|
policy_name,
|
|
key=policy_id,
|
|
)
|
|
|
|
self.selected_policy = None
|
|
label = self.query_one("#selected_policy_label", Static)
|
|
label.update("")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error clearing filter: {e}")
|
|
|
|
def on_text_area_changed(self, event) -> None:
|
|
"""
|
|
Handle TextArea change events - specifically for Enter key in filter.
|
|
|
|
When the user types in the filter TextArea and the text ends with a newline,
|
|
treat it as pressing Enter and apply the filter.
|
|
"""
|
|
if event.text_area.id == "policy_filter":
|
|
# Check if the text ends with a newline (Enter was pressed)
|
|
if event.text_area.text.endswith("\n"):
|
|
# Remove the newline that was added
|
|
event.text_area.text = event.text_area.text.rstrip("\n")
|
|
# Apply the filter
|
|
self._apply_filter()
|
|
|
|
def _confirm_selection(self) -> None:
|
|
"""
|
|
Confirm the selected policy and post selection message.
|
|
|
|
Posts a PolicySelected message to the parent widget/screen with the
|
|
selected policy. If no policy is selected, displays an error notification.
|
|
"""
|
|
if self.selected_policy is None:
|
|
self.app.notify(
|
|
"⚠️ Please select a policy first by clicking on a row in the table",
|
|
severity="warning",
|
|
timeout=3,
|
|
)
|
|
return
|
|
|
|
# Log confirmation for debugging
|
|
logger.info(f"Confirming selection of policy: {self.selected_policy.name}")
|
|
self.app.notify(
|
|
f"✅ Confirmed: {self.selected_policy.name}", severity="success", timeout=2
|
|
)
|
|
self.post_message(self.PolicySelected(self.selected_policy))
|