eleven / messenger Public

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>
Paul Campbell 9341e4f7b77e321c61668d1a39edd1b0e387388f parent 23e538f
8 files changed, +89 −28
  • deploy/status-publish.sh +22 −3
  • docs/status.md +7 −2
  • test/status-contract.test.mjs +15 −1
  • test/status-fixtures/books.json +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 +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);
}