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>
8 files changed,
+334
−11
deploy/status-publish.sh+175 −2docs/status.md+35 −6test/status-contract.test.mjs+44 −1test/status-fixtures/books.json+1 −1test/status-fixtures/fleet-at-capacity.json+1 −0test/status-fixtures/fleet-getting-full.json+1 −0test/status-fixtures/fleet.json+1 −1web/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 = [ |