eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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.
117const 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.
133const 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};
138const LOAD_LEVELS = {
139 "comfortable": { text: "βœ“ comfortable", tone: "ok" },
140 "busy": { text: "◐ busy", tone: "warn" },
141 "critical": { text: "β–² critical", tone: "bad" },
142};
143
144const $ = (id) => document.getElementById(id);
145const 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.
149function 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
270async 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}
281refresh();
282setInterval(refresh, 60 * 1000);
283void FIELDS;
284</script>
285</body>
286</html>
287