use crate::tui::health::activity::{current_spinner_tick, palace_activity, spinner_frame};
use crate::tui::health::format::{
ascii_bar, format_bytes, format_count, format_relative_time, format_rss, format_uptime,
format_with_commas,
};
use crate::tui::health::types::{
CollectionRow, Daemon, HealthScreen, HealthTab, HealthUpdate, LogBuffer, PalaceActivity,
PanelState,
};
impl HealthScreen {
pub fn new(search_url: impl Into<String>, memory_url: impl Into<String>) -> Self {
Self {
search: PanelState::Connecting,
search_url: search_url.into(),
memory: PanelState::Connecting,
memory_url: memory_url.into(),
focus: Daemon::Search,
tab: HealthTab::default(),
search_collections: Vec::new(),
memory_collections: Vec::new(),
selected_collection: 0,
search_logs: LogBuffer::new(),
memory_logs: LogBuffer::new(),
search_query: String::new(),
search_input_focused: false,
}
}
pub fn set_tab(&mut self, tab: HealthTab) {
self.tab = tab;
self.search_input_focused = matches!(tab, HealthTab::Search);
}
pub fn focused_collections(&self) -> &[CollectionRow] {
match self.focus {
Daemon::Search => &self.search_collections,
Daemon::Memory => &self.memory_collections,
}
}
pub fn focused_logs_mut(&mut self) -> &mut LogBuffer {
match self.focus {
Daemon::Search => &mut self.search_logs,
Daemon::Memory => &mut self.memory_logs,
}
}
pub fn focused_logs(&self) -> &LogBuffer {
match self.focus {
Daemon::Search => &self.search_logs,
Daemon::Memory => &self.memory_logs,
}
}
pub fn select_collection_up(&mut self) {
self.selected_collection = self.selected_collection.saturating_sub(1);
}
pub fn select_collection_down(&mut self) {
let max = self.focused_collections().len().saturating_sub(1);
if self.selected_collection < max {
self.selected_collection += 1;
}
}
pub fn clamp_collection_selection(&mut self) {
let max = self.focused_collections().len().saturating_sub(1);
if self.selected_collection > max {
self.selected_collection = max;
}
}
pub fn toggle_focus(&mut self) {
self.focus = match self.focus {
Daemon::Search => Daemon::Memory,
Daemon::Memory => Daemon::Search,
};
}
pub fn apply_update(&mut self, update: HealthUpdate) {
match update.daemon {
Daemon::Search => self.search = update.state,
Daemon::Memory => self.memory = update.state,
}
}
pub fn focused_url(&self) -> &str {
match self.focus {
Daemon::Search => &self.search_url,
Daemon::Memory => &self.memory_url,
}
}
}
pub fn service_name(focus: Daemon) -> &'static str {
match focus {
Daemon::Search => "trusty-search",
Daemon::Memory => "trusty-memory",
}
}
pub fn header_lines(screen: &HealthScreen) -> Vec<String> {
let focused = match screen.focus {
Daemon::Search => &screen.search,
Daemon::Memory => &screen.memory,
};
let name = service_name(screen.focus);
match focused {
PanelState::Online(data) => {
let version = if data.version.is_empty() {
"?".to_string()
} else {
format!("v{}", data.version)
};
vec![
format!("{name} {version} [●] ONLINE"),
format!(
"RSS: {} CPU: {:.0}% Disk: {} Uptime: {}",
format_rss(data.rss_mb),
data.cpu_pct,
format_bytes(data.disk_bytes),
format_uptime(data.uptime_secs),
),
]
}
PanelState::Offline { last_error } => vec![
format!("{name} [○] OFFLINE"),
format!("last error: {last_error}"),
],
PanelState::Connecting => vec![format!("{name} [○] connecting…"), String::new()],
}
}
pub fn collections_lines(screen: &HealthScreen) -> Vec<String> {
collections_lines_at_tick(screen, current_spinner_tick())
}
pub fn collections_lines_at_tick(screen: &HealthScreen, tick: usize) -> Vec<String> {
let rows = screen.focused_collections();
if rows.is_empty() {
return vec!["(none)".to_string()];
}
let focus = screen.focus;
rows.iter()
.enumerate()
.map(|(i, r)| {
let marker = if i == screen.selected_collection {
">"
} else {
" "
};
match focus {
Daemon::Memory => format_palace_row(marker, r, tick),
Daemon::Search => format_search_row(marker, r),
}
})
.collect()
}
fn format_search_row(marker: &str, r: &CollectionRow) -> String {
let glyph = if r.ok { "✓" } else { "✗" };
let badge_text = if r.last_indexed.is_some() {
format_relative_time(r.last_indexed.as_deref())
} else if !r.note.is_empty() {
r.note.clone()
} else {
String::new()
};
let badge = if badge_text.is_empty() {
String::new()
} else {
format!(" [{badge_text}]")
};
format!(
"{marker} {:<12} {:>6} {glyph}{badge}",
r.id,
format_count(r.count)
)
}
fn format_palace_row(marker: &str, r: &CollectionRow, tick: usize) -> String {
let vec_cell = format_count_suffix(r.count, 'v');
let kg_cell = format_count_suffix(r.kg_count, 'g');
let glyph = spinner_frame(palace_activity(r), tick).unwrap_or(' ');
format!(
"{marker}{glyph} {:<15} {:>4} {:>4}",
r.id, vec_cell, kg_cell
)
}
pub(crate) fn format_count_suffix(n: u64, suffix: char) -> String {
if n == 0 {
format!("--{suffix}")
} else {
format!("{}{suffix}", format_count(n))
}
}
pub fn tab_bar(active: HealthTab) -> Vec<(String, bool)> {
[
("[1]HEALTH", HealthTab::Health),
("[2]LOGS", HealthTab::Logs),
("[3]SEARCH", HealthTab::Search),
("[4]INDEX", HealthTab::Index),
]
.iter()
.map(|(label, tab)| ((*label).to_string(), *tab == active))
.collect()
}
pub fn health_tab_lines(screen: &HealthScreen) -> Vec<String> {
let panel = match screen.focus {
Daemon::Search => &screen.search,
Daemon::Memory => &screen.memory,
};
let data = match panel {
PanelState::Online(d) => d,
PanelState::Offline { last_error } => {
return vec![format!("offline: {last_error}")];
}
PanelState::Connecting => {
return vec!["connecting…".to_string()];
}
};
const MEM_CEILING_MB: u64 = 8 * 1024;
let mem_ratio = (data.rss_mb as f64 / MEM_CEILING_MB as f64).clamp(0.0, 1.0);
let mem_pct = (mem_ratio * 100.0).round() as u64;
let disk_ratio = if data.disk_bytes > 0 {
const DISK_CEILING_BYTES: u64 = 10 * 1024 * 1024 * 1024;
(data.disk_bytes as f64 / DISK_CEILING_BYTES as f64).clamp(0.0, 1.0)
} else {
0.0
};
vec![
format!(
"Memory {bar} {used} / {cap} ({pct}%)",
bar = ascii_bar(mem_ratio, 10),
used = format_rss(data.rss_mb),
cap = format_rss(MEM_CEILING_MB),
pct = mem_pct,
),
format!(
"Disk {bar} {used}",
bar = ascii_bar(disk_ratio, 10),
used = format_bytes(data.disk_bytes),
),
String::new(),
"Embedder: ready".to_string(),
"CoreML: batch=32 tripwire=4GB".to_string(),
]
}
pub fn palace_index_tab_lines(row: &CollectionRow) -> Vec<String> {
let mut lines = Vec::with_capacity(12);
lines.push(format!(
"Vectors: {:<12} Drawers: {}",
format_with_commas(row.count),
format_with_commas(row.drawer_count),
));
lines.push(format!(
"Wings: {}",
format_with_commas(row.wing_count),
));
lines.push(String::new());
lines.push("-- Knowledge Graph ------------------------------------".to_string());
lines.push(format!("Triples: {}", format_with_commas(row.kg_count),));
let node_cell = if row.node_count == 0 {
"N/A".to_string()
} else {
format_with_commas(row.node_count)
};
let edge_cell = if row.edge_count == 0 {
"N/A".to_string()
} else {
format_with_commas(row.edge_count)
};
lines.push(format!(
"Nodes: {:<12} Edges: {}",
node_cell, edge_cell,
));
lines.push(String::new());
lines.push("-- Activity -------------------------------------------".to_string());
let last_write_human = match row.last_write_at.as_deref() {
Some(s) => match chrono::DateTime::parse_from_rfc3339(s) {
Ok(dt) => format!(
"{} ({})",
dt.format("%Y-%m-%d %H:%M"),
format_relative_time(Some(s)),
),
Err(_) => s.to_string(),
},
None => "never".to_string(),
};
lines.push(format!("Last write: {last_write_human}"));
let state_label = match palace_activity(row) {
PalaceActivity::Idle => "idle",
PalaceActivity::Indexing => "indexing",
PalaceActivity::Dreaming => "dreaming (compacting)",
PalaceActivity::Active => "active (recent write)",
PalaceActivity::Error => "error",
};
lines.push(format!("State: {state_label}"));
lines
}
pub fn index_tab_lines(screen: &HealthScreen) -> Vec<String> {
const MAX_BAR_WIDTH: usize = 16;
let rows = screen.focused_collections();
if rows.is_empty() {
return vec!["(no collection selected)".to_string()];
}
let Some(row) = rows.get(screen.selected_collection) else {
return vec!["(no collection selected)".to_string()];
};
if matches!(screen.focus, Daemon::Memory) {
return palace_index_tab_lines(row);
}
let mut lines = Vec::with_capacity(12);
lines.push(format!(
"Chunks: {:<10} Disk: {}",
format_count(row.count),
format_bytes(row.disk_bytes),
));
let last_indexed_human = match row.last_indexed.as_deref() {
Some(s) => {
let abs = chrono::DateTime::parse_from_rfc3339(s)
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|_| s.to_string());
format!("{abs} ({})", format_relative_time(Some(s)))
}
None => "never".to_string(),
};
lines.push(format!("Last index: {last_indexed_human}"));
let context = if row.has_context_embedding {
"embedded"
} else {
"none"
};
lines.push(format!("Context: {context}"));
lines.push(String::new());
lines.push("-- Graph ----------------------------------------------".to_string());
lines.push(format!(
"Nodes: {:<10} Edges: {}",
format_count(row.node_count),
format_count(row.edge_count),
));
let max_kind = row.edge_kinds.iter().map(|(_, c)| *c).max().unwrap_or(0);
for (name, count) in &row.edge_kinds {
let ratio = if max_kind == 0 {
0.0
} else {
*count as f64 / max_kind as f64
};
let bar = ascii_bar(ratio, MAX_BAR_WIDTH);
lines.push(format!("{:<16} {:>6} {bar}", name, format_count(*count)));
}
lines
}