Skip to content

Commit 201fb48

Browse files
authored
Merge pull request #1025 from finitecomputer/claude/admin-glance-tiles
The admin page opens on a glance: who joined, what runs, what's in trouble
2 parents 7d5dc24 + 491b76d commit 201fb48

4 files changed

Lines changed: 118 additions & 7 deletions

File tree

‎cell/shell/admin.css‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,10 @@ dl { display: grid; grid-template-columns: max-content 1fr; gap: .2rem 1rem; }
2020
dt { opacity: .7; }
2121
code, .mono { font-family: ui-monospace, monospace; font-size: .9em; }
2222
.lapsed { color: #c33; }
23+
#tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: .6rem; margin: .75rem 0; }
24+
.tile { border: 1px solid #8884; border-radius: .5rem; padding: .6rem .75rem; }
25+
.tile .label { opacity: .7; font-size: .85rem; }
26+
.tile .big { font-size: 1.8rem; font-weight: 600; line-height: 1.2; }
27+
.tile .small { opacity: .8; font-size: .85rem; }
28+
section h2 { font-size: 1rem; margin: 1rem 0 .25rem; }
29+
.quiet { opacity: .6; font-size: .85rem; }

‎cell/shell/admin.html‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
<header>
1212
<h1>fragment admin</h1>
1313
<nav id="tabs">
14-
<button data-tab="people" class="on">People</button>
14+
<button data-tab="glance" class="on">Glance</button>
15+
<button data-tab="people">People</button>
1516
<button data-tab="orgs">Orgs</button>
1617
<button data-tab="trials">Trials</button>
1718
<button data-tab="health">Health</button>
@@ -21,7 +22,16 @@ <h1>fragment admin</h1>
2122
</header>
2223
<p id="status" role="status"></p>
2324
<main>
24-
<section id="people">
25+
<section id="glance">
26+
<div id="tiles"></div>
27+
<p id="glance-logs"></p>
28+
<h2 id="trouble-head" hidden>In trouble, the last 24 hours</h2>
29+
<table id="trouble" hidden><thead><tr><th>Who</th><th>Computer</th><th>What</th><th>Lines</th></tr></thead><tbody id="trouble-rows"></tbody></table>
30+
<h2>New people</h2>
31+
<table><thead><tr><th>Email</th><th>Joined</th><th>Seat</th><th>First agent</th><th>Computer up</th></tr></thead><tbody id="new-rows"></tbody></table>
32+
<p id="glance-at" class="quiet"></p>
33+
</section>
34+
<section id="people" hidden>
2535
<form id="search"><input name="q" placeholder="email starts with…" autocomplete="off"> <button>Search</button></form>
2636
<form id="comp"><input name="email" type="email" placeholder="email" required>
2737
<select name="kind"><option value="seat_always_on">always-on seat</option><option value="seat">seat that sleeps (comps only)</option></select>

‎cell/shell/admin.js‎

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// 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
34
// of what operators did, through `/api/admin/*` with the operator's own
45
// platform session (`x-fragment-shell`, same-origin only). The page is
56
// served to an operator's session alone (anyone else gets the 404 of a
@@ -203,6 +204,51 @@ async function trials() {
203204
say(`${v.codes.length} codes`);
204205
}
205206

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+
206252
// ---- health and the log
207253
async function health() {
208254
const v = await api("GET", "/api/admin/health");
@@ -236,12 +282,15 @@ async function log(more = false) {
236282
}
237283

238284
// ---- tabs
239-
const load = { people: () => people(), orgs: () => orgs(), trials, health, log: () => log() };
285+
const load = { glance, people: () => people(), orgs: () => orgs(), trials, health, log: () => log() };
240286

241287
function show(tab) {
242288
for (const b of document.querySelectorAll("#tabs button")) b.classList.toggle("on", b.dataset.tab === tab);
243289
for (const s of document.querySelectorAll("main > section")) s.hidden = s.id !== tab;
244290
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;
245294
return guard(load[tab])();
246295
}
247296

@@ -276,5 +325,5 @@ document.addEventListener("DOMContentLoaded", guard(async () => {
276325
$("people-more").addEventListener("click", guard(() => people(true)));
277326
$("orgs-more").addEventListener("click", guard(() => orgs(true)));
278327
$("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");
280329
}));

‎crates/e2e/src/lanes/admin.rs‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,10 +233,55 @@ pub fn admin_page(s: &mut Suite, api: &Api) -> Result<()> {
233233
let session = api.sign_in("operator@e2e.test")?;
234234
let _ = api.approve(&session, &s.operator);
235235
b.set_cookie(&format!("{}/", api.base), "fragment_session", &session)?;
236+
// the glance first, its default tab: a person whose computer the logs
237+
// say is awake and won't wake (lines planted in the fake, a local run's)
238+
let glancer = api.person()?;
239+
let glancer_id = api.identity(&glancer)?;
240+
let glancer_email = Api::email_of(&glancer);
241+
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_millis() as i64;
242+
if !s.hosted() {
243+
let computer = fragment_core::computer::default_computer_of(&glancer_id);
244+
let script = crate::OBSERVABILITY_SCRIPT;
245+
s.observability.log(now - 30_000, json!({ "$workers.scriptName": script, "computer": computer, "phase": { "phase": "awake" } }));
246+
s.observability.log(now - 20_000, json!({ "$workers.scriptName": script, "computer": computer, "phase": { "phase": "failed" } }));
247+
}
236248
let page = b.open(&format!("{}/admin", api.base))?;
237-
let listed = b.until(&page, "document.querySelectorAll('#people-rows tr').length > 0 && /people/.test(document.getElementById('status').textContent)", wait);
249+
let tiles = b.until(&page, "document.querySelectorAll('#tiles .tile').length === 5 && /Updated/.test(document.getElementById('glance-at').textContent)", wait);
250+
let shown = b.eval(&page, "[...document.querySelectorAll('#tiles .tile')].map(t => t.querySelector('.label').textContent + ': ' + t.querySelector('.big').textContent)")?;
238251
let me = b.eval(&page, "document.getElementById('me').textContent")?;
239-
s.ok("an operator's session opens the admin's page: it lists people, and says who they are", listed && me == "operator@e2e.test", &me);
252+
s.ok("an operator's session opens the admin's page on its glance: five tiles, and says who they are", tiles && me == "operator@e2e.test", &shown);
253+
// the glance as an operator sees it, for review
254+
let _ = b.screenshot(&page, &s.dir("admin-page").join("glance.png"));
255+
if !s.hosted() {
256+
let day = b.eval(&page, "(() => { const d = new Date(); d.setHours(0, 0, 0, 0); return d.getTime(); })()")?;
257+
let read = api.signed(&s.operator, "GET", &format!("/api/admin/glance?dayStart={day}"), None)?;
258+
let awake = read.body["logs"]["computersAwakeNow"].as_u64().unwrap_or(0);
259+
let rows = b.until(
260+
&page,
261+
&format!(
262+
"[...document.querySelectorAll('#trouble-rows tr')].some(r => r.textContent.includes({glancer_email:?}) && r.textContent.includes(\"won't wake\")) && [...document.querySelectorAll('#new-rows tr')].some(r => r.textContent.includes({glancer_email:?}) && r.textContent.endsWith('yes'))"
263+
),
264+
wait,
265+
);
266+
let tile = |b: &mut crate::browser::Browser| b.eval(&page, "[...document.querySelectorAll('#tiles .tile')].find(t => t.querySelector('.label').textContent === 'Computers awake now').querySelector('.big').textContent");
267+
let before = tile(&mut b)?;
268+
s.ok(
269+
"its tiles are the glance's numbers, its trouble names the person whose computer won't wake, and its new people say whose computer came up",
270+
rows && before == json!(awake.to_string()),
271+
json!({ "page": shown, "glance": read.body["logs"] }),
272+
);
273+
// no cache: a computer awake since, and the glance read again
274+
s.observability.log(now, json!({ "$workers.scriptName": crate::OBSERVABILITY_SCRIPT, "computer": "computer:000000000000000000000a11", "phase": { "phase": "awake" } }));
275+
b.click(&page, "#tabs button[data-tab=glance]")?;
276+
let again = b.until(&page, &format!("[...document.querySelectorAll('#tiles .tile')].find(t => t.querySelector('.label').textContent === 'Computers awake now')?.querySelector('.big').textContent === '{}'", awake + 1), wait);
277+
s.ok("read again, the glance asks afresh: a computer awake since counts", again, tile(&mut b)?);
278+
b.eval(&page, &format!("[...document.querySelectorAll('#trouble-rows tr')].find(r => r.textContent.includes({glancer_email:?})).click()"))?;
279+
let opened = b.until(&page, &format!("!document.getElementById('people').hidden && !document.getElementById('person').hidden && document.getElementById('person').textContent.includes({glancer_email:?})"), wait);
280+
s.ok("a person in trouble opens their page", opened, b.eval(&page, "document.getElementById('person').textContent")?);
281+
}
282+
b.click(&page, "#tabs button[data-tab=people]")?;
283+
let listed = b.until(&page, "document.querySelectorAll('#people-rows tr').length > 0 && /people/.test(document.getElementById('status').textContent)", wait);
284+
s.ok("its People tab lists people", listed, b.eval(&page, "document.getElementById('status').textContent")?);
240285

241286
let email = format!("admin-page-{}@e2e.test", &Keys::generate().pubkey_hex()[..10]);
242287
b.eval(&page, &format!("(() => {{ const f = document.getElementById('comp'); f.email.value = {email:?}; f.kind.value = 'seat_always_on'; f.requestSubmit(); }})()"))?;

0 commit comments

Comments
 (0)