use std::time::Duration;
use serde_json::{Value, json};
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
}
async fn memory_call(&self, method: &str, params: serde_json::Value) -> anyhow::Result<Value> {
trusty_common::memory_rpc::call_memory_tool_at_with_timeout(
std::path::Path::new(&self.base),
method,
params,
REQUEST_TIMEOUT,
)
.await
}
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: HealthWire = match self.daemon {
Daemon::Search => {
self.http
.get(format!("{}/health", self.base))
.send()
.await?
.error_for_status()?
.json()
.await?
}
Daemon::Memory => {
serde_json::from_value(self.memory_call("memory.health", 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.memory_call("memory.status", json!({})).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 raw = match self.daemon {
Daemon::Memory => self
.memory_call("memory.logs_tail", json!({ "n": n }))
.await
.unwrap_or(Value::Null),
Daemon::Search => {
let url = format!("{}/logs/tail?n={n}", self.base);
match self.http.get(url).send().await {
Ok(resp) if resp.status().is_success() => {
resp.json::<Value>().await.unwrap_or(Value::Null)
}
_ => Value::Null,
}
}
};
Ok(project_log_tail(&raw))
}
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(listed) = self
.memory_call("memory.palaces_list", json!({ "counts": false }))
.await
else {
return Vec::new();
};
project_palace_roster(&listed)
}
pub async fn stop(&self) -> anyhow::Result<()> {
match self.daemon {
Daemon::Search => {
self.http
.post(format!("{}/admin/stop", self.base))
.json(&json!({}))
.send()
.await?
.error_for_status()?;
}
Daemon::Memory => {
self.memory_call("memory.admin_stop", json!({})).await?;
}
}
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_roster(answer: &serde_json::Value) -> Vec<CollectionRow> {
let Some(rows) = answer.get("palaces").and_then(|v| v.as_array()) else {
return Vec::new();
};
let mut out = Vec::with_capacity(rows.len());
for row in rows {
if let Some(palace) = row.get("palace").filter(|v| v.is_object()) {
out.extend(project_palace_rows(&serde_json::Value::Array(vec![
palace.clone(),
])));
continue;
}
let Some(error) = row.get("error").and_then(|v| v.as_str()) else {
continue;
};
let id = row
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string();
out.push(CollectionRow {
id,
note: format!("counts unavailable: {error}"),
ok: false,
counts_unknown: true,
..Default::default()
});
}
out
}
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 room_count = p
.get("room_count")
.or_else(|| 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);
let counts_unknown = p.get("cached").and_then(|v| v.as_bool()) == Some(false);
if !counts_unknown && count == 0 && kg_count == 0 {
return None;
}
Some(CollectionRow {
id,
count,
kg_count,
drawer_count,
room_count,
last_write_at,
node_count,
edge_count,
community_count,
is_compacting,
counts_unknown,
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
}