| 1 | <!-- π€ Public fleet-status page (issue #550 Parts 11/12). Fully self-contained: |
| 2 | inline CSS/JS, no external requests except ./fleet.json (relative, so it |
| 3 | works at any origin). Published by deploy/status-publish.sh; the JSON it |
| 4 | reads is AGGREGATES ONLY β no hostnames, nothing per-space. --> |
| 5 | <!doctype html> |
| 6 | <html lang="en"> |
| 7 | <head> |
| 8 | <meta charset="utf-8" /> |
| 9 | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 10 | <meta name="color-scheme" content="light dark" /> |
| 11 | <title>Eleven β fleet status</title> |
| 12 | <link rel="icon" type="image/png" href="./icon-192.png" /> |
| 13 | <link rel="stylesheet" href="./status.css" /> |
| 14 | <style> |
| 15 | /* Fleet-only: the capacity band, the serving-hosts + services lists, and the |
| 16 | monospace version label. Everything shared lives in status.css. */ |
| 17 | .tiles { grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); } |
| 18 | |
| 19 | .bars .ver { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; overflow-wrap: anywhere; } |
| 20 | |
| 21 | /* Capacity: the abstract room-left band β a word + glyph, no numbers (the |
| 22 | JSON carries none). Tone classes only color it; the word and glyph carry |
| 23 | the state on their own (never color alone). */ |
| 24 | .capacity { margin-bottom: 10px; } |
| 25 | .capacity .value.ok { color: var(--good); } |
| 26 | .capacity .value.warn { color: var(--warn); } |
| 27 | .capacity .value.bad { color: var(--bad); } |
| 28 | |
| 29 | /* Hosts: one compact row per serving box β anonymous ordinals only, never a |
| 30 | hostname. Load mirrors the service-state pattern: word + glyph. */ |
| 31 | .hosts-head { font-size: 14px; font-weight: 650; margin: 16px 0 8px; } |
| 32 | .hosts { display: grid; gap: 10px; list-style: none; padding: 0; } |
| 33 | .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; } |
| 34 | .hosts .who { display: inline-flex; gap: 8px; align-items: baseline; } |
| 35 | .hosts .name { font-weight: 600; } |
| 36 | .hosts .region { color: var(--ink-2); font-size: 14px; } |
| 37 | .hosts .split { color: var(--ink-2); font-size: 14px; } |
| 38 | .hosts .state { font-size: 14px; font-weight: 600; white-space: nowrap; } |
| 39 | .hosts .state.ok { color: var(--good); } |
| 40 | .hosts .state.warn { color: var(--warn); } |
| 41 | .hosts .state.bad { color: var(--bad); } |
| 42 | |
| 43 | /* Services: word + glyph carry the state; color is supplementary (never |
| 44 | color alone). Replication is full-width for its lag detail. */ |
| 45 | .services { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; list-style: none; padding: 0; } |
| 46 | .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; } |
| 47 | .services .name { font-weight: 600; } |
| 48 | .services .state { font-size: 14px; font-weight: 600; white-space: nowrap; } |
| 49 | .services li:last-child { grid-column: 1 / -1; } |
| 50 | .services .state.up { color: var(--good); } |
| 51 | .services .state.down { color: var(--bad); } |
| 52 | </style> |
| 53 | </head> |
| 54 | <body> |
| 55 | <div class="wrap"> |
| 56 | <header data-title="Fleet status"></header> |
| 57 | <nav class="status-nav" aria-label="Status"></nav> |
| 58 | <script src="./status-nav.js"></script> |
| 59 | <p class="lede">Live aggregate health of the Eleven fleet β how many spaces are running |
| 60 | (always-on or on-demand, and of the on-demand ones how many are awake), what |
| 61 | they run, and whether the shared services are up. |
| 62 | Counts only: no space is ever named here.</p> |
| 63 | <p class="asof">As of <time id="asof" datetime="">β</time><span id="staleness"></span></p> |
| 64 | |
| 65 | <p id="error" role="alert">Couldnβt load the status feed. It may be mid-publish β this page retries every minute.</p> |
| 66 | |
| 67 | <section aria-labelledby="h-instances"> |
| 68 | <h2 id="h-instances">Spaces</h2> |
| 69 | <!-- On-demand (nΓ© Hibernating) is a MODE β activator-managed instances; |
| 70 | awake/asleep are the STATE of that same set. The state nests inside |
| 71 | the mode's tile so the structure encodes the relationship β five |
| 72 | sibling tiles read "Hibernating 17" vs "Awake 17" as a contradiction. --> |
| 73 | <div class="tiles"> |
| 74 | <div class="tile"><div class="label">Instances</div><div class="value" id="t-total">β</div></div> |
| 75 | <div class="tile"><div class="label">Always-on</div><div class="value" id="t-systemd">β</div></div> |
| 76 | <div class="tile"> |
| 77 | <div class="label">On-demand</div> |
| 78 | <div class="value" id="t-ondemand">β</div> |
| 79 | <div class="sub" id="t-ondemand-state"></div> |
| 80 | </div> |
| 81 | </div> |
| 82 | </section> |
| 83 | |
| 84 | <!-- Capacity + hosts are deliberately abstract: band words only, no |
| 85 | remaining-slot counts, GB, or percentages β host labels are anonymous |
| 86 | ordinals, never hostnames, and a box's region is continent + country |
| 87 | only. --> |
| 88 | <section aria-labelledby="h-capacity"> |
| 89 | <h2 id="h-capacity">Capacity</h2> |
| 90 | <div class="tile capacity"> |
| 91 | <div class="label">Room for new spaces</div> |
| 92 | <div class="value" id="cap-level">β</div> |
| 93 | </div> |
| 94 | <h3 class="hosts-head" id="hosts-head">Serving hosts</h3> |
| 95 | <ul class="hosts" id="hosts" aria-labelledby="hosts-head"></ul> |
| 96 | </section> |
| 97 | |
| 98 | <section aria-labelledby="h-services"> |
| 99 | <h2 id="h-services">Services</h2> |
| 100 | <ul class="services" id="services"></ul> |
| 101 | </section> |
| 102 | |
| 103 | <section aria-labelledby="h-versions"> |
| 104 | <h2 id="h-versions">Version distribution</h2> |
| 105 | <ul class="bars" id="versions"><li><span class="empty">Loadingβ¦</span></li></ul> |
| 106 | </section> |
| 107 | |
| 108 | <footer> |
| 109 | <p>Published every 5 minutes from aggregate counters on the fleet. |
| 110 | By design it contains no hostnames and nothing about any individual space.</p> |
| 111 | </footer> |
| 112 | </div> |
| 113 | |
| 114 | <script> |
| 115 | // contract: the fields this page reads from ./fleet.json β kept in sync with |
| 116 | // deploy/status-publish.sh and checked by test/status-contract.test.mjs. |
| 117 | const FIELDS = [ |
| 118 | "generated_at", |
| 119 | "instances.total", "instances.systemd", "instances.hibernating", |
| 120 | "instances.awake", "instances.asleep", |
| 121 | "hosts.count", "hosts.boxes[].label", "hosts.boxes[].region", |
| 122 | "hosts.boxes[].awake", "hosts.boxes[].asleep", "hosts.boxes[].load", |
| 123 | "capacity.level", |
| 124 | "versions[].version", "versions[].count", |
| 125 | "services.home", "services.push", "services.cloud", "services.connect", |
| 126 | "replication.healthy", "replication.lag_seconds", |
| 127 | ]; |
| 128 | |
| 129 | // Abstract bands β display word + glyph + tone. The word and glyph carry the |
| 130 | // state (never color alone); anything outside the vocabulary renders "β" |
| 131 | // rather than guessing. These are the ONLY capacity values the feed may hold β |
| 132 | // levels, not numbers. |
| 133 | const CAP_LEVELS = { |
| 134 | "plenty": { text: "β Plenty of room", tone: "ok" }, |
| 135 | "getting-full": { text: "β Getting full", tone: "warn" }, |
| 136 | "at-capacity": { text: "β² At capacity", tone: "bad" }, |
| 137 | }; |
| 138 | const LOAD_LEVELS = { |
| 139 | "comfortable": { text: "β comfortable", tone: "ok" }, |
| 140 | "busy": { text: "β busy", tone: "warn" }, |
| 141 | "critical": { text: "β² critical", tone: "bad" }, |
| 142 | }; |
| 143 | |
| 144 | const $ = (id) => document.getElementById(id); |
| 145 | const num = (v) => (v === null || v === undefined) ? "β" : String(v); |
| 146 | |
| 147 | // All rendering is textContent / createElement β never innerHTML of fetched |
| 148 | // data, even though the feed is our own. |
| 149 | function render(d) { |
| 150 | const t = new Date(d.generated_at); |
| 151 | $("asof").dateTime = d.generated_at; |
| 152 | $("asof").textContent = t.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); |
| 153 | // Honesty check: flag a feed older than 20 minutes (four missed publishes). |
| 154 | const staleEl = $("staleness"); |
| 155 | staleEl.textContent = ""; |
| 156 | staleEl.className = ""; |
| 157 | if (Date.now() - t.getTime() > 20 * 60 * 1000) { |
| 158 | staleEl.className = "stale"; |
| 159 | staleEl.textContent = " β this feed looks stale"; |
| 160 | } |
| 161 | |
| 162 | $("t-total").textContent = num(d.instances.total); |
| 163 | $("t-systemd").textContent = num(d.instances.systemd); |
| 164 | // On-demand is the mode; the awake/asleep pair is the state of that set, |
| 165 | // rendered as the tile's sub-line rather than as sibling tiles. |
| 166 | $("t-ondemand").textContent = num(d.instances.hibernating); |
| 167 | $("t-ondemand-state").textContent = |
| 168 | num(d.instances.awake) + " awake Β· " + num(d.instances.asleep) + " asleep"; |
| 169 | |
| 170 | const cap = d.capacity && CAP_LEVELS[d.capacity.level]; |
| 171 | const capEl = $("cap-level"); |
| 172 | capEl.className = "value" + (cap ? " " + cap.tone : ""); |
| 173 | capEl.textContent = cap ? cap.text : "β"; |
| 174 | |
| 175 | $("hosts-head").textContent = "Serving hosts Β· " + num(d.hosts && d.hosts.count); |
| 176 | const hosts = $("hosts"); |
| 177 | hosts.replaceChildren(); |
| 178 | const boxes = (d.hosts && d.hosts.boxes) || []; |
| 179 | for (const b of boxes) { |
| 180 | const li = document.createElement("li"); |
| 181 | const who = document.createElement("span"); |
| 182 | who.className = "who"; |
| 183 | const n = document.createElement("span"); |
| 184 | n.className = "name"; |
| 185 | n.textContent = b.label; |
| 186 | who.append(n); |
| 187 | // Coarse public region (continent + country) β only when the box declares |
| 188 | // one; an absent region renders nothing, never a guess. |
| 189 | if (typeof b.region === "string" && b.region) { |
| 190 | const r = document.createElement("span"); |
| 191 | r.className = "region"; |
| 192 | r.textContent = b.region; |
| 193 | who.append(r); |
| 194 | } |
| 195 | const split = document.createElement("span"); |
| 196 | split.className = "split"; |
| 197 | // The awake/asleep pair covers only this box's HIBERNATING instances β |
| 198 | // the always-on count sits beside it so the row sums to what the box runs. |
| 199 | split.textContent = num(b.always_on) + " always-on Β· hibernating: " + |
| 200 | num(b.awake) + " awake, " + num(b.asleep) + " asleep"; |
| 201 | const load = LOAD_LEVELS[b.load]; |
| 202 | const s = document.createElement("span"); |
| 203 | s.className = "state" + (load ? " " + load.tone : ""); |
| 204 | s.textContent = load ? load.text : "β"; |
| 205 | li.append(who, split, s); |
| 206 | hosts.append(li); |
| 207 | } |
| 208 | |
| 209 | const svc = $("services"); |
| 210 | svc.replaceChildren(); |
| 211 | const rows = [ |
| 212 | ["Home", d.services.home], ["Push", d.services.push], |
| 213 | ["Cloud", d.services.cloud], ["Connect", d.services.connect], |
| 214 | ["Replication", d.replication.healthy], |
| 215 | ]; |
| 216 | for (const [name, up] of rows) { |
| 217 | const li = document.createElement("li"); |
| 218 | const n = document.createElement("span"); |
| 219 | n.className = "name"; |
| 220 | n.textContent = name; |
| 221 | const s = document.createElement("span"); |
| 222 | s.className = "state " + (up ? "up" : "down"); |
| 223 | // The word is the state; the glyph + color are supplementary. |
| 224 | s.textContent = (up ? "β up" : "β down"); |
| 225 | if (name === "Replication" && up && d.replication.lag_seconds !== null) { |
| 226 | // The lag is frozen into each snapshot, not live β "0s ago" read like a |
| 227 | // ticking clock. 0 means the replicator was mid-sync at snapshot time |
| 228 | // (its normal state); anything else is how far behind it had fallen. |
| 229 | const lag = d.replication.lag_seconds; |
| 230 | s.textContent = lag === 0 ? "β syncing Β· current at snapshot" |
| 231 | : "β up Β· " + lag + "s behind at snapshot"; |
| 232 | } |
| 233 | li.append(n, s); |
| 234 | svc.append(li); |
| 235 | } |
| 236 | |
| 237 | const list = $("versions"); |
| 238 | list.replaceChildren(); |
| 239 | const versions = d.versions || []; |
| 240 | const max = Math.max(1, ...versions.map((v) => v.count)); |
| 241 | for (const v of versions) { |
| 242 | const li = document.createElement("li"); |
| 243 | const ver = document.createElement("span"); |
| 244 | ver.className = "ver"; |
| 245 | ver.textContent = v.version; |
| 246 | const track = document.createElement("span"); |
| 247 | track.className = "track"; |
| 248 | track.setAttribute("aria-hidden", "true"); |
| 249 | const bar = document.createElement("span"); |
| 250 | bar.className = "bar"; |
| 251 | bar.style.width = (100 * v.count / max) + "%"; |
| 252 | bar.style.display = "block"; |
| 253 | track.append(bar); |
| 254 | const count = document.createElement("span"); |
| 255 | count.className = "count"; |
| 256 | count.textContent = String(v.count); |
| 257 | li.append(ver, track, count); |
| 258 | list.append(li); |
| 259 | } |
| 260 | if (!versions.length) { |
| 261 | const li = document.createElement("li"); |
| 262 | const s = document.createElement("span"); |
| 263 | s.className = "empty"; |
| 264 | s.textContent = "No versions reported."; |
| 265 | li.append(s); |
| 266 | list.append(li); |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | async function refresh() { |
| 271 | try { |
| 272 | const res = await fetch("./fleet.json", { cache: "no-cache" }); |
| 273 | if (!res.ok) throw new Error("HTTP " + res.status); |
| 274 | render(await res.json()); |
| 275 | $("error").classList.remove("show"); |
| 276 | } catch (e) { |
| 277 | // Keep the previous render (no skeleton flash); surface the failure inline. |
| 278 | $("error").classList.add("show"); |
| 279 | } |
| 280 | } |
| 281 | refresh(); |
| 282 | setInterval(refresh, 60 * 1000); |
| 283 | void FIELDS; |
| 284 | </script> |
| 285 | </body> |
| 286 | </html> |
| 287 | |