use net_sdk::deck::FailureRecord;
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,
records: &[FailureRecord],
cursor: usize,
search: &str,
search_editing: bool,
) {
if records.is_empty() {
render_empty(frame, area, search, search_editing);
} else {
render_table(frame, area, records, cursor, search, search_editing);
}
}
fn render_empty(frame: &mut Frame<'_>, area: Rect, search: &str, search_editing: bool) {
let mut title_spans = vec![
Span::styled(format!("{} ", theme::SECTION_PREFIX), theme::green()),
Span::styled("FAILURES", theme::green_hi()),
Span::styled(" 0 records", theme::chrome()),
];
append_search_chip(&mut title_spans, search, search_editing);
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme::rule())
.title(Line::from(title_spans));
let inner = block.inner(area);
frame.render_widget(block, area);
widgets::empty::render(
frame,
inner,
"no failures recorded",
"executor rejections / drain failures / constraint drops will appear here",
);
}
fn render_table(
frame: &mut Frame<'_>,
area: Rect,
records: &[FailureRecord],
cursor: usize,
search: &str,
search_editing: bool,
) {
let needle = search.to_ascii_lowercase();
let visible: Vec<&FailureRecord> = records
.iter()
.rev()
.filter(|r| needle.is_empty() || record_matches(r, &needle))
.collect();
let total = records.len();
let shown = visible.len();
let pos = if shown == 0 {
0
} else {
cursor.min(shown - 1) + 1
};
let mut title_spans = vec![
Span::styled(format!("{} ", theme::SECTION_PREFIX), theme::green()),
Span::styled("FAILURES", theme::green_hi()),
Span::styled(format!(" {shown}/{total} records"), theme::chrome()),
Span::styled(format!(" {pos}/{shown}"), theme::dim()),
];
append_search_chip(&mut title_spans, search, search_editing);
let header_line = Line::from(title_spans);
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme::rule())
.title(header_line)
.title_alignment(Alignment::Left);
let header = Row::new(vec![
cell_dim(" "),
cell_dim("SEQ"),
cell_dim("WHEN"),
cell_dim("SOURCE"),
cell_dim("REASON"),
])
.height(1);
let now_ms = unix_now_ms();
let mut rows: Vec<Row> = Vec::with_capacity(shown);
let effective_cursor = cursor.min(shown.saturating_sub(1));
for (i, rec) in visible.iter().enumerate() {
let is_cursor = i == effective_cursor;
let marker = if is_cursor { "▶" } else { " " };
let when = format_relative(rec.recorded_at_ms, now_ms);
let seq_text = if rec.seq == 0 {
" —".to_string()
} else {
format!("{:>5}", rec.seq)
};
let reason_style = if is_cursor {
theme::green_hi()
} else {
theme::text()
};
rows.push(Row::new(vec![
Cell::from(Span::styled(marker, theme::green_hi())),
Cell::from(Span::styled(seq_text, theme::dim())),
Cell::from(Span::styled(when, theme::text())),
Cell::from(Span::styled(rec.source.clone(), theme::amber())),
Cell::from(Span::styled(rec.reason.clone(), reason_style)),
]));
}
let table = Table::new(
rows,
[
Constraint::Length(2), Constraint::Length(5), Constraint::Length(9), Constraint::Length(24), Constraint::Min(0), ],
)
.header(header)
.block(block)
.column_spacing(2);
let mut state =
TableState::default().with_selected(Some(effective_cursor.min(shown.saturating_sub(1))));
frame.render_stateful_widget(table, area, &mut state);
if shown == 0 && total > 0 {
let inner = area.inner(ratatui::layout::Margin {
vertical: 2,
horizontal: 2,
});
let hint = Line::from(Span::styled(
format!("no matches for \"{search}\" — {total} records hidden by filter"),
theme::dim(),
));
frame.render_widget(
ratatui::widgets::Paragraph::new(hint).alignment(Alignment::Left),
inner,
);
}
}
fn cell_dim(s: &'static str) -> Cell<'static> {
Cell::from(Span::styled(s, theme::chrome()))
}
fn append_search_chip(spans: &mut Vec<Span<'static>>, search: &str, search_editing: bool) {
if search_editing {
spans.push(Span::styled(" / ", theme::amber()));
spans.push(Span::styled(search.to_string(), theme::green_hi()));
spans.push(Span::styled("_", theme::amber()));
spans.push(Span::styled(
" [Enter] commit [Esc] cancel",
theme::dim(),
));
} else if !search.is_empty() {
spans.push(Span::styled(
format!(" [match /{search}/]"),
theme::amber(),
));
}
}
pub(crate) fn record_matches(rec: &FailureRecord, needle_lower: &str) -> bool {
if needle_lower.is_empty() {
return true;
}
super::audit::ascii_icontains(&rec.source, needle_lower)
|| super::audit::ascii_icontains(&rec.reason, needle_lower)
}
use super::unix_now_ms;
fn format_relative(recorded_at_ms: u64, now_ms: u64) -> String {
let delta = now_ms.saturating_sub(recorded_at_ms) / 1_000;
if delta < 60 {
format!("{delta}s ago")
} else if delta < 3_600 {
format!("{}m ago", delta / 60)
} else {
format!("{}h ago", delta / 3_600)
}
}