use std::time::Duration;
use serde::Deserialize;
use crate::monitor::dashboard::{IndexRow, SearchData};
pub const DEFAULT_SEARCH_URL: &str = "http://127.0.0.1:7878";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(3);
pub fn resolve_search_url() -> String {
match crate::read_daemon_addr("trusty-search") {
Ok(Some(addr)) => normalize_url(&addr),
_ => DEFAULT_SEARCH_URL.to_string(),
}
}
pub fn normalize_url(raw: &str) -> String {
if raw.starts_with("http://") || raw.starts_with("https://") {
raw.to_string()
} else {
format!("http://{raw}")
}
}
#[derive(Debug, Deserialize)]
struct HealthWire {
version: String,
#[serde(default)]
uptime_secs: u64,
}
#[derive(Debug, Deserialize)]
struct IndexListWire {
#[serde(default)]
indexes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct IndexStatusWire {
#[serde(default)]
root_path: String,
#[serde(default)]
chunk_count: u64,
}
#[derive(Debug, Clone)]
pub struct SearchClient {
base: String,
http: reqwest::Client,
}
impl SearchClient {
pub fn new(base: impl Into<String>) -> Self {
let http = reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.unwrap_or_default();
Self {
base: base.into(),
http,
}
}
pub fn base_url(&self) -> &str {
&self.base
}
pub fn set_base_url(&mut self, base: impl Into<String>) {
self.base = base.into();
}
pub async fn fetch_all(&self) -> anyhow::Result<SearchData> {
let health: HealthWire = self
.http
.get(format!("{}/health", self.base))
.send()
.await?
.error_for_status()?
.json()
.await?;
let list: IndexListWire = self
.http
.get(format!("{}/indexes", self.base))
.send()
.await?
.error_for_status()?
.json()
.await?;
let mut indexes = Vec::with_capacity(list.indexes.len());
for id in list.indexes {
let row = match self.index_status(&id).await {
Ok((root_path, chunk_count)) => IndexRow {
id,
chunk_count,
root_path,
},
Err(e) => {
tracing::warn!("index status probe failed for {id}: {e}");
IndexRow {
id,
chunk_count: 0,
root_path: String::new(),
}
}
};
indexes.push(row);
}
indexes.sort_by(|a, b| a.id.cmp(&b.id));
Ok(SearchData {
version: health.version,
uptime_secs: health.uptime_secs,
indexes,
})
}
async fn index_status(&self, id: &str) -> anyhow::Result<(String, u64)> {
let status: IndexStatusWire = self
.http
.get(format!("{}/indexes/{id}/status", self.base))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok((status.root_path, status.chunk_count))
}
pub async fn reindex(&self, id: &str) -> anyhow::Result<()> {
self.http
.post(format!("{}/indexes/{id}/reindex", self.base))
.json(&serde_json::json!({}))
.send()
.await?
.error_for_status()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_search_url_is_local() {
assert_eq!(DEFAULT_SEARCH_URL, "http://127.0.0.1:7878");
}
#[test]
fn normalize_url_adds_scheme() {
assert_eq!(normalize_url("127.0.0.1:7878"), "http://127.0.0.1:7878");
assert_eq!(
normalize_url("http://127.0.0.1:7878"),
"http://127.0.0.1:7878"
);
assert_eq!(normalize_url("https://example.com"), "https://example.com");
}
#[test]
fn search_client_stores_base_url() {
let client = SearchClient::new("http://127.0.0.1:7878");
assert_eq!(client.base_url(), "http://127.0.0.1:7878");
}
#[test]
fn search_client_repoints() {
let mut client = SearchClient::new("http://127.0.0.1:7878");
client.set_base_url("http://127.0.0.1:9999");
assert_eq!(client.base_url(), "http://127.0.0.1:9999");
}
#[test]
fn resolve_search_url_falls_back_to_default() {
let url = resolve_search_url();
assert!(url.starts_with("http://") || url.starts_with("https://"));
}
}