use crate::firewall::{
AdvisorSeverity, CandidateFilter, CandidateStatus, FirewallController, FirewallProfile,
ReviewGateStatus, RuleAction,
};
use crossterm::event::{
self as crossterm_event, Event, KeyCode, KeyEventKind, MouseButton as CrosstermMouseButton,
MouseEvent, MouseEventKind,
};
use scrin::command_palette::{Command, CommandPalette};
use scrin::core::{buffer::Buffer, color::Color, rect::Rect};
use scrin::effects::{EffectKind, EffectPlayer};
use scrin::interaction::{
HitRegion, MouseButton as ScrinMouseButton, MouseCursor, PointerEvent, PointerEventKind,
UiEvent, WidgetAction, WidgetId, WidgetRole,
};
use scrin::overlays::modal::ModalStyle;
use scrin::overlays::toast::ToastKind;
use scrin::overlays::{Modal, Overlay, OverlayPosition, Toast, Transition};
use scrin::status_bar::{StatusBar, StatusBarPosition};
use scrin::style::Style;
use scrin::terminal::{Frame, FrameTiming, PresentStrategy, Terminal};
use scrin::widgets::popup::{PopupPosition, PopupStyle};
use scrin::widgets::table::{Cell as TableCell, Row as TableRow};
use scrin::widgets::{Block, Line, List, ListItem, Paragraph, Popup, Span, Table, Tabs, Widget};
use std::env;
use std::fs;
use std::time::Duration;
const BG: Color = Color::rgb(3, 7, 13);
const BG_BAND: Color = Color::rgb(5, 12, 20);
const PANEL: Color = Color::rgb(8, 14, 23);
const PANEL_SOFT: Color = Color::rgb(12, 21, 33);
const PANEL_HOT: Color = Color::rgb(22, 18, 28);
const BORDER: Color = Color::rgb(45, 59, 76);
const BORDER_SOFT: Color = Color::rgb(31, 42, 58);
const DIM: Color = Color::rgb(132, 148, 166);
const TEXT: Color = Color::rgb(230, 238, 247);
const ACCENT: Color = Color::rgb(85, 195, 255);
const ACCENT_2: Color = Color::rgb(145, 104, 255);
const WARN: Color = Color::rgb(255, 91, 93);
const CAUTION: Color = Color::rgb(245, 181, 74);
const OK: Color = Color::rgb(80, 220, 139);
const OPEN_PORT: Color = Color::rgb(245, 181, 74);
const ALLOW: Color = Color::rgb(124, 198, 255);
const FOCUS: Color = Color::rgb(88, 160, 245);
const SELECTED_BG: Color = Color::rgb(18, 42, 63);
const SELECTED_WARN_BG: Color = Color::rgb(52, 28, 31);
const TAB_TITLES: [&str; 5] = ["Dashboard", "Rules", "Ports", "Plan", "Advisor"];
const REPORT_PATH: &str = "kwall-dry-run-report.txt";
const SCRIPT_PATH: &str = "kwall-plan-preview.sh";
const MANIFEST_PATH: &str = "kwall-dry-run-manifest.json";
const FRAME_METRICS_ENV: &str = "KWALL_FRAME_METRICS";
const SHOW_FRAME_METRICS_DEFAULT: bool = false;
const ACTIVE_FRAME_STEP: Duration = Duration::from_millis(66);
const IDLE_EVENT_POLL: Duration = Duration::from_millis(250);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Focus {
Rules,
Ports,
Plan,
Advisor,
}
impl Focus {
fn next(self) -> Self {
match self {
Self::Rules => Self::Ports,
Self::Ports => Self::Plan,
Self::Plan => Self::Advisor,
Self::Advisor => Self::Rules,
}
}
}
#[derive(Debug)]
enum PendingAction {
ApplyLive {
fingerprint: String,
commands: usize,
},
ApplyProfile(FirewallProfile),
ApplyRecommendedProfile,
ApplySafeRecommendedProfile,
BlockCandidate {
index: usize,
name: &'static str,
},
AllowCandidate {
index: usize,
name: &'static str,
},
ToggleCandidateBlock {
index: usize,
name: &'static str,
},
ToggleCandidateException {
index: usize,
name: &'static str,
},
BlockSelectedGroup {
index: usize,
group: &'static str,
},
BlockSelectedRisk {
index: usize,
risk: &'static str,
},
BlockVisibleCandidates(Vec<usize>),
AllowVisibleCandidates(Vec<usize>),
BlockPriorityQueue(Vec<usize>),
HardenHighRisk,
ClearRules(usize),
ResetStarterPlan,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ConfirmationStep {
First,
Final,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PortPaneAction {
Block,
Allow,
Toggle,
Exception,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PortPointerTarget {
Candidate(usize),
Action(PortPaneAction),
}
pub struct FirewallTui {
controller: FirewallController,
focus: Focus,
selected_tab: usize,
selected_rule: usize,
selected_plan: usize,
selected_candidate: usize,
candidate_filter: CandidateFilter,
selected_group_filter: Option<usize>,
selected_risk_filter: Option<usize>,
selected_finding: usize,
banner: EffectPlayer,
help_modal: Modal,
safety_popup: Popup,
command_palette: CommandPalette,
confirmation_modal: Modal,
pending_confirmation: Option<PendingAction>,
confirmation_step: ConfirmationStep,
confirmation_title: String,
confirmation_message: String,
toasts: Vec<Toast>,
last_frame_timing: Option<FrameTiming>,
show_frame_metrics: bool,
ports_list_area: Option<Rect>,
port_info_area: Option<Rect>,
port_hit_rows: Vec<(u16, usize)>,
port_action_rows: Vec<(u16, PortPaneAction)>,
port_hit_regions: Vec<HitRegion>,
context_candidate: Option<usize>,
}
impl FirewallTui {
pub fn new(controller: FirewallController) -> Self {
Self {
controller,
focus: Focus::Rules,
selected_tab: 0,
selected_rule: 0,
selected_plan: 0,
selected_candidate: 0,
candidate_filter: CandidateFilter::All,
selected_group_filter: None,
selected_risk_filter: None,
selected_finding: 0,
banner: EffectPlayer::new(EffectKind::Decrypt, "KWALL")
.with_size(72, 3)
.with_duration(64)
.with_accent(ACCENT),
help_modal: Modal::new("Help", "Guarded firewall control surface")
.with_style(ModalStyle::Dialog)
.with_options(vec![
"Tab: switch pages".into(),
": or /: open command palette".into(),
"Up/Down: move current selection".into(),
"Home/End/PageUp/PageDown: jump or jump by pages".into(),
"Enter or d: block/unblock selected known port".into(),
"N: jump to priority at-risk port".into(),
"n: block priority at-risk port".into(),
"M: block visible priority queue".into(),
"X: mark/remove reviewed exception for allowed risky port".into(),
"F/G/K: cycle status/name, group, and risk filters".into(),
"g/B: block selected group or risk class".into(),
"V/A: block or allow visible filter".into(),
"h: block all high-risk known ports".into(),
"Y/y: apply strongest or management-preserving profile".into(),
"w/e/D/k: workstation, web, developer, lockdown profiles".into(),
"r: advisor quick fix, u: undo".into(),
"o/S/m/E: export report, script, manifest, bundle".into(),
"I/O: toggle default incoming/outgoing policy".into(),
"c/R: clear rules or restore starter plan".into(),
"L: apply staged plan to live firewall after confirmation".into(),
"H: focus selected port info pane".into(),
"Click port: select; right-click arms action pane".into(),
"Enter/y/Y twice: confirm | n/Esc: cancel modal".into(),
"b/a: force block or allow selected service port".into(),
"f: dashboard focus, p: backend, l: logging".into(),
"s: safety popup".into(),
"Esc or ?: close this modal".into(),
])
.with_border_color(ACCENT)
.with_transition(Transition::Scale),
safety_popup: Popup::new(
"Safety Lock",
"Guarded live apply is available with L.\nIt runs the selected backend commands only after confirmation.\nReview-gate blockers stop apply; warnings require operator judgment.\nRoot privileges are required; exports remain inert.",
)
.with_style(PopupStyle::Panel)
.with_size(62, 10)
.with_min_size(32, 7)
.with_position(PopupPosition::BottomRight)
.with_border_color(WARN)
.with_auto_close_on_resize(false),
command_palette: build_command_palette(),
confirmation_modal: Modal::new("Confirm", "")
.with_style(ModalStyle::Confirm)
.with_border_color(WARN)
.with_transition(Transition::Scale),
pending_confirmation: None,
confirmation_step: ConfirmationStep::First,
confirmation_title: String::new(),
confirmation_message: String::new(),
last_frame_timing: None,
show_frame_metrics: read_frame_metrics_flag(),
ports_list_area: None,
port_info_area: None,
port_hit_rows: Vec::new(),
port_action_rows: Vec::new(),
port_hit_regions: Vec::new(),
context_candidate: None,
toasts: vec![Toast::new("Staged firewall mode active", ToastKind::Success)
.with_position(OverlayPosition::TopRight)
.with_lifetime(Duration::from_secs(4))
.with_max_width(44)],
}
}
pub fn run(&mut self) -> std::io::Result<()> {
let mut terminal = Terminal::init()?;
let mouse_capture = terminal.set_mouse_capture(true).is_ok();
let mut needs_render = true;
loop {
if needs_render {
let frame_timing = terminal
.draw_with_present_strategy(PresentStrategy::DirtyBounds, |frame| {
self.render(frame)
})?;
self.last_frame_timing = Some(frame_timing);
needs_render = false;
}
let has_timed_state = self.has_active_timed_state();
let event_wait = if has_timed_state {
ACTIVE_FRAME_STEP
} else {
IDLE_EVENT_POLL
};
if crossterm_event::poll(event_wait)? {
let event = crossterm_event::read()?;
if self.handle_terminal_event(event, &mut terminal) {
break;
}
needs_render = true;
} else if has_timed_state {
needs_render |= self.advance_timed_state(ACTIVE_FRAME_STEP);
}
}
if mouse_capture {
let _ = terminal.set_mouse_capture(false);
}
terminal.restore()
}
fn handle_terminal_event(&mut self, event: Event, terminal: &mut Terminal) -> bool {
match event {
Event::Mouse(mouse) => {
let handled = pointer_event_from_mouse(mouse)
.map(|pointer| {
let batch = terminal.handle_pointer_event(pointer);
self.handle_scrin_ui_events(&batch.events)
})
.unwrap_or(false);
if !handled {
self.handle_mouse_event(mouse);
}
false
}
other => self.handle_event(other),
}
}
fn handle_scrin_ui_events(&mut self, events: &[UiEvent]) -> bool {
let mut handled = false;
for event in events {
match event {
UiEvent::MouseDown { id, button, .. } => match port_pointer_target(id) {
Some(PortPointerTarget::Candidate(index)) => {
if *button == ScrinMouseButton::Right {
self.show_context_pane(index);
} else {
self.select_port_candidate(index, false);
}
handled = true;
}
Some(PortPointerTarget::Action(action)) => {
self.request_port_pane_action(action);
handled = true;
}
None => {}
},
_ => {}
}
}
handled
}
fn handle_event(&mut self, event: Event) -> bool {
let key = match event {
Event::Key(key) => key,
Event::Mouse(mouse) => return self.handle_mouse_event(mouse),
_ => return false,
};
if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
return false;
}
if self.command_palette.is_open() {
match key.code {
KeyCode::Esc => self.command_palette.close(),
KeyCode::Enter => {
let query = self.command_palette.query.trim().to_string();
if let Some(action) = self.command_palette.execute_selected().map(str::to_owned)
{
self.command_palette.close();
self.run_palette_action(&action);
} else if let Some(target) = palette_target_block_input(&query) {
self.command_palette.close();
self.block_palette_target(target);
} else if !query.is_empty() {
self.toast("No matching command", ToastKind::Warning);
}
}
KeyCode::Up => self.command_palette.select_prev(),
KeyCode::Down => self.command_palette.select_next(),
KeyCode::Backspace => self.command_palette.backspace(),
KeyCode::Char(c) => self.command_palette.input_char(c),
_ => {}
}
return false;
}
if self.confirmation_modal.is_visible() {
match key.code {
KeyCode::Esc | KeyCode::Char('n') => {
self.resolve_confirmation(Some(false));
}
KeyCode::Enter => {
self.resolve_confirmation(None);
}
KeyCode::Char('y') | KeyCode::Char('Y') => {
self.resolve_confirmation(Some(true));
}
KeyCode::Left | KeyCode::Up => self.confirmation_modal.select_prev(),
KeyCode::Right | KeyCode::Down => self.confirmation_modal.select_next(),
_ => {}
}
return false;
}
if self.help_modal.is_visible() {
match key.code {
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => self.help_modal.hide(),
KeyCode::Up => self.help_modal.select_prev(),
KeyCode::Down => self.help_modal.select_next(),
_ => {}
}
return false;
}
if self.safety_popup.is_visible() {
if matches!(
key.code,
KeyCode::Esc | KeyCode::Char('s') | KeyCode::Char('q')
) {
self.safety_popup.hide();
return false;
}
return false;
}
if self.context_candidate.is_some() {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
self.context_candidate = None;
self.toast("Port action pane cleared", ToastKind::Info);
return false;
}
KeyCode::Char('b') | KeyCode::Char('B') => {
self.request_context_block_candidate();
return false;
}
KeyCode::Char('a') | KeyCode::Char('A') => {
self.request_context_allow_candidate();
return false;
}
KeyCode::Char('d') | KeyCode::Enter => {
self.request_context_toggle_candidate();
return false;
}
KeyCode::Char('X') | KeyCode::Char('x') => {
self.request_context_toggle_exception();
return false;
}
KeyCode::Char('H') => {
self.show_context_port_info();
return false;
}
_ => {}
}
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => true,
KeyCode::Tab => {
self.select_tab((self.selected_tab + 1) % TAB_TITLES.len());
self.toast("Switched tab", ToastKind::Info);
false
}
KeyCode::BackTab => {
let next = if self.selected_tab == 0 {
TAB_TITLES.len() - 1
} else {
self.selected_tab - 1
};
self.select_tab(next);
false
}
KeyCode::Char('1') => self.select_tab_and_continue(0),
KeyCode::Char('2') => self.select_tab_and_continue(1),
KeyCode::Char('3') => self.select_tab_and_continue(2),
KeyCode::Char('4') => self.select_tab_and_continue(3),
KeyCode::Char('5') => self.select_tab_and_continue(4),
KeyCode::Up => {
self.move_selection(-1);
false
}
KeyCode::Down => {
self.move_selection(1);
false
}
KeyCode::PageUp => {
self.move_selection(-10);
false
}
KeyCode::PageDown => {
self.move_selection(10);
false
}
KeyCode::Home => {
self.move_selection_to_boundary(true);
false
}
KeyCode::End => {
self.move_selection_to_boundary(false);
false
}
KeyCode::Enter => {
self.activate_focused_item();
false
}
KeyCode::Char('f') => {
if self.selected_tab == 0 {
self.focus = self.focus.next();
self.toast("Dashboard focus moved", ToastKind::Info);
}
false
}
KeyCode::Char('d') => {
self.toggle_selected_port_block();
false
}
KeyCode::Char('F') => {
self.cycle_candidate_filter();
false
}
KeyCode::Char('G') => {
self.cycle_candidate_group_filter();
false
}
KeyCode::Char('K') => {
self.cycle_candidate_risk_filter();
false
}
KeyCode::Char('N') => {
self.jump_to_priority_candidate();
false
}
KeyCode::Char('n') => {
self.block_priority_candidate();
false
}
KeyCode::Char('M') => {
self.request_block_priority_queue();
false
}
KeyCode::Char('X') => {
self.toggle_selected_exception();
false
}
KeyCode::Char('h') => {
self.request_harden_high_risk();
false
}
KeyCode::Char('g') => {
self.request_block_selected_group();
false
}
KeyCode::Char('B') => {
self.request_block_selected_risk();
false
}
KeyCode::Char('V') => {
self.request_block_visible_candidates();
false
}
KeyCode::Char('A') => {
self.request_allow_visible_candidates();
false
}
KeyCode::Char('Y') => {
self.request_apply_recommended_profile();
false
}
KeyCode::Char('y') => {
self.request_apply_safe_recommended_profile();
false
}
KeyCode::Char('w') => {
self.request_apply_profile(FirewallProfile::Workstation);
false
}
KeyCode::Char('e') => {
self.request_apply_profile(FirewallProfile::WebServer);
false
}
KeyCode::Char('D') => {
self.request_apply_profile(FirewallProfile::Developer);
false
}
KeyCode::Char('k') => {
self.request_apply_profile(FirewallProfile::Lockdown);
false
}
KeyCode::Char('r') => {
self.apply_advisor_quick_fix();
false
}
KeyCode::Char('u') => {
if self.controller.undo() {
self.selected_rule = self
.selected_rule
.min(self.controller.rules().len().saturating_sub(1));
self.align_selected_candidate_with_filter();
self.toast("Undid last staged change", ToastKind::Info);
} else {
self.toast("Nothing to undo", ToastKind::Info);
}
false
}
KeyCode::Char('b') => {
self.block_selected_candidate();
false
}
KeyCode::Char('a') => {
self.allow_selected_candidate();
false
}
KeyCode::Char(' ') => {
self.controller.toggle_rule(self.selected_rule);
self.align_selected_candidate_with_filter();
self.toast("Rule toggled", ToastKind::Info);
false
}
KeyCode::Char('x') => {
self.controller.remove_rule(self.selected_rule);
self.selected_rule = self
.selected_rule
.min(self.controller.rules().len().saturating_sub(1));
self.align_selected_candidate_with_filter();
self.toast("Rule removed", ToastKind::Warning);
false
}
KeyCode::Char('p') => {
self.controller.cycle_backend();
self.toast("Preview backend changed", ToastKind::Info);
false
}
KeyCode::Char('l') => {
self.controller.toggle_logging();
self.toast("Logging plan toggled", ToastKind::Info);
false
}
KeyCode::Char('I') => {
self.controller.toggle_default_incoming();
self.toast("Default incoming policy toggled", ToastKind::Info);
false
}
KeyCode::Char('O') => {
self.controller.toggle_default_outgoing();
self.toast("Default outgoing policy toggled", ToastKind::Info);
false
}
KeyCode::Char('c') => {
self.request_clear_rules();
false
}
KeyCode::Char('R') => {
self.request_reset_to_starter_plan();
false
}
KeyCode::Char('L') => {
self.request_live_apply();
false
}
KeyCode::Char('H') => {
self.show_port_info_pane();
false
}
KeyCode::Char('o') => {
self.export_report();
false
}
KeyCode::Char('S') => {
self.export_script();
false
}
KeyCode::Char('m') => {
self.export_manifest();
false
}
KeyCode::Char('E') => {
self.export_bundle();
false
}
KeyCode::Char('?') => {
self.help_modal.show();
false
}
KeyCode::Char('s') => {
self.safety_popup.toggle();
false
}
KeyCode::Char(':') | KeyCode::Char('/') => {
self.command_palette.open();
false
}
_ => false,
}
}
fn run_palette_action(&mut self, action: &str) {
match action {
"dashboard" => self.select_tab(0),
"rules" => self.select_tab(1),
"ports" => self.select_tab(2),
"plan" => self.select_tab(3),
"block-selected" => {
self.block_selected_candidate();
return;
}
"block-target-help" => {
self.toast(
"Type block example.com or deny 203.0.113.10",
ToastKind::Info,
);
return;
}
"toggle-port-block" => self.toggle_selected_port_block(),
"cycle-port-filter" => {
self.cycle_candidate_filter();
return;
}
"cycle-group-filter" => {
self.cycle_candidate_group_filter();
return;
}
"cycle-risk-filter" => {
self.cycle_candidate_risk_filter();
return;
}
"jump-priority-port" => {
self.jump_to_priority_candidate();
return;
}
"block-priority-port" => {
self.block_priority_candidate();
return;
}
"block-priority-queue" => {
self.request_block_priority_queue();
return;
}
"toggle-exception" => {
self.toggle_selected_exception();
return;
}
"block-selected-group" => {
self.request_block_selected_group();
return;
}
"block-selected-risk" => {
self.request_block_selected_risk();
return;
}
"block-visible-ports" => {
self.request_block_visible_candidates();
return;
}
"allow-visible-ports" => {
self.request_allow_visible_candidates();
return;
}
"harden-known" => {
self.request_harden_high_risk();
return;
}
"profile-recommended" => {
self.request_apply_recommended_profile();
return;
}
"profile-safe-recommended" => {
self.request_apply_safe_recommended_profile();
return;
}
"profile-workstation" => {
self.request_apply_profile(FirewallProfile::Workstation);
return;
}
"profile-web" => {
self.request_apply_profile(FirewallProfile::WebServer);
return;
}
"profile-developer" => {
self.request_apply_profile(FirewallProfile::Developer);
return;
}
"profile-lockdown" => {
self.request_apply_profile(FirewallProfile::Lockdown);
return;
}
"clear-rules" => {
self.request_clear_rules();
return;
}
"reset-starter" => {
self.request_reset_to_starter_plan();
return;
}
"live-apply" => {
self.request_live_apply();
return;
}
"risk-overlay" => {
self.show_port_info_pane();
return;
}
"allow-selected" => {
self.allow_selected_candidate();
return;
}
"advisor" => self.select_tab(4),
"advisor-fix" => self.apply_advisor_quick_fix(),
"undo" => {
self.controller.undo();
self.selected_rule = self
.selected_rule
.min(self.controller.rules().len().saturating_sub(1));
self.align_selected_candidate_with_filter();
}
"toggle-rule" => {
self.controller.toggle_rule(self.selected_rule);
self.align_selected_candidate_with_filter();
}
"remove-rule" => {
self.controller.remove_rule(self.selected_rule);
self.selected_rule = self
.selected_rule
.min(self.controller.rules().len().saturating_sub(1));
self.align_selected_candidate_with_filter();
}
"cycle-backend" => self.controller.cycle_backend(),
"toggle-logging" => self.controller.toggle_logging(),
"toggle-default-incoming" => self.controller.toggle_default_incoming(),
"toggle-default-outgoing" => self.controller.toggle_default_outgoing(),
"export-report" => {
self.export_report();
return;
}
"export-script" => {
self.export_script();
return;
}
"export-manifest" => {
self.export_manifest();
return;
}
"export-bundle" => {
self.export_bundle();
return;
}
"safety" => self.safety_popup.toggle(),
"help" => self.help_modal.show(),
_ => {}
}
self.toast("Command palette action applied", ToastKind::Success);
}
fn handle_mouse_event(&mut self, mouse: MouseEvent) -> bool {
if self.command_palette.is_open()
|| self.confirmation_modal.is_visible()
|| self.help_modal.is_visible()
|| self.safety_popup.is_visible()
{
return false;
}
if !matches!(
mouse.kind,
MouseEventKind::Down(CrosstermMouseButton::Left | CrosstermMouseButton::Right)
) {
return false;
}
if matches!(
mouse.kind,
MouseEventKind::Down(CrosstermMouseButton::Left | CrosstermMouseButton::Right)
) && self.handle_port_action_click(mouse.column, mouse.row)
{
return false;
}
let Some(area) = self.ports_list_area else {
return false;
};
if mouse.column < area.x
|| mouse.column >= area.x.saturating_add(area.width)
|| mouse.row < area.y
|| mouse.row >= area.y.saturating_add(area.height)
{
return false;
}
if let Some((_, candidate_index)) = self
.port_hit_rows
.iter()
.find(|(row, _)| *row == mouse.row)
.copied()
{
match mouse.kind {
MouseEventKind::Down(CrosstermMouseButton::Right) => {
self.show_context_pane(candidate_index)
}
_ => self.select_port_candidate(candidate_index, false),
}
}
false
}
fn activate_focused_item(&mut self) {
match self.focus {
Focus::Ports => self.toggle_selected_port_block(),
Focus::Rules => {
self.controller.toggle_rule(self.selected_rule);
self.align_selected_candidate_with_filter();
self.toast("Rule toggled", ToastKind::Info);
}
Focus::Plan => {
let plan = self.controller.plan();
if let Some(command) =
plan.get(self.selected_plan.min(plan.len().saturating_sub(1)))
{
self.toast(
&format!("Selected: {}", command.shell_line()),
ToastKind::Info,
);
} else {
self.toast("No generated command selected", ToastKind::Warning);
}
}
Focus::Advisor => self.command_palette.open(),
}
}
fn toggle_selected_port_block(&mut self) {
let Some(index) = self.selected_visible_candidate() else {
self.toast("No known port in active filter", ToastKind::Warning);
return;
};
match self.controller.toggle_candidate_block(index) {
Some(true) => {
self.selected_rule = self.controller.rules().len().saturating_sub(1);
self.align_selected_candidate_with_filter();
self.toast("Known port blocked", ToastKind::Success);
}
Some(false) => {
self.selected_rule = self
.selected_rule
.min(self.controller.rules().len().saturating_sub(1));
self.align_selected_candidate_with_filter();
self.toast("Known port unblocked", ToastKind::Info);
}
None => self.toast("No known port selected", ToastKind::Warning),
}
}
fn request_confirmation(&mut self, title: &str, message: &str, action: PendingAction) {
self.pending_confirmation = Some(action);
self.confirmation_step = ConfirmationStep::First;
self.confirmation_title = title.to_string();
self.confirmation_message = message.to_string();
self.confirmation_modal = Modal::new(
title,
&format!("{message}\n\nStep 1 of 2: choose Yes to continue."),
)
.with_style(ModalStyle::Confirm)
.with_border_color(WARN)
.with_transition(Transition::Scale)
.with_options(vec!["Yes".into(), "No".into()]);
self.confirmation_modal.show();
}
fn show_final_confirmation(&mut self) {
self.confirmation_step = ConfirmationStep::Final;
let title = if self.confirmation_title.is_empty() {
"Are you sure?".to_string()
} else {
format!("Are you sure? {}", self.confirmation_title)
};
let message = format!(
"{}\n\nStep 2 of 2: confirm again to run this action.",
self.confirmation_message
);
self.confirmation_modal = Modal::new(&title, &message)
.with_style(ModalStyle::Confirm)
.with_border_color(WARN)
.with_transition(Transition::Scale)
.with_options(vec!["Yes, I'm sure".into(), "Cancel".into()]);
self.confirmation_modal.show();
}
fn clear_confirmation(&mut self) {
self.pending_confirmation = None;
self.confirmation_step = ConfirmationStep::First;
self.confirmation_title.clear();
self.confirmation_message.clear();
self.confirmation_modal.hide();
}
fn request_block_selected_group(&mut self) {
let Some(index) = self.selected_visible_candidate() else {
self.toast("No known port in active filter", ToastKind::Warning);
return;
};
let Some(group) = self.controller.candidate_group(index) else {
self.toast("Selected candidate is unavailable", ToastKind::Warning);
return;
};
let total = self
.controller
.candidates()
.iter()
.enumerate()
.filter(|(candidate_index, candidate)| {
candidate.group == group
&& self
.controller
.candidate_status(*candidate_index)
.is_some_and(|status| status != CandidateStatus::Blocked)
})
.count();
if total == 0 {
self.toast(
&format!("{group} group already fully blocked or not eligible"),
ToastKind::Info,
);
return;
}
self.request_confirmation(
"Confirm group stage",
&format!("Stage block rules for all {total} ports in {group}?"),
PendingAction::BlockSelectedGroup { index, group },
);
}
fn request_block_selected_risk(&mut self) {
let Some(index) = self.selected_visible_candidate() else {
self.toast("No known port in active filter", ToastKind::Warning);
return;
};
let Some(risk) = self.controller.candidate_risk(index) else {
self.toast("Selected candidate is unavailable", ToastKind::Warning);
return;
};
let total = self
.controller
.candidates()
.iter()
.enumerate()
.filter(|(candidate_index, candidate)| {
candidate.risk == risk
&& self
.controller
.candidate_status(*candidate_index)
.is_some_and(|status| status != CandidateStatus::Blocked)
})
.count();
if total == 0 {
self.toast(
&format!("{risk} risk already fully blocked or not eligible"),
ToastKind::Info,
);
return;
}
self.request_confirmation(
"Confirm risk stage",
&format!("Stage block rules for all {total} {risk} risk ports?"),
PendingAction::BlockSelectedRisk { index, risk },
);
}
fn request_block_visible_candidates(&mut self) {
let visible = self.visible_candidate_indexes();
if visible.is_empty() {
self.toast("No known ports in active filter", ToastKind::Warning);
return;
}
let open_indexes = visible
.into_iter()
.filter(|index| {
self.controller
.candidate_status(*index)
.is_some_and(|status| status != CandidateStatus::Blocked)
})
.collect::<Vec<_>>();
if open_indexes.is_empty() {
self.toast("No visible ports need blocking", ToastKind::Info);
return;
}
self.request_confirmation(
"Confirm visible blocks",
&format!(
"Stage blocks for all {} currently visible ports?",
open_indexes.len()
),
PendingAction::BlockVisibleCandidates(open_indexes),
);
}
fn request_allow_visible_candidates(&mut self) {
let visible = self.visible_candidate_indexes();
if visible.is_empty() {
self.toast("No known ports in active filter", ToastKind::Warning);
return;
}
let allow_indexes = visible
.into_iter()
.filter(|index| {
self.controller
.candidate_status(*index)
.is_some_and(|status| status != CandidateStatus::Allowed)
})
.collect::<Vec<_>>();
if allow_indexes.is_empty() {
self.toast("No visible ports need allowing", ToastKind::Info);
return;
}
self.request_confirmation(
"Confirm visible allow",
&format!(
"Stage allows for all {} currently visible ports?",
allow_indexes.len()
),
PendingAction::AllowVisibleCandidates(allow_indexes),
);
}
fn request_block_candidate_action(&mut self, index: usize) {
let Some(candidate) = self.controller.candidates().get(index) else {
self.toast("Known port is no longer available", ToastKind::Warning);
return;
};
self.request_confirmation(
"Block known port",
&format!(
"Stage a block for {} {}/{}?",
candidate.name, candidate.protocol, candidate.port
),
PendingAction::BlockCandidate {
index,
name: candidate.name,
},
);
}
fn request_allow_candidate_action(&mut self, index: usize) {
let Some(candidate) = self.controller.candidates().get(index) else {
self.toast("Known port is no longer available", ToastKind::Warning);
return;
};
self.request_confirmation(
"Allow known port",
&format!(
"Stage an allow for {} {}/{}?",
candidate.name, candidate.protocol, candidate.port
),
PendingAction::AllowCandidate {
index,
name: candidate.name,
},
);
}
fn request_toggle_candidate_action(&mut self, index: usize) {
let Some(candidate) = self.controller.candidates().get(index) else {
self.toast("Known port is no longer available", ToastKind::Warning);
return;
};
let status = self
.controller
.candidate_status(index)
.unwrap_or(CandidateStatus::Open);
let verb = if status == CandidateStatus::Blocked {
"unblock"
} else {
"block"
};
self.request_confirmation(
"Toggle known port",
&format!(
"{} {} {}/{} from the context menu?",
capitalize_first(verb),
candidate.name,
candidate.protocol,
candidate.port
),
PendingAction::ToggleCandidateBlock {
index,
name: candidate.name,
},
);
}
fn request_toggle_candidate_exception_action(&mut self, index: usize) {
let Some(candidate) = self.controller.candidates().get(index) else {
self.toast("Known port is no longer available", ToastKind::Warning);
return;
};
self.request_confirmation(
"Toggle reviewed exception",
&format!(
"Record or remove reviewed exception state for {} {}/{}?",
candidate.name, candidate.protocol, candidate.port
),
PendingAction::ToggleCandidateException {
index,
name: candidate.name,
},
);
}
fn request_apply_profile(&mut self, profile: FirewallProfile) {
let pending_changes = self.controller.profile_change_preview(profile).len();
if pending_changes == 0 {
self.toast(
&format!(
"{} profile already matches the current plan",
profile.label()
),
ToastKind::Info,
);
return;
}
self.request_confirmation(
"Apply profile",
&format!(
"Apply {} profile ({} changes)?",
profile.label(),
pending_changes
),
PendingAction::ApplyProfile(profile),
);
}
fn request_apply_recommended_profile(&mut self) {
let recommendation = self.controller.recommended_profile();
let pending_changes = self
.controller
.profile_change_preview(recommendation.profile)
.len();
if pending_changes == 0 {
self.toast("Strongest recommendation already applied", ToastKind::Info);
return;
}
self.request_confirmation(
"Apply strongest recommendation",
&format!(
"Apply strongest recommended {} profile (score {})?",
recommendation.profile.label(),
recommendation.score
),
PendingAction::ApplyRecommendedProfile,
);
}
fn request_apply_safe_recommended_profile(&mut self) {
let Some(recommendation) = self.controller.safe_recommended_profile() else {
self.toast(
"No management-preserving profile is available",
ToastKind::Warning,
);
return;
};
let pending_changes = self
.controller
.profile_change_preview(recommendation.profile)
.len();
if pending_changes == 0 {
self.toast("Safe recommendation already applied", ToastKind::Info);
return;
}
self.request_confirmation(
"Apply safe recommendation",
&format!(
"Apply management-preserving {} profile ({} change(s))?",
recommendation.profile.label(),
recommendation.score
),
PendingAction::ApplySafeRecommendedProfile,
);
}
fn request_harden_high_risk(&mut self) {
let high_risk_candidates = self
.controller
.candidates()
.iter()
.enumerate()
.filter(|(index, candidate)| {
matches!(candidate.risk, "admin" | "high" | "data")
&& self
.controller
.candidate_status(*index)
.is_some_and(|status| status != CandidateStatus::Blocked)
})
.count();
if high_risk_candidates == 0 {
self.toast(
"No high-risk known ports currently need blocking",
ToastKind::Info,
);
return;
}
self.request_confirmation(
"Harden high-risk ports",
&format!("Stage blocks for all {high_risk_candidates} known high-risk ports?",),
PendingAction::HardenHighRisk,
);
}
fn request_clear_rules(&mut self) {
let staged = self.controller.rules().len();
if staged == 0 {
self.toast("No staged rules to clear", ToastKind::Warning);
return;
}
self.request_confirmation(
"Clear staged rules",
&format!("Remove all {} staged rules from the plan?", staged),
PendingAction::ClearRules(staged),
);
}
fn request_reset_to_starter_plan(&mut self) {
self.request_confirmation(
"Reset starter plan",
"Restore the safe starter plan baseline?",
PendingAction::ResetStarterPlan,
);
}
fn request_live_apply(&mut self) {
let preview = self.controller.live_apply_preview();
if preview.command_count == 0 {
self.toast("No generated commands to apply", ToastKind::Warning);
return;
}
if preview.blocked_gates > 0 {
let blocker = preview
.first_blocker
.as_deref()
.unwrap_or("review blockers remain");
self.toast(
&format!("Live apply blocked: {blocker}"),
ToastKind::Warning,
);
return;
}
let privilege = if preview.privileged {
"root privileges detected"
} else {
"root required; current session may be blocked"
};
self.request_confirmation(
"Apply firewall live",
&format!(
"Run {} {} command(s) against the live firewall?\nFingerprint {} | warnings {} | {}\nFailed commands stop the apply.",
preview.backend,
preview.command_count,
preview.fingerprint,
preview.warning_gates,
privilege
),
PendingAction::ApplyLive {
fingerprint: preview.fingerprint,
commands: preview.command_count,
},
);
}
fn request_block_priority_queue(&mut self) {
let queue = self.controller.priority_ports(3);
if queue.is_empty() {
self.toast("No at-risk priority ports remain", ToastKind::Success);
return;
}
let indexes = queue
.iter()
.map(|priority| priority.index)
.collect::<Vec<_>>();
self.request_confirmation(
"Block priority queue",
&format!("Stage blocks for top {} priority ports?", indexes.len()),
PendingAction::BlockPriorityQueue(indexes),
);
}
fn resolve_confirmation(&mut self, selected: Option<bool>) {
let Some(action) = self.pending_confirmation.take() else {
self.clear_confirmation();
return;
};
let proceed = selected.unwrap_or_else(|| {
self.confirmation_modal
.selected_option()
.is_some_and(|option| option.starts_with("Yes"))
});
if !proceed {
self.clear_confirmation();
self.toast("Action cancelled", ToastKind::Info);
return;
}
if self.confirmation_step == ConfirmationStep::First {
self.pending_confirmation = Some(action);
self.show_final_confirmation();
return;
}
self.clear_confirmation();
match action {
PendingAction::ApplyLive {
fingerprint,
commands,
} => self.apply_live(fingerprint, commands),
PendingAction::ApplyProfile(profile) => self.apply_profile(profile),
PendingAction::ApplyRecommendedProfile => self.apply_recommended_profile(),
PendingAction::ApplySafeRecommendedProfile => self.apply_safe_recommended_profile(),
PendingAction::BlockCandidate { index, name } => {
self.controller.block_candidate(index);
self.selected_candidate = index;
self.selected_rule = self.controller.rules().len().saturating_sub(1);
self.align_selected_candidate_with_filter();
self.toast(&format!("{name} block staged"), ToastKind::Success);
}
PendingAction::AllowCandidate { index, name } => {
self.controller.allow_candidate(index);
self.selected_candidate = index;
self.selected_rule = self.controller.rules().len().saturating_sub(1);
self.align_selected_candidate_with_filter();
self.toast(&format!("{name} allow staged"), ToastKind::Success);
}
PendingAction::ToggleCandidateBlock { index, name } => {
let result = self.controller.toggle_candidate_block(index);
self.selected_candidate = index;
self.selected_rule = self.controller.rules().len().saturating_sub(1);
self.align_selected_candidate_with_filter();
match result {
Some(true) => self.toast(&format!("{name} block staged"), ToastKind::Success),
Some(false) => self.toast(&format!("{name} unblocked"), ToastKind::Info),
None => self.toast("Known port unavailable", ToastKind::Warning),
}
}
PendingAction::ToggleCandidateException { index, name } => {
match self.controller.toggle_candidate_exception(index) {
Some(true) => self.toast(
&format!("{name} reviewed exception recorded"),
ToastKind::Warning,
),
Some(false) => self.toast(
&format!("{name} reviewed exception removed"),
ToastKind::Info,
),
None => self.toast(
"Allow an at-risk port before exception review",
ToastKind::Warning,
),
}
}
PendingAction::BlockSelectedGroup { index, group } => {
let staged = self.controller.block_candidate_group(index);
self.selected_rule = self.controller.rules().len().saturating_sub(1);
self.align_selected_candidate_with_filter();
let message = if staged == 0 {
format!("{group} group already blocked")
} else {
format!("{group} group blocked")
};
self.toast(&message, ToastKind::Success);
}
PendingAction::BlockSelectedRisk { index, risk } => {
let staged = self.controller.block_candidate_risk(index);
self.selected_rule = self.controller.rules().len().saturating_sub(1);
self.align_selected_candidate_with_filter();
let message = if staged == 0 {
format!("{risk} risk already blocked")
} else {
format!("{risk} risk blocked")
};
self.toast(&message, ToastKind::Success);
}
PendingAction::BlockVisibleCandidates(indexes) => {
let staged = self.controller.block_candidate_indexes(&indexes);
self.selected_rule = self.controller.rules().len().saturating_sub(1);
self.align_selected_candidate_with_filter();
self.toast(
if staged == 0 {
"Visible ports already blocked"
} else {
"Visible ports blocked"
},
ToastKind::Success,
);
}
PendingAction::AllowVisibleCandidates(indexes) => {
let staged = self.controller.allow_candidate_indexes(&indexes);
self.selected_rule = self.controller.rules().len().saturating_sub(1);
self.align_selected_candidate_with_filter();
self.toast(
if staged == 0 {
"Visible ports already allowed"
} else {
"Visible ports allowed"
},
ToastKind::Success,
);
}
PendingAction::BlockPriorityQueue(indexes) => {
let staged = self.controller.block_candidate_indexes(&indexes);
self.selected_candidate = indexes.first().copied().unwrap_or(0);
self.selected_rule = self.controller.rules().len().saturating_sub(1);
self.selected_tab = 2;
self.focus = Focus::Ports;
self.candidate_filter = CandidateFilter::AtRisk;
self.selected_group_filter = None;
self.selected_risk_filter = None;
self.toast(
if staged == 0 {
"Priority queue already blocked"
} else {
"Priority queue blocked"
},
ToastKind::Success,
);
}
PendingAction::HardenHighRisk => {
let staged = self.controller.block_high_risk_candidates();
self.selected_rule = self.controller.rules().len().saturating_sub(1);
self.align_selected_candidate_with_filter();
self.toast(
if staged == 0 {
"High-risk ports already blocked"
} else {
"High-risk ports blocked"
},
ToastKind::Success,
);
}
PendingAction::ClearRules(staged_count) => {
let removed = self.controller.clear_rules();
self.selected_rule = 0;
self.align_selected_candidate_with_filter();
self.toast(
if staged_count == 0 {
"No staged rules to clear"
} else if removed == 0 {
"No staged rules to clear"
} else {
"Staged rules cleared"
},
ToastKind::Warning,
);
}
PendingAction::ResetStarterPlan => {
self.controller.reset_to_starter_plan();
self.selected_rule = self.controller.rules().len().saturating_sub(1);
self.candidate_filter = CandidateFilter::All;
self.selected_group_filter = None;
self.selected_risk_filter = None;
self.align_selected_candidate_with_filter();
self.toast("Starter plan restored", ToastKind::Success);
}
}
}
fn apply_live(&mut self, fingerprint: String, commands: usize) {
let preview = self.controller.live_apply_preview();
if preview.fingerprint != fingerprint || preview.command_count != commands {
self.toast(
"Live apply cancelled: staged plan changed",
ToastKind::Warning,
);
return;
}
let report = self.controller.apply_live();
if report.attempted == 0 || report.failed > 0 {
self.toast(&report.message, ToastKind::Warning);
} else {
self.toast("Live firewall apply completed", ToastKind::Success);
}
}
fn apply_profile(&mut self, profile: FirewallProfile) {
let staged = self.controller.apply_profile(profile);
self.selected_rule = self.controller.rules().len().saturating_sub(1);
self.align_selected_candidate_with_filter();
self.toast(
if staged == 0 {
"Profile already reflected"
} else {
"Profile applied to staged plan"
},
ToastKind::Success,
);
}
fn apply_recommended_profile(&mut self) {
let (profile, staged) = self.controller.apply_recommended_profile();
self.selected_rule = self.controller.rules().len().saturating_sub(1);
self.align_selected_candidate_with_filter();
let message = if staged == 0 {
format!("Recommended {} profile already reflected", profile.label())
} else {
format!("Recommended {} profile applied", profile.label())
};
self.toast(&message, ToastKind::Success);
}
fn apply_safe_recommended_profile(&mut self) {
let Some((profile, staged)) = self.controller.apply_safe_recommended_profile() else {
self.toast(
"No management-preserving profile is available",
ToastKind::Warning,
);
return;
};
self.selected_rule = self.controller.rules().len().saturating_sub(1);
self.align_selected_candidate_with_filter();
let message = if staged == 0 {
format!(
"Management-preserving {} profile already reflected",
profile.label()
)
} else {
format!("Management-preserving {} profile applied", profile.label())
};
self.toast(&message, ToastKind::Success);
}
fn apply_advisor_quick_fix(&mut self) {
let changes = self.controller.apply_advisor_quick_fix();
self.selected_rule = self.controller.rules().len().saturating_sub(1);
self.align_selected_candidate_with_filter();
self.toast(
if changes == 0 {
"Advisor found no quick changes"
} else {
"Advisor quick fix staged"
},
ToastKind::Success,
);
}
fn export_report(&mut self) {
match fs::write(REPORT_PATH, self.controller.dry_run_report()) {
Ok(()) => {
self.controller.record_report_export(REPORT_PATH);
self.toast("Review report exported", ToastKind::Success);
}
Err(_) => self.toast("Review report export failed", ToastKind::Warning),
}
}
fn export_script(&mut self) {
match fs::write(SCRIPT_PATH, self.controller.dry_run_script()) {
Ok(()) => {
self.controller.record_script_export(SCRIPT_PATH);
self.toast("Safe command script exported", ToastKind::Success);
}
Err(_) => self.toast("Command script export failed", ToastKind::Warning),
}
}
fn export_manifest(&mut self) {
match fs::write(MANIFEST_PATH, self.controller.dry_run_manifest()) {
Ok(()) => {
self.controller.record_manifest_export(MANIFEST_PATH);
self.toast("Staged manifest exported", ToastKind::Success);
}
Err(_) => self.toast("Manifest export failed", ToastKind::Warning),
}
}
fn export_bundle(&mut self) {
let report = self.controller.dry_run_report();
let script = self.controller.dry_run_script();
let manifest = self.controller.dry_run_manifest();
let result = fs::write(REPORT_PATH, report)
.and_then(|()| fs::write(SCRIPT_PATH, script))
.and_then(|()| fs::write(MANIFEST_PATH, manifest));
match result {
Ok(()) => {
self.controller.record_bundle_export();
self.toast("Review bundle exported", ToastKind::Success);
}
Err(_) => self.toast("Review bundle export failed", ToastKind::Warning),
}
}
fn cycle_candidate_filter(&mut self) {
self.candidate_filter = self.candidate_filter.next();
self.align_selected_candidate_with_filter();
self.toast("Known-port filter changed", ToastKind::Info);
}
fn cycle_candidate_group_filter(&mut self) {
let groups = self.controller.candidate_groups();
self.selected_group_filter = match self.selected_group_filter {
None if !groups.is_empty() => Some(0),
Some(index) if index + 1 < groups.len() => Some(index + 1),
_ => None,
};
self.align_selected_candidate_with_filter();
self.toast("Known-port group filter changed", ToastKind::Info);
}
fn cycle_candidate_risk_filter(&mut self) {
let risks = self.controller.candidate_risks();
self.selected_risk_filter = match self.selected_risk_filter {
None if !risks.is_empty() => Some(0),
Some(index) if index + 1 < risks.len() => Some(index + 1),
_ => None,
};
self.align_selected_candidate_with_filter();
self.toast("Known-port risk filter changed", ToastKind::Info);
}
fn jump_to_priority_candidate(&mut self) {
let priority = self.controller.priority_ports(1).into_iter().next();
let Some(priority) = priority else {
self.toast("No at-risk priority port remains", ToastKind::Success);
return;
};
self.selected_candidate = priority.index;
self.selected_tab = 2;
self.focus = Focus::Ports;
self.candidate_filter = CandidateFilter::AtRisk;
self.selected_group_filter = None;
self.selected_risk_filter = None;
self.toast(
&format!(
"Priority port selected: {} (score {})",
priority.name, priority.score
),
if priority.status == CandidateStatus::Allowed {
ToastKind::Warning
} else {
ToastKind::Info
},
);
}
fn block_priority_candidate(&mut self) {
let priority = self.controller.priority_ports(1).into_iter().next();
let Some(priority) = priority else {
self.toast("No at-risk priority port remains", ToastKind::Success);
return;
};
self.controller.block_candidate(priority.index);
self.selected_candidate = priority.index;
self.selected_rule = self.controller.rules().len().saturating_sub(1);
self.selected_tab = 2;
self.focus = Focus::Ports;
self.candidate_filter = CandidateFilter::AtRisk;
self.selected_group_filter = None;
self.selected_risk_filter = None;
self.toast(
&format!(
"Blocked priority port: {} (score {})",
priority.name, priority.score
),
ToastKind::Success,
);
}
fn align_selected_candidate_with_filter(&mut self) {
let visible = self.visible_candidate_indexes();
if visible.contains(&self.selected_candidate) {
return;
}
self.selected_candidate = visible.first().copied().unwrap_or(0);
}
fn selected_visible_candidate(&self) -> Option<usize> {
self.visible_candidate_indexes()
.into_iter()
.find(|index| *index == self.selected_candidate)
}
fn visible_candidate_indexes(&self) -> Vec<usize> {
self.controller.filtered_candidate_indexes(
self.candidate_filter,
self.current_group_filter(),
self.current_risk_filter(),
)
}
fn current_group_filter(&self) -> Option<&'static str> {
let index = self.selected_group_filter?;
self.controller.candidate_groups().get(index).copied()
}
fn current_group_label(&self) -> &'static str {
self.current_group_filter().unwrap_or("all-groups")
}
fn current_risk_filter(&self) -> Option<&'static str> {
let index = self.selected_risk_filter?;
self.controller.candidate_risks().get(index).copied()
}
fn current_risk_label(&self) -> &'static str {
self.current_risk_filter().unwrap_or("all-risks")
}
fn block_selected_candidate(&mut self) {
let Some(index) = self.selected_visible_candidate() else {
self.toast("No known port in active filter", ToastKind::Warning);
return;
};
self.controller.block_candidate(index);
self.selected_rule = self.controller.rules().len().saturating_sub(1);
self.align_selected_candidate_with_filter();
self.toast("Block rule staged", ToastKind::Warning);
}
fn allow_selected_candidate(&mut self) {
let Some(index) = self.selected_visible_candidate() else {
self.toast("No known port in active filter", ToastKind::Warning);
return;
};
self.controller.allow_candidate(index);
self.selected_rule = self.controller.rules().len().saturating_sub(1);
self.align_selected_candidate_with_filter();
self.toast("Allow rule staged", ToastKind::Success);
}
fn block_palette_target(&mut self, target: &str) {
match self.controller.block_target(target) {
Ok(result) => {
self.selected_tab = 1;
self.focus = Focus::Rules;
self.selected_rule = self.controller.rules().len().saturating_sub(1);
let message = if result.staged == 0 {
format!("{} already blocked", result.host)
} else {
format!(
"{} blocked with {} address rule(s)",
result.host, result.staged
)
};
self.toast(&message, ToastKind::Success);
}
Err(error) => self.toast(&format!("Target block failed: {error}"), ToastKind::Warning),
}
}
fn toggle_selected_exception(&mut self) {
let Some(index) = self.selected_visible_candidate() else {
self.toast("No known port in active filter", ToastKind::Warning);
return;
};
self.request_toggle_candidate_exception_action(index);
}
fn show_context_pane(&mut self, index: usize) {
if self.controller.candidates().get(index).is_none() {
self.toast("Known port is no longer available", ToastKind::Warning);
return;
}
self.selected_candidate = index;
self.focus = Focus::Ports;
self.context_candidate = Some(index);
}
fn context_candidate_index(&self) -> Option<usize> {
self.context_candidate
.filter(|index| self.controller.candidates().get(*index).is_some())
}
fn request_context_block_candidate(&mut self) {
let Some(index) = self.context_candidate_index() else {
self.toast("No context port selected", ToastKind::Warning);
return;
};
self.context_candidate = None;
self.request_block_candidate_action(index);
}
fn request_context_allow_candidate(&mut self) {
let Some(index) = self.context_candidate_index() else {
self.toast("No context port selected", ToastKind::Warning);
return;
};
self.context_candidate = None;
self.request_allow_candidate_action(index);
}
fn request_context_toggle_candidate(&mut self) {
let Some(index) = self.context_candidate_index() else {
self.toast("No context port selected", ToastKind::Warning);
return;
};
self.context_candidate = None;
self.request_toggle_candidate_action(index);
}
fn request_context_toggle_exception(&mut self) {
let Some(index) = self.context_candidate_index() else {
self.toast("No context port selected", ToastKind::Warning);
return;
};
self.context_candidate = None;
self.request_toggle_candidate_exception_action(index);
}
fn show_context_port_info(&mut self) {
let Some(index) = self.context_candidate_index() else {
self.toast("No context port selected", ToastKind::Warning);
return;
};
self.context_candidate = None;
self.selected_candidate = index;
self.show_port_info_pane();
}
fn show_port_info_pane(&mut self) {
let index = self.selected_visible_candidate().or_else(|| {
self.controller
.candidates()
.get(self.selected_candidate)
.map(|_| self.selected_candidate)
});
let Some(index) = index else {
self.toast("No known port selected for info pane", ToastKind::Warning);
return;
};
self.selected_candidate = index;
self.focus = Focus::Ports;
self.context_candidate = None;
self.toast("Port info pane focused", ToastKind::Info);
}
fn select_port_candidate(&mut self, index: usize, arm_actions: bool) {
if self.controller.candidates().get(index).is_none() {
return;
}
self.selected_candidate = index;
self.focus = Focus::Ports;
if arm_actions {
self.context_candidate = Some(index);
} else if self.context_candidate != Some(index) {
self.context_candidate = None;
}
}
fn request_port_pane_action(&mut self, action: PortPaneAction) {
let index = self
.context_candidate_index()
.or_else(|| self.selected_visible_candidate())
.or_else(|| {
self.controller
.candidates()
.get(self.selected_candidate)
.map(|_| self.selected_candidate)
});
let Some(index) = index else {
self.toast("No known port selected", ToastKind::Warning);
return;
};
self.selected_candidate = index;
self.context_candidate = None;
match action {
PortPaneAction::Block => self.request_block_candidate_action(index),
PortPaneAction::Allow => self.request_allow_candidate_action(index),
PortPaneAction::Toggle => self.request_toggle_candidate_action(index),
PortPaneAction::Exception => self.request_toggle_candidate_exception_action(index),
}
}
fn handle_port_action_click(&mut self, column: u16, row: u16) -> bool {
let Some(area) = self.port_info_area else {
return false;
};
if column < area.x || column >= area.x.saturating_add(area.width) {
return false;
}
let Some((_, action)) = self
.port_action_rows
.iter()
.find(|(action_row, _)| *action_row == row)
.copied()
else {
return false;
};
self.request_port_pane_action(action);
true
}
fn select_tab_and_continue(&mut self, index: usize) -> bool {
self.select_tab(index);
false
}
fn select_tab(&mut self, index: usize) {
self.selected_tab = index.min(TAB_TITLES.len().saturating_sub(1));
self.focus = match self.selected_tab {
1 => Focus::Rules,
2 => Focus::Ports,
3 => Focus::Plan,
4 => Focus::Advisor,
_ => self.focus,
};
}
fn toast(&mut self, message: &str, kind: ToastKind) {
self.toasts.push(
Toast::new(message, kind)
.with_position(OverlayPosition::TopRight)
.with_lifetime(Duration::from_secs(3))
.with_transition(Transition::Fade)
.with_max_width(44),
);
if self.toasts.len() > 3 {
self.toasts.remove(0);
}
}
fn update_overlays(&mut self, delta: Duration) {
self.help_modal.update(delta);
self.safety_popup.update(delta);
self.confirmation_modal.update(delta);
for toast in &mut self.toasts {
toast.update(delta);
}
self.toasts.retain(|toast| !toast.is_expired());
}
fn has_active_timed_state(&self) -> bool {
!self.banner.is_complete()
|| self.help_modal.is_visible()
|| self.safety_popup.is_visible()
|| self.confirmation_modal.is_visible()
|| !self.toasts.is_empty()
}
fn advance_timed_state(&mut self, delta: Duration) -> bool {
let mut changed = false;
if !self.banner.is_complete() {
self.banner.advance();
changed = true;
}
if self.help_modal.is_visible()
|| self.safety_popup.is_visible()
|| self.confirmation_modal.is_visible()
|| !self.toasts.is_empty()
{
self.update_overlays(delta);
changed = true;
}
changed
}
fn move_selection(&mut self, delta: isize) {
match self.focus {
Focus::Rules => {
self.selected_rule =
move_index(self.selected_rule, self.controller.rules().len(), delta);
}
Focus::Ports => {
let visible = self.visible_candidate_indexes();
if visible.is_empty() {
self.selected_candidate = 0;
return;
}
let current = visible
.iter()
.position(|index| *index == self.selected_candidate)
.unwrap_or(0);
self.selected_candidate = visible[move_index(current, visible.len(), delta)];
}
Focus::Plan => {
self.selected_plan =
move_index(self.selected_plan, self.controller.plan().len(), delta);
}
Focus::Advisor => {
self.selected_finding = move_index(
self.selected_finding,
self.controller.advisor_findings().len(),
delta,
);
}
}
}
fn move_selection_to_boundary(&mut self, to_start: bool) {
match self.focus {
Focus::Rules => {
self.selected_rule = if to_start {
0
} else {
self.controller.rules().len().saturating_sub(1)
};
}
Focus::Ports => {
let visible = self.visible_candidate_indexes();
self.selected_candidate = if to_start {
visible.first().copied().unwrap_or(0)
} else {
visible.last().copied().unwrap_or(0)
};
}
Focus::Advisor => {
let findings = self.controller.advisor_findings();
self.selected_finding = if to_start {
0
} else {
findings.len().saturating_sub(1)
};
}
Focus::Plan => {
self.selected_plan = if to_start {
0
} else {
self.controller.plan().len().saturating_sub(1)
};
}
}
}
fn render(&mut self, frame: &mut Frame<'_>) {
let area = frame.area();
self.port_hit_regions.clear();
{
let buffer = frame.buffer();
fill_app_background(buffer, area);
if area.width < 24 || area.height < 8 {
Paragraph::new("Kwall needs at least 24x8 terminal space.")
.with_style(Style::new().fg(WARN))
.with_word_wrap(true)
.render(buffer, area);
return;
}
let header_height = if area.height >= 12 { 4 } else { 3 };
let tabs_height = if area.height >= 10 { 3 } else { 1 };
let footer_height = if area.height >= 14 { 2 } else { 1 };
let body_height = area
.height
.saturating_sub(header_height + tabs_height + footer_height);
let header = Rect::new(0, 0, area.width, header_height);
let tabs = Rect::new(0, header_height, area.width, tabs_height);
let body = Rect::new(0, header_height + tabs_height, area.width, body_height);
let footer = Rect::new(
0,
header_height + tabs_height + body_height,
area.width,
footer_height,
);
self.render_header(buffer, header);
self.render_tabs(buffer, tabs);
self.ports_list_area = None;
self.port_info_area = None;
self.port_hit_rows.clear();
self.port_action_rows.clear();
match self.selected_tab {
1 => self.render_rules(buffer, body),
2 => self.render_ports(buffer, body),
3 => self.render_plan(buffer, body),
4 => self.render_advisor(buffer, body),
_ => self.render_dashboard(buffer, body),
}
self.render_footer(buffer, footer);
self.render_overlays(buffer, area);
}
for region in self.port_hit_regions.iter().cloned() {
frame.register_hit_region(region);
}
}
fn render_dashboard(&mut self, buffer: &mut Buffer, area: Rect) {
if area.height >= 11 {
let overview_height = if area.height >= 18 { 6 } else { 4 };
let overview = Rect::new(area.x, area.y, area.width, overview_height);
let panels = Rect::new(
area.x,
area.y + overview_height,
area.width,
area.height.saturating_sub(overview_height),
);
self.render_overview(buffer, overview);
let (rules, ports, plan) = dashboard_panel_layout(panels, self.focus);
self.render_rules(buffer, rules);
self.render_ports(buffer, ports);
self.render_plan(buffer, plan);
return;
}
let (rules, ports, plan) = dashboard_panel_layout(area, self.focus);
self.render_rules(buffer, rules);
self.render_ports(buffer, ports);
self.render_plan(buffer, plan);
}
fn render_overview(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
let block = Block::new("Overview")
.with_title_right("f cycles pane focus")
.with_bg(PANEL_SOFT)
.border_style(Style::new().fg(ACCENT));
let inner = block.inner(area);
block.render(buffer, area);
if inner.width == 0 || inner.height == 0 {
return;
}
let stats = self.controller.stats();
let advisor_summary = self.controller.advisor_summary();
let gate_summary = self.controller.review_gate_summary();
let recommended_profile = self.controller.recommended_profile();
let safe_profile_label = self
.controller
.safe_recommended_profile()
.map(|recommendation| recommendation.profile.label())
.unwrap_or("none");
let management_access = self.controller.management_access_summary();
let left_width = if inner.width >= 86 {
inner.width / 2
} else {
inner.width
};
let left = Rect::new(inner.x, inner.y, left_width, inner.height);
let right = Rect::new(
inner.x + left_width,
inner.y,
inner.width.saturating_sub(left_width),
inner.height,
);
let summary = format!(
"readiness {}% {} | rec {} | gates {}B/{}W/{}P | advisor {}C/{}W | undo {}",
stats.risk_percent,
stats.readiness_grade(),
recommended_profile.profile.label(),
gate_summary.block,
gate_summary.warn,
gate_summary.pass,
advisor_summary.critical,
advisor_summary.warning,
self.controller.history_depth()
);
buffer.set_str(
left.x as usize + 1,
left.y as usize,
&summary,
TEXT,
Some(PANEL),
);
if left.height > 1 {
draw_meter(
buffer,
Rect::new(left.x + 1, left.y + 1, left.width.saturating_sub(2), 1),
stats.coverage_percent,
"known high-risk blocked",
);
}
if left.height > 2 {
let port_summary = format!(
"known ports: {} blocked | {} open | {} at-risk | mgmt {} blocked/{} open",
stats.known_blocked,
stats.known_open,
stats.at_risk_open,
management_access.blocked,
management_access.open
);
buffer.set_str(
left.x as usize + 1,
left.y as usize + 2,
&port_summary,
DIM,
Some(PANEL_SOFT),
);
}
if left.height > 3 {
let hint = format!(
"profiles: Y strongest {} | y mgmt-safe {} | ports F/G/K",
recommended_profile.profile.label(),
safe_profile_label
);
let hint: String = hint
.chars()
.take(left.width.saturating_sub(2) as usize)
.collect();
buffer.set_str(
left.x as usize + 1,
left.y as usize + 3,
&hint,
DIM,
Some(PANEL_SOFT),
);
}
if left.height > 4 {
buffer.set_str(
left.x as usize + 1,
left.y as usize + 4,
"live apply: L confirms | click/right-click port info | I/O policies | r fix | u undo",
ACCENT,
Some(PANEL_SOFT),
);
}
if right.width == 0 {
return;
}
let mut audit_start = right.y;
if let Some(candidate) = self.controller.candidates().get(self.selected_candidate) {
let status = self
.controller
.candidate_status(self.selected_candidate)
.unwrap_or(CandidateStatus::Open);
let exception = if self
.controller
.candidate_has_review_exception(self.selected_candidate)
{
" | reviewed exception"
} else {
""
};
let selected = format!(
"selected: {} {} {}/{} group {} risk {}{} | filters {}/{}/{}",
candidate.name,
status.label(),
candidate.port,
candidate.protocol,
candidate.group,
candidate.risk,
exception,
self.candidate_filter.label(),
self.current_group_label(),
self.current_risk_label()
);
buffer.set_str(
right.x as usize + 1,
right.y as usize,
&selected,
status_color(status),
Some(PANEL_SOFT),
);
audit_start = right.y + 1;
}
if audit_start < right.bottom() {
let next_action = self.controller.advisor_next_action();
let next = format!(
"next: [{}] {}",
next_action.severity.label(),
next_action.title
);
let display: String = next
.chars()
.take(right.width.saturating_sub(2) as usize)
.collect();
buffer.set_str(
right.x as usize + 1,
audit_start as usize,
&display,
severity_color(next_action.severity),
Some(PANEL_SOFT),
);
audit_start += 1;
}
if audit_start < right.bottom() {
let (priority, color) = self.dashboard_priority_line();
let display: String = priority
.chars()
.take(right.width.saturating_sub(2) as usize)
.collect();
buffer.set_str(
right.x as usize + 1,
audit_start as usize,
&display,
color,
Some(PANEL_SOFT),
);
audit_start += 1;
}
if audit_start < right.bottom() {
let gate = self.controller.next_review_gate();
let line = format!("gate: [{}] {}", gate.status.label(), gate.title);
let display: String = line
.chars()
.take(right.width.saturating_sub(2) as usize)
.collect();
buffer.set_str(
right.x as usize + 1,
audit_start as usize,
&display,
review_gate_color(gate.status),
Some(PANEL_SOFT),
);
audit_start += 1;
}
if audit_start < right.bottom() {
let queue = self.controller.priority_ports(3);
if !queue.is_empty() {
let title = format!(
"queue: {} scored priority port(s) | n blocks next | N jumps",
queue.len()
);
let display: String = title
.chars()
.take(right.width.saturating_sub(2) as usize)
.collect();
buffer.set_str(
right.x as usize + 1,
audit_start as usize,
&display,
ACCENT,
Some(PANEL),
);
audit_start += 1;
for (row, priority) in queue.iter().enumerate() {
if audit_start >= right.bottom() {
break;
}
let line = format!(
"{}. {} score {} {} {}/{} {}",
row + 1,
priority.status.label(),
priority.score,
priority.name,
priority.protocol,
priority.port,
priority.risk
);
let display: String = line
.chars()
.take(right.width.saturating_sub(2) as usize)
.collect();
buffer.set_str(
right.x as usize + 1,
audit_start as usize,
&display,
status_color(priority.status),
Some(PANEL_SOFT),
);
audit_start += 1;
}
}
}
let audit_rows = right.bottom().saturating_sub(audit_start) as usize;
for (row, message) in self
.controller
.audit_log()
.iter()
.rev()
.take(audit_rows)
.enumerate()
{
let y = audit_start + row as u16;
if y >= right.bottom() {
break;
}
let display: String = message
.chars()
.take(right.width.saturating_sub(2) as usize)
.collect();
buffer.set_str(right.x as usize + 1, y as usize, &display, DIM, Some(PANEL));
}
}
fn dashboard_priority_line(&self) -> (String, Color) {
let stats = self.controller.stats();
if let Some(priority) = self.controller.priority_ports(1).into_iter().next() {
let color = if priority.status == CandidateStatus::Allowed {
WARN
} else {
status_color(priority.status)
};
return (
format!(
"priority: {} score {} {} {}/{} {} - {}",
priority.status.label(),
priority.score,
priority.name,
priority.protocol,
priority.port,
priority.risk,
priority.reason
),
color,
);
}
(
format!(
"priority: {} {}% {} - review export bundle with E",
stats.readiness_grade(),
stats.risk_percent,
stats.readiness_label()
),
OK,
)
}
fn render_header(&self, buffer: &mut Buffer, area: Rect) {
let block = Block::new("Kwall")
.with_title_right("scrin + Aisling")
.with_bg(PANEL_SOFT)
.border_style(Style::new().fg(ACCENT));
let inner = block.inner(area);
block.render(buffer, area);
if inner.height >= 2 {
self.banner.render_to_buffer(buffer, inner);
let y = inner.y as usize;
buffer.set_str_bold(inner.x as usize + 1, y, "Kwall", ACCENT, Some(PANEL_SOFT));
buffer.set_str(
inner.x as usize + 1,
y + 1,
"ufw-style firewall control with guarded live apply",
TEXT,
Some(PANEL_SOFT),
);
} else {
buffer.set_str(
inner.x as usize,
inner.y as usize,
"guarded firewall planner",
TEXT,
Some(PANEL_SOFT),
);
}
if inner.width > 54 && inner.height >= 3 {
let note = "staged rules | L live apply | exports inert";
buffer.set_str(
inner.x as usize + inner.width.saturating_sub(note.len() as u16 + 2) as usize,
inner.y as usize + inner.height.saturating_sub(1) as usize,
note,
DIM,
Some(PANEL_SOFT),
);
}
}
fn render_tabs(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
buffer.fill(area, ' ', TEXT, Some(BG));
let inner = if area.height >= 3 {
let block = Block::new("Pages")
.with_bg(BG)
.border_style(Style::new().fg(BORDER));
let inner = block.inner(area);
block.render(buffer, area);
inner
} else {
area
};
Tabs::new(&TAB_TITLES)
.with_selected(self.selected_tab)
.with_style(Style::new().fg(DIM))
.with_highlight_style(Style::new().fg(ACCENT).bold().underlined())
.render(
buffer,
Rect::new(inner.x + 1, inner.y, inner.width.saturating_sub(2), 1),
);
}
fn render_rules(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
let total_rules = self.controller.rules().len();
let rule_position = if total_rules == 0 {
"0/0".to_string()
} else {
format!(
"{}/{}",
self.selected_rule.min(total_rules.saturating_sub(1)) + 1,
total_rules
)
};
let title = format!(
"Rules {} {}",
focus_marker(self.focus == Focus::Rules),
rule_position
);
let block = panel_block(&title, self.focus == Focus::Rules);
let inner = block.inner(area);
block.render(buffer, area);
if inner.width == 0 || inner.height == 0 {
return;
}
if self.controller.rules().is_empty() {
Paragraph::new(
"No staged rules yet. Select a service port and press b to block or a to allow.",
)
.with_word_wrap(true)
.with_style(Style::new().fg(DIM))
.render(buffer, inner);
return;
}
let items = self
.controller
.rules()
.iter()
.map(|rule| {
let action_color = match rule.action {
RuleAction::Allow => OK,
RuleAction::Deny | RuleAction::Reject => WARN,
};
ListItem::with_line(Line::new(vec![
Span::styled(&format!("#{:02} ", rule.id), Style::new().fg(DIM)),
Span::styled(rule.action.label(), Style::new().fg(action_color).bold()),
Span::styled(
&format!(
" {} {} {} -> {} ",
rule.direction.label(),
rule.protocol,
rule.port,
rule.target
),
Style::new().fg(TEXT),
),
Span::styled(
if rule.enabled { "on " } else { "off " },
Style::new().fg(if rule.enabled { ACCENT } else { DIM }),
),
Span::styled(&rule.note, Style::new().fg(DIM)),
]))
})
.collect::<Vec<_>>();
let selected = self.selected_rule.min(items.len().saturating_sub(1));
List::new(&items)
.with_selected(selected)
.with_highlight_symbol("> ")
.with_highlight_style(selected_list_style(self.focus == Focus::Rules))
.render(buffer, inner);
}
fn render_ports(&mut self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
let visible = self.visible_candidate_indexes();
let selector = if visible.is_empty() {
"0/0".to_string()
} else {
let selected = visible
.iter()
.position(|index| *index == self.selected_candidate)
.unwrap_or(0);
format!("{}/{}", selected + 1, visible.len())
};
let title = format!(
"Known Ports {} [{}] filter {} | group {} | risk {} | F/G/K | X exception",
focus_marker(self.focus == Focus::Ports),
selector,
self.candidate_filter.label(),
self.current_group_label(),
self.current_risk_label()
);
let block = panel_block(&title, self.focus == Focus::Ports);
let inner = block.inner(area);
block.render(buffer, area);
if inner.width == 0 || inner.height == 0 {
return;
}
if visible.is_empty() {
Paragraph::new(
"No known ports match these filters. Press F, G, or K to cycle filters.",
)
.with_word_wrap(true)
.with_style(Style::new().fg(DIM))
.render(buffer, inner);
return;
}
let list_area = if inner.height > 4 {
let summary = self.group_coverage_line(inner.width as usize);
buffer.set_str(
inner.x as usize,
inner.y as usize,
&summary,
DIM,
Some(PANEL),
);
let risk_summary = self.risk_coverage_line(inner.width as usize);
buffer.set_str(
inner.x as usize,
inner.y as usize + 1,
&risk_summary,
DIM,
Some(PANEL),
);
Rect::new(
inner.x,
inner.y + 3,
inner.width,
inner.height.saturating_sub(3),
)
} else if inner.height > 3 {
let summary = self.group_coverage_line(inner.width as usize);
buffer.set_str(
inner.x as usize,
inner.y as usize,
&summary,
DIM,
Some(PANEL),
);
Rect::new(
inner.x,
inner.y + 2,
inner.width,
inner.height.saturating_sub(2),
)
} else {
inner
};
if list_area.height == 0 {
return;
}
let (list_area, info_area) = port_content_layout(list_area);
if let Some(info_area) = info_area {
self.render_port_info_pane(buffer, info_area);
}
if list_area.height == 0 || list_area.width == 0 {
return;
}
self.ports_list_area = Some(list_area);
self.port_hit_rows = visible
.iter()
.take(list_area.height as usize)
.enumerate()
.map(|(row, index)| (list_area.y + row as u16, *index))
.collect();
for (visible_row, (row, index)) in self.port_hit_rows.iter().enumerate() {
if let Some(candidate) = self.controller.candidates().get(*index) {
self.port_hit_regions.push(
HitRegion::new(
port_candidate_widget_id(*index),
Rect::new(list_area.x, *row, list_area.width, 1),
)
.with_role(WidgetRole::ListItem)
.with_label(candidate.name)
.with_tooltip("Click selects this port; right-click arms pane actions")
.with_action(WidgetAction::Select)
.with_cursor(MouseCursor::Pointer)
.with_row(visible_row),
);
}
}
let items = visible
.iter()
.map(|index| {
let candidate = &self.controller.candidates()[*index];
let status = self
.controller
.candidate_status(*index)
.unwrap_or(CandidateStatus::Open);
let risk = self.controller.candidate_risk_factor(*index);
let score = risk.as_ref().map(|risk| risk.score).unwrap_or(0);
let reviewed = self.controller.candidate_has_review_exception(*index);
let action = port_row_action_label(status, score, reviewed);
let risk_chip = risk
.as_ref()
.map(|risk| format!("{:>3} {:<4}", risk.score, risk_band_short(risk.band)))
.unwrap_or_else(|| " - low ".into());
ListItem::with_line(Line::new(vec![
Span::styled(
&format!(" {:<7}", status.label()),
Style::new()
.fg(status_color(status))
.bg(status_bg(status, score))
.bold(),
),
Span::styled(
&format!(" {:<9}", risk_chip),
Style::new()
.fg(risk_score_color(score))
.bg(risk_score_bg(score))
.bold(),
),
Span::styled(
&format!(" {:<13}", candidate.name),
Style::new().fg(TEXT).bold(),
),
Span::styled(
&format!(" {:>7}/{:<3} ", candidate.port, candidate.protocol),
Style::new().fg(ACCENT),
),
Span::styled(&format!("{:<12}", candidate.group), Style::new().fg(DIM)),
Span::styled(
&format!(" {:<12}", action),
Style::new()
.fg(action_color(status, score, reviewed))
.bold(),
),
Span::styled(candidate.detail, Style::new().fg(DIM)),
]))
})
.collect::<Vec<_>>();
let selected = visible
.iter()
.position(|index| *index == self.selected_candidate)
.unwrap_or(0);
List::new(&items)
.with_selected(selected)
.with_highlight_symbol("> ")
.with_highlight_style(selected_list_style(self.focus == Focus::Ports))
.render(buffer, list_area);
}
fn render_port_info_pane(&mut self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
self.port_info_area = Some(area);
let armed = self.context_candidate_index();
let pane_bg = if armed.is_some() {
PANEL_HOT
} else {
PANEL_SOFT
};
let title = if armed.is_some() {
"Port Actions"
} else {
"Port Info"
};
let block = Block::new(title)
.with_title_right(if armed.is_some() {
"actions armed"
} else {
"click/select updates"
})
.with_bg(pane_bg)
.border_style(Style::new().fg(if armed.is_some() {
ACCENT_2
} else {
BORDER_SOFT
}));
let inner = block.inner(area);
block.render(buffer, area);
if inner.width == 0 || inner.height == 0 {
return;
}
let index = armed
.or_else(|| self.selected_visible_candidate())
.or_else(|| {
self.controller
.candidates()
.get(self.selected_candidate)
.map(|_| self.selected_candidate)
});
let Some(index) = index else {
Paragraph::new("Select or click a known port to inspect it here.")
.with_word_wrap(true)
.with_style(Style::new().fg(DIM))
.render(buffer, inner);
return;
};
let Some(candidate) = self.controller.candidates().get(index) else {
return;
};
let status = self
.controller
.candidate_status(index)
.unwrap_or(CandidateStatus::Open);
let risk = self.controller.candidate_risk_factor(index);
let score = risk.as_ref().map(|risk| risk.score).unwrap_or(0);
let reviewed = if self.controller.candidate_has_review_exception(index) {
"reviewed exception"
} else {
"no exception"
};
let reviewed_exception = reviewed.starts_with("reviewed");
let mut y = inner.y;
write_clipped(
buffer,
inner.x,
y,
inner.width,
&format!(
"service {} | {} {}/{}",
candidate.name,
status.label(),
candidate.port,
candidate.protocol
),
status_color(status),
pane_bg,
);
y += 1;
if y < inner.bottom() {
let risk_label = risk
.as_ref()
.map(|risk| format!("score {} ({})", risk.score, risk.band))
.unwrap_or_else(|| "score unavailable".into());
write_clipped(
buffer,
inner.x,
y,
inner.width,
&format!(
"{} | risk {} | {}",
candidate.group, candidate.risk, risk_label
),
risk_score_color(score),
pane_bg,
);
y += 1;
}
if y < inner.bottom() {
write_clipped(
buffer,
inner.x,
y,
inner.width,
reviewed,
if reviewed.starts_with("reviewed") {
OK
} else {
DIM
},
pane_bg,
);
y += 1;
}
if let Some(risk) = risk.as_ref() {
if y < inner.bottom() {
write_clipped(buffer, inner.x, y, inner.width, &risk.reason, DIM, pane_bg);
y += 1;
}
if y < inner.bottom() {
write_clipped(
buffer,
inner.x,
y,
inner.width,
&risk.recommendation,
if risk.score >= 65 { WARN } else { ACCENT },
pane_bg,
);
y += 1;
}
} else if y < inner.bottom() {
write_clipped(
buffer,
inner.x,
y,
inner.width,
candidate.detail,
DIM,
pane_bg,
);
y += 1;
}
if y < inner.bottom() {
write_clipped(
buffer,
inner.x,
y,
inner.width,
&format!(
"defaults in {} out {} | backend {}",
self.controller.default_incoming().label(),
self.controller.default_outgoing().label(),
self.controller.backend()
),
DIM,
pane_bg,
);
y += 1;
}
if y + 1 < inner.bottom() {
y += 1;
}
if y < inner.bottom() {
let hint = if armed.is_some() {
"select an action; changes stage before live apply"
} else {
"right-click row or click action; L applies live"
};
write_clipped(buffer, inner.x, y, inner.width, hint, ACCENT, pane_bg);
y += 1;
}
for action in [
PortPaneAction::Block,
PortPaneAction::Allow,
PortPaneAction::Toggle,
PortPaneAction::Exception,
] {
if y >= inner.bottom() {
break;
}
let (label, color) = port_action_label(action, status, score, reviewed_exception);
write_clipped(buffer, inner.x, y, inner.width, &label, color, pane_bg);
self.port_action_rows.push((y, action));
self.port_hit_regions.push(
HitRegion::new(
port_action_widget_id(action),
Rect::new(inner.x, y, inner.width, 1),
)
.with_role(WidgetRole::Button)
.with_label(label)
.with_tooltip("Click to run this action through confirmation")
.with_action(WidgetAction::Custom(port_action_id(action).into()))
.with_cursor(MouseCursor::Pointer),
);
y += 1;
}
}
fn group_coverage_line(&self, width: usize) -> String {
let coverage = self.controller.group_coverage_summaries();
let text = if let Some(group) = self.current_group_filter() {
coverage
.iter()
.find(|summary| summary.group == group)
.map(|summary| {
format!(
"group coverage: {} {}/{} blocked, {} allowed, {} open, {} at-risk open",
summary.group,
summary.blocked,
summary.total,
summary.allowed,
summary.open,
summary.at_risk_open
)
})
.unwrap_or_else(|| "group coverage: no selected group".into())
} else {
let groups = coverage
.iter()
.take(4)
.map(|summary| format!("{} {}/{}", summary.group, summary.blocked, summary.total))
.collect::<Vec<_>>()
.join(" | ");
format!("group coverage: {groups}")
};
text.chars().take(width).collect()
}
fn risk_coverage_line(&self, width: usize) -> String {
let coverage = self.controller.risk_coverage_summaries();
let text = if let Some(risk) = self.current_risk_filter() {
coverage
.iter()
.find(|summary| summary.risk == risk)
.map(|summary| {
format!(
"risk exposure: {} {}/{} blocked, {} allowed, {} open",
summary.risk, summary.blocked, summary.total, summary.allowed, summary.open
)
})
.unwrap_or_else(|| "risk exposure: no selected risk".into())
} else {
let risks = coverage
.iter()
.take(5)
.map(|summary| format!("{} {} open", summary.risk, summary.open))
.collect::<Vec<_>>()
.join(" | ");
format!("risk exposure: {risks}")
};
text.chars().take(width).collect()
}
fn render_plan(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
let plan = self.controller.plan();
let title = format!(
"Plan {} [{} commands, selected {}] L live apply | o report",
focus_marker(self.focus == Focus::Plan),
plan.len(),
selected_position_label(self.selected_plan, plan.len())
);
let block = panel_block(&title, self.focus == Focus::Plan);
let inner = block.inner(area);
block.render(buffer, area);
if inner.width == 0 || inner.height == 0 {
return;
}
let fingerprint = self.controller.plan_fingerprint();
let backend_summary = self
.controller
.backend_plan_summaries()
.iter()
.map(|summary| format!("{} {}", summary.backend, summary.commands))
.collect::<Vec<_>>()
.join(" | ");
let profile_summary = self
.controller
.profile_impact_summaries()
.iter()
.map(|impact| {
format!(
"{} +{} mgmt {}{}",
impact.profile.label(),
impact.staged_changes,
impact.management_allowed + impact.management_open,
if impact.management_all_blocked {
"!"
} else {
""
}
)
})
.collect::<Vec<_>>()
.join(" | ");
let recommended_profile = self.controller.recommended_profile();
let recommended_warning = recommended_profile
.warning
.as_deref()
.unwrap_or("management continuity ok");
let safe_recommended_profile = self.controller.safe_recommended_profile();
let safe_profile_summary = safe_recommended_profile
.as_ref()
.map(|recommendation| {
format!(
"safe alternative: {} score {} | {} management path(s) stay open/allowed",
recommendation.profile.label(),
recommendation.score,
recommendation.management_allowed + recommendation.management_open
)
})
.unwrap_or_else(|| "safe alternative: none preserves management access".into());
let recommended_changes = self.controller.recommended_profile_changes();
let safe_recommended_changes = self.controller.safe_recommended_profile_changes();
let recommended_change_summary = recommended_changes
.iter()
.take(3)
.map(|change| {
format!(
"{} {}->{}",
change.name,
change.before.label(),
change.after.label()
)
})
.collect::<Vec<_>>()
.join(" | ");
let safe_change_summary = safe_recommended_changes
.iter()
.take(3)
.map(|change| {
format!(
"{} {}->{}",
change.name,
change.before.label(),
change.after.label()
)
})
.collect::<Vec<_>>()
.join(" | ");
if inner.width >= 58 && inner.height >= 10 {
let stats = self.controller.stats();
let meta = format!(
"id {} | backend {} | defaults in {} out {} | logging {} | commands {}",
fingerprint,
self.controller.backend(),
self.controller.default_incoming().label(),
self.controller.default_outgoing().label(),
if self.controller.logging() {
"on"
} else {
"off"
},
stats.plan_commands
);
buffer.set_str(inner.x as usize, inner.y as usize, &meta, TEXT, Some(PANEL));
buffer.set_str(
inner.x as usize,
inner.y as usize + 2,
"Press L to apply live after confirmation; exported artifacts stay inert.",
ACCENT,
Some(PANEL),
);
buffer.set_str(
inner.x as usize,
inner.y as usize + 3,
&format!("backend comparison: {backend_summary}"),
DIM,
Some(PANEL),
);
buffer.set_str(
inner.x as usize,
inner.y as usize + 4,
&format!(
"recommended profile: {} score {} | {}",
recommended_profile.profile.label(),
recommended_profile.score,
recommended_warning
),
if recommended_profile.management_all_blocked {
WARN
} else {
DIM
},
Some(PANEL),
);
if inner.height >= 11 {
buffer.set_str(
inner.x as usize,
inner.y as usize + 5,
&safe_profile_summary,
if safe_recommended_profile.is_some() {
DIM
} else {
WARN
},
Some(PANEL),
);
}
if inner.height >= 12 {
buffer.set_str(
inner.x as usize,
inner.y as usize + 6,
&format!(
"recommended changes: {} | {}",
recommended_changes.len(),
recommended_change_summary
),
DIM,
Some(PANEL),
);
}
if inner.height >= 13 {
buffer.set_str(
inner.x as usize,
inner.y as usize + 7,
&format!("profile impact: {profile_summary}"),
DIM,
Some(PANEL),
);
}
let reason_width = if inner.width >= 92 { 24 } else { 16 };
let index_width = 4;
let backend_width = 9;
let command_width = inner
.width
.saturating_sub(index_width + backend_width + reason_width + 4)
.max(12);
let widths = [index_width, backend_width, command_width, reason_width];
let header = TableRow::new(vec![
TableCell::new("#").with_style(Style::new().fg(ACCENT).bold()),
TableCell::new("backend").with_style(Style::new().fg(ACCENT).bold()),
TableCell::new("command").with_style(Style::new().fg(ACCENT).bold()),
TableCell::new("reason").with_style(Style::new().fg(ACCENT).bold()),
]);
let table_area = Rect::new(
inner.x,
inner.y + 9,
inner.width,
inner.height.saturating_sub(9),
);
let selected = self.selected_plan.min(plan.len().saturating_sub(1));
let row_capacity = table_area.height.saturating_sub(1) as usize;
let offset = selected_scroll_offset(selected, plan.len(), row_capacity);
let rows = plan
.iter()
.enumerate()
.skip(offset)
.take(row_capacity.max(1))
.enumerate()
.map(|(_, (index, command))| {
TableRow::new(vec![
TableCell::new(&format!("{:02}", index + 1))
.with_style(Style::new().fg(DIM)),
TableCell::new(&command.backend.to_string())
.with_style(Style::new().fg(OK)),
TableCell::new(&command.shell_line()).with_style(Style::new().fg(TEXT)),
TableCell::new(&command.reason).with_style(Style::new().fg(DIM)),
])
})
.collect::<Vec<_>>();
let mut table = Table::new(&rows, &widths)
.with_header(&header)
.with_selected(selected.saturating_sub(offset));
table.selected_style = selected_table_style();
table.render(buffer, table_area);
return;
}
let mut lines = vec![
format!("review id: {fingerprint}"),
format!("mode: {}", self.controller.mode()),
format!("backend: {}", self.controller.backend()),
format!("backend comparison: {backend_summary}"),
format!(
"recommended profile: {} score {} - {} | {} change(s)",
recommended_profile.profile.label(),
recommended_profile.score,
recommended_warning,
recommended_changes.len()
),
safe_profile_summary,
format!("recommended changes: {recommended_change_summary}"),
format!(
"safe changes: {} | {}",
safe_recommended_changes.len(),
safe_change_summary
),
format!("profile impact: {profile_summary}"),
format!(
"defaults: incoming {}, outgoing {}, logging {}",
self.controller.default_incoming().label(),
self.controller.default_outgoing().label(),
if self.controller.logging() {
"on"
} else {
"off"
}
),
"".into(),
"Press L to apply live after confirmation; exported artifacts stay inert.".into(),
"".into(),
];
let available_command_rows = inner.height.saturating_sub(lines.len() as u16) as usize;
let command_limit = (available_command_rows / 2).max(1);
let selected = self.selected_plan.min(plan.len().saturating_sub(1));
let offset = selected_scroll_offset(selected, plan.len(), command_limit);
for (index, command) in plan.iter().enumerate().skip(offset).take(command_limit) {
let marker = if index == selected { ">" } else { "$" };
lines.push(format!("{marker} {}", command.shell_line()));
lines.push(format!(" # {}", command.reason));
}
if plan.len().saturating_sub(offset) > command_limit {
lines.push(format!(
"... {} more planned commands",
plan.len() - offset - command_limit
));
}
let text = lines.join("\n");
Paragraph::new(&text)
.with_word_wrap(true)
.with_style(Style::new().fg(TEXT))
.render(buffer, inner);
}
fn render_advisor(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
let findings = self.controller.advisor_findings();
let finding_position = if findings.is_empty() {
"0/0".to_string()
} else {
format!(
"{}/{}",
self.selected_finding.min(findings.len().saturating_sub(1)) + 1,
findings.len()
)
};
let title = format!(
"Advisor {} [{}] r quick fix | u undo",
focus_marker(self.focus == Focus::Advisor),
finding_position
);
let block = panel_block(&title, self.focus == Focus::Advisor);
let inner = block.inner(area);
block.render(buffer, area);
if inner.width == 0 || inner.height == 0 {
return;
}
if inner.width >= 72 && inner.height >= 8 {
let header = TableRow::new(vec![
TableCell::new("level").with_style(Style::new().fg(ACCENT).bold()),
TableCell::new("finding").with_style(Style::new().fg(ACCENT).bold()),
TableCell::new("recommendation").with_style(Style::new().fg(ACCENT).bold()),
]);
let level_width = 10;
let rec_width = if inner.width >= 110 { 38 } else { 28 };
let title_width = inner
.width
.saturating_sub(level_width + rec_width + 3)
.max(18);
let widths = [level_width, title_width, rec_width];
let rows = findings
.iter()
.map(|finding| {
TableRow::new(vec![
TableCell::new(finding.severity.label())
.with_style(Style::new().fg(severity_color(finding.severity)).bold()),
TableCell::new(&finding.title).with_style(Style::new().fg(TEXT)),
TableCell::new(&finding.recommendation).with_style(Style::new().fg(DIM)),
])
})
.collect::<Vec<_>>();
let selected = self.selected_finding.min(rows.len().saturating_sub(1));
let mut table = Table::new(&rows, &widths)
.with_header(&header)
.with_selected(selected);
table.selected_style = selected_table_style();
table.render(buffer, inner);
return;
}
let items = findings
.iter()
.map(|finding| {
ListItem::with_line(Line::new(vec![
Span::styled(
&format!("{:<8}", finding.severity.label()),
Style::new().fg(severity_color(finding.severity)).bold(),
),
Span::styled(&finding.title, Style::new().fg(TEXT)),
]))
})
.collect::<Vec<_>>();
let selected = self.selected_finding.min(items.len().saturating_sub(1));
List::new(&items)
.with_selected(selected)
.with_highlight_symbol("> ")
.with_highlight_style(selected_list_style(self.focus == Focus::Advisor))
.render(buffer, inner);
}
fn render_footer(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
let mut status = StatusBar::new()
.with_position(StatusBarPosition::Bottom)
.with_bg(Color::rgb(3, 7, 18));
status.height = area.height;
status.set_left("STAGED/LIVE GUARD", ACCENT);
status.set_center(self.controller.last_message(), DIM);
let right = if self.confirmation_modal.is_visible() {
"Confirmation: Enter/y advances twice | n/Esc cancels | Arrow keys move"
} else {
"L live apply | click/right-click ports | N priority | F/G/K filters | E bundle | : palette"
};
if self.show_frame_metrics {
let timing = self.last_frame_timing.unwrap_or(FrameTiming {
elapsed: Duration::from_nanos(0),
bytes_written: 0,
dirty_cells: 0,
areas: 0,
full_repaint: false,
});
let metrics = format!(
"{} | frame {}µs | {}B/cells {}",
right,
timing.elapsed.as_micros(),
timing.bytes_written,
timing.dirty_cells
);
status.set_right(&metrics, OK);
} else {
status.set_right(right, OK);
}
status.render(buffer, area);
}
fn render_overlays(&mut self, buffer: &mut Buffer, area: Rect) {
self.safety_popup.adapt_to_terminal(area.width, area.height);
self.safety_popup.render(buffer, area);
self.help_modal.render(buffer, area);
self.command_palette.render(buffer, area);
self.confirmation_modal.render(buffer, area);
for toast in self.toasts.iter().rev().take(1) {
toast.render(buffer, area);
}
}
}
fn dashboard_panel_layout(area: Rect, focus: Focus) -> (Rect, Rect, Rect) {
if area.width >= 90 {
let focused = area.width * 48 / 100;
let side_total = area.width.saturating_sub(focused);
let side = side_total / 2;
let last = side_total.saturating_sub(side);
return match focus {
Focus::Rules => (
Rect::new(area.x, area.y, focused, area.height),
Rect::new(area.x + focused, area.y, side, area.height),
Rect::new(area.x + focused + side, area.y, last, area.height),
),
Focus::Ports => (
Rect::new(area.x, area.y, side, area.height),
Rect::new(area.x + side, area.y, focused, area.height),
Rect::new(area.x + side + focused, area.y, last, area.height),
),
Focus::Plan | Focus::Advisor => (
Rect::new(area.x, area.y, side, area.height),
Rect::new(area.x + side, area.y, last, area.height),
Rect::new(area.x + side + last, area.y, focused, area.height),
),
};
}
let focused = area.height * 50 / 100;
let side_total = area.height.saturating_sub(focused);
let side = side_total / 2;
let last = side_total.saturating_sub(side);
match focus {
Focus::Rules => (
Rect::new(area.x, area.y, area.width, focused),
Rect::new(area.x, area.y + focused, area.width, side),
Rect::new(area.x, area.y + focused + side, area.width, last),
),
Focus::Ports => (
Rect::new(area.x, area.y, area.width, side),
Rect::new(area.x, area.y + side, area.width, focused),
Rect::new(area.x, area.y + side + focused, area.width, last),
),
Focus::Plan | Focus::Advisor => (
Rect::new(area.x, area.y, area.width, side),
Rect::new(area.x, area.y + side, area.width, last),
Rect::new(area.x, area.y + side + last, area.width, focused),
),
}
}
fn panel_block<'a>(title: &'a str, focused: bool) -> Block<'a> {
let color = if focused { FOCUS } else { BORDER };
Block::new(title)
.with_bg(PANEL)
.border_style(Style::new().fg(color))
}
fn selected_list_style(focused: bool) -> Style {
Style::new()
.fg(Color::WHITE)
.bg(if focused { SELECTED_BG } else { BORDER_SOFT })
.bold()
}
fn selected_table_style() -> Style {
Style::new().fg(Color::WHITE).bg(SELECTED_BG).bold()
}
fn selected_position_label(selected: usize, len: usize) -> String {
if len == 0 {
"0/0".into()
} else {
format!("{}/{}", selected.min(len.saturating_sub(1)) + 1, len)
}
}
fn selected_scroll_offset(selected: usize, len: usize, visible: usize) -> usize {
if len == 0 || visible == 0 {
return 0;
}
let selected = selected.min(len.saturating_sub(1));
selected
.saturating_add(1)
.saturating_sub(visible)
.min(len.saturating_sub(visible))
}
fn port_content_layout(area: Rect) -> (Rect, Option<Rect>) {
if area.width >= 76 && area.height >= 4 {
let gap = 1;
let info_width = (area.width / 3).max(28).min(44);
if area.width > info_width + gap + 20 {
let list_width = area.width.saturating_sub(info_width + gap);
return (
Rect::new(area.x, area.y, list_width, area.height),
Some(Rect::new(
area.x + list_width + gap,
area.y,
info_width,
area.height,
)),
);
}
}
if area.height >= 8 {
let gap = 1;
let info_height = (area.height / 3).max(4).min(7);
if area.height > info_height + gap + 2 {
let list_height = area.height.saturating_sub(info_height + gap);
return (
Rect::new(area.x, area.y, area.width, list_height),
Some(Rect::new(
area.x,
area.y + list_height + gap,
area.width,
info_height,
)),
);
}
}
(area, None)
}
fn write_clipped(
buffer: &mut Buffer,
x: u16,
y: u16,
width: u16,
text: &str,
fg: Color,
bg: Color,
) {
if width == 0 {
return;
}
let display: String = text.chars().take(width as usize).collect();
buffer.set_str(x as usize, y as usize, &display, fg, Some(bg));
}
fn port_candidate_widget_id(index: usize) -> String {
format!("kwall.port.{index}")
}
fn port_action_widget_id(action: PortPaneAction) -> String {
format!("kwall.port.action.{}", port_action_id(action))
}
fn port_action_id(action: PortPaneAction) -> &'static str {
match action {
PortPaneAction::Block => "block",
PortPaneAction::Allow => "allow",
PortPaneAction::Toggle => "toggle",
PortPaneAction::Exception => "exception",
}
}
fn port_action_from_id(id: &str) -> Option<PortPaneAction> {
match id {
"block" => Some(PortPaneAction::Block),
"allow" => Some(PortPaneAction::Allow),
"toggle" => Some(PortPaneAction::Toggle),
"exception" => Some(PortPaneAction::Exception),
_ => None,
}
}
fn port_action_label(
action: PortPaneAction,
status: CandidateStatus,
score: u8,
reviewed_exception: bool,
) -> (String, Color) {
match action {
PortPaneAction::Block => {
if status == CandidateStatus::Blocked {
("[b] keep blocked".into(), OK)
} else {
("[b] block incoming service".into(), OK)
}
}
PortPaneAction::Allow => {
let color = if score >= 65 { WARN } else { ALLOW };
if status == CandidateStatus::Allowed {
("[a] already allowed".into(), color)
} else {
("[a] allow service".into(), color)
}
}
PortPaneAction::Toggle => {
if status == CandidateStatus::Blocked {
("[d] unblock service".into(), CAUTION)
} else {
("[d] block service".into(), OK)
}
}
PortPaneAction::Exception => {
if reviewed_exception {
("[x] remove review exception".into(), CAUTION)
} else {
("[x] mark reviewed exception".into(), CAUTION)
}
}
}
}
fn port_row_action_label(
status: CandidateStatus,
score: u8,
reviewed_exception: bool,
) -> &'static str {
match status {
CandidateStatus::Blocked => "protected",
CandidateStatus::Allowed if reviewed_exception => "documented",
CandidateStatus::Allowed if score >= 65 => "review X",
CandidateStatus::Allowed => "allowed",
CandidateStatus::Open if score >= 65 => "block now",
CandidateStatus::Open if score >= 35 => "decide",
CandidateStatus::Open => "monitor",
}
}
fn action_color(status: CandidateStatus, score: u8, reviewed_exception: bool) -> Color {
match status {
CandidateStatus::Blocked => OK,
CandidateStatus::Allowed if reviewed_exception => OK,
CandidateStatus::Allowed if score >= 65 => WARN,
CandidateStatus::Allowed => ALLOW,
CandidateStatus::Open if score >= 65 => WARN,
CandidateStatus::Open if score >= 35 => CAUTION,
CandidateStatus::Open => OPEN_PORT,
}
}
fn risk_band_short(band: &str) -> &str {
match band {
"critical" => "crit",
"medium" => "med",
"low" => "low",
other => other,
}
}
fn risk_score_color(score: u8) -> Color {
match score {
80..=100 => WARN,
60..=79 => Color::rgb(255, 132, 76),
35..=59 => CAUTION,
1..=34 => ACCENT,
_ => DIM,
}
}
fn risk_score_bg(score: u8) -> Color {
match score {
80..=100 => Color::rgb(55, 18, 25),
60..=79 => Color::rgb(50, 28, 18),
35..=59 => Color::rgb(43, 35, 17),
1..=34 => Color::rgb(16, 34, 45),
_ => Color::rgb(17, 24, 35),
}
}
fn port_pointer_target(id: &WidgetId) -> Option<PortPointerTarget> {
let value = id.as_ref();
if let Some(action) = value.strip_prefix("kwall.port.action.") {
return port_action_from_id(action).map(PortPointerTarget::Action);
}
value
.strip_prefix("kwall.port.")
.and_then(|index| index.parse::<usize>().ok())
.map(PortPointerTarget::Candidate)
}
fn pointer_event_from_mouse(mouse: MouseEvent) -> Option<PointerEvent> {
let kind = match mouse.kind {
MouseEventKind::Down(button) => PointerEventKind::Down(scrin_mouse_button(button)?),
MouseEventKind::Drag(button) => PointerEventKind::Drag(scrin_mouse_button(button)?),
MouseEventKind::Up(button) => PointerEventKind::Up(scrin_mouse_button(button)?),
_ => return None,
};
Some(PointerEvent::new(kind, mouse.column, mouse.row))
}
fn scrin_mouse_button(button: CrosstermMouseButton) -> Option<ScrinMouseButton> {
match button {
CrosstermMouseButton::Left => Some(ScrinMouseButton::Left),
CrosstermMouseButton::Right => Some(ScrinMouseButton::Right),
CrosstermMouseButton::Middle => Some(ScrinMouseButton::Middle),
}
}
fn fill_app_background(buffer: &mut Buffer, area: Rect) {
buffer.fill(area, ' ', TEXT, Some(BG));
if area.is_empty() {
return;
}
for row in (area.y..area.bottom()).step_by(4) {
buffer.fill(
Rect::new(area.x, row, area.width, 1),
' ',
TEXT,
Some(BG_BAND),
);
}
if area.width > 72 {
let rail = Rect::new(area.x, area.y, 1, area.height);
buffer.fill(rail, ' ', TEXT, Some(Color::rgb(8, 13, 22)));
}
}
fn focus_marker(focused: bool) -> &'static str {
if focused {
"[active]"
} else {
""
}
}
fn read_frame_metrics_flag() -> bool {
env::var(FRAME_METRICS_ENV)
.ok()
.map(|value| {
value.eq_ignore_ascii_case("1")
|| value.eq_ignore_ascii_case("true")
|| value.eq_ignore_ascii_case("yes")
|| value.eq_ignore_ascii_case("on")
})
.unwrap_or(SHOW_FRAME_METRICS_DEFAULT)
}
fn move_index(current: usize, len: usize, delta: isize) -> usize {
if len == 0 {
return 0;
}
let max = len - 1;
if delta < 0 {
current.saturating_sub(delta.unsigned_abs()).min(max)
} else {
current.saturating_add(delta as usize).min(max)
}
}
fn status_color(status: CandidateStatus) -> Color {
match status {
CandidateStatus::Blocked => ACCENT,
CandidateStatus::Allowed => TEXT,
CandidateStatus::Open => DIM,
}
}
fn status_bg(status: CandidateStatus, score: u8) -> Color {
match status {
CandidateStatus::Blocked => Color::rgb(12, 42, 48),
CandidateStatus::Allowed if score >= 65 => Color::rgb(55, 18, 25),
CandidateStatus::Allowed => Color::rgb(16, 34, 45),
CandidateStatus::Open if score >= 65 => SELECTED_WARN_BG,
CandidateStatus::Open => Color::rgb(17, 24, 35),
}
}
fn severity_color(severity: AdvisorSeverity) -> Color {
match severity {
AdvisorSeverity::Critical => WARN,
AdvisorSeverity::Warning => Color::rgb(214, 160, 72),
AdvisorSeverity::Info => DIM,
AdvisorSeverity::Good => ACCENT,
}
}
fn review_gate_color(status: ReviewGateStatus) -> Color {
match status {
ReviewGateStatus::Pass => ACCENT,
ReviewGateStatus::Warn => Color::rgb(214, 160, 72),
ReviewGateStatus::Block => WARN,
}
}
fn capitalize_first(value: &str) -> String {
let mut chars = value.chars();
let Some(first) = chars.next() else {
return String::new();
};
first.to_uppercase().chain(chars).collect()
}
fn draw_meter(buffer: &mut Buffer, area: Rect, percent: u8, label: &str) {
if area.is_empty() {
return;
}
let width = area.width as usize;
let suffix = format!(" {:>3}%", percent.min(100));
let reserve = label.len().saturating_add(suffix.len()).saturating_add(4);
let bar_width = width.saturating_sub(reserve).max(8);
let fill = bar_width * percent.min(100) as usize / 100;
let bar = format!("[{}{}]", "#".repeat(fill), "-".repeat(bar_width - fill));
let text = format!("{} {}{}", label, bar, suffix);
let display: String = text.chars().take(width).collect();
buffer.set_str(
area.x as usize,
area.y as usize,
&display,
ACCENT,
Some(PANEL_SOFT),
);
}
fn build_command_palette() -> CommandPalette {
let mut palette = CommandPalette::new().with_commands(palette_commands());
palette.border_color = ACCENT;
palette.bg = PANEL;
palette.selected_bg = Color::rgb(20, 63, 58);
palette
}
fn palette_commands() -> Vec<Command> {
vec![
command("Dashboard", "1", "Open dashboard page", "dashboard"),
command("Rules", "2", "Open staged rules page", "rules"),
command("Ports", "3", "Open service port page", "ports"),
command("Plan", "4", "Open generated plan page", "plan"),
command("Advisor", "5", "Open advisor review", "advisor"),
command(
"Block selected port",
"b",
"Stage a deny rule for the selected service port",
"block-selected",
),
command(
"Block URL/IP target",
"block <target>",
"Type block, deny, or blacklist followed by a URL, domain, or IP",
"block-target-help",
),
command(
"Toggle known port block",
"d/Enter",
"Block or unblock the selected known service port",
"toggle-port-block",
),
command(
"Cycle known-port filter",
"F",
"Show all, named, at-risk, open, blocked, or allowed ports",
"cycle-port-filter",
),
command(
"Cycle group filter",
"G",
"Narrow known ports by service group",
"cycle-group-filter",
),
command(
"Cycle risk filter",
"K",
"Narrow known ports by risk class",
"cycle-risk-filter",
),
command(
"Jump to priority port",
"N",
"Select the highest-priority at-risk known port",
"jump-priority-port",
),
command(
"Block priority port",
"n",
"Stage a block for the highest-priority at-risk known port",
"block-priority-port",
),
command(
"Block priority queue",
"M",
"Stage blocks for the top visible priority ports",
"block-priority-queue",
),
command(
"Toggle reviewed exception",
"X",
"Record or remove exception review for an allowed risky port",
"toggle-exception",
),
command(
"Harden high-risk ports",
"h",
"Stage blocks for known admin, high, and data ports",
"harden-known",
),
command(
"Block selected group",
"g",
"Stage blocks for every known port in the selected group",
"block-selected-group",
),
command(
"Block selected risk class",
"B",
"Stage blocks for every known port in the selected risk class",
"block-selected-risk",
),
command(
"Block visible ports",
"V",
"Stage blocks for every known port in the active filters",
"block-visible-ports",
),
command(
"Allow visible ports",
"A",
"Stage allows for every known port in the active filters",
"allow-visible-ports",
),
command(
"Apply recommended profile",
"Y",
"Stage the strongest currently recommended profile",
"profile-recommended",
),
command(
"Apply safe recommended profile",
"y",
"Stage the management-preserving recommended profile",
"profile-safe-recommended",
),
command(
"Apply workstation profile",
"w",
"Block legacy, file, data, admin, dev, and orchestration services",
"profile-workstation",
),
command(
"Apply web profile",
"e",
"Allow HTTP/HTTPS and block data/dev/control-plane services",
"profile-web",
),
command(
"Apply developer profile",
"D",
"Block legacy, file, data, and orchestration services",
"profile-developer",
),
command(
"Apply lockdown profile",
"k",
"Block every known service port in the catalog",
"profile-lockdown",
),
command(
"Advisor quick fix",
"r",
"Stage obvious safe hardening recommendations",
"advisor-fix",
),
command(
"Undo last staged change",
"u",
"Restore the previous in-memory staged state",
"undo",
),
command(
"Allow selected port",
"a",
"Stage an allow rule for the selected service port",
"allow-selected",
),
command(
"Toggle selected rule",
"Space",
"Enable or disable the selected staged rule",
"toggle-rule",
),
command(
"Remove selected rule",
"x",
"Remove the selected staged rule",
"remove-rule",
),
command(
"Cycle backend preview",
"p",
"Switch ufw, iptables, and nftables command previews",
"cycle-backend",
),
command(
"Toggle logging plan",
"l",
"Toggle planned logging commands",
"toggle-logging",
),
command(
"Toggle default incoming",
"I",
"Switch planned default incoming policy between allow and deny",
"toggle-default-incoming",
),
command(
"Toggle default outgoing",
"O",
"Switch planned default outgoing policy between allow and deny",
"toggle-default-outgoing",
),
command(
"Clear staged rules",
"c",
"Remove all staged rules while keeping undo available",
"clear-rules",
),
command(
"Restore starter plan",
"R",
"Return to the safe starter baseline",
"reset-starter",
),
command(
"Apply firewall live",
"L",
"Run the staged backend commands against the live firewall after confirmation",
"live-apply",
),
command(
"Port info pane",
"H/mouse",
"Focus the selected service details pane",
"risk-overlay",
),
command(
"Export dry-run report",
"o",
"Write a local inert review report file",
"export-report",
),
command(
"Export safe command script",
"S",
"Write a commented shell preview that exits before commands",
"export-script",
),
command(
"Export JSON manifest",
"m",
"Write an inert machine-readable staged plan",
"export-manifest",
),
command(
"Export review bundle",
"E",
"Write report, safe script, and JSON manifest together",
"export-bundle",
),
command("Safety popup", "s", "Show live-apply guardrails", "safety"),
command("Help", "?", "Show keyboard help", "help"),
]
}
fn palette_target_block_input(query: &str) -> Option<&str> {
let query = query.trim();
let query = query.strip_prefix('/').unwrap_or(query).trim_start();
let lower = query.to_ascii_lowercase();
for verb in ["block", "deny", "blacklist", "ban"] {
if let Some(rest) = lower.strip_prefix(verb) {
if rest.is_empty() || !rest.starts_with(char::is_whitespace) {
continue;
}
let target = query[verb.len()..].trim_start();
if !target.is_empty() {
return Some(target);
}
}
}
None
}
fn command(name: &str, shortcut: &str, description: &str, action_id: &str) -> Command {
Command {
name: name.to_string(),
shortcut: Some(shortcut.to_string()),
description: description.to_string(),
action_id: action_id.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{
KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, MouseButton, MouseEvent,
MouseEventKind,
};
fn select_palette_action(tui: &mut FirewallTui, action_id: &str) {
let target = tui
.command_palette
.commands
.iter()
.position(|command| command.action_id == action_id)
.expect("command palette should expose action");
let filtered_pos = tui
.command_palette
.filtered
.iter()
.position(|&index| index == target)
.expect("command should be in visible palette list");
tui.command_palette.selected_index = filtered_pos;
}
fn execute_palette_action(tui: &mut FirewallTui, action_id: &str) {
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char(':'),
KeyModifiers::empty(),
)));
select_palette_action(tui, action_id);
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::empty(),
)));
}
fn confirm_once(tui: &mut FirewallTui) {
assert!(tui.confirmation_modal.is_visible());
assert!(tui.pending_confirmation.is_some());
tui.resolve_confirmation(Some(true));
}
fn confirm_twice(tui: &mut FirewallTui) {
assert_eq!(tui.confirmation_step, ConfirmationStep::First);
confirm_once(tui);
assert_eq!(tui.confirmation_step, ConfirmationStep::Final);
assert!(tui.confirmation_modal.is_visible());
assert!(tui.pending_confirmation.is_some());
confirm_once(tui);
assert_eq!(tui.confirmation_step, ConfirmationStep::First);
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
}
fn apply_profile_palette_action(
tui: &mut FirewallTui,
action_id: &str,
expected: FirewallProfile,
) {
execute_palette_action(tui, action_id);
match tui.pending_confirmation.as_ref() {
Some(PendingAction::ApplyProfile(profile)) => {
assert_eq!(*profile, expected);
confirm_twice(tui);
}
Some(_) => panic!("expected ApplyProfile action for {action_id}"),
None => {}
}
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
}
fn visible_open_count(tui: &FirewallTui) -> usize {
tui.visible_candidate_indexes()
.into_iter()
.filter(|index| {
tui.controller
.candidate_status(*index)
.is_some_and(|status| status != CandidateStatus::Blocked)
})
.count()
}
fn ui_snapshot(
tui: &FirewallTui,
) -> (
usize,
usize,
usize,
usize,
CandidateFilter,
Option<usize>,
Option<usize>,
Focus,
) {
(
tui.selected_tab,
tui.selected_rule,
tui.selected_plan,
tui.selected_candidate,
tui.candidate_filter,
tui.selected_group_filter,
tui.selected_risk_filter,
tui.focus,
)
}
fn assert_ui_snapshot_eq(
first: &(
usize,
usize,
usize,
usize,
CandidateFilter,
Option<usize>,
Option<usize>,
Focus,
),
second: &(
usize,
usize,
usize,
usize,
CandidateFilter,
Option<usize>,
Option<usize>,
Focus,
),
) {
assert_eq!(first, second);
}
#[test]
fn request_block_visible_candidates_can_cancel_without_changes() {
let mut tui = FirewallTui::new(FirewallController::demo());
let open_count = visible_open_count(&tui);
assert!(
open_count > 0,
"demo should include unblocked visible ports"
);
tui.request_block_visible_candidates();
assert!(tui.confirmation_modal.is_visible());
match tui.pending_confirmation.as_ref() {
Some(PendingAction::BlockVisibleCandidates(indexes)) => {
assert_eq!(indexes.len(), open_count);
}
Some(_) => panic!("expected pending BlockVisibleCandidates action"),
None => panic!("expected a pending confirmation action"),
}
let before = tui.controller.rules().len();
tui.resolve_confirmation(Some(false));
assert_eq!(before, tui.controller.rules().len());
assert!(!tui.confirmation_modal.is_visible());
assert!(tui.pending_confirmation.is_none());
}
#[test]
fn request_block_visible_candidates_confirms_with_enter() {
let mut tui = FirewallTui::new(FirewallController::demo());
let open_count = visible_open_count(&tui);
assert!(
open_count > 0,
"demo should include unblocked visible ports"
);
tui.request_block_visible_candidates();
let before = tui.controller.rules().len();
tui.resolve_confirmation(None);
assert_eq!(tui.confirmation_step, ConfirmationStep::Final);
tui.resolve_confirmation(None);
assert!(tui.controller.rules().len() > before);
assert_eq!(visible_open_count(&tui), 0);
assert!(!tui.confirmation_modal.is_visible());
assert!(tui.pending_confirmation.is_none());
}
#[test]
fn confirmation_modal_confirms_with_y_key() {
let mut tui = FirewallTui::new(FirewallController::demo());
let before = tui.controller.rules().len();
tui.request_block_visible_candidates();
assert!(tui.confirmation_modal.is_visible());
assert!(matches!(
tui.pending_confirmation.as_ref(),
Some(PendingAction::BlockVisibleCandidates(_))
));
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('y'),
KeyModifiers::empty(),
)));
assert_eq!(tui.confirmation_step, ConfirmationStep::Final);
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('y'),
KeyModifiers::empty(),
)));
assert!(tui.controller.rules().len() > before);
assert_eq!(visible_open_count(&tui), 0);
assert!(!tui.confirmation_modal.is_visible());
assert!(tui.pending_confirmation.is_none());
}
#[test]
fn confirmation_modal_confirms_with_uppercase_y_key() {
let mut tui = FirewallTui::new(FirewallController::demo());
let before = tui.controller.rules().len();
tui.request_block_visible_candidates();
assert!(tui.confirmation_modal.is_visible());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('Y'),
KeyModifiers::empty(),
)));
assert_eq!(tui.confirmation_step, ConfirmationStep::Final);
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('Y'),
KeyModifiers::empty(),
)));
assert!(tui.controller.rules().len() > before);
assert!(!tui.confirmation_modal.is_visible());
assert!(tui.pending_confirmation.is_none());
}
#[test]
fn confirmation_modal_cancels_with_n_key() {
let mut tui = FirewallTui::new(FirewallController::demo());
let before = tui.controller.rules().len();
tui.request_block_visible_candidates();
assert!(tui.confirmation_modal.is_visible());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('n'),
KeyModifiers::empty(),
)));
assert_eq!(before, tui.controller.rules().len());
assert!(!tui.confirmation_modal.is_visible());
assert!(tui.pending_confirmation.is_none());
}
#[test]
fn confirmation_modal_cancels_with_esc_key() {
let mut tui = FirewallTui::new(FirewallController::demo());
let before = tui.controller.rules().len();
tui.request_block_visible_candidates();
assert!(tui.confirmation_modal.is_visible());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Esc,
KeyModifiers::empty(),
)));
assert_eq!(before, tui.controller.rules().len());
assert!(!tui.confirmation_modal.is_visible());
assert!(tui.pending_confirmation.is_none());
}
#[test]
fn help_modal_toggles_with_question_mark() {
let mut tui = FirewallTui::new(FirewallController::demo());
assert!(!tui.help_modal.is_visible());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('?'),
KeyModifiers::empty(),
)));
assert!(tui.help_modal.is_visible());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('?'),
KeyModifiers::empty(),
)));
assert!(!tui.help_modal.is_visible());
}
#[test]
fn help_modal_dismisses_with_enter_and_escape() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('?'),
KeyModifiers::empty(),
)));
assert!(tui.help_modal.is_visible());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::empty(),
)));
assert!(!tui.help_modal.is_visible());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('?'),
KeyModifiers::empty(),
)));
assert!(tui.help_modal.is_visible());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Esc,
KeyModifiers::empty(),
)));
assert!(!tui.help_modal.is_visible());
}
#[test]
fn safety_popup_toggles_with_s_key() {
let mut tui = FirewallTui::new(FirewallController::demo());
assert!(!tui.safety_popup.is_visible());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('s'),
KeyModifiers::empty(),
)));
assert!(tui.safety_popup.is_visible());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('s'),
KeyModifiers::empty(),
)));
assert!(!tui.safety_popup.is_visible());
}
#[test]
fn safety_popup_dismisses_with_escape() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('s'),
KeyModifiers::empty(),
)));
assert!(tui.safety_popup.is_visible());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Esc,
KeyModifiers::empty(),
)));
assert!(!tui.safety_popup.is_visible());
}
#[test]
fn port_info_pane_focuses_with_h_key() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.focus = Focus::Rules;
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('H'),
KeyModifiers::empty(),
)));
assert_eq!(tui.focus, Focus::Ports);
assert!(tui.context_candidate.is_none());
}
#[test]
fn mouse_move_over_port_row_does_not_select_candidate() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.ports_list_area = Some(Rect::new(4, 10, 40, 4));
tui.port_hit_rows = vec![(10, 0), (11, 1)];
tui.focus = Focus::Rules;
tui.handle_event(Event::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
column: 8,
row: 11,
modifiers: KeyModifiers::empty(),
}));
assert_eq!(tui.selected_candidate, 0);
assert_eq!(tui.focus, Focus::Rules);
assert!(tui.context_candidate.is_none());
}
#[test]
fn right_click_over_port_row_arms_action_pane() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.ports_list_area = Some(Rect::new(4, 10, 40, 4));
tui.port_hit_rows = vec![(10, 0), (11, 1)];
tui.handle_event(Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Right),
column: 8,
row: 11,
modifiers: KeyModifiers::empty(),
}));
assert_eq!(tui.selected_candidate, 1);
assert_eq!(tui.focus, Focus::Ports);
assert_eq!(tui.context_candidate, Some(1));
}
#[test]
fn mouse_move_after_right_click_keeps_action_pane_target() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.ports_list_area = Some(Rect::new(4, 10, 40, 4));
tui.port_hit_rows = vec![(10, 0), (11, 1)];
tui.handle_event(Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Right),
column: 8,
row: 11,
modifiers: KeyModifiers::empty(),
}));
assert_eq!(tui.context_candidate, Some(1));
tui.handle_event(Event::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
column: 8,
row: 10,
modifiers: KeyModifiers::empty(),
}));
assert_eq!(tui.selected_candidate, 1);
assert_eq!(tui.context_candidate, Some(1));
}
#[test]
fn port_action_row_click_uses_confirmation() {
let mut tui = FirewallTui::new(FirewallController::demo());
let ssh = tui
.controller
.candidates()
.iter()
.position(|candidate| candidate.name == "SSH")
.expect("known test candidate");
tui.selected_candidate = ssh;
tui.port_info_area = Some(Rect::new(4, 10, 48, 8));
tui.port_action_rows = vec![(12, PortPaneAction::Block)];
tui.handle_event(Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 8,
row: 12,
modifiers: KeyModifiers::empty(),
}));
assert!(matches!(
tui.pending_confirmation.as_ref(),
Some(PendingAction::BlockCandidate { index, name })
if *index == ssh && *name == "SSH"
));
assert_eq!(
tui.controller.candidate_status(ssh),
Some(CandidateStatus::Open)
);
}
#[test]
fn scrin_port_widget_ids_route_to_targets() {
assert_eq!(
port_pointer_target(&WidgetId::new(port_candidate_widget_id(7))),
Some(PortPointerTarget::Candidate(7))
);
assert_eq!(
port_pointer_target(&WidgetId::new(port_action_widget_id(PortPaneAction::Allow))),
Some(PortPointerTarget::Action(PortPaneAction::Allow))
);
}
#[test]
fn context_block_action_uses_two_step_confirmation() {
let mut tui = FirewallTui::new(FirewallController::demo());
let ssh = tui
.controller
.candidates()
.iter()
.position(|candidate| candidate.name == "SSH")
.expect("known test candidate");
tui.show_context_pane(ssh);
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('b'),
KeyModifiers::empty(),
)));
assert!(tui.context_candidate.is_none());
assert!(matches!(
tui.pending_confirmation.as_ref(),
Some(PendingAction::BlockCandidate { index, name })
if *index == ssh && *name == "SSH"
));
assert_eq!(
tui.controller.candidate_status(ssh),
Some(CandidateStatus::Open)
);
confirm_once(&mut tui);
assert_eq!(tui.confirmation_step, ConfirmationStep::Final);
assert_eq!(
tui.controller.candidate_status(ssh),
Some(CandidateStatus::Open)
);
confirm_once(&mut tui);
assert_eq!(
tui.controller.candidate_status(ssh),
Some(CandidateStatus::Blocked)
);
assert!(tui.pending_confirmation.is_none());
}
#[test]
fn context_exception_action_is_confirmation_gated() {
let mut tui = FirewallTui::new(FirewallController::demo());
let mysql = tui
.controller
.candidates()
.iter()
.position(|candidate| candidate.name == "MySQL")
.expect("known test candidate");
tui.controller.allow_candidate(mysql);
tui.show_context_pane(mysql);
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('X'),
KeyModifiers::empty(),
)));
assert!(matches!(
tui.pending_confirmation.as_ref(),
Some(PendingAction::ToggleCandidateException { index, name })
if *index == mysql && *name == "MySQL"
));
assert!(!tui.controller.candidate_has_review_exception(mysql));
confirm_twice(&mut tui);
assert!(tui.controller.candidate_has_review_exception(mysql));
}
#[test]
fn command_palette_opens_with_colon() {
let mut tui = FirewallTui::new(FirewallController::demo());
assert!(!tui.command_palette.is_open());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char(':'),
KeyModifiers::empty(),
)));
assert!(tui.command_palette.is_open());
}
#[test]
fn command_palette_opens_with_slash() {
let mut tui = FirewallTui::new(FirewallController::demo());
assert!(!tui.command_palette.is_open());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('/'),
KeyModifiers::empty(),
)));
assert!(tui.command_palette.is_open());
}
#[test]
fn command_palette_closes_with_escape() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char(':'),
KeyModifiers::empty(),
)));
assert!(tui.command_palette.is_open());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Esc,
KeyModifiers::empty(),
)));
assert!(!tui.command_palette.is_open());
}
#[test]
fn command_palette_enter_executes_clear_rules_action() {
let mut tui = FirewallTui::new(FirewallController::demo());
let before = tui.controller.rules().len();
assert!(before > 0, "demo should start with staged rules");
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char(':'),
KeyModifiers::empty(),
)));
select_palette_action(&mut tui, "clear-rules");
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::empty(),
)));
assert!(!tui.command_palette.is_open());
assert!(tui.confirmation_modal.is_visible());
assert!(matches!(
tui.pending_confirmation,
Some(PendingAction::ClearRules(staged_count)) if staged_count == before
));
}
#[test]
fn command_palette_enter_with_no_matches_keeps_open() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char(':'),
KeyModifiers::empty(),
)));
assert!(tui.command_palette.is_open());
for c in "qzzq".chars() {
tui.command_palette.input_char(c);
}
assert!(tui.command_palette.filtered.is_empty());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::empty(),
)));
assert!(tui.command_palette.is_open());
}
#[test]
fn palette_target_block_input_parses_spotlight_commands() {
assert_eq!(
palette_target_block_input("block https://Example.com/path"),
Some("https://Example.com/path")
);
assert_eq!(
palette_target_block_input("/Deny 203.0.113.20"),
Some("203.0.113.20")
);
assert_eq!(
palette_target_block_input("blacklist example.org"),
Some("example.org")
);
assert_eq!(palette_target_block_input("block"), None);
assert_eq!(palette_target_block_input("blocked example.org"), None);
}
#[test]
fn command_palette_can_stage_target_block_from_typed_query() {
let mut tui = FirewallTui::new(FirewallController::demo());
let before = tui.controller.rules().len();
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char(':'),
KeyModifiers::empty(),
)));
for c in "block 203.0.113.20".chars() {
tui.command_palette.input_char(c);
}
assert!(tui.command_palette.filtered.is_empty());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::empty(),
)));
assert!(!tui.command_palette.is_open());
assert_eq!(tui.controller.rules().len(), before + 1);
assert_eq!(tui.selected_tab, 1);
assert_eq!(tui.focus, Focus::Rules);
assert_eq!(
tui.controller
.rules()
.last()
.map(|rule| rule.target.to_string()),
Some("203.0.113.20".into())
);
}
#[test]
fn plan_tab_has_keyboard_selection() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.select_tab(3);
assert_eq!(tui.focus, Focus::Plan);
assert_eq!(tui.selected_plan, 0);
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Down,
KeyModifiers::empty(),
)));
assert_eq!(tui.selected_plan, 1);
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::End,
KeyModifiers::empty(),
)));
assert_eq!(
tui.selected_plan,
tui.controller.plan().len().saturating_sub(1)
);
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Home,
KeyModifiers::empty(),
)));
assert_eq!(tui.selected_plan, 0);
}
#[test]
fn confirmation_modal_preempts_help_modal() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.request_block_visible_candidates();
assert!(tui.confirmation_modal.is_visible());
assert!(!tui.help_modal.is_visible());
let should_quit = tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('?'),
KeyModifiers::empty(),
)));
assert!(!should_quit);
assert!(tui.confirmation_modal.is_visible());
assert!(!tui.help_modal.is_visible());
}
#[test]
fn confirmation_modal_preempts_safety_popup() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.request_block_visible_candidates();
assert!(tui.confirmation_modal.is_visible());
assert!(!tui.safety_popup.is_visible());
let should_quit = tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('s'),
KeyModifiers::empty(),
)));
assert!(!should_quit);
assert!(tui.confirmation_modal.is_visible());
assert!(!tui.safety_popup.is_visible());
}
#[test]
fn command_palette_action_matrix_routes_common_actions() {
let mut tui = FirewallTui::new(FirewallController::demo());
let before_rules = tui.controller.rules().len();
assert!(before_rules > 0, "demo should start with staged rules");
execute_palette_action(&mut tui, "plan");
assert_eq!(tui.selected_tab, 3);
execute_palette_action(&mut tui, "cycle-risk-filter");
if tui.controller.candidate_risks().is_empty() {
assert!(tui.selected_risk_filter.is_none());
} else {
assert!(tui.selected_risk_filter.is_some());
}
execute_palette_action(&mut tui, "help");
assert!(tui.help_modal.is_visible());
tui.help_modal.hide();
execute_palette_action(&mut tui, "clear-rules");
assert!(matches!(
tui.pending_confirmation,
Some(PendingAction::ClearRules(staged_count)) if staged_count == before_rules
));
}
#[test]
fn command_palette_state_actions_update_controller() {
let mut tui = FirewallTui::new(FirewallController::demo());
let before_backend = tui.controller.backend();
let before_incoming = tui.controller.default_incoming();
let before_outgoing = tui.controller.default_outgoing();
let before_logging = tui.controller.logging();
execute_palette_action(&mut tui, "cycle-backend");
assert_eq!(tui.controller.backend(), before_backend.next());
execute_palette_action(&mut tui, "toggle-default-incoming");
assert_eq!(tui.controller.default_incoming(), before_incoming.next());
execute_palette_action(&mut tui, "toggle-default-outgoing");
assert_eq!(tui.controller.default_outgoing(), before_outgoing.next());
execute_palette_action(&mut tui, "toggle-logging");
assert_eq!(tui.controller.logging(), !before_logging);
execute_palette_action(&mut tui, "clear-rules");
assert!(matches!(
tui.pending_confirmation,
Some(PendingAction::ClearRules(_))
));
}
#[test]
fn command_palette_prevents_quit_key() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char(':'),
KeyModifiers::empty(),
)));
let should_quit = tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('q'),
KeyModifiers::empty(),
)));
assert!(!should_quit);
assert!(tui.command_palette.is_open());
assert_eq!(tui.command_palette.query, "q");
}
#[test]
fn command_palette_prevents_safety_toggle() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char(':'),
KeyModifiers::empty(),
)));
let should_quit = tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('s'),
KeyModifiers::empty(),
)));
assert!(!should_quit);
assert!(tui.command_palette.is_open());
assert_eq!(tui.command_palette.query, "s");
assert!(!tui.safety_popup.is_visible());
}
#[test]
fn quit_shortcuts_exit_when_no_modal_is_open() {
let mut tui = FirewallTui::new(FirewallController::demo());
assert!(!tui.confirmation_modal.is_visible());
assert!(!tui.command_palette.is_open());
assert!(!tui.help_modal.is_visible());
assert!(!tui.safety_popup.is_visible());
assert!(tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('q'),
KeyModifiers::empty(),
))));
assert!(tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Esc,
KeyModifiers::empty(),
))));
}
#[test]
fn confirmation_modal_prevents_quit_key() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.request_clear_rules();
assert!(tui.confirmation_modal.is_visible());
let should_quit = tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('q'),
KeyModifiers::empty(),
)));
assert!(!should_quit);
assert!(tui.confirmation_modal.is_visible());
assert!(tui.pending_confirmation.is_some());
}
#[test]
fn help_modal_prevents_quit_key() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('?'),
KeyModifiers::empty(),
)));
assert!(tui.help_modal.is_visible());
let should_quit = tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('q'),
KeyModifiers::empty(),
)));
assert!(!should_quit);
assert!(tui.help_modal.is_visible());
}
#[test]
fn safety_popup_prevents_quit_key() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('s'),
KeyModifiers::empty(),
)));
assert!(tui.safety_popup.is_visible());
let should_quit = tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('q'),
KeyModifiers::empty(),
)));
assert!(!should_quit);
assert!(!tui.safety_popup.is_visible());
}
#[test]
fn request_clear_rules_enter_confirms_by_default() {
let mut tui = FirewallTui::new(FirewallController::demo());
let before = tui.controller.rules().len();
assert!(before > 0, "demo should start with staged rules");
tui.request_clear_rules();
match tui.pending_confirmation.as_ref() {
Some(PendingAction::ClearRules(staged_count)) => {
assert_eq!(*staged_count, before);
}
Some(_) => panic!("expected pending ClearRules action"),
None => panic!("expected a pending confirmation action"),
}
tui.resolve_confirmation(None);
assert_eq!(tui.confirmation_step, ConfirmationStep::Final);
tui.resolve_confirmation(None);
assert_eq!(tui.controller.rules().len(), 0);
assert!(!tui.confirmation_modal.is_visible());
assert!(tui.pending_confirmation.is_none());
}
#[test]
fn request_live_apply_routes_to_confirmation_when_no_blockers() {
let mut tui = FirewallTui::new(FirewallController::demo());
let preview = tui.controller.live_apply_preview();
tui.request_live_apply();
assert!(tui.confirmation_modal.is_visible());
assert!(matches!(
tui.pending_confirmation.as_ref(),
Some(PendingAction::ApplyLive { fingerprint, commands })
if *fingerprint == preview.fingerprint && *commands == preview.command_count
));
}
#[test]
fn request_live_apply_blocks_when_review_blockers_remain() {
let mut tui = FirewallTui::new(FirewallController::demo());
let mysql = tui
.controller
.candidates()
.iter()
.position(|candidate| candidate.name == "MySQL")
.expect("known test candidate");
tui.controller.allow_candidate(mysql);
tui.request_live_apply();
assert!(!tui.confirmation_modal.is_visible());
assert!(tui.pending_confirmation.is_none());
}
#[test]
fn command_palette_escape_is_layer_priority() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char(':'),
KeyModifiers::empty(),
)));
assert!(tui.command_palette.is_open());
let should_quit = tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Esc,
KeyModifiers::empty(),
)));
assert!(!should_quit);
assert!(!tui.command_palette.is_open());
let should_quit = tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Esc,
KeyModifiers::empty(),
)));
assert!(should_quit);
}
#[test]
fn confirmation_modal_escape_is_layer_priority() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.request_clear_rules();
assert!(tui.confirmation_modal.is_visible());
let should_quit = tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Esc,
KeyModifiers::empty(),
)));
assert!(!should_quit);
assert!(!tui.confirmation_modal.is_visible());
assert!(tui.pending_confirmation.is_none());
let should_quit = tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Esc,
KeyModifiers::empty(),
)));
assert!(should_quit);
}
#[test]
fn help_modal_escape_is_layer_priority() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('?'),
KeyModifiers::empty(),
)));
assert!(tui.help_modal.is_visible());
let should_quit = tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Esc,
KeyModifiers::empty(),
)));
assert!(!should_quit);
assert!(!tui.help_modal.is_visible());
let should_quit = tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Esc,
KeyModifiers::empty(),
)));
assert!(should_quit);
}
#[test]
fn safety_popup_escape_is_layer_priority() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('s'),
KeyModifiers::empty(),
)));
assert!(tui.safety_popup.is_visible());
let should_quit = tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Esc,
KeyModifiers::empty(),
)));
assert!(!should_quit);
assert!(!tui.safety_popup.is_visible());
let should_quit = tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Esc,
KeyModifiers::empty(),
)));
assert!(should_quit);
}
#[test]
fn request_block_selected_group_skips_when_fully_blocked() {
let mut tui = FirewallTui::new(FirewallController::demo());
let staged = tui.controller.block_candidate_group(0);
assert!(
staged > 0,
"selected candidate should belong to a valid group"
);
tui.request_block_selected_group();
assert!(!tui.confirmation_modal.is_visible());
assert!(tui.pending_confirmation.is_none());
}
#[test]
fn request_block_selected_risk_skips_when_fully_blocked() {
let mut tui = FirewallTui::new(FirewallController::demo());
let Some(index) = tui.selected_visible_candidate() else {
panic!("demo should have at least one visible candidate");
};
let risk = tui
.controller
.candidate_risk(index)
.expect("candidate should have risk");
let staged = tui.controller.block_candidate_risk(index);
assert!(
staged > 0,
"selected risk class should block at least one candidate"
);
tui.request_block_selected_risk();
let open_count = tui
.controller
.candidates()
.iter()
.enumerate()
.filter(|(candidate_index, candidate)| {
candidate.risk == risk
&& tui
.controller
.candidate_status(*candidate_index)
.is_some_and(|status| status != CandidateStatus::Blocked)
})
.count();
assert_eq!(open_count, 0);
assert!(!tui.confirmation_modal.is_visible());
assert!(tui.pending_confirmation.is_none());
}
#[test]
fn request_allow_visible_candidates_noop_when_already_allowed() {
let mut tui = FirewallTui::new(FirewallController::demo());
let visible = tui.visible_candidate_indexes();
let _ = tui.controller.allow_candidate_indexes(&visible);
assert!(
tui.visible_candidate_indexes()
.into_iter()
.all(|index| tui.controller.candidate_status(index)
== Some(CandidateStatus::Allowed)),
"every visible candidate should be allowed after pre-staging"
);
tui.request_allow_visible_candidates();
assert!(!tui.confirmation_modal.is_visible());
assert!(tui.pending_confirmation.is_none());
}
#[test]
fn request_harden_high_risk_skips_when_no_candidates() {
let mut tui = FirewallTui::new(FirewallController::demo());
let staged = tui.controller.block_high_risk_candidates();
assert!(staged > 0, "demo should include high-risk candidates");
tui.request_harden_high_risk();
assert!(!tui.confirmation_modal.is_visible());
assert!(tui.pending_confirmation.is_none());
}
#[test]
fn block_priority_queue_action_requests_confirmation() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.request_block_priority_queue();
match tui.pending_confirmation.as_ref() {
Some(PendingAction::BlockPriorityQueue(indexes)) => {
assert!(!indexes.is_empty());
}
Some(_) => panic!("expected pending BlockPriorityQueue action"),
None => panic!("expected a pending confirmation action"),
}
assert!(tui.confirmation_modal.is_visible());
}
#[test]
fn run_palette_action_routes_hardening_to_confirmation() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.run_palette_action("harden-known");
assert!(matches!(
tui.pending_confirmation.as_ref(),
Some(PendingAction::HardenHighRisk)
));
}
#[test]
fn run_palette_action_routes_profile_actions_to_confirmation_or_noop() {
let profile_actions = [
("profile-workstation", FirewallProfile::Workstation),
("profile-web", FirewallProfile::WebServer),
("profile-developer", FirewallProfile::Developer),
("profile-lockdown", FirewallProfile::Lockdown),
];
for (action_id, profile) in profile_actions {
let mut tui = FirewallTui::new(FirewallController::demo());
let pending_changes = tui.controller.profile_change_preview(profile).len();
tui.run_palette_action(action_id);
if pending_changes == 0 {
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
} else {
assert!(matches!(
tui.pending_confirmation.as_ref(),
Some(PendingAction::ApplyProfile(actual)) if *actual == profile
));
confirm_twice(&mut tui);
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
}
}
}
#[test]
fn run_palette_action_profile_workstation_is_noop_when_already_applied() {
let mut tui = FirewallTui::new(FirewallController::demo());
apply_profile_palette_action(
&mut tui,
"profile-workstation",
FirewallProfile::Workstation,
);
assert_eq!(
tui.controller
.profile_change_preview(FirewallProfile::Workstation)
.len(),
0
);
apply_profile_palette_action(
&mut tui,
"profile-workstation",
FirewallProfile::Workstation,
);
assert_eq!(
tui.controller
.profile_change_preview(FirewallProfile::Workstation)
.len(),
0
);
assert!(tui.pending_confirmation.is_none());
}
#[test]
fn run_palette_action_profile_web_is_noop_when_already_applied() {
let mut tui = FirewallTui::new(FirewallController::demo());
apply_profile_palette_action(&mut tui, "profile-web", FirewallProfile::WebServer);
assert_eq!(
tui.controller
.profile_change_preview(FirewallProfile::WebServer)
.len(),
0
);
apply_profile_palette_action(&mut tui, "profile-web", FirewallProfile::WebServer);
assert_eq!(
tui.controller
.profile_change_preview(FirewallProfile::WebServer)
.len(),
0
);
assert!(tui.pending_confirmation.is_none());
}
#[test]
fn run_palette_action_profile_developer_is_noop_when_already_applied() {
let mut tui = FirewallTui::new(FirewallController::demo());
apply_profile_palette_action(&mut tui, "profile-developer", FirewallProfile::Developer);
assert_eq!(
tui.controller
.profile_change_preview(FirewallProfile::Developer)
.len(),
0
);
apply_profile_palette_action(&mut tui, "profile-developer", FirewallProfile::Developer);
assert_eq!(
tui.controller
.profile_change_preview(FirewallProfile::Developer)
.len(),
0
);
assert!(tui.pending_confirmation.is_none());
}
#[test]
fn run_palette_action_profile_lockdown_is_noop_when_already_applied() {
let mut tui = FirewallTui::new(FirewallController::demo());
apply_profile_palette_action(&mut tui, "profile-lockdown", FirewallProfile::Lockdown);
assert_eq!(
tui.controller
.profile_change_preview(FirewallProfile::Lockdown)
.len(),
0
);
apply_profile_palette_action(&mut tui, "profile-lockdown", FirewallProfile::Lockdown);
assert_eq!(
tui.controller
.profile_change_preview(FirewallProfile::Lockdown)
.len(),
0
);
assert!(tui.pending_confirmation.is_none());
}
#[test]
fn run_palette_action_profile_recommended_is_noop_after_apply() {
let mut tui = FirewallTui::new(FirewallController::demo());
let recommendation = tui.controller.recommended_profile();
execute_palette_action(&mut tui, "profile-recommended");
if tui
.controller
.profile_change_preview(recommendation.profile)
.len()
== 0
{
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
} else {
assert!(matches!(
tui.pending_confirmation.as_ref(),
Some(PendingAction::ApplyRecommendedProfile)
));
confirm_twice(&mut tui);
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
}
let before_changes = tui
.controller
.profile_change_preview(recommendation.profile)
.len();
execute_palette_action(&mut tui, "profile-recommended");
assert_eq!(before_changes, 0);
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
}
#[test]
fn run_palette_action_profile_safe_recommended_is_noop_after_apply_or_unavailable() {
let mut tui = FirewallTui::new(FirewallController::demo());
let safe_recommendation = tui.controller.safe_recommended_profile();
match safe_recommendation {
Some(recommendation) => {
execute_palette_action(&mut tui, "profile-safe-recommended");
if tui
.controller
.profile_change_preview(recommendation.profile)
.len()
== 0
{
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
} else {
assert!(matches!(
tui.pending_confirmation.as_ref(),
Some(PendingAction::ApplySafeRecommendedProfile)
));
confirm_twice(&mut tui);
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
}
let before_changes = tui
.controller
.profile_change_preview(recommendation.profile)
.len();
execute_palette_action(&mut tui, "profile-safe-recommended");
assert_eq!(before_changes, 0);
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
}
None => {
execute_palette_action(&mut tui, "profile-safe-recommended");
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
execute_palette_action(&mut tui, "profile-safe-recommended");
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
}
}
}
#[test]
fn run_palette_action_routes_recommended_actions_to_confirmation_or_noop() {
let mut tui = FirewallTui::new(FirewallController::demo());
let recommendation = tui.controller.recommended_profile();
let pending_changes = tui
.controller
.profile_change_preview(recommendation.profile)
.len();
tui.run_palette_action("profile-recommended");
if pending_changes == 0 {
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
} else {
assert!(matches!(
tui.pending_confirmation.as_ref(),
Some(PendingAction::ApplyRecommendedProfile)
));
confirm_twice(&mut tui);
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
}
tui = FirewallTui::new(FirewallController::demo());
match tui.controller.safe_recommended_profile() {
Some(recommendation) => {
let pending_changes = tui
.controller
.profile_change_preview(recommendation.profile)
.len();
tui.run_palette_action("profile-safe-recommended");
if pending_changes == 0 {
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
} else {
assert!(matches!(
tui.pending_confirmation.as_ref(),
Some(PendingAction::ApplySafeRecommendedProfile)
));
confirm_twice(&mut tui);
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
}
}
None => {
tui.run_palette_action("profile-safe-recommended");
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
}
}
}
#[test]
fn run_palette_action_overlay_and_info_routes() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.run_palette_action("help");
assert!(tui.help_modal.is_visible());
tui.help_modal.hide();
tui.run_palette_action("safety");
assert!(tui.safety_popup.is_visible());
tui.safety_popup.hide();
tui.focus = Focus::Rules;
tui.run_palette_action("risk-overlay");
assert_eq!(tui.focus, Focus::Ports);
}
#[test]
fn run_palette_action_routes_live_apply_to_confirmation() {
let mut tui = FirewallTui::new(FirewallController::demo());
let preview = tui.controller.live_apply_preview();
tui.run_palette_action("live-apply");
assert!(matches!(
tui.pending_confirmation.as_ref(),
Some(PendingAction::ApplyLive { fingerprint, commands })
if *fingerprint == preview.fingerprint && *commands == preview.command_count
));
assert!(tui.confirmation_modal.is_visible());
}
#[test]
fn run_palette_action_navigation_commands_are_idempotent_by_action_id() {
let mut tui = FirewallTui::new(FirewallController::demo());
let tabs = [
("dashboard", 0usize),
("rules", 1usize),
("ports", 2usize),
("plan", 3usize),
("advisor", 4usize),
];
for (action_id, expected_tab) in tabs {
tui.run_palette_action(action_id);
assert_eq!(tui.selected_tab, expected_tab);
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
let before = ui_snapshot(&tui);
tui.run_palette_action(action_id);
let after = ui_snapshot(&tui);
assert_eq!(tui.selected_tab, expected_tab);
assert_ui_snapshot_eq(&before, &after);
}
}
#[test]
fn run_palette_action_clear_rules_is_noop_when_nothing_to_clear() {
let mut tui = FirewallTui::new(FirewallController::demo());
assert!(
tui.controller.clear_rules() > 0,
"demo should have starter rules"
);
tui.run_palette_action("clear-rules");
assert_eq!(tui.controller.rules().len(), 0);
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
let before = ui_snapshot(&tui);
tui.run_palette_action("clear-rules");
assert_eq!(tui.controller.rules().len(), 0);
assert_eq!(ui_snapshot(&tui), before);
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
}
#[test]
fn run_palette_action_safety_idempotent_toggle_path() {
let mut tui = FirewallTui::new(FirewallController::demo());
let baseline = ui_snapshot(&tui);
tui.run_palette_action("safety");
assert!(tui.safety_popup.is_visible());
assert!(tui.pending_confirmation.is_none());
assert!(!tui.help_modal.is_visible());
tui.run_palette_action("safety");
assert!(!tui.safety_popup.is_visible());
assert_eq!(ui_snapshot(&tui), baseline);
tui.run_palette_action("safety");
assert!(tui.safety_popup.is_visible());
}
#[test]
fn run_palette_action_reset_starter_and_undo_restore_previous_rules() {
let mut tui = FirewallTui::new(FirewallController::demo());
let starter_rule_count = tui.controller.rules().len();
execute_palette_action(&mut tui, "clear-rules");
let clear_rules = tui
.pending_confirmation
.as_ref()
.and_then(|action| match action {
PendingAction::ClearRules(staged) => Some(*staged),
_ => None,
});
assert!(clear_rules.is_some_and(|count| count > 0));
confirm_twice(&mut tui);
assert_eq!(tui.controller.rules().len(), 0);
tui.run_palette_action("reset-starter");
assert!(matches!(
tui.pending_confirmation.as_ref(),
Some(PendingAction::ResetStarterPlan)
));
confirm_twice(&mut tui);
assert_eq!(tui.controller.rules().len(), starter_rule_count);
tui.run_palette_action("undo");
assert_eq!(tui.controller.rules().len(), 0);
}
#[test]
fn handle_event_ignores_resize_events() {
let mut tui = FirewallTui::new(FirewallController::demo());
let before_tab = tui.selected_tab;
let before_rule = tui.selected_rule;
let before_candidate = tui.selected_candidate;
let before_filter = tui.candidate_filter;
let before_group = tui.selected_group_filter;
let before_risk = tui.selected_risk_filter;
let before_focus = tui.focus;
let before_pending = tui.pending_confirmation.is_some();
let should_quit = tui.handle_event(Event::Resize(120, 30));
assert!(!should_quit);
assert_eq!(tui.selected_tab, before_tab);
assert_eq!(tui.selected_rule, before_rule);
assert_eq!(tui.selected_candidate, before_candidate);
assert_eq!(tui.candidate_filter, before_filter);
assert_eq!(tui.selected_group_filter, before_group);
assert_eq!(tui.selected_risk_filter, before_risk);
assert_eq!(tui.focus, before_focus);
assert_eq!(tui.pending_confirmation.is_some(), before_pending);
assert!(!tui.command_palette.is_open());
assert!(!tui.confirmation_modal.is_visible());
assert!(!tui.help_modal.is_visible());
assert!(!tui.safety_popup.is_visible());
}
#[test]
fn handle_event_ignores_non_press_key_events() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.select_tab(2);
let before_tab = tui.selected_tab;
let before_rule = tui.selected_rule;
let before_candidate = tui.selected_candidate;
let before_focus = tui.focus;
let should_quit = tui.handle_event(Event::Key(KeyEvent {
code: KeyCode::Char('q'),
modifiers: KeyModifiers::empty(),
kind: KeyEventKind::Release,
state: KeyEventState::empty(),
}));
assert!(!should_quit);
assert_eq!(tui.selected_tab, before_tab);
assert_eq!(tui.selected_rule, before_rule);
assert_eq!(tui.selected_candidate, before_candidate);
assert_eq!(tui.focus, before_focus);
assert!(!tui.command_palette.is_open());
assert!(!tui.confirmation_modal.is_visible());
assert!(!tui.help_modal.is_visible());
assert!(!tui.safety_popup.is_visible());
}
#[test]
fn handle_event_accepts_key_repeat_events() {
let mut tui = FirewallTui::new(FirewallController::demo());
assert_eq!(tui.selected_tab, 0);
let should_quit = tui.handle_event(Event::Key(KeyEvent {
code: KeyCode::Tab,
modifiers: KeyModifiers::empty(),
kind: KeyEventKind::Repeat,
state: KeyEventState::empty(),
}));
assert!(!should_quit);
assert_eq!(tui.selected_tab, 1);
}
#[test]
fn run_palette_action_unknown_action_is_noop_for_state() {
let mut tui = FirewallTui::new(FirewallController::demo());
let before_tab = tui.selected_tab;
let before_rule = tui.selected_rule;
let before_candidate = tui.selected_candidate;
let before_focus = tui.focus;
let before_filter = tui.candidate_filter;
let before_group = tui.selected_group_filter;
let before_risk = tui.selected_risk_filter;
let before_rules = tui.controller.rules().len();
let before_pending = tui.pending_confirmation.is_some();
tui.run_palette_action("does-not-exist");
assert_eq!(tui.selected_tab, before_tab);
assert_eq!(tui.selected_rule, before_rule);
assert_eq!(tui.selected_candidate, before_candidate);
assert_eq!(tui.focus, before_focus);
assert_eq!(tui.candidate_filter, before_filter);
assert_eq!(tui.selected_group_filter, before_group);
assert_eq!(tui.selected_risk_filter, before_risk);
assert_eq!(tui.controller.rules().len(), before_rules);
assert_eq!(tui.pending_confirmation.is_some(), before_pending);
assert!(!tui.confirmation_modal.is_visible());
}
#[test]
fn request_clear_rules_is_noop_with_empty_rules() {
let mut tui = FirewallTui::new(FirewallController::demo());
let cleared = tui.controller.clear_rules();
assert!(cleared > 0, "demo should have staged rules to clear first");
assert_eq!(tui.controller.rules().len(), 0);
tui.request_clear_rules();
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('c'),
KeyModifiers::empty(),
)));
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
}
#[test]
fn safety_popup_prevents_palette_and_help_keybinds() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('s'),
KeyModifiers::empty(),
)));
assert!(tui.safety_popup.is_visible());
let should_quit = tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char(':'),
KeyModifiers::empty(),
)));
assert!(!should_quit);
assert!(!tui.command_palette.is_open());
assert!(tui.safety_popup.is_visible());
let should_quit = tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('?'),
KeyModifiers::empty(),
)));
assert!(!should_quit);
assert!(!tui.help_modal.is_visible());
assert!(tui.safety_popup.is_visible());
let should_quit = tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('q'),
KeyModifiers::empty(),
)));
assert!(!should_quit);
assert!(!tui.safety_popup.is_visible());
assert!(tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('q'),
KeyModifiers::empty(),
))));
}
#[test]
fn confirmation_modal_blocks_profile_hotkey_without_state_change() {
let mut tui = FirewallTui::new(FirewallController::demo());
tui.request_block_visible_candidates();
assert!(tui.confirmation_modal.is_visible());
assert!(matches!(
tui.pending_confirmation.as_ref(),
Some(PendingAction::BlockVisibleCandidates(_))
));
let before_changes = tui.controller.rules().len();
let should_quit = tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('w'),
KeyModifiers::empty(),
)));
assert!(!should_quit);
assert!(tui.confirmation_modal.is_visible());
assert_eq!(tui.controller.rules().len(), before_changes);
let should_quit = tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char(':'),
KeyModifiers::empty(),
)));
assert!(!should_quit);
assert!(tui.confirmation_modal.is_visible());
assert!(!tui.command_palette.is_open());
}
#[test]
fn tab_shortcuts_cycle_tabs_and_jump_by_number() {
let mut tui = FirewallTui::new(FirewallController::demo());
assert_eq!(tui.selected_tab, 0);
assert!(!tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Tab,
KeyModifiers::empty(),
))));
assert_eq!(tui.selected_tab, 1);
assert!(!tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::BackTab,
KeyModifiers::empty(),
))));
assert_eq!(tui.selected_tab, 0);
assert!(!tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('4'),
KeyModifiers::empty(),
))));
assert_eq!(tui.selected_tab, 3);
assert!(!tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('5'),
KeyModifiers::empty(),
))));
assert_eq!(tui.selected_tab, 4);
}
#[test]
fn dashboard_focus_key_cycles_focus_only_on_dashboard() {
let mut tui = FirewallTui::new(FirewallController::demo());
assert_eq!(tui.selected_tab, 0);
assert_eq!(tui.focus, Focus::Rules);
assert!(!tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('f'),
KeyModifiers::empty(),
))));
assert_eq!(tui.focus, Focus::Ports);
tui.select_tab(1);
assert!(!tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('f'),
KeyModifiers::empty(),
))));
assert_eq!(tui.focus, Focus::Rules);
}
#[test]
fn handle_event_direct_profile_keys_are_noops_when_already_applied() {
let mut tui = FirewallTui::new(FirewallController::demo());
apply_profile_palette_action(
&mut tui,
"profile-workstation",
FirewallProfile::Workstation,
);
assert_eq!(
tui.controller
.profile_change_preview(FirewallProfile::Workstation)
.len(),
0
);
let before_rules = tui.controller.rules().len();
assert!(!tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('w'),
KeyModifiers::empty(),
))));
assert_eq!(tui.controller.rules().len(), before_rules);
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
assert!(!tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('Y'),
KeyModifiers::empty(),
))));
let recommended = tui.controller.recommended_profile().profile;
assert_eq!(tui.controller.profile_change_preview(recommended).len(), 0);
assert!(tui.pending_confirmation.is_none());
assert!(!tui.confirmation_modal.is_visible());
}
#[test]
fn help_modal_does_not_open_other_overlays_while_visible() {
let mut tui = FirewallTui::new(FirewallController::demo());
assert!(!tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('?'),
KeyModifiers::empty(),
))));
assert!(tui.help_modal.is_visible());
let should_quit = tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char(':'),
KeyModifiers::empty(),
)));
assert!(!should_quit);
assert!(!tui.command_palette.is_open());
let should_quit = tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('?'),
KeyModifiers::empty(),
)));
assert!(!should_quit);
assert!(!tui.help_modal.is_visible());
assert!(!tui.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('s'),
KeyModifiers::empty(),
))));
assert!(tui.safety_popup.is_visible());
}
}