use axum::{extract::State, response::IntoResponse};
use serde_json::Value;
use super::{AppState, internal_err};
#[derive(Debug, serde::Serialize)]
pub struct LogEntry {
pub timestamp: String,
pub level: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
}
pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
let config = state.config.read().await;
let primary_model = config.models.primary.clone();
let fallbacks = config.models.fallbacks.clone();
let agent_name = config.agent.name.clone();
drop(config);
let llm = state.llm.read().await;
let current_model = llm.router.select_model().to_string();
drop(llm);
axum::Json(serde_json::json!({
"status": "ok",
"version": env!("CARGO_PKG_VERSION"),
"agent": agent_name,
"uptime_seconds": state.started_at.elapsed().as_secs(),
"models": {
"primary": primary_model,
"current": current_model,
"fallbacks": fallbacks,
},
}))
}
pub async fn get_logs(
State(state): State<AppState>,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
let lines_limit = params
.get("lines")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(100)
.min(10_000);
let level_filter = params
.get("level")
.map(|s| s.to_lowercase())
.filter(|s| matches!(s.as_str(), "info" | "warn" | "error" | "debug" | "trace"));
let log_dir = {
let config = state.config.read().await;
config.server.log_dir.clone()
};
let entries = match read_log_entries(&log_dir, lines_limit, level_filter.as_deref()) {
Ok(entries) => entries,
Err(e) => return Err(internal_err(&e)),
};
Ok(axum::Json(serde_json::json!({ "entries": entries })))
}
pub fn read_log_entries(
log_dir: &std::path::Path,
lines: usize,
level_filter: Option<&str>,
) -> Result<Vec<LogEntry>, String> {
let mut log_files: Vec<std::path::PathBuf> = match std::fs::read_dir(log_dir) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
Err(e) => return Err(format!("failed to read log directory: {}", e)),
}
.filter_map(|e| {
e.inspect_err(|e| tracing::warn!("skipping unreadable log dir entry: {e}"))
.ok()
})
.map(|e| e.path())
.filter(|p| {
p.is_file()
&& p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("ironclad.log") || n.ends_with(".log"))
})
.collect();
if log_files.is_empty() {
return Ok(vec![]);
}
log_files.sort_by(|a, b| {
let ma = std::fs::metadata(a)
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
let mb = std::fs::metadata(b)
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
ma.cmp(&mb).reverse().then_with(|| a.cmp(b).reverse())
});
let path = log_files
.first()
.cloned()
.ok_or_else(|| "no log file path (empty list after sort)".to_string())?;
let content =
std::fs::read_to_string(&path).map_err(|e| format!("failed to read log file: {}", e))?;
let raw_lines: Vec<&str> = content.lines().rev().take(lines).collect();
let raw_lines: Vec<&str> = raw_lines.into_iter().rev().collect();
let mut entries = Vec::with_capacity(raw_lines.len());
for line in raw_lines {
let line = line.trim();
if line.is_empty() {
continue;
}
let obj: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue,
};
let level = obj
.get("level")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_lowercase();
if let Some(filter) = level_filter
&& level != filter
{
continue;
}
let message = obj
.get("fields")
.and_then(|f| f.get("message"))
.and_then(|m| m.as_str())
.unwrap_or("")
.to_string();
let timestamp = obj
.get("timestamp")
.and_then(|t| t.as_str())
.unwrap_or("")
.to_string();
let target = obj.get("target").and_then(|t| t.as_str()).map(String::from);
entries.push(LogEntry {
timestamp,
level,
message,
target,
});
}
Ok(entries)
}