# 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 . """ Quiet Agent Workflow Screen Module Provides a TUI workflow for identifying quiet agents and moving them to target policies. This screen replaces the legacy quietAgent.py with a comprehensive TUI interface that: 1. Allows selection of an initial policy to analyze 2. Categorizes devices into "Enforce Ready" and "Non-Enforce Ready" based on activity 3. Allows users to select target policies for each category 4. Uses the API to move devices to their target policies """ import datetime import logging import os from typing import List, Optional import pandas as pd from textual.app import ComposeResult from textual.containers import Horizontal, Vertical from textual.reactive import reactive from textual.screen import Screen from textual.widgets import Button, DataTable, Footer, Header, Static from models.policy import Policy from services.API import AirlockAPIWrapper from services.policyhandler import getPolicyInfo from TUI.policyselector import PolicySelector from utils.configmanager import load_env logger = logging.getLogger(__name__) class QuietAgentWorkflowScreen(Screen): """ A Textual screen for the Quiet Agent analysis and migration workflow. This screen provides a multi-step workflow: 1. Select initial policy to analyze 2. View categorized agents (enforce ready vs. non-enforce ready) 3. Select target policies for each category 4. Execute agent migrations Attributes: api (AirlockAPIWrapper): API wrapper for Airlock operations policies (List[Policy]): List of all available policies selected_policy (Optional[Policy]): The initially selected policy to analyze history_days (int): Number of days of history to pull (default: 150) quiet_days (int): Number of days without execution to be considered quiet (default: 45) agents_df (Optional[pd.DataFrame]): DataFrame of all agents with analysis results enforce_ready_df (Optional[pd.DataFrame]): DataFrame of agents ready for enforcement non_enforce_ready_df (Optional[pd.DataFrame]): DataFrame of agents not ready for enforcement workflow_stage (str): Current stage of the workflow """ BINDINGS = [ ("escape", "go_back", "Back"), ] workflow_stage = reactive("select_policy") # Tracks current workflow stage def __init__(self, api: AirlockAPIWrapper, policies: List[Policy]): """ Initialize the QuietAgentWorkflowScreen. Args: api (AirlockAPIWrapper): API wrapper for Airlock operations policies (List[Policy]): List of all available policies """ super().__init__() self.api = api self.policies = policies self.selected_policy: Optional[Policy] = None self.history_days = 150 # Fixed as per requirements self.quiet_days = 45 # Default value self.agents_df: Optional[pd.DataFrame] = None self.enforce_ready_df: Optional[pd.DataFrame] = None self.non_enforce_ready_df: Optional[pd.DataFrame] = None self.enforce_ready_target_policy: Optional[Policy] = None self.non_enforce_ready_target_policy: Optional[Policy] = None def compose(self) -> ComposeResult: """Build the UI layout for the workflow screen.""" # Include Header and Footer like other standalone screens yield Header(show_clock=True, icon="āš™") # Title area title = Static("šŸ”’ Quiet Agent Workflow", id="workflow_title") title.styles.margin = (0, 0, 0, 1) yield title # Status area status = Static("Step 1: Select Policy to Analyze", id="workflow_status") status.styles.margin = (0, 0, 1, 1) yield status # Content area - dynamically populated based on workflow stage yield Vertical(id="content_area") yield Footer() def on_mount(self) -> None: """Initialize the screen when mounted.""" # Show initial policy selection self._show_policy_selection() def watch_workflow_stage(self, old_value: str, new_value: str) -> None: """React to workflow stage changes.""" logger.debug(f"Workflow stage changed from {old_value} to {new_value}") self._update_status_message() def _update_status_message(self) -> None: """Update the status message based on current workflow stage.""" status_widget = self.query_one("#workflow_status", Static) stage_messages = { "select_policy": "Step 1: Select Policy to Analyze", "select_quiet_days": "Step 2: Select Quiet Time Period", "analyzing": "šŸ“Š Analyzing agent activity...", "view_results": "Step 3: Review Categorized Agents", "select_enforce_target": "Step 4: Select Target Policy for Enforce Ready Agents", "select_non_enforce_target": "Step 5: Select Target Policy for Non-Enforce Ready Agents", "confirm_migration": "Step 6: Confirm and Execute Migration", "executing": "ā³ Executing agent migrations...", "complete": "āœ… Migration Complete", } status_widget.update(stage_messages.get(self.workflow_stage, "Unknown Stage")) def _show_policy_selection(self) -> None: """Show the initial policy selection screen.""" self.workflow_stage = "select_policy" content = self.query_one("#content_area", Vertical) content.remove_children() # Create policy selector widget policy_selector = PolicySelector(self.policies) content.mount(policy_selector) def on_policy_selector_policy_selected( self, message: PolicySelector.PolicySelected ) -> None: """Handle policy selection from PolicySelector widget.""" # Handle based on current workflow stage if self.workflow_stage == "select_policy": # Initial policy selection for analysis self.selected_policy = message.policy logger.info(f"Selected policy for analysis: {self.selected_policy.name}") self._show_quiet_days_selection() elif self.workflow_stage == "select_enforce_target": # Target policy selection for enforce ready agents self.enforce_ready_target_policy = message.policy logger.info( f"Selected target policy for enforce ready: {message.policy.name}" ) self._show_non_enforce_target_selection() elif self.workflow_stage == "select_non_enforce_target": # Target policy selection for non-enforce ready agents self.non_enforce_ready_target_policy = message.policy logger.info( f"Selected target policy for non-enforce ready: {message.policy.name}" ) self._show_migration_confirmation() def _show_quiet_days_selection(self) -> None: """Show the quiet days selection screen.""" self.workflow_stage = "select_quiet_days" content = self.query_one("#content_area", Vertical) content.remove_children() # Create info text info_widget = Static( f"Policy Selected: {self.selected_policy.name}\n\n" f"History Period: {self.history_days} days\n\n" "Select quiet time period (days without untrusted execution):", id="quiet_days_info", ) info_widget.styles.margin = (0, 0, 2, 0) content.mount(info_widget) # Create button container and mount it first button_container = Vertical(id="quiet_days_buttons") button_container.styles.height = "auto" content.mount(button_container) # Now add buttons to the mounted container for days in [15, 30, 45, 60]: btn = Button( f"{days} days {'(Default)' if days == 45 else ''}", id=f"quiet_days_{days}", classes="quiet_day_btn", ) btn.styles.width = "100%" btn.styles.margin = (0, 0, 1, 0) button_container.mount(btn) back_btn = Button("← Back", id="back_to_policy_selection") back_btn.styles.width = "100%" back_btn.styles.margin = (2, 0, 0, 0) button_container.mount(back_btn) def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button press events.""" button_id = event.button.id # Quiet days selection buttons if button_id and button_id.startswith("quiet_days_"): days = int(button_id.split("_")[-1]) self.quiet_days = days logger.info(f"Selected quiet days: {days}") self._start_analysis() return # Navigation buttons if button_id == "back_to_policy_selection": self._show_policy_selection() return if button_id == "back_to_results": self._show_results() return if button_id == "select_enforce_target_btn": self._show_enforce_target_selection() return if button_id == "select_non_enforce_target_btn": self._show_non_enforce_target_selection() return if button_id == "skip_enforce_target_btn": # Skip enforce ready target selection self.enforce_ready_target_policy = None self._show_non_enforce_target_selection() return if button_id == "skip_non_enforce_target_btn": # Skip non-enforce ready target selection self.non_enforce_ready_target_policy = None self._show_migration_confirmation() return if button_id == "confirm_migration_btn": self._execute_migration() return if button_id == "cancel_migration_btn": self._show_results() return if button_id == "export_results_btn": self._export_results() return if button_id == "start_over_btn": self._show_policy_selection() return def _start_analysis(self) -> None: """Start the agent activity analysis.""" self.workflow_stage = "analyzing" content = self.query_one("#content_area", Vertical) content.remove_children() # Show analyzing message with detailed steps analyzing_msg = Static( f"šŸ“Š Analyzing Agent Activity\n" f"{'=' * 50}\n\n" f"Policy: {self.selected_policy.name}\n" f"History Period: {self.history_days} days\n" f"Quiet Threshold: {self.quiet_days} days\n\n" f"Progress:\n" f"ā³ Step 1/4: Fetching agents from policy...\n" f"ā±ļø Step 2/4: Pulling execution history (this may take a moment)...\n" f"ā±ļø Step 3/4: Analyzing activity patterns...\n" f"ā±ļø Step 4/4: Categorizing agents...\n\n" f"Please wait - this operation cannot be cancelled.", id="analyzing_message", ) analyzing_msg.styles.margin = (2, 1) content.mount(analyzing_msg) # Show notification self.app.notify( "Starting analysis - this may take several minutes for large policies", severity="information", timeout=5, ) # Perform the analysis asynchronously self.call_later(self._perform_analysis) def _perform_analysis(self) -> None: """Perform the actual agent activity analysis.""" try: # Update status: Fetching agents self._update_analysis_status("Step 1/4: Fetching agents from policy...") # Get agents in the selected policy agents = self.api.agents_find_by_group(self.selected_policy.groupid) if agents.empty: self.app.notify( f"No agents found in policy: {self.selected_policy.name}", severity="warning", timeout=5, ) self._show_policy_selection() return agent_count = len(agents) self.app.notify( f"Found {agent_count} agents - fetching execution history...", severity="information", timeout=3, ) # Update status: Pulling execution history self._update_analysis_status( f"Step 2/4: Pulling execution history for {agent_count} agents...\n" f"(This may take several minutes - progress shown in terminal)" ) # Get execution history (this shows progress bars in terminal via airlock_libs) policy_exec_history = getPolicyInfo( self.api, self.selected_policy, [1, 2, 6, 7], self.history_days ) # Update status: Analyzing patterns self._update_analysis_status("Step 3/4: Analyzing activity patterns...") self.app.notify( "History retrieved - analyzing patterns...", severity="information", timeout=2, ) if policy_exec_history.empty: logger.info( "No execution history found for the selected policy and time range." ) # All agents are quiet (no executions) agents["execution_count"] = 0 agents["days_since"] = None agents["required_quiet"] = self.quiet_days agents["enforce_ready"] = True else: # Convert datetime column policy_exec_history["datetime"] = pd.to_datetime( policy_exec_history["datetime"], format="%Y-%m-%dT%H:%M:%SZ", utc=True, ) # Calculate days ago now = datetime.datetime.now(datetime.timezone.utc) policy_exec_history["days_ago"] = policy_exec_history["datetime"].apply( lambda dt: (now - dt).days ) # Count total executions per hostname hostname_counts = policy_exec_history["hostname"].value_counts() agents["execution_count"] = ( agents["hostname"].map(hostname_counts).fillna(0).astype(int) ) # Find most recent execution per hostname most_recent_exec = policy_exec_history.sort_values( by="days_ago" ).drop_duplicates(subset="hostname", keep="first") # Map most recent execution age to agents agents["days_since"] = agents["hostname"].map( most_recent_exec.set_index("hostname")["days_ago"] ) # Check for enforcement readiness agents["required_quiet"] = self.quiet_days agents["enforce_ready"] = agents["days_since"].apply( lambda x: True if pd.isna(x) or x > self.quiet_days else False ) # Update status: Categorizing self._update_analysis_status("Step 4/4: Categorizing agents...") # Sort agents agents = agents.sort_values( by=["execution_count", "hostname"], ascending=[True, True] ) # Store the results self.agents_df = agents # Categorize agents into DataFrames self.enforce_ready_df = agents[agents["enforce_ready"] == True].copy() self.non_enforce_ready_df = agents[agents["enforce_ready"] == False].copy() logger.info( f"Analysis complete: {len(self.enforce_ready_df)} enforce ready, " f"{len(self.non_enforce_ready_df)} non-enforce ready" ) self.app.notify( f"Analysis complete! Found {len(self.enforce_ready_df)} enforce ready, " f"{len(self.non_enforce_ready_df)} not ready", severity="success", timeout=5, ) # Show results self._show_results() except Exception as e: logger.error(f"Error during analysis: {e}", exc_info=True) self.app.notify(f"Analysis failed: {str(e)}", severity="error", timeout=5) self._show_policy_selection() def _update_analysis_status(self, status_text: str) -> None: """Update the analysis status message.""" try: analyzing_msg = self.query_one("#analyzing_message", Static) # Build updated message updated_text = ( f"šŸ“Š Analyzing Agent Activity\n" f"{'=' * 50}\n\n" f"Policy: {self.selected_policy.name}\n" f"History Period: {self.history_days} days\n" f"Quiet Threshold: {self.quiet_days} days\n\n" f"Progress:\n" f"āœ… {status_text}\n\n" f"Please wait - this operation cannot be cancelled." ) analyzing_msg.update(updated_text) except Exception as e: logger.debug(f"Could not update analysis status: {e}") def _show_results(self) -> None: """Show the categorized results.""" self.workflow_stage = "view_results" content = self.query_one("#content_area", Vertical) content.remove_children() # Create results display container and mount it first results_container = Vertical(id="results_container") results_container.styles.height = "auto" results_container.styles.margin = (1, 1) content.mount(results_container) # Summary statistics total_agents = len(self.enforce_ready_df) + len(self.non_enforce_ready_df) ready_count = len(self.enforce_ready_df) not_ready_count = len(self.non_enforce_ready_df) ready_percentage = (ready_count / total_agents * 100) if total_agents > 0 else 0 summary = Static( f"Analysis Results for: {self.selected_policy.name}\n\n" f"šŸ“Š Total Agents: {total_agents}\n" f"āœ… Enforce Ready: {ready_count} ({ready_percentage:.1f}%)\n" f"āŒ Not Ready: {not_ready_count} ({100 - ready_percentage:.1f}%)\n\n" f"Quiet Threshold: {self.quiet_days} days\n" f"History Period: {self.history_days} days", id="results_summary", ) summary.styles.margin = (0, 0, 2, 0) results_container.mount(summary) # Action buttons button_container = Horizontal(id="results_buttons") button_container.styles.height = "auto" results_container.mount(button_container) if ready_count > 0: enforce_btn = Button( f"Select Target for Enforce Ready ({ready_count})", id="select_enforce_target_btn", ) enforce_btn.styles.margin = (0, 1, 1, 0) button_container.mount(enforce_btn) if not_ready_count > 0: non_enforce_btn = Button( f"Select Target for Non-Enforce Ready ({not_ready_count})", id="select_non_enforce_target_btn", ) non_enforce_btn.styles.margin = (0, 1, 1, 0) button_container.mount(non_enforce_btn) export_btn = Button("šŸ’¾ Export Results", id="export_results_btn") export_btn.styles.margin = (0, 1, 1, 0) button_container.mount(export_btn) start_over_btn = Button("šŸ”„ Start Over", id="start_over_btn") start_over_btn.styles.margin = (0, 0, 1, 0) button_container.mount(start_over_btn) # Tables showing agents tables_container = Horizontal() tables_container.styles.height = "1fr" results_container.mount(tables_container) # Enforce Ready table if ready_count > 0: enforce_col = Vertical() enforce_col.styles.width = "1fr" enforce_col.styles.margin = (1, 1, 0, 0) tables_container.mount(enforce_col) enforce_label = Static("āœ… Enforce Ready Agents") enforce_label.styles.margin = (0, 0, 1, 0) enforce_col.mount(enforce_label) enforce_table = DataTable(id="enforce_ready_table") enforce_table.styles.height = "1fr" enforce_table.add_columns("Hostname", "Last Exec (days)") # Display first 50 agents for idx, row in self.enforce_ready_df.head(50).iterrows(): days_since = row["days_since"] days_str = f"{int(days_since)}" if not pd.isna(days_since) else "Never" enforce_table.add_row(row["hostname"], days_str) if len(self.enforce_ready_df) > 50: enforce_table.add_row( f"... and {len(self.enforce_ready_df) - 50} more", "" ) enforce_col.mount(enforce_table) # Non-Enforce Ready table if not_ready_count > 0: non_enforce_col = Vertical() non_enforce_col.styles.width = "1fr" non_enforce_col.styles.margin = (1, 0, 0, 1) tables_container.mount(non_enforce_col) non_enforce_label = Static("āŒ Non-Enforce Ready Agents") non_enforce_label.styles.margin = (0, 0, 1, 0) non_enforce_col.mount(non_enforce_label) non_enforce_table = DataTable(id="non_enforce_ready_table") non_enforce_table.styles.height = "1fr" non_enforce_table.add_columns("Hostname", "Last Exec (days)") # Display first 50 agents for idx, row in self.non_enforce_ready_df.head(50).iterrows(): days_since = row["days_since"] days_str = f"{int(days_since)}" if not pd.isna(days_since) else "N/A" non_enforce_table.add_row(row["hostname"], days_str) if len(self.non_enforce_ready_df) > 50: non_enforce_table.add_row( f"... and {len(self.non_enforce_ready_df) - 50} more", "" ) non_enforce_col.mount(non_enforce_table) def _show_enforce_target_selection(self) -> None: """Show policy selection for enforce ready agents.""" self.workflow_stage = "select_enforce_target" content = self.query_one("#content_area", Vertical) content.remove_children() # Info message info = Static( f"Select target policy for {len(self.enforce_ready_df)} Enforce Ready agents\n" f"Source Policy: {self.selected_policy.name}", id="enforce_target_info", ) info.styles.margin = (0, 0, 2, 0) content.mount(info) # Policy selector policy_selector = PolicySelector(self.policies) content.mount(policy_selector) # Skip button skip_btn = Button("ā­•ļø Skip - No Migration", id="skip_enforce_target_btn") skip_btn.styles.width = "50%" skip_btn.styles.margin = (2, 0, 0, 0) content.mount(skip_btn) def _show_non_enforce_target_selection(self) -> None: """Show policy selection for non-enforce ready agents.""" self.workflow_stage = "select_non_enforce_target" content = self.query_one("#content_area", Vertical) content.remove_children() # Info message info = Static( f"Select target policy for {len(self.non_enforce_ready_df)} Non-Enforce Ready agents\n" f"Source Policy: {self.selected_policy.name}", id="non_enforce_target_info", ) info.styles.margin = (0, 0, 2, 0) content.mount(info) # Policy selector policy_selector = PolicySelector(self.policies) content.mount(policy_selector) # Skip button skip_btn = Button("ā­•ļø Skip - No Migration", id="skip_non_enforce_target_btn") skip_btn.styles.width = "50%" skip_btn.styles.margin = (2, 0, 0, 0) content.mount(skip_btn) def _show_migration_confirmation(self) -> None: """Show migration confirmation screen.""" self.workflow_stage = "confirm_migration" content = self.query_one("#content_area", Vertical) content.remove_children() # Build confirmation message confirmation_lines = [ "šŸ” Migration Summary\n", f"Source Policy: {self.selected_policy.name}\n", ] if self.enforce_ready_target_policy: confirmation_lines.append( f"\nāœ… Enforce Ready Migration:\n" f" • Agents: {len(self.enforce_ready_df)}\n" f" • Target: {self.enforce_ready_target_policy.name}\n" ) if self.non_enforce_ready_target_policy: confirmation_lines.append( f"\nāŒ Non-Enforce Ready Migration:\n" f" • Agents: {len(self.non_enforce_ready_df)}\n" f" • Target: {self.non_enforce_ready_target_policy.name}\n" ) if ( not self.enforce_ready_target_policy and not self.non_enforce_ready_target_policy ): confirmation_lines.append("\nāš ļø No migrations will be performed.") confirmation = Static("".join(confirmation_lines), id="migration_confirmation") confirmation.styles.margin = (1, 1, 2, 1) content.mount(confirmation) # Action buttons - mount container first, then add buttons button_container = Horizontal(id="confirmation_buttons") button_container.styles.height = "auto" button_container.styles.margin = (1, 1) content.mount(button_container) if self.enforce_ready_target_policy or self.non_enforce_ready_target_policy: confirm_btn = Button("āœ… Confirm Migration", id="confirm_migration_btn") confirm_btn.styles.margin = (0, 1, 0, 0) button_container.mount(confirm_btn) cancel_btn = Button("āŒ Cancel", id="cancel_migration_btn") button_container.mount(cancel_btn) def _execute_migration(self) -> None: """Execute the agent migrations.""" self.workflow_stage = "executing" content = self.query_one("#content_area", Vertical) content.remove_children() # Show executing message executing_msg = Static( "ā³ Executing agent migrations...\nPlease wait...", id="executing_message", ) executing_msg.styles.margin = (2, 1) content.mount(executing_msg) # Perform migrations asynchronously self.call_later(self._perform_migrations) def _perform_migrations(self) -> None: """Perform the actual agent migrations.""" successful_migrations = [] failed_migrations = [] try: # Migrate enforce ready agents if self.enforce_ready_target_policy: for idx, row in self.enforce_ready_df.iterrows(): try: result = self.api.agent_move( row["agentid"], self.enforce_ready_target_policy.groupid ) successful_migrations.append( (row["hostname"], self.enforce_ready_target_policy.name) ) logger.debug( f"Moved {row['hostname']} to {self.enforce_ready_target_policy.name}" ) except Exception as e: failed_migrations.append((row["hostname"], str(e))) logger.error(f"Failed to move {row['hostname']}: {e}") # Migrate non-enforce ready agents if self.non_enforce_ready_target_policy: for idx, row in self.non_enforce_ready_df.iterrows(): try: result = self.api.agent_move( row["agentid"], self.non_enforce_ready_target_policy.groupid ) successful_migrations.append( (row["hostname"], self.non_enforce_ready_target_policy.name) ) logger.debug( f"Moved {row['hostname']} to {self.non_enforce_ready_target_policy.name}" ) except Exception as e: failed_migrations.append((row["hostname"], str(e))) logger.error(f"Failed to move {row['hostname']}: {e}") # Show completion results self._show_completion_results(successful_migrations, failed_migrations) except Exception as e: logger.error(f"Error during migration execution: {e}", exc_info=True) self.app.notify(f"Migration failed: {str(e)}", severity="error", timeout=5) self._show_results() def _show_completion_results( self, successful: List[tuple], failed: List[tuple] ) -> None: """Show migration completion results.""" self.workflow_stage = "complete" content = self.query_one("#content_area", Vertical) content.remove_children() # Results summary total_attempted = len(successful) + len(failed) success_rate = ( (len(successful) / total_attempted * 100) if total_attempted > 0 else 0 ) results = Static( f"āœ… Migration Complete\n\n" f"Total Agents Migrated: {len(successful)}\n" f"Failed Migrations: {len(failed)}\n" f"Success Rate: {success_rate:.1f}%", id="completion_summary", ) results.styles.margin = (1, 1, 2, 1) content.mount(results) # Details tables if successful: success_container = Vertical() success_container.styles.margin = (0, 1) content.mount(success_container) success_label = Static("āœ… Successful Migrations") success_label.styles.margin = (0, 0, 1, 0) success_container.mount(success_label) success_table = DataTable(id="success_table") success_table.styles.height = "auto" success_table.add_columns("Hostname", "Target Policy") for hostname, target_policy in successful[:25]: # Show first 25 success_table.add_row(hostname, target_policy) if len(successful) > 25: success_table.add_row(f"... and {len(successful) - 25} more", "") success_container.mount(success_table) if failed: failed_container = Vertical() failed_container.styles.margin = (2, 1, 0, 1) content.mount(failed_container) failed_label = Static("āŒ Failed Migrations") failed_label.styles.margin = (0, 0, 1, 0) failed_container.mount(failed_label) failed_table = DataTable(id="failed_table") failed_table.styles.height = "auto" failed_table.add_columns("Hostname", "Error") for hostname, error in failed[:25]: # Show first 25 failed_table.add_row(hostname, error[:50]) # Truncate error if len(failed) > 25: failed_table.add_row(f"... and {len(failed) - 25} more", "") failed_container.mount(failed_table) # Action button done_btn = Button("āœ” Done", id="start_over_btn") done_btn.styles.width = "50%" done_btn.styles.margin = (2, 0, 0, 0) content.mount(done_btn) def _export_results(self) -> None: """Export analysis results to CSV.""" try: working_dir = load_env("WORKING_DIR") or os.getcwd() filename = os.path.join( working_dir, f"{self.selected_policy.name}_quiet_analysis_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.csv", ) self.agents_df.to_csv(filename, index=False) logger.info(f"Exported results to {filename}") self.app.notify( f"Results exported to:\n{filename}", severity="information", timeout=5, ) except Exception as e: logger.error(f"Failed to export results: {e}") self.app.notify(f"Export failed: {str(e)}", severity="error", timeout=5) def action_go_back(self) -> None: """Handle back/escape action.""" # Depending on stage, go back to previous stage or exit if self.workflow_stage in ["select_policy", "view_results", "complete"]: self.app.pop_screen() elif self.workflow_stage == "select_quiet_days": self._show_policy_selection() elif self.workflow_stage == "select_enforce_target": self._show_results() elif self.workflow_stage == "select_non_enforce_target": if self.enforce_ready_target_policy: self._show_enforce_target_selection() else: self._show_results() elif self.workflow_stage == "confirm_migration": self._show_non_enforce_target_selection() else: self.app.pop_screen()