eleven / messenger Public

server: status dashboard gains serving hosts + capacity — abstract levels only (#697)

fleet.json grows two aggregates, both deliberately needle-free (bucketed
words, never precise numbers): capacity.level (plenty / getting-full /
at-capacity) and hosts.boxes[] (anonymous ordinal label, awake/asleep,
load word comfortable / busy / critical).

- Generator: capacity is worst-of the atCapacity() inputs (controld.go
  #663 — materialized vs LCHAT_MAX_INSTANCES, registered vs
  LCHAT_MAX_REGISTERED under hibernate-creates, disk vs the
  LCHAT_MIN_FREE_GB floor) plus /proc/meminfo utilization, banded at
  70/90; caps read from /data/control.env with controld's code defaults
  (40 / 5 GB / 10x) when absent. Only the band word ships — every
  number dies in the generator, and the dry-run fails on any digit in
  the capacity subtree. Hosts v1 is the local box alone (box-1);
  multi-box joins when the mesh goes live (needs a read grant on the
  mesh prefix — the publisher's S3 key is status-bucket-scoped).
- Page: a Capacity section between the space tiles and services — the
  band as a prominent tile plus one compact row per box, word + glyph
  per level (never color alone), light + dark.
- Tests: STATUS_DRY_CAPACITY=getting-full|at-capacity fixture sets make
  every band derivable in dry-run; the contract test allowlists the
  hosts/capacity schema (any extra key fails, so slots/GB/percent
  fields can't ship), checks labels are ordinals, and pins one fleet
  fixture per band.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell e37be0e70cc393d7b9bc1251ddc9129407651d65 parent d9947f0
8 files changed, +334 −11
  • deploy/status-publish.sh +175 −2
  • docs/status.md +35 −6
  • test/status-contract.test.mjs +44 −1
  • test/status-fixtures/books.json +1 −1
  • test/status-fixtures/fleet-at-capacity.json +1 −0
  • test/status-fixtures/fleet-getting-full.json +1 −0
  • test/status-fixtures/fleet.json +1 −1
  • web/status/index.html +76 −0
diff --git a/deploy/status-publish.sh b/deploy/status-publish.sh
index 8c98700..73c2a4d 100755
--- a/deploy/status-publish.sh
+++ b/deploy/status-publish.sh
@@ -18,6 +18,14 @@
# keyed by space. Hostnames are read internally (to match sockets) and
# must never reach the JSON.
#
+# Capacity + hosts (Paul's call: bucketed words, not needles): the box's
+# room-for-new-spaces and per-box load publish as ABSTRACT BAND WORDS only
+# (plenty / getting-full / at-capacity; comfortable / busy / critical).
+# The caps, ratios, free GB, and percentages are read internally to pick
+# the word and MUST NEVER reach the JSON — no remaining-slot counts, no GB,
+# no percentages, no cap values. Host labels are anonymous ordinals
+# ("box-1"), never peer URLs, IPs, or hostnames.
+#
# 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
@@ -57,6 +65,14 @@ BUCKET=${STATUS_BUCKET:-eleven-status-427880590669}
REGION=${STATUS_REGION:-eu-west-1}
REGISTRY_DB=${STATUS_REGISTRY_DB:-/data/registry.db}
CLOUD_ENV=${STATUS_CLOUD_ENV:-/data/cloud.env}
+# The control daemon's env (capacity caps: LCHAT_MAX_INSTANCES etc.). May be
+# root-owned/unreadable to the lchat user — then the code defaults below apply,
+# which are exactly what controld itself defaults to when the vars are unset.
+CONTROL_ENV=${STATUS_CONTROL_ENV:-/data/control.env}
+# Box-load + disk inputs (STATUS_ overrides exist for testability; the dry-run
+# uses fixture twins instead and never reads live box state).
+MEMINFO=${STATUS_MEMINFO:-/proc/meminfo}
+DATA_DIR=${STATUS_DATA_DIR:-/data}
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}
@@ -123,6 +139,46 @@ replication_lag_json() {
echo $((now - ts))
}
+# ---- capacity + hosts probes (ABSTRACT BANDS ONLY — see PRIVACY above) -------
+
+# env_int <file> <var> <default> — one integer from an env file (same grep/cut
+# shape as the Stripe key read). Missing file/var or a non-numeric value falls
+# back to the default — for the capacity caps the defaults mirror controld.go's
+# code defaults (#663), so an absent/unreadable control.env yields the same
+# numbers controld itself enforces.
+env_int() {
+ local v
+ v=$(grep -E "^$2=" "$1" 2>/dev/null | head -n1 | cut -d= -f2- || true)
+ case "$v" in ''|*[!0-9]*) echo "$3" ;; *) echo "$v" ;; esac
+}
+
+# disk_free_gb — whole GB free on the data disk (`df -Pk`: POSIX, and avail
+# matches controld's Statfs Bavail). Empty on failure — an unknown contributes
+# nothing to the band, never a guess.
+disk_free_gb() {
+ if [ -n "$DRY_RUN" ]; then cat "$FIXDIR/disk-free-gb.txt"; return; fi
+ df -Pk "$DATA_DIR" 2>/dev/null | awk 'NR==2 && $4 ~ /^[0-9]+$/ { printf "%d", $4 / 1048576 }' || true
+}
+
+# meminfo_kb <Field> — one kB figure from /proc/meminfo (MemTotal /
+# MemAvailable drive the box-load band). Empty when unreadable (e.g. a
+# non-Linux dev machine) — box load then publishes null, never a guess.
+meminfo_kb() {
+ local src="$MEMINFO"
+ if [ -n "$DRY_RUN" ]; then src="$FIXDIR/meminfo.txt"; fi
+ awk -v f="$1:" '$1 == f { print $2; exit }' "$src" 2>/dev/null || true
+}
+
+# pct_band <pct> — the three public bands as an index: 0 below 70%, 1 at
+# 70–90%, 2 above 90%. These thresholds ARE the abstraction: only the band
+# word ships, the percentage dies here.
+pct_band() { if [ "$1" -lt 70 ]; then echo 0; elif [ "$1" -le 90 ]; then echo 1; else echo 2; fi; }
+
+# The two public vocabularies, one per surface: capacity (room for new spaces)
+# and per-box load. Same bands, different words.
+band_word() { case "$1" in 0) echo plenty ;; 1) echo getting-full ;; *) echo at-capacity ;; esac; }
+load_word() { case "$1" in 0) echo comfortable ;; 1) echo busy ;; *) echo critical ;; esac; }
+
# ---- fleet.json --------------------------------------------------------------
build_fleet() {
@@ -169,8 +225,76 @@ build_fleet() {
local lag
lag=$(replication_lag_json)
+ # ---- capacity: the abstract room-for-new-spaces band ------------------------
+ # Worst-of over every input controld's atCapacity() enforces (controld.go
+ # #663) plus memory pressure, each folded through pct_band's 70/90 lines.
+ # Only the resulting WORD is published; every number below dies in this
+ # block. Caps come from the control daemon's env, defaulting to controld's
+ # own code defaults: 40 instances, 5 GB disk floor, registered ceiling
+ # 0 → 10x instances.
+ local max_inst min_free max_reg hib_creates band=0 b
+ max_inst=$(env_int "$CONTROL_ENV" LCHAT_MAX_INSTANCES 40)
+ min_free=$(env_int "$CONTROL_ENV" LCHAT_MIN_FREE_GB 5)
+ max_reg=$(env_int "$CONTROL_ENV" LCHAT_MAX_REGISTERED 0)
+ hib_creates=$(env_int "$CONTROL_ENV" LCHAT_CONTROL_HIBERNATE_CREATES 0)
+ # A zero cap would be a config typo, and we must not divide by it.
+ [ "$max_inst" -gt 0 ] || max_inst=40
+
+ if [ "$hib_creates" = 1 ]; then
+ # hibernate-creates on: the instance cap guards what's MATERIALIZED
+ # (systemd-managed rows — hibernating rows hold no process); total
+ # registrations get the higher sanity ceiling. Mirrors atCapacity().
+ b=$(pct_band $((systemd_n * 100 / max_inst))); if [ "$b" -gt "$band" ]; then band=$b; fi
+ [ "$max_reg" -gt 0 ] || max_reg=$((10 * max_inst))
+ b=$(pct_band $((total * 100 / max_reg))); if [ "$b" -gt "$band" ]; then band=$b; fi
+ else
+ b=$(pct_band $((total * 100 / max_inst))); if [ "$b" -gt "$band" ]; then band=$b; fi
+ fi
+
+ # Disk: the floor is controld's hard refusal line, so at/below it is
+ # at-capacity outright. Above it, band the PROXIMITY to the floor
+ # (floor/free): free at 10x the floor reads 10% (plenty), at 1.25x it reads
+ # 80% (getting-full).
+ local free_gb
+ free_gb=$(disk_free_gb)
+ if [ -n "$free_gb" ]; then
+ if [ "$free_gb" -le "$min_free" ]; then
+ band=2
+ else
+ b=$(pct_band $((min_free * 100 / free_gb))); if [ "$b" -gt "$band" ]; then band=$b; fi
+ fi
+ fi
+
+ # Memory: utilization (MemTotal - MemAvailable)/MemTotal feeds both the
+ # capacity band and this box's load word. Unreadable meminfo → load is
+ # null and memory contributes nothing (unknowns never guess).
+ local mem_total mem_avail mem_pct load_json=null
+ mem_total=$(meminfo_kb MemTotal)
+ mem_avail=$(meminfo_kb MemAvailable)
+ if [ -n "$mem_total" ] && [ -n "$mem_avail" ] && [ "$mem_total" -gt 0 ]; then
+ mem_pct=$(((mem_total - mem_avail) * 100 / mem_total))
+ b=$(pct_band "$mem_pct"); if [ "$b" -gt "$band" ]; then band=$b; fi
+ load_json="\"$(load_word "$b")\""
+ fi
+ local capacity_level
+ capacity_level=$(band_word "$band")
+
+ # ---- hosts: v1 is the local box alone --------------------------------------
+ # Mesh membership isn't enabled on this box, and the status-publisher's S3
+ # key is scoped to the status bucket, so it couldn't list mesh heartbeats
+ # anyway — multi-box joins here when the mesh goes live (it will need a read
+ # grant on the mesh prefix). Labels are ALWAYS anonymous ordinals ("box-1",
+ # "box-2", …), never peer URLs, IPs, or hostnames. Per-box awake/asleep are
+ # the fleet-wide counts (one box = the whole fleet), already public above.
+ local boxes
+ boxes=$(sqj "SELECT json_array(json_object(
+ 'label', 'box-1',
+ 'awake', json('$awake_json'),
+ 'asleep', json('$asleep_json'),
+ 'load', json('$load_json')))")
+
# Assembled by sqlite json_object: the only interpolated values are
- # script-controlled numerals/booleans and the sqlite-built versions array.
+ # script-controlled numerals/booleans/band-words and the sqlite-built arrays.
sqj "SELECT json_object(
'generated_at', '$now',
'instances', json_object(
@@ -179,6 +303,11 @@ build_fleet() {
'hibernating', $hib,
'awake', json('$awake_json'),
'asleep', json('$asleep_json')),
+ 'hosts', json_object(
+ 'count', 1,
+ 'boxes', json('$boxes')),
+ 'capacity', json_object(
+ 'level', '$capacity_level'),
'versions', json('$(printf '%s' "$versions" | sed "s/'/''/g")'),
'services', json_object(
'home', json('$home_up'),
@@ -418,6 +547,7 @@ dry_run() {
mkdir -p "$FIXDIR"
REGISTRY_DB="$FIXDIR/registry.db"
CLOUD_ENV="$FIXDIR/cloud.env"
+ CONTROL_ENV="$FIXDIR/control.env"
# A fake registry: 6 instances (2 systemd-managed, 4 hibernating of which 2
# are awake), 2 service rows (must not count as instances), 3 versions.
@@ -447,6 +577,36 @@ EOF
printf '%s\n' lchat-home lchat-push lchat-cloud lchat-litestream > "$FIXDIR/active-units.txt" # connect down on purpose
echo 42 > "$FIXDIR/replication-lag.txt"
+ # Capacity fixtures (control caps + disk + meminfo): one set per public band
+ # (default plenty; STATUS_DRY_CAPACITY=getting-full|at-capacity), so the
+ # abstract derivation is testable end-to-end at every level without touching
+ # box state. hibernate-creates is on in all sets (the live posture the bands
+ # mirror); the caps are otherwise left unset so the default set exercises
+ # controld.go's code defaults (40 / 5 GB floor / 10x registered).
+ echo "LCHAT_CONTROL_HIBERNATE_CREATES=1" > "$FIXDIR/control.env"
+ case "${STATUS_DRY_CAPACITY:-}" in
+ getting-full)
+ # Registered ratio 6/8 = 75% → getting-full, while disk (100 GB) and
+ # memory (25% used) stay roomy — capacity and box load are independent
+ # signals, so load reads comfortable here.
+ echo "LCHAT_MAX_REGISTERED=8" >> "$FIXDIR/control.env"
+ echo 100 > "$FIXDIR/disk-free-gb.txt"
+ printf 'MemTotal: 4014980 kB\nMemAvailable: 3011235 kB\n' > "$FIXDIR/meminfo.txt"
+ ;;
+ at-capacity)
+ # Disk below the 5 GB floor → at-capacity outright; memory at 95% used
+ # → load critical.
+ echo 3 > "$FIXDIR/disk-free-gb.txt"
+ printf 'MemTotal: 4014980 kB\nMemAvailable: 200749 kB\n' > "$FIXDIR/meminfo.txt"
+ ;;
+ *)
+ # Everything roomy: ratios ~5%/1.5%, floor proximity 5%, memory 25%
+ # used → plenty / comfortable.
+ echo 100 > "$FIXDIR/disk-free-gb.txt"
+ printf 'MemTotal: 4014980 kB\nMemAvailable: 3011235 kB\n' > "$FIXDIR/meminfo.txt"
+ ;;
+ esac
+
# STATUS_DRY_BOOKS=unavailable exercises the degraded books path (no key).
if [ "${STATUS_DRY_BOOKS:-}" = unavailable ]; then
: > "$FIXDIR/cloud.env"
@@ -490,9 +650,22 @@ EOF
echo "dry-run: PRIVACY LEAK — a per-payer id or hostname reached the output" >&2
exit 1
fi
+ # Capacity must publish as an abstract band word — a known word, and not a
+ # single digit anywhere in the capacity subtree (no slots, GB, percentages).
+ local lvl cap
+ lvl=$(sqj "SELECT json_extract(readfile('$outdir/fleet.json'), '\$.capacity.level')")
+ case "$lvl" in plenty|getting-full|at-capacity) ;; *)
+ echo "dry-run: capacity.level '$lvl' is not one of the three band words" >&2
+ exit 1 ;;
+ esac
+ cap=$(sqj "SELECT json_extract(readfile('$outdir/fleet.json'), '\$.capacity')")
+ if printf '%s' "$cap" | grep -q '[0-9]'; then
+ echo "dry-run: PRIVACY — capacity subtree carries a number: $cap" >&2
+ exit 1
+ fi
echo "--- fleet.json ---"; cat "$outdir/fleet.json"
echo "--- books.json ---"; cat "$outdir/books.json"
- echo "dry-run ok: valid JSON, no hostnames, no per-payer fields"
+ echo "dry-run ok: valid JSON, no hostnames, no per-payer fields, capacity abstract ($lvl)"
}
case "${1:-}" in
diff --git a/docs/status.md b/docs/status.md
index f83e373..1064530 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -6,8 +6,10 @@ public *ledger* repo is still roadmap — see `docs/planet-scale.md`).
- **Fleet status** (`index.html` + `fleet.json`) — how many instances exist,
how many are always-on vs hibernating, how many of the hibernating ones are
- awake right now, the version distribution, whether the shared services
- (home / push / cloud / connect) are up, and fleet-backup replication health.
+ awake right now, the serving hosts and allocation capacity (as abstract
+ band words — see below), the version distribution, whether the shared
+ services (home / push / cloud / connect) are up, and fleet-backup
+ replication health.
- **The books** (`books.html` + `books.json`) — income, in aggregate: active
paid subscriptions (total and by tier — tier names are the public price
book), MRR in EUR, and gross collected this month.
@@ -25,6 +27,12 @@ aggregation boundary lives in the generator and holds by construction:
signed release feed), booleans. **Never a hostname** — member spaces
obviously, but also `test-*` canaries; the simple rule is *no hostnames at
all*. Never per-space member/message counts, never anything keyed by space.
+- **Capacity + hosts: abstract band words only** (Paul's call: bucketed words,
+ not needles). `capacity.level` is `plenty` / `getting-full` / `at-capacity`;
+ a box's `load` is `comfortable` / `busy` / `critical`. **Never** remaining
+ 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
+ anonymous ordinals (`box-1`), never peer URLs, IPs, or hostnames.
- **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
@@ -42,6 +50,8 @@ 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": "box-1", "awake": 2, "asleep": 2, "load": "comfortable" } ] },
+ "capacity": { "level": "plenty" },
"versions": [ { "version": "v100", "count": 3 } ],
"services": { "home": true, "push": true, "cloud": true, "connect": true },
"replication": { "healthy": true, "lag_seconds": 42 }
@@ -57,6 +67,19 @@ the litestream replicator last logged (null when unknown). Both `awake` and
`asleep` are null if `ss` is unavailable — unknowns publish as null, never as
guesses.
+`capacity.level` is the worst of four internally-measured signals, each banded
+at the same lines (**plenty** below 70%, **getting-full** 70–90%,
+**at-capacity** above 90%): the materialized-instance ratio against
+`LCHAT_MAX_INSTANCES`, the registered-space ratio against
+`LCHAT_MAX_REGISTERED` (both mirror `controld.go`'s `atCapacity()`, including
+its hibernate-creates branch and code defaults — 40 / 5 GB floor / 10×), disk
+proximity to the `LCHAT_MIN_FREE_GB` floor (at or below the floor is
+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 (`box-1`); multi-box joins when the mesh goes live.
+
`books.json` (or `{"available": false, "reason": "…"}` when billing isn't
configured / Stripe is unreachable):
@@ -109,9 +132,15 @@ origin unchanged.
- `./deploy/status-publish.sh --dry-run <dir>` builds both JSONs from
self-constructed fake registry/Stripe fixtures (exercising pagination and
- the awake/asleep split) and fails on invalid JSON or a privacy leak. No box
- state, no network, no upload.
+ the awake/asleep split) and fails on invalid JSON or a privacy leak —
+ including any digit inside the capacity subtree. No box state, no network,
+ no upload. `STATUS_DRY_CAPACITY=getting-full|at-capacity` swaps in the
+ capacity fixture set for that band (default: plenty), so every abstract
+ level is derivable in tests.
- `node test/status-contract.test.mjs` (in `npm test`) pins the pages' FIELDS
manifests against the dry-run fixtures in `test/status-fixtures/` — if the
- generator's shape and the pages drift, it fails. Regenerate fixtures from a
- fresh dry-run after changing the generator.
+ generator's shape and the pages drift, it fails. It also allowlists the
+ hosts/capacity schema (any extra key fails, so a precise number can't ship)
+ and pins one fleet fixture per capacity band (`fleet-getting-full.json`,
+ `fleet-at-capacity.json`). Regenerate fixtures from a fresh dry-run after
+ changing the generator (the per-band ones via `STATUS_DRY_CAPACITY`).
diff --git a/test/status-contract.test.mjs b/test/status-contract.test.mjs
index 8b75180..ba0d166 100644
--- a/test/status-contract.test.mjs
+++ b/test/status-contract.test.mjs
@@ -23,6 +23,10 @@ async function test(name, fn) {
const read = (p) => readFileSync(new URL("../" + p, import.meta.url), "utf8");
const fleet = JSON.parse(read("test/status-fixtures/fleet.json"));
+// One dry-run fixture per capacity band (STATUS_DRY_CAPACITY=… regenerates
+// them) — the abstract derivation is pinned at every level.
+const fleetGF = JSON.parse(read("test/status-fixtures/fleet-getting-full.json"));
+const fleetAC = JSON.parse(read("test/status-fixtures/fleet-at-capacity.json"));
const books = JSON.parse(read("test/status-fixtures/books.json"));
const booksDown = JSON.parse(read("test/status-fixtures/books-unavailable.json"));
@@ -90,12 +94,51 @@ await test("degraded books shape carries available:false + reason", () => {
// belt and suspenders on what's in git): no fixture hostname, subscription,
// customer, or transaction id may appear in published output.
await test("fixtures contain no per-payer ids or hostnames", () => {
- for (const j of [fleet, books, booksDown]) {
+ for (const j of [fleet, fleetGF, fleetAC, books, booksDown]) {
const s = JSON.stringify(j);
assert.ok(!/sub_|cus_|txn_|\.example|\.sock/.test(s), "leak in " + s.slice(0, 80));
}
});
+// Capacity + hosts publish ABSTRACT levels only (Paul's call: bucketed words,
+// never needles). The schema is an ALLOWLIST — any extra key under hosts /
+// capacity fails, so a precise number (remaining slots, GB, percentages, cap
+// values) can't ship by accident, and the level/load words themselves must be
+// digit-free vocabulary entries.
+const fleetShapes = [["fleet", fleet], ["fleet-getting-full", fleetGF], ["fleet-at-capacity", fleetAC]];
+await test("hosts + capacity carry only the allowed abstract schema", () => {
+ for (const [name, f] of fleetShapes) {
+ assert.deepEqual(Object.keys(f.capacity).sort(), ["level"], name + ": capacity keys");
+ assert.ok(["plenty", "getting-full", "at-capacity"].includes(f.capacity.level),
+ name + ": capacity.level is a band word, got " + f.capacity.level);
+ 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");
+ // Labels are anonymous ordinals — never a hostname, IP, or URL.
+ assert.match(b.label, /^box-\d+$/, name + ": box label must be an ordinal");
+ assert.ok([null, "comfortable", "busy", "critical"].includes(b.load),
+ name + ": box load is a band word or null, got " + b.load);
+ }
+ // No digit may ride in the capacity subtree — the band word is the entire
+ // public surface.
+ assert.ok(!/\d/.test(JSON.stringify(f.capacity)), name + ": capacity carries a number");
+ }
+});
+
+// Each committed fixture set derives its band — pins the 70/90 thresholds and
+// the disk-floor rule in the generator (see dry_run's capacity fixtures).
+await test("capacity fixtures derive plenty / getting-full / at-capacity", () => {
+ assert.equal(fleet.capacity.level, "plenty");
+ assert.equal(fleetGF.capacity.level, "getting-full");
+ assert.equal(fleetAC.capacity.level, "at-capacity");
+ assert.equal(fleet.hosts.boxes[0].load, "comfortable");
+ // getting-full is driven by the registered ratio, not memory — capacity and
+ // box load are independent signals.
+ assert.equal(fleetGF.hosts.boxes[0].load, "comfortable");
+ assert.equal(fleetAC.hosts.boxes[0].load, "critical");
+});
+
if (failures > 0) {
console.error(failures + " failure(s)");
process.exit(1);
diff --git a/test/status-fixtures/books.json b/test/status-fixtures/books.json
index b7aa4f2..6a81aa3 100644
--- a/test/status-fixtures/books.json
+++ b/test/status-fixtures/books.json
@@ -1 +1 @@
-{"generated_at":"2026-07-11T13:13:47Z","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-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}
diff --git a/test/status-fixtures/fleet-at-capacity.json b/test/status-fixtures/fleet-at-capacity.json
new file mode 100644
index 0000000..acf75f3
--- /dev/null
+++ b/test/status-fixtures/fleet-at-capacity.json
@@ -0,0 +1 @@
+{"generated_at":"2026-07-11T14:22:57Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"hosts":{"count":1,"boxes":[{"label":"box-1","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
new file mode 100644
index 0000000..0e0487b
--- /dev/null
+++ b/test/status-fixtures/fleet-getting-full.json
@@ -0,0 +1 @@
+{"generated_at":"2026-07-11T14:22:57Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"hosts":{"count":1,"boxes":[{"label":"box-1","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 da820e3..30a4301 100644
--- a/test/status-fixtures/fleet.json
+++ b/test/status-fixtures/fleet.json
@@ -1 +1 @@
-{"generated_at":"2026-07-11T13:13:47Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"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-11T14:22:57Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"hosts":{"count":1,"boxes":[{"label":"box-1","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 6592be4..35bcd09 100644
--- a/web/status/index.html
+++ b/web/status/index.html
@@ -21,6 +21,7 @@
--grid: #e1e0d9; --border: rgba(11,11,11,0.10);
--series: #4a3aa7;
--good: #006300; --good-mark: #0ca30c;
+ --warn: #7a4d00;
--bad: #b23030; --bad-mark: #d03b3b;
--link: #4a3aa7;
}
@@ -31,6 +32,7 @@
--grid: #2c2c2a; --border: rgba(255,255,255,0.10);
--series: #9085e9;
--good: #0ca30c; --good-mark: #0ca30c;
+ --warn: #e0a33c;
--bad: #e66767; --bad-mark: #e66767;
--link: #9085e9;
}
@@ -61,6 +63,25 @@
section h2 { font-size: 15px; font-weight: 650; margin: 0 0 10px; }
section { margin-bottom: 28px; }
+ /* Capacity: the abstract room-left band — deliberately a word + glyph, no
+ numbers (the JSON carries none). Tone classes color it, but the word and
+ glyph carry the state on their own (never color alone). */
+ .capacity { margin-bottom: 10px; }
+ .capacity .value.ok { color: var(--good); }
+ .capacity .value.warn { color: var(--warn); }
+ .capacity .value.bad { color: var(--bad); }
+
+ /* Hosts: one compact row per serving box — anonymous ordinals only, never a
+ hostname. Load mirrors the service-state pattern: word + glyph. */
+ .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; }
+ .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); }
+ .hosts .state.warn { color: var(--warn); }
+ .hosts .state.bad { color: var(--bad); }
+
/* Services: word + glyph carry the state; color is supplementary (never
color alone). */
.services { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; list-style: none; padding: 0; }
@@ -120,6 +141,18 @@
</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. -->
+ <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>
+ <ul class="hosts" id="hosts"></ul>
+ </section>
+
<section aria-labelledby="h-services">
<h2 id="h-services">Services</h2>
<ul class="services" id="services"></ul>
@@ -144,11 +177,29 @@ 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",
+ "capacity.level",
"versions[].version", "versions[].count",
"services.home", "services.push", "services.cloud", "services.connect",
"replication.healthy", "replication.lag_seconds",
];
+// Abstract bands → display word + glyph + tone. The word and glyph carry the
+// state (never color alone); anything outside the vocabulary renders "—"
+// rather than guessing. These are the ONLY capacity values the feed may hold —
+// levels, not numbers.
+const CAP_LEVELS = {
+ "plenty": { text: "✓ Plenty of room", tone: "ok" },
+ "getting-full": { text: "◐ Getting full", tone: "warn" },
+ "at-capacity": { text: "▲ At capacity", tone: "bad" },
+};
+const LOAD_LEVELS = {
+ "comfortable": { text: "✓ comfortable", tone: "ok" },
+ "busy": { text: "◐ busy", tone: "warn" },
+ "critical": { text: "▲ critical", tone: "bad" },
+};
+
const $ = (id) => document.getElementById(id);
const num = (v) => (v === null || v === undefined) ? "—" : String(v);
@@ -173,6 +224,31 @@ function render(d) {
$("t-awake").textContent = num(d.instances.awake);
$("t-asleep").textContent = num(d.instances.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 : "—";
+
+ 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 n = document.createElement("span");
+ n.className = "name";
+ n.textContent = b.label;
+ const split = document.createElement("span");
+ split.className = "split";
+ split.textContent = 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 : "");
+ s.textContent = load ? load.text : "—";
+ li.append(n, split, s);
+ hosts.append(li);
+ }
+
const svc = $("services");
svc.replaceChildren();
const rows = [