use crate::firewall::{
AdvisorSeverity, CandidateFilter, CandidateStatus, FirewallController, FirewallProfile,
ReviewGateStatus, RuleAction,
};
use crossterm::event::{Event, KeyCode, KeyEventKind};
use scrin::command_palette::{Command, CommandPalette};
use scrin::core::{buffer::Buffer, color::Color, rect::Rect};
use scrin::effects::{EffectKind, EffectPlayer};
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, 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(4, 8, 14);
const PANEL: Color = Color::rgb(12, 19, 36);
const DIM: Color = Color::rgb(91, 107, 130);
const TEXT: Color = Color::rgb(226, 232, 240);
const ACCENT: Color = Color::rgb(56, 189, 170);
const WARN: Color = Color::rgb(226, 88, 88);
const OK: Color = Color::rgb(56, 189, 170);
const FOCUS: Color = Color::rgb(34, 211, 238);
const SELECTED_BG: Color = Color::rgb(20, 63, 58);
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;
#[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 {
ApplyProfile(FirewallProfile),
ApplyRecommendedProfile,
ApplySafeRecommendedProfile,
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,
}
pub struct FirewallTui {
controller: FirewallController,
focus: Focus,
selected_tab: usize,
selected_rule: 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>,
toasts: Vec<Toast>,
last_frame_timing: Option<FrameTiming>,
show_frame_metrics: bool,
}
impl FirewallTui {
pub fn new(controller: FirewallController) -> Self {
Self {
controller,
focus: Focus::Rules,
selected_tab: 0,
selected_rule: 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", "Dry-run 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(),
"Enter/y/Y: confirm | n/Esc: cancel in confirmation 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",
"No live executor is compiled in.\nNo ufw, iptables, nft, or /etc writes happen.\nThe plan panel is preview-only.\nCLI and live apply can be added later behind explicit guards.",
)
.with_style(PopupStyle::Panel)
.with_size(58, 9)
.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,
last_frame_timing: None,
show_frame_metrics: read_frame_metrics_flag(),
toasts: vec![Toast::new("Dry-run safe 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()?;
loop {
let frame_timing = terminal.draw_timed(|frame| self.render(frame))?;
self.last_frame_timing = Some(frame_timing);
if let Some(event) = terminal.poll_event()? {
if self.handle_event(event) {
break;
}
}
let frame_step = Duration::from_millis(45);
self.banner.advance();
self.update_overlays(frame_step);
std::thread::sleep(frame_step);
}
terminal.restore()
}
fn handle_event(&mut self, event: Event) -> bool {
let Event::Key(key) = event else {
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 => {
if let Some(action) = self.command_palette.execute_selected().map(str::to_owned)
{
self.command_palette.close();
self.run_palette_action(&action);
}
}
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;
}
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('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;
}
"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;
}
"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 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 | 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_modal = Modal::new(title, message)
.with_style(ModalStyle::Confirm)
.with_border_color(WARN)
.with_transition(Transition::Scale)
.with_options(vec!["Confirm".into(), "Cancel".into()]);
self.confirmation_modal.show();
}
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_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 dry-run plan?", staged),
PendingAction::ClearRules(staged),
);
}
fn request_reset_to_starter_plan(&mut self) {
self.request_confirmation(
"Reset starter plan",
"Restore the safe starter dry-run plan baseline?",
PendingAction::ResetStarterPlan,
);
}
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.confirmation_modal.hide();
return;
};
let proceed = selected.unwrap_or_else(|| {
self.confirmation_modal
.selected_option()
.is_some_and(|option| option == "Confirm")
});
self.confirmation_modal.hide();
if !proceed {
self.toast("Action cancelled", ToastKind::Info);
return;
}
match action {
PendingAction::ApplyProfile(profile) => self.apply_profile(profile),
PendingAction::ApplyRecommendedProfile => self.apply_recommended_profile(),
PendingAction::ApplySafeRecommendedProfile => self.apply_safe_recommended_profile(),
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 dry-run plan restored", 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 {
"Dry-run profile applied"
},
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("Dry-run report exported", ToastKind::Success);
}
Err(_) => self.toast("Dry-run 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("Dry-run 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 toggle_selected_exception(&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_exception(index) {
Some(true) => self.toast("Reviewed exception recorded", ToastKind::Warning),
Some(false) => self.toast("Reviewed exception removed", ToastKind::Info),
None => self.toast(
"Allow an at-risk port before exception review",
ToastKind::Warning,
),
}
}
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 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 => {}
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 => {}
}
}
fn render(&mut self, frame: &mut Frame<'_>) {
let area = frame.area();
let buffer = frame.buffer();
buffer.fill(area, ' ', TEXT, Some(BG));
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 { 5 } 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);
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);
}
fn render_dashboard(&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)
.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),
);
}
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),
);
}
if left.height > 4 {
buffer.set_str(
left.x as usize + 1,
left.y as usize + 4,
"live executor: absent | I/O policies | c clear rules | r fix | u undo",
ACCENT,
Some(PANEL),
);
}
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),
);
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),
);
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),
);
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),
);
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),
);
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)
.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));
buffer.set_str(
inner.x as usize + 1,
y + 1,
"ufw-style firewall control, dry-run planning only",
TEXT,
Some(PANEL),
);
} else {
buffer.set_str(
inner.x as usize,
inner.y as usize,
"dry-run firewall planner",
TEXT,
Some(PANEL),
);
}
if inner.width > 54 && inner.height >= 3 {
let note = "dry-run only | staged rules | no system writes";
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),
);
}
}
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(Color::rgb(30, 41, 59)));
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
),
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(Style::new().fg(Color::WHITE).bg(SELECTED_BG))
.render(buffer, inner);
}
fn render_ports(&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 items = visible
.iter()
.map(|index| {
let candidate = &self.controller.candidates()[*index];
let status = self
.controller
.candidate_status(*index)
.unwrap_or(CandidateStatus::Open);
let action = if status == CandidateStatus::Blocked {
"unblock"
} else if self.controller.candidate_has_review_exception(*index) {
"reviewed"
} else if status == CandidateStatus::Allowed
&& matches!(candidate.risk, "high" | "data" | "admin")
{
"needs X"
} else {
"block"
};
ListItem::with_line(Line::new(vec![
Span::styled(
&format!("{:<8}", status.label()),
Style::new().fg(status_color(status)).bold(),
),
Span::styled(
&format!("{:<12}", candidate.name),
Style::new().fg(TEXT).bold(),
),
Span::styled(
&format!(" {:>7}/{:<3} ", candidate.port, candidate.protocol),
Style::new().fg(ACCENT),
),
Span::styled(&format!("{:<13}", candidate.group), Style::new().fg(DIM)),
Span::styled(&format!("{:<9}", action), Style::new().fg(ACCENT)),
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(Style::new().fg(Color::WHITE).bg(SELECTED_BG))
.render(buffer, list_area);
}
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] o exports report",
focus_marker(self.focus == Focus::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,
"Generated commands are review-only. They are not executed.",
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 rows = plan
.iter()
.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<_>>();
Table::new(&rows, &widths).with_header(&header).render(
buffer,
Rect::new(
inner.x,
inner.y + 9,
inner.width,
inner.height.saturating_sub(9),
),
);
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(),
"Generated commands are review-only. They are not executed.".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);
for command in plan.iter().take(command_limit) {
lines.push(format!("$ {}", command.shell_line()));
lines.push(format!(" # {}", command.reason));
}
if plan.len() > command_limit {
lines.push(format!(
"... {} more planned commands",
plan.len() - 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));
Table::new(&rows, &widths)
.with_header(&header)
.with_selected(selected)
.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(Style::new().fg(Color::WHITE).bg(SELECTED_BG))
.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("DRY RUN LOCK", ACCENT);
status.set_center(self.controller.last_message(), DIM);
let right = if self.confirmation_modal.is_visible() {
"Confirmation: Enter selects option, y confirm, n/Esc cancel | Arrow keys move"
} else {
"N priority | F/G/K filters | V block | A allow | 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 {
Color::rgb(51, 65, 85)
};
Block::new(title)
.with_bg(PANEL)
.border_style(Style::new().fg(color))
}
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 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 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),
);
}
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 dry-run advisor review", "advisor"),
command(
"Block selected port",
"b",
"Stage a deny rule for the selected service port",
"block-selected",
),
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 dry-run profile",
"profile-recommended",
),
command(
"Apply safe recommended profile",
"y",
"Stage the management-preserving recommended dry-run 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 dry-run 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 dry-run baseline",
"reset-starter",
),
command(
"Export dry-run report",
"o",
"Write a local review-only 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 a review-only machine-readable dry-run plan",
"export-manifest",
),
command(
"Export review bundle",
"E",
"Write report, safe script, and JSON manifest together",
"export-bundle",
),
command("Safety popup", "s", "Show dry-run safety lock", "safety"),
command("Help", "?", "Show keyboard help", "help"),
]
}
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};
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 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);
tui.resolve_confirmation(Some(true));
}
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,
CandidateFilter,
Option<usize>,
Option<usize>,
Focus,
) {
(
tui.selected_tab,
tui.selected_rule,
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,
CandidateFilter,
Option<usize>,
Option<usize>,
Focus,
),
second: &(
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!(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!(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!(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 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 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.controller.rules().len(), 0);
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
));
tui.resolve_confirmation(Some(true));
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)
));
tui.resolve_confirmation(Some(true));
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)
));
tui.resolve_confirmation(Some(true));
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)
));
tui.resolve_confirmation(Some(true));
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)
));
tui.resolve_confirmation(Some(true));
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_toggles() {
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());
}
#[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));
tui.resolve_confirmation(Some(true));
assert_eq!(tui.controller.rules().len(), 0);
tui.run_palette_action("reset-starter");
assert!(matches!(
tui.pending_confirmation.as_ref(),
Some(PendingAction::ResetStarterPlan)
));
tui.resolve_confirmation(Some(true));
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());
}
}