use net_sdk::dataforts::{BlobInventoryEntry, DEFAULT_RETENTION_FLOOR};
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,
entries: &[BlobInventoryEntry],
cursor: usize,
search: &str,
search_editing: bool,
) {
if entries.is_empty() && search.is_empty() && !search_editing {
render_empty(frame, area);
return;
}
render_table(frame, area, entries, cursor, search, search_editing);
}
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("BLOBS", theme::green_hi()),
Span::styled(" 0 chunks", theme::chrome()),
]));
let inner = block.inner(area);
frame.render_widget(block, area);
widgets::empty::render(
frame,
inner,
"no blob chunks indexed",
"wire a `MeshBlobAdapter` + store blobs",
);
}
fn render_table(
frame: &mut Frame<'_>,
area: Rect,
entries: &[BlobInventoryEntry],
cursor: usize,
search: &str,
search_editing: bool,
) {
let needle = search.to_ascii_lowercase();
let visible: Vec<&BlobInventoryEntry> = entries
.iter()
.filter(|e| needle.is_empty() || e.hash_hex.contains(&needle))
.collect();
let total = entries.len();
let shown = visible.len();
let pos = if shown == 0 {
0
} else {
cursor.min(shown - 1) + 1
};
let body_h = (area.height as usize).saturating_sub(2).saturating_sub(1);
let effective_cursor = cursor.min(shown.saturating_sub(1));
let (start, end, hidden_above, hidden_below) =
super::scroll_window(shown, body_h, effective_cursor);
let mut title_spans = vec![
Span::styled(format!("{} ", theme::SECTION_PREFIX), theme::green()),
Span::styled("BLOBS", theme::green_hi()),
Span::styled(format!(" {shown}/{total} chunks"), theme::chrome()),
Span::styled(format!(" {pos}/{shown}"), 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(),
));
}
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))
.title_alignment(Alignment::Left);
let header = Row::new(vec![
cell_dim(" "),
cell_dim("ADAPTER"),
cell_dim("HASH"),
cell_dim("SIZE"),
cell_dim("REPL"),
cell_dim("STATUS"),
cell_dim("AGE"),
cell_dim("CHANNEL"),
])
.height(1);
let now_ms = unix_now_ms();
let floor_ms = DEFAULT_RETENTION_FLOOR.as_millis() as u64;
let mut rows: Vec<Row> = Vec::with_capacity(end.saturating_sub(start));
for (offset, e) in visible[start..end].iter().enumerate() {
let i = start + offset;
let is_cursor = i == effective_cursor;
let marker = if is_cursor { "▶" } else { " " };
let hash_short = if e.hash_hex.len() > 16 {
format!(
"{}…{}",
&e.hash_hex[..8],
&e.hash_hex[e.hash_hex.len() - 8..]
)
} else {
e.hash_hex.clone()
};
let hash_style = if is_cursor {
theme::green_hi()
} else {
theme::text()
};
let size_text = match e.size_bytes {
Some(n) => format_bytes(n),
None => "—".to_string(),
};
let repl_text = match (e.replicas_observed, e.replica_target) {
(Some(o), Some(t)) => format!("{o}/{t}"),
(None, Some(t)) => format!("—/{t}"),
(Some(o), None) => format!("{o}/—"),
(None, None) => "—".to_string(),
};
let repl_style = match (e.replicas_observed, e.replica_target) {
(Some(o), Some(t)) if o < t => theme::amber(),
(Some(_), Some(_)) => theme::green(),
_ => theme::dim(),
};
let age_first = now_ms.saturating_sub(e.first_seen_unix_ms);
let (status_text, status_style) = if e.pinned {
("pinned", theme::amber())
} else if e.refcount > 0 {
("live", theme::green())
} else if age_first >= floor_ms {
("sweepable", theme::red())
} else {
("quiet", theme::dim())
};
let age_text = format_relative(e.first_seen_unix_ms, now_ms);
let channel_text = if e.hash_hex.len() >= 2 && e.hash_hex.is_char_boundary(2) {
format!("blob/{}/{}", &e.hash_hex[..2], &e.hash_hex[2..])
} else {
"blob/?".to_string()
};
rows.push(Row::new(vec![
Cell::from(Span::styled(marker, theme::green_hi())),
Cell::from(Span::styled(e.adapter_id.clone(), theme::cyan())),
Cell::from(Span::styled(hash_short, hash_style)),
Cell::from(Span::styled(size_text, theme::text())),
Cell::from(Span::styled(repl_text, repl_style)),
Cell::from(Span::styled(status_text, status_style)),
Cell::from(Span::styled(age_text, theme::dim())),
Cell::from(Span::styled(channel_text, theme::chrome())),
]));
}
let table = Table::new(
rows,
[
Constraint::Length(2), Constraint::Length(12), Constraint::Length(19), Constraint::Length(8), Constraint::Length(7), Constraint::Length(10), Constraint::Length(11), Constraint::Min(0), ],
)
.header(header)
.block(block)
.column_spacing(2);
let selected = effective_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);
if shown == 0 && total > 0 {
let inner = area.inner(ratatui::layout::Margin {
vertical: 2,
horizontal: 2,
});
let hint = Line::from(Span::styled(
format!("no chunks match \"{search}\" — {total} hidden by filter"),
theme::dim(),
));
frame.render_widget(
ratatui::widgets::Paragraph::new(hint).alignment(Alignment::Left),
inner,
);
}
}
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: &BlobInventoryEntry, needle_lower: &str) -> bool {
if needle_lower.is_empty() {
return true;
}
rec.hash_hex.contains(needle_lower)
}
fn cell_dim(s: &'static str) -> Cell<'static> {
Cell::from(Span::styled(s, theme::chrome()))
}
use super::unix_now_ms;
fn format_relative(then_ms: u64, now_ms: u64) -> String {
let delta = now_ms.saturating_sub(then_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)
}
}
use super::format_bytes;