web+server: status page — mode vs state structural, hosts header, coarse box regions (#730)
Three connected fleet-dashboard changes:
- Spaces tiles: three tiles (Instances / Always-on / On-demand, né
Hibernating) instead of five siblings. Awake/asleep is the STATE of the
on-demand set, so it nests inside that tile as a sub-line ("2 awake ·
2 asleep") — structure now encodes the mode/state relationship that read
as "Hibernating 17" vs "Awake 17" contradicting each other.
- "Serving hosts · N" becomes the header of the host list (h3 above .hosts,
aria-labelledby) instead of a cap-sub line inside the capacity tile.
- Each box may carry a coarse public region ("EU · Germany" — continent +
country ONLY, never city/IP/hostname), read from the new STATUS_BOX_REGION
env in status-publish.sh; absent env omits the field and the page renders
nothing (never guessed). Per-box regions ride mesh heartbeats when
multi-box lands; the env is the single-box seam. Rendered muted after the
box label. Contract test allowlists boxes[].region as an optional string
and rejects IP/hostname-looking values; fixtures regenerated from all
three dry-run variants.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
8 files changed,
+89
−28
deploy/status-publish.sh+22 −3docs/status.md+7 −2test/status-contract.test.mjs+15 −1test/status-fixtures/books.json+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+41 −18
diff --git a/deploy/status-publish.sh b/deploy/status-publish.sh| index 8016e71..391485a 100755 |
| --- a/deploy/status-publish.sh |
| +++ b/deploy/status-publish.sh |
| @@ -27,6 +27,11 @@ |
| # pairs hashed from the machine id ("ada-stargazing") — stable per box, |
| # never peer URLs, IPs, hostnames, or the machine id itself. |
| # |
| +# A box's `region` is COARSE BY DESIGN: an operator-set continent + country |
| +# pair ("EU · Germany", via STATUS_BOX_REGION) — never a city, datacenter, |
| +# IP, or hostname. Unset → the field is omitted entirely (the page shows |
| +# nothing); a region is never guessed from the box. |
| +# |
| # books.json — AGGREGATED INCOME ONLY. Active-subscription count (by tier — |
| # tier names are the public price book), MRR total in EUR, gross collected |
| # this month. NEVER customer ids, emails, subscription ids, per-payer |
| @@ -74,6 +79,11 @@ CONTROL_ENV=${STATUS_CONTROL_ENV:-/data/control.env} |
| # uses fixture twins instead and never reads live box state). |
| MEMINFO=${STATUS_MEMINFO:-/proc/meminfo} |
| DATA_DIR=${STATUS_DATA_DIR:-/data} |
| +# Coarse public region of this box — continent + country ONLY ("EU · Germany"); |
| +# see PRIVACY above. Optional: unset omits the field from fleet.json. Per-box |
| +# regions will come from mesh heartbeats when multi-box lands; this env is the |
| +# single-box seam until then. |
| +BOX_REGION=${STATUS_BOX_REGION:-} |
| STATUS_ENV=${STATUS_ENV_FILE:-/data/status.env} |
| PAGES_DIR=${STATUS_PAGES_DIR:-/opt/lchat/status-pages} |
| STRIPE_API=${STATUS_STRIPE_API:-https://api.stripe.com} |
| @@ -316,14 +326,20 @@ build_fleet() { |
| # from a HASH of the machine id ("ada-stargazing") — stable per box, and |
| # never peer URLs, IPs, hostnames, or the machine id itself. Per-box |
| # awake/asleep are the fleet-wide counts (one box = the whole fleet), |
| - # already public above. |
| - local boxes |
| + # already public above. The optional region is the operator-set coarse |
| + # continent + country word pair (see PRIVACY) — absent env, absent key, |
| + # never guessed; escaped like the version strings so a quote can't break |
| + # out of the SQL literal. |
| + local boxes region_kv="" |
| + if [ -n "$BOX_REGION" ]; then |
| + region_kv=", 'region', '$(printf '%s' "$BOX_REGION" | sed "s/'/''/g")'" |
| + fi |
| 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')))") |
| + 'load', json('$load_json')$region_kv))") |
| # Assembled by sqlite json_object: the only interpolated values are |
| # script-controlled numerals/booleans/band-words and the sqlite-built arrays. |
| @@ -611,6 +627,9 @@ EOF |
| # (a dev Mac has no /etc/machine-id; hostname fallback would vary per machine). |
| echo dry-run-fixture-machine-id > "$FIXDIR/machine-id" |
| export STATUS_MACHINE_ID_FILE="$FIXDIR/machine-id" |
| + # Fixed region fixture: exercises the STATUS_BOX_REGION seam end-to-end (the |
| + # contract test pins both the value and its coarse continent+country shape). |
| + BOX_REGION="EU · Germany" |
| echo 42 > "$FIXDIR/replication-lag.txt" |
| # Capacity fixtures (control caps + disk + meminfo): one set per public band |
diff --git a/docs/status.md b/docs/status.md| index 0bfcc08..4727c90 100644 |
| --- a/docs/status.md |
| +++ b/docs/status.md |
| @@ -33,6 +33,9 @@ aggregation boundary lives in the generator and holds by construction: |
| slot counts, free GB, percentages, or cap values — the caps and ratios are |
| read internally to pick the word and die in the generator. Host labels are |
| whimsical name-action pairs hashed from the machine id (`ada-stargazing`) — stable per box; never peer URLs, IPs, hostnames, or the machine id itself. |
| + A box's optional `region` is coarse by design: an operator-set continent + |
| + country pair (`STATUS_BOX_REGION`, e.g. `EU · Germany`) — never a city, |
| + datacenter, IP, or hostname; unset, the field is omitted (never guessed). |
| - **Books: aggregated income only** (decision #19 in #550: income publishes |
| aggregated, never per-payer; invariant 12: no PII in our stores). Never |
| customer ids, emails, subscription ids, per-payer lines, or timestamps of |
| @@ -50,7 +53,7 @@ aggregation boundary lives in the generator and holds by construction: |
| { |
| "generated_at": "2026-07-11T13:00:00Z", |
| "instances": { "total": 6, "systemd": 2, "hibernating": 4, "awake": 2, "asleep": 2 }, |
| - "hosts": { "count": 1, "boxes": [ { "label": "ada-stargazing", "awake": 2, "asleep": 2, "load": "comfortable" } ] }, |
| + "hosts": { "count": 1, "boxes": [ { "label": "ada-stargazing", "region": "EU · Germany", "always_on": 2, "awake": 2, "asleep": 2, "load": "comfortable" } ] }, |
| "capacity": { "level": "plenty" }, |
| "versions": [ { "version": "v100", "count": 3 } ], |
| "services": { "home": true, "push": true, "cloud": true, "connect": true }, |
| @@ -78,7 +81,9 @@ at-capacity outright), and memory utilization (`/proc/meminfo`). The caps are |
| read from `/data/control.env`. A box's `load` word bands memory utilization |
| the same way (`comfortable` / `busy` / `critical`, null when unknown). Only |
| the words publish — never the ratios, GB, or caps behind them. `hosts` v1 is |
| -the local box alone; multi-box joins when the mesh goes live. |
| +the local box alone; multi-box joins when the mesh goes live — per-box |
| +`region` will then ride the mesh heartbeats, with `STATUS_BOX_REGION` as the |
| +single-box seam until it does. |
| `books.json` (or `{"available": false, "reason": "…"}` when billing isn't |
| configured / Stripe is unreachable): |
diff --git a/test/status-contract.test.mjs b/test/status-contract.test.mjs| index fea490f..e6b3470 100644 |
| --- a/test/status-contract.test.mjs |
| +++ b/test/status-contract.test.mjs |
| @@ -114,7 +114,18 @@ 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(), ["always_on", "asleep", "awake", "label", "load"], name + ": box keys"); |
| + // region is optional (omitted when the operator sets none). When present |
| + // it must be a coarse public area — continent + country — and must never |
| + // read like an IP or hostname (no dots-and-digits / label.tld shapes). |
| + const base = ["always_on", "asleep", "awake", "label", "load"]; |
| + if ("region" in b) { |
| + assert.deepEqual(Object.keys(b).sort(), base.concat("region"), name + ": box keys"); |
| + assert.equal(typeof b.region, "string", name + ": region is a string"); |
| + assert.ok(!/\d+\.\d+|[a-z0-9-]+\.[a-z]{2,}/i.test(b.region), |
| + name + ": region reads like an IP/hostname: " + b.region); |
| + } else { |
| + assert.deepEqual(Object.keys(b).sort(), base, 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), |
| @@ -137,6 +148,9 @@ await test("capacity fixtures derive plenty / getting-full / at-capacity", () => |
| // box load are independent signals. |
| assert.equal(fleetGF.hosts.boxes[0].load, "comfortable"); |
| assert.equal(fleetAC.hosts.boxes[0].load, "critical"); |
| + // The STATUS_BOX_REGION seam: the dry-run's fixed coarse region rides |
| + // through to the box row verbatim. |
| + assert.equal(fleet.hosts.boxes[0].region, "EU · Germany"); |
| }); |
| if (failures > 0) { |
diff --git a/test/status-fixtures/books.json b/test/status-fixtures/books.json| index 6a81aa3..241338b 100644 |
| --- a/test/status-fixtures/books.json |
| +++ b/test/status-fixtures/books.json |
| @@ -1 +1 @@ |
| -{"generated_at":"2026-07-11T14:22:57Z","available":true,"currency":"eur","month":"2026-07","subscriptions":{"active":3,"by_tier":[{"tier":"friends","count":2},{"tier":"family","count":1}]},"mrr_cents":1600,"collected_month_cents":1200} |
| +{"generated_at":"2026-07-11T18:41:14Z","available":true,"currency":"eur","month":"2026-07","subscriptions":{"active":3,"by_tier":[{"tier":"friends","count":2},{"tier":"family","count":1}]},"mrr_cents":1600,"collected_month_cents":1200} |
diff --git a/test/status-fixtures/fleet-at-capacity.json b/test/status-fixtures/fleet-at-capacity.json| index e6c336a..ac470f4 100644 |
| --- a/test/status-fixtures/fleet-at-capacity.json |
| +++ b/test/status-fixtures/fleet-at-capacity.json |
| @@ -1 +1 @@ |
| -{"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}} |
| +{"generated_at":"2026-07-11T18:41:14Z","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","region":"EU · Germany"}]},"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 6083c0f..3e47143 100644 |
| --- a/test/status-fixtures/fleet-getting-full.json |
| +++ b/test/status-fixtures/fleet-getting-full.json |
| @@ -1 +1 @@ |
| -{"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}} |
| +{"generated_at":"2026-07-11T18:41:14Z","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","region":"EU · Germany"}]},"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 436d721..a8cd4db 100644 |
| --- a/test/status-fixtures/fleet.json |
| +++ b/test/status-fixtures/fleet.json |
| @@ -1 +1 @@ |
| -{"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}} |
| +{"generated_at":"2026-07-11T18:41:14Z","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","region":"EU · Germany"}]},"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 60eee23..e9405e5 100644 |
| --- a/web/status/index.html |
| +++ b/web/status/index.html |
| @@ -59,6 +59,7 @@ |
| .tile { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; } |
| .tile .label { font-size: 13px; color: var(--ink-2); } |
| .tile .value { font-size: 30px; font-weight: 600; margin-top: 2px; } |
| + .tile .sub { color: var(--ink-2); font-size: 14px; margin-top: 6px; } |
| section h2 { font-size: 15px; font-weight: 650; margin: 0 0 10px; } |
| section { margin-bottom: 28px; } |
| @@ -73,10 +74,12 @@ |
| /* Hosts: one compact row per serving box — anonymous ordinals only, never a |
| hostname. Load mirrors the service-state pattern: word + glyph. */ |
| + .hosts-head { font-size: 14px; font-weight: 650; margin: 16px 0 8px; } |
| .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 .who { display: inline-flex; gap: 8px; align-items: baseline; } |
| .hosts .name { font-weight: 600; } |
| - .capacity .cap-sub { color: var(--ink-2); font-size: 14px; margin-top: 6px; } |
| + .hosts .region { color: var(--ink-2); font-size: 14px; } |
| .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); } |
| @@ -124,8 +127,9 @@ |
| <h1>Fleet status</h1> |
| </div> |
| </header> |
| - <p class="lede">Live aggregate health of the Eleven fleet — how many spaces are running, |
| - awake, or hibernating, what they run, and whether the shared services are up. |
| + <p class="lede">Live aggregate health of the Eleven fleet — how many spaces are running |
| + (always-on or on-demand, and of the on-demand ones how many are awake), what |
| + they run, and whether the shared services are up. |
| Counts only: no space is ever named here.</p> |
| <p class="asof">As of <time id="asof" datetime="">—</time><span id="staleness"></span></p> |
| @@ -133,26 +137,33 @@ |
| <section aria-labelledby="h-instances"> |
| <h2 id="h-instances">Spaces</h2> |
| + <!-- On-demand (né Hibernating) is a MODE — activator-managed instances; |
| + awake/asleep are the STATE of that same set. The state nests inside |
| + the mode's tile so the structure encodes the relationship — five |
| + sibling tiles read "Hibernating 17" vs "Awake 17" as a contradiction. --> |
| <div class="tiles"> |
| <div class="tile"><div class="label">Instances</div><div class="value" id="t-total">—</div></div> |
| <div class="tile"><div class="label">Always-on</div><div class="value" id="t-systemd">—</div></div> |
| - <div class="tile"><div class="label">Hibernating</div><div class="value" id="t-hib">—</div></div> |
| - <div class="tile"><div class="label">Awake now</div><div class="value" id="t-awake">—</div></div> |
| - <div class="tile"><div class="label">Asleep</div><div class="value" id="t-asleep">—</div></div> |
| + <div class="tile"> |
| + <div class="label">On-demand</div> |
| + <div class="value" id="t-ondemand">—</div> |
| + <div class="sub" id="t-ondemand-state"></div> |
| + </div> |
| </div> |
| </section> |
| <!-- Capacity + hosts are deliberately abstract: band words only, no |
| - remaining-slot counts, GB, or percentages — and host labels are |
| - anonymous ordinals, never hostnames. --> |
| + remaining-slot counts, GB, or percentages — host labels are anonymous |
| + ordinals, never hostnames, and a box's region is continent + country |
| + only. --> |
| <section aria-labelledby="h-capacity"> |
| <h2 id="h-capacity">Capacity</h2> |
| <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> |
| + <h3 class="hosts-head" id="hosts-head">Serving hosts</h3> |
| + <ul class="hosts" id="hosts" aria-labelledby="hosts-head"></ul> |
| </section> |
| <section aria-labelledby="h-services"> |
| @@ -179,8 +190,8 @@ const FIELDS = [ |
| "generated_at", |
| "instances.total", "instances.systemd", "instances.hibernating", |
| "instances.awake", "instances.asleep", |
| - "hosts.count", "hosts.boxes[].label", "hosts.boxes[].awake", |
| - "hosts.boxes[].asleep", "hosts.boxes[].load", |
| + "hosts.count", "hosts.boxes[].label", "hosts.boxes[].region", |
| + "hosts.boxes[].awake", "hosts.boxes[].asleep", "hosts.boxes[].load", |
| "capacity.level", |
| "versions[].version", "versions[].count", |
| "services.home", "services.push", "services.cloud", "services.connect", |
| @@ -222,25 +233,37 @@ function render(d) { |
| $("t-total").textContent = num(d.instances.total); |
| $("t-systemd").textContent = num(d.instances.systemd); |
| - $("t-hib").textContent = num(d.instances.hibernating); |
| - $("t-awake").textContent = num(d.instances.awake); |
| - $("t-asleep").textContent = num(d.instances.asleep); |
| + // On-demand is the mode; the awake/asleep pair is the state of that set, |
| + // rendered as the tile's sub-line rather than as sibling tiles. |
| + $("t-ondemand").textContent = num(d.instances.hibernating); |
| + $("t-ondemand-state").textContent = |
| + num(d.instances.awake) + " awake · " + num(d.instances.asleep) + " asleep"; |
| const cap = d.capacity && CAP_LEVELS[d.capacity.level]; |
| 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); |
| + $("hosts-head").textContent = "Serving hosts · " + num(d.hosts && d.hosts.count); |
| const hosts = $("hosts"); |
| - hosts.setAttribute("aria-label", "Serving hosts: " + num(d.hosts && d.hosts.count)); |
| hosts.replaceChildren(); |
| const boxes = (d.hosts && d.hosts.boxes) || []; |
| for (const b of boxes) { |
| const li = document.createElement("li"); |
| + const who = document.createElement("span"); |
| + who.className = "who"; |
| const n = document.createElement("span"); |
| n.className = "name"; |
| n.textContent = b.label; |
| + who.append(n); |
| + // Coarse public region (continent + country) — only when the box declares |
| + // one; an absent region renders nothing, never a guess. |
| + if (typeof b.region === "string" && b.region) { |
| + const r = document.createElement("span"); |
| + r.className = "region"; |
| + r.textContent = b.region; |
| + who.append(r); |
| + } |
| const split = document.createElement("span"); |
| split.className = "split"; |
| // The awake/asleep pair covers only this box's HIBERNATING instances — |
| @@ -251,7 +274,7 @@ function render(d) { |
| const s = document.createElement("span"); |
| s.className = "state" + (load ? " " + load.tone : ""); |
| s.textContent = load ? load.text : "—"; |
| - li.append(n, split, s); |
| + li.append(who, split, s); |
| hosts.append(li); |
| } |