use std::time::Duration;
use crate::tui::health::format::format_relative_time;
use crate::tui::health::types::{
CollectionRow, Daemon, HealthClient, HealthWire, PanelData, PanelState,
};
const REQUEST_TIMEOUT: Duration = Duration::from_secs(3);
impl HealthClient {
pub fn new(base: impl Into<String>, daemon: Daemon) -> Self {
let http = reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.unwrap_or_default();
Self {
base: base.into(),
daemon,
http,
}
}
pub fn base_url(&self) -> &str {
&self.base
}
pub async fn poll(&self) -> PanelState {
match self.fetch().await {
Ok(data) => PanelState::Online(data),
Err(e) => PanelState::Offline {
last_error: e.to_string(),
},
}
}
async fn fetch(&self) -> anyhow::Result<PanelData> {
let health_path = match self.daemon {
Daemon::Search => "/health",
Daemon::Memory => "/health",
};
let health: HealthWire = self
.http
.get(format!("{}{health_path}", self.base))
.send()
.await?
.error_for_status()?
.json()
.await?;
let (count_a, count_b, count_c, count_d) = match self.daemon {
Daemon::Search => self.search_counts().await,
Daemon::Memory => self.memory_counts().await,
};
Ok(PanelData {
version: health.version,
rss_mb: health.rss_mb,
cpu_pct: health.cpu_pct,
uptime_secs: health.uptime_secs,
disk_bytes: health.disk_bytes,
count_a,
count_b,
count_c,
count_d,
})
}
async fn search_counts(&self) -> (u64, u64, u64, u64) {
let Ok(list) = self.get_json(format!("{}/indexes", self.base)).await else {
return (0, 0, 0, 0);
};
let ids = list
.get("indexes")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let mut total_chunks = 0u64;
for id in &ids {
if let Ok(status) = self
.get_json(format!("{}/indexes/{id}/status", self.base))
.await
{
total_chunks = total_chunks.saturating_add(
status
.get("chunk_count")
.and_then(|v| v.as_u64())
.unwrap_or(0),
);
}
}
(ids.len() as u64, total_chunks, 0, 0)
}
async fn memory_counts(&self) -> (u64, u64, u64, u64) {
match self.get_json(format!("{}/api/v1/status", self.base)).await {
Ok(status) => project_memory_counts(&status),
Err(_) => (0, 0, 0, 0),
}
}
async fn get_json(&self, url: String) -> anyhow::Result<serde_json::Value> {
Ok(self
.http
.get(url)
.send()
.await?
.error_for_status()?
.json()
.await?)
}
pub async fn logs_tail(&self, n: u32) -> anyhow::Result<(Vec<String>, u64)> {
let url = format!("{}/logs/tail?n={n}", self.base);
match self.http.get(url).send().await {
Ok(resp) if resp.status().is_success() => {
match resp.json::<serde_json::Value>().await {
Ok(body) => Ok(project_log_tail(&body)),
Err(_) => Ok((Vec::new(), 0)),
}
}
Ok(_) => Ok((Vec::new(), 0)),
Err(e) => Err(anyhow::anyhow!("logs_tail: {e}")),
}
}
pub async fn search_collections(&self) -> Vec<CollectionRow> {
let Ok(list) = self.get_json(format!("{}/indexes", self.base)).await else {
return Vec::new();
};
let ids: Vec<String> = list
.get("indexes")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let mut rows = Vec::with_capacity(ids.len());
for id in ids {
let status = self
.get_json(format!("{}/indexes/{id}/status", self.base))
.await
.ok();
let count = status
.as_ref()
.and_then(|v| v.get("chunk_count").and_then(|c| c.as_u64()))
.unwrap_or(0);
let last_indexed = status
.as_ref()
.and_then(|v| v.get("last_indexed").and_then(|c| c.as_str()))
.map(str::to_string);
let disk_bytes = status
.as_ref()
.and_then(|v| v.get("disk_bytes").and_then(|c| c.as_u64()))
.unwrap_or(0);
let has_context_embedding = status
.as_ref()
.and_then(|v| v.get("has_context_embedding").and_then(|c| c.as_bool()))
.unwrap_or(false);
let graph = self
.get_json(format!("{}/indexes/{id}/graph/stats", self.base))
.await
.ok();
let node_count = graph
.as_ref()
.and_then(|v| v.get("node_count").and_then(|c| c.as_u64()))
.unwrap_or(0);
let edge_count = graph
.as_ref()
.and_then(|v| v.get("edge_count").and_then(|c| c.as_u64()))
.unwrap_or(0);
let edge_kinds = graph.as_ref().map(project_edge_kinds).unwrap_or_default();
let communities = self
.get_json(format!("{}/indexes/{id}/communities", self.base))
.await
.ok();
let community_count = communities
.as_ref()
.and_then(|v| v.get("community_count").and_then(|c| c.as_u64()))
.unwrap_or(0);
let modularity = communities
.as_ref()
.and_then(|v| v.get("modularity").and_then(|c| c.as_f64()))
.unwrap_or(0.0);
let note = format_relative_time(last_indexed.as_deref());
rows.push(CollectionRow {
id,
count,
note,
ok: true,
last_indexed,
node_count,
edge_count,
edge_kinds,
community_count,
modularity,
disk_bytes,
has_context_embedding,
..Default::default()
});
}
rows
}
pub async fn memory_collections(&self) -> Vec<CollectionRow> {
let Ok(list) = self.get_json(format!("{}/api/v1/palaces", self.base)).await else {
return Vec::new();
};
project_palace_rows(&list)
}
pub async fn stop(&self) -> anyhow::Result<()> {
let path = match self.daemon {
Daemon::Search => "/admin/stop",
Daemon::Memory => "/api/v1/admin/stop",
};
self.http
.post(format!("{}{path}", self.base))
.json(&serde_json::json!({}))
.send()
.await?
.error_for_status()?;
Ok(())
}
}
pub fn client_for(daemon: Daemon, base_url: &str) -> HealthClient {
HealthClient::new(base_url, daemon)
}
pub(crate) fn project_memory_counts(status: &serde_json::Value) -> (u64, u64, u64, u64) {
let u = |key: &str| status.get(key).and_then(|v| v.as_u64()).unwrap_or(0);
(
u("palace_count"),
u("total_vectors"),
u("total_drawers"),
u("total_kg_triples"),
)
}
pub(crate) fn project_log_tail(body: &serde_json::Value) -> (Vec<String>, u64) {
let lines: Vec<String> = body
.get("lines")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let total = body
.get("total")
.and_then(|v| v.as_u64())
.unwrap_or(lines.len() as u64);
(lines, total)
}
pub(crate) fn project_palace_rows(list: &serde_json::Value) -> Vec<CollectionRow> {
let Some(arr) = list.as_array() else {
return Vec::new();
};
arr.iter()
.filter_map(|p| {
let id = p
.get("name")
.or_else(|| p.get("id"))
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string();
let count = p.get("vector_count").and_then(|v| v.as_u64()).unwrap_or(0);
let kg_count = p
.get("kg_triple_count")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let drawer_count = p.get("drawer_count").and_then(|v| v.as_u64()).unwrap_or(0);
let wing_count = p.get("wing_count").and_then(|v| v.as_u64()).unwrap_or(0);
let last_write_at = p
.get("last_write_at")
.and_then(|v| v.as_str())
.map(str::to_string);
let node_count = p.get("node_count").and_then(|v| v.as_u64()).unwrap_or(0);
let edge_count = p.get("edge_count").and_then(|v| v.as_u64()).unwrap_or(0);
let community_count = p
.get("community_count")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let is_compacting = p
.get("is_compacting")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if count == 0 && kg_count == 0 {
return None;
}
Some(CollectionRow {
id,
count,
kg_count,
drawer_count,
wing_count,
last_write_at,
node_count,
edge_count,
community_count,
is_compacting,
note: String::new(),
ok: true,
..Default::default()
})
})
.collect()
}
pub(crate) fn project_edge_kinds(stats: &serde_json::Value) -> Vec<(String, u64)> {
let Some(map) = stats.get("edge_kinds").and_then(|v| v.as_object()) else {
return Vec::new();
};
let mut pairs: Vec<(String, u64)> = map
.iter()
.map(|(k, v)| (k.clone(), v.as_u64().unwrap_or(0)))
.collect();
pairs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
pairs
}