web+server: host rows show the full per-box picture; capacity shows fleet size (#717)
'0 awake · 1 asleep' beside a 16-instance fleet read as a contradiction — the pair covers only the box's hibernating instances. Rows now read '15 always-on · hibernating: 0 awake, 1 asleep' (boxes gain always_on in the JSON, allowlisted), and the Capacity tile carries 'Serving hosts: N'.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+12
−5
deploy/status-publish.sh+1 −0test/status-contract.test.mjs+1 −1test/status-fixtures/fleet-at-capacity.json+1 −1test/status-fixtures/fleet-getting-full.json+1 −1test/status-fixtures/fleet.json+1 −1web/status/index.html+7 −1
diff --git a/deploy/status-publish.sh b/deploy/status-publish.sh| index c2e0b6d..8016e71 100755 |
| --- a/deploy/status-publish.sh |
| +++ b/deploy/status-publish.sh |
| @@ -320,6 +320,7 @@ build_fleet() { |
| local boxes |
| boxes=$(sqj "SELECT json_array(json_object( |
| 'label', '$(box_label)', |
| + 'always_on', $systemd_n, |
| 'awake', json('$awake_json'), |
| 'asleep', json('$asleep_json'), |
| 'load', json('$load_json')))") |
diff --git a/test/status-contract.test.mjs b/test/status-contract.test.mjs| index 011d376..fea490f 100644 |
| --- a/test/status-contract.test.mjs |
| +++ b/test/status-contract.test.mjs |
| @@ -114,7 +114,7 @@ await test("hosts + capacity carry only the allowed abstract schema", () => { |
| assert.deepEqual(Object.keys(f.hosts).sort(), ["boxes", "count"], name + ": hosts keys"); |
| assert.equal(f.hosts.count, f.hosts.boxes.length, name + ": count matches boxes"); |
| for (const b of f.hosts.boxes) { |
| - assert.deepEqual(Object.keys(b).sort(), ["asleep", "awake", "label", "load"], name + ": box keys"); |
| + assert.deepEqual(Object.keys(b).sort(), ["always_on", "asleep", "awake", "label", "load"], name + ": box keys"); |
| // Labels are anonymous ordinals — never a hostname, IP, or URL. |
| assert.match(b.label, /^[a-z]+-[a-z]+$/, name + ": box label must be a name-action pair"); |
| assert.ok([null, "comfortable", "busy", "critical"].includes(b.load), |
diff --git a/test/status-fixtures/fleet-at-capacity.json b/test/status-fixtures/fleet-at-capacity.json| index adf06c0..e6c336a 100644 |
| --- a/test/status-fixtures/fleet-at-capacity.json |
| +++ b/test/status-fixtures/fleet-at-capacity.json |
| @@ -1 +1 @@ |
| -{"generated_at":"2026-07-11T14:49:29Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"hosts":{"count":1,"boxes":[{"label":"hypatia-sketching","awake":2,"asleep":2,"load":"critical"}]},"capacity":{"level":"at-capacity"},"versions":[{"version":"v100","count":3},{"version":"v101","count":2},{"version":"unknown","count":1}],"services":{"home":true,"push":true,"cloud":true,"connect":false},"replication":{"healthy":true,"lag_seconds":42}} |
| +{"generated_at":"2026-07-11T16:57:50Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"hosts":{"count":1,"boxes":[{"label":"hypatia-sketching","always_on":2,"awake":2,"asleep":2,"load":"critical"}]},"capacity":{"level":"at-capacity"},"versions":[{"version":"v100","count":3},{"version":"v101","count":2},{"version":"unknown","count":1}],"services":{"home":true,"push":true,"cloud":true,"connect":false},"replication":{"healthy":true,"lag_seconds":42}} |
diff --git a/test/status-fixtures/fleet-getting-full.json b/test/status-fixtures/fleet-getting-full.json| index b1089f3..6083c0f 100644 |
| --- a/test/status-fixtures/fleet-getting-full.json |
| +++ b/test/status-fixtures/fleet-getting-full.json |
| @@ -1 +1 @@ |
| -{"generated_at":"2026-07-11T14:49:29Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"hosts":{"count":1,"boxes":[{"label":"hypatia-sketching","awake":2,"asleep":2,"load":"comfortable"}]},"capacity":{"level":"getting-full"},"versions":[{"version":"v100","count":3},{"version":"v101","count":2},{"version":"unknown","count":1}],"services":{"home":true,"push":true,"cloud":true,"connect":false},"replication":{"healthy":true,"lag_seconds":42}} |
| +{"generated_at":"2026-07-11T16:57:50Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"hosts":{"count":1,"boxes":[{"label":"hypatia-sketching","always_on":2,"awake":2,"asleep":2,"load":"comfortable"}]},"capacity":{"level":"getting-full"},"versions":[{"version":"v100","count":3},{"version":"v101","count":2},{"version":"unknown","count":1}],"services":{"home":true,"push":true,"cloud":true,"connect":false},"replication":{"healthy":true,"lag_seconds":42}} |
diff --git a/test/status-fixtures/fleet.json b/test/status-fixtures/fleet.json| index 6dede19..436d721 100644 |
| --- a/test/status-fixtures/fleet.json |
| +++ b/test/status-fixtures/fleet.json |
| @@ -1 +1 @@ |
| -{"generated_at":"2026-07-11T14:49:28Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"hosts":{"count":1,"boxes":[{"label":"hypatia-sketching","awake":2,"asleep":2,"load":"comfortable"}]},"capacity":{"level":"plenty"},"versions":[{"version":"v100","count":3},{"version":"v101","count":2},{"version":"unknown","count":1}],"services":{"home":true,"push":true,"cloud":true,"connect":false},"replication":{"healthy":true,"lag_seconds":42}} |
| +{"generated_at":"2026-07-11T16:57:50Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"hosts":{"count":1,"boxes":[{"label":"hypatia-sketching","always_on":2,"awake":2,"asleep":2,"load":"comfortable"}]},"capacity":{"level":"plenty"},"versions":[{"version":"v100","count":3},{"version":"v101","count":2},{"version":"unknown","count":1}],"services":{"home":true,"push":true,"cloud":true,"connect":false},"replication":{"healthy":true,"lag_seconds":42}} |
diff --git a/web/status/index.html b/web/status/index.html| index 8dc1ca2..60eee23 100644 |
| --- a/web/status/index.html |
| +++ b/web/status/index.html |
| @@ -76,6 +76,7 @@ |
| .hosts { display: grid; gap: 10px; list-style: none; padding: 0; } |
| .hosts li { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 12px 16px; display: flex; align-items: baseline; justify-content: space-between; gap: 8px; flex-wrap: wrap; } |
| .hosts .name { font-weight: 600; } |
| + .capacity .cap-sub { color: var(--ink-2); font-size: 14px; margin-top: 6px; } |
| .hosts .split { color: var(--ink-2); font-size: 14px; } |
| .hosts .state { font-size: 14px; font-weight: 600; white-space: nowrap; } |
| .hosts .state.ok { color: var(--good); } |
| @@ -149,6 +150,7 @@ |
| <div class="tile capacity"> |
| <div class="label">Room for new spaces</div> |
| <div class="value" id="cap-level">—</div> |
| + <div class="cap-sub" id="cap-hosts"></div> |
| </div> |
| <ul class="hosts" id="hosts"></ul> |
| </section> |
| @@ -228,6 +230,7 @@ function render(d) { |
| const capEl = $("cap-level"); |
| capEl.className = "value" + (cap ? " " + cap.tone : ""); |
| capEl.textContent = cap ? cap.text : "—"; |
| + $("cap-hosts").textContent = "Serving hosts: " + num(d.hosts && d.hosts.count); |
| const hosts = $("hosts"); |
| hosts.setAttribute("aria-label", "Serving hosts: " + num(d.hosts && d.hosts.count)); |
| @@ -240,7 +243,10 @@ function render(d) { |
| n.textContent = b.label; |
| const split = document.createElement("span"); |
| split.className = "split"; |
| - split.textContent = num(b.awake) + " awake · " + num(b.asleep) + " asleep"; |
| + // The awake/asleep pair covers only this box's HIBERNATING instances — |
| + // the always-on count sits beside it so the row sums to what the box runs. |
| + split.textContent = num(b.always_on) + " always-on · hibernating: " + |
| + num(b.awake) + " awake, " + num(b.asleep) + " asleep"; |
| const load = LOAD_LEVELS[b.load]; |
| const s = document.createElement("span"); |
| s.className = "state" + (load ? " " + load.tone : ""); |