server: public status dashboards — fleet state + the books (aggregates only) (#687)
The transparency direction from #550 Parts 11/12, running half: two
world-readable pages published every 5 minutes from the router box.
- deploy/status-publish.sh — box timer (lchat user; unit+timer written by
its install step, mirroring fleet-backup.sh). Builds fleet.json (registry
counts; awake = socket bound+listening via ss -xlH, since slept instances
leave stale socket files) and books.json (Stripe aggregates: active
subscriptions by tier, EUR MRR, gross collected this month; degrades to
"unavailable" rather than publishing zeros). Privacy invariants commented
and self-checked: no hostnames, no per-payer data, ever (decision #19 /
invariant 12). --dry-run builds both JSONs from fake fixtures.
- web/status/{index,books}.html — self-contained static viewers (inline
CSS/JS, relative fetches, light+dark, WCAG 2.2 AA, validated palette,
Eleven brand mark copied from web/static/icon-192.png).
- infra/prod/status.tf — eleven-status bucket mirroring releases.tf
(versioned, GET-only public read, status-publisher IAM user with no
Delete). NOT applied.
- test/status-contract.test.mjs + fixtures — pins each page's FIELDS
manifest against the generator's dry-run output; wired into npm test.
- docs/status.md + planet-scale.md roadmap note (dashboards shipped,
ledger repo still to come). Changelog entry held for the go-live
announcement.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
12 files changed,
+1316
−2
deploy/status-publish.sh+504 −0docs/planet-scale.md+4 −1docs/status.md+117 −0infra/prod/status.tf+122 −0package.json+1 −1test/status-contract.test.mjs+102 −0test/status-fixtures/books-unavailable.json+1 −0test/status-fixtures/books.json+1 −0test/status-fixtures/fleet.json+1 −0web/status/books.html+215 −0web/status/icon-192.pngbinaryweb/status/index.html+248 −0
diff --git a/deploy/status-publish.sh b/deploy/status-publish.sh| new file mode 100755 |
| index 0000000..9bb86d6 |
| --- /dev/null |
| +++ b/deploy/status-publish.sh |
| @@ -0,0 +1,504 @@ |
| +#!/usr/bin/env bash |
| +# 🤖 Public status publish (issue #550 Parts 11/12): every 5 minutes, build two |
| +# small world-readable JSONs — fleet.json (fleet state) and books.json (the |
| +# books: Stripe aggregates) — and upload them plus the static viewer pages to |
| +# the public status bucket (infra/prod/status.tf). docs/status.md is the |
| +# operator guide. |
| +# |
| +# ============================ PRIVACY — NON-NEGOTIABLE ======================== |
| +# Everything this script uploads is PUBLIC. The rules (decision #19 in #550: |
| +# income publishes AGGREGATED, never per-payer; invariant 12: no PII in our |
| +# stores, so none can leak here — but we hold the line at the aggregation |
| +# boundary anyway): |
| +# |
| +# fleet.json — AGGREGATES ONLY. Totals, counts, version distribution, |
| +# service up/down booleans, replication health. 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. Hostnames are read internally (to match sockets) and |
| +# must never reach the JSON. |
| +# |
| +# 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 |
| +# lines, or timestamps of individual payments. The Stripe key is read for |
| +# the API calls and must never be echoed, logged, or uploaded. |
| +# |
| +# Adding a field? It must be an aggregate over the whole fleet / all payers, |
| +# or it doesn't ship. |
| +# ============================================================================== |
| +# |
| +# Privilege: runs as the lchat user, not root — least privilege that works: |
| +# - /data/registry.db and /data/cloud.env are lchat-owned on the hardened |
| +# box (all units run as lchat since PR #186; /data is owned by it). |
| +# - `systemctl is-active` and `ss -xlH` are unprivileged read-only queries. |
| +# - the journal read (replication lag) needs the systemd-journal group, |
| +# granted via SupplementaryGroups in the unit below. |
| +# Only `install` (writing units, enabling the timer) needs root. |
| +# |
| +# Usage: |
| +# status-publish.sh # build + upload (what the timer runs) |
| +# status-publish.sh install # root: install unit + timer, enable |
| +# status-publish.sh --dry-run DIR # build both JSONs into DIR from |
| +# # self-constructed fake registry/Stripe |
| +# # fixtures; no box state touched, no |
| +# # upload — the committed contract test |
| +# |
| +# Prerequisites on the box: |
| +# /data/status.env — AWS creds for the status-publisher IAM user |
| +# (infra/prod/status.tf; Put/Get/List on the status |
| +# bucket only, no Delete). chown lchat, chmod 0600. |
| +# /opt/lchat/status-pages/ — the viewer pages, copied from web/status/ |
| +# (index.html, books.html, icon-192.png). |
| +# aws CLI + sqlite3 (apt install awscli sqlite3). |
| +set -euo pipefail |
| + |
| +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} |
| +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} |
| +SELF=/opt/lchat/status-publish.sh |
| +UNIT=/etc/systemd/system/lchat-status.service |
| +TIMER=/etc/systemd/system/lchat-status.timer |
| + |
| +DRY_RUN="" |
| +FIXDIR="" |
| + |
| +TMP=$(mktemp -d) |
| +trap 'rm -rf "$TMP"' EXIT |
| + |
| +# sq runs a query against an on-disk DB with a busy timeout (the router writes |
| +# the registry concurrently); sqj evaluates JSON expressions in :memory:. All |
| +# JSON this script emits is BUILT BY SQLITE's json functions, never by string |
| +# interpolation in bash — DB/Stripe-sourced strings (versions, tier keys) get |
| +# escaped correctly and can't break or inject into the output. |
| +sq() { sqlite3 -cmd '.timeout 3000' "$REGISTRY_DB" "$1"; } |
| +sqj() { sqlite3 :memory: "$1"; } |
| + |
| +now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; } |
| + |
| +# ---- box probes (each has a dry-run twin so --dry-run exercises the same |
| +# ---- aggregation code with no live systemctl/ss/journalctl) ------------------ |
| + |
| +# svc_active <unit> — unprivileged read-only state query. |
| +svc_active() { |
| + if [ -n "$DRY_RUN" ]; then grep -qxF "$1" "$FIXDIR/active-units.txt"; return; fi |
| + systemctl is-active --quiet "$1" |
| +} |
| + |
| +# listening_sockets — the kernel's list of LISTENING unix sockets. This is the |
| +# awake test for hibernating hosts, and the choice matters: |
| +# - Socket-file EXISTENCE is NOT sound: an instance unlinks a stale socket |
| +# file on start (main.go listen()) but never unlinks on exit, so a slept |
| +# instance leaves the file behind. Existence would count every host that |
| +# has ever woken as awake forever. |
| +# - Dialing the socket (curl --unix-socket) IS sound but touches the |
| +# instance — a probe every 5 minutes must not risk interacting with the |
| +# activator's idle accounting or show up in instance logs. |
| +# - `ss -xlH` reads the socket table: an entry exists iff a process has the |
| +# path BOUND AND LISTENING right now (the activator's children bind the |
| +# same /run/lchat/<host>.sock path systemd units use). Zero side effects. |
| +listening_sockets() { |
| + if [ -n "$DRY_RUN" ]; then cat "$FIXDIR/listening.txt"; return; fi |
| + if command -v ss >/dev/null 2>&1; then ss -xlH 2>/dev/null || true; fi |
| +} |
| + |
| +# replication_lag_json — seconds since the fleet replicator (lchat-litestream, |
| +# deploy/fleet-backup.sh) last logged anything: an approximation of replication |
| +# lag (litestream logs each sync; a healthy replicator is chatty, a wedged one |
| +# goes silent). "null" when the journal is unreadable or empty — published as |
| +# null, never faked. |
| +replication_lag_json() { |
| + if [ -n "$DRY_RUN" ]; then cat "$FIXDIR/replication-lag.txt"; return; fi |
| + local line ts now |
| + line=$(journalctl -u lchat-litestream -n1 -o json 2>/dev/null || true) |
| + if [ -z "$line" ]; then echo "null"; return; fi |
| + printf '%s' "$line" > "$TMP/journal.json" |
| + ts=$(sqj "SELECT CAST(json_extract(readfile('$TMP/journal.json'), '\$.__REALTIME_TIMESTAMP') AS INTEGER) / 1000000" || echo "") |
| + if [ -z "$ts" ] || [ "$ts" = "0" ]; then echo "null"; return; fi |
| + now=$(date -u +%s) |
| + echo $((now - ts)) |
| +} |
| + |
| +# ---- fleet.json -------------------------------------------------------------- |
| + |
| +build_fleet() { |
| + local out=$1 now total systemd_n hib awake_json asleep_json versions |
| + now=$(now_iso) |
| + |
| + total=$(sq "SELECT COUNT(*) FROM routes WHERE kind='instance'") |
| + hib=$(sq "SELECT COUNT(*) FROM routes WHERE kind='instance' AND hibernate=1") |
| + systemd_n=$((total - hib)) |
| + |
| + # Awake = hibernating hosts whose socket is currently bound+listening (see |
| + # listening_sockets above). Hostnames stay inside this loop — only the |
| + # counts leave it. If `ss` is unavailable the split is published as null |
| + # (unknown), never guessed. |
| + if [ -n "$DRY_RUN" ] || command -v ss >/dev/null 2>&1; then |
| + local listening awake=0 sock |
| + listening=$(listening_sockets) |
| + while IFS= read -r sock; do |
| + [ -z "$sock" ] && continue |
| + # -F fixed-string match: every socket path starts /run/lchat/, so one |
| + # path can never be a substring of another host's line. |
| + if printf '%s\n' "$listening" | grep -qF "$sock"; then awake=$((awake+1)); fi |
| + done < <(sq "SELECT socket FROM routes WHERE kind='instance' AND hibernate=1") |
| + awake_json=$awake |
| + asleep_json=$((hib - awake)) |
| + else |
| + awake_json=null |
| + asleep_json=null |
| + fi |
| + |
| + # Version distribution: version strings and counts only (git SHAs / |
| + # release versions — already public via the signed release feed). No |
| + # hostnames, ever. |
| + versions=$(sq "SELECT COALESCE(json_group_array(json_object('version', v, 'count', c)), json('[]')) |
| + FROM (SELECT CASE WHEN version='' THEN 'unknown' ELSE version END AS v, COUNT(*) AS c |
| + FROM routes WHERE kind='instance' GROUP BY v ORDER BY c DESC, v)") |
| + |
| + local home_up=false push_up=false cloud_up=false connect_up=false rep_ok=false |
| + svc_active lchat-home && home_up=true |
| + svc_active lchat-push && push_up=true |
| + svc_active lchat-cloud && cloud_up=true |
| + svc_active lchat-connect && connect_up=true |
| + svc_active lchat-litestream && rep_ok=true |
| + local lag |
| + lag=$(replication_lag_json) |
| + |
| + # Assembled by sqlite json_object: the only interpolated values are |
| + # script-controlled numerals/booleans and the sqlite-built versions array. |
| + sqj "SELECT json_object( |
| + 'generated_at', '$now', |
| + 'instances', json_object( |
| + 'total', $total, |
| + 'systemd', $systemd_n, |
| + 'hibernating', $hib, |
| + 'awake', json('$awake_json'), |
| + 'asleep', json('$asleep_json')), |
| + 'versions', json('$(printf '%s' "$versions" | sed "s/'/''/g")'), |
| + 'services', json_object( |
| + 'home', json('$home_up'), |
| + 'push', json('$push_up'), |
| + 'cloud', json('$cloud_up'), |
| + 'connect', json('$connect_up')), |
| + 'replication', json_object( |
| + 'healthy', json('$rep_ok'), |
| + 'lag_seconds', json('$lag')))" > "$out" |
| +} |
| + |
| +# ---- books.json -------------------------------------------------------------- |
| + |
| +# stripe_get <path-and-query> <outfile> — one authenticated GET. The key rides |
| +# only in the header; -sS keeps curl from ever printing it. Dry-run reads |
| +# numbered fixture files instead (same downstream aggregation code). |
| +STRIPE_FIXTURE_N=0 |
| +stripe_get() { |
| + if [ -n "$DRY_RUN" ]; then |
| + STRIPE_FIXTURE_N=$((STRIPE_FIXTURE_N + 1)) |
| + local kind=subs |
| + case "$1" in /v1/balance_transactions*) kind=bal ;; esac |
| + cp "$FIXDIR/stripe-$kind-$STRIPE_FIXTURE_N.json" "$2" |
| + return |
| + fi |
| + curl -sS --fail --max-time 30 -H "Authorization: Bearer $STRIPE_KEY" \ |
| + "$STRIPE_API$1" > "$2" |
| +} |
| + |
| +# fetch_pages <path> <query> <prefix> — follow Stripe's cursor pagination |
| +# (has_more + starting_after) into $TMP/<prefix>-N.json. Hard cap of 50 pages |
| +# (5000 objects) keeps a pathological account from wedging the run. Every |
| +# failure is an explicit `return 1` — callers run this inside an `if !`, where |
| +# set -e is suspended, and a half-fetched month must degrade to "unavailable", |
| +# never aggregate to zeros that read as "no income". |
| +fetch_pages() { |
| + local path=$1 query=$2 prefix=$3 after="" n=0 f has_more |
| + while :; do |
| + n=$((n + 1)) |
| + f="$TMP/$prefix-$n.json" |
| + stripe_get "$path?limit=100${query:+&$query}${after:+&starting_after=$after}" "$f" || return 1 |
| + # A page that isn't a valid list envelope is a failed/garbled fetch. |
| + [ "$(sqj "SELECT json_valid(readfile('$f')) AND json_type(readfile('$f'), '\$.data')='array'")" = "1" ] || return 1 |
| + has_more=$(sqj "SELECT json_extract(readfile('$f'), '\$.has_more')") |
| + [ "$has_more" = "1" ] && [ "$n" -lt 50 ] || break |
| + after=$(sqj "SELECT json_extract(readfile('$f'), '\$.data[#-1].id')") |
| + [ -n "$after" ] || return 1 |
| + done |
| + # Reset the fixture counter so the next endpoint's dry-run pages start at 1. |
| + STRIPE_FIXTURE_N=0 |
| +} |
| + |
| +books_unavailable() { # $1=out $2=reason (a fixed, script-controlled string) |
| + sqj "SELECT json_object('generated_at', '$(now_iso)', 'available', json('false'), 'reason', '$2')" > "$1" |
| +} |
| + |
| +build_books() { |
| + local out=$1 now month month_start |
| + now=$(now_iso) |
| + |
| + # The Stripe key comes from the cloud provisioner's env (billing.go reads the |
| + # same var). Absent → billing isn't configured here; publish an honest |
| + # "unavailable" rather than zeros that would read as "no income". |
| + STRIPE_KEY=$(grep -E '^LCHAT_STRIPE_SECRET_KEY=' "$CLOUD_ENV" 2>/dev/null | head -n1 | cut -d= -f2- || true) |
| + if [ -z "${STRIPE_KEY:-}" ]; then |
| + books_unavailable "$out" "billing not configured" |
| + return |
| + fi |
| + |
| + # Month boundary via sqlite (portable — GNU/BSD date flags differ). |
| + month=$(sqj "SELECT strftime('%Y-%m','now')") |
| + month_start=$(sqj "SELECT strftime('%s', datetime('now','start of month'))") |
| + |
| + # The two aggregation sources (decision #19 — these are the ONLY Stripe |
| + # reads, both fleet-wide): |
| + # GET /v1/subscriptions?status=active&limit=100 (+ cursor pages) |
| + # → active count, per-tier counts (metadata.tier = public price-book |
| + # key), MRR from each item's price amount normalized to monthly. |
| + # GET /v1/balance_transactions?created[gte]=<month start>&limit=100 |
| + # → gross collected this month (positive charge/payment amounts). |
| + # Degrade to "unavailable" on any fetch failure — a Stripe outage must not |
| + # publish half-numbers. |
| + if ! fetch_pages /v1/subscriptions "status=active" subs || |
| + ! fetch_pages /v1/balance_transactions "created[gte]=$month_start" bal; then |
| + books_unavailable "$out" "stripe unreachable" |
| + return |
| + fi |
| + |
| + # Aggregate with sqlite json_each — never per-object output, only SUM/COUNT/ |
| + # GROUP BY. Per-payer fields (ids, customer, created) die inside this query. |
| + { |
| + echo "CREATE TABLE subs(j TEXT);" |
| + echo "CREATE TABLE bal(j TEXT);" |
| + local f |
| + for f in "$TMP"/subs-*.json; do |
| + echo "INSERT INTO subs SELECT value FROM json_each(readfile('$f'), '\$.data');" |
| + done |
| + for f in "$TMP"/bal-*.json; do |
| + echo "INSERT INTO bal SELECT value FROM json_each(readfile('$f'), '\$.data');" |
| + done |
| + # MRR: unit_amount × quantity per item, normalized to a month. EUR items |
| + # only (the price book is EUR; mixing currencies without FX would lie). |
| + # Unknown intervals contribute 0 to MRR but still count as subscribers. |
| + cat <<SQL |
| +SELECT json_object( |
| + 'generated_at', '$now', |
| + 'available', json('true'), |
| + 'currency', 'eur', |
| + 'month', '$month', |
| + 'subscriptions', json_object( |
| + 'active', (SELECT COUNT(*) FROM subs), |
| + 'by_tier', (SELECT COALESCE(json_group_array(json_object('tier', tier, 'count', c)), json('[]')) |
| + FROM (SELECT COALESCE(NULLIF(json_extract(j,'\$.metadata.tier'),''),'other') AS tier, |
| + COUNT(*) AS c |
| + FROM subs GROUP BY tier ORDER BY c DESC, tier))), |
| + 'mrr_cents', CAST(ROUND(COALESCE(( |
| + SELECT SUM( |
| + json_extract(i.value,'\$.price.unit_amount') |
| + * COALESCE(json_extract(i.value,'\$.quantity'),1) |
| + * (CASE json_extract(i.value,'\$.price.recurring.interval') |
| + WHEN 'month' THEN 1.0 |
| + WHEN 'year' THEN 1.0/12.0 |
| + WHEN 'week' THEN 52.0/12.0 |
| + WHEN 'day' THEN 365.0/12.0 |
| + ELSE 0 END) |
| + / COALESCE(json_extract(i.value,'\$.price.recurring.interval_count'),1)) |
| + FROM subs s, json_each(s.j, '\$.items.data') i |
| + WHERE json_extract(i.value,'\$.price.currency')='eur'), 0)) AS INTEGER), |
| + 'collected_month_cents', CAST(COALESCE(( |
| + SELECT SUM(json_extract(b.j,'\$.amount')) FROM bal b |
| + WHERE json_extract(b.j,'\$.type') IN ('charge','payment') |
| + AND json_extract(b.j,'\$.currency')='eur' |
| + AND json_extract(b.j,'\$.amount') > 0), 0) AS INTEGER)); |
| +SQL |
| + } | sqlite3 :memory: > "$out" |
| +} |
| + |
| +# ---- upload ------------------------------------------------------------------ |
| + |
| +upload() { # file key content-type cache-control |
| + aws s3 cp "$1" "s3://$BUCKET/$2" --region "$REGION" \ |
| + --content-type "$3" --cache-control "$4" --no-progress >/dev/null |
| +} |
| + |
| +publish() { |
| + command -v aws >/dev/null 2>&1 || { echo "aws CLI missing (apt install awscli)" >&2; exit 1; } |
| + # Creds for the scoped status-publisher IAM user (never in tofu state; mint |
| + # by hand per infra/prod/status.tf). The unit injects them via |
| + # EnvironmentFile; a manual run sources the same file. |
| + if [ -z "${AWS_ACCESS_KEY_ID:-}" ] && [ -f "$STATUS_ENV" ]; then |
| + set -a; . "$STATUS_ENV"; set +a |
| + fi |
| + |
| + build_fleet "$TMP/fleet.json" |
| + build_books "$TMP/books.json" |
| + |
| + # JSONs: short cache so "as of" stays honest at the 5-minute cadence. |
| + upload "$TMP/fleet.json" fleet.json "application/json" "max-age=60" |
| + upload "$TMP/books.json" books.json "application/json" "max-age=60" |
| + |
| + # The viewer pages + brand mark (from web/status/, staged on the box). |
| + # Re-uploading tiny idempotent files every run self-heals a missed copy. |
| + if [ -d "$PAGES_DIR" ]; then |
| + [ -f "$PAGES_DIR/index.html" ] && upload "$PAGES_DIR/index.html" index.html "text/html; charset=utf-8" "max-age=300" |
| + [ -f "$PAGES_DIR/books.html" ] && upload "$PAGES_DIR/books.html" books.html "text/html; charset=utf-8" "max-age=300" |
| + [ -f "$PAGES_DIR/icon-192.png" ] && upload "$PAGES_DIR/icon-192.png" icon-192.png "image/png" "max-age=86400" |
| + else |
| + echo "warning: $PAGES_DIR missing — JSONs published, pages skipped (copy web/status/* there)" >&2 |
| + fi |
| + echo "published to s3://$BUCKET (fleet + books, $(now_iso))" |
| +} |
| + |
| +# ---- install (root) ---------------------------------------------------------- |
| + |
| +install_units() { |
| + [ "$(id -u)" = 0 ] || { echo "install needs root (writes systemd units)" >&2; exit 1; } |
| + [ -f "$STATUS_ENV" ] || { echo "missing $STATUS_ENV (AWS creds for status-publisher) — mint per infra/prod/status.tf" >&2; exit 1; } |
| + |
| + if [ "$(readlink -f "$0" 2>/dev/null || echo "$0")" != "$SELF" ]; then |
| + cp "$0" "$SELF" && chmod 0755 "$SELF" |
| + fi |
| + mkdir -p "$PAGES_DIR" |
| + |
| + # Same hardening posture as the litestream unit. ReadWritePaths=/data is |
| + # required even though we only SELECT: WAL readers take shared-memory locks |
| + # (writes to the -shm file), so a read-only /data breaks registry reads. |
| + cat > "$UNIT" <<'EOF' |
| +[Unit] |
| +Description=Eleven public status publish (fleet + books aggregates) |
| +Wants=network-online.target |
| +After=network-online.target |
| + |
| +[Service] |
| +Type=oneshot |
| +User=lchat |
| +Group=lchat |
| +SupplementaryGroups=systemd-journal |
| +EnvironmentFile=/data/status.env |
| +ExecStart=/opt/lchat/status-publish.sh |
| +NoNewPrivileges=true |
| +ProtectSystem=strict |
| +ReadWritePaths=/data |
| +ProtectHome=true |
| +PrivateTmp=true |
| +MemoryMax=256M |
| +EOF |
| + |
| + # Persistent=false on purpose: a missed run's output is superseded by the |
| + # next one five minutes later — catch-up publishes are pure noise. |
| + cat > "$TIMER" <<'EOF' |
| +[Unit] |
| +Description=Eleven public status publish — every 5 minutes |
| + |
| +[Timer] |
| +OnBootSec=2min |
| +OnUnitActiveSec=5min |
| +RandomizedDelaySec=20 |
| +Persistent=false |
| + |
| +[Install] |
| +WantedBy=timers.target |
| +EOF |
| + |
| + systemctl daemon-reload |
| + systemctl enable --now lchat-status.timer |
| + echo "installed — verify with: systemctl list-timers lchat-status.timer; journalctl -u lchat-status -n 20" |
| + echo "remember: copy web/status/{index.html,books.html,icon-192.png} to $PAGES_DIR" |
| +} |
| + |
| +# ---- dry-run: self-constructed fixtures, real aggregation code ---------------- |
| + |
| +dry_run() { |
| + local outdir=$1 |
| + mkdir -p "$outdir" |
| + DRY_RUN=1 |
| + FIXDIR="$TMP/fixtures" |
| + mkdir -p "$FIXDIR" |
| + REGISTRY_DB="$FIXDIR/registry.db" |
| + CLOUD_ENV="$FIXDIR/cloud.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. |
| + sqlite3 "$REGISTRY_DB" <<'SQL' |
| +CREATE TABLE routes ( |
| + host TEXT PRIMARY KEY, kind TEXT NOT NULL, socket TEXT NOT NULL, |
| + version TEXT NOT NULL DEFAULT '', hibernate INTEGER NOT NULL DEFAULT 0 |
| +); |
| +INSERT INTO routes VALUES |
| + ('alpha.example', 'instance', '/run/lchat/alpha.example.sock', 'v100', 0), |
| + ('bravo.example', 'instance', '/run/lchat/bravo.example.sock', 'v100', 0), |
| + ('charlie.example', 'instance', '/run/lchat/charlie.example.sock', 'v100', 1), |
| + ('delta.example', 'instance', '/run/lchat/delta.example.sock', 'v101', 1), |
| + ('echo.example', 'instance', '/run/lchat/echo.example.sock', 'v101', 1), |
| + ('foxtrot.example', 'instance', '/run/lchat/foxtrot.example.sock', '', 1), |
| + ('home.example', 'service', '/run/lchat/home.sock', '', 0), |
| + ('push.example', 'service', '/run/lchat/push.sock', '', 0); |
| +SQL |
| + |
| + # ss -xlH shape: charlie + delta listening (awake), echo + foxtrot not. |
| + cat > "$FIXDIR/listening.txt" <<'EOF' |
| +u_str LISTEN 0 4096 /run/lchat/alpha.example.sock 12001 * 0 |
| +u_str LISTEN 0 4096 /run/lchat/bravo.example.sock 12002 * 0 |
| +u_str LISTEN 0 4096 /run/lchat/charlie.example.sock 12003 * 0 |
| +u_str LISTEN 0 4096 /run/lchat/delta.example.sock 12004 * 0 |
| +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" |
| + |
| + # STATUS_DRY_BOOKS=unavailable exercises the degraded books path (no key). |
| + if [ "${STATUS_DRY_BOOKS:-}" = unavailable ]; then |
| + : > "$FIXDIR/cloud.env" |
| + else |
| + echo "LCHAT_STRIPE_SECRET_KEY=sk_test_dryrun_not_a_real_key" > "$FIXDIR/cloud.env" |
| + fi |
| + |
| + # Two subscription pages (exercises pagination) + one balance page. Shapes |
| + # follow Stripe's list envelope; per-payer fields present in the fixture |
| + # (ids, customer) must NOT survive into books.json — that's the test. |
| + cat > "$FIXDIR/stripe-subs-1.json" <<'EOF' |
| +{"object":"list","has_more":true,"data":[ |
| + {"id":"sub_fixture_a","customer":"cus_fixture_a","status":"active","metadata":{"tier":"friends","space_id":"7"}, |
| + "items":{"data":[{"quantity":1,"price":{"unit_amount":400,"currency":"eur","recurring":{"interval":"month","interval_count":1}}}]}}, |
| + {"id":"sub_fixture_b","customer":"cus_fixture_b","status":"active","metadata":{"tier":"family","space_id":"9"}, |
| + "items":{"data":[{"quantity":1,"price":{"unit_amount":800,"currency":"eur","recurring":{"interval":"month","interval_count":1}}}]}} |
| +]} |
| +EOF |
| + cat > "$FIXDIR/stripe-subs-2.json" <<'EOF' |
| +{"object":"list","has_more":false,"data":[ |
| + {"id":"sub_fixture_c","customer":"cus_fixture_c","status":"active","metadata":{"tier":"friends","space_id":"12"}, |
| + "items":{"data":[{"quantity":1,"price":{"unit_amount":4800,"currency":"eur","recurring":{"interval":"year","interval_count":1}}}]}} |
| +]} |
| +EOF |
| + cat > "$FIXDIR/stripe-bal-1.json" <<'EOF' |
| +{"object":"list","has_more":false,"data":[ |
| + {"id":"txn_fixture_1","type":"charge","amount":400,"currency":"eur","created":1751328000}, |
| + {"id":"txn_fixture_2","type":"charge","amount":800,"currency":"eur","created":1751414400}, |
| + {"id":"txn_fixture_3","type":"refund","amount":-400,"currency":"eur","created":1751500800}, |
| + {"id":"txn_fixture_4","type":"payout","amount":-1200,"currency":"eur","created":1751587200} |
| +]} |
| +EOF |
| + |
| + build_fleet "$outdir/fleet.json" |
| + build_books "$outdir/books.json" |
| + |
| + # Self-checks: valid JSON, and no per-payer fixture value leaked through. |
| + sqj "SELECT CASE WHEN json_valid(readfile('$outdir/fleet.json')) AND json_valid(readfile('$outdir/books.json')) THEN 'ok' ELSE 'INVALID' END" \ |
| + | grep -qx ok || { echo "dry-run: output is not valid JSON" >&2; exit 1; } |
| + if grep -qE 'sub_fixture|cus_fixture|txn_fixture|\.example' "$outdir/fleet.json" "$outdir/books.json"; then |
| + echo "dry-run: PRIVACY LEAK — a per-payer id or hostname reached the output" >&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" |
| +} |
| + |
| +case "${1:-}" in |
| + install) install_units ;; |
| + --dry-run) [ -n "${2:-}" ] || { echo "usage: $0 --dry-run <outdir>" >&2; exit 1; } |
| + dry_run "$2" ;; |
| + "") publish ;; |
| + *) echo "usage: $0 [install | --dry-run <outdir>]" >&2; exit 1 ;; |
| +esac |
diff --git a/docs/planet-scale.md b/docs/planet-scale.md| index d1de754..d6bc78c 100644 |
| --- a/docs/planet-scale.md |
| +++ b/docs/planet-scale.md |
| @@ -372,7 +372,10 @@ a seam). Build triggers are events, not dates. |
| - **Observability gauges** — wake latency, lease conflicts, replication lag |
| are loud log lines today, not metrics; Part 9's E2EE-safe metrics discipline |
| is still owed. |
| -- **The public ledger** — Part 11's append-only books repo + static viewer. |
| +- **The public ledger** — Part 11's append-only books repo. Partially |
| + shipped: the public *status dashboards* exist (fleet state + aggregated |
| + Stripe income, published from the box every 5 minutes — `docs/status.md`); |
| + the itemized expense/disbursement ledger repo is still to come. |
| ## The historical record |
diff --git a/docs/status.md b/docs/status.md| new file mode 100644 |
| index 0000000..f83e373 |
| --- /dev/null |
| +++ b/docs/status.md |
| @@ -0,0 +1,117 @@ |
| +# 🤖 Public status dashboards — fleet state & the books |
| + |
| +Two world-readable pages, published every 5 minutes from the router box: the |
| +running half of #550 Part 11/12's transparency direction (the append-only |
| +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. |
| +- **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. |
| + |
| +Pieces: generator `deploy/status-publish.sh` (box timer, mirrors |
| +`deploy/fleet-backup.sh`), viewer pages `web/status/`, bucket + IAM |
| +`infra/prod/status.tf`, contract test `test/status-contract.test.mjs`. |
| + |
| +## The privacy rules (non-negotiable) |
| + |
| +Everything published is public, forever (the bucket is versioned). The |
| +aggregation boundary lives in the generator and holds by construction: |
| + |
| +- **Fleet: aggregates only.** Counts, version strings (already public via the |
| + 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. |
| +- **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 |
| + individual payments. Expenses/disbursements publish *itemized* — but those |
| + belong to the future ledger repo, not these dashboards. |
| +- Adding a field? It must be an aggregate over the whole fleet / all payers, |
| + or it doesn't ship. The `--dry-run` self-check and the contract test both |
| + fail if a fixture hostname or per-payer id reaches the output. |
| + |
| +## What's in the JSONs |
| + |
| +`fleet.json`: |
| + |
| +```json |
| +{ |
| + "generated_at": "2026-07-11T13:00:00Z", |
| + "instances": { "total": 6, "systemd": 2, "hibernating": 4, "awake": 2, "asleep": 2 }, |
| + "versions": [ { "version": "v100", "count": 3 } ], |
| + "services": { "home": true, "push": true, "cloud": true, "connect": true }, |
| + "replication": { "healthy": true, "lag_seconds": 42 } |
| +} |
| +``` |
| + |
| +`awake`/`asleep` split the hibernating set by whether each host's unix socket |
| +is currently *bound and listening* (`ss -xlH`) — socket-file existence is not |
| +sound (instances never unlink on exit, so slept hosts leave stale files) and |
| +dialing the socket would touch the instance; the kernel socket table is |
| +side-effect-free. `lag_seconds` approximates replication lag as seconds since |
| +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. |
| + |
| +`books.json` (or `{"available": false, "reason": "…"}` when billing isn't |
| +configured / Stripe is unreachable): |
| + |
| +```json |
| +{ |
| + "generated_at": "2026-07-11T13:00:00Z", |
| + "available": true, |
| + "currency": "eur", |
| + "month": "2026-07", |
| + "subscriptions": { "active": 3, "by_tier": [ { "tier": "friends", "count": 2 } ] }, |
| + "mrr_cents": 1600, |
| + "collected_month_cents": 1200 |
| +} |
| +``` |
| + |
| +MRR sums each active subscription item's `unit_amount × quantity` normalized |
| +to a month (yearly ÷ 12, etc.), EUR items only. Collected-this-month is the |
| +gross of positive `charge`/`payment` balance transactions since the month |
| +started (refunds and payouts excluded — it's gross income, not net cash). |
| + |
| +## Install (router box) |
| + |
| +1. **Apply the infra** (deliberate, named — not yet applied): |
| + `AWS_PROFILE=… tofu -chdir=infra/prod apply`, then mint the publisher key: |
| + `aws iam create-access-key --user-name status-publisher` (keys never land |
| + in tofu state). |
| +2. **Creds on the box**: write `/data/status.env` with |
| + `AWS_ACCESS_KEY_ID=…` / `AWS_SECRET_ACCESS_KEY=…`; `chown lchat:lchat`, |
| + `chmod 0600`. |
| +3. **Pages on the box**: `scp web/status/{index.html,books.html,icon-192.png} |
| + root@<box>:/opt/lchat/status-pages/`. |
| +4. **Install the timer**: `scp deploy/status-publish.sh root@<box>:/opt/lchat/` |
| + then `ssh root@<box> /opt/lchat/status-publish.sh install`. The unit runs |
| + as the `lchat` user (least privilege: registry + cloud env are lchat-owned; |
| + `systemctl is-active` / `ss` are unprivileged; the journal read rides |
| + `SupplementaryGroups=systemd-journal`). |
| +5. **Verify**: `systemctl list-timers lchat-status.timer`, |
| + `journalctl -u lchat-status -n 20`, then load the page. |
| + |
| +The dashboards live at |
| +`https://eleven-status-<account-id>.s3.<region>.amazonaws.com/index.html` |
| +(the `status_url` tofu output; account `427880590669`, region `eu-west-1` → |
| +`https://eleven-status-427880590669.s3.eu-west-1.amazonaws.com/index.html`, |
| +books at `…/books.html`). A friendlier hostname (e.g. |
| +`status.elevenmessenger.com` via CloudFront or a CNAME-compatible front) is |
| +deliberately later work — the pages use relative URLs so they work at any |
| +origin unchanged. |
| + |
| +## Testing |
| + |
| +- `./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. |
| +- `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. |
diff --git a/infra/prod/status.tf b/infra/prod/status.tf| new file mode 100644 |
| index 0000000..62cc151 |
| --- /dev/null |
| +++ b/infra/prod/status.tf |
| @@ -0,0 +1,122 @@ |
| +# 🤖 The public status bucket (issue #550 Parts 11/12): world-readable |
| +# dashboards — fleet.json + books.json (published every 5 minutes by |
| +# deploy/status-publish.sh on the router box) and the two static viewer pages |
| +# (web/status/). Everything in it is AGGREGATE BY DESIGN: fleet counts and |
| +# version distribution with no hostnames, and Stripe income as totals only — |
| +# decision #19 (income publishes aggregated, never per-payer) and invariant 12 |
| +# (no PII in our stores) are enforced upstream in the generator; this bucket |
| +# only ever receives what passed that gate. docs/status.md is the guide. |
| +# |
| +# NOT YET APPLIED — needs a deliberate, named |
| +# `AWS_PROFILE=… tofu apply` in infra/prod; plan only until then. |
| + |
| +resource "aws_s3_bucket" "status" { |
| + bucket = "eleven-status-${local.account_id}" |
| +} |
| + |
| +resource "aws_s3_bucket_versioning" "status" { |
| + bucket = aws_s3_bucket.status.id |
| + |
| + versioning_configuration { |
| + status = "Enabled" |
| + } |
| +} |
| + |
| +# The JSONs are clobbered every 5 minutes (≈576 noncurrent versions/day), so |
| +# versions are pure rollback/forensic noise past a short window — 7 days is |
| +# plenty to reconstruct "what did the page say when". |
| +resource "aws_s3_bucket_lifecycle_configuration" "status" { |
| + bucket = aws_s3_bucket.status.id |
| + |
| + rule { |
| + id = "expire-noncurrent-7d" |
| + status = "Enabled" |
| + |
| + filter {} |
| + |
| + noncurrent_version_expiration { |
| + noncurrent_days = 7 |
| + } |
| + } |
| +} |
| + |
| +# Deliberately PUBLIC-READ, like the releases bucket (releases.tf) and against |
| +# the everything-else-blocked posture (backup.tf, hibernate.tf): a public |
| +# status page is the point — the content is aggregates that were reviewed for |
| +# publication before the generator ever wrote them (no hostnames, no per-payer |
| +# data, no PII exists upstream to leak). ACLs stay fully blocked; only the |
| +# bucket *policy* door is opened, and the GET-only policy below is the entire |
| +# public surface (no anonymous List — object names are known, enumeration |
| +# isn't offered). |
| +resource "aws_s3_bucket_public_access_block" "status" { |
| + bucket = aws_s3_bucket.status.id |
| + |
| + block_public_acls = true |
| + ignore_public_acls = true |
| + block_public_policy = false |
| + restrict_public_buckets = false |
| +} |
| + |
| +data "aws_iam_policy_document" "status_public_read" { |
| + statement { |
| + sid = "PublicReadStatusObjects" |
| + actions = ["s3:GetObject"] |
| + resources = ["${aws_s3_bucket.status.arn}/*"] |
| + |
| + principals { |
| + type = "*" |
| + identifiers = ["*"] |
| + } |
| + } |
| +} |
| + |
| +resource "aws_s3_bucket_policy" "status" { |
| + bucket = aws_s3_bucket.status.id |
| + policy = data.aws_iam_policy_document.status_public_read.json |
| + |
| + # The policy grants public access, so the access block must relax |
| + # block_public_policy before it can be attached. |
| + depends_on = [aws_s3_bucket_public_access_block.status] |
| +} |
| + |
| +# The publisher's credentials: object put/get + list on this bucket, nothing |
| +# else in the account — and no DeleteObject, so a leaked publisher key can |
| +# overwrite the current dashboards (noncurrent versions survive; versioning is |
| +# the rollback) but never destroy history. Mint the access key by hand |
| +# (aws iam create-access-key --user-name status-publisher) — keys never land |
| +# in tofu state; they go in /data/status.env on the router box. |
| +resource "aws_iam_user" "status_publisher" { |
| + name = "status-publisher" |
| +} |
| + |
| +data "aws_iam_policy_document" "status_publisher" { |
| + statement { |
| + sid = "ListStatusBucket" |
| + actions = ["s3:ListBucket"] |
| + resources = [aws_s3_bucket.status.arn] |
| + } |
| + |
| + statement { |
| + sid = "WriteStatusObjects" |
| + actions = [ |
| + "s3:GetObject", |
| + "s3:PutObject", |
| + ] |
| + resources = ["${aws_s3_bucket.status.arn}/*"] |
| + } |
| +} |
| + |
| +resource "aws_iam_user_policy" "status_publisher" { |
| + name = "status-publisher-bucket" |
| + user = aws_iam_user.status_publisher.name |
| + policy = data.aws_iam_policy_document.status_publisher.json |
| +} |
| + |
| +output "status_bucket" { |
| + value = aws_s3_bucket.status.bucket |
| +} |
| + |
| +# Where the dashboards live once published. |
| +output "status_url" { |
| + value = "https://${aws_s3_bucket.status.bucket_regional_domain_name}/index.html" |
| +} |
diff --git a/package.json b/package.json| index 14deb72..6a0bb41 100644 |
| --- a/package.json |
| +++ b/package.json |
| @@ -3,6 +3,6 @@ |
| "private": true, |
| "type": "module", |
| "scripts": { |
| - "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/fader.test.mjs" |
| + "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/fader.test.mjs" |
| } |
| } |
diff --git a/test/status-contract.test.mjs b/test/status-contract.test.mjs| new file mode 100644 |
| index 0000000..8b75180 |
| --- /dev/null |
| +++ b/test/status-contract.test.mjs |
| @@ -0,0 +1,102 @@ |
| +// 🤖 Contract test for the public status dashboards: the fields each page in |
| +// web/status/ declares it reads (its FIELDS manifest) must exist in the JSON |
| +// deploy/status-publish.sh generates. The fixtures in test/status-fixtures/ |
| +// are the script's own --dry-run output (fleet.json, books.json; regenerate |
| +// with `./deploy/status-publish.sh --dry-run <dir>` after changing the |
| +// generator) plus the hand-pinned degraded shape (books-unavailable.json, |
| +// matching books_unavailable()). If this fails, page and generator have |
| +// drifted — fix whichever side changed, then the fixtures. |
| +// node test/status-contract.test.mjs |
| +import assert from "node:assert/strict"; |
| +import { readFileSync } from "node:fs"; |
| + |
| +let failures = 0; |
| +async function test(name, fn) { |
| + try { |
| + await fn(); |
| + console.log("PASS " + name); |
| + } catch (e) { |
| + console.log("FAIL " + name + " — " + (e && e.message)); |
| + failures++; |
| + } |
| +} |
| + |
| +const read = (p) => readFileSync(new URL("../" + p, import.meta.url), "utf8"); |
| +const fleet = JSON.parse(read("test/status-fixtures/fleet.json")); |
| +const books = JSON.parse(read("test/status-fixtures/books.json")); |
| +const booksDown = JSON.parse(read("test/status-fixtures/books-unavailable.json")); |
| + |
| +// Pull the FIELDS manifest out of a page's inline script. |
| +function fields(html) { |
| + const m = html.match(/const FIELDS = \[([\s\S]*?)\];/); |
| + assert.ok(m, "page declares a FIELDS manifest"); |
| + return [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]); |
| +} |
| + |
| +// Resolve a dotted path against an object; "seg[]" descends into the first |
| +// element of a non-empty array (the fixture must exercise the array). |
| +function resolve(obj, path) { |
| + let cur = obj; |
| + for (const seg of path.split(".")) { |
| + if (cur === null || typeof cur !== "object") return undefined; |
| + if (seg.endsWith("[]")) { |
| + cur = cur[seg.slice(0, -2)]; |
| + if (!Array.isArray(cur) || cur.length === 0) return undefined; |
| + cur = cur[0]; |
| + } else { |
| + cur = cur[seg]; |
| + } |
| + if (cur === undefined) return undefined; |
| + } |
| + return cur; |
| +} |
| + |
| +const indexHtml = read("web/status/index.html"); |
| +const booksHtml = read("web/status/books.html"); |
| + |
| +await test("fleet page fields all exist in the generator's fleet.json", () => { |
| + for (const f of fields(indexHtml)) { |
| + assert.notEqual(resolve(fleet, f), undefined, "fleet.json is missing " + f); |
| + } |
| +}); |
| + |
| +await test("books page fields all exist in one of the two books shapes", () => { |
| + for (const f of fields(booksHtml)) { |
| + const hit = resolve(books, f) !== undefined || resolve(booksDown, f) !== undefined; |
| + assert.ok(hit, "neither books.json shape has " + f); |
| + } |
| +}); |
| + |
| +// A manifest entry nobody reads is drift too: each leaf property must appear |
| +// in the page's render code, not just the manifest. |
| +await test("every declared field is actually read by its page", () => { |
| + for (const [html, fs] of [[indexHtml, fields(indexHtml)], [booksHtml, fields(booksHtml)]]) { |
| + const body = html.slice(html.indexOf("];") + 2); |
| + for (const f of fs) { |
| + const leaf = f.split(".").pop().replace("[]", ""); |
| + assert.ok(body.includes(leaf), "manifest field " + f + " is never used"); |
| + } |
| + } |
| +}); |
| + |
| +// The degraded shape must be renderable: available flips the page, reason is |
| +// the message. |
| +await test("degraded books shape carries available:false + reason", () => { |
| + assert.equal(booksDown.available, false); |
| + assert.equal(typeof booksDown.reason, "string"); |
| +}); |
| + |
| +// Privacy re-check on the committed fixtures (the dry-run asserts this too — |
| +// 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]) { |
| + const s = JSON.stringify(j); |
| + assert.ok(!/sub_|cus_|txn_|\.example|\.sock/.test(s), "leak in " + s.slice(0, 80)); |
| + } |
| +}); |
| + |
| +if (failures > 0) { |
| + console.error(failures + " failure(s)"); |
| + process.exit(1); |
| +} |
diff --git a/test/status-fixtures/books-unavailable.json b/test/status-fixtures/books-unavailable.json| new file mode 100644 |
| index 0000000..b5f02ad |
| --- /dev/null |
| +++ b/test/status-fixtures/books-unavailable.json |
| @@ -0,0 +1 @@ |
| +{"generated_at":"2026-07-11T13:13:47Z","available":false,"reason":"billing not configured"} |
diff --git a/test/status-fixtures/books.json b/test/status-fixtures/books.json| new file mode 100644 |
| index 0000000..b7aa4f2 |
| --- /dev/null |
| +++ b/test/status-fixtures/books.json |
| @@ -0,0 +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} |
diff --git a/test/status-fixtures/fleet.json b/test/status-fixtures/fleet.json| new file mode 100644 |
| index 0000000..da820e3 |
| --- /dev/null |
| +++ b/test/status-fixtures/fleet.json |
| @@ -0,0 +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}} |
diff --git a/web/status/books.html b/web/status/books.html| new file mode 100644 |
| index 0000000..be710bc |
| --- /dev/null |
| +++ b/web/status/books.html |
| @@ -0,0 +1,215 @@ |
| +<!-- 🤖 Public "books" page (issue #550 Parts 11/12, decision #19: income |
| + publishes AGGREGATED, never per-payer). Fully self-contained: inline |
| + CSS/JS, no external requests except ./books.json (relative). Published by |
| + deploy/status-publish.sh from Stripe aggregates — totals only. --> |
| +<!doctype html> |
| +<html lang="en"> |
| +<head> |
| +<meta charset="utf-8" /> |
| +<meta name="viewport" content="width=device-width, initial-scale=1" /> |
| +<meta name="color-scheme" content="light dark" /> |
| +<title>Eleven — the books</title> |
| +<link rel="icon" type="image/png" href="./icon-192.png" /> |
| +<style> |
| + /* Same validated palette roles as index.html (dataviz six-checks, light + |
| + dark); violet series hue, secondary ink for all small text (>=4.5:1). */ |
| + :root { |
| + --plane: #f9f9f7; --surface: #fcfcfb; |
| + --ink: #0b0b0b; --ink-2: #52514e; |
| + --grid: #e1e0d9; --border: rgba(11,11,11,0.10); |
| + --series: #4a3aa7; |
| + --bad: #b23030; --bad-mark: #d03b3b; |
| + --link: #4a3aa7; |
| + } |
| + @media (prefers-color-scheme: dark) { |
| + :root { |
| + --plane: #0d0d0d; --surface: #1a1a19; |
| + --ink: #ffffff; --ink-2: #c3c2b7; |
| + --grid: #2c2c2a; --border: rgba(255,255,255,0.10); |
| + --series: #9085e9; |
| + --bad: #e66767; --bad-mark: #e66767; |
| + --link: #9085e9; |
| + } |
| + } |
| + * { box-sizing: border-box; margin: 0; } |
| + body { |
| + background: var(--plane); color: var(--ink); |
| + font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; |
| + padding: 24px 16px 48px; |
| + } |
| + .wrap { max-width: 760px; margin: 0 auto; } |
| + a { color: var(--link); } |
| + a:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 4px; } |
| + |
| + header { display: flex; align-items: center; gap: 14px; margin-bottom: 6px; } |
| + header img { width: 44px; height: 44px; border-radius: 10px; } |
| + header h1 { font-size: 22px; font-weight: 650; } |
| + header .brand { color: var(--ink-2); font-size: 14px; } |
| + .lede { color: var(--ink-2); margin: 6px 0 2px; } |
| + .asof { font-weight: 600; margin: 10px 0 24px; } |
| + .asof .stale { color: var(--bad); font-weight: 600; } |
| + |
| + .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin-bottom: 28px; } |
| + .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; } |
| + |
| + section h2 { font-size: 15px; font-weight: 650; margin: 0 0 10px; } |
| + section { margin-bottom: 28px; } |
| + |
| + /* Per-tier subscriptions: single-series horizontal bar list; rows are real |
| + text (their own table view), bars aria-hidden. Thin mark, 4px rounded |
| + data-end, square baseline. */ |
| + .bars { list-style: none; padding: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; } |
| + .bars li { display: grid; grid-template-columns: minmax(90px, 160px) 1fr 3.5em; align-items: center; gap: 12px; padding: 10px 16px; } |
| + .bars li + li { border-top: 1px solid var(--grid); } |
| + .bars .tier { font-size: 14px; font-weight: 600; text-transform: capitalize; overflow-wrap: anywhere; } |
| + .bars .track { height: 12px; } |
| + .bars .bar { height: 12px; min-width: 2px; background: var(--series); border-radius: 0 4px 4px 0; } |
| + .bars .count { font-size: 14px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; } |
| + .bars .empty { display: block; color: var(--ink-2); } |
| + |
| + #unavailable, #error { display: none; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; margin-bottom: 24px; } |
| + #error { border-color: var(--bad-mark); } |
| + #unavailable.show, #error.show { display: block; } |
| + |
| + footer { margin-top: 36px; color: var(--ink-2); font-size: 14px; } |
| + footer p { margin-top: 6px; } |
| +</style> |
| +</head> |
| +<body> |
| +<div class="wrap"> |
| + <header> |
| + <img src="./icon-192.png" alt="" /> |
| + <div> |
| + <p class="brand">Eleven Messenger</p> |
| + <h1>The books</h1> |
| + </div> |
| + </header> |
| + <p class="lede">Eleven’s income, published as it stands — part of running the |
| + service in the open. <strong>Aggregated by design:</strong> our billing records hold no |
| + customer identities (Stripe does), and what appears here is totals only — |
| + never a per-payer line, never an individual payment.</p> |
| + <p class="asof">As of <time id="asof" datetime="">—</time><span id="staleness"></span></p> |
| + |
| + <p id="error" role="alert">Couldn’t load the books feed. It may be mid-publish — this page retries every minute.</p> |
| + <p id="unavailable" role="status">The books are unavailable right now: <span id="reason"></span>.</p> |
| + |
| + <div id="books"> |
| + <section aria-labelledby="h-income"> |
| + <h2 id="h-income">Income</h2> |
| + <div class="tiles"> |
| + <div class="tile"><div class="label">Active subscriptions</div><div class="value" id="t-active">—</div></div> |
| + <div class="tile"><div class="label">Monthly recurring revenue</div><div class="value" id="t-mrr">—</div></div> |
| + <div class="tile"><div class="label" id="l-collected">Collected this month</div><div class="value" id="t-collected">—</div></div> |
| + </div> |
| + </section> |
| + |
| + <section aria-labelledby="h-tiers"> |
| + <h2 id="h-tiers">Subscriptions by tier</h2> |
| + <ul class="bars" id="tiers"><li><span class="empty">Loading…</span></li></ul> |
| + </section> |
| + </div> |
| + |
| + <footer> |
| + <p><a href="./index.html">Fleet status</a></p> |
| + <p>Published every 5 minutes from Stripe aggregates. Tier names are the |
| + public price book; amounts are EUR. Expenses and disbursements will join |
| + these pages as the public ledger lands (see the roadmap in |
| + docs/planet-scale.md).</p> |
| + </footer> |
| +</div> |
| + |
| +<script> |
| +// contract: the fields this page reads from ./books.json — kept in sync with |
| +// deploy/status-publish.sh and checked by test/status-contract.test.mjs. |
| +// (available/reason come from the degraded shape; the rest from the full one.) |
| +const FIELDS = [ |
| + "generated_at", "available", "reason", |
| + "month", "currency", |
| + "subscriptions.active", "subscriptions.by_tier[].tier", "subscriptions.by_tier[].count", |
| + "mrr_cents", "collected_month_cents", |
| +]; |
| + |
| +const $ = (id) => document.getElementById(id); |
| +const eur = new Intl.NumberFormat("en-IE", { style: "currency", currency: "EUR" }); |
| + |
| +// All rendering is textContent / createElement — never innerHTML of fetched |
| +// data, even though the feed is our own. |
| +function render(d) { |
| + const t = new Date(d.generated_at); |
| + $("asof").dateTime = d.generated_at; |
| + $("asof").textContent = t.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); |
| + const staleEl = $("staleness"); |
| + staleEl.textContent = ""; |
| + staleEl.className = ""; |
| + if (Date.now() - t.getTime() > 20 * 60 * 1000) { |
| + staleEl.className = "stale"; |
| + staleEl.textContent = " — this feed looks stale"; |
| + } |
| + |
| + if (!d.available) { |
| + $("reason").textContent = d.reason || "not configured"; |
| + $("unavailable").classList.add("show"); |
| + $("books").style.display = "none"; |
| + return; |
| + } |
| + $("unavailable").classList.remove("show"); |
| + $("books").style.display = ""; |
| + |
| + $("t-active").textContent = String(d.subscriptions.active); |
| + $("t-mrr").textContent = eur.format(d.mrr_cents / 100); |
| + $("t-collected").textContent = eur.format(d.collected_month_cents / 100); |
| + $("l-collected").textContent = "Collected in " + d.month; |
| + void d.currency; // fixed "eur" today; the formatter would key on it if that changes |
| + |
| + const list = $("tiers"); |
| + list.replaceChildren(); |
| + const tiers = d.subscriptions.by_tier || []; |
| + const max = Math.max(1, ...tiers.map((x) => x.count)); |
| + for (const x of tiers) { |
| + const li = document.createElement("li"); |
| + const name = document.createElement("span"); |
| + name.className = "tier"; |
| + name.textContent = x.tier; |
| + const track = document.createElement("span"); |
| + track.className = "track"; |
| + track.setAttribute("aria-hidden", "true"); |
| + const bar = document.createElement("span"); |
| + bar.className = "bar"; |
| + bar.style.width = (100 * x.count / max) + "%"; |
| + bar.style.display = "block"; |
| + track.append(bar); |
| + const count = document.createElement("span"); |
| + count.className = "count"; |
| + count.textContent = String(x.count); |
| + li.append(name, track, count); |
| + list.append(li); |
| + } |
| + if (!tiers.length) { |
| + const li = document.createElement("li"); |
| + const s = document.createElement("span"); |
| + s.className = "empty"; |
| + s.textContent = "No active subscriptions yet."; |
| + li.append(s); |
| + list.append(li); |
| + } |
| +} |
| + |
| +async function refresh() { |
| + try { |
| + const res = await fetch("./books.json", { cache: "no-cache" }); |
| + if (!res.ok) throw new Error("HTTP " + res.status); |
| + render(await res.json()); |
| + $("error").classList.remove("show"); |
| + } catch (e) { |
| + // Keep the previous render (no skeleton flash); surface the failure inline. |
| + $("error").classList.add("show"); |
| + } |
| +} |
| +refresh(); |
| +setInterval(refresh, 60 * 1000); |
| +void FIELDS; |
| +</script> |
| +</body> |
| +</html> |
diff --git a/web/status/icon-192.png b/web/status/icon-192.png| new file mode 100644 |
| index 0000000..3917fe1 |
| Binary files /dev/null and b/web/status/icon-192.png differ |
diff --git a/web/status/index.html b/web/status/index.html| new file mode 100644 |
| index 0000000..6592be4 |
| --- /dev/null |
| +++ b/web/status/index.html |
| @@ -0,0 +1,248 @@ |
| +<!-- 🤖 Public fleet-status page (issue #550 Parts 11/12). Fully self-contained: |
| + inline CSS/JS, no external requests except ./fleet.json (relative, so it |
| + works at any origin). Published by deploy/status-publish.sh; the JSON it |
| + reads is AGGREGATES ONLY — no hostnames, nothing per-space. --> |
| +<!doctype html> |
| +<html lang="en"> |
| +<head> |
| +<meta charset="utf-8" /> |
| +<meta name="viewport" content="width=device-width, initial-scale=1" /> |
| +<meta name="color-scheme" content="light dark" /> |
| +<title>Eleven — fleet status</title> |
| +<link rel="icon" type="image/png" href="./icon-192.png" /> |
| +<style> |
| + /* Palette roles (validated: dataviz six-checks, light + dark). Series hue is |
| + the violet slot — the Eleven brand family. Secondary ink is used for ALL |
| + small text (>=4.5:1 both modes); the muted axis tone is deliberately |
| + absent — it fails AA at body sizes. */ |
| + :root { |
| + --plane: #f9f9f7; --surface: #fcfcfb; |
| + --ink: #0b0b0b; --ink-2: #52514e; |
| + --grid: #e1e0d9; --border: rgba(11,11,11,0.10); |
| + --series: #4a3aa7; |
| + --good: #006300; --good-mark: #0ca30c; |
| + --bad: #b23030; --bad-mark: #d03b3b; |
| + --link: #4a3aa7; |
| + } |
| + @media (prefers-color-scheme: dark) { |
| + :root { |
| + --plane: #0d0d0d; --surface: #1a1a19; |
| + --ink: #ffffff; --ink-2: #c3c2b7; |
| + --grid: #2c2c2a; --border: rgba(255,255,255,0.10); |
| + --series: #9085e9; |
| + --good: #0ca30c; --good-mark: #0ca30c; |
| + --bad: #e66767; --bad-mark: #e66767; |
| + --link: #9085e9; |
| + } |
| + } |
| + * { box-sizing: border-box; margin: 0; } |
| + body { |
| + background: var(--plane); color: var(--ink); |
| + font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; |
| + padding: 24px 16px 48px; |
| + } |
| + .wrap { max-width: 760px; margin: 0 auto; } |
| + a { color: var(--link); } |
| + a:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 4px; } |
| + |
| + header { display: flex; align-items: center; gap: 14px; margin-bottom: 6px; } |
| + header img { width: 44px; height: 44px; border-radius: 10px; } |
| + header h1 { font-size: 22px; font-weight: 650; } |
| + header .brand { color: var(--ink-2); font-size: 14px; } |
| + .lede { color: var(--ink-2); margin: 6px 0 2px; } |
| + .asof { font-weight: 600; margin: 10px 0 24px; } |
| + .asof .stale { color: var(--bad); font-weight: 600; } |
| + |
| + .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin-bottom: 28px; } |
| + .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; } |
| + |
| + section h2 { font-size: 15px; font-weight: 650; margin: 0 0 10px; } |
| + section { margin-bottom: 28px; } |
| + |
| + /* 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; } |
| + .services 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; } |
| + .services .name { font-weight: 600; } |
| + .services .state { font-size: 14px; font-weight: 600; white-space: nowrap; } |
| + /* Replication is the odd one out (it carries a lag detail) — a full-width |
| + row gives the detail room instead of wrapping inside a narrow tile. */ |
| + .services li:last-child { grid-column: 1 / -1; } |
| + .services .state.up { color: var(--good); } |
| + .services .state.down { color: var(--bad); } |
| + |
| + /* Version distribution: a single-series horizontal bar list. Every row is |
| + real text (version + count) so the list is its own table view; the bar is |
| + decoration for the eye and hidden from AT. Thin mark, 4px rounded |
| + data-end, square at the baseline. */ |
| + .bars { list-style: none; padding: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; } |
| + .bars li { display: grid; grid-template-columns: minmax(90px, 160px) 1fr 3.5em; align-items: center; gap: 12px; padding: 10px 16px; } |
| + .bars li + li { border-top: 1px solid var(--grid); } |
| + .bars .ver { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; overflow-wrap: anywhere; } |
| + .bars .track { height: 12px; } |
| + .bars .bar { height: 12px; min-width: 2px; background: var(--series); border-radius: 0 4px 4px 0; } |
| + .bars .count { font-size: 14px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; } |
| + .bars .empty { display: block; color: var(--ink-2); } |
| + |
| + #error { display: none; background: var(--surface); border: 1px solid var(--bad-mark); border-radius: 12px; padding: 14px 16px; margin-bottom: 24px; } |
| + #error.show { display: block; } |
| + |
| + footer { margin-top: 36px; color: var(--ink-2); font-size: 14px; } |
| + footer p { margin-top: 6px; } |
| +</style> |
| +</head> |
| +<body> |
| +<div class="wrap"> |
| + <header> |
| + <img src="./icon-192.png" alt="" /> |
| + <div> |
| + <p class="brand">Eleven Messenger</p> |
| + <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. |
| + Counts only: no space is ever named here.</p> |
| + <p class="asof">As of <time id="asof" datetime="">—</time><span id="staleness"></span></p> |
| + |
| + <p id="error" role="alert">Couldn’t load the status feed. It may be mid-publish — this page retries every minute.</p> |
| + |
| + <section aria-labelledby="h-instances"> |
| + <h2 id="h-instances">Spaces</h2> |
| + <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> |
| + </section> |
| + |
| + <section aria-labelledby="h-services"> |
| + <h2 id="h-services">Services</h2> |
| + <ul class="services" id="services"></ul> |
| + </section> |
| + |
| + <section aria-labelledby="h-versions"> |
| + <h2 id="h-versions">Version distribution</h2> |
| + <ul class="bars" id="versions"><li><span class="empty">Loading…</span></li></ul> |
| + </section> |
| + |
| + <footer> |
| + <p><a href="./books.html">The books — income, in aggregate</a></p> |
| + <p>Published every 5 minutes from aggregate counters on the fleet. |
| + By design it contains no hostnames and nothing about any individual space.</p> |
| + </footer> |
| +</div> |
| + |
| +<script> |
| +// contract: the fields this page reads from ./fleet.json — kept in sync with |
| +// deploy/status-publish.sh and checked by test/status-contract.test.mjs. |
| +const FIELDS = [ |
| + "generated_at", |
| + "instances.total", "instances.systemd", "instances.hibernating", |
| + "instances.awake", "instances.asleep", |
| + "versions[].version", "versions[].count", |
| + "services.home", "services.push", "services.cloud", "services.connect", |
| + "replication.healthy", "replication.lag_seconds", |
| +]; |
| + |
| +const $ = (id) => document.getElementById(id); |
| +const num = (v) => (v === null || v === undefined) ? "—" : String(v); |
| + |
| +// All rendering is textContent / createElement — never innerHTML of fetched |
| +// data, even though the feed is our own. |
| +function render(d) { |
| + const t = new Date(d.generated_at); |
| + $("asof").dateTime = d.generated_at; |
| + $("asof").textContent = t.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); |
| + // Honesty check: flag a feed older than 20 minutes (four missed publishes). |
| + const staleEl = $("staleness"); |
| + staleEl.textContent = ""; |
| + staleEl.className = ""; |
| + if (Date.now() - t.getTime() > 20 * 60 * 1000) { |
| + staleEl.className = "stale"; |
| + staleEl.textContent = " — this feed looks stale"; |
| + } |
| + |
| + $("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); |
| + |
| + const svc = $("services"); |
| + svc.replaceChildren(); |
| + const rows = [ |
| + ["Home", d.services.home], ["Push", d.services.push], |
| + ["Cloud", d.services.cloud], ["Connect", d.services.connect], |
| + ["Replication", d.replication.healthy], |
| + ]; |
| + for (const [name, up] of rows) { |
| + const li = document.createElement("li"); |
| + const n = document.createElement("span"); |
| + n.className = "name"; |
| + n.textContent = name; |
| + const s = document.createElement("span"); |
| + s.className = "state " + (up ? "up" : "down"); |
| + // The word is the state; the glyph + color are supplementary. |
| + s.textContent = (up ? "✓ up" : "✕ down"); |
| + if (name === "Replication" && up && d.replication.lag_seconds !== null) { |
| + s.textContent += " · " + d.replication.lag_seconds + "s ago"; |
| + } |
| + li.append(n, s); |
| + svc.append(li); |
| + } |
| + |
| + const list = $("versions"); |
| + list.replaceChildren(); |
| + const versions = d.versions || []; |
| + const max = Math.max(1, ...versions.map((v) => v.count)); |
| + for (const v of versions) { |
| + const li = document.createElement("li"); |
| + const ver = document.createElement("span"); |
| + ver.className = "ver"; |
| + ver.textContent = v.version; |
| + const track = document.createElement("span"); |
| + track.className = "track"; |
| + track.setAttribute("aria-hidden", "true"); |
| + const bar = document.createElement("span"); |
| + bar.className = "bar"; |
| + bar.style.width = (100 * v.count / max) + "%"; |
| + bar.style.display = "block"; |
| + track.append(bar); |
| + const count = document.createElement("span"); |
| + count.className = "count"; |
| + count.textContent = String(v.count); |
| + li.append(ver, track, count); |
| + list.append(li); |
| + } |
| + if (!versions.length) { |
| + const li = document.createElement("li"); |
| + const s = document.createElement("span"); |
| + s.className = "empty"; |
| + s.textContent = "No versions reported."; |
| + li.append(s); |
| + list.append(li); |
| + } |
| +} |
| + |
| +async function refresh() { |
| + try { |
| + const res = await fetch("./fleet.json", { cache: "no-cache" }); |
| + if (!res.ok) throw new Error("HTTP " + res.status); |
| + render(await res.json()); |
| + $("error").classList.remove("show"); |
| + } catch (e) { |
| + // Keep the previous render (no skeleton flash); surface the failure inline. |
| + $("error").classList.add("show"); |
| + } |
| +} |
| +refresh(); |
| +setInterval(refresh, 60 * 1000); |
| +void FIELDS; |
| +</script> |
| +</body> |
| +</html> |