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,369 @@
|
||||
# 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 logging
|
||||
from typing import List, Optional
|
||||
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.css.query import NoMatches
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import (
|
||||
Button,
|
||||
Footer,
|
||||
Header,
|
||||
Input,
|
||||
RadioButton,
|
||||
RadioSet,
|
||||
Static,
|
||||
TextArea,
|
||||
)
|
||||
|
||||
from models.agent import Agent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OTPGenerator(Widget):
|
||||
"""Widget for generating OTPs for selected devices."""
|
||||
|
||||
# Reactive properties to track form completion
|
||||
requestor_filled = reactive(False)
|
||||
reasoning_filled = reactive(False)
|
||||
duration_selected = reactive(True) # Default is selected
|
||||
otp_generated = reactive(False)
|
||||
|
||||
class OTPInfo(Message):
|
||||
def __init__(
|
||||
self,
|
||||
devices: Optional[List[Agent]],
|
||||
requestor: str,
|
||||
reasoning: str,
|
||||
duration: int,
|
||||
):
|
||||
super().__init__()
|
||||
self.devices = devices
|
||||
self.requestor = requestor
|
||||
self.reasoning = reasoning
|
||||
self.duration = duration
|
||||
|
||||
# Duration options in minutes
|
||||
DURATION_OPTIONS = [
|
||||
(15, "15 minutes"),
|
||||
(60, "1 hour"),
|
||||
(360, "6 hours"),
|
||||
(1440, "1 day"),
|
||||
(10080, "7 days"),
|
||||
]
|
||||
|
||||
def __init__(self, devices: List[Agent]):
|
||||
"""Initialize with a list of Agent objects."""
|
||||
super().__init__()
|
||||
self.devices = devices
|
||||
|
||||
def watch_requestor_filled(self, old_value: bool, new_value: bool) -> None:
|
||||
"""Update button state when requestor changes."""
|
||||
self._update_button_state()
|
||||
|
||||
def watch_reasoning_filled(self, old_value: bool, new_value: bool) -> None:
|
||||
"""Update button state when reasoning changes."""
|
||||
self._update_button_state()
|
||||
|
||||
def watch_otp_generated(self, old_value: bool, new_value: bool) -> None:
|
||||
"""Update button state when OTP is generated."""
|
||||
self._update_button_state()
|
||||
|
||||
def _update_button_state(self) -> None:
|
||||
"""Enable/disable the generate button based on form state."""
|
||||
try:
|
||||
button = self.query_one("#generate_button", Button)
|
||||
# Enable only if all fields filled and OTP not yet generated
|
||||
button.disabled = not (
|
||||
self.requestor_filled
|
||||
and self.reasoning_filled
|
||||
and not self.otp_generated
|
||||
)
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
def compose(self):
|
||||
yield Header(show_clock=True, icon="⚙")
|
||||
title_text = Static(
|
||||
f"🎫 Generate One Time Passes for {len(self.devices)} device(s)",
|
||||
id="otpgen_title",
|
||||
)
|
||||
title_text.styles.margin = (0, 0, 1, 0)
|
||||
yield title_text
|
||||
|
||||
with Horizontal() as main_layout:
|
||||
main_layout.styles.height = "auto"
|
||||
|
||||
# Left side - Inputs and controls
|
||||
with Vertical() as left_side:
|
||||
left_side.styles.width = "1fr"
|
||||
left_side.styles.height = "auto"
|
||||
|
||||
# Requestor input
|
||||
requestor_label = Static("Who is requesting OTP?")
|
||||
requestor_label.styles.margin = (0, 0, 0, 0)
|
||||
yield requestor_label
|
||||
|
||||
requestor_box = Input(
|
||||
placeholder="Enter requestor name", id="requestor_input"
|
||||
)
|
||||
requestor_box.styles.margin = (0, 0, 1, 0)
|
||||
yield requestor_box
|
||||
|
||||
# Reasoning input
|
||||
reasoning_label = Static("What work are they doing?")
|
||||
reasoning_label.styles.margin = (0, 0, 0, 0)
|
||||
yield reasoning_label
|
||||
|
||||
reasoning_box = Input(
|
||||
placeholder="Enter reason for OTP", id="reasoning_input"
|
||||
)
|
||||
reasoning_box.styles.margin = (0, 0, 1, 0)
|
||||
yield reasoning_box
|
||||
|
||||
# Duration selection
|
||||
duration_label = Static("Duration:")
|
||||
duration_label.styles.margin = (0, 0, 0, 0)
|
||||
yield duration_label
|
||||
|
||||
with RadioSet(id="duration_radio") as radio_set:
|
||||
radio_set.styles.margin = (0, 0, 1, 0)
|
||||
for minutes, label in self.DURATION_OPTIONS:
|
||||
radio = RadioButton(label, id=f"duration_{minutes}")
|
||||
if minutes == 360: # Default to 6 hours
|
||||
radio.value = True
|
||||
yield radio
|
||||
|
||||
# Buttons in a horizontal layout
|
||||
with Horizontal() as button_row:
|
||||
button_row.styles.height = "auto"
|
||||
button_row.styles.margin = (1, 0, 0, 0)
|
||||
|
||||
generate_button = Button(
|
||||
"Generate OTP", id="generate_button", variant="primary"
|
||||
)
|
||||
generate_button.styles.width = "100%"
|
||||
yield generate_button
|
||||
|
||||
# Right side - Show device list initially, then output after generation
|
||||
with Vertical() as right_side:
|
||||
right_side.styles.width = "2fr"
|
||||
right_side.styles.height = "100%"
|
||||
|
||||
output_label = Static(
|
||||
f"Selected Devices ({len(self.devices)}):", id="output_label"
|
||||
)
|
||||
output_label.styles.margin = (0, 0, 0, 0)
|
||||
yield output_label
|
||||
|
||||
# Container for either device list or output
|
||||
with Vertical(id="output_container") as output_container:
|
||||
output_container.styles.height = "1fr"
|
||||
output_container.styles.margin = (1, 0, 0, 0)
|
||||
output_container.styles.overflow_y = "auto"
|
||||
output_container.styles.border = ("round", "green")
|
||||
|
||||
# Show device list initially
|
||||
device_list_text = "\n".join(
|
||||
f"{device.hostname}" for device in self.devices
|
||||
)
|
||||
device_display = Static(device_list_text, id="device_display")
|
||||
yield device_display
|
||||
|
||||
# Copy to clipboard button (hidden initially)
|
||||
copy_button = Button("📋 Copy to Clipboard", id="copy_clipboard_button")
|
||||
copy_button.styles.margin = (1, 0, 0, 0)
|
||||
copy_button.styles.display = "none"
|
||||
yield copy_button
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Set initial button state."""
|
||||
self._update_button_state()
|
||||
|
||||
def on_input_changed(self, event: Input.Changed) -> None:
|
||||
"""Handle input field changes."""
|
||||
input_id = event.input.id
|
||||
|
||||
if input_id == "requestor_input":
|
||||
self.requestor_filled = bool(event.value.strip())
|
||||
elif input_id == "reasoning_input":
|
||||
self.reasoning_filled = bool(event.value.strip())
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed):
|
||||
btn_id = event.button.id
|
||||
|
||||
if btn_id == "copy_clipboard_button":
|
||||
try:
|
||||
output_area = self.query_one("#otp_output", TextArea)
|
||||
text_to_copy = output_area.text
|
||||
|
||||
import pyperclip
|
||||
|
||||
pyperclip.copy(text_to_copy)
|
||||
self.app.notify(
|
||||
"✓ Copied to clipboard!", severity="information", timeout=2
|
||||
)
|
||||
except ImportError:
|
||||
self.app.notify(
|
||||
"⚠️ pyperclip not installed. Run: pip install pyperclip",
|
||||
severity="warning",
|
||||
)
|
||||
except Exception as e:
|
||||
self.app.notify(f"❌ Failed to copy: {str(e)}", severity="error")
|
||||
event.stop()
|
||||
|
||||
elif btn_id == "generate_button":
|
||||
try:
|
||||
requestor = self.query_one("#requestor_input", Input).value.strip()
|
||||
reasoning = self.query_one("#reasoning_input", Input).value.strip()
|
||||
|
||||
radio_set = self.query_one("#duration_radio", RadioSet)
|
||||
selected_button_id = (
|
||||
radio_set.pressed_button.id if radio_set.pressed_button else None
|
||||
)
|
||||
|
||||
if not selected_button_id:
|
||||
self._show_error("Please select a duration")
|
||||
return
|
||||
|
||||
duration = int(selected_button_id.replace("duration_", ""))
|
||||
|
||||
if not requestor or not reasoning:
|
||||
self._show_error("Please fill in all fields")
|
||||
return
|
||||
|
||||
self.otp_generated = True
|
||||
|
||||
# Access API from the app - this is the key change!
|
||||
api = self.app.api # type: ignore
|
||||
|
||||
output_lines = [
|
||||
"Requested OTP Codes:",
|
||||
"=" * 25,
|
||||
]
|
||||
|
||||
otp_dict = {}
|
||||
for device in self.devices:
|
||||
try:
|
||||
otp_code = api.otp_generate(device.agentid, duration, reasoning)
|
||||
otp_dict[device.hostname] = otp_code
|
||||
logger.debug(f"Generated OTP for {device.hostname}: {otp_code}")
|
||||
except Exception as e:
|
||||
otp_dict[device.hostname] = f"ERROR: {str(e)}"
|
||||
logger.error(
|
||||
f"Failed to generate OTP for {device.hostname}: {e}"
|
||||
)
|
||||
|
||||
for hostname, otp_code in otp_dict.items():
|
||||
output_lines.append(f"{hostname} | {otp_code}")
|
||||
|
||||
output_lines.append("=" * 25)
|
||||
result_text = "\n".join(output_lines)
|
||||
self._show_result(result_text)
|
||||
|
||||
# Post message with the OTP info
|
||||
self.post_message(
|
||||
self.OTPInfo(self.devices, requestor, reasoning, duration)
|
||||
)
|
||||
event.stop()
|
||||
|
||||
except NoMatches:
|
||||
self._show_error("UI elements not found")
|
||||
except Exception as e:
|
||||
self._show_error(f"Error: {str(e)}")
|
||||
logger.exception("Error generating OTP")
|
||||
|
||||
def _show_error(self, message: str):
|
||||
"""Display error message in output area."""
|
||||
try:
|
||||
container = self.query_one("#output_container", Vertical)
|
||||
try:
|
||||
device_display = self.query_one("#device_display", Static)
|
||||
device_display.remove()
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
try:
|
||||
output_area = self.query_one("#otp_output", TextArea)
|
||||
except NoMatches:
|
||||
output_area = TextArea(id="otp_output", read_only=True)
|
||||
container.mount(output_area)
|
||||
|
||||
output_area.text = f"❌ ERROR: {message}"
|
||||
except Exception as e:
|
||||
logger.debug(f"Error showing error message: {e}")
|
||||
|
||||
def _show_result(self, message: str):
|
||||
"""Display result message in output area."""
|
||||
try:
|
||||
container = self.query_one("#output_container", Vertical)
|
||||
try:
|
||||
device_display = self.query_one("#device_display", Static)
|
||||
device_display.remove()
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
try:
|
||||
output_area = self.query_one("#otp_output", TextArea)
|
||||
except NoMatches:
|
||||
output_area = TextArea(id="otp_output", read_only=True)
|
||||
container.mount(output_area)
|
||||
|
||||
output_area.text = message
|
||||
output_label = self.query_one("#output_label", Static)
|
||||
output_label.update("Generated OTP Details:")
|
||||
copy_button = self.query_one("#copy_clipboard_button", Button)
|
||||
copy_button.styles.display = "block"
|
||||
except Exception as e:
|
||||
logger.debug(f"Error showing result: {e}")
|
||||
|
||||
def display_otp_result(self, result_text: str):
|
||||
"""Display OTP generation result in the output area."""
|
||||
try:
|
||||
container = self.query_one("#output_container", Vertical)
|
||||
try:
|
||||
device_display = self.query_one("#device_display", Static)
|
||||
device_display.remove()
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
try:
|
||||
output_area = self.query_one("#otp_output", TextArea)
|
||||
except NoMatches:
|
||||
output_area = TextArea(id="otp_output", read_only=True)
|
||||
container.mount(output_area)
|
||||
|
||||
output_area.text = result_text
|
||||
except Exception as e:
|
||||
logger.debug(f"Error displaying OTP result: {e}")
|
||||
|
||||
def clear_form(self):
|
||||
"""Clear all input fields and reset state."""
|
||||
try:
|
||||
self.query_one("#requestor_input", Input).value = ""
|
||||
self.query_one("#reasoning_input", Input).value = ""
|
||||
self.query_one("#otp_output", TextArea).text = ""
|
||||
self.otp_generated = False
|
||||
self.requestor_filled = False
|
||||
self.reasoning_filled = False
|
||||
self._update_button_state()
|
||||
except NoMatches:
|
||||
pass
|
||||
@@ -0,0 +1,740 @@
|
||||
# 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/>.
|
||||
|
||||
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
import pandas as pd
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.css.query import NoMatches
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Button, DataTable, Footer, Header, Static, TextArea
|
||||
|
||||
from models.agent import Agent
|
||||
from TUI.Screens.otpworkflowscreen import OTPWorkflowScreen
|
||||
from TUI.Screens.policyselectorscreen import PolicySelectorScreen
|
||||
from TUI.Widgets.OTP_generate import OTPGenerator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentMoveOperations(Widget):
|
||||
"""
|
||||
A Textual widget for managing bulk agent operations and policy migrations.
|
||||
|
||||
This widget provides a comprehensive UI for performing operations on multiple
|
||||
selected agents. It displays the list of target agents and provides buttons to
|
||||
trigger various bulk operations like toggling policy modes or enabling local approval.
|
||||
|
||||
The widget manages its own state through reactive properties and provides real-time
|
||||
feedback on operation progress and results. Operations are executed sequentially
|
||||
per agent with error handling that tracks both successful and failed operations.
|
||||
|
||||
Attributes:
|
||||
operation_in_progress (reactive[bool]): Tracks whether an operation is currently
|
||||
executing. Used to disable buttons during execution.
|
||||
selected_operation (reactive[str]): Tracks which operation type is currently
|
||||
selected or in progress (e.g., "local_approval", "toggle_enforcement").
|
||||
|
||||
Example:
|
||||
```python
|
||||
agents = [agent1, agent2, agent3]
|
||||
widget = AgentMoveOperations(agents)
|
||||
```
|
||||
"""
|
||||
|
||||
# Reactive property to track if an operation is in progress
|
||||
operation_in_progress = reactive(False)
|
||||
# Tracks the currently selected operation type
|
||||
selected_operation = reactive("")
|
||||
|
||||
class OperationComplete(Message):
|
||||
"""
|
||||
Message posted when a bulk operation completes.
|
||||
|
||||
This message is broadcast to parent widgets/screens to notify them of
|
||||
operation completion along with detailed results. It contains the list
|
||||
of agents that were processed and the outcome for each.
|
||||
|
||||
Attributes:
|
||||
operation (str): Name of the operation that completed (e.g., "Local Approval Mode").
|
||||
agents (List[Agent]): List of all agents that were targeted by the operation.
|
||||
successful (List[tuple]): List of (Agent, result_data) tuples for successfully
|
||||
processed agents. Result data varies by operation type.
|
||||
unsuccessful (List[tuple]): List of (Agent, error_message) tuples for agents
|
||||
where the operation failed. Error message is a string explaining the failure.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
operation: str,
|
||||
agents: List[Agent],
|
||||
successful: List[tuple],
|
||||
unsuccessful: List[tuple],
|
||||
):
|
||||
super().__init__()
|
||||
self.operation = operation
|
||||
self.agents = agents
|
||||
self.successful = successful # List of (agent, result) tuples
|
||||
self.unsuccessful = unsuccessful # List of (agent, error) tuples
|
||||
|
||||
def __init__(self, agents: List[Agent]):
|
||||
"""
|
||||
Initialize the AgentMoveOperations widget.
|
||||
|
||||
Args:
|
||||
agents (List[Agent]): List of Agent objects to perform operations on.
|
||||
These agents will be displayed in the widget's agent table.
|
||||
"""
|
||||
super().__init__()
|
||||
self.agents = agents
|
||||
|
||||
def watch_operation_in_progress(self, old_value: bool, new_value: bool) -> None:
|
||||
"""
|
||||
React to changes in the operation_in_progress reactive property.
|
||||
|
||||
This is called automatically by Textual when operation_in_progress changes.
|
||||
It updates the button states to reflect whether an operation is running.
|
||||
|
||||
Args:
|
||||
old_value (bool): Previous value of operation_in_progress.
|
||||
new_value (bool): New value of operation_in_progress.
|
||||
"""
|
||||
self._update_button_states()
|
||||
|
||||
def _update_button_states(self) -> None:
|
||||
"""
|
||||
Update the enabled/disabled state of operation buttons based on current status.
|
||||
|
||||
This method implements the following logic:
|
||||
- If an operation is in progress: disable all buttons
|
||||
- If an operation is selected: disable only that operation's button
|
||||
- If no operation is selected: enable all buttons
|
||||
|
||||
The state transitions prevent users from starting multiple operations
|
||||
simultaneously and provide visual feedback on which operation is active.
|
||||
|
||||
Handles NoMatches exceptions gracefully in case buttons are not yet rendered.
|
||||
"""
|
||||
try:
|
||||
export_csv_btn = self.query_one("#export_csv_btn", Button)
|
||||
local_approval_btn = self.query_one("#local_approval_btn", Button)
|
||||
toggle_enforcement_btn = self.query_one("#toggle_enforcement_btn", Button)
|
||||
other_policy_btn = self.query_one("#other_policy_btn", Button)
|
||||
otp_gen_btn = self.query_one("#otp_gen_btn", Button)
|
||||
|
||||
# If operation in progress, disable all
|
||||
if self.operation_in_progress:
|
||||
otp_gen_btn = True
|
||||
export_csv_btn.disabled = True
|
||||
local_approval_btn.disabled = True
|
||||
toggle_enforcement_btn.disabled = True
|
||||
other_policy_btn.disabled = True
|
||||
else:
|
||||
# If an operation was selected, disable
|
||||
if self.selected_operation:
|
||||
otp_gen_btn.disabled = self.selected_operation == "otp_gen"
|
||||
export_csv_btn.disabled = self.selected_operation == "export_csv"
|
||||
local_approval_btn.disabled = (
|
||||
self.selected_operation == "local_approval"
|
||||
)
|
||||
toggle_enforcement_btn.disabled = (
|
||||
self.selected_operation == "toggle_enforcement"
|
||||
)
|
||||
other_policy_btn.disabled = (
|
||||
self.selected_operation == "other_policy"
|
||||
)
|
||||
else:
|
||||
# Enable all buttons
|
||||
otp_gen_btn = False
|
||||
export_csv_btn = False
|
||||
local_approval_btn.disabled = False
|
||||
toggle_enforcement_btn.disabled = False
|
||||
other_policy_btn.disabled = False
|
||||
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
def _display_results(
|
||||
self, operation_name: str, successful: list, unsuccessful: list
|
||||
) -> None:
|
||||
"""
|
||||
Display operation results in the results text area.
|
||||
|
||||
Formats the results into a human-readable summary including:
|
||||
- Operation name and separator
|
||||
- List of successful operations with agent hostnames
|
||||
- List of failed operations with agent hostnames and error messages
|
||||
- Summary statistics (total successful/failed count)
|
||||
|
||||
The results are displayed in the results_text TextArea widget and the
|
||||
results container is made visible after being initially hidden.
|
||||
|
||||
Args:
|
||||
operation_name (str): Human-readable name of the operation (e.g., "Local Approval Mode").
|
||||
successful (list): List of (Agent, result_data) tuples for successful operations.
|
||||
unsuccessful (list): List of (Agent, error_message) tuples for failed operations.
|
||||
"""
|
||||
try:
|
||||
# Build results text
|
||||
results_lines = [
|
||||
f"Operation: {operation_name}",
|
||||
f"{'=' * 50}",
|
||||
"",
|
||||
f"✅ Successful ({len(successful)}):",
|
||||
]
|
||||
|
||||
if successful:
|
||||
for agent, result in successful:
|
||||
results_lines.append(f" ✅ {agent.hostname}")
|
||||
else:
|
||||
results_lines.append(" (none)")
|
||||
|
||||
results_lines.append("")
|
||||
results_lines.append(f"❌ Failed ({len(unsuccessful)}):")
|
||||
|
||||
if unsuccessful:
|
||||
for agent, error in unsuccessful:
|
||||
results_lines.append(f" ❌ {agent.hostname}: {error}")
|
||||
else:
|
||||
results_lines.append(" (none)")
|
||||
|
||||
results_lines.append("")
|
||||
results_lines.append(f"{'=' * 50}")
|
||||
results_lines.append(
|
||||
f"Total: {len(successful)} successful, {len(unsuccessful)} failed"
|
||||
)
|
||||
|
||||
results_text_widget = self.query_one("#results_text", TextArea)
|
||||
results_text_widget.text = "\n".join(results_lines)
|
||||
|
||||
# Show results container
|
||||
results_container = self.query_one("#results_container", Vertical)
|
||||
results_container.styles.display = "block"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error displaying results: {e}")
|
||||
|
||||
def compose(self):
|
||||
"""
|
||||
Build the UI layout for the AgentMoveOperations widget.
|
||||
|
||||
This method is called by Textual to create the widget's UI structure.
|
||||
It builds a two-column layout with:
|
||||
- Left side: Agent table showing selected agents and their current policies
|
||||
- Right side: Operation buttons and results display area
|
||||
- Bottom: Navigation buttons (Back)
|
||||
|
||||
The layout is responsive with:
|
||||
- Agent table: 2/3 width
|
||||
- Operations panel: 1/3 width
|
||||
- Results area: Initially hidden, shown after operation completion
|
||||
"""
|
||||
yield Header(show_clock=True, icon="⚙️")
|
||||
title_text = Static(
|
||||
f"🖥️ Agent Operations - {len(self.agents)} device(s) selected",
|
||||
id="move_ops_title",
|
||||
)
|
||||
title_text.styles.margin = (0, 0, 1, 0)
|
||||
yield title_text
|
||||
|
||||
with Horizontal() as main_layout:
|
||||
main_layout.styles.height = "auto"
|
||||
|
||||
# Left side - Agent list
|
||||
with Vertical() as left_side:
|
||||
left_side.styles.width = "3fr"
|
||||
left_side.styles.height = "auto"
|
||||
|
||||
agents_label = Static("Selected Agents:")
|
||||
agents_label.styles.margin = (0, 0, 0, 0)
|
||||
yield agents_label
|
||||
|
||||
# Create a DataTable to show agents with their current policies
|
||||
agent_table = DataTable(id="agent_table")
|
||||
agent_table.styles.height = "1fr"
|
||||
agent_table.styles.margin = (1, 0, 1, 0)
|
||||
yield agent_table
|
||||
|
||||
# Right side - Operation buttons
|
||||
with Vertical() as right_side:
|
||||
right_side.styles.width = "2fr"
|
||||
right_side.styles.margin = (0, 1, 0, 1)
|
||||
right_side.styles.height = "auto"
|
||||
|
||||
operations_label = Static("Operations:")
|
||||
operations_label.styles.margin = (0, 0, 1, 0)
|
||||
yield operations_label
|
||||
|
||||
# Operation buttons
|
||||
export_csv_btn = Button("📄 Export CSV", id="export_csv_btn")
|
||||
export_csv_btn.styles.width = "100%"
|
||||
export_csv_btn.styles.margin = (0, 0, 1, 0)
|
||||
yield export_csv_btn
|
||||
|
||||
local_approval_btn = Button(
|
||||
"✔️ Local Approval Mode", id="local_approval_btn"
|
||||
)
|
||||
local_approval_btn.styles.width = "100%"
|
||||
local_approval_btn.styles.margin = (0, 0, 1, 0)
|
||||
yield local_approval_btn
|
||||
|
||||
otp_gen_btn = Button("🎫 Generate One Time Passes", id="otp_gen_btn")
|
||||
otp_gen_btn.styles.width = "100%"
|
||||
otp_gen_btn.styles.margin = (0, 0, 1, 0)
|
||||
yield otp_gen_btn
|
||||
|
||||
toggle_enforcement_btn = Button(
|
||||
"🔄 Toggle Audit/Enforcement", id="toggle_enforcement_btn"
|
||||
)
|
||||
toggle_enforcement_btn.styles.width = "100%"
|
||||
toggle_enforcement_btn.styles.margin = (0, 0, 1, 0)
|
||||
yield toggle_enforcement_btn
|
||||
|
||||
other_policy_btn = Button(
|
||||
"🔀 Move to Other Policy", id="other_policy_btn"
|
||||
)
|
||||
other_policy_btn.styles.width = "100%"
|
||||
other_policy_btn.styles.margin = (0, 0, 1, 0)
|
||||
yield other_policy_btn
|
||||
|
||||
# Status label
|
||||
status_label = Static("", id="status_label")
|
||||
status_label.styles.margin = (2, 0, 0, 0)
|
||||
yield status_label
|
||||
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""
|
||||
Initialize widget after it has been mounted on the screen.
|
||||
|
||||
This Textual lifecycle method is called after the widget is added to the DOM.
|
||||
It performs initialization tasks:
|
||||
- Populates the agent table with columns for Hostname, Policy, and Status
|
||||
- Adds rows to the table for each agent in self.agents
|
||||
- Initializes button states based on current widget state
|
||||
|
||||
The agent table displays agent.hostname, agent.groupname (or "Unknown"),
|
||||
and agent.status_text (or "Unknown") for each agent.
|
||||
"""
|
||||
table = self.query_one("#agent_table", DataTable)
|
||||
table.add_columns("Hostname", "Current Policy", "Status")
|
||||
|
||||
for agent in self.agents:
|
||||
table.add_row(
|
||||
agent.hostname,
|
||||
agent.groupname or "Unknown",
|
||||
agent.status_text or "Unknown",
|
||||
)
|
||||
|
||||
self._update_button_states()
|
||||
|
||||
def on_otp_generator_otp_info(self, message: OTPGenerator.OTPInfo) -> None:
|
||||
"""Handle OTP generation request - call the actual OTP generation function."""
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed):
|
||||
"""
|
||||
Handle button press events from the widget.
|
||||
|
||||
This Textual event handler routes button presses to appropriate actions:
|
||||
|
||||
- copy_results_btn: Copy results text to clipboard (requires pyperclip)
|
||||
- local_approval_btn: Start local approval operation
|
||||
- toggle_enforcement_btn: Start toggle audit/enforcement operation
|
||||
- other_policy_btn: Start move to other policy operation
|
||||
|
||||
After handling, event.stop() is called to prevent event propagation.
|
||||
|
||||
Args:
|
||||
event (Button.Pressed): The button press event containing the button reference.
|
||||
"""
|
||||
|
||||
btn_id = event.button.id
|
||||
|
||||
if btn_id == "copy_results_btn":
|
||||
try:
|
||||
results_text = self.query_one("#results_text", TextArea)
|
||||
import pyperclip
|
||||
|
||||
pyperclip.copy(results_text.text)
|
||||
self.app.notify(
|
||||
"📋✅ Results copied to clipboard!",
|
||||
severity="information",
|
||||
timeout=2,
|
||||
)
|
||||
except ImportError:
|
||||
self.app.notify(
|
||||
"❌ pyperclip not installed. Run: pip install pyperclip",
|
||||
severity="warning",
|
||||
)
|
||||
except Exception as e:
|
||||
self.app.notify(f"❌ Failed to copy: {str(e)}", severity="error")
|
||||
event.stop()
|
||||
elif btn_id == "export_csv_btn":
|
||||
self._start_export_csv_operation()
|
||||
event.stop()
|
||||
|
||||
elif btn_id == "local_approval_btn":
|
||||
self._start_local_approval_operation()
|
||||
event.stop()
|
||||
|
||||
elif btn_id == "toggle_enforcement_btn":
|
||||
self._start_toggle_enforcement_operation()
|
||||
event.stop()
|
||||
|
||||
elif btn_id == "other_policy_btn":
|
||||
self._start_other_policy_operation()
|
||||
event.stop()
|
||||
elif btn_id == "otp_gen_btn":
|
||||
self._start_OTP_gen_operation()
|
||||
event.stop()
|
||||
|
||||
def _start_local_approval_operation(self) -> None:
|
||||
"""
|
||||
Execute the local approval mode operation on all selected agents.
|
||||
|
||||
This operation performs the following steps for each agent:
|
||||
1. Generate a unique batch ID (current Unix timestamp)
|
||||
2. Create a local approval OTP with default duration of 360 minutes (6 hours)
|
||||
3. Move the agent to its related audit policy mode
|
||||
|
||||
The operation:
|
||||
- Sets operation state flags (selected_operation, operation_in_progress)
|
||||
- Updates the status label with progress indicator
|
||||
- Iterates through all agents, tracking successful and unsuccessful operations
|
||||
- Displays formatted results via _display_results()
|
||||
- Posts an OperationComplete message for parent widget handling
|
||||
|
||||
Agents that fail are logged and added to the unsuccessful list with error details.
|
||||
The operation completes and returns to a non-busy state regardless of individual
|
||||
agent success/failure.
|
||||
|
||||
Note: The OTP duration (360 minutes) is currently hardcoded and could be
|
||||
made configurable in future versions.
|
||||
"""
|
||||
self.selected_operation = "local_approval"
|
||||
self.operation_in_progress = True
|
||||
|
||||
status_label = self.query_one("#status_label", Static)
|
||||
status_label.update("✔️ Moving agents to local approval...")
|
||||
|
||||
# Get API from app
|
||||
api = self.app.api
|
||||
|
||||
successful = []
|
||||
unsuccessful = []
|
||||
|
||||
try:
|
||||
import time
|
||||
|
||||
from services.agenthandler import moveAgentToRelatedPolicy
|
||||
|
||||
# Generate batch ID
|
||||
batch = int(time.time())
|
||||
duration = 360 # Default 6 hours, could make this configurable
|
||||
|
||||
for agent in self.agents:
|
||||
try:
|
||||
# Add local approval OTP
|
||||
addLocalApproval(api, batch, duration, agent.agentid)
|
||||
# Move to audit mode
|
||||
result = moveAgentToRelatedPolicy(api, agent, "audit")
|
||||
successful.append((agent, result))
|
||||
logger.info(
|
||||
f"Successfully moved {agent.hostname} to local approval"
|
||||
)
|
||||
except Exception as e:
|
||||
unsuccessful.append((agent, str(e)))
|
||||
logger.error(
|
||||
f"Failed to move {agent.hostname} to local approval: {e}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during local approval operation: {e}")
|
||||
status_label.update(f"❌ Error: {str(e)}")
|
||||
self.operation_in_progress = False
|
||||
return
|
||||
|
||||
self.operation_in_progress = False
|
||||
status_label.update("✅ Operation complete!")
|
||||
|
||||
# Display results in the widget
|
||||
self._display_results("Local Approval Mode", successful, unsuccessful)
|
||||
|
||||
# Also post message for potential parent handling
|
||||
self.post_message(
|
||||
self.OperationComplete(
|
||||
"Local Approval Mode", self.agents, successful, unsuccessful
|
||||
)
|
||||
)
|
||||
|
||||
def _start_export_csv_operation(self) -> None:
|
||||
self.selected_operation = "export_csv"
|
||||
self.operation_in_progress = True
|
||||
successful = []
|
||||
status_label = self.query_one("#status_label", Static)
|
||||
status_label.update("Exporting CSV...")
|
||||
self.app.refresh_data()
|
||||
agents = self.agents
|
||||
policies = self.app.policies
|
||||
path = self.app.working_dir
|
||||
|
||||
try:
|
||||
# Enrich each agent with policies and status text
|
||||
for agent in agents:
|
||||
agent.enrich_with_policies(policies)
|
||||
|
||||
# Convert each Agent to a dictionary, including all fields
|
||||
data = []
|
||||
for agent in agents:
|
||||
row = asdict(agent)
|
||||
# Remove the class-level status_map from the row
|
||||
row.pop("status_map", None)
|
||||
data.append(row)
|
||||
|
||||
# Create DataFrame
|
||||
df = pd.DataFrame(data)
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
filename = f"agentsearch_{timestamp}.csv"
|
||||
file_path = os.path.join(str(path), filename)
|
||||
df.to_csv(file_path, index=False)
|
||||
successful.append(file_path)
|
||||
status_label.update(f"✅ Exported to {file_path}")
|
||||
except Exception:
|
||||
status_label.update("❌ Failed")
|
||||
|
||||
self.operation_in_progress = False
|
||||
|
||||
"""
|
||||
# Display results in the widget
|
||||
self._display_results("CSV Export", successful, unsuccessful)
|
||||
|
||||
# Also post message for potential parent handling
|
||||
self.post_message(
|
||||
self.OperationComplete(
|
||||
"CSV Export", self.agents, successful, unsuccessful
|
||||
)
|
||||
)
|
||||
"""
|
||||
|
||||
def _start_toggle_enforcement_operation(self) -> None:
|
||||
"""
|
||||
Toggle agents between enforcement and audit policy modes.
|
||||
|
||||
This operation intelligently switches each agent between enforcement and
|
||||
audit modes based on its current state:
|
||||
- If agent.groupid is in POLICY_MAP_ENF_AUD: currently enforcing , move to audit
|
||||
- Otherwise: currently in audit, move to enforcement
|
||||
|
||||
The operation:
|
||||
- Retrieves the enforcement/audit policy relationship map from protected config
|
||||
- Sets operation state flags and updates status label
|
||||
- Iterates through agents, determining current mode and toggling to opposite
|
||||
- Tracks successful toggles with the new mode in the result message
|
||||
- Logs both successes and failures
|
||||
- Displays results and posts OperationComplete message
|
||||
|
||||
The policy relationship map (POLICY_MAP_ENF_AUD) must be present in protected
|
||||
configuration and maps enforcement policy IDs to audit policy IDs. If the map
|
||||
is empty or not found, all agents are assumed to be in audit mode and will
|
||||
be moved to enforcement.
|
||||
|
||||
Returns to a non-busy state after completion regardless of individual results.
|
||||
"""
|
||||
self.selected_operation = "toggle_enforcement"
|
||||
self.operation_in_progress = True
|
||||
|
||||
status_label = self.query_one("#status_label", Static)
|
||||
status_label.update("🔄 Toggling enforcement mode...")
|
||||
|
||||
# Get API from app
|
||||
api = self.app.api
|
||||
|
||||
successful = []
|
||||
unsuccessful = []
|
||||
|
||||
try:
|
||||
from services.agenthandler import moveAgentToRelatedPolicy
|
||||
from utils.configmanager import get_system_json
|
||||
|
||||
policy_relationship_map = get_system_json("POLICY_MAP_ENF_AUD", "{}")
|
||||
|
||||
for agent in self.agents:
|
||||
try:
|
||||
# Determine current mode and toggle
|
||||
if agent.groupid in policy_relationship_map:
|
||||
# Currently in enforcement, move to audit
|
||||
result = moveAgentToRelatedPolicy(api, agent, "audit")
|
||||
mode = "audit"
|
||||
else:
|
||||
# Currently in audit, move to enforcement
|
||||
result = moveAgentToRelatedPolicy(api, agent, "enforcement")
|
||||
mode = "enforcement"
|
||||
|
||||
successful.append((agent, f"Moved to {mode}: {result}"))
|
||||
logger.info(f"Successfully toggled {agent.hostname} to {mode}")
|
||||
self.app.refresh_data()
|
||||
|
||||
except Exception as e:
|
||||
unsuccessful.append((agent, str(e)))
|
||||
logger.error(f"Failed to toggle {agent.hostname}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during toggle enforcement operation: {e}")
|
||||
status_label.update(f"❌ Error: {str(e)}")
|
||||
self.operation_in_progress = False
|
||||
return
|
||||
|
||||
self.operation_in_progress = False
|
||||
status_label.update("✅ Operation complete!")
|
||||
|
||||
# Display results in the widget
|
||||
self._display_results("Toggle Audit/Enforcement", successful, unsuccessful)
|
||||
|
||||
# Also post message for potential parent handling
|
||||
self.post_message(
|
||||
self.OperationComplete(
|
||||
"Toggle Audit/Enforcement", self.agents, successful, unsuccessful
|
||||
)
|
||||
)
|
||||
|
||||
def _start_other_policy_operation(self) -> None:
|
||||
"""
|
||||
Move agents to a user-selected policy (currently unimplemented).
|
||||
|
||||
This operation is intended to allow bulk movement of selected agents to any
|
||||
alternative policy via a policy selection dialog. Currently, this feature
|
||||
is not fully implemented.
|
||||
|
||||
Planned Implementation:
|
||||
1. Push a new policy selector screen (TUI modal/overlay)
|
||||
2. Allow user to choose target policy from available options
|
||||
3. Move all selected agents to the chosen policy
|
||||
4. Display results like other operations
|
||||
|
||||
Current Behavior:
|
||||
- Sets selected_operation to "other_policy"
|
||||
- Displays "Policy selection not yet implemented" status message
|
||||
- Clears selected_operation without performing any action
|
||||
|
||||
TODO: Complete implementation by:
|
||||
- Creating a policy selector screen component
|
||||
- Implementing the policy selection logic
|
||||
- Integrating with moveAgentToPolicy API call
|
||||
- Adding proper result tracking and display
|
||||
"""
|
||||
self.selected_operation = "other_policy"
|
||||
self.operation_in_progress = True
|
||||
|
||||
status_label = self.query_one("#status_label", Static)
|
||||
status_label.update("Loading available policies...")
|
||||
|
||||
try:
|
||||
# Fetch all policies from API
|
||||
api = self.app.api
|
||||
|
||||
# Fetch all available policies
|
||||
all_policies_df = api.policy_find_all()
|
||||
|
||||
if all_policies_df.empty:
|
||||
status_label.update("No policies available")
|
||||
self.operation_in_progress = False
|
||||
self.selected_operation = ""
|
||||
return
|
||||
|
||||
# Create and push the policy selector screen
|
||||
policy_selector_screen = PolicySelectorScreen(
|
||||
policies=all_policies_df,
|
||||
agent_move_operations=self,
|
||||
)
|
||||
self.app.push_screen(policy_selector_screen)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading policies: {e}")
|
||||
status_label.update(f"❌ Error: {str(e)}")
|
||||
self.operation_in_progress = False
|
||||
self.selected_operation = ""
|
||||
self.app.notify(f"Failed to load policies: {str(e)}", severity="error")
|
||||
|
||||
def _start_OTP_gen_operation(self) -> None:
|
||||
status_label = self.query_one("#status_label", Static)
|
||||
status_label.update("Generating OTP.")
|
||||
|
||||
self.app.push_screen(OTPWorkflowScreen(self.agents))
|
||||
|
||||
def _execute_move_to_policy(self, target_policy) -> None:
|
||||
"""
|
||||
Execute the actual move of agents to the selected policy.
|
||||
|
||||
Moves each agent sequentially to the target policy, tracking success/failure.
|
||||
Updates the status label and displays results upon completion.
|
||||
|
||||
Args:
|
||||
target_policy: The Policy object selected by the user.
|
||||
"""
|
||||
status_label = self.query_one("#status_label", Static)
|
||||
status_label.update(f"Moving agents to {target_policy.name}...")
|
||||
|
||||
api = self.app.api
|
||||
successful = []
|
||||
unsuccessful = []
|
||||
|
||||
try:
|
||||
for agent in self.agents:
|
||||
try:
|
||||
# Move agent to target policy
|
||||
api.agent_move(agent.agentid, target_policy.groupid)
|
||||
successful.append((agent, f"Moved to {target_policy.name}"))
|
||||
logger.info(
|
||||
f"Successfully moved {agent.hostname} to policy {target_policy.name}"
|
||||
)
|
||||
except Exception as e:
|
||||
unsuccessful.append((agent, str(e)))
|
||||
logger.error(
|
||||
f"Failed to move {agent.hostname} to policy {target_policy.name}: {e}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during move to policy operation: {e}")
|
||||
status_label.update(f"Error: {str(e)}")
|
||||
self.operation_in_progress = False
|
||||
return
|
||||
self.app.refresh_data()
|
||||
self.operation_in_progress = False
|
||||
status_label.update("Operation complete!")
|
||||
|
||||
# Display results in the widget
|
||||
self._display_results(
|
||||
f"Move to {target_policy.name}",
|
||||
successful,
|
||||
unsuccessful,
|
||||
)
|
||||
|
||||
# Also post message for potential parent handling
|
||||
self.post_message(
|
||||
self.OperationComplete(
|
||||
f"Move to {target_policy.name}",
|
||||
self.agents,
|
||||
successful,
|
||||
unsuccessful,
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,486 @@
|
||||
# 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 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, 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
|
||||
- Use escape key to go back
|
||||
|
||||
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
|
||||
- Use escape key to go back
|
||||
"""
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
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:
|
||||
- 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 == "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))
|
||||
@@ -0,0 +1,289 @@
|
||||
# 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/>.
|
||||
|
||||
from collections import defaultdict
|
||||
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, Switch, 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
|
||||
self.leaf_counts = defaultdict(int)
|
||||
self.match_type = "Count" # Default to sorting by count
|
||||
|
||||
def compose(self):
|
||||
# Create the switch and its label
|
||||
switch = Switch(value=False, id="match_switch")
|
||||
switch.styles.margin = (0, 0, 0, 0) # top, right, bottom, left
|
||||
switch.styles.padding = (0, 0, 0, 0)
|
||||
|
||||
switch_label = Static("Sort: Count", id="match_switch_label")
|
||||
switch_label.styles.margin = (1, 0, 0, 0)
|
||||
switch_label.styles.padding = (0, 0, 0, 0)
|
||||
|
||||
# Create the tree
|
||||
policy_tree = Tree("", id="policy_tree") # Label set in on_mount
|
||||
policy_tree.styles.width = "2fr"
|
||||
policy_tree.styles.height = "100%"
|
||||
|
||||
# Create the search box and details pane
|
||||
label = Static("Device Search:")
|
||||
search_box = Input(
|
||||
placeholder="Search policies or devices...", id="tree_search"
|
||||
)
|
||||
details_pane = Static("", id="details_pane")
|
||||
|
||||
# Layout the UI
|
||||
with Horizontal():
|
||||
yield policy_tree
|
||||
with Vertical() as right_pane:
|
||||
right_pane.styles.width = "3fr"
|
||||
# Use a Horizontal container for the switch and label
|
||||
with Horizontal() as switch_container:
|
||||
switch_container.styles.height = 3
|
||||
switch_container.styles.margin = (0, 0, 0, 1)
|
||||
switch_container.styles.padding = (0, 0, 0, 0)
|
||||
yield switch
|
||||
yield switch_label
|
||||
# Add the search box and details pane
|
||||
yield label
|
||||
yield search_box
|
||||
yield details_pane
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._precompute_leaf_counts()
|
||||
# Update root label with total leaf count
|
||||
total_leaves = sum(
|
||||
self.leaf_counts.get(policy.groupid, 0)
|
||||
for policy in self.policies
|
||||
if policy.parent == "global-policy-settings"
|
||||
)
|
||||
policy_tree = self.query_one("#policy_tree", Tree)
|
||||
policy_tree.root.set_label(f"Agents in Policies: ({total_leaves})")
|
||||
self._build_tree()
|
||||
# Expand the root node
|
||||
policy_tree.root.expand()
|
||||
|
||||
def _precompute_leaf_counts(self):
|
||||
"""Precompute leaf counts for each policy group."""
|
||||
device_counts = defaultdict(int)
|
||||
for device in self.devices:
|
||||
device_counts[device.groupid] += 1
|
||||
|
||||
child_map = defaultdict(list)
|
||||
for policy in self.policies:
|
||||
child_map[policy.parent].append(policy.groupid)
|
||||
|
||||
def count_leaves(groupid):
|
||||
count = device_counts[groupid]
|
||||
for child_id in child_map.get(groupid, []):
|
||||
count += count_leaves(child_id)
|
||||
self.leaf_counts[groupid] = count
|
||||
return count
|
||||
|
||||
for policy in self.policies:
|
||||
if policy.parent == "global-policy-settings":
|
||||
count_leaves(policy.groupid)
|
||||
|
||||
def _build_tree(self):
|
||||
policy_tree = self.query_one("#policy_tree", Tree)
|
||||
policy_tree.clear() # Clear existing nodes
|
||||
node_map = {}
|
||||
|
||||
# Sort top-level policies
|
||||
top_policies = [
|
||||
p for p in self.policies if p.parent == "global-policy-settings"
|
||||
]
|
||||
|
||||
# Sort by count (default) or alphabetically
|
||||
if getattr(self, "match_type", "Count") == "Count":
|
||||
top_policies.sort(
|
||||
key=lambda p: self.leaf_counts.get(p.groupid, 0), reverse=True
|
||||
)
|
||||
else: # Alphabetical
|
||||
top_policies.sort(key=lambda p: p.name.lower())
|
||||
|
||||
for policy in top_policies:
|
||||
label = f"{policy.name} ({self.leaf_counts.get(policy.groupid, 0)})"
|
||||
node = policy_tree.root.add(label=label, data=policy)
|
||||
node_map[policy.groupid] = node
|
||||
|
||||
# Sort and add child policies
|
||||
children_by_parent = defaultdict(list)
|
||||
for policy in self.policies:
|
||||
if policy.parent != "global-policy-settings":
|
||||
children_by_parent[policy.parent].append(policy)
|
||||
|
||||
for parent_id, children in children_by_parent.items():
|
||||
if getattr(self, "match_type", "Count") == "Count":
|
||||
children.sort(
|
||||
key=lambda p: self.leaf_counts.get(p.groupid, 0), reverse=True
|
||||
)
|
||||
else: # Alphabetical
|
||||
children.sort(key=lambda p: p.name.lower())
|
||||
|
||||
parent_node = node_map.get(parent_id)
|
||||
if parent_node:
|
||||
for policy in children:
|
||||
label = f"{policy.name} ({self.leaf_counts.get(policy.groupid, 0)})"
|
||||
node = parent_node.add(label=label, data=policy)
|
||||
node_map[policy.groupid] = node
|
||||
|
||||
# Add devices (leaf nodes) - always sort alphabetically
|
||||
devices_by_group = defaultdict(list)
|
||||
for device in self.devices:
|
||||
devices_by_group[device.groupid].append(device)
|
||||
|
||||
for group_id, devices in devices_by_group.items():
|
||||
devices.sort(key=lambda d: d.hostname.lower()) # Always sort alphabetically
|
||||
parent_node = node_map.get(group_id)
|
||||
if parent_node:
|
||||
for device in devices:
|
||||
parent_node.add(label=device.hostname, data=device)
|
||||
|
||||
def _collect_tree_nodes(self, node, all_nodes):
|
||||
all_nodes.append(node)
|
||||
for child in node.children:
|
||||
self._collect_tree_nodes(child, all_nodes)
|
||||
|
||||
def _remove_match_selector(self):
|
||||
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:
|
||||
node = message.node
|
||||
data = node.data
|
||||
details_pane = self.query_one("#details_pane", Static)
|
||||
|
||||
if self.last_highlighted_node is not None:
|
||||
original_label = str(self.last_highlighted_node.label).strip()
|
||||
if isinstance(self.last_highlighted_node.label, Text):
|
||||
original_label = self.last_highlighted_node.label.plain
|
||||
self.last_highlighted_node.set_label(original_label)
|
||||
|
||||
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
|
||||
|
||||
if data:
|
||||
details = "\n".join(
|
||||
f"{key}: {value}" for key, value in data.__dict__.items()
|
||||
)
|
||||
else:
|
||||
details = f"Selected: {node.label}"
|
||||
details_pane.update(details)
|
||||
|
||||
message.stop()
|
||||
|
||||
def on_switch_changed(self, event: Switch.Changed):
|
||||
self.match_type = "Alpha" if event.value else "Count"
|
||||
self.query_one("#match_switch_label", Static).update(
|
||||
f"Sort: {self.match_type.capitalize()}"
|
||||
)
|
||||
self._build_tree()
|
||||
|
||||
def on_input_submitted(self, message: Input.Submitted) -> None:
|
||||
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:
|
||||
data_dict = (
|
||||
node.data.__dict__ if hasattr(node.data, "__dict__") else node.data
|
||||
)
|
||||
for key, value in data_dict.items():
|
||||
if isinstance(value, str):
|
||||
label_to_node[value.lower()] = node
|
||||
|
||||
matches = sorted([label for label in label_to_node if query in label])
|
||||
|
||||
if matches:
|
||||
try:
|
||||
option_list = self.query_one("#match_selector", OptionList)
|
||||
option_list.clear_options()
|
||||
option_list.display = True
|
||||
except:
|
||||
option_list = OptionList(id="match_selector")
|
||||
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:
|
||||
self._remove_match_selector()
|
||||
details_pane.update("No matches found.")
|
||||
|
||||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
selected_id = event.option.id.replace("match_", "")
|
||||
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 = {str(node.label).lower(): node for node in all_nodes}
|
||||
match_node = label_to_node.get(selected_id.lower())
|
||||
|
||||
if match_node:
|
||||
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}")
|
||||
|
||||
try:
|
||||
option_list = self.query_one("#match_selector", OptionList)
|
||||
option_list.remove()
|
||||
except:
|
||||
pass
|
||||
@@ -0,0 +1,870 @@
|
||||
# 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 logging
|
||||
import os
|
||||
import os.path
|
||||
import re
|
||||
from typing import List
|
||||
|
||||
import dotenv
|
||||
import pandas as pd
|
||||
|
||||
from models.execution import ExecutionHistoryRecord
|
||||
from models.policy import Allowlist, Policy
|
||||
from services.API import AirlockAPIWrapper
|
||||
from utils.configmanager import get_system_list, get_system_value, load_env
|
||||
from utils.selector import Selector
|
||||
from utils.utils import (
|
||||
areYouSure,
|
||||
clear_screen,
|
||||
colorText,
|
||||
formatHTML,
|
||||
get_sanitized_input,
|
||||
locked,
|
||||
open_directory,
|
||||
print_x_wide,
|
||||
regulator,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
|
||||
def selectPolicies(api: AirlockAPIWrapper, allow_multiple=True) -> List[Policy]:
|
||||
|
||||
policies = [Policy(**row.to_dict()) for _, row in api.policy_find_all().iterrows()]
|
||||
logger.debug("Prompting for Policies")
|
||||
print(colorText("Please select policy/policies", "white"))
|
||||
selected = Selector.select_objects(policies, allow_multiple, prompt_each=True)
|
||||
|
||||
if selected is None:
|
||||
return []
|
||||
|
||||
# Normalize to always return a list
|
||||
logger.debug("Returning {selected.dict}")
|
||||
return selected if isinstance(selected, list) else [selected]
|
||||
|
||||
|
||||
def selectAllowlists(
|
||||
api: AirlockAPIWrapper, policy=all, allow_multiple=True
|
||||
) -> List[Allowlist]:
|
||||
if policy == "all":
|
||||
allowlists = [
|
||||
Allowlist(**row.to_dict()) for _, row in api.allowlist_find_all().iterrows()
|
||||
]
|
||||
else:
|
||||
allowlists = [
|
||||
Allowlist(**row.to_dict())
|
||||
for _, row in api.policy_list_allowlists(policy[0].groupid).iterrows()
|
||||
]
|
||||
logger.debug("Prompting for Allowlist(s)")
|
||||
print(colorText("Please select allowlist(s)", "white"))
|
||||
selected = Selector.select_objects(allowlists, allow_multiple, prompt_each=True)
|
||||
|
||||
if selected is None:
|
||||
return []
|
||||
|
||||
# Normalize to always return a list
|
||||
logger.debug(f"Returning {selected}")
|
||||
return selected if isinstance(selected, list) else [selected]
|
||||
|
||||
|
||||
def sortHashes(
|
||||
api: AirlockAPIWrapper, selected_policies: List[Policy], type=[1, 2, 6, 7]
|
||||
):
|
||||
working_dir = load_env("WORKING_DIR")
|
||||
history_days = Selector.select_value(
|
||||
prompt="Enter how many days of history to pull (1–150): ",
|
||||
value_type=int,
|
||||
valid_range=(1, 150),
|
||||
)
|
||||
|
||||
logger.debug(f"{history_days} day selected for history")
|
||||
|
||||
if history_days is None:
|
||||
logging.warning("No history range selected. Aborting.")
|
||||
return
|
||||
|
||||
policy_executions = ExecutionHistoryRecord.from_policies(
|
||||
api, selected_policies, type_=type, history_days=history_days
|
||||
)
|
||||
|
||||
logger.debug(f"Executions contains {policy_executions}")
|
||||
|
||||
enriched_executions = ExecutionHistoryRecord.enrich_with_hashes(
|
||||
api, policy_executions
|
||||
)
|
||||
categorized_executions = (
|
||||
ExecutionHistoryRecord.categorize_executions_by_hash_decision(
|
||||
enriched_executions
|
||||
)
|
||||
)
|
||||
approved, unapproved, needs_review, unknown = (
|
||||
ExecutionHistoryRecord.sort_by_hash_decision(categorized_executions)
|
||||
)
|
||||
|
||||
categories = {
|
||||
"needs_review": needs_review,
|
||||
"approved": approved,
|
||||
"unapproved": unapproved,
|
||||
"leftover": unknown,
|
||||
}
|
||||
|
||||
for label, records in categories.items():
|
||||
if not records:
|
||||
continue # Skip empty or falsy categories
|
||||
|
||||
csv_path = f"{working_dir}\\Needs_Review\\Review_First\\{selected_policies[0].name}_{label}_executions.csv"
|
||||
html_path = f"{working_dir}\\Needs_Review\\HTML\\{selected_policies[0].name}_{label}.html"
|
||||
|
||||
# Convert ExecutionHistoryRecord objects to dictionaries
|
||||
df = pd.DataFrame([r.__dict__ for r in records])
|
||||
|
||||
# Optional: flatten hash_obj if needed
|
||||
if not df.empty and "hash_obj" in df.columns:
|
||||
hash_df = df["hash_obj"].apply(lambda h: h.to_dict() if h else {})
|
||||
df = pd.concat([df.drop(columns=["hash_obj"]), hash_df], axis=1)
|
||||
|
||||
# Save to CSV
|
||||
df.to_csv(csv_path, index=False)
|
||||
logger.info(f"Saved {label} executions to {csv_path}")
|
||||
|
||||
# Generate HTML
|
||||
formatHTML(df, html_path)
|
||||
logger.info(f"Generated HTML report at {html_path}")
|
||||
|
||||
|
||||
def buildPathsandPublishers(selected_policies: List[Policy], split):
|
||||
working_dir = load_env("WORKING_DIR")
|
||||
df1 = pd.DataFrame()
|
||||
df2 = pd.DataFrame()
|
||||
all_approved_hashes = pd.DataFrame()
|
||||
path1 = (
|
||||
f"{working_dir}\\Approved\\{selected_policies[0].name}_approved_executions.csv"
|
||||
)
|
||||
path2 = f"{working_dir}\\Approved\\{selected_policies[0].name}_needs_review_executions.csv"
|
||||
path_exclusion_constant = get_system_value("PATH_EXCLUSION_CONST", cast_type=int)
|
||||
|
||||
if os.path.exists(path1):
|
||||
df1 = pd.read_csv(path1)
|
||||
else:
|
||||
logger.warning(f"File not found: {path1}")
|
||||
|
||||
if os.path.exists(path2):
|
||||
df2 = pd.read_csv(path2)
|
||||
else:
|
||||
logger.warning(f"File not found: {path2}")
|
||||
|
||||
if df1.empty and df2.empty:
|
||||
logger.warning("Both DataFrames are empty. Skipping sort.")
|
||||
all_approved_hashes = pd.DataFrame()
|
||||
logger.debug(all_approved_hashes.head)
|
||||
else:
|
||||
all_approved_hashes = pd.concat([df1, df2], ignore_index=True)
|
||||
if "filename" in all_approved_hashes.columns:
|
||||
all_approved_hashes = all_approved_hashes.sort_values(by="filename")
|
||||
else:
|
||||
logger.warning(
|
||||
"Warning: 'filename' column not found in concatenated DataFrame."
|
||||
)
|
||||
|
||||
if not all_approved_hashes.empty and path_exclusion_constant:
|
||||
|
||||
primary_path_exclusions = calculatePath(
|
||||
all_approved_hashes,
|
||||
path_exclusion_constant,
|
||||
split,
|
||||
)
|
||||
remaining_hashes = all_approved_hashes[
|
||||
~all_approved_hashes["sha256"].isin(primary_path_exclusions["sha256"])
|
||||
]
|
||||
secondary_path_exclusions = calculatePath(
|
||||
remaining_hashes, (path_exclusion_constant - 1), split
|
||||
)
|
||||
remaining_hashes = remaining_hashes[
|
||||
~remaining_hashes["sha256"].isin(secondary_path_exclusions["sha256"])
|
||||
]
|
||||
dataframes = {
|
||||
"all_approved_hashes": all_approved_hashes,
|
||||
"primary_Paths": primary_path_exclusions,
|
||||
"secondary_Paths": secondary_path_exclusions,
|
||||
"hashes_not_approvable_by_path": remaining_hashes,
|
||||
}
|
||||
logger.debug("Preparing to sort dataframes")
|
||||
for name, df in dataframes.items():
|
||||
logger.debug(f" DataFrame headers: {list(df.columns)}")
|
||||
if "hashes" in name:
|
||||
df.sort_values(by="filename", inplace=True)
|
||||
else:
|
||||
df.sort_values(by="longestcfp", inplace=True)
|
||||
|
||||
df.to_csv(
|
||||
f"{working_dir}\\Needs_Review\\Review_Second\\{selected_policies[0].name}_{name}.csv",
|
||||
index=False,
|
||||
)
|
||||
formatHTML(
|
||||
df,
|
||||
f"{working_dir}\\Needs_Review\\HTML\\{selected_policies[0].name}_{name}.html",
|
||||
)
|
||||
|
||||
if not all_approved_hashes.empty:
|
||||
# Drop all not signed, only keep unique values
|
||||
publist = all_approved_hashes[
|
||||
all_approved_hashes["publisher"] != "Not Signed"
|
||||
].drop_duplicates(subset=["publisher"])
|
||||
# Remove Bad publisher if somehow they made it this far
|
||||
pattern = regulator(get_system_list("BAD_PUBLISHERS"))
|
||||
publist = publist[~publist["publisher"].str.contains(pattern, na=False)]
|
||||
publist = publist[["publisher"]]
|
||||
publist.sort_values(by="publisher", inplace=True)
|
||||
publist.to_csv(
|
||||
f"{working_dir}\\Needs_Review\\Review_Second\\{selected_policies[0].name}_publishers.csv",
|
||||
index=False,
|
||||
)
|
||||
else:
|
||||
logger.debug("Approved Hashes list appears empty")
|
||||
|
||||
|
||||
def buildPreflights(selected_policies: List[Policy]):
|
||||
working_dir = load_env("WORKING_DIR")
|
||||
|
||||
df1 = pd.DataFrame()
|
||||
df2 = pd.DataFrame()
|
||||
approved_hashes = pd.DataFrame()
|
||||
approved_publishers = pd.DataFrame()
|
||||
|
||||
hash = f"{working_dir}\\Needs_Review\\Review_Second\\{selected_policies[0].name}_all_approved_hashes.csv"
|
||||
path1 = f"{working_dir}\\Approved\\{selected_policies[0].name}_primary_Paths.csv"
|
||||
path2 = f"{working_dir}\\Approved\\{selected_policies[0].name}_secondary_Paths.csv"
|
||||
publishers = f"{working_dir}\\Approved\\{selected_policies[0].name}_publishers.csv"
|
||||
|
||||
# Read in and combine the two path generations
|
||||
if os.path.exists(path1):
|
||||
df1 = pd.read_csv(path1)
|
||||
else:
|
||||
logger.warning(f"File not found: {path1}")
|
||||
|
||||
if os.path.exists(path2):
|
||||
df2 = pd.read_csv(path2)
|
||||
else:
|
||||
logger.warning(f"File not found: {path2}")
|
||||
|
||||
if df1.empty and df2.empty:
|
||||
logger.warning("Both DataFrames are empty. Skipping sort.")
|
||||
approved_paths = pd.DataFrame()
|
||||
else:
|
||||
approved_paths = pd.concat([df1, df2], ignore_index=True)
|
||||
|
||||
approved_paths = approved_paths.drop_duplicates(subset="longestcfp", keep="first")
|
||||
|
||||
# We create a list of hashes that are left over if we exclude the ones that are covered by the path exclusions.
|
||||
if os.path.exists(hash):
|
||||
hashes = pd.read_csv(hash)
|
||||
approved_hashes = hashes[~hashes["filename"].isin(approved_paths["longestcfp"])]
|
||||
|
||||
approved_hashes = approved_hashes.drop_duplicates(subset="sha256", keep="first")
|
||||
|
||||
else:
|
||||
logger.warning(f"File not found: {hash}")
|
||||
|
||||
if os.path.exists(publishers):
|
||||
approved_publishers = pd.read_csv(publishers)
|
||||
|
||||
else:
|
||||
logger.warning(f"File not found: {publishers}")
|
||||
|
||||
dataframes = {
|
||||
"approved_paths": approved_paths,
|
||||
"approved_hashes": approved_hashes,
|
||||
"approved_publishers": approved_publishers,
|
||||
}
|
||||
|
||||
for name, df in dataframes.items():
|
||||
logger.debug(f" DataFrame headers: {list(df.columns)}")
|
||||
if name == "approved_paths":
|
||||
df.sort_values(by="longestcfp", inplace=True)
|
||||
elif name == "approved_hashes":
|
||||
df.sort_values(by="filename", inplace=True)
|
||||
elif name == "approved_publishers":
|
||||
df.sort_values(by="publisher", inplace=True)
|
||||
|
||||
df.to_csv(
|
||||
f"{working_dir}\\Preflight\\{selected_policies[0].name}_{name}.csv",
|
||||
index=False,
|
||||
)
|
||||
formatHTML(
|
||||
df,
|
||||
f"{working_dir}\\Preflight\\HTML\\{selected_policies[0].name}_{name}.html",
|
||||
)
|
||||
|
||||
|
||||
def splitFilepathsGrouped(df, path_exclusion_constant, col="filename"):
|
||||
min_files_for_path = get_system_value("MIN_FILES_FOR_PATH", cast_type=int)
|
||||
|
||||
def clean_split(path):
|
||||
if not isinstance(path, (str, bytes, os.PathLike)):
|
||||
return []
|
||||
parts = str(os.path.normpath(path)).split(os.sep)
|
||||
parts = [p for p in parts if p] # Remove empty strings
|
||||
return parts
|
||||
|
||||
# Diagnostic: log any non-string entries
|
||||
non_string_entries = df[
|
||||
~df[col].apply(lambda x: isinstance(x, (str, bytes, os.PathLike)))
|
||||
]
|
||||
if not non_string_entries.empty:
|
||||
print(f"[WARNING] Non-string entries found in column '{col}':")
|
||||
print(non_string_entries)
|
||||
|
||||
df = df.copy()
|
||||
split_paths = df[col].apply(clean_split)
|
||||
|
||||
if min_files_for_path is not None:
|
||||
df = df[
|
||||
split_paths.apply(lambda parts: len(parts) >= min_files_for_path)
|
||||
].copy()
|
||||
split_paths = split_paths[df.index]
|
||||
|
||||
df["group_key"] = split_paths.apply(
|
||||
lambda parts: os.sep.join(parts[:path_exclusion_constant])
|
||||
)
|
||||
grouped = df.groupby("group_key")
|
||||
new_rows = []
|
||||
|
||||
for _, group_df in grouped:
|
||||
paths = group_df[col].tolist()
|
||||
split_parts = [clean_split(p) for p in paths]
|
||||
|
||||
def longest_common_prefix(paths):
|
||||
if not paths:
|
||||
return []
|
||||
prefix = paths[0]
|
||||
for path in paths[1:]:
|
||||
prefix = [a for a, b in zip(prefix, path) if a == b]
|
||||
if not prefix:
|
||||
break
|
||||
return prefix
|
||||
|
||||
common_prefix = longest_common_prefix(split_parts)
|
||||
prefix_str = os.sep.join(common_prefix)
|
||||
|
||||
for i, parts in enumerate(split_parts):
|
||||
filename = parts[-1]
|
||||
middle = (
|
||||
os.sep.join(parts[len(common_prefix) : -1])
|
||||
if len(parts) > len(common_prefix) + 1
|
||||
else ""
|
||||
)
|
||||
row = group_df.iloc[i].copy()
|
||||
row["longestcfp"] = prefix_str
|
||||
row["middle"] = middle
|
||||
row["filename_only"] = filename
|
||||
row["file_extension"] = os.path.splitext(filename)[1].lower()
|
||||
new_rows.append(row)
|
||||
|
||||
return pd.DataFrame(new_rows).drop(columns=["group_key"])
|
||||
|
||||
|
||||
def calculatePath(approved_hashes, path_exclusion_constant, split):
|
||||
if split:
|
||||
dfs_by_policy = [group for _, group in approved_hashes.groupby("policy")]
|
||||
else:
|
||||
dfs_by_policy = [approved_hashes]
|
||||
|
||||
badpathparts = get_system_list("BAD_PATH_PARTS")
|
||||
min_files_for_path = get_system_value("MIN_FILES_FOR_PATH", cast_type=int)
|
||||
|
||||
processed_dfs = []
|
||||
|
||||
for df in dfs_by_policy:
|
||||
haslcp = splitFilepathsGrouped(df, path_exclusion_constant, "filename")
|
||||
haslcp = haslcp.drop_duplicates()
|
||||
|
||||
forbidden = regulator(badpathparts, True)
|
||||
forbidden_lcfp = haslcp["longestcfp"].str.contains(forbidden, na=False)
|
||||
|
||||
logger.debug("Removing forbidden filepaths for path exceptions")
|
||||
print(colorText("Removing forbidden filepaths for path exceptions", "green"))
|
||||
lcp_not_forbidden = haslcp[~forbidden_lcfp].copy()
|
||||
|
||||
lcp_not_forbidden_review = lcp_not_forbidden[
|
||||
[
|
||||
"policyname",
|
||||
"longestcfp",
|
||||
"middle",
|
||||
"filename_only",
|
||||
"file_extension",
|
||||
"sha256",
|
||||
]
|
||||
]
|
||||
|
||||
unique_sha_counts = (
|
||||
lcp_not_forbidden_review.groupby("longestcfp")["sha256"]
|
||||
.nunique()
|
||||
.reset_index()
|
||||
)
|
||||
unique_sha_counts.columns = ["longestcfp", "unique_sha256_count"]
|
||||
|
||||
lcp_not_forbidden_review = lcp_not_forbidden_review.merge(
|
||||
unique_sha_counts, on="longestcfp", how="left"
|
||||
)
|
||||
lcp_not_forbidden_review = lcp_not_forbidden_review[
|
||||
lcp_not_forbidden_review["unique_sha256_count"] >= min_files_for_path
|
||||
]
|
||||
processed_dfs.append(lcp_not_forbidden_review)
|
||||
|
||||
pathExclusions = pd.concat(processed_dfs, ignore_index=True)
|
||||
|
||||
return pathExclusions
|
||||
|
||||
|
||||
def testChange(selected_policies, destination_policy, destination_allowlist):
|
||||
working_dir = load_env("WORKING_DIR")
|
||||
|
||||
logger.info("These path exclusions would be added to:")
|
||||
logger.info(destination_policy)
|
||||
|
||||
pathexclusions = pd.read_csv(
|
||||
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_paths.csv"
|
||||
)
|
||||
hashes = pd.read_csv(
|
||||
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_hashes.csv"
|
||||
)
|
||||
|
||||
unique_combinations = pathexclusions[
|
||||
["longestcfp", "file_extension"]
|
||||
].drop_duplicates()
|
||||
|
||||
drive_letter_pattern = re.compile(r"^[a-zA-Z]:\\")
|
||||
processed_paths = [
|
||||
(path if drive_letter_pattern.match(path) else f"\\\\{path}") + f"\\**{ext}"
|
||||
for path, ext in unique_combinations.itertuples(index=False, name=None)
|
||||
]
|
||||
|
||||
for path in processed_paths:
|
||||
logger.info(path)
|
||||
|
||||
print(colorText("These publishers would added", "yellow"))
|
||||
processed_publishers = []
|
||||
if os.path.exists(
|
||||
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_publishers.csv"
|
||||
):
|
||||
publishers = pd.read_csv(
|
||||
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_publishers.csv"
|
||||
)
|
||||
if publishers.empty:
|
||||
print(colorText("The publishers list is empty.", "red"))
|
||||
else:
|
||||
processed_publishers = (
|
||||
publishers[publishers["publisher"] != "Not Signed"]["publisher"]
|
||||
.drop_duplicates()
|
||||
.tolist()
|
||||
)
|
||||
for publisher in processed_publishers:
|
||||
print(publisher)
|
||||
|
||||
print(colorText("These hashes would be added to:", "yellow"))
|
||||
print(destination_allowlist)
|
||||
|
||||
processed_hashes = hashes["sha256"].unique().tolist()
|
||||
print_x_wide(processed_hashes, 3)
|
||||
|
||||
return processed_paths, processed_hashes, processed_publishers
|
||||
|
||||
|
||||
def menu_policy_enforce(
|
||||
api: AirlockAPIWrapper,
|
||||
): # TODO Need to clean up 6 and 7 into functions
|
||||
selected_policies = []
|
||||
destination_policy = []
|
||||
destination_allowlist = []
|
||||
processed_paths = []
|
||||
processed_hashes = []
|
||||
processed_publishers = []
|
||||
working_dir = load_env("WORKING_DIR")
|
||||
|
||||
while True:
|
||||
printEnforceChecklist(
|
||||
selected_policies, destination_policy, destination_allowlist
|
||||
)
|
||||
choice = get_sanitized_input("\nEnter your choice: ")
|
||||
|
||||
if choice == "1":
|
||||
clear_screen()
|
||||
selected_policies = selectPolicies(api, True)
|
||||
|
||||
elif choice == "2":
|
||||
clear_screen()
|
||||
print(
|
||||
colorText(
|
||||
"Please choose destination_name Policy for Path Exclusions", "white"
|
||||
)
|
||||
)
|
||||
|
||||
destination_policy = selectPolicies(api, False)
|
||||
|
||||
print(colorText("Please choose Allowlist for Hashes", "white"))
|
||||
|
||||
destination_allowlist = selectAllowlists(api, destination_policy, False)
|
||||
|
||||
elif choice == "3":
|
||||
clear_screen()
|
||||
sortHashes(
|
||||
api,
|
||||
selected_policies,
|
||||
type=[1, 2, 6, 7],
|
||||
)
|
||||
|
||||
elif choice == "4":
|
||||
clear_screen()
|
||||
if os.path.exists(
|
||||
f"{working_dir}\\Needs_Review\\Review_First\\{selected_policies[0].name}_approved_executions.csv"
|
||||
):
|
||||
buildPathsandPublishers(selected_policies, False)
|
||||
else:
|
||||
print(
|
||||
"File not found. Please make sure it's saved correctly and try again."
|
||||
)
|
||||
|
||||
elif choice == "5":
|
||||
clear_screen()
|
||||
if os.path.exists(
|
||||
f"{working_dir}\\Approved\\{selected_policies[0].name}_approved_executions.csv"
|
||||
) and os.path.exists(
|
||||
f"{working_dir}\\Approved\\{selected_policies[0].name}_primary_Paths.csv"
|
||||
):
|
||||
buildPreflights(selected_policies)
|
||||
else:
|
||||
print(
|
||||
"File not found. Please make sure it's saved correctly and try again."
|
||||
)
|
||||
|
||||
elif choice == "6":
|
||||
clear_screen()
|
||||
if (
|
||||
os.path.exists(
|
||||
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_paths.csv"
|
||||
)
|
||||
and os.path.exists(
|
||||
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_hashes.csv"
|
||||
)
|
||||
and destination_policy
|
||||
and destination_allowlist
|
||||
):
|
||||
processed_paths, processed_hashes, processed_publishers = testChange(
|
||||
selected_policies, destination_policy, destination_allowlist
|
||||
)
|
||||
else:
|
||||
# Log which condition(s) failed
|
||||
missing_items = []
|
||||
if not os.path.exists(
|
||||
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_paths.csv"
|
||||
):
|
||||
missing_items.append("approved_paths.csv not found")
|
||||
if not os.path.exists(
|
||||
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_hashes.csv"
|
||||
):
|
||||
missing_items.append("approved_hashes.csv not found")
|
||||
if not destination_policy:
|
||||
missing_items.append("destination_policy is empty or None")
|
||||
if not destination_allowlist:
|
||||
missing_items.append("destination_allowlist is empty or None")
|
||||
|
||||
logger.error("Preflight check failed due to the following:")
|
||||
for item in missing_items:
|
||||
logger.error(f" - {item}")
|
||||
|
||||
elif choice == "7":
|
||||
clear_screen()
|
||||
areYouSure()
|
||||
confirmation = get_sanitized_input("Type 'I AGREE' to continue: ")
|
||||
if (
|
||||
processed_paths
|
||||
and processed_hashes
|
||||
and processed_publishers
|
||||
and destination_policy
|
||||
and destination_allowlist
|
||||
and confirmation.strip() == "I AGREE"
|
||||
):
|
||||
print(colorText("Proceeding with the code...", "yellow"))
|
||||
api.hash_add_to_allowlist(
|
||||
destination_allowlist[0].applicationid, processed_hashes
|
||||
)
|
||||
api.policy_add_path_exclusions(
|
||||
destination_policy[0].groupid, processed_paths
|
||||
)
|
||||
if processed_publishers:
|
||||
api.policy_add_publishers(
|
||||
destination_policy[0].groupid, processed_publishers
|
||||
)
|
||||
|
||||
locked()
|
||||
|
||||
else:
|
||||
logger.error("Confirmation block failed. Reasons:")
|
||||
if not processed_publishers or processed_hashes or processed_paths:
|
||||
logger.error(" - Test not performed.")
|
||||
if not destination_policy:
|
||||
logger.error(" - `destination_policy` is missing or invalid.")
|
||||
if not destination_allowlist:
|
||||
logger.error(" - `destination_allowlist` is missing or invalid.")
|
||||
if confirmation.strip() != "I AGREE":
|
||||
logger.error(
|
||||
" - User did not confirm with 'I AGREE'. Received: '%s'",
|
||||
confirmation.strip(),
|
||||
)
|
||||
|
||||
elif choice.upper() == "F":
|
||||
open_directory(working_dir)
|
||||
elif choice.upper() == "B":
|
||||
break
|
||||
|
||||
else:
|
||||
print(colorText("Invalid choice. Please try again.", "red"))
|
||||
|
||||
|
||||
def section_header(title):
|
||||
print(
|
||||
colorText(
|
||||
"\n --------------------------------------------------------------------",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
print(colorText(f" ------------- {title} -------------", "cyan"))
|
||||
print(
|
||||
colorText(
|
||||
" --------------------------------------------------------------------",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def printEnforceChecklist(selected_policies, destination_policy, destination_allowlist):
|
||||
working_dir = load_env("WORKING_DIR")
|
||||
section_header("Prepare to Enforce Policy ")
|
||||
print(
|
||||
colorText(
|
||||
"\nSequentially follow these steps to prepare a policy for enforcement:",
|
||||
"white",
|
||||
)
|
||||
)
|
||||
|
||||
# Step 1: Originating Policies
|
||||
print(
|
||||
colorText(
|
||||
"\n1. Choose which policy or policies to gather execution info from", "cyan"
|
||||
)
|
||||
)
|
||||
if not selected_policies:
|
||||
print(colorText(" [✗] No policies have been chosen", "red"))
|
||||
else:
|
||||
print(colorText("The following policies have been chosen:", "green"))
|
||||
for policy in selected_policies:
|
||||
print(colorText(f" [✓] {policy.name}", "green"))
|
||||
|
||||
# Step 2: Destination Policy and Allowlist
|
||||
print(
|
||||
colorText("2. Choose the destination policy and associated allowlist", "cyan")
|
||||
)
|
||||
if destination_policy:
|
||||
print(
|
||||
colorText(
|
||||
f" [✓] {destination_policy[0].name} has been selected as the destination policy",
|
||||
"green",
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(colorText(" [✗] No destination policy has been chosen", "red"))
|
||||
|
||||
if destination_allowlist:
|
||||
print(
|
||||
colorText(
|
||||
f" [✓] {destination_allowlist[0].name} has been selected as allowlist",
|
||||
"green",
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(colorText(" [✗] No allowlist has been chosen", "red"))
|
||||
|
||||
# Step 3: Data Preparation
|
||||
print(
|
||||
colorText(
|
||||
f"3. Select to begin pulling execution history. The executions will be sorted and placed in {working_dir}\\data\\Needs_Review",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
if selected_policies:
|
||||
policy_id = selected_policies[0].name
|
||||
review_path = f"{working_dir}\\Needs_Review\\Review_First\\{policy_id}_approved_executions.csv"
|
||||
print(
|
||||
colorText(
|
||||
(
|
||||
" [✓] Data has been fetched"
|
||||
if os.path.exists(review_path)
|
||||
else " [✗] Data has not been fetched"
|
||||
),
|
||||
"green" if os.path.exists(review_path) else "red",
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(
|
||||
colorText(
|
||||
" [✗] No policies selected, cannot check data fetch status", "red"
|
||||
)
|
||||
)
|
||||
|
||||
# Step 4: Manual Review
|
||||
print(colorText("4. Manually review the files:", "cyan"))
|
||||
print(
|
||||
colorText(
|
||||
" Remove the rows containing hashes you do not approve of", "cyan"
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
f" When complete, save both csv files to {working_dir}\\data\\Approved and choose this option.",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
" This will start the process to generate possible filepath approvals",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
|
||||
if selected_policies:
|
||||
policy_id = selected_policies[0].name
|
||||
approved_path = f"{working_dir}\\Approved\\{policy_id}_approved_executions.csv"
|
||||
second_review_path = (
|
||||
f"{working_dir}\\Needs_Review\\Review_Second\\{policy_id}_primary_Paths.csv"
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
(
|
||||
" [✓] Reviewed hashes have been loaded"
|
||||
if os.path.exists(approved_path)
|
||||
else " [✗] Reviewed hashes have not been loaded"
|
||||
),
|
||||
"green" if os.path.exists(approved_path) else "red",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
(
|
||||
" [✓] Path review list created"
|
||||
if os.path.exists(second_review_path)
|
||||
else " [✗] Path review list has not been created"
|
||||
),
|
||||
"green" if os.path.exists(second_review_path) else "red",
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(
|
||||
colorText(
|
||||
" [✗] No policies selected, cannot check reviewed hashes or path list",
|
||||
"red",
|
||||
)
|
||||
)
|
||||
|
||||
# Step 5: Path Review
|
||||
print(
|
||||
colorText(
|
||||
f"5. Manually review the files in {working_dir}\\Needs_Review\\Review_Second\\",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
" Remove the rows containing path exclusions or publishers you do not approve of.",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
f" When complete, save the files to {working_dir}\\data\\Approved",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(" Choose this option when done to build your preflights", "cyan")
|
||||
)
|
||||
|
||||
if selected_policies:
|
||||
policy_id = selected_policies[0].name
|
||||
reviewed_path = f"{working_dir}\\Approved\\{policy_id}_primary_Paths.csv"
|
||||
preflight_paths = f"{working_dir}\\Preflight\\{policy_id}_approved_paths.csv"
|
||||
preflight_hashes = f"{working_dir}\\Preflight\\{policy_id}_approved_hashes.csv"
|
||||
print(
|
||||
colorText(
|
||||
(
|
||||
" [✓] Reviewed path list detected"
|
||||
if os.path.exists(reviewed_path)
|
||||
else " [✗] Path review list has not been detected"
|
||||
),
|
||||
"green" if os.path.exists(reviewed_path) else "red",
|
||||
)
|
||||
)
|
||||
preflight_ready = os.path.exists(preflight_paths) and os.path.exists(
|
||||
preflight_hashes
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
(
|
||||
" [✓] Preflight Path Exclusion List has been generated"
|
||||
if preflight_ready
|
||||
else " [✗] Preflight Path Exclusion List has not been generated"
|
||||
),
|
||||
"green" if preflight_ready else "red",
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(
|
||||
colorText(
|
||||
" [✗] No policies selected, cannot check preflight status", "red"
|
||||
)
|
||||
)
|
||||
|
||||
# Final Steps
|
||||
print(
|
||||
colorText(
|
||||
"6. Test ------------------------------------------------------", "cyan"
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
" Prints to console the changes that would be made, must be done to proceed. ",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
|
||||
print(
|
||||
colorText(
|
||||
"7. Liftoff ------------------------------------------------------", "cyan"
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
" Apply path exclusions and approved publishers to selected policy",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
print(colorText(" Apply approved hashes to allowlist", "cyan"))
|
||||
|
||||
# Utility Options
|
||||
print(colorText("F. Open Working Directory", "cyan"))
|
||||
print(colorText("B. Back", "cyan"))
|
||||
@@ -0,0 +1,176 @@
|
||||
# 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 logging
|
||||
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.message import Message
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Button, Footer, Header, Static
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResultsDisplay(Widget):
|
||||
"""Widget for displaying operation results in a two-column layout."""
|
||||
|
||||
CSS = """
|
||||
ResultsDisplay {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#results_screen {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#results_title {
|
||||
text-align: center;
|
||||
margin: 1 0;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
#results_layout {
|
||||
height: 1fr;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
#left_column, #right_column {
|
||||
width: 1fr;
|
||||
height: 100%;
|
||||
border: solid green;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
#right_column {
|
||||
border: solid red;
|
||||
}
|
||||
|
||||
#success_label, #failure_label {
|
||||
text-style: bold;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#success_results, #failure_results {
|
||||
height: 1fr;
|
||||
overflow-y: auto;
|
||||
background: $surface;
|
||||
border: round $primary;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
.copy_button {
|
||||
margin-top: 1;
|
||||
width: 100%;
|
||||
}
|
||||
"""
|
||||
|
||||
class CopySuccess(Message):
|
||||
"""Posted when success results are copied."""
|
||||
|
||||
pass
|
||||
|
||||
class CopyFailure(Message):
|
||||
"""Posted when failure results are copied."""
|
||||
|
||||
pass
|
||||
|
||||
class GoBack(Message):
|
||||
"""Posted when back button is pressed."""
|
||||
|
||||
pass
|
||||
|
||||
def __init__(
|
||||
self, operation: str, successful_results: str, unsuccessful_results: str
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.operation = operation
|
||||
self.successful_results = successful_results
|
||||
self.unsuccessful_results = unsuccessful_results
|
||||
|
||||
def compose(self):
|
||||
with Vertical(id="results_screen"):
|
||||
yield Header(show_clock=True, icon="⚙️")
|
||||
# Title
|
||||
title = Static(f"{self.operation} - Results", id="results_title")
|
||||
yield title
|
||||
|
||||
# Two-column layout
|
||||
with Horizontal(id="results_layout"):
|
||||
# Left Column - Success
|
||||
with Vertical(id="left_column"):
|
||||
yield Static("✅ Successful", id="success_label")
|
||||
yield Static(self.successful_results, id="success_results")
|
||||
yield Button(
|
||||
"Copy Success List",
|
||||
id="copy_success",
|
||||
classes="copy_button",
|
||||
)
|
||||
|
||||
# Right Column - Failure
|
||||
with Vertical(id="right_column"):
|
||||
yield Static("❌ Failed", id="failure_label")
|
||||
yield Static(self.unsuccessful_results, id="failure_results")
|
||||
yield Button(
|
||||
"Copy Failure List",
|
||||
id="copy_failure",
|
||||
classes="copy_button",
|
||||
)
|
||||
|
||||
yield Footer()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
btn_id = event.button.id
|
||||
|
||||
if btn_id == "copy_success":
|
||||
success_widget = self.query_one("#success_results", Static)
|
||||
try:
|
||||
import pyperclip
|
||||
|
||||
pyperclip.copy(str(success_widget.renderable))
|
||||
self.app.notify(
|
||||
"Success list copied to clipboard!",
|
||||
severity="information",
|
||||
timeout=2,
|
||||
)
|
||||
self.post_message(self.CopySuccess())
|
||||
except ImportError:
|
||||
self.app.notify(
|
||||
"❌ pyperclip not installed. Run: pip install pyperclip",
|
||||
severity="warning",
|
||||
)
|
||||
except Exception as e:
|
||||
self.app.notify(f"¢ Failed to copy: {str(e)}", severity="error")
|
||||
event.stop()
|
||||
|
||||
elif btn_id == "copy_failure":
|
||||
failure_widget = self.query_one("#failure_results", Static)
|
||||
try:
|
||||
import pyperclip
|
||||
|
||||
pyperclip.copy(str(failure_widget.renderable))
|
||||
self.app.notify(
|
||||
"Failure list copied to clipboard!",
|
||||
severity="information",
|
||||
timeout=2,
|
||||
)
|
||||
self.post_message(self.CopyFailure())
|
||||
except ImportError:
|
||||
self.app.notify(
|
||||
"❌ pyperclip not installed. Run: pip install pyperclip",
|
||||
severity="warning",
|
||||
)
|
||||
except Exception as e:
|
||||
self.app.notify(f"❌ Failed to copy: {str(e)}", severity="error")
|
||||
event.stop()
|
||||
Reference in New Issue
Block a user