use net_sdk::deck::{MeshOsSnapshot, MigrationPhaseSnapshot, NodeId};
use ratatui::{
layout::{Alignment, Constraint, Rect},
text::{Line, Span},
widgets::{Block, Borders, Cell, Row, Table, TableState},
Frame,
};
use crate::{theme, widgets};
pub fn render(
frame: &mut Frame<'_>,
area: Rect,
snapshot: Option<&MeshOsSnapshot>,
cursor: usize,
this_node: NodeId,
) {
let has_records = snapshot
.map(|s| !s.in_flight_migrations.is_empty())
.unwrap_or(false);
if has_records {
render_table(frame, area, snapshot.unwrap(), cursor, this_node);
} else {
render_empty(frame, area);
}
}
fn render_empty(frame: &mut Frame<'_>, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme::rule())
.title(Line::from(vec![
Span::styled(format!("{} ", theme::SECTION_PREFIX), theme::green()),
Span::styled("MIGRATIONS", theme::green_hi()),
Span::styled(" 0 in flight", theme::chrome()),
]));
let inner = block.inner(area);
frame.render_widget(block, area);
widgets::empty::render(
frame,
inner,
"no daemon migrations in flight",
"wire a MigrationSnapshotSource (production: OrchestratorMigrationSnapshotSource)",
);
}
fn render_table(
frame: &mut Frame<'_>,
area: Rect,
snapshot: &MeshOsSnapshot,
cursor: usize,
this_node: NodeId,
) {
let total = snapshot.in_flight_migrations.len();
let pos = cursor.min(total.saturating_sub(1)) + 1;
let body_h = (area.height as usize).saturating_sub(2).saturating_sub(1);
let (start, end, hidden_above, hidden_below) = super::scroll_window(total, body_h, cursor);
let mut title_spans = vec![
Span::styled(format!("{} ", theme::SECTION_PREFIX), theme::green()),
Span::styled("MIGRATIONS", theme::green_hi()),
Span::styled(format!(" {total} in flight"), theme::chrome()),
Span::styled(format!(" {pos}/{total}"), theme::dim()),
];
if hidden_above > 0 {
title_spans.push(Span::styled(
format!(" ▲ {hidden_above} more"),
theme::dim(),
));
}
if hidden_below > 0 {
title_spans.push(Span::styled(
format!(" ▼ {hidden_below} more"),
theme::dim(),
));
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme::rule())
.title(Line::from(title_spans))
.title_alignment(Alignment::Left);
let header = Row::new(vec![
cell_dim(" "),
cell_dim("DAEMON"),
cell_dim("SOURCE"),
cell_dim("TARGET"),
cell_dim("ROLE"),
cell_dim("SIZE"),
cell_dim("PHASE"),
cell_dim("PROG"),
cell_dim("RETRY"),
cell_dim("AGE/PHASE"),
cell_dim("ELAPSED"),
])
.height(1);
let mut rows: Vec<Row> = Vec::with_capacity(end.saturating_sub(start));
for (offset, m) in snapshot.in_flight_migrations[start..end].iter().enumerate() {
let i = start + offset;
let is_cursor = i == cursor;
let marker = if is_cursor { "▶" } else { " " };
let daemon_text = format!("daemon.0x{:x}", m.daemon_origin);
let daemon_style = if is_cursor {
theme::green_hi()
} else {
theme::text()
};
let source_text = node_label(m.source_node);
let target_text = node_label(m.target_node);
let (role_text, role_style) = role_for(m.source_node, m.target_node, this_node);
let size_text = match m.snapshot_bytes {
Some(n) => format_bytes(n),
None => "—".to_string(),
};
let (phase_style, phase_text) = phase_repr(&m.phase);
let prog_text = match m.progress_pct {
Some(p) => format!("{p}%"),
None => "—".to_string(),
};
let retry_text = format!("{}", m.retries);
let retry_style = if m.retries == 0 {
theme::dim()
} else if m.retries < 3 {
theme::amber()
} else {
theme::red()
};
rows.push(Row::new(vec![
Cell::from(Span::styled(marker, theme::green_hi())),
Cell::from(Span::styled(daemon_text, daemon_style)),
Cell::from(Span::styled(source_text, theme::cyan())),
Cell::from(Span::styled(target_text, theme::cyan())),
Cell::from(Span::styled(role_text, role_style)),
Cell::from(Span::styled(size_text, theme::text())),
Cell::from(Span::styled(phase_text, phase_style)),
Cell::from(Span::styled(prog_text, theme::text())),
Cell::from(Span::styled(retry_text, retry_style)),
Cell::from(Span::styled(format_age(m.age_in_phase_ms), theme::text())),
Cell::from(Span::styled(format_age(m.elapsed_ms), theme::dim())),
]));
}
let table = Table::new(
rows,
[
Constraint::Length(2), Constraint::Length(18), Constraint::Length(10), Constraint::Length(10), Constraint::Length(8), Constraint::Length(8), Constraint::Length(10), Constraint::Length(5), Constraint::Length(5), Constraint::Length(10), Constraint::Min(0), ],
)
.header(header)
.block(block)
.column_spacing(2);
let selected = cursor.checked_sub(start).filter(|s| start + *s < end);
let mut state = TableState::default().with_selected(selected);
frame.render_stateful_widget(table, area, &mut state);
}
fn node_label(id: u64) -> String {
format!("0x{id:x}")
}
fn role_for(
source: NodeId,
target: NodeId,
this_node: NodeId,
) -> (&'static str, ratatui::style::Style) {
if this_node == target {
("target", theme::green())
} else if this_node == source {
("source", theme::cyan())
} else {
("observer", theme::dim())
}
}
use super::format_bytes;
fn phase_repr(p: &MigrationPhaseSnapshot) -> (ratatui::style::Style, &'static str) {
match p {
MigrationPhaseSnapshot::Snapshot => (theme::dim(), "Snapshot"),
MigrationPhaseSnapshot::Transfer => (theme::cyan(), "Transfer"),
MigrationPhaseSnapshot::Restore => (theme::cyan(), "Restore"),
MigrationPhaseSnapshot::Replay => (theme::cyan(), "Replay"),
MigrationPhaseSnapshot::Cutover => (theme::amber(), "Cutover"),
MigrationPhaseSnapshot::Complete => (theme::green(), "Complete"),
_ => (theme::chrome(), "?"),
}
}
fn cell_dim(s: &'static str) -> Cell<'static> {
Cell::from(Span::styled(s, theme::chrome()))
}
use super::format_age_ms as format_age;