eleven / messenger Public

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>
Paul Campbell 3d48c826a775c4807f41eeef20c4de18b025437f parent 067ad3e
6 files changed, +12 −5
  • deploy/status-publish.sh +1 −0
  • test/status-contract.test.mjs +1 −1
  • test/status-fixtures/fleet-at-capacity.json +1 −1
  • test/status-fixtures/fleet-getting-full.json +1 −1
  • test/status-fixtures/fleet.json +1 −1
  • web/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 : "");