use std::path::{Path, PathBuf};
use std::time::Duration;
use serde_json::{Value, json};
use crate::daemon::search_rpc::{
METHOD_HEALTH, METHOD_INDEX_STATUS, METHOD_INDEXES_LIST, call_at, search_socket,
};
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);
const METHOD_GRAPH_STATS: &str = "search.graph.stats";
const METHOD_LOGS_TAIL: &str = "search.logs.tail";
const METHOD_ADMIN_STOP: &str = "search.admin.stop";
const UNREACHABLE_SEARCH_SOCKET: &str = "/nonexistent/trusty-search/trusty-search.sock";
pub fn resolve_search_socket_or_unreachable() -> PathBuf {
socket_or_unreachable(search_socket())
}
pub(crate) fn socket_or_unreachable(resolved: anyhow::Result<PathBuf>) -> PathBuf {
resolved.unwrap_or_else(|e| {
tracing::warn!("trusty-search socket unresolved, panel will show offline: {e:#}");
PathBuf::from(UNREACHABLE_SEARCH_SOCKET)
})
}
fn index_ids(list: &Value) -> 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()
}
impl HealthClient {
pub fn new(base: impl Into<String>, daemon: Daemon) -> Self {
Self {
base: base.into(),
daemon,
}
}
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
}
async fn search_call(&self, method: &str, params: Value) -> anyhow::Result<Value> {
call_at(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 => {
serde_json::from_value(self.search_call(METHOD_HEALTH, 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.search_call(METHOD_INDEXES_LIST, json!({})).await else {
return (0, 0, 0, 0);
};
let ids = index_ids(&list);
let mut total_chunks = 0u64;
for id in &ids {
if let Ok(status) = self.index_status(id).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 index_status(&self, id: &str) -> anyhow::Result<Value> {
self.search_call(METHOD_INDEX_STATUS, json!({ "index_id": id }))
.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 => self
.search_call(METHOD_LOGS_TAIL, json!({ "n": n }))
.await
.unwrap_or(Value::Null),
};
Ok(project_log_tail(&raw))
}
pub async fn search_collections(&self) -> Vec<CollectionRow> {
let Ok(list) = self.search_call(METHOD_INDEXES_LIST, json!({})).await else {
return Vec::new();
};
let ids = index_ids(&list);
let mut rows = Vec::with_capacity(ids.len());
for id in ids {
let status = self.index_status(&id).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
.search_call(METHOD_GRAPH_STATS, json!({ "index_id": id }))
.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 note = format_relative_time(last_indexed.as_deref());
rows.push(CollectionRow {
id,
count,
note,
ok: true,
last_indexed,
node_count,
edge_count,
edge_kinds,
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.search_call(METHOD_ADMIN_STOP, json!({})).await?;
}
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
}