Skip to main content

max / makenotwork

11.2 KB · 307 lines History Blame Raw
1 //! MCP tool parameters and handlers for the health-check tools.
2
3 use std::fmt::Write as _;
4
5 use schemars::JsonSchema;
6 use serde::Deserialize;
7 use tracing::instrument;
8
9 use crate::checks::{drift, http};
10 use crate::db;
11 use crate::types::{LatencyStats, TargetInfo};
12
13 use super::PomServer;
14
15 #[derive(Debug, Deserialize, JsonSchema)]
16 pub struct CheckHealthParams {
17 /// Target name to check (omit to check all targets)
18 pub target: Option<String>,
19 }
20
21 #[derive(Debug, Deserialize, JsonSchema)]
22 pub struct HealthHistoryParams {
23 /// Filter by target name
24 pub target: Option<String>,
25 /// Number of results to return (default 10)
26 pub limit: Option<i64>,
27 }
28
29 impl PomServer {
30 #[instrument(skip_all)]
31 pub async fn get_status_impl(&self) -> crate::error::Result<String> {
32 let mut status_parts = Vec::new();
33
34 for name in self.config.target_names() {
35 let target = self.config.get_target(&name).unwrap();
36 let mut target_status = format!("## {name} ({})\n", target.label);
37
38 // Latest health
39 if let Ok(Some(health)) = db::get_latest_health(&self.pool, &name).await {
40 let _ = writeln!(
41 target_status,
42 "Health: {} ({}ms, {})",
43 health.status, health.response_time_ms, health.checked_at
44 );
45 if let Some(details) = &health.details {
46 if let Some(v) = &details.version {
47 let _ = writeln!(target_status, "Version: {v}");
48 }
49 if let Some(u) = &details.uptime {
50 let _ = writeln!(target_status, "Uptime: {u}");
51 }
52 }
53 if let Some(err) = &health.error {
54 let _ = writeln!(target_status, "Error: {err}");
55 }
56 } else {
57 target_status.push_str("Health: no data\n");
58 }
59
60 // 24h latency stats
61 let latency_cutoff = (chrono::Utc::now() - chrono::Duration::hours(24)).to_rfc3339();
62 if let Ok(times) = db::get_response_times(&self.pool, &name, &latency_cutoff).await {
63 let operational_times: Vec<i64> = times
64 .iter()
65 .filter(|(_, ms)| *ms > 0)
66 .map(|(_, ms)| *ms)
67 .collect();
68 if let Some(l) = LatencyStats::from_times(&operational_times) {
69 let _ = writeln!(
70 target_status,
71 "Latency (24h): avg {:.0}ms, p95 {}ms, range {}-{}ms ({} samples)",
72 l.avg_ms, l.p95_ms, l.min_ms, l.max_ms, l.sample_count
73 );
74 }
75 }
76
77 // Active incident
78 if let Ok(Some(incident)) = db::get_open_incident(&self.pool, &name).await {
79 let _ = writeln!(
80 target_status,
81 "Incident: [ACTIVE] {} since {}",
82 incident.to_status, incident.started_at
83 );
84 }
85
86 // Recent incidents
87 if let Ok(incidents) = db::get_recent_incidents(&self.pool, &name, 5).await {
88 let closed: Vec<_> = incidents.iter().filter(|i| i.ended_at.is_some()).collect();
89 if !closed.is_empty() {
90 target_status.push_str("Recent incidents:\n");
91 for inc in closed {
92 let duration = inc
93 .duration_secs
94 .map(|d| format!(" ({d}s)"))
95 .unwrap_or_default();
96 let _ = writeln!(
97 target_status,
98 " {} -> {} at {}{}",
99 inc.from_status, inc.to_status, inc.started_at, duration
100 );
101 }
102 }
103 }
104
105 // Latest test run
106 let latest_test = db::get_latest_test_run(&self.pool, &name)
107 .await
108 .ok()
109 .flatten();
110 if let Some(ref test) = latest_test {
111 let result = if test.passed { "PASSED" } else { "FAILED" };
112 let _ = write!(target_status, "Tests: {result}");
113 if let Some(d) = test.duration_secs {
114 let _ = write!(target_status, " ({d}s)");
115 }
116 let _ = writeln!(target_status, " ({})", test.started_at);
117 if let (Some(p), Some(f)) = (test.summary.total_passed, test.summary.total_failed) {
118 let _ = writeln!(target_status, " {p} passed, {f} failed");
119 }
120 for step in &test.summary.steps {
121 let mark = if step.passed { "PASS" } else { "FAIL" };
122 let _ = writeln!(target_status, " {mark} {}", step.name);
123 }
124 } else {
125 target_status.push_str("Tests: no data\n");
126 }
127
128 // Test staleness
129 if let Some(tests_config) = &target.tests {
130 let current_version = db::get_latest_health(&self.pool, &name)
131 .await
132 .ok()
133 .flatten()
134 .and_then(|h| h.details)
135 .and_then(|d| d.version);
136
137 let tested_version = if let Some(ref test) = latest_test {
138 db::get_version_at_time(&self.pool, &name, &test.started_at)
139 .await
140 .unwrap_or(None)
141 } else {
142 None
143 };
144
145 let staleness = drift::compute_test_staleness(
146 current_version.as_deref(),
147 tested_version.as_deref(),
148 latest_test.as_ref().map(|t| t.started_at.as_str()),
149 tests_config.staleness_days,
150 );
151
152 if staleness.stale
153 && let Some(reason) = &staleness.reason
154 {
155 let _ = writeln!(target_status, "Tests: STALE ({reason})");
156 }
157 }
158
159 status_parts.push(target_status);
160 }
161
162 if status_parts.is_empty() {
163 return Ok("No targets configured.".to_string());
164 }
165
166 Ok(status_parts.join("\n"))
167 }
168
169 #[instrument(skip_all)]
170 pub async fn check_health_impl(
171 &self,
172 params: CheckHealthParams,
173 ) -> crate::error::Result<String> {
174 let targets: Vec<String> = match &params.target {
175 Some(t) => {
176 if self.config.get_target(t).is_none() {
177 return Ok(format!("Unknown target: {t}"));
178 }
179 vec![t.clone()]
180 }
181 None => self.config.target_names(),
182 };
183
184 let mut results = Vec::new();
185
186 for name in &targets {
187 let target = self.config.get_target(name).unwrap();
188 if let Some(health_config) = &target.health {
189 let snapshot =
190 http::check_health(name, health_config, health_config.expect.as_ref()).await;
191 db::insert_health_check(&self.pool, &snapshot).await?;
192 results.push(serde_json::to_string_pretty(&snapshot)?);
193 } else {
194 results.push(format!("{name}: no health endpoint configured"));
195 }
196 }
197
198 Ok(results.join("\n\n"))
199 }
200
201 #[instrument(skip_all)]
202 pub async fn health_history_impl(
203 &self,
204 params: HealthHistoryParams,
205 ) -> crate::error::Result<String> {
206 let limit = params.limit.unwrap_or(10);
207 let history = db::get_health_history(&self.pool, params.target.as_deref(), limit).await?;
208
209 if history.is_empty() {
210 return Ok("No health check history.".to_string());
211 }
212
213 Ok(serde_json::to_string_pretty(&history)?)
214 }
215
216 #[instrument(skip_all)]
217 pub async fn get_mesh_status_impl(&self) -> crate::error::Result<String> {
218 let listen = &self.config.serve.listen;
219 let url = format!("http://{listen}/api/mesh");
220
221 let client = crate::tls::https_client_builder()
222 .timeout(std::time::Duration::from_secs(5))
223 .build()?;
224
225 let response = client.get(&url).send().await.map_err(|e| {
226 crate::error::PomError::Config(format!(
227 "Could not reach local PoM instance at {listen}: {e}"
228 ))
229 })?;
230
231 let data: serde_json::Value = response.json().await?;
232
233 let Some(instances) = data.get("instances").and_then(|v| v.as_object()) else {
234 return Ok("No mesh data available. Is serve mode running?".to_string());
235 };
236
237 let mut output = String::from("# Peer Mesh Status\n\n");
238
239 for (name, instance_data) in instances {
240 let instance = instance_data.get("instance");
241 let version = instance
242 .and_then(|i| i.get("version"))
243 .and_then(|v| v.as_str())
244 .unwrap_or("?");
245
246 let _ = writeln!(output, "## {name} (v{version})");
247
248 if let Some(targets) = instance_data.get("targets").and_then(|v| v.as_object()) {
249 for (target_name, target_data) in targets {
250 let status = target_data
251 .get("status")
252 .and_then(|v| v.as_str())
253 .unwrap_or("?");
254 let ms = target_data
255 .get("response_time_ms")
256 .and_then(serde_json::Value::as_i64);
257 let ms_str = ms.map(|m| format!(" ({m}ms)")).unwrap_or_default();
258 let _ = writeln!(output, "- Target {target_name}: {status}{ms_str}");
259 }
260 }
261
262 if let Some(peers) = instance_data.get("peers").and_then(|v| v.as_object()) {
263 for (peer_name, peer_data) in peers {
264 let status = peer_data
265 .get("status")
266 .and_then(|v| v.as_str())
267 .unwrap_or("?");
268 let latency = peer_data
269 .get("latency_ms")
270 .and_then(serde_json::Value::as_u64)
271 .map(|ms| format!(" ({ms}ms)"))
272 .unwrap_or_default();
273 let _ = writeln!(output, "- Peer {peer_name}: {status}{latency}");
274 }
275 }
276
277 if let Some(err) = instance_data.get("error").and_then(|v| v.as_str()) {
278 let _ = writeln!(output, "- ({err})");
279 }
280
281 output.push('\n');
282 }
283
284 Ok(output)
285 }
286
287 #[instrument(skip_all)]
288 pub async fn list_targets_impl(&self) -> crate::error::Result<String> {
289 let targets: Vec<TargetInfo> = self
290 .config
291 .target_names()
292 .into_iter()
293 .map(|name| {
294 let t = self.config.get_target(&name).unwrap();
295 TargetInfo {
296 name,
297 label: t.label.clone(),
298 has_health: t.health.is_some(),
299 has_tests: t.tests.is_some(),
300 }
301 })
302 .collect();
303
304 Ok(serde_json::to_string_pretty(&targets)?)
305 }
306 }
307