|
1 | 1 | // The operators' admin page (docs/billing.md, "The operator's admin"; |
2 | | -// decision 59): people, orgs, trial codes, billing's health and the log |
| 2 | +// decision 59): a glance at who joined and what runs, people, orgs, trial |
| 3 | +// codes, billing's health and the log |
3 | 4 | // of what operators did, through `/api/admin/*` with the operator's own |
4 | 5 | // platform session (`x-fragment-shell`, same-origin only). The page is |
5 | 6 | // served to an operator's session alone (anyone else gets the 404 of a |
@@ -203,6 +204,51 @@ async function trials() { |
203 | 204 | say(`${v.codes.length} codes`); |
204 | 205 | } |
205 | 206 |
|
| 207 | +// ---- the glance: who joined, from the registry; what runs, from the |
| 208 | +// deployment's logs (`GET /api/admin/glance`). Read afresh each minute |
| 209 | +// while it is shown, never cached (Paul, 2026-11-04). |
| 210 | +const GLANCE_EVERY_MS = 60_000; |
| 211 | +const TROUBLE = { wont_wake: "won't wake", saves_failing: "a save failed", disk_filling: "disk nearly full" }; |
| 212 | +let glanceTimer = null; |
| 213 | + |
| 214 | +function tile(label, big, small) { |
| 215 | + const t = el("div", undefined, "tile"); |
| 216 | + t.append(el("div", label, "label"), el("div", String(big), "big"), el("div", small, "small")); |
| 217 | + return t; |
| 218 | +} |
| 219 | + |
| 220 | +// a person in the glance opens their page, as the People tab does |
| 221 | +function openFromGlance(npub) { |
| 222 | + return guard(async () => { |
| 223 | + await show("people"); |
| 224 | + await openPerson(npub); |
| 225 | + }); |
| 226 | +} |
| 227 | + |
| 228 | +async function glance() { |
| 229 | + const midnight = new Date(); |
| 230 | + midnight.setHours(0, 0, 0, 0); |
| 231 | + const [v, h] = await Promise.all([api("GET", `/api/admin/glance?dayStart=${midnight.getTime()}`), api("GET", "/api/admin/health")]); |
| 232 | + const logs = v.logs; |
| 233 | + const read = logs.state === "read"; |
| 234 | + $("tiles").replaceChildren( |
| 235 | + tile("Signups today", v.signups.today, `${v.signups.hour} in the last hour · ${v.signups.week} this week · ${v.signups.total} in all`), |
| 236 | + tile("Computers awake now", read ? logs.computersAwakeNow : "–", read ? `${logs.computersAwakeToday} today · ${logs.computersAwakeWeek} this week` : "from the logs"), |
| 237 | + tile("Fragments awake now", read ? logs.fragmentsAwakeNow : "–", read ? `${logs.fragmentsAwakeToday} today` : "from the logs"), |
| 238 | + tile("Seats paid", h.seatsPaid, `${h.seatsComped} comped · ${h.orgsPaying} orgs paying`), |
| 239 | + tile("Signed in, last 24 hours", v.signedIn.day, `${v.signedIn.week} in the last 7 days`), |
| 240 | + ); |
| 241 | + $("glance-logs").textContent = |
| 242 | + logs.state === "notConfigured" ? "Computers and fragments come from the deployment's logs, which aren't configured here (observability_read)." : logs.state === "failed" ? `The logs couldn't be read: ${logs.why}` : ""; |
| 243 | + const trouble = read ? logs.trouble : []; |
| 244 | + $("trouble-head").hidden = $("trouble").hidden = trouble.length === 0; |
| 245 | + $("trouble-rows").replaceChildren(...trouble.map((t) => row([t.email || t.npub || "no one's", el("code", t.computer), TROUBLE[t.kind] || t.kind, String(t.lines)], t.npub ? openFromGlance(t.npub) : undefined))); |
| 246 | + const up = (p) => (p.computerUp == null ? "–" : p.computerUp ? "yes" : "not yet"); |
| 247 | + $("new-rows").replaceChildren(...v.newPeople.map((p) => row([p.email || p.npub, when(p.joinedAt), seatText(p.seat) || "none", p.agentAt ? when(p.agentAt) : "none", up(p)], openFromGlance(p.npub)))); |
| 248 | + $("glance-at").textContent = `Updated ${new Date(v.at).toLocaleTimeString()}; again each minute.`; |
| 249 | + say(""); |
| 250 | +} |
| 251 | + |
206 | 252 | // ---- health and the log |
207 | 253 | async function health() { |
208 | 254 | const v = await api("GET", "/api/admin/health"); |
@@ -236,12 +282,15 @@ async function log(more = false) { |
236 | 282 | } |
237 | 283 |
|
238 | 284 | // ---- tabs |
239 | | -const load = { people: () => people(), orgs: () => orgs(), trials, health, log: () => log() }; |
| 285 | +const load = { glance, people: () => people(), orgs: () => orgs(), trials, health, log: () => log() }; |
240 | 286 |
|
241 | 287 | function show(tab) { |
242 | 288 | for (const b of document.querySelectorAll("#tabs button")) b.classList.toggle("on", b.dataset.tab === tab); |
243 | 289 | for (const s of document.querySelectorAll("main > section")) s.hidden = s.id !== tab; |
244 | 290 | history.replaceState(null, "", `/admin#${tab}`); |
| 291 | + // the glance reads again each minute while it is shown, and the page is seen |
| 292 | + clearInterval(glanceTimer); |
| 293 | + glanceTimer = tab === "glance" ? setInterval(() => document.hidden || guard(glance)(), GLANCE_EVERY_MS) : null; |
245 | 294 | return guard(load[tab])(); |
246 | 295 | } |
247 | 296 |
|
@@ -276,5 +325,5 @@ document.addEventListener("DOMContentLoaded", guard(async () => { |
276 | 325 | $("people-more").addEventListener("click", guard(() => people(true))); |
277 | 326 | $("orgs-more").addEventListener("click", guard(() => orgs(true))); |
278 | 327 | $("log-more").addEventListener("click", guard(() => log(true))); |
279 | | - await show(location.hash.slice(1) in load ? location.hash.slice(1) : "people"); |
| 328 | + await show(location.hash.slice(1) in load ? location.hash.slice(1) : "glance"); |
280 | 329 | })); |
0 commit comments