roboticus_api/api/routes/
health.rs1use axum::{extract::State, response::IntoResponse};
4use serde_json::Value;
5
6use super::{AppState, internal_err};
7
8#[derive(Debug, serde::Serialize)]
10pub struct LogEntry {
11 pub timestamp: String,
12 pub level: String,
13 pub message: String,
14 #[serde(skip_serializing_if = "Option::is_none")]
15 pub target: Option<String>,
16}
17
18pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
19 let config = state.config.read().await;
20 let primary_model = config.models.primary.clone();
21 let fallbacks = config.models.fallbacks.clone();
22 let agent_name = config.agent.name.clone();
23 drop(config);
24
25 let llm = state.llm.read().await;
26 let current_model = llm.router.select_model().to_string();
27 drop(llm);
28
29 axum::Json(serde_json::json!({
30 "status": "ok",
31 "version": env!("CARGO_PKG_VERSION"),
32 "agent": agent_name,
33 "uptime_seconds": state.started_at.elapsed().as_secs(),
34 "models": {
35 "primary": primary_model,
36 "current": current_model,
37 "fallbacks": fallbacks,
38 },
39 }))
40}
41
42pub async fn get_logs(
43 State(state): State<AppState>,
44 axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
45) -> impl IntoResponse {
46 let lines_limit = params
47 .get("lines")
48 .and_then(|v| v.parse::<usize>().ok())
49 .unwrap_or(100)
50 .min(10_000);
51 let level_filter = params
52 .get("level")
53 .map(|s| s.to_lowercase())
54 .filter(|s| matches!(s.as_str(), "info" | "warn" | "error" | "debug" | "trace"));
55
56 let log_dir = {
57 let config = state.config.read().await;
58 config.server.log_dir.clone()
59 };
60
61 let entries = match read_log_entries(&log_dir, lines_limit, level_filter.as_deref()) {
62 Ok(entries) => entries,
63 Err(e) => return Err(internal_err(&e)),
64 };
65 Ok(axum::Json(serde_json::json!({ "entries": entries })))
66}
67
68pub fn read_log_entries(
70 log_dir: &std::path::Path,
71 lines: usize,
72 level_filter: Option<&str>,
73) -> Result<Vec<LogEntry>, String> {
74 let mut log_files: Vec<std::path::PathBuf> = match std::fs::read_dir(log_dir) {
75 Ok(rd) => rd,
76 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
77 Err(e) => return Err(format!("failed to read log directory: {}", e)),
78 }
79 .filter_map(|e| {
80 e.inspect_err(|e| tracing::warn!("skipping unreadable log dir entry: {e}"))
81 .ok()
82 })
83 .map(|e| e.path())
84 .filter(|p| {
85 p.is_file()
86 && p.file_name()
87 .and_then(|n| n.to_str())
88 .is_some_and(|n| n.starts_with("roboticus.log") || n.ends_with(".log"))
89 })
90 .collect();
91 if log_files.is_empty() {
92 return Ok(vec![]);
93 }
94 log_files.sort_by(|a, b| {
95 let ma = std::fs::metadata(a)
96 .and_then(|m| m.modified())
97 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
98 let mb = std::fs::metadata(b)
99 .and_then(|m| m.modified())
100 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
101 ma.cmp(&mb).reverse().then_with(|| a.cmp(b).reverse())
102 });
103 let path = log_files
104 .first()
105 .cloned()
106 .ok_or_else(|| "no log file path (empty list after sort)".to_string())?;
107 let content =
108 std::fs::read_to_string(&path).map_err(|e| format!("failed to read log file: {}", e))?;
109 let raw_lines: Vec<&str> = content.lines().rev().take(lines).collect();
110 let raw_lines: Vec<&str> = raw_lines.into_iter().rev().collect();
111 let mut entries = Vec::with_capacity(raw_lines.len());
112 for line in raw_lines {
113 let line = line.trim();
114 if line.is_empty() {
115 continue;
116 }
117 let obj: Value = match serde_json::from_str(line) {
118 Ok(v) => v,
119 Err(_) => continue,
120 };
121 let level = obj
122 .get("level")
123 .and_then(|v| v.as_str())
124 .unwrap_or("")
125 .to_lowercase();
126 if let Some(filter) = level_filter
127 && level != filter
128 {
129 continue;
130 }
131 let message = obj
132 .get("fields")
133 .and_then(|f| f.get("message"))
134 .and_then(|m| m.as_str())
135 .unwrap_or("")
136 .to_string();
137 let timestamp = obj
138 .get("timestamp")
139 .and_then(|t| t.as_str())
140 .unwrap_or("")
141 .to_string();
142 let target = obj.get("target").and_then(|t| t.as_str()).map(String::from);
143 entries.push(LogEntry {
144 timestamp,
145 level,
146 message,
147 target,
148 });
149 }
150 Ok(entries)
151}