max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+154 insertions,
-10 deletions
| @@ -155,7 +155,7 @@ | |||
| 155 | 155 | | `/api/status` | GET | JSON summary of all targets (latest health, uptime, latency, TLS, staleness, incidents) | | |
| 156 | 156 | | `/api/status/{target}` | GET | Same as above for a single target | | |
| 157 | 157 | | `/api/trends/{target}` | GET | Latency trend data with configurable window and bucket size (`?hours=24&bucket_minutes=60`) | | |
| 158 | - | | `/api/versions` | GET | What each target is running: version, git sha, when it was seen, commits behind this host's checkout | | |
| 158 | + | | `/api/versions` | GET | What each target is running: version, git sha, when that version was first seen, when it was last checked, commits behind this host's checkout | | |
| 159 | 159 | | `/api/peer/info` | GET | This instance's identity (id, name, version, targets, started_at) | | |
| 160 | 160 | | `/api/peer/status` | GET | This instance's full view: identity + target statuses + peer summaries | | |
| 161 | 161 | | `/api/mesh` | GET | Aggregated mesh view: self + each peer's cached status | |
| @@ -372,20 +372,24 @@ | |||
| 372 | 372 | return "No targets configured.\n".to_string(); | |
| 373 | 373 | } | |
| 374 | 374 | ||
| 375 | - | let cells: Vec<[String; 5]> = rows | |
| 375 | + | let cells: Vec<[String; 6]> = rows | |
| 376 | 376 | .iter() | |
| 377 | 377 | .map(|r| { | |
| 378 | 378 | [ | |
| 379 | 379 | scrub(&r.target), | |
| 380 | 380 | r.version.as_deref().map_or_else(dash, scrub), | |
| 381 | 381 | r.git_sha.as_deref().map_or_else(dash, short_sha), | |
| 382 | + | r.version_since.as_deref().map_or_else(dash, minute_stamp), | |
| 382 | 383 | r.checked_at.as_deref().map_or_else(dash, minute_stamp), | |
| 383 | 384 | r.commits_behind.map_or_else(dash, |n| n.to_string()), | |
| 384 | 385 | ] | |
| 385 | 386 | }) | |
| 386 | 387 | .collect(); | |
| 387 | 388 | ||
| 388 | - | const HEADERS: [&str; 5] = ["TARGET", "VERSION", "SHA", "CHECKED", "BEHIND"]; | |
| 389 | + | // SINCE is when this version was first seen, not when it was deployed: | |
| 390 | + | // nothing tells PoM about a deploy, and the column header should not | |
| 391 | + | // promise more than a poller can know. | |
| 392 | + | const HEADERS: [&str; 6] = ["TARGET", "VERSION", "SHA", "SINCE", "CHECKED", "BEHIND"]; | |
| 389 | 393 | let widths: Vec<usize> = (0..HEADERS.len()) | |
| 390 | 394 | .map(|i| { | |
| 391 | 395 | cells | |
| @@ -412,7 +416,7 @@ | |||
| 412 | 416 | } | |
| 413 | 417 | ||
| 414 | 418 | /// Write one table row, every column but the last padded to its width. | |
| 415 | - | fn write_row(out: &mut String, cells: &[String; 5], widths: &[usize]) { | |
| 419 | + | fn write_row(out: &mut String, cells: &[String; 6], widths: &[usize]) { | |
| 416 | 420 | for (i, cell) in cells.iter().enumerate() { | |
| 417 | 421 | if i + 1 == cells.len() { | |
| 418 | 422 | writeln!(out, "{cell}").unwrap(); | |
| @@ -1586,6 +1590,7 @@ | |||
| 1586 | 1590 | version: version.map(String::from), | |
| 1587 | 1591 | git_sha: sha.map(String::from), | |
| 1588 | 1592 | checked_at: Some("2026-07-29T18:04:37.123456+00:00".to_string()), | |
| 1593 | + | version_since: Some("2026-07-28T09:00:00+00:00".to_string()), | |
| 1589 | 1594 | commits_behind: Some(8), | |
| 1590 | 1595 | behind_error: None, | |
| 1591 | 1596 | } | |
| @@ -1623,6 +1628,7 @@ | |||
| 1623 | 1628 | version: None, | |
| 1624 | 1629 | git_sha: None, | |
| 1625 | 1630 | checked_at: None, | |
| 1631 | + | version_since: None, | |
| 1626 | 1632 | commits_behind: None, | |
| 1627 | 1633 | behind_error: None, | |
| 1628 | 1634 | }]; | |
| @@ -1630,7 +1636,7 @@ | |||
| 1630 | 1636 | let row = out.lines().nth(1).unwrap(); | |
| 1631 | 1637 | assert_eq!( | |
| 1632 | 1638 | row.split_whitespace().collect::<Vec<_>>(), | |
| 1633 | - | ["mt", "-", "-", "-", "-"] | |
| 1639 | + | ["mt", "-", "-", "-", "-", "-"] | |
| 1634 | 1640 | ); | |
| 1635 | 1641 | } | |
| 1636 | 1642 |
| @@ -245,6 +245,11 @@ | |||
| 245 | 245 | /// When that health check ran, RFC 3339. `None` if the target has never | |
| 246 | 246 | /// been checked, or has no health endpoint at all. | |
| 247 | 247 | pub checked_at: Option<String>, | |
| 248 | + | /// When the live version was first seen, RFC 3339. The closest honest | |
| 249 | + | /// answer to "when was this deployed": PoM observes versions, not deploys, | |
| 250 | + | /// and only as far back as retention keeps the checks. | |
| 251 | + | #[serde(default)] | |
| 252 | + | pub version_since: Option<String>, | |
| 248 | 253 | /// Commits in the configured repo between the live sha and local HEAD. | |
| 249 | 254 | /// `None` when there is no repo configured, no live sha to anchor on, or | |
| 250 | 255 | /// the count could not be taken: the column degrades to blank rather than |
| @@ -31,12 +31,15 @@ | |||
| 31 | 31 | ||
| 32 | 32 | let (commits_behind, behind_error) = match (&target.repo, &git_sha) { | |
| 33 | 33 | (None, _) => (None, None), | |
| 34 | - | // A repo is configured but the target never reported a commit, so | |
| 35 | - | // there is nothing to anchor the count to. Say which of the two is | |
| 36 | - | // missing: the fix is on the target, not in pom.toml. | |
| 34 | + | // A repo is configured but there is no commit to anchor the count | |
| 35 | + | // to. A target that has been checked and did not send one is a gap | |
| 36 | + | // on the target; a target that has never been checked has nothing | |
| 37 | + | // to say yet, and the blank row already says it. | |
| 37 | 38 | (Some(_), None) => ( | |
| 38 | 39 | None, | |
| 39 | - | Some("target reports no git_sha in its health body".to_string()), | |
| 40 | + | health | |
| 41 | + | .as_ref() | |
| 42 | + | .map(|_| "target reports no git_sha in its health body".to_string()), | |
| 40 | 43 | ), | |
| 41 | 44 | (Some(repo), Some(sha)) => match commits_behind(repo, sha).await { | |
| 42 | 45 | Ok(n) => (Some(n), None), | |
| @@ -44,12 +47,19 @@ | |||
| 44 | 47 | }, | |
| 45 | 48 | }; | |
| 46 | 49 | ||
| 50 | + | let version = details.and_then(|d| d.version.clone()); | |
| 51 | + | let version_since = match &version { | |
| 52 | + | Some(v) => db::get_version_first_seen(pool, &name, v).await?, | |
| 53 | + | None => None, | |
| 54 | + | }; | |
| 55 | + | ||
| 47 | 56 | rows.push(VersionRow { | |
| 48 | 57 | target: name, | |
| 49 | 58 | label: target.label.clone(), | |
| 50 | - | version: details.and_then(|d| d.version.clone()), | |
| 59 | + | version, | |
| 51 | 60 | git_sha, | |
| 52 | 61 | checked_at: health.as_ref().map(|h| h.checked_at.clone()), | |
| 62 | + | version_since, | |
| 53 | 63 | commits_behind, | |
| 54 | 64 | behind_error, | |
| 55 | 65 | }); |
| @@ -3938,6 +3938,32 @@ | |||
| 3938 | 3938 | assert_eq!(rows[0].version.as_deref(), Some("0.11.0")); | |
| 3939 | 3939 | } | |
| 3940 | 3940 | ||
| 3941 | + | #[tokio::test] | |
| 3942 | + | async fn versions_stays_quiet_about_a_target_that_has_never_been_checked() { | |
| 3943 | + | // A repo is configured, but no health check has ever run. There is nothing | |
| 3944 | + | // to report yet, and claiming the target "reports no git_sha" would blame | |
| 3945 | + | // it for a check that never happened. | |
| 3946 | + | let (path, _) = scratch_repo("nocheck", 1, None); | |
| 3947 | + | let pool = db::connect_in_memory().await.unwrap(); | |
| 3948 | + | let config: pom::config::Config = toml::from_str(&format!( | |
| 3949 | + | r#" | |
| 3950 | + | [targets.mnw] | |
| 3951 | + | label = "MakeNotWork" | |
| 3952 | + | [targets.mnw.repo] | |
| 3953 | + | path = "{}" | |
| 3954 | + | "#, | |
| 3955 | + | path.display() | |
| 3956 | + | )) | |
| 3957 | + | .unwrap(); | |
| 3958 | + | ||
| 3959 | + | let rows = pom::versions::collect(&pool, &config).await.unwrap(); | |
| 3960 | + | assert!(rows[0].checked_at.is_none()); | |
| 3961 | + | assert!(rows[0].commits_behind.is_none()); | |
| 3962 | + | assert!(rows[0].behind_error.is_none(), "{:?}", rows[0].behind_error); | |
| 3963 | + | ||
| 3964 | + | std::fs::remove_dir_all(&path).unwrap(); | |
| 3965 | + | } | |
| 3966 | + | ||
| 3941 | 3967 | #[tokio::test] | |
| 3942 | 3968 | async fn versions_says_when_a_target_reports_no_sha_to_anchor_on() { | |
| 3943 | 3969 | let (path, _) = scratch_repo("nosha", 1, None); | |
| @@ -4271,3 +4297,66 @@ | |||
| 4271 | 4297 | assert_eq!(json[0]["version"], "0.11.0"); | |
| 4272 | 4298 | assert_eq!(json[0]["git_sha"], "6402bf4e"); | |
| 4273 | 4299 | } | |
| 4300 | + | ||
| 4301 | + | #[tokio::test] | |
| 4302 | + | async fn versions_reports_when_the_live_version_was_first_seen() { | |
| 4303 | + | let pool = db::connect_in_memory().await.unwrap(); | |
| 4304 | + | let config: pom::config::Config = toml::from_str( | |
| 4305 | + | r#" | |
| 4306 | + | [targets.mnw] | |
| 4307 | + | label = "MakeNotWork" | |
| 4308 | + | "#, | |
| 4309 | + | ) | |
| 4310 | + | .unwrap(); | |
| 4311 | + | ||
| 4312 | + | insert_version_health(&pool, "mnw", Some("0.10.0"), None, "2026-07-20T00:00:00Z").await; | |
| 4313 | + | insert_version_health(&pool, "mnw", Some("0.11.0"), None, "2026-07-27T00:00:00Z").await; | |
| 4314 | + | // An unreachable check reports no version at all. It must not read as a | |
| 4315 | + | // version change, or every blip would look like a fresh deploy. | |
| 4316 | + | let blip = HealthSnapshot { | |
| 4317 | + | id: None, | |
| 4318 | + | target: "mnw".to_string(), | |
| 4319 | + | status: HealthStatus::Unreachable, | |
| 4320 | + | checked_at: "2026-07-28T00:00:00Z".to_string(), | |
| 4321 | + | response_time_ms: 0, | |
| 4322 | + | details: None, | |
| 4323 | + | error: Some("timeout".to_string()), | |
| 4324 | + | }; | |
| 4325 | + | db::insert_health_check(&pool, &blip).await.unwrap(); | |
| 4326 | + | insert_version_health(&pool, "mnw", Some("0.11.0"), None, "2026-07-29T00:00:00Z").await; | |
| 4327 | + | ||
| 4328 | + | let rows = pom::versions::collect(&pool, &config).await.unwrap(); | |
| 4329 | + | assert_eq!(rows[0].version.as_deref(), Some("0.11.0")); | |
| 4330 | + | assert_eq!( | |
| 4331 | + | rows[0].version_since.as_deref(), | |
| 4332 | + | Some("2026-07-27T00:00:00Z") | |
| 4333 | + | ); | |
| 4334 | + | assert_eq!(rows[0].checked_at.as_deref(), Some("2026-07-29T00:00:00Z")); | |
| 4335 | + | } | |
| 4336 | + | ||
| 4337 | + | #[tokio::test] | |
| 4338 | + | async fn versions_first_seen_moves_when_the_version_does() { | |
| 4339 | + | let pool = db::connect_in_memory().await.unwrap(); | |
| 4340 | + | let config: pom::config::Config = toml::from_str( | |
| 4341 | + | r#" | |
| 4342 | + | [targets.mnw] | |
| 4343 | + | label = "MakeNotWork" | |
| 4344 | + | "#, | |
| 4345 | + | ) | |
| 4346 | + | .unwrap(); | |
| 4347 | + | ||
| 4348 | + | insert_version_health(&pool, "mnw", Some("0.11.0"), None, "2026-07-27T00:00:00Z").await; | |
| 4349 | + | let before = pom::versions::collect(&pool, &config).await.unwrap(); | |
| 4350 | + | assert_eq!( | |
| 4351 | + | before[0].version_since.as_deref(), | |
| 4352 | + | Some("2026-07-27T00:00:00Z") | |
| 4353 | + | ); | |
| 4354 | + | ||
| 4355 | + | insert_version_health(&pool, "mnw", Some("0.11.1"), None, "2026-07-29T12:00:00Z").await; | |
| 4356 | + | let after = pom::versions::collect(&pool, &config).await.unwrap(); | |
| 4357 | + | assert_eq!(after[0].version.as_deref(), Some("0.11.1")); | |
| 4358 | + | assert_eq!( | |
| 4359 | + | after[0].version_since.as_deref(), | |
| 4360 | + | Some("2026-07-29T12:00:00Z") | |
| 4361 | + | ); | |
| 4362 | + | } |
| @@ -75,6 +75,40 @@ | |||
| 75 | 75 | Ok(row.map(HealthCheckRow::into_snapshot)) | |
| 76 | 76 | } | |
| 77 | 77 | ||
| 78 | + | /// When the target's *current* version was first seen, i.e. the earliest check | |
| 79 | + | /// in the unbroken run of checks reporting it. | |
| 80 | + | /// | |
| 81 | + | /// The closest PoM can honestly get to "when was this deployed": it observes | |
| 82 | + | /// versions, never deployments. Two consequences worth knowing at the call | |
| 83 | + | /// site. A failed check reports no version at all, and is skipped rather than | |
| 84 | + | /// treated as a version change, so an outage does not read as a redeploy. And | |
| 85 | + | /// the answer is bounded by retention: a version older than the prune window | |
| 86 | + | /// looks like it was first seen at the oldest row still held. | |
| 87 | + | #[instrument(skip_all)] | |
| 88 | + | pub async fn get_version_first_seen( | |
| 89 | + | pool: &SqlitePool, | |
| 90 | + | target: &str, | |
| 91 | + | version: &str, | |
| 92 | + | ) -> Result<Option<String>> { | |
| 93 | + | let row = sqlx::query_as::<_, (Option<String>,)>( | |
| 94 | + | "SELECT MIN(checked_at) FROM health_checks | |
| 95 | + | WHERE target = ?1 | |
| 96 | + | AND json_extract(details_json, '$.version') = ?2 | |
| 97 | + | AND id > COALESCE( | |
| 98 | + | (SELECT MAX(id) FROM health_checks | |
| 99 | + | WHERE target = ?1 | |
| 100 | + | AND json_extract(details_json, '$.version') IS NOT NULL | |
| 101 | + | AND json_extract(details_json, '$.version') != ?2), | |
| 102 | + | 0)", | |
| 103 | + | ) | |
| 104 | + | .bind(target) | |
| 105 | + | .bind(version) | |
| 106 | + | .fetch_optional(pool) | |
| 107 | + | .await?; | |
| 108 | + | ||
| 109 | + | Ok(row.and_then(|r| r.0)) | |
| 110 | + | } | |
| 111 | + | ||
| 78 | 112 | /// Calculate uptime percentage for a target over the given number of hours. | |
| 79 | 113 | /// Returns the percentage of health checks with "operational" status. | |
| 80 | 114 | #[instrument(skip_all)] |