use net_sdk::deck::{BlastRadius, BlastWarning};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
use crate::theme;
#[derive(Clone, Debug)]
pub enum ConfirmAction {
RestartAllDaemons {
node: u64,
node_display: String,
daemon_count: usize,
},
Cordon { node: u64, node_display: String },
Uncordon { node: u64, node_display: String },
Drain {
node: u64,
node_display: String,
drain_for: std::time::Duration,
},
EnterMaintenance {
node: u64,
node_display: String,
drain_for: Option<std::time::Duration>,
},
ExitMaintenance { node: u64, node_display: String },
ClearAvoidList { node: u64, node_display: String },
InvalidatePlacement { node: u64, node_display: String },
IceFreezeCluster {
ttl: std::time::Duration,
blast: BlastRadius,
},
IceThawCluster { blast: BlastRadius },
IceForceRestartDaemon {
daemon_id: u64,
daemon_name: String,
blast: BlastRadius,
},
DropReplicas {
node: u64,
node_display: String,
chains: Vec<u64>,
},
IceFlushAvoidLists { blast: BlastRadius },
IceKillMigration { migration: u64, blast: BlastRadius },
IceForceEvictReplica {
chain: u64,
victim: u64,
victim_display: String,
blast: BlastRadius,
},
IceForceCutover {
chain: u64,
target: u64,
target_display: String,
blast: BlastRadius,
},
}
impl ConfirmAction {
pub fn is_ice(&self) -> bool {
matches!(
self,
Self::IceFreezeCluster { .. }
| Self::IceThawCluster { .. }
| Self::IceForceRestartDaemon { .. }
| Self::IceFlushAvoidLists { .. }
| Self::IceKillMigration { .. }
| Self::IceForceEvictReplica { .. }
| Self::IceForceCutover { .. }
)
}
pub fn blast(&self) -> Option<&BlastRadius> {
match self {
Self::IceFreezeCluster { blast, .. }
| Self::IceThawCluster { blast }
| Self::IceForceRestartDaemon { blast, .. }
| Self::IceFlushAvoidLists { blast }
| Self::IceKillMigration { blast, .. }
| Self::IceForceEvictReplica { blast, .. }
| Self::IceForceCutover { blast, .. } => Some(blast),
_ => None,
}
}
}
impl ConfirmAction {
pub fn headline(&self) -> String {
match self {
Self::RestartAllDaemons { node_display, .. } => {
format!("restart all daemons on {node_display}")
}
Self::Cordon { node_display, .. } => format!("cordon node {node_display}"),
Self::Uncordon { node_display, .. } => {
format!("uncordon node {node_display}")
}
Self::Drain {
node_display,
drain_for,
..
} => {
format!(
"drain node {node_display} · window {}s",
drain_for.as_secs()
)
}
Self::EnterMaintenance {
node_display,
drain_for,
..
} => match drain_for {
Some(d) => format!(
"enter maintenance on {node_display} · window {}s",
d.as_secs()
),
None => format!("enter maintenance on {node_display} · cluster default"),
},
Self::ExitMaintenance { node_display, .. } => {
format!("exit maintenance on {node_display}")
}
Self::ClearAvoidList { node_display, .. } => {
format!("clear avoid list on {node_display}")
}
Self::InvalidatePlacement { node_display, .. } => {
format!("invalidate placement on {node_display}")
}
Self::IceFreezeCluster { ttl, .. } => {
format!("ICE freeze cluster · ttl {}s", ttl.as_secs())
}
Self::IceThawCluster { .. } => "ICE thaw cluster".to_string(),
Self::IceForceRestartDaemon {
daemon_id,
daemon_name,
..
} => format!("ICE force-restart daemon · 0x{daemon_id:x} · {daemon_name}"),
Self::DropReplicas {
node_display,
chains,
..
} => format!("drop {} replica(s) on {node_display}", chains.len()),
Self::IceFlushAvoidLists { .. } => {
"ICE flush avoid lists · global scope".to_string()
}
Self::IceKillMigration { migration, .. } => {
format!("ICE kill migration · 0x{migration:x}")
}
Self::IceForceEvictReplica {
chain,
victim_display,
..
} => format!("ICE force-evict replica · chain.0x{chain:x} from {victim_display}"),
Self::IceForceCutover {
chain,
target_display,
..
} => format!("ICE force-cutover · chain.0x{chain:x} → {target_display}"),
}
}
pub fn detail(&self) -> Vec<String> {
match self {
Self::RestartAllDaemons { daemon_count, .. } => vec![
format!("affects {daemon_count} daemon(s) on the host node"),
"each daemon is stopped and re-spawned by the supervisor".to_string(),
"fires `admin().restart_all_daemons(node)`".to_string(),
],
Self::Cordon { .. } => vec![
"stops new placements from landing on this node".to_string(),
"existing daemons + replicas stay; no eviction".to_string(),
"reversible via `[C]` (uncordon) without further effect".to_string(),
"fires `admin().cordon(node)`".to_string(),
],
Self::Uncordon { .. } => vec![
"re-admits the node to the placement scorer".to_string(),
"new replicas + daemons may land here on the next pass".to_string(),
"no-op if the node was never cordoned".to_string(),
"fires `admin().uncordon(node)`".to_string(),
],
Self::Drain { drain_for, .. } => vec![
format!("drains the node within {}s", drain_for.as_secs()),
"kicks the maintenance state machine: Active →".to_string(),
"EnteringMaintenance → Maintenance → DrainFailed?".to_string(),
"replicas evacuate; daemons receive Shutdown control event".to_string(),
"fires `admin().drain(node, drain_for)`".to_string(),
],
Self::EnterMaintenance { .. } => vec![
"begins an indefinite maintenance window".to_string(),
"drain runs to the deadline; node stays Maintenance".to_string(),
"no auto-exit — requires `[M]` to release".to_string(),
"fires `admin().enter_maintenance(node, drain_for)`".to_string(),
],
Self::ExitMaintenance { .. } => vec![
"ends an active maintenance window".to_string(),
"kicks Maintenance → ExitingMaintenance → Recovery".to_string(),
"no-op if the node wasn't in maintenance".to_string(),
"fires `admin().exit_maintenance(node)`".to_string(),
],
Self::ClearAvoidList { .. } => vec![
"wipes this node's local avoid list".to_string(),
"previously-avoided peers become eligible immediately".to_string(),
"reconcile may re-add entries next tick if RTT still bad".to_string(),
"fires `admin().clear_avoid_list(node)`".to_string(),
],
Self::InvalidatePlacement { .. } => vec![
"forces a placement recompute on the next tick".to_string(),
"useful after a capability / topology change".to_string(),
"no replica moves until the scorer re-runs".to_string(),
"fires `admin().invalidate_placement(node)`".to_string(),
],
Self::IceFreezeCluster { ttl, .. } => vec![
format!(
"freezes cluster-wide action emission for {}s",
ttl.as_secs()
),
"reconcile + folds keep running; only outbound actions stop".to_string(),
"auto-thaws at the deadline; `[T]` cancels early".to_string(),
"fires `ice().freeze_cluster(ttl)`".to_string(),
],
Self::IceThawCluster { .. } => vec![
"cancels an in-effect freeze immediately".to_string(),
"reconcile resumes action emission on the next tick".to_string(),
"no-op if no freeze is in effect".to_string(),
"fires `ice().thaw_cluster()`".to_string(),
],
Self::IceForceRestartDaemon { .. } => vec![
"force-restarts the daemon, bypassing crash-loop backoff".to_string(),
"supervisor's BackingOff / CrashLooping gate is cleared".to_string(),
"use after operator-side recovery — not a routine retry".to_string(),
"fires `ice().force_restart_daemon(daemon)`".to_string(),
],
Self::DropReplicas { chains, .. } => vec![
format!("evicts {} replica(s) from this node", chains.len()),
"desired_local_replicas → Drop; reconcile fires".to_string(),
"DropReplica actions; refill happens elsewhere".to_string(),
"fires `admin().drop_replicas(node, chains)`".to_string(),
],
Self::IceFlushAvoidLists { .. } => vec![
"flushes avoid-list entries cluster-wide".to_string(),
"every node clears its local avoid list".to_string(),
"reconcile may re-add entries on the next tick".to_string(),
"fires `ice().flush_avoid_lists(scope)`".to_string(),
],
Self::IceKillMigration { .. } => vec![
"aborts the in-flight migration on its host node".to_string(),
"MigrationOrchestrator drops the daemon's record".to_string(),
"no-op on nodes that aren't hosting this migration".to_string(),
"fires `ice().kill_migration(migration)`".to_string(),
],
Self::IceForceEvictReplica { .. } => vec![
"evicts the replica holder, bypassing scheduler".to_string(),
"cooldown + count-driven hysteresis".to_string(),
"elected chain leader emits the RequestEviction".to_string(),
"non-leaders fold the event but emit nothing".to_string(),
"fires `ice().force_evict_replica(chain, victim)`".to_string(),
],
Self::IceForceCutover { .. } => vec![
"pins the chain's next placement to the target".to_string(),
"bypasses the placement scorer for one pass".to_string(),
"elected chain leader emits the RequestPlacement".to_string(),
"no-op if target is already a holder".to_string(),
"fires `ice().force_cutover(chain, target)`".to_string(),
],
}
}
}
pub fn render(frame: &mut Frame<'_>, area: Rect, action: &ConfirmAction) {
let is_ice = action.is_ice();
let modal_height: u16 = if is_ice { 18 } else { 12 };
let modal_area = center(area, 72, modal_height);
frame.render_widget(Clear, modal_area);
let (border_style, title_glyph, title_text, title_color) = if is_ice {
(theme::red(), " ❄ ", "ICE BREAK-GLASS", theme::RED)
} else {
(theme::amber(), " ⚠ ", "CONFIRM", theme::AMBER)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(Line::from(vec![
Span::styled(title_glyph, border_style),
Span::styled(
title_text,
Style::default()
.fg(title_color)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
]))
.title_alignment(Alignment::Left);
let inner = block.inner(modal_area);
frame.render_widget(block, modal_area);
let constraints: Vec<Constraint> = if is_ice {
vec![
Constraint::Length(1), Constraint::Length(1), Constraint::Length(5), Constraint::Min(0), Constraint::Length(1), ]
} else {
vec![
Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ]
};
let bindings_idx = constraints.len() - 1;
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(inner);
let headline_style = if is_ice {
Style::default().fg(theme::RED).add_modifier(Modifier::BOLD)
} else {
theme::green_hi()
};
let headline = Line::from(vec![Span::styled(action.headline(), headline_style)]);
frame.render_widget(
Paragraph::new(headline).alignment(Alignment::Center),
rows[0],
);
let detail_lines: Vec<Line> = action
.detail()
.into_iter()
.map(|s| Line::from(Span::styled(s, theme::text())))
.collect();
frame.render_widget(
Paragraph::new(detail_lines).alignment(Alignment::Center),
rows[2],
);
if is_ice {
if let Some(blast) = action.blast() {
render_blast_radius(frame, rows[3], blast);
}
}
let bindings = Line::from(vec![
Span::styled(
"[Enter]",
if is_ice {
theme::red()
} else {
theme::green_hi()
},
),
Span::styled(" confirm ", theme::dim()),
Span::styled("[Esc]", theme::dim()),
Span::styled(" cancel", theme::dim()),
]);
frame.render_widget(
Paragraph::new(bindings).alignment(Alignment::Center),
rows[bindings_idx],
);
}
fn render_blast_radius(frame: &mut Frame<'_>, area: Rect, blast: &BlastRadius) {
let mut lines = Vec::new();
lines.push(Line::from(vec![Span::styled(
"── BLAST RADIUS ──",
theme::chrome(),
)]));
lines.push(Line::from(vec![
Span::styled("affects ", theme::chrome()),
Span::styled(
format!(
"{} node(s) · {} replica(s) · {} daemon(s)",
blast.affected_nodes.len(),
blast.affected_replicas.len(),
blast.affected_daemons.len()
),
theme::text(),
),
]));
if let Some(delay) = blast.estimated_drain_delay {
lines.push(Line::from(vec![
Span::styled("delay ", theme::chrome()),
Span::styled(format!("~{}s drain", delay.as_secs()), theme::text()),
]));
}
if blast.placement_stability_delta.abs() > 1e-3 {
lines.push(Line::from(vec![
Span::styled("stab Δ ", theme::chrome()),
Span::styled(
format!("{:+.2}", blast.placement_stability_delta),
theme::amber(),
),
]));
}
const MAX_VISIBLE_WARNINGS: usize = 3;
let total = blast.warnings.len();
for w in blast.warnings.iter().take(MAX_VISIBLE_WARNINGS) {
lines.push(Line::from(vec![
Span::styled("⚠ ", theme::amber()),
Span::styled(warning_label(w), theme::amber()),
]));
}
if total > MAX_VISIBLE_WARNINGS {
let hidden = total - MAX_VISIBLE_WARNINGS;
lines.push(Line::from(vec![Span::styled(
format!("⚠ … +{hidden} more (see AUDIT)"),
theme::amber(),
)]));
}
frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), area);
}
fn warning_label(w: &BlastWarning) -> String {
match w {
BlastWarning::ClusterFreezeBlocksOperatorActions => {
"freeze blocks operator actions until thaw".to_string()
}
BlastWarning::ThawResumesPendingReconciles => {
"thaw resumes reconcile transitions paused mid-flight".to_string()
}
BlastWarning::ThawHasNoFreezeToCancel => "no freeze in effect — thaw is a no-op".to_string(),
BlastWarning::GlobalAvoidFlushMayReEmit => {
"global avoid flush: reconcile may re-emit on the next tick".to_string()
}
BlastWarning::AvoidFlushLocalToTargetNodeOnly => {
"local-scope flush — other nodes ignore this event".to_string()
}
BlastWarning::AvoidFlushRecoversPeer { peer } => {
format!("every node will stop avoiding peer 0x{peer:x}")
}
BlastWarning::ForcedEvictionBypassesCooldown { chain, victim } => {
format!("force-evict bypasses cooldown (chain 0x{chain:x}, victim 0x{victim:x})")
}
BlastWarning::ForcedEvictionTargetsUnknownChain { chain } => {
format!("chain 0x{chain:x} not in snapshot — no leader will emit")
}
BlastWarning::ForcedEvictionTargetsNonHolder { chain, victim } => format!(
"victim 0x{victim:x} not currently a holder of chain 0x{chain:x} — no-op"
),
BlastWarning::ForcedRestartBypassesBackoff { daemon_id } => {
format!("force-restart bypasses crash-loop backoff (daemon 0x{daemon_id:x})")
}
BlastWarning::ForcedRestartTargetsUnknownDaemon { daemon_id } => {
format!("daemon 0x{daemon_id:x} not observed — likely a typo")
}
BlastWarning::ForcedRestartDaemonNotInBackoff { daemon_id } => {
format!("daemon 0x{daemon_id:x} already idle — restart is a no-op")
}
BlastWarning::ForcedCutoverBypassesPlacementScorer { chain, target } => {
format!("force-cutover bypasses placement scorer (chain 0x{chain:x}, target 0x{target:x})")
}
BlastWarning::ForcedCutoverTargetsUnknownChain { chain } => {
format!("chain 0x{chain:x} not in snapshot — no leader will emit")
}
BlastWarning::ForcedCutoverTargetAlreadyHolder { chain, target } => {
format!("target 0x{target:x} already a holder of chain 0x{chain:x} — no-op")
}
BlastWarning::KillMigrationDispatcherIntegrationPending { migration } => format!(
"kill_migration is local-only today — migration 0x{migration:x} may exist on another node"
),
_ => "unknown-warning".to_string(),
}
}
use super::center;