@adrrr/tarmac 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -116
- package/dist/fleet.js +100 -3
- package/dist/history.js +88 -0
- package/dist/limits.js +62 -0
- package/dist/map.js +10 -1
- package/dist/render.js +655 -25
- package/dist/schema.js +2 -2
- package/dist/server.js +74 -3
- package/dist/sessions.js +15 -1
- package/package.json +2 -2
package/dist/render.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
import { formatDuration } from './config.js';
|
|
10
10
|
import { buildMap, INTERACTIVE, stateOf } from './map.js';
|
|
11
11
|
import { schemaNotice } from './schema.js';
|
|
12
|
+
import { LIMIT_WINDOWS, RESET_HORIZON_MS, readLimits } from './limits.js';
|
|
13
|
+
import { accountLimits, busyOnStaleFleet } from './fleet.js';
|
|
12
14
|
/**
|
|
13
15
|
* The other thing this module renders: the plan a user consents to before install or
|
|
14
16
|
* uninstall touches their settings.json. Everything the decision rests on has to be here —
|
|
@@ -147,7 +149,7 @@ export function renderTable({ rows, health }) {
|
|
|
147
149
|
const head = ['PROJECT', 'STATE', 'CTX', 'AS OF', 'MODEL', 'EFFORT', 'COST', 'UP'];
|
|
148
150
|
const body = rows.map((r) => [
|
|
149
151
|
r.project ?? '—',
|
|
150
|
-
r
|
|
152
|
+
stateCell(r),
|
|
151
153
|
r.ctxPct === null ? `— ${r.ctxState}` : `${r.ctxPct}%`,
|
|
152
154
|
// The age of the reading, never implied to be "now".
|
|
153
155
|
r.snapshotAgeMs === null ? '—' : ahead(r) ? '— ahead' : `${age(r.snapshotAgeMs)}${r.stale ? ' !' : ''}`,
|
|
@@ -200,6 +202,21 @@ export function renderTable({ rows, health }) {
|
|
|
200
202
|
(warns.length ? '\n' + warns.join('\n') + '\n' : '') +
|
|
201
203
|
`\n${health.sessions} sessions · ${health.busy} busy · ${total}\n`);
|
|
202
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* The STATE column, out of the same verdict the page draws from.
|
|
207
|
+
*
|
|
208
|
+
* `?` is this column's word for "a status tarmac does not recognise", so it may not lead the
|
|
209
|
+
* one status tarmac knows by name and now has a state for. The reason follows the word, in
|
|
210
|
+
* the separator this renderer already uses for two facts on one line — and it is the only
|
|
211
|
+
* value here that can widen a column: it does so on a fleet that has a session blocked on a
|
|
212
|
+
* human, which is the fleet you wanted it on.
|
|
213
|
+
*/
|
|
214
|
+
function stateCell(r) {
|
|
215
|
+
const state = stateOf(r);
|
|
216
|
+
if (state === 'unknown')
|
|
217
|
+
return `?${r.status ?? ''}`;
|
|
218
|
+
return state === 'waiting' && r.waitingFor ? `waiting · ${r.waitingFor}` : state;
|
|
219
|
+
}
|
|
203
220
|
/**
|
|
204
221
|
* A snapshot dated AFTER the clock we are reading it with — a mount whose time runs ahead, an
|
|
205
222
|
* NTP correction between the write and the read. Its age is not a small number, it is not a
|
|
@@ -261,16 +278,36 @@ export function renderLive(fleet) {
|
|
|
261
278
|
if (health.unknownStatus > 0) {
|
|
262
279
|
warnings.push(`${health.unknownStatus} session(s) report a status tarmac does not know — treated as unknown, not idle.`);
|
|
263
280
|
}
|
|
264
|
-
|
|
265
|
-
|
|
281
|
+
// NOT "N readings are stale", which used to live here and was on every hour of every day
|
|
282
|
+
// (#53): a statusline is written when a terminal draws a frame, so a fleet that idles keeps
|
|
283
|
+
// yesterday's numbers and says so on every poll. The rows and the nodes date each reading
|
|
284
|
+
// themselves — a page-wide box repeating it is wallpaper, and wallpaper is what teaches a
|
|
285
|
+
// reader to skip the boxes below. What is left here is the one stale-shaped thing that is
|
|
286
|
+
// an event: `busyOnStaleFleet` (see fleet.ts for why both halves of it are needed).
|
|
287
|
+
const stalled = busyOnStaleFleet(rows);
|
|
288
|
+
if (stalled > 0) {
|
|
289
|
+
warnings.push(`Every context reading is stale, including ${stalled} session(s) busy right now — a busy session redraws its status line, so its reading should not be older than ${formatDuration(health.staleAfterMs)}. The statusline writer looks stopped rather than the fleet idle: check that the wrapper is still installed and that the snapshot directory is writable.`);
|
|
266
290
|
}
|
|
267
291
|
const skewed = rows.filter(ahead).length;
|
|
268
292
|
if (skewed > 0) {
|
|
269
293
|
warnings.push(`${skewed} reading(s) are dated in the future — ${SKEW}. They are shown undated rather than as brand new.`);
|
|
270
294
|
}
|
|
295
|
+
// Under the fleet, at a footnote's weight: two facts that are true, worth keeping, and worth
|
|
296
|
+
// nobody's alarm. The first is the legend for the marks the rows carry — a `!` whose
|
|
297
|
+
// threshold is invisible is a mark the reader cannot argue with, which is why demoting the
|
|
298
|
+
// banner above could not take the number with it. The second is a maintainer's line: it
|
|
299
|
+
// stands for every user of a released tarmac until the next release ships the fixture, so
|
|
300
|
+
// amber would mean amber forever. Both keep every word they had.
|
|
301
|
+
const notes = [];
|
|
302
|
+
// Not under the stall banner, which names the same threshold two lines up: the pair reads as
|
|
303
|
+
// the alarm followed by its own excuse, and the excuse is the reading the alarm exists to
|
|
304
|
+
// tell you not to accept.
|
|
305
|
+
if (health.stale > 0 && stalled === 0) {
|
|
306
|
+
notes.push(`Readings past the ${formatDuration(health.staleAfterMs)} freshness threshold are dated where they sit — a statusline is only written when its terminal draws a frame, so an idle session's number is "as of" its last one. Set another with --stale-after.`);
|
|
307
|
+
}
|
|
271
308
|
const schema = schemaNotice(health.schemaGuard);
|
|
272
309
|
if (schema)
|
|
273
|
-
|
|
310
|
+
notes.push(schema);
|
|
274
311
|
// Both views, every time, out of the one reading the page just asked for. The tabs are
|
|
275
312
|
// links and the shell decides which of the two is visible, so a fleet cannot be drawn as a
|
|
276
313
|
// table of one age beside a map of another.
|
|
@@ -280,16 +317,98 @@ export function renderLive(fleet) {
|
|
|
280
317
|
// on screen and read out all the same by anything going through the markup.
|
|
281
318
|
const body = rows.length === 0
|
|
282
319
|
? empty(health)
|
|
283
|
-
:
|
|
320
|
+
: // `aria-describedby` on both views, because demoting the footnote moved it BELOW every
|
|
321
|
+
// row and every node: a reader going through the markup now meets `! 3h ago` N times
|
|
322
|
+
// before anything says what threshold put it there. Sighted readers glance down; this
|
|
323
|
+
// is the same glance for anyone who cannot. The target is rendered whether or not it
|
|
324
|
+
// has anything in it, so the reference is never dangling.
|
|
325
|
+
`<div class="view view-table"><div class="wrap"><table aria-describedby="fleet-notes">
|
|
284
326
|
<thead><tr>
|
|
285
327
|
<th>Project</th><th>Session</th><th>State</th><th>Context</th><th>Model</th><th>Effort</th><th>Cost</th><th>Uptime</th>
|
|
286
328
|
</tr></thead>
|
|
287
329
|
<tbody>${rows.map(renderRow).join('')}</tbody>
|
|
288
330
|
</table></div></div>
|
|
289
|
-
<div class="view view-map">${renderMap(fleet)}</div>`;
|
|
290
|
-
return `<div
|
|
331
|
+
<div class="view view-map" role="group" aria-label="fleet map" aria-describedby="fleet-notes">${renderMap(fleet)}</div>`;
|
|
332
|
+
return `<div id="limits-src" hidden>${renderLimits(fleet)}</div>
|
|
333
|
+
<div class="meta">${health.sessions} session${health.sessions === 1 ? '' : 's'} · ${health.busy} busy · ${cost(health)} · ${esc(new Date(health.generatedAt).toISOString())}</div>
|
|
291
334
|
${warnings.map((w) => `<div class="warn">${esc(w)}</div>`).join('')}
|
|
292
|
-
${body}
|
|
335
|
+
${body}
|
|
336
|
+
<div id="fleet-notes">${notes.map((n) => `<div class="note">${esc(n)}</div>`).join('')}</div>`;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* The account's two windows, for the page's header.
|
|
340
|
+
*
|
|
341
|
+
* They are the one pair of numbers here that is not about a session: every session on the page
|
|
342
|
+
* spends from the same five-hour and seven-day allowance, so the gauges sit at the top of the
|
|
343
|
+
* page rather than on a node — and the fleet's own rule decides whose reading counts when the
|
|
344
|
+
* sessions carry the same number at different ages.
|
|
345
|
+
*
|
|
346
|
+
* Rendered into the FRAGMENT as well as into the shell, in a slot the script copies up on every
|
|
347
|
+
* swap. The header is the shell's — it has to survive a poll, the tabs and a replay — but the
|
|
348
|
+
* numbers are the fleet's, and the fleet is what the fragment carries. A gauge left in the shell
|
|
349
|
+
* alone would be as old as the tab.
|
|
350
|
+
*/
|
|
351
|
+
export function renderLimits({ rows, health }) {
|
|
352
|
+
const account = accountLimits(rows);
|
|
353
|
+
const gauges = readLimits(account === null ? null : account.rateLimits, health.generatedAt)
|
|
354
|
+
.map(gauge)
|
|
355
|
+
.join('');
|
|
356
|
+
// Dated when the snapshot behind it is past the threshold, exactly as the table dates a stale
|
|
357
|
+
// context. It matters more here than anywhere else on the page: the percentage is as old as
|
|
358
|
+
// that snapshot, while the countdown beside it is recomputed on every five-second re-render —
|
|
359
|
+
// so an undated pair puts a frozen number next to a visibly moving one and lets the reader
|
|
360
|
+
// assume both are now.
|
|
361
|
+
//
|
|
362
|
+
// Once for the two, not once each: both windows come out of the SAME snapshot, and the same
|
|
363
|
+
// fact said twice is noise. The replay has no equivalent — the ring keeps each reading and
|
|
364
|
+
// never how old it was, which is why nothing replayed on this page is dated.
|
|
365
|
+
const stale = account !== null && account.ageMs > health.staleAfterMs;
|
|
366
|
+
return gauges + (stale ? `<span class="stale">! ${esc(asOfAge(account.ageMs))} ago</span>` : '');
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* One window. Four things in a line: which window it is, a bar for the glance, the number that
|
|
370
|
+
* is authoritative, and how long is left. The bar is `aria-hidden` because it says nothing the
|
|
371
|
+
* number does not, and the abbreviation is replaced rather than doubled for a reader who hears
|
|
372
|
+
* the page — "5h" is a label on a screen and a syllable in an ear.
|
|
373
|
+
*/
|
|
374
|
+
function gauge(g) {
|
|
375
|
+
// No fill, ever, for a window nobody read: an empty bar is what an account at 0% wears, and
|
|
376
|
+
// "I could not look" must not be able to wear it. The same dotted emptiness as an unmeasured
|
|
377
|
+
// dial, in the shape a bar has.
|
|
378
|
+
const rail = g.pct === null
|
|
379
|
+
? `<span class="rail unmeasured" aria-hidden="true"></span>`
|
|
380
|
+
: `<span class="rail" aria-hidden="true"><i style="width:${g.pct}%"></i></span>`;
|
|
381
|
+
return (`<div class="gauge"><span class="lbl" aria-hidden="true">${g.label}</span><span class="sr">${g.said}</span>` +
|
|
382
|
+
`${rail}<span class="num">${g.pct === null ? dash() : `${g.pct}%`}</span>` +
|
|
383
|
+
`<span class="reset">${g.pct === null ? LIMIT_WHY[g.why] : resetWords(g.resetsInMs)}</span></div>`);
|
|
384
|
+
}
|
|
385
|
+
/** Which kind of missing a missing window is, in the two words both surfaces use. */
|
|
386
|
+
const LIMIT_WHY = { absent: 'no reading', drift: 'schema drift' };
|
|
387
|
+
/**
|
|
388
|
+
* The reset, as a stretch of time rather than as the epoch the payload carries.
|
|
389
|
+
*
|
|
390
|
+
* A negative one is not a countdown to be printed with a minus sign: the window rolled over
|
|
391
|
+
* after the reading that reported it, so the percentage beside these words belongs to a window
|
|
392
|
+
* that no longer exists. Saying that is the whole point of showing a reset at all.
|
|
393
|
+
*/
|
|
394
|
+
const resetWords = (ms) => ms === null ? `reset ${dash()}` : ms > 0 ? `resets in ${left(ms)}` : `reset was due ${left(-ms)} ago`;
|
|
395
|
+
/**
|
|
396
|
+
* How long, in the two units that matter at each scale. Deliberately finer than `duration()`
|
|
397
|
+
* next door, which floors a session's uptime to whole hours: five hours is a window someone
|
|
398
|
+
* plans the next hour around, and "resets in 2h" said anywhere between 2h00 and 2h59 is the
|
|
399
|
+
* kind of rounding that makes a reader stop believing the number.
|
|
400
|
+
*/
|
|
401
|
+
function left(ms) {
|
|
402
|
+
const m = Math.floor(ms / 60000);
|
|
403
|
+
if (m < 1)
|
|
404
|
+
return '<1m';
|
|
405
|
+
if (m < 60)
|
|
406
|
+
return `${m}m`;
|
|
407
|
+
const h = Math.floor(m / 60);
|
|
408
|
+
if (h < 24)
|
|
409
|
+
return m % 60 === 0 ? `${h}h` : `${h}h ${m % 60}m`;
|
|
410
|
+
const d = Math.floor(h / 24);
|
|
411
|
+
return h % 24 === 0 ? `${d}d` : `${d}d ${h % 24}h`;
|
|
293
412
|
}
|
|
294
413
|
/**
|
|
295
414
|
* What to say instead of a fleet. Discovery returning entries we could not identify is not
|
|
@@ -346,14 +465,20 @@ function ago(ms) {
|
|
|
346
465
|
return m < 60 ? `${m}m` : `${Math.round(m / 60)}h`;
|
|
347
466
|
}
|
|
348
467
|
export function renderPage(fleet, view = 'table') {
|
|
468
|
+
// The header's copy. `renderLive` below renders its own, out of this same fleet and through
|
|
469
|
+
// this same function — two calls of one pure renderer over one reading, which is what keeps
|
|
470
|
+
// the pair the reader sees and the pair the script will copy up from being two accounts.
|
|
471
|
+
const gauges = renderLimits(fleet);
|
|
349
472
|
return `<!doctype html>
|
|
350
473
|
<html lang="en"><head>
|
|
351
474
|
<meta charset="utf-8">
|
|
352
475
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
353
476
|
<title>tarmac — fleet</title>
|
|
354
477
|
<style>
|
|
355
|
-
|
|
356
|
-
|
|
478
|
+
/* --wait is a fourth hue rather than the warning one: a session blocked on a human is not
|
|
479
|
+
a fault, and painting it amber puts it in the same column as "tarmac cannot read this". */
|
|
480
|
+
:root { color-scheme: light dark; --fg:#111; --dim:#6b7280; --line:#e5e7eb; --bg:#fff; --warn:#b45309; --warnbg:#fffbeb; --busy:#047857; --wait:#1d4ed8; }
|
|
481
|
+
@media (prefers-color-scheme: dark) { :root { --fg:#e5e7eb; --dim:#9ca3af; --line:#374151; --bg:#0b0f14; --warn:#fbbf24; --warnbg:#231a06; --busy:#34d399; --wait:#93c5fd; } }
|
|
357
482
|
body { margin:0; padding:2rem 1.25rem; background:var(--bg); color:var(--fg);
|
|
358
483
|
font:14px/1.5 ui-sans-serif,-apple-system,"Segoe UI",sans-serif; }
|
|
359
484
|
header { display:flex; align-items:baseline; gap:1rem; flex-wrap:wrap; margin-bottom:1rem; }
|
|
@@ -364,6 +489,11 @@ export function renderPage(fleet, view = 'table') {
|
|
|
364
489
|
.warn { background:var(--warnbg); color:var(--warn); border:1px solid currentColor; border-radius:6px;
|
|
365
490
|
padding:.35rem .65rem; margin:.3rem 0; font-size:.8rem; line-height:1.45; }
|
|
366
491
|
.warn:last-of-type { margin-bottom:.9rem; }
|
|
492
|
+
/* The footnote: same words, none of the weight. Dim, small, below the fleet and with no box
|
|
493
|
+
around it, because what it carries is true rather than urgent — the threshold that dated a
|
|
494
|
+
reading, the payload shapes nobody has captured yet. It reads as chrome to someone
|
|
495
|
+
scanning their sessions and as an answer to someone who came looking for it. */
|
|
496
|
+
.note { color:var(--dim); font-size:.75rem; line-height:1.5; margin:.9rem 0 0; max-width:95ch; }
|
|
367
497
|
.stale { color:var(--warn); font-weight:600; }
|
|
368
498
|
.wrap { overflow-x:auto; }
|
|
369
499
|
table { border-collapse:collapse; width:100%; min-width:44rem; }
|
|
@@ -377,11 +507,14 @@ export function renderPage(fleet, view = 'table') {
|
|
|
377
507
|
.pill { display:inline-block; font-size:.8rem; font-weight:600; padding:.05rem .5rem;
|
|
378
508
|
border:1px solid currentColor; border-radius:99px; white-space:nowrap; }
|
|
379
509
|
.pill.busy { color:var(--busy); }
|
|
510
|
+
.pill.waiting { color:var(--wait); }
|
|
380
511
|
.pill.unknown { color:var(--warn); }
|
|
381
512
|
.pill.idle { color:var(--dim); font-weight:400; }
|
|
382
|
-
/*
|
|
513
|
+
/* Accented states carry their own hue down the row edge. Bold stays on busy alone — it
|
|
514
|
+
says "working", not "read me first"; waiting's weight is the top of the sort. */
|
|
383
515
|
td:first-child { border-left:3px solid transparent; }
|
|
384
516
|
tr[data-state="busy"] td:first-child { border-left-color:var(--busy); }
|
|
517
|
+
tr[data-state="waiting"] td:first-child { border-left-color:var(--wait); }
|
|
385
518
|
tr[data-state="unknown"] td:first-child { border-left-color:var(--warn); }
|
|
386
519
|
tr[data-state="busy"] .project { font-weight:700; }
|
|
387
520
|
/* The bar reads a magnitude at a glance; the number beside it is what is authoritative.
|
|
@@ -405,6 +538,72 @@ export function renderPage(fleet, view = 'table') {
|
|
|
405
538
|
body[data-view="table"] .view-map { display:none; }
|
|
406
539
|
body[data-view="map"] .view-table { display:none; }
|
|
407
540
|
|
|
541
|
+
/* ── the account's two windows ───────────────────────────────────────────────────────
|
|
542
|
+
In the header, because a rate limit is the account's and not a node's. Slim on purpose:
|
|
543
|
+
the fleet is what the page is about, and these two numbers are the weather it flies in.
|
|
544
|
+
Laid out with flex behind the same :not([hidden]) guard the replay containers carry —
|
|
545
|
+
the replayed pair ships hidden, and a display in a stylesheet beats the attribute. */
|
|
546
|
+
.limits:not([hidden]) { display:flex; gap:1rem; flex-wrap:wrap; align-items:center; }
|
|
547
|
+
.gauge { display:flex; align-items:baseline; gap:.35rem; font-size:.8rem; }
|
|
548
|
+
/* Not upper-cased, alone among the small labels on this page: "5H" is not an hour, and a
|
|
549
|
+
unit that has been shouted reads as a different unit. */
|
|
550
|
+
.gauge .lbl { color:var(--dim); font-weight:600; letter-spacing:.04em; }
|
|
551
|
+
.gauge .num { font-variant-numeric:tabular-nums; font-weight:650; }
|
|
552
|
+
.gauge .reset { color:var(--dim); }
|
|
553
|
+
/* Same bargain as the row bars: a glance at a magnitude, in the quiet ink of a secondary
|
|
554
|
+
fact, beside the number that is the authority. Its own class rather than .bar — that one
|
|
555
|
+
is dropped below 46rem, where a card layout gives every value the name of its column, and
|
|
556
|
+
these two have no column to be named by. */
|
|
557
|
+
.gauge .rail { display:inline-block; width:3.5rem; height:.3rem; border-radius:99px;
|
|
558
|
+
background:var(--line); align-self:center; }
|
|
559
|
+
.gauge .rail > i { display:block; height:100%; border-radius:99px; background:var(--dim); }
|
|
560
|
+
/* Nothing was measured — the dotted track of an unmeasured dial, in the shape of a bar. An
|
|
561
|
+
empty rail is what an account at 0% wears, and the two must not match. */
|
|
562
|
+
.gauge .rail.unmeasured { background:repeating-linear-gradient(90deg,var(--line) 0 2px,transparent 2px 8px); }
|
|
563
|
+
/* The live pair goes down with the live fragment: they are about now, and left up they would
|
|
564
|
+
be the one present-tense number standing over a fleet three hours old. */
|
|
565
|
+
body.replaying #limits { display:none; }
|
|
566
|
+
/* The replayed pair leads the past fleet rather than sitting on top of its totals. */
|
|
567
|
+
#replay-limits { margin-bottom:.2rem; }
|
|
568
|
+
|
|
569
|
+
/* ── the scrubber ────────────────────────────────────────────────────────────────────
|
|
570
|
+
Under the map, and only under the map: the record holds what the MAP draws, so a
|
|
571
|
+
scrubber over the table would offer a drag onto rows it cannot fill.
|
|
572
|
+
Everything here lives in the shell for the same reason the tabs do: the /live fragment is
|
|
573
|
+
swapped into innerHTML every five seconds, and a handle inside it would be dragged back
|
|
574
|
+
to the present by a poll nobody asked for. */
|
|
575
|
+
body[data-view="table"] #replay, body[data-view="table"] #replay-view { display:none; }
|
|
576
|
+
/* One fleet at a time, and the whole fragment rather than only its map: the fragment's
|
|
577
|
+
header is the LIVE count, cost and timestamp, and hiding the map alone left it sitting
|
|
578
|
+
directly above the replayed one — two totals of two different moments, the pair dated
|
|
579
|
+
with the present. The warnings above them are about the present too. The failure banner
|
|
580
|
+
is in the shell, so a refresh that breaks mid-replay still says so. */
|
|
581
|
+
body.replaying #live { display:none; }
|
|
582
|
+
/* Both of these are laid out with flex, and both are hidden by the attribute until a script
|
|
583
|
+
raises them — so the display is refused to a hidden one explicitly. The hidden attribute
|
|
584
|
+
is only a UA rule of display:none, and any display a stylesheet gives the same element
|
|
585
|
+
beats it: unguarded, this page came up announcing a replay nobody had asked for. */
|
|
586
|
+
.replay:not([hidden]) { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; margin-top:1rem;
|
|
587
|
+
padding-top:.7rem; border-top:1px solid var(--line); }
|
|
588
|
+
.replay button { font:inherit; font-size:.8rem; color:var(--fg); background:transparent;
|
|
589
|
+
border:1px solid var(--line); border-radius:99px; padding:.15rem .8rem; cursor:pointer; }
|
|
590
|
+
.replay input[type="range"] { flex:1; min-width:10rem; accent-color:var(--dim); }
|
|
591
|
+
.replay input[type="range"]:disabled { opacity:.4; }
|
|
592
|
+
/* The two things the reader has to be able to read while dragging: the minute under the
|
|
593
|
+
handle, and what the whole range covers. Tabular, so neither jitters as it counts. */
|
|
594
|
+
#replay-at { font-variant-numeric:tabular-nums; font-weight:600; }
|
|
595
|
+
.replay .covers { flex-basis:100%; color:var(--dim); font-size:.75rem; }
|
|
596
|
+
/* The banner wears the warning style on purpose: a page showing a past minute as though it were the
|
|
597
|
+
fleet is the worst thing this dashboard could do, so it wears the loudest thing it has. */
|
|
598
|
+
/* Sticky, because the handle is at the bottom of a map that can be taller than the
|
|
599
|
+
viewport: a reader dragging with the "this is the past" banner scrolled off the top is
|
|
600
|
+
a reader the banner is not warning. */
|
|
601
|
+
.replaying-note:not([hidden]) { display:flex; align-items:baseline; gap:.6rem; flex-wrap:wrap;
|
|
602
|
+
position:sticky; top:0; z-index:1; }
|
|
603
|
+
.replaying-note button { font:inherit; font-size:.75rem; font-weight:600; color:inherit;
|
|
604
|
+
background:transparent; border:1px solid currentColor; border-radius:99px;
|
|
605
|
+
padding:.05rem .7rem; cursor:pointer; }
|
|
606
|
+
|
|
408
607
|
/* ── the map ─────────────────────────────────────────────────────────────────────────
|
|
409
608
|
One node per session. The arc is the context, its weight is how much that reading may
|
|
410
609
|
be believed, and the halo — the only thing on this page that moves — says a frame
|
|
@@ -413,6 +612,7 @@ export function renderPage(fleet, view = 'table') {
|
|
|
413
612
|
.node { border:1px solid var(--line); border-radius:10px; padding:.8rem .85rem .7rem;
|
|
414
613
|
display:flex; flex-direction:column; align-items:center; text-align:center; }
|
|
415
614
|
.node[data-state="busy"] { border-color:color-mix(in srgb, var(--busy) 45%, var(--line)); }
|
|
615
|
+
.node[data-state="waiting"] { border-color:color-mix(in srgb, var(--wait) 45%, var(--line)); }
|
|
416
616
|
/* An agent is a smaller body in the same system, next to the session it shares a directory
|
|
417
617
|
with — never inside it. Tinted rather than outlined, and hooked, so it reads as the
|
|
418
618
|
session's dependent without a line claiming a parentage the source never published. */
|
|
@@ -439,15 +639,28 @@ export function renderPage(fleet, view = 'table') {
|
|
|
439
639
|
stroke-dasharray, which is what carries the percentage. */
|
|
440
640
|
.node[data-reading="stale"] .arc, .node[data-reading="undated"] .arc {
|
|
441
641
|
stroke:var(--warn); stroke-width:2.5; opacity:.7; }
|
|
642
|
+
/* A replayed reading. Its EXTENT is what the record vouches for; its age is the one thing
|
|
643
|
+
the ring never kept, so it may not wear the solid arc that means "as current as a reading
|
|
644
|
+
gets" — nor the warning hue of a stale one, which would claim the opposite. Between the
|
|
645
|
+
two: full colour, a shade lighter, and no date underneath it. */
|
|
646
|
+
.node[data-reading="undatable"] .arc { stroke-width:4; opacity:.85; }
|
|
442
647
|
/* Nothing was measured. Keyed on the measurement and never on the age of the file: a
|
|
443
648
|
solid empty ring is what a session measured at 0% wears, and the two must not match. */
|
|
444
649
|
.track.unmeasured { stroke-dasharray:2 6; stroke-linecap:round; }
|
|
445
650
|
/* Once per arrival, not forever: the fragment is replaced on every poll, so a single run
|
|
446
651
|
per swap is what makes the fleet breathe at the rate its frames actually land. A looping
|
|
447
652
|
animation would say "a frame just arrived" for five seconds after it stopped being true. */
|
|
448
|
-
|
|
653
|
+
/* What the halo says is that a frame landed, and it says it by being there at all. Its
|
|
654
|
+
COLOUR is free, and it was spending it on a claim: stroked with the busy hue under a lone
|
|
655
|
+
idle override, it pulsed green over an unrecognised status and — since the fourth state — over
|
|
656
|
+
a session halted on a human, in the hue of the one thing it is certainly not doing. Same
|
|
657
|
+
palette as the glyph under the name, off the same four states, so the two channels drawing
|
|
658
|
+
one node cannot end up disagreeing about it. */
|
|
659
|
+
.halo { fill:none; stroke:var(--dim); stroke-width:2; opacity:0; transform-origin:50% 50%;
|
|
449
660
|
animation:halo 1.6s ease-out 1; }
|
|
450
|
-
.node[data-state="
|
|
661
|
+
.node[data-state="busy"] .halo { stroke:var(--busy); }
|
|
662
|
+
.node[data-state="waiting"] .halo { stroke:var(--wait); }
|
|
663
|
+
.node[data-state="unknown"] .halo { stroke:var(--warn); }
|
|
451
664
|
@keyframes halo { from { opacity:.5; transform:scale(1); } to { opacity:0; transform:scale(1.22); } }
|
|
452
665
|
/* Motion is the one thing here nobody can look away from, so it is the first thing a
|
|
453
666
|
reader who asked for less of it stops getting. The reading is still readable without it. */
|
|
@@ -465,8 +678,12 @@ export function renderPage(fleet, view = 'table') {
|
|
|
465
678
|
.node[data-state="busy"] .who .project { font-weight:700; }
|
|
466
679
|
.shape { font-size:.7rem; color:var(--dim); }
|
|
467
680
|
.node[data-state="busy"] .shape { color:var(--busy); }
|
|
681
|
+
.node[data-state="waiting"] .shape { color:var(--wait); }
|
|
468
682
|
.node[data-state="unknown"] .shape { color:var(--warn); }
|
|
469
683
|
.sub { color:var(--dim); font-size:.76rem; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
684
|
+
/* The one caption that is not a footnote: it is why this node is not working, and it sits
|
|
685
|
+
directly under the name in the state's own hue rather than in the grey of the rest. */
|
|
686
|
+
.sub.waiting-for { color:var(--wait); font-weight:600; }
|
|
470
687
|
.asof { font-size:.72rem; color:var(--dim); font-variant-numeric:tabular-nums; margin-top:.15rem; }
|
|
471
688
|
.asof.stale { color:var(--warn); font-weight:600; }
|
|
472
689
|
@media (max-width: 30rem) { .map { grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr)); gap:.6rem; } }
|
|
@@ -483,6 +700,7 @@ export function renderPage(fleet, view = 'table') {
|
|
|
483
700
|
tr { border:1px solid var(--line); border-left-width:3px; border-radius:8px;
|
|
484
701
|
padding:.35rem .7rem; margin-bottom:.6rem; }
|
|
485
702
|
tr[data-state="busy"] { border-left-color:var(--busy); }
|
|
703
|
+
tr[data-state="waiting"] { border-left-color:var(--wait); }
|
|
486
704
|
tr[data-state="unknown"] { border-left-color:var(--warn); }
|
|
487
705
|
td, td:first-child { border:0; padding:.2rem 0; white-space:normal;
|
|
488
706
|
display:flex; justify-content:space-between; align-items:baseline; gap:1rem; }
|
|
@@ -503,6 +721,11 @@ export function renderPage(fleet, view = 'table') {
|
|
|
503
721
|
<a href="/"${view === 'table' ? ' aria-current="page"' : ''}>Table</a>
|
|
504
722
|
<a href="/map"${view === 'map' ? ' aria-current="page"' : ''}>Map</a>
|
|
505
723
|
</nav>
|
|
724
|
+
<!-- The account's two windows, page-level because that is what they are: a limit belongs to
|
|
725
|
+
the account every session below is spending from, not to any one of them. Their VALUES
|
|
726
|
+
come up from the fragment on every poll (the script's limits-src copy), so the header
|
|
727
|
+
structure can be the shell's without the numbers being as old as the tab. -->
|
|
728
|
+
<div class="limits" id="limits" role="group" aria-label="account rate limits">${gauges}</div>
|
|
506
729
|
<!-- Not "updated just now". If the script never runs — a policy-injected CSP without
|
|
507
730
|
'unsafe-inline', a script error — that text would stand as a permanent lie, and
|
|
508
731
|
<noscript> would not fire to correct it because JavaScript is enabled. The page's one
|
|
@@ -510,12 +733,42 @@ export function renderPage(fleet, view = 'table') {
|
|
|
510
733
|
<span class="freshness"><span class="pulse" aria-hidden="true"></span><span id="age">updated —</span></span>
|
|
511
734
|
</header>
|
|
512
735
|
<div class="warn offline" id="offline" hidden>
|
|
513
|
-
<strong>⚠ refresh failing</strong> — nothing
|
|
736
|
+
<strong>⚠ refresh failing</strong> — nothing on this page has moved since the time in the header.
|
|
514
737
|
<span id="why"></span>
|
|
515
738
|
</div>
|
|
739
|
+
<!-- The one claim on this page that could be a lie, so it is the loudest element on it and it
|
|
740
|
+
carries the minute it is showing. Hidden until a script raises it: with no script there
|
|
741
|
+
is no replay, and a banner about one would be a warning about nothing. -->
|
|
742
|
+
<!-- role="status" because it appears without a reload and without focus moving: drawn only,
|
|
743
|
+
it is the page's loudest claim and its most invisible one. -->
|
|
744
|
+
<div class="warn replaying-note" id="replaying" role="status" hidden>
|
|
745
|
+
<strong>⏹ replaying <span id="replay-at"></span></strong>
|
|
746
|
+
<span>— a reading from the past, not the fleet now.</span>
|
|
747
|
+
<button type="button" id="to-live">Back to live</button>
|
|
748
|
+
</div>
|
|
516
749
|
<noscript><div class="warn">JavaScript is off, so this page will not refresh itself. Reload it to see the fleet now.</div></noscript>
|
|
517
750
|
<div id="live">${renderLive(fleet)}</div>
|
|
518
|
-
|
|
751
|
+
<!-- Where the past is drawn: the shell's own map, in the place the live one occupies, so
|
|
752
|
+
that swapping the fragment underneath cannot repaint what the reader is scrubbing. -->
|
|
753
|
+
<div id="replay-view" hidden>
|
|
754
|
+
<!-- The same pair, for the minute under the reader's hand — and here rather than in the
|
|
755
|
+
header, where the live pair sits. The banner that says "this is the past" is below the
|
|
756
|
+
header: an account drawn above it would be the one past number on the page with nothing
|
|
757
|
+
over it saying so, and the first thing a screen reader reaches, long before the warning.
|
|
758
|
+
Hidden until a script raises it: with no script there is no replay, and an empty gauge
|
|
759
|
+
would be a claim about nothing. -->
|
|
760
|
+
<div class="limits" id="replay-limits" role="group" aria-label="account rate limits, at the minute being replayed" hidden></div>
|
|
761
|
+
<div class="meta" id="replay-meta"></div>
|
|
762
|
+
<div class="map" id="replay-map"></div>
|
|
763
|
+
</div>
|
|
764
|
+
<!-- A dead handle is worse than no handle: this is revealed once the record is in hand, and
|
|
765
|
+
what it says it covers is whatever the record answered with. -->
|
|
766
|
+
<div class="replay" id="replay" hidden>
|
|
767
|
+
<button type="button" id="play">Play</button>
|
|
768
|
+
<input type="range" id="scrub" min="0" max="0" step="1" value="0" disabled aria-label="Replay position">
|
|
769
|
+
<div class="covers" id="covers"></div>
|
|
770
|
+
</div>
|
|
771
|
+
<script>${pageScript(view)}</script>
|
|
519
772
|
</body></html>
|
|
520
773
|
`;
|
|
521
774
|
}
|
|
@@ -531,9 +784,15 @@ export function renderPage(fleet, view = 'table') {
|
|
|
531
784
|
* polled for a reader who is not there. A poll is the only one of the three where the client
|
|
532
785
|
* decides — so a hidden tab simply stops asking, and a waking one asks at once.
|
|
533
786
|
*
|
|
534
|
-
* The page therefore owns exactly two facts: when it last heard from the
|
|
535
|
-
* the last attempt failed. Everything a reader interprets
|
|
536
|
-
* server, where the suite can reach it.
|
|
787
|
+
* The page therefore owns exactly two facts about the present: when it last heard from the
|
|
788
|
+
* server, and whether the last attempt failed. Everything a reader interprets about NOW is
|
|
789
|
+
* rendered by `renderLive` on the server, where the suite can reach it.
|
|
790
|
+
*
|
|
791
|
+
* The replay below is the one exception, and it is one the issue asks for: scrubbing a day
|
|
792
|
+
* has to be a lookup in samples the page already holds, or every pixel of a drag would be a
|
|
793
|
+
* request and a `claude agents --json` behind it. So a second, smaller renderer lives in the
|
|
794
|
+
* browser — fed the same three words, the same three glyphs and the same dial geometry as the
|
|
795
|
+
* server's, by interpolation rather than by copy, and executed by `test/replay-script`.
|
|
537
796
|
*/
|
|
538
797
|
export const REFRESH_MS = 5000;
|
|
539
798
|
/**
|
|
@@ -542,10 +801,30 @@ export const REFRESH_MS = 5000;
|
|
|
542
801
|
* on the server side first and arrives with a real reason instead of this generic one.
|
|
543
802
|
*/
|
|
544
803
|
const STALL_MS = 20000;
|
|
545
|
-
|
|
804
|
+
/**
|
|
805
|
+
* How fast play walks the record — one reading per step, so a serve that has seen ten minutes
|
|
806
|
+
* plays for a second and a full day for two and a half minutes. It is a step interval and not
|
|
807
|
+
* a total duration on purpose: the samples are not evenly spaced (a minute the collector
|
|
808
|
+
* missed is a minute nobody recorded), so a fixed run time would silently speed up over the
|
|
809
|
+
* gaps and make the day look busier than it was.
|
|
810
|
+
*/
|
|
811
|
+
const PLAY_STEP_MS = 100;
|
|
812
|
+
/**
|
|
813
|
+
* The same walk for a reader who asked their system for less motion. Play is the one thing
|
|
814
|
+
* here that moves, and the honest answer to that preference is not to take the feature away —
|
|
815
|
+
* it is to stop flickering ten frames a second at someone who said that hurts.
|
|
816
|
+
*/
|
|
817
|
+
const PLAY_STEP_CALM_MS = 1000;
|
|
818
|
+
/**
|
|
819
|
+
* A function, not a constant: it reads the vocabulary and the geometry declared below it, and
|
|
820
|
+
* it takes the view because only one of the two has a scrubber to feed.
|
|
821
|
+
*/
|
|
822
|
+
function pageScript(view) {
|
|
823
|
+
return `
|
|
546
824
|
(function () {
|
|
547
825
|
var live = document.getElementById('live'), age = document.getElementById('age');
|
|
548
826
|
var off = document.getElementById('offline'), why = document.getElementById('why');
|
|
827
|
+
var limits = document.getElementById('limits');
|
|
549
828
|
var last = Date.now(), failing = false, inFlight = false, since = 0, gen = 0;
|
|
550
829
|
|
|
551
830
|
function ago(ms) {
|
|
@@ -607,6 +886,15 @@ const SCRIPT = `
|
|
|
607
886
|
if (body.trim() === '') throw new Error('The server answered with an empty page.');
|
|
608
887
|
if (!mineStill()) return;
|
|
609
888
|
live.innerHTML = body;
|
|
889
|
+
// The account's gauges, lifted out of the fragment and into the header where they
|
|
890
|
+
// belong. Here rather than in the fragment's own place on the page because a limit is
|
|
891
|
+
// the account's and not a session's; here rather than in the shell alone because the
|
|
892
|
+
// NUMBERS arrive with the fleet, and a five-hour window that stopped counting down
|
|
893
|
+
// would be the one thing on this page still claiming to be about now.
|
|
894
|
+
// Inside the accepted-answer branch on purpose: a body that was refused is a body
|
|
895
|
+
// nothing is read out of, the account's numbers included.
|
|
896
|
+
var src = document.getElementById('limits-src');
|
|
897
|
+
if (src) limits.innerHTML = src.innerHTML;
|
|
610
898
|
last = Date.now();
|
|
611
899
|
failing = false;
|
|
612
900
|
});
|
|
@@ -625,15 +913,345 @@ const SCRIPT = `
|
|
|
625
913
|
});
|
|
626
914
|
}
|
|
627
915
|
|
|
916
|
+
// ── the day behind the present ──────────────────────────────────────────────────────
|
|
917
|
+
//
|
|
918
|
+
// The record is asked for once, and every drag after that is a lookup in it. The state of
|
|
919
|
+
// the replay lives here rather than in the fragment for the same reason the tabs do: /live
|
|
920
|
+
// is swapped wholesale every five seconds, and the reader's hand is not the server's to move.
|
|
921
|
+
|
|
922
|
+
var replay = document.getElementById('replay'), scrub = document.getElementById('scrub');
|
|
923
|
+
var playBtn = document.getElementById('play'), covers = document.getElementById('covers');
|
|
924
|
+
var rview = document.getElementById('replay-view'), rmap = document.getElementById('replay-map');
|
|
925
|
+
var rmeta = document.getElementById('replay-meta'), note = document.getElementById('replaying');
|
|
926
|
+
var rlimits = document.getElementById('replay-limits');
|
|
927
|
+
var atEl = document.getElementById('replay-at'), toLive = document.getElementById('to-live');
|
|
928
|
+
var record = null, recordAt = 0, at = -1, replaying = false, playing = null, hgen = 0;
|
|
929
|
+
|
|
930
|
+
// The vocabulary and the geometry, handed over rather than written twice: three words for
|
|
931
|
+
// the three kinds of missing, three glyphs for the three states, one dial radius.
|
|
932
|
+
var WHY = ${JSON.stringify(CTX_WHY)}, SHAPE = ${JSON.stringify(SHAPE)};
|
|
933
|
+
var INTERACTIVE = ${JSON.stringify(INTERACTIVE)};
|
|
934
|
+
var ENT = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
935
|
+
var R = ${DIAL_R}, C = 2 * Math.PI * R;
|
|
936
|
+
var STEP = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
937
|
+
? ${PLAY_STEP_CALM_MS} : ${PLAY_STEP_MS};
|
|
938
|
+
|
|
939
|
+
function esc(v) {
|
|
940
|
+
if (v === null || v === undefined || v === '') return '<span class="dim">—</span>';
|
|
941
|
+
return String(v).replace(/[&<>"']/g, function (c) { return ENT[c]; });
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** A lookup that cannot be answered by Object.prototype. */
|
|
945
|
+
function own(table, key) {
|
|
946
|
+
return Object.prototype.hasOwnProperty.call(table, key);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function two(n) { return (n < 10 ? '0' : '') + n; }
|
|
950
|
+
function hhmm(t) { var d = new Date(t); return two(d.getHours()) + ':' + two(d.getMinutes()); }
|
|
951
|
+
|
|
952
|
+
// A day-long ring can straddle one midnight, and "09:14 – 08:59" reads as a span running
|
|
953
|
+
// backwards until the older edge says which day it is.
|
|
954
|
+
function edge(t, ref) {
|
|
955
|
+
return hhmm(t) + (new Date(t).getDate() === new Date(ref).getDate() ? '' : ' yesterday');
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// What the range covers, in the record's own terms. Never "a day": that is the size of the
|
|
959
|
+
// ring, and a serve ten minutes old has seen ten minutes.
|
|
960
|
+
function coversText() {
|
|
961
|
+
var n = record.samples.length;
|
|
962
|
+
if (n === 0) {
|
|
963
|
+
// A record empty because every reading FAILED is not a record that has just started, and
|
|
964
|
+
// this was the one branch that threw that away: ten hours of a collector that could not
|
|
965
|
+
// run read exactly like a serve thirty seconds old.
|
|
966
|
+
return record.missed
|
|
967
|
+
? 'Nothing recorded — this serve started at ' + hhmm(record.since) + ' and '
|
|
968
|
+
+ record.missed + ' minute' + (record.missed === 1 ? '' : 's') + ' were due and never read.'
|
|
969
|
+
: 'Nothing recorded yet — this serve started at ' + hhmm(record.since)
|
|
970
|
+
+ ' and takes a reading every ' + Math.round(record.cadence / 1000) + 's.';
|
|
971
|
+
}
|
|
972
|
+
var last = record.samples[n - 1].t;
|
|
973
|
+
return 'Covering ' + edge(record.since, last) + ' – ' + hhmm(last)
|
|
974
|
+
// Not "when the page loaded": the record is asked for again when a tab that has been
|
|
975
|
+
// away comes back, so the sentence names the last time this page asked rather than a
|
|
976
|
+
// moment it may be hours past.
|
|
977
|
+
+ ', as this page last had it — ' + n + ' reading' + (n === 1 ? '' : 's')
|
|
978
|
+
// A gap that says it is a gap is not a gap. The handle steps through readings, not
|
|
979
|
+
// through minutes, and a record with holes in it is not a smooth walk.
|
|
980
|
+
+ (record.missed ? ', ' + record.missed + ' minute' + (record.missed === 1 ? '' : 's') + ' with no reading' : '')
|
|
981
|
+
+ '. The record keeps each reading, not how old that reading was, so nothing replayed here is dated.';
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function ready() {
|
|
985
|
+
var n = record.samples.length;
|
|
986
|
+
replay.hidden = false;
|
|
987
|
+
covers.textContent = coversText();
|
|
988
|
+
scrub.max = String(n === 0 ? 0 : n - 1);
|
|
989
|
+
scrub.disabled = n === 0;
|
|
990
|
+
playBtn.disabled = n === 0;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// Revealed, not hidden, when the record cannot be had: a scrubber that silently never
|
|
994
|
+
// appears is indistinguishable from one this build does not have.
|
|
995
|
+
function noRecord(said) {
|
|
996
|
+
replay.hidden = false;
|
|
997
|
+
scrub.disabled = true;
|
|
998
|
+
playBtn.disabled = true;
|
|
999
|
+
covers.textContent = said;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function load() {
|
|
1003
|
+
// The same generation guard the fleet poll carries, for the same reason and one more.
|
|
1004
|
+
// The replaying flag is read when the tab regains focus; the answer lands later, and
|
|
1005
|
+
// a reader's hand can arrive in between — so the question is asked AGAIN at the moment of
|
|
1006
|
+
// the swap. Without it the record was replaced under a live scrub: the handle pointing at
|
|
1007
|
+
// one minute, the map drawing another, out of a record that no longer existed.
|
|
1008
|
+
var mine = ++hgen;
|
|
1009
|
+
return fetch('/api/history', { cache: 'no-store' }).then(function (res) {
|
|
1010
|
+
// The same refusal the fragment makes, for the same reason: what comes back is parsed
|
|
1011
|
+
// and drawn into this page, and loopback proves where bytes came from, not who wrote them.
|
|
1012
|
+
if (!res.headers.get('X-Tarmac')) throw new Error('The answer on this port did not come from tarmac.');
|
|
1013
|
+
return res.text().then(function (body) {
|
|
1014
|
+
if (!res.ok) throw new Error(body.split('\\n').filter(Boolean).join(' ').slice(0, 200));
|
|
1015
|
+
var got = JSON.parse(body);
|
|
1016
|
+
if (!got || !got.samples) throw new Error('the record came back in a shape this page does not know');
|
|
1017
|
+
if (mine !== hgen || replaying) return;
|
|
1018
|
+
record = got;
|
|
1019
|
+
recordAt = Date.now();
|
|
1020
|
+
ready();
|
|
1021
|
+
});
|
|
1022
|
+
}).catch(function (e) {
|
|
1023
|
+
if (mine !== hgen) return;
|
|
1024
|
+
// A refresh is not a first load. Failing one is no reason to take away a record the page
|
|
1025
|
+
// is already holding — and saying "the record could not be read" over one the reader is
|
|
1026
|
+
// scrubbing would be false. The fleet poll's own banner already says the server is quiet.
|
|
1027
|
+
if (record !== null) return;
|
|
1028
|
+
noRecord('The record could not be read — ' + String((e && e.message) || e).slice(0, 200));
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// One node, out of what the ring holds and nothing more. No name, for any kind of session:
|
|
1033
|
+
// a background session is named after the prompt it was given, and the ring stores none.
|
|
1034
|
+
function nodeOf(x, anchored) {
|
|
1035
|
+
// The map's own rule, in the map's own words: an absent kind is not evidence of an agent,
|
|
1036
|
+
// and a fleet where nothing calls itself interactive is a fleet whose source moved.
|
|
1037
|
+
var role = !anchored || x.kind === null || x.kind === undefined || x.kind === INTERACTIVE ? 'session' : 'agent';
|
|
1038
|
+
// Own keys only. A bare read inherits from Object.prototype, so "constructor" and
|
|
1039
|
+
// "toString" passed this guard and reached the markup below — into an attribute
|
|
1040
|
+
// unescaped, and into the glyph slot as a function body.
|
|
1041
|
+
var state = own(SHAPE, x.state) ? x.state : 'unknown';
|
|
1042
|
+
var pct = typeof x.ctxPct === 'number' ? x.ctxPct : null;
|
|
1043
|
+
// The ring keeps each reading and never how old that reading was, so the arc weight that
|
|
1044
|
+
// says how much a reading may be believed cannot be earned here. It is not the live
|
|
1045
|
+
// default either: this third value is de-weighted in the stylesheet, and never the warning
|
|
1046
|
+
// hue, which would claim the opposite — that the reading is known to be old.
|
|
1047
|
+
return '<article class="node" data-role="' + role + '" data-state="' + state + '" data-reading="undatable">'
|
|
1048
|
+
// No halo, ever. It means a frame landed moments ago, which is never true of a sample.
|
|
1049
|
+
+ '<div class="dial"><svg viewBox="0 0 80 80" aria-hidden="true">'
|
|
1050
|
+
+ '<circle class="track' + (pct === null ? ' unmeasured' : '') + '" cx="40" cy="40" r="' + R + '"/>'
|
|
1051
|
+
+ (pct === null ? '' : arcOf(pct))
|
|
1052
|
+
+ '</svg><div class="val">'
|
|
1053
|
+
+ (pct === null
|
|
1054
|
+
? '<span class="why"><b>—</b>' + esc(own(WHY, x.ctxState) ? WHY[x.ctxState] : 'no reading') + '</span>'
|
|
1055
|
+
: '<span class="pct">' + pct + '<i>%</i></span>')
|
|
1056
|
+
+ '</div></div>'
|
|
1057
|
+
+ '<div class="who"><span class="shape" aria-hidden="true">' + SHAPE[state] + '</span>'
|
|
1058
|
+
+ '<span class="sr">' + state + '</span>'
|
|
1059
|
+
+ '<span class="project">' + esc(x.project) + '</span></div>'
|
|
1060
|
+
// The one caption the ring can fill. Guarded on the state as well as on the field: a
|
|
1061
|
+
// reason left over beside another state is not a session waiting for anything, and esc()
|
|
1062
|
+
// answers an absent field with a dash, which would caption a node "waiting for —".
|
|
1063
|
+
+ (state === 'waiting' && x.waitingFor ? '<div class="sub waiting-for">' + esc(x.waitingFor) + '</div>' : '')
|
|
1064
|
+
+ (x.kind === null || x.kind === undefined || x.kind === INTERACTIVE ? '' : '<div class="sub">' + esc(x.kind) + '</div>')
|
|
1065
|
+
+ (typeof x.costUsd === 'number' ? '<div class="sub">$' + x.costUsd.toFixed(2) + '</div>' : '')
|
|
1066
|
+
+ '</article>';
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// The server's own arithmetic, off the server's own radius: a fraction of the real
|
|
1070
|
+
// circumference, never pathLength, so a browser that ignores it cannot close every ring
|
|
1071
|
+
// into a full context window.
|
|
1072
|
+
function arcOf(pct) {
|
|
1073
|
+
var filled = (Math.min(100, Math.max(0, pct)) / 100) * C;
|
|
1074
|
+
var r2 = function (n) { return Math.round(n * 100) / 100; };
|
|
1075
|
+
return '<circle class="arc" cx="40" cy="40" r="' + R + '" transform="rotate(-90 40 40)"'
|
|
1076
|
+
+ ' stroke-dasharray="' + r2(filled) + ' ' + r2(C - filled) + '"/>';
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// ── the account, as it stood that minute ────────────────────────────────────────────
|
|
1080
|
+
//
|
|
1081
|
+
// The second thing this page interprets twice, and for the same reason as the dials: a
|
|
1082
|
+
// replay is a lookup in samples the page already holds, and the ring holds the payload's own
|
|
1083
|
+
// rate_limits rather than anything rendered. The vocabulary, the dash and the two windows are
|
|
1084
|
+
// handed over below rather than written again; what is mirrored is the arithmetic, and a
|
|
1085
|
+
// test compares this output with the server's character for character.
|
|
1086
|
+
//
|
|
1087
|
+
// What it counts the reset against is the SAMPLE's own clock, never Date.now(). A reset is a
|
|
1088
|
+
// moment, and "how long is left" is a question about the minute being replayed: at 09:14 the
|
|
1089
|
+
// five-hour window had two hours to run, and it had two hours to run whatever time it is now.
|
|
1090
|
+
// Counted against the present, every reset in the record would read as long overdue the
|
|
1091
|
+
// moment it aged past — a page announcing an account over its limit for a day that ended.
|
|
1092
|
+
var LIMITS = ${JSON.stringify(LIMIT_WINDOWS)}, LIMIT_WHY = ${JSON.stringify(LIMIT_WHY)};
|
|
1093
|
+
var DASH = ${JSON.stringify(dash())};
|
|
1094
|
+
|
|
1095
|
+
function left(ms) {
|
|
1096
|
+
var m = Math.floor(ms / 60000);
|
|
1097
|
+
if (m < 1) return '<1m';
|
|
1098
|
+
if (m < 60) return m + 'm';
|
|
1099
|
+
var h = Math.floor(m / 60);
|
|
1100
|
+
if (h < 24) return m % 60 === 0 ? h + 'h' : h + 'h ' + (m % 60) + 'm';
|
|
1101
|
+
var d = Math.floor(h / 24);
|
|
1102
|
+
return h % 24 === 0 ? d + 'd' : d + 'd ' + (h % 24) + 'h';
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function gaugesOf(rl, now) {
|
|
1106
|
+
// Anything can be in a sample: rate_limits is a shape someone else versions, and the ring
|
|
1107
|
+
// stored whatever the payload had. None of it may throw in the header of a dashboard.
|
|
1108
|
+
var ok = rl !== null && rl !== undefined && typeof rl === 'object' && !Array.isArray(rl);
|
|
1109
|
+
var html = '';
|
|
1110
|
+
for (var i = 0; i < LIMITS.length; i++) {
|
|
1111
|
+
var w = ok ? rl[LIMITS[i].key] : undefined;
|
|
1112
|
+
var has = w !== null && w !== undefined && typeof w === 'object' && !Array.isArray(w) && 'used_percentage' in w;
|
|
1113
|
+
var v = has ? w.used_percentage : undefined;
|
|
1114
|
+
var pct = has && typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 100 ? Math.floor(v) : null;
|
|
1115
|
+
var at = has && typeof w.resets_at === 'number' && Number.isFinite(w.resets_at) ? w.resets_at : null;
|
|
1116
|
+
var ms = at === null ? null : at * 1000 - now;
|
|
1117
|
+
// The server's horizon, off the server's own number: a reset further from the reading than
|
|
1118
|
+
// the longest window can be is not this account's reset, whatever it says.
|
|
1119
|
+
if (ms !== null && Math.abs(ms) > ${RESET_HORIZON_MS}) ms = null;
|
|
1120
|
+
// Presence, never value: a window that is there and null is a number not taken yet, and
|
|
1121
|
+
// one that is gone is a schema that moved. Same discriminant as everywhere else here.
|
|
1122
|
+
// Read off rl and NOT off ok: rate_limits carrying something that is not a pair of
|
|
1123
|
+
// windows — an array, which the snapshot reader lets through — is a schema that moved,
|
|
1124
|
+
// not an account nobody measured. Written as !ok, this said the opposite of the server
|
|
1125
|
+
// about the very same minute.
|
|
1126
|
+
var why = pct !== null ? null : (rl === null || rl === undefined || (has && v === null)) ? 'absent' : 'drift';
|
|
1127
|
+
html += '<div class="gauge"><span class="lbl" aria-hidden="true">' + LIMITS[i].label + '</span>'
|
|
1128
|
+
+ '<span class="sr">' + LIMITS[i].said + '</span>'
|
|
1129
|
+
+ (pct === null
|
|
1130
|
+
? '<span class="rail unmeasured" aria-hidden="true"></span>'
|
|
1131
|
+
: '<span class="rail" aria-hidden="true"><i style="width:' + pct + '%"></i></span>')
|
|
1132
|
+
+ '<span class="num">' + (pct === null ? DASH : pct + '%') + '</span>'
|
|
1133
|
+
+ '<span class="reset">'
|
|
1134
|
+
+ (pct === null
|
|
1135
|
+
? LIMIT_WHY[why]
|
|
1136
|
+
: ms === null
|
|
1137
|
+
? 'reset ' + DASH
|
|
1138
|
+
: ms > 0 ? 'resets in ' + left(ms) : 'reset was due ' + left(-ms) + ' ago')
|
|
1139
|
+
+ '</span></div>';
|
|
1140
|
+
}
|
|
1141
|
+
return html;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function nodesOf(s) {
|
|
1145
|
+
var anchored = false, html = '', i;
|
|
1146
|
+
for (i = 0; i < s.sessions.length; i++) if (s.sessions[i].kind === INTERACTIVE) anchored = true;
|
|
1147
|
+
// In the order the sample carries. The live map places an agent beside the session it
|
|
1148
|
+
// shares a directory with; the ring holds no directory, so the past is drawn in the order
|
|
1149
|
+
// the fleet was sorted in rather than in a grouping this page would have to invent.
|
|
1150
|
+
for (i = 0; i < s.sessions.length; i++) html += nodeOf(s.sessions[i], anchored);
|
|
1151
|
+
return html;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// The fleet of that minute, counted from that minute. A partial sum is never presented as
|
|
1155
|
+
// the total, the same rule the live header follows.
|
|
1156
|
+
function metaOf(s) {
|
|
1157
|
+
var n = s.sessions.length, busy = 0, cost = 0, reporting = 0;
|
|
1158
|
+
for (var i = 0; i < n; i++) {
|
|
1159
|
+
if (s.sessions[i].state === 'busy') busy++;
|
|
1160
|
+
if (typeof s.sessions[i].costUsd === 'number') { cost += s.sessions[i].costUsd; reporting++; }
|
|
1161
|
+
}
|
|
1162
|
+
return n + ' session' + (n === 1 ? '' : 's') + ' · ' + busy + ' busy · '
|
|
1163
|
+
+ (reporting === 0 ? 'cost —'
|
|
1164
|
+
: '$' + cost.toFixed(2) + (reporting < n ? ' (' + reporting + '/' + n + ' reporting cost)' : ''));
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function draw(i) {
|
|
1168
|
+
var s = record && record.samples[i];
|
|
1169
|
+
if (!s) return;
|
|
1170
|
+
at = i;
|
|
1171
|
+
replaying = true;
|
|
1172
|
+
scrub.value = String(i);
|
|
1173
|
+
// The handle's own value is an index, so a reader who cannot see the banner would be read
|
|
1174
|
+
// "3" while the fleet on screen is three hours old. The minute travels with the handle.
|
|
1175
|
+
scrub.setAttribute('aria-valuetext', hhmm(s.t));
|
|
1176
|
+
atEl.textContent = hhmm(s.t);
|
|
1177
|
+
rmeta.textContent = metaOf(s);
|
|
1178
|
+
rmap.innerHTML = nodesOf(s);
|
|
1179
|
+
// The account of that minute, in the place the live pair occupies — which the body class
|
|
1180
|
+
// has just taken down. One allowance on screen at a time, and it is the one belonging to
|
|
1181
|
+
// the fleet being shown.
|
|
1182
|
+
rlimits.innerHTML = gaugesOf(s.rateLimits, s.t);
|
|
1183
|
+
rlimits.hidden = false;
|
|
1184
|
+
note.hidden = false;
|
|
1185
|
+
rview.hidden = false;
|
|
1186
|
+
document.body.classList.toggle('replaying', true);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function stopPlay() {
|
|
1190
|
+
if (playing) { clearInterval(playing); playing = null; }
|
|
1191
|
+
playBtn.textContent = 'Play';
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Back to now, in one gesture, with nothing of the past left behind a hidden attribute.
|
|
1195
|
+
// The position is NOT reset: the handle stays where the reader let go of it, so what it
|
|
1196
|
+
// shows and where play would pick up are the same place.
|
|
1197
|
+
function present() {
|
|
1198
|
+
stopPlay();
|
|
1199
|
+
replaying = false;
|
|
1200
|
+
note.hidden = true;
|
|
1201
|
+
rview.hidden = true;
|
|
1202
|
+
rlimits.hidden = true;
|
|
1203
|
+
rlimits.innerHTML = '';
|
|
1204
|
+
rmap.innerHTML = '';
|
|
1205
|
+
scrub.removeAttribute('aria-valuetext');
|
|
1206
|
+
document.body.classList.toggle('replaying', false);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function play() {
|
|
1210
|
+
if (playing) { stopPlay(); return; }
|
|
1211
|
+
if (!record || record.samples.length === 0) return;
|
|
1212
|
+
// From the top when there is nothing to resume: a play button that ends where it started
|
|
1213
|
+
// has played nothing.
|
|
1214
|
+
draw(at < 0 || at >= record.samples.length - 1 ? 0 : at);
|
|
1215
|
+
playBtn.textContent = 'Pause';
|
|
1216
|
+
playing = setInterval(function () {
|
|
1217
|
+
// It stops at the end rather than looping back: a day that restarts on its own is a
|
|
1218
|
+
// day whose beginning and end are impossible to tell apart.
|
|
1219
|
+
if (at >= record.samples.length - 1) { stopPlay(); return; }
|
|
1220
|
+
draw(at + 1);
|
|
1221
|
+
}, STEP);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
scrub.addEventListener('input', function () { stopPlay(); draw(Number(scrub.value)); });
|
|
1225
|
+
playBtn.addEventListener('click', play);
|
|
1226
|
+
toLive.addEventListener('click', present);
|
|
1227
|
+
|
|
628
1228
|
setInterval(tick, 1000);
|
|
629
1229
|
setInterval(function () { if (!document.hidden) poll(); }, ${REFRESH_MS});
|
|
630
|
-
document.addEventListener('visibilitychange', function () {
|
|
1230
|
+
document.addEventListener('visibilitychange', function () {
|
|
1231
|
+
if (document.hidden) return;
|
|
1232
|
+
poll();
|
|
1233
|
+
// The record was answered once, at load. A tab left alone all afternoon holds a record
|
|
1234
|
+
// that stops where the reader's attention did — so it is asked again on the way back in.
|
|
1235
|
+
// Never while a reader is scrubbing (the record under their hand is not ours to swap, and
|
|
1236
|
+
// the load asks that question again when the answer lands), and never for one younger than
|
|
1237
|
+
// a single slot. A record that could never be read at all is not retried here: it stays
|
|
1238
|
+
// null, the guard holds, and the reader has a sentence saying so rather than a page
|
|
1239
|
+
// quietly trying again forever.
|
|
1240
|
+
if (!replaying && record !== null && Date.now() - recordAt >= record.cadence) load();
|
|
1241
|
+
});
|
|
1242
|
+
// Only where there is a scrubber to feed. The table view hides these controls in CSS, and a
|
|
1243
|
+
// full ring is megabytes of session ids, projects and costs: fetching and parsing it to
|
|
1244
|
+
// write a sentence into an element with display:none is a cost paid on every load of the
|
|
1245
|
+
// page most people open first, for a control they cannot see.
|
|
1246
|
+
if (${view === 'map'}) load();
|
|
631
1247
|
})();
|
|
632
1248
|
`;
|
|
1249
|
+
}
|
|
633
1250
|
/**
|
|
634
|
-
* The sort puts
|
|
635
|
-
*
|
|
636
|
-
* for the ones that are
|
|
1251
|
+
* The sort puts waiting first — the one row that is work for the reader — then busy, then
|
|
1252
|
+
* unknown, idle last. This is where that order is given its weight — an accent down the row
|
|
1253
|
+
* in the state's own hue, a bold name for the ones that are working, a quiet row for the
|
|
1254
|
+
* ones that are not.
|
|
637
1255
|
*
|
|
638
1256
|
* The state travels three ways at once: a shape, a word, and an attribute. Colour alone is
|
|
639
1257
|
* no signal to a reader who cannot separate two of ours, and `data-state` is what the narrow
|
|
@@ -641,7 +1259,7 @@ const SCRIPT = `
|
|
|
641
1259
|
*/
|
|
642
1260
|
function renderRow(r) {
|
|
643
1261
|
const state = stateOf(r);
|
|
644
|
-
const word =
|
|
1262
|
+
const word = stateLabel(state, r);
|
|
645
1263
|
// `data-label` is not decoration: below ~46rem the columns stack, the header row is gone,
|
|
646
1264
|
// and a value whose column has no name is a bare "—" that could mean anything.
|
|
647
1265
|
// Every cell holds exactly ONE element. Stacked on a phone the label sits left and the
|
|
@@ -658,7 +1276,7 @@ function renderRow(r) {
|
|
|
658
1276
|
<td data-label="Uptime" class="num dim"><span class="v">${r.uptimeMs === null ? dash() : esc(duration(r.uptimeMs))}</span></td>
|
|
659
1277
|
</tr>`;
|
|
660
1278
|
}
|
|
661
|
-
const SHAPE = { busy: '●', unknown: '▲', idle: '○' };
|
|
1279
|
+
const SHAPE = { busy: '●', waiting: '◐', unknown: '▲', idle: '○' };
|
|
662
1280
|
/**
|
|
663
1281
|
* The state in words, for both surfaces — derived from the state the MODEL decided, never
|
|
664
1282
|
* from the row a second time. Two expressions for one fact on one element is how a node ends
|
|
@@ -668,6 +1286,13 @@ const SHAPE = { busy: '●', unknown: '▲', idle: '○' };
|
|
|
668
1286
|
* of keeping it is that someone reading the page can go and find out what `compacting` means.
|
|
669
1287
|
*/
|
|
670
1288
|
const stateWord = (state, r) => state === 'unknown' ? (r.status ?? 'unknown') : state;
|
|
1289
|
+
/**
|
|
1290
|
+
* The same word with the reason attached, for the table — which has one cell per session and
|
|
1291
|
+
* no room for a caption of its own. The map keeps them apart instead: the word is what a
|
|
1292
|
+
* screen reader is handed in place of the glyph, and repeating the reason there would read it
|
|
1293
|
+
* twice, once hidden and once out of the caption below it.
|
|
1294
|
+
*/
|
|
1295
|
+
const stateLabel = (state, r) => state === 'waiting' && r.waitingFor ? `${stateWord(state, r)} · ${r.waitingFor}` : stateWord(state, r);
|
|
671
1296
|
/**
|
|
672
1297
|
* Which kind of missing a missing percentage is. One lookup for both surfaces: the table
|
|
673
1298
|
* says it beside a dash, the map says it inside an empty dial, and a second copy of these
|
|
@@ -697,6 +1322,10 @@ export function renderMap(fleet) {
|
|
|
697
1322
|
* dial is no reading at all, the shape beside the name is the session's state, and the halo
|
|
698
1323
|
* says one landed moments ago. The words under them are the same ones the table uses for the
|
|
699
1324
|
* same conditions — including the halo's, which would otherwise live only in a drawing.
|
|
1325
|
+
*
|
|
1326
|
+
* One state brings a caption with it. A waiting session is the only one where the shape
|
|
1327
|
+
* leaves a question the source can answer — which human answer it is halted on — and it is
|
|
1328
|
+
* printed directly under the name, not hidden in a title attribute nobody hovers on a phone.
|
|
700
1329
|
*/
|
|
701
1330
|
function renderNode({ row: r, role, state, reading, measured, pulse }) {
|
|
702
1331
|
// The model owns "is there a number"; this reads its verdict rather than asking the row a
|
|
@@ -722,6 +1351,7 @@ function renderNode({ row: r, role, state, reading, measured, pulse }) {
|
|
|
722
1351
|
${pulse ? `<span class="sr">a reading just landed</span>` : ''}
|
|
723
1352
|
</div>
|
|
724
1353
|
<div class="who"><span class="shape" aria-hidden="true">${SHAPE[state]}</span><span class="sr">${esc(stateWord(state, r))}</span><span class="project">${esc(r.project)}</span></div>
|
|
1354
|
+
${state === 'waiting' && r.waitingFor ? `<div class="sub waiting-for">${esc(r.waitingFor)}</div>` : ''}
|
|
725
1355
|
<div class="sub">${esc(r.name)}</div>
|
|
726
1356
|
${r.kind === null || r.kind === INTERACTIVE ? '' : `<div class="sub">${esc(r.kind)}</div>`}
|
|
727
1357
|
<div class="sub">${esc(r.model)}${r.effort === null ? '' : ` · ${esc(r.effort)}`}</div>
|