use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
};
pub const WIDE_LAYOUT_MIN_COLS: u16 = 120;
pub const KEY_HINT: &str = "[Tab] focus [r] reindex [q] quit [?] help";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Focus {
#[default]
Search,
Memory,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct IndexRow {
pub id: String,
pub chunk_count: u64,
pub root_path: String,
}
#[derive(Debug, Clone, Default)]
pub struct SearchData {
pub version: String,
pub uptime_secs: u64,
pub indexes: Vec<IndexRow>,
}
impl SearchData {
pub fn total_chunks(&self) -> u64 {
self.indexes.iter().map(|i| i.chunk_count).sum()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PalaceRow {
pub id: String,
pub name: String,
pub vector_count: u64,
}
#[derive(Debug, Clone, Default)]
pub struct MemoryData {
pub version: String,
pub palace_count: u64,
pub total_drawers: u64,
pub total_vectors: u64,
pub total_kg_triples: u64,
pub palaces: Vec<PalaceRow>,
}
#[derive(Debug, Clone)]
pub enum PanelStatus<T> {
Connecting,
Online(T),
Offline {
last_error: String,
},
}
impl<T> PanelStatus<T> {
pub fn is_online(&self) -> bool {
matches!(self, PanelStatus::Online(_))
}
}
#[derive(Debug, Clone)]
pub struct DaemonPanel<T> {
pub status: PanelStatus<T>,
pub base_url: String,
}
impl<T> DaemonPanel<T> {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
status: PanelStatus::Connecting,
base_url: base_url.into(),
}
}
}
#[derive(Debug, Clone)]
pub struct DashboardState {
pub search: DaemonPanel<SearchData>,
pub memory: DaemonPanel<MemoryData>,
pub focus: Focus,
pub show_help: bool,
pub last_action: Option<String>,
}
impl DashboardState {
pub fn new(search_url: impl Into<String>, memory_url: impl Into<String>) -> Self {
Self {
search: DaemonPanel::new(search_url),
memory: DaemonPanel::new(memory_url),
focus: Focus::Search,
show_help: false,
last_action: None,
}
}
pub fn toggle_focus(&mut self) {
self.focus = match self.focus {
Focus::Search => Focus::Memory,
Focus::Memory => Focus::Search,
};
}
pub fn reindex_target(&self) -> Option<String> {
if self.focus != Focus::Search {
return None;
}
match &self.search.status {
PanelStatus::Online(data) => data.indexes.first().map(|i| i.id.clone()),
_ => None,
}
}
}
pub fn format_uptime(secs: u64) -> String {
let hours = secs / 3600;
let minutes = (secs % 3600) / 60;
format!("{hours}h {minutes}m")
}
pub fn format_count(n: u64) -> String {
if n >= 10_000 {
let thousands = n as f64 / 1000.0;
format!("{thousands:.1}k")
} else {
group_thousands(n)
}
}
fn group_thousands(n: u64) -> String {
let digits = n.to_string();
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
let bytes = digits.as_bytes();
for (i, b) in bytes.iter().enumerate() {
if i > 0 && (bytes.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(*b as char);
}
out
}
pub fn panel_layout(width: u16) -> (Direction, [Constraint; 2]) {
if width >= WIDE_LAYOUT_MIN_COLS {
(
Direction::Horizontal,
[Constraint::Percentage(50), Constraint::Percentage(50)],
)
} else {
(
Direction::Vertical,
[Constraint::Percentage(50), Constraint::Percentage(50)],
)
}
}
pub fn help_text() -> String {
[
" Tab switch focus between the search and memory panels",
" r reindex the first index of the focused search panel",
" ? toggle this help overlay",
" Esc close this help overlay",
" q quit",
"",
" Offline panels retry automatically every 5 seconds.",
]
.join("\n")
}
pub fn status_badge<T>(status: &PanelStatus<T>) -> (char, &'static str, Color) {
match status {
PanelStatus::Online(_) => ('●', "ONLINE", Color::Green),
PanelStatus::Connecting => ('◌', "CONNECTING", Color::Yellow),
PanelStatus::Offline { .. } => ('○', "OFFLINE", Color::Red),
}
}
pub fn search_panel_lines(panel: &DaemonPanel<SearchData>) -> Vec<String> {
match &panel.status {
PanelStatus::Connecting => vec![format!("connecting to {}…", panel.base_url)],
PanelStatus::Offline { last_error } => vec![
format!("daemon unreachable at {}", panel.base_url),
format!("last error: {last_error}"),
"retrying every 5s…".to_string(),
],
PanelStatus::Online(data) => {
let mut lines = vec![
format!("Uptime: {}", format_uptime(data.uptime_secs)),
format!("Indexes: {}", data.indexes.len()),
format!("Total chunks: {}", format_count(data.total_chunks())),
String::new(),
];
if data.indexes.is_empty() {
lines.push("(no indexes registered)".to_string());
} else {
for idx in &data.indexes {
lines.push(format!(
"{:<16} {:>10} chunks",
truncate(&idx.id, 16),
format_count(idx.chunk_count),
));
}
}
lines
}
}
}
pub fn memory_panel_lines(panel: &DaemonPanel<MemoryData>) -> Vec<String> {
match &panel.status {
PanelStatus::Connecting => vec![format!("connecting to {}…", panel.base_url)],
PanelStatus::Offline { last_error } => vec![
format!("daemon unreachable at {}", panel.base_url),
format!("last error: {last_error}"),
"retrying every 5s…".to_string(),
],
PanelStatus::Online(data) => {
let mut lines = vec![
format!("Palaces: {}", data.palace_count),
format!("Drawers: {}", format_count(data.total_drawers)),
format!("Vectors: {}", format_count(data.total_vectors)),
format!("KG triples: {}", format_count(data.total_kg_triples)),
String::new(),
];
if data.palaces.is_empty() {
lines.push("(no palaces)".to_string());
} else {
for palace in &data.palaces {
let label = if palace.name.is_empty() {
truncate(&palace.id, 16)
} else {
truncate(&palace.name, 16)
};
lines.push(format!(
"{:<16} {:>10} vectors",
label,
format_count(palace.vector_count),
));
}
}
lines
}
}
}
pub fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let kept: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{kept}…")
}
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let w = width.min(area.width);
let h = height.min(area.height);
Rect {
x: area.x + (area.width.saturating_sub(w)) / 2,
y: area.y + (area.height.saturating_sub(h)) / 2,
width: w,
height: h,
}
}
fn panel_block(name: &str, badge: (char, &str, Color), focused: bool) -> Block<'static> {
let (glyph, label, color) = badge;
let title = Line::from(vec![
Span::styled(
format!(" {name} "),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(format!("{glyph} {label} "), Style::default().fg(color)),
]);
let border_style = if focused {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(title)
}
fn render_help_overlay(frame: &mut Frame) {
let area = centered_rect(56, 11, frame.area());
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(help_text())
.style(Style::default().fg(Color::White))
.block(
Block::default()
.borders(Borders::ALL)
.title(" Help — press ? or Esc to close "),
),
area,
);
}
pub fn render(frame: &mut Frame, state: &DashboardState) {
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(6)])
.split(frame.area());
let header_lines = vec![
Line::from(Span::styled(
format!(" trusty-monitor v{VERSION} "),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
state
.last_action
.clone()
.unwrap_or_else(|| KEY_HINT.to_string()),
Style::default().fg(Color::Gray),
)),
];
frame.render_widget(
Paragraph::new(header_lines).block(Block::default().borders(Borders::ALL)),
outer[0],
);
let (direction, constraints) = panel_layout(frame.area().width);
let panels = Layout::default()
.direction(direction)
.constraints(constraints)
.split(outer[1]);
let search_block = panel_block(
"SEARCH",
search_version_badge(&state.search),
state.focus == Focus::Search,
);
frame.render_widget(
Paragraph::new(
search_panel_lines(&state.search)
.into_iter()
.map(Line::from)
.collect::<Vec<_>>(),
)
.block(search_block),
panels[0],
);
let memory_block = panel_block(
"MEMORY",
memory_version_badge(&state.memory),
state.focus == Focus::Memory,
);
frame.render_widget(
List::new(
memory_panel_lines(&state.memory)
.into_iter()
.map(ListItem::new)
.collect::<Vec<_>>(),
)
.block(memory_block),
panels[1],
);
if state.show_help {
render_help_overlay(frame);
}
}
fn search_version_badge(panel: &DaemonPanel<SearchData>) -> (char, &'static str, Color) {
status_badge(&panel.status)
}
fn memory_version_badge(panel: &DaemonPanel<MemoryData>) -> (char, &'static str, Color) {
status_badge(&panel.status)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{Terminal, backend::TestBackend};
fn sample_search() -> SearchData {
SearchData {
version: "0.3.63".into(),
uptime_secs: 7440,
indexes: vec![
IndexRow {
id: "cto".into(),
chunk_count: 1_200,
root_path: "/tmp/cto".into(),
},
IndexRow {
id: "trusty".into(),
chunk_count: 18_994,
root_path: "/tmp/trusty".into(),
},
],
}
}
fn sample_memory() -> MemoryData {
MemoryData {
version: "0.4.2".into(),
palace_count: 2,
total_drawers: 14,
total_vectors: 8_400,
total_kg_triples: 1_200,
palaces: vec![
PalaceRow {
id: "default".into(),
name: "default".into(),
vector_count: 8_400,
},
PalaceRow {
id: "work".into(),
name: "work".into(),
vector_count: 0,
},
],
}
}
#[test]
fn test_layout_wide() {
let (direction, constraints) = panel_layout(140);
assert_eq!(direction, Direction::Horizontal);
assert_eq!(
constraints,
[Constraint::Percentage(50), Constraint::Percentage(50)]
);
assert_eq!(panel_layout(WIDE_LAYOUT_MIN_COLS).0, Direction::Horizontal);
}
#[test]
fn test_layout_narrow() {
let (direction, constraints) = panel_layout(80);
assert_eq!(direction, Direction::Vertical);
assert_eq!(
constraints,
[Constraint::Percentage(50), Constraint::Percentage(50)]
);
}
#[test]
fn test_offline_panel_renders() {
let search: DaemonPanel<SearchData> = DaemonPanel {
status: PanelStatus::Offline {
last_error: "connection refused".into(),
},
base_url: "http://127.0.0.1:7878".into(),
};
let lines = search_panel_lines(&search);
assert!(lines.iter().any(|l| l.contains("connection refused")));
assert!(lines.iter().any(|l| l.contains("unreachable")));
let memory: DaemonPanel<MemoryData> = DaemonPanel {
status: PanelStatus::Offline {
last_error: "timeout".into(),
},
base_url: "http://127.0.0.1:7070".into(),
};
let mlines = memory_panel_lines(&memory);
assert!(mlines.iter().any(|l| l.contains("timeout")));
let state = DashboardState {
search,
memory,
focus: Focus::Search,
show_help: false,
last_action: None,
};
let backend = TestBackend::new(130, 30);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|f| render(f, &state))
.expect("offline render must not panic");
}
#[test]
fn test_uptime_format() {
assert_eq!(format_uptime(7440), "2h 4m");
assert_eq!(format_uptime(0), "0h 0m");
assert_eq!(format_uptime(59), "0h 0m");
assert_eq!(format_uptime(3600), "1h 0m");
assert_eq!(format_uptime(3661), "1h 1m");
}
#[test]
fn test_format_count() {
assert_eq!(format_count(0), "0");
assert_eq!(format_count(900), "900");
assert_eq!(format_count(1_200), "1,200");
assert_eq!(format_count(9_999), "9,999");
assert_eq!(format_count(19_400), "19.4k");
assert_eq!(format_count(10_000), "10.0k");
}
#[test]
fn test_search_total_chunks() {
assert_eq!(sample_search().total_chunks(), 20_194);
assert_eq!(SearchData::default().total_chunks(), 0);
}
#[test]
fn test_search_panel_renders() {
let panel = DaemonPanel {
status: PanelStatus::Online(sample_search()),
base_url: "http://127.0.0.1:7878".into(),
};
let lines = search_panel_lines(&panel);
assert!(
lines
.iter()
.any(|l| l.contains("Uptime:") && l.contains("2h 4m"))
);
assert!(
lines
.iter()
.any(|l| l.contains("Indexes:") && l.contains('2'))
);
assert!(
lines
.iter()
.any(|l| l.contains("cto") && l.contains("1,200"))
);
assert!(
lines
.iter()
.any(|l| l.contains("trusty") && l.contains("19.0k"))
);
}
#[test]
fn test_memory_panel_renders() {
let panel = DaemonPanel {
status: PanelStatus::Online(sample_memory()),
base_url: "http://127.0.0.1:7070".into(),
};
let lines = memory_panel_lines(&panel);
assert!(
lines
.iter()
.any(|l| l.contains("Palaces:") && l.contains('2'))
);
assert!(
lines
.iter()
.any(|l| l.contains("Vectors:") && l.contains("8,400"))
);
assert!(
lines
.iter()
.any(|l| l.contains("KG triples:") && l.contains("1,200"))
);
assert!(
lines
.iter()
.any(|l| l.contains("default") && l.contains("8,400"))
);
}
#[test]
fn test_toggle_focus() {
let mut state = DashboardState::new("http://a", "http://b");
assert_eq!(state.focus, Focus::Search);
state.toggle_focus();
assert_eq!(state.focus, Focus::Memory);
state.toggle_focus();
assert_eq!(state.focus, Focus::Search);
}
#[test]
fn test_new_state_starts_connecting() {
let state = DashboardState::new("http://a", "http://b");
assert!(matches!(state.search.status, PanelStatus::Connecting));
assert!(matches!(state.memory.status, PanelStatus::Connecting));
assert_eq!(state.search.base_url, "http://a");
assert_eq!(state.memory.base_url, "http://b");
}
#[test]
fn test_panel_starts_connecting() {
let panel: DaemonPanel<SearchData> = DaemonPanel::new("http://x");
assert!(matches!(panel.status, PanelStatus::Connecting));
assert_eq!(panel.base_url, "http://x");
}
#[test]
fn test_panel_status_is_online() {
let online: PanelStatus<u32> = PanelStatus::Online(1);
assert!(online.is_online());
let offline: PanelStatus<u32> = PanelStatus::Offline {
last_error: "x".into(),
};
assert!(!offline.is_online());
let connecting: PanelStatus<u32> = PanelStatus::Connecting;
assert!(!connecting.is_online());
}
#[test]
fn test_reindex_target() {
let mut state = DashboardState::new("http://a", "http://b");
assert_eq!(state.reindex_target(), None);
state.search.status = PanelStatus::Online(sample_search());
assert_eq!(state.reindex_target(), Some("cto".to_string()));
state.focus = Focus::Memory;
assert_eq!(state.reindex_target(), None);
}
#[test]
fn test_status_badge() {
let online: PanelStatus<u32> = PanelStatus::Online(0);
assert_eq!(status_badge(&online), ('●', "ONLINE", Color::Green));
let connecting: PanelStatus<u32> = PanelStatus::Connecting;
assert_eq!(
status_badge(&connecting),
('◌', "CONNECTING", Color::Yellow)
);
let offline: PanelStatus<u32> = PanelStatus::Offline {
last_error: "x".into(),
};
assert_eq!(status_badge(&offline), ('○', "OFFLINE", Color::Red));
}
#[test]
fn test_truncate() {
assert_eq!(truncate("short", 16), "short");
assert_eq!(truncate("0123456789abcdefghij", 8), "0123456…");
assert_eq!(truncate("exactlyeight", 12), "exactlyeight");
}
#[test]
fn test_help_text_lists_bindings() {
let text = help_text();
for token in ["Tab", "r ", "?", "Esc", "q "] {
assert!(text.contains(token), "help text missing {token}");
}
}
#[test]
fn test_render_smoke() {
let state = DashboardState {
search: DaemonPanel {
status: PanelStatus::Online(sample_search()),
base_url: "http://127.0.0.1:7878".into(),
},
memory: DaemonPanel {
status: PanelStatus::Online(sample_memory()),
base_url: "http://127.0.0.1:7070".into(),
},
focus: Focus::Memory,
show_help: true,
last_action: Some("reindex queued".into()),
};
for (w, h) in [(140u16, 30u16), (90, 40)] {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|f| render(f, &state))
.expect("render must not panic");
}
}
}