@adrrr/tarmac 0.4.1 → 0.6.0
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 +86 -63
- package/dist/args.js +11 -3
- package/dist/cli.js +11 -6
- package/dist/config.js +61 -1
- package/dist/fleet.js +70 -11
- package/dist/history.js +1 -1
- package/dist/limits.js +77 -7
- package/dist/map.js +37 -30
- package/dist/render.js +533 -63
- package/dist/server.js +48 -6
- package/package.json +1 -1
package/dist/render.js
CHANGED
|
@@ -137,6 +137,12 @@ export function renderSettings(config, configFile) {
|
|
|
137
137
|
['port', String(config.port.value), config.port.source],
|
|
138
138
|
['snapshots', config.snapshotsDir.value, config.snapshotsDir.source],
|
|
139
139
|
];
|
|
140
|
+
// Only when there are any. An empty list is what every other run has, chosen by nobody —
|
|
141
|
+
// a `(default)` line saying "none" on every serve is noise, and this line has to read as
|
|
142
|
+
// what it is: the one setting that widened who may read this port.
|
|
143
|
+
if (config.trustHosts.value.length > 0) {
|
|
144
|
+
rows.push(['trusted', config.trustHosts.value.join(', '), config.trustHosts.source]);
|
|
145
|
+
}
|
|
140
146
|
// Only the LABEL column is padded. Padding the values aligned the sources against the
|
|
141
147
|
// snapshots path, which is absolute — pushing the one word that says where a value came
|
|
142
148
|
// from past the edge of an 80-column terminal.
|
|
@@ -157,7 +163,7 @@ export function renderTable({ rows, health }) {
|
|
|
157
163
|
r.effort ?? '—',
|
|
158
164
|
r.costUsd === null ? '—' : `$${r.costUsd.toFixed(2)}`,
|
|
159
165
|
r.uptimeMs === null ? '—' : `${Math.round(r.uptimeMs / 3600000)}h`,
|
|
160
|
-
]);
|
|
166
|
+
].map(clip));
|
|
161
167
|
const w = head.map((h, i) => Math.max(h.length, ...body.map((r) => r[i].length)));
|
|
162
168
|
const line = (cells) => cells.map((c, i) => c.padEnd(w[i])).join(' ').trimEnd();
|
|
163
169
|
const warns = [];
|
|
@@ -192,6 +198,11 @@ export function renderTable({ rows, health }) {
|
|
|
192
198
|
warns.push(`! ${skewed} reading(s) are dated in the future — ${SKEW}`);
|
|
193
199
|
if (health.unknownStatus > 0)
|
|
194
200
|
warns.push(`! ${health.unknownStatus} session(s) report an unknown status`);
|
|
201
|
+
const account = accountLimits(rows, health.generatedAt);
|
|
202
|
+
const gauges = readLimits(account === null ? null : account.rateLimits, health.generatedAt);
|
|
203
|
+
const split = accountSplit(account, gauges);
|
|
204
|
+
if (split)
|
|
205
|
+
warns.push(`! ${split}`);
|
|
195
206
|
// Last, and never instead of anything above: this one is a heads-up, not a fault.
|
|
196
207
|
const schema = schemaNotice(health.schemaGuard);
|
|
197
208
|
if (schema)
|
|
@@ -200,7 +211,86 @@ export function renderTable({ rows, health }) {
|
|
|
200
211
|
return ([line(head), ...body.map(line)].join('\n') +
|
|
201
212
|
'\n' +
|
|
202
213
|
(warns.length ? '\n' + warns.join('\n') + '\n' : '') +
|
|
203
|
-
`\n${health.sessions} sessions · ${health.busy} busy · ${total}\n`);
|
|
214
|
+
`\n${health.sessions} sessions · ${health.busy} busy · ${total}\n${accountLine(gauges, account, health)}\n`);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The account's two windows, under the fleet rather than in a column.
|
|
218
|
+
*
|
|
219
|
+
* They are the one pair of numbers in this table that is not about a session: every row above
|
|
220
|
+
* spends from the same five-hour and seven-day allowance, so a column of them would be the
|
|
221
|
+
* same two numbers printed once per session. Under the totals, where the other fleet-wide
|
|
222
|
+
* facts are.
|
|
223
|
+
*
|
|
224
|
+
* Dated like every reading here, and always: the AS OF column exists because a percentage is
|
|
225
|
+
* as old as the frame that wrote it, and this one has no column to be dated by. The `!` is the
|
|
226
|
+
* same mark, past the same threshold, explained by the same warning above.
|
|
227
|
+
*/
|
|
228
|
+
function accountLine(gauges, account, health) {
|
|
229
|
+
const windows = gauges
|
|
230
|
+
.map((g) => `${g.label} ${g.pct === null ? `— ${LIMIT_WHY[g.why]}` : `${g.pct}% ${resetWords(g.resetsInMs, '—')}`}`)
|
|
231
|
+
.join(' · ');
|
|
232
|
+
// A reading is dated; no reading is not. The two states read alike in the windows above —
|
|
233
|
+
// `— no reading` is what a payload with no rate limits and a fleet with no snapshot at all
|
|
234
|
+
// both come to — and the age is what tells them apart: a snapshot that said nothing carries
|
|
235
|
+
// the moment it said it, and a fleet nothing was read for has no such moment to print.
|
|
236
|
+
const as = account === null ? '' : ` · as of ${age(account.ageMs)}${account.ageMs > health.staleAfterMs ? ' !' : ''}`;
|
|
237
|
+
return `account ${windows}${as}`;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* What to say when the readings behind that line are not all about the same windows, and
|
|
241
|
+
* `null` on the ordinary fleet, where they are.
|
|
242
|
+
*
|
|
243
|
+
* One warning for both surfaces to be written from: the account is the ONE number here picked
|
|
244
|
+
* out of several that could have been it, and a picked winner presented as the fleet's account
|
|
245
|
+
* is exactly what a fleet signed into two logins at once would look like. The count is what the
|
|
246
|
+
* reader needs in order to go and look; WHY two windows were open at the same time is published
|
|
247
|
+
* nowhere tarmac reads, so it is not guessed.
|
|
248
|
+
*
|
|
249
|
+
* Only windows that are drawn as a number, because this sentence qualifies one: a window the
|
|
250
|
+
* surface prints as `— schema drift` has nothing for "the freshest is shown" to be true of, and
|
|
251
|
+
* a warning derived from a field the line under it has just called unreadable is a warning about
|
|
252
|
+
* the wrong thing. When that leaves nothing to name, there is nothing to say.
|
|
253
|
+
*/
|
|
254
|
+
function accountSplit(account, gauges) {
|
|
255
|
+
if (account === null || account.apart === 0)
|
|
256
|
+
return null;
|
|
257
|
+
const drawn = new Set(gauges.filter((g) => g.pct !== null).map((g) => g.key));
|
|
258
|
+
const labels = LIMIT_WINDOWS.filter((w) => account.apartWindows.includes(w.key) && drawn.has(w.key)).map((w) => w.label);
|
|
259
|
+
if (labels.length === 0)
|
|
260
|
+
return null;
|
|
261
|
+
const which = `the ${labels.join(' and ')} window${labels.length === 1 ? '' : 's'}`;
|
|
262
|
+
// Said the long way round on purpose: "1 of 4 readings names" and "2 of 4 readings name" are
|
|
263
|
+
// two sentences, and a count that has to agree with a verb is a count someone will get wrong.
|
|
264
|
+
return `${which} ${labels.length === 1 ? 'is' : 'are'} read differently by ${account.apart} of ${account.readings} readings — the freshest is shown`;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* How wide a column may get, `null` for one nothing can stretch.
|
|
268
|
+
*
|
|
269
|
+
* Four of the eight carry a string this tool did not choose the length of — a directory
|
|
270
|
+
* basename, a status word tarmac does not know or the free text a `waiting` session gives, a
|
|
271
|
+
* model name, an effort — and one long value in any of them used to push every row of the
|
|
272
|
+
* table past 190 columns, on a terminal that wraps at 80. The caps are picked so that the
|
|
273
|
+
* worst fleet a source can hand this renderer stays within 120 CODE POINTS a row — display
|
|
274
|
+
* width is the wider, separate question (#80): a CJK glyph is one point and two columns, and
|
|
275
|
+
* the width math below counts `.length` like it always has. The page has CSS to wrap with, a
|
|
276
|
+
* terminal has nothing. The other four are a percentage, an age, a cost and an hour count;
|
|
277
|
+
* their own magnitude is what bounds them, and no cap here would ever bite.
|
|
278
|
+
*/
|
|
279
|
+
// STATE is the widest of the four because it is two facts on one line: the state, and the
|
|
280
|
+
// reason a `waiting` session gives for being in it. `waiting · permission prompt` — the
|
|
281
|
+
// reason the fleet's own suite is written around — is 27 of these 28 columns.
|
|
282
|
+
const CAPS = [20, 28, null, null, 16, 8, null, null];
|
|
283
|
+
/**
|
|
284
|
+
* One cell, cut to its column. The ellipsis is spent out of the cap rather than added past
|
|
285
|
+
* it — a cap a cut cell can exceed is not a cap — and the cut is by code point, because half
|
|
286
|
+
* a surrogate pair is not a shorter name, it is a broken one.
|
|
287
|
+
*/
|
|
288
|
+
function clip(cell, i) {
|
|
289
|
+
const cap = CAPS[i];
|
|
290
|
+
if (cap === null)
|
|
291
|
+
return cell;
|
|
292
|
+
const chars = Array.from(cell);
|
|
293
|
+
return chars.length <= cap ? cell : chars.slice(0, cap - 1).join('') + '…';
|
|
204
294
|
}
|
|
205
295
|
/**
|
|
206
296
|
* The STATE column, out of the same verdict the page draws from.
|
|
@@ -330,7 +420,7 @@ export function renderLive(fleet) {
|
|
|
330
420
|
</table></div></div>
|
|
331
421
|
<div class="view view-map" role="group" aria-label="fleet map" aria-describedby="fleet-notes">${renderMap(fleet)}</div>`;
|
|
332
422
|
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>
|
|
423
|
+
<div class="meta">${health.sessions} session${health.sessions === 1 ? '' : 's'} · ${health.busy} busy · ${cost(health)}<span class="stamp"> · ${esc(new Date(health.generatedAt).toISOString())}</span></div>
|
|
334
424
|
${warnings.map((w) => `<div class="warn">${esc(w)}</div>`).join('')}
|
|
335
425
|
${body}
|
|
336
426
|
<div id="fleet-notes">${notes.map((n) => `<div class="note">${esc(n)}</div>`).join('')}</div>`;
|
|
@@ -349,10 +439,9 @@ ${body}
|
|
|
349
439
|
* alone would be as old as the tab.
|
|
350
440
|
*/
|
|
351
441
|
export function renderLimits({ rows, health }) {
|
|
352
|
-
const account = accountLimits(rows);
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
.join('');
|
|
442
|
+
const account = accountLimits(rows, health.generatedAt);
|
|
443
|
+
const read = readLimits(account === null ? null : account.rateLimits, health.generatedAt);
|
|
444
|
+
const gauges = read.map(gauge).join('');
|
|
356
445
|
// Dated when the snapshot behind it is past the threshold, exactly as the table dates a stale
|
|
357
446
|
// context. It matters more here than anywhere else on the page: the percentage is as old as
|
|
358
447
|
// that snapshot, while the countdown beside it is recomputed on every five-second re-render —
|
|
@@ -363,7 +452,15 @@ export function renderLimits({ rows, health }) {
|
|
|
363
452
|
// fact said twice is noise. The replay has no equivalent — the ring keeps each reading and
|
|
364
453
|
// never how old it was, which is why nothing replayed on this page is dated.
|
|
365
454
|
const stale = account !== null && account.ageMs > health.staleAfterMs;
|
|
366
|
-
|
|
455
|
+
// The other thing that can be wrong with this pair, and the one the age cannot say: the
|
|
456
|
+
// number was picked out of several readings, and they were not all about the same window.
|
|
457
|
+
// Beside the number rather than in a box below the fleet, because what it qualifies is the
|
|
458
|
+
// number — and it says the whole sentence, since a mark whose reason is elsewhere is a mark
|
|
459
|
+
// the reader cannot argue with.
|
|
460
|
+
const split = accountSplit(account, read);
|
|
461
|
+
return (gauges +
|
|
462
|
+
(stale ? `<span class="stale">! ${esc(asOfAge(account.ageMs))} ago</span>` : '') +
|
|
463
|
+
(split === null ? '' : `<span class="mixed">! ${esc(split)}</span>`));
|
|
367
464
|
}
|
|
368
465
|
/**
|
|
369
466
|
* One window. Four things in a line: which window it is, a bar for the glance, the number that
|
|
@@ -380,7 +477,7 @@ function gauge(g) {
|
|
|
380
477
|
: `<span class="rail" aria-hidden="true"><i style="width:${g.pct}%"></i></span>`;
|
|
381
478
|
return (`<div class="gauge"><span class="lbl" aria-hidden="true">${g.label}</span><span class="sr">${g.said}</span>` +
|
|
382
479
|
`${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>`);
|
|
480
|
+
`<span class="reset">${g.pct === null ? LIMIT_WHY[g.why] : resetWords(g.resetsInMs, dash())}</span></div>`);
|
|
384
481
|
}
|
|
385
482
|
/** Which kind of missing a missing window is, in the two words both surfaces use. */
|
|
386
483
|
const LIMIT_WHY = { absent: 'no reading', drift: 'schema drift' };
|
|
@@ -391,7 +488,7 @@ const LIMIT_WHY = { absent: 'no reading', drift: 'schema drift' };
|
|
|
391
488
|
* after the reading that reported it, so the percentage beside these words belongs to a window
|
|
392
489
|
* that no longer exists. Saying that is the whole point of showing a reset at all.
|
|
393
490
|
*/
|
|
394
|
-
const resetWords = (ms) => ms === null ? `reset ${
|
|
491
|
+
const resetWords = (ms, none) => ms === null ? `reset ${none}` : ms > 0 ? `resets in ${left(ms)}` : `reset was due ${left(-ms)} ago`;
|
|
395
492
|
/**
|
|
396
493
|
* How long, in the two units that matter at each scale. Deliberately finer than `duration()`
|
|
397
494
|
* next door, which floors a session's uptime to whole hours: five hours is a window someone
|
|
@@ -494,7 +591,10 @@ export function renderPage(fleet, view = 'table') {
|
|
|
494
591
|
reading, the payload shapes nobody has captured yet. It reads as chrome to someone
|
|
495
592
|
scanning their sessions and as an answer to someone who came looking for it. */
|
|
496
593
|
.note { color:var(--dim); font-size:.75rem; line-height:1.5; margin:.9rem 0 0; max-width:95ch; }
|
|
497
|
-
|
|
594
|
+
/* Two marks, one weight: a reading that has gone cold, and a reading picked out of several
|
|
595
|
+
that were not about the same window. Both say the number beside them may not be what the
|
|
596
|
+
reader takes it for, so neither may end up quieter than the other. */
|
|
597
|
+
.stale, .mixed { color:var(--warn); font-weight:600; }
|
|
498
598
|
.wrap { overflow-x:auto; }
|
|
499
599
|
table { border-collapse:collapse; width:100%; min-width:44rem; }
|
|
500
600
|
th { text-align:left; font-weight:600; font-size:.75rem; text-transform:uppercase; letter-spacing:.06em;
|
|
@@ -535,6 +635,28 @@ export function renderPage(fleet, view = 'table') {
|
|
|
535
635
|
nav a { color:var(--dim); text-decoration:none; font-size:.8rem; font-weight:600; text-transform:uppercase;
|
|
536
636
|
letter-spacing:.06em; padding:.15rem .55rem; border-radius:99px; border:1px solid transparent; }
|
|
537
637
|
nav a[aria-current="page"] { color:var(--fg); border-color:var(--line); }
|
|
638
|
+
/* A finger is not a cursor. Every control on this page is a pill sized for a pointer that
|
|
639
|
+
lands on a single pixel — about 26px of box against the 44 a thumb is asked to hit — and
|
|
640
|
+
the fix cannot be more padding: that would redraw the page for everyone to solve a problem
|
|
641
|
+
only a touchscreen has. So the TAPPABLE box grows and the drawn one does not, through an
|
|
642
|
+
invisible overlay that exists only where the pointer is coarse.
|
|
643
|
+
|
|
644
|
+
Two rules rather than one: the inset is what is LEFT to reach 44 once the pill's own line
|
|
645
|
+
box and padding are counted, and the way out of a replay is set in smaller type than the
|
|
646
|
+
tabs. Sized as one number for all three, it came out at 41px. The border is NOT part of that
|
|
647
|
+
sum: the overlay's containing block is the control's padding box, so the border sits inside
|
|
648
|
+
the rectangle rather than adding to it. Counting it read 45.2px for a target Chrome laid out
|
|
649
|
+
at 43.2 — the whole feature short of the threshold it exists for, in both rules at once.
|
|
650
|
+
|
|
651
|
+
Vertical only. Every control here is already wider than 44px on its own text (the narrowest,
|
|
652
|
+
Map, is 50), so a horizontal inset buys nothing — and at .3rem against a .15rem gap between
|
|
653
|
+
the tabs it made their two overlays overlap by 7px, where a tap meant for Table landed on
|
|
654
|
+
Map because Map's pseudo paints later. */
|
|
655
|
+
nav a, .replay button, .replaying-note button { position:relative; }
|
|
656
|
+
@media (pointer: coarse) {
|
|
657
|
+
nav a::after, .replay button::after { content:''; position:absolute; inset:-.7rem 0; }
|
|
658
|
+
.replaying-note button::after { content:''; position:absolute; inset:-.85rem 0; }
|
|
659
|
+
}
|
|
538
660
|
body[data-view="table"] .view-map { display:none; }
|
|
539
661
|
body[data-view="map"] .view-table { display:none; }
|
|
540
662
|
|
|
@@ -585,6 +707,12 @@ export function renderPage(fleet, view = 'table') {
|
|
|
585
707
|
beats it: unguarded, this page came up announcing a replay nobody had asked for. */
|
|
586
708
|
.replay:not([hidden]) { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; margin-top:1rem;
|
|
587
709
|
padding-top:.7rem; border-top:1px solid var(--line); }
|
|
710
|
+
/* The pair gets a name. A button reading "Play" and a slider under a map, with nothing
|
|
711
|
+
saying what they move, is a control nobody dares touch — which on a phone is most of what
|
|
712
|
+
is on screen. Its own line above them, because dropped into the row it would read as a
|
|
713
|
+
label for the button rather than for the pair, and take width from the slider to do it. */
|
|
714
|
+
.replay .replay-name { flex-basis:100%; font-size:.7rem; font-weight:700; letter-spacing:.07em;
|
|
715
|
+
text-transform:uppercase; color:var(--dim); }
|
|
588
716
|
.replay button { font:inherit; font-size:.8rem; color:var(--fg); background:transparent;
|
|
589
717
|
border:1px solid var(--line); border-radius:99px; padding:.15rem .8rem; cursor:pointer; }
|
|
590
718
|
.replay input[type="range"] { flex:1; min-width:10rem; accent-color:var(--dim); }
|
|
@@ -607,23 +735,87 @@ export function renderPage(fleet, view = 'table') {
|
|
|
607
735
|
/* ── the map ─────────────────────────────────────────────────────────────────────────
|
|
608
736
|
One node per session. The arc is the context, its weight is how much that reading may
|
|
609
737
|
be believed, and the halo — the only thing on this page that moves — says a frame
|
|
610
|
-
landed moments ago.
|
|
611
|
-
|
|
738
|
+
landed moments ago. A background agent is drawn with none of the three — there is no
|
|
739
|
+
terminal behind it to draw a statusline frame with — and is a strip instead, docked
|
|
740
|
+
under the cards of its berth, printing as text whatever its snapshot did publish.
|
|
741
|
+
|
|
742
|
+
Two layouts, each named, because the two surfaces know different things. The live map
|
|
743
|
+
groups by working directory (the berths below); the replay behind the scrubber keeps a
|
|
744
|
+
project name and never the directory it was read in, and a basename is not a directory —
|
|
745
|
+
a frame drawn on it would group two checkouts of one repository into one. So it stays the
|
|
746
|
+
flat grid this view was before, which is the honest drawing of what it holds. */
|
|
747
|
+
.map { gap:.9rem; }
|
|
748
|
+
.map.berths { display:flex; flex-wrap:wrap; align-items:flex-start; }
|
|
749
|
+
.map.flat { display:grid; grid-template-columns:repeat(auto-fill,minmax(10.5rem,1fr)); }
|
|
750
|
+
/* The berth: a frame around the nodes read in one directory, and the label is the whole of
|
|
751
|
+
what it claims. Quiet on purpose — a hairline and a caption in the grey the rest of the
|
|
752
|
+
page uses for a heading, because the loud thing on this view is a session's state, and a
|
|
753
|
+
frame that competed with it would be a box drawn around a fact nobody asked about.
|
|
754
|
+
|
|
755
|
+
min-width:0 because a berth is a flex ITEM, and a flex item's automatic minimum size is
|
|
756
|
+
its min-content width — here the widest strip docked in it, whose prompt is one nowrap
|
|
757
|
+
line with no length limit. At auto the frame simply grows to fit the prompt: the
|
|
758
|
+
ellipsis on the strip still resolves, against a column that is never narrower than its own
|
|
759
|
+
text, so nothing is ever clipped and the page scrolls sideways instead. The flat grid gave
|
|
760
|
+
the strip a column to be cut to by being a grid; the frame has to say so. */
|
|
761
|
+
.berth { min-width:0; border:1px solid var(--line); border-radius:12px; padding:.6rem .7rem .7rem; }
|
|
762
|
+
.berth-label { margin:0 0 .5rem; font-size:.72rem; font-weight:600; text-transform:uppercase;
|
|
763
|
+
letter-spacing:.06em; color:var(--dim); }
|
|
764
|
+
/* The cards side by side at their own width, wrapping inside the frame when the directory
|
|
765
|
+
holds more of them than the row can take. */
|
|
766
|
+
.berth-cards { display:flex; flex-wrap:wrap; gap:.6rem; align-items:stretch; }
|
|
767
|
+
.berth-cards .node { width:10.5rem; }
|
|
768
|
+
/* And the strips docked underneath, full width of the frame, one under the other: a strip is
|
|
769
|
+
a line of text, and a line of text in a column half a card wide is an ellipsis where the
|
|
770
|
+
prompt was. Below the cards rather than among them because that is what it is — the
|
|
771
|
+
directory's background work, under the terminals someone is sitting at — and NOT because
|
|
772
|
+
one of those terminals dispatched it, which nothing here knows. */
|
|
773
|
+
.berth-strips { display:flex; flex-direction:column; gap:.4rem; margin-top:.6rem; }
|
|
612
774
|
.node { border:1px solid var(--line); border-radius:10px; padding:.8rem .85rem .7rem;
|
|
613
775
|
display:flex; flex-direction:column; align-items:center; text-align:center; }
|
|
614
776
|
.node[data-state="busy"] { border-color:color-mix(in srgb, var(--busy) 45%, var(--line)); }
|
|
615
777
|
.node[data-state="waiting"] { border-color:color-mix(in srgb, var(--wait) 45%, var(--line)); }
|
|
616
|
-
/* An agent is a smaller
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
778
|
+
/* An agent is not a smaller session — it is a strip. It was a card at three quarters scale,
|
|
779
|
+
which put a dial on a session that has no terminal to draw a statusline frame with: a ring
|
|
780
|
+
that can never fill, captioned with the words of a fault someone could go and repair. The
|
|
781
|
+
honest form is the one the table already speaks in — text on a line, left-aligned, its
|
|
782
|
+
state in the same glyph and in a three-pixel accent down the left edge. */
|
|
783
|
+
/* align-self, never the grid's own align-items: a strip is half the height of the card
|
|
784
|
+
beside it and must not be stretched to match, but the CARDS in a row still share one
|
|
785
|
+
height — telling the grid to stop stretching would have changed every session on the page
|
|
786
|
+
to make room for this one. Scoped to the flat grid, which is the only place a strip has a
|
|
787
|
+
card beside it: docked in a berth it is a full-width band, and "start" in that column
|
|
788
|
+
would shrink it to the width of its own prompt. */
|
|
789
|
+
.map.flat .node[data-role="agent"] { align-self:start; }
|
|
790
|
+
.node[data-role="agent"] { align-items:stretch; text-align:left;
|
|
791
|
+
padding:.5rem .7rem .55rem; border-radius:8px;
|
|
792
|
+
background:color-mix(in srgb, var(--line) 18%, transparent);
|
|
793
|
+
/* The box goes back to the neutral line the tinted rule above gave it: the accent is
|
|
794
|
+
the channel that carries state here, and a strip outlined in its hue as well was
|
|
795
|
+
the same fact said twice, in two weights, on a shape half the size of a card. */
|
|
796
|
+
border-color:var(--line); border-left-width:3px; border-left-color:var(--dim); }
|
|
797
|
+
.node[data-role="agent"][data-state="busy"] { border-left-color:var(--busy); }
|
|
798
|
+
.node[data-role="agent"][data-state="waiting"] { border-left-color:var(--wait); }
|
|
799
|
+
.node[data-role="agent"][data-state="unknown"] { border-left-color:var(--warn); }
|
|
800
|
+
.node[data-role="agent"] .who { margin-top:0; width:100%; }
|
|
801
|
+
/* A strip's project, which since the berths is the REPLAY's business alone: a live strip
|
|
802
|
+
prints none — the frame around it says the directory — and behind the scrubber there is no
|
|
803
|
+
frame, and the project is the only name the ring kept. The rule stayed when the markup
|
|
804
|
+
that used to need it went, or that name would sit at the body's own size, a size and a
|
|
805
|
+
half larger than the line it is on, on the one surface this suite renders no markup for. */
|
|
806
|
+
.node[data-role="agent"] .project { font-weight:600; font-size:.8rem; }
|
|
807
|
+
/* What the node calls itself, at the end of its line: an agent's line already reads as a
|
|
808
|
+
sentence, and the kind is the word that says it is not a terminal. This one and the prompt
|
|
809
|
+
below it are scoped to a node like every other rule here: both are words a table cell could
|
|
810
|
+
want the day it grows one, and unprefixed they would take it. */
|
|
811
|
+
.node .kind { margin-left:auto; font-size:.6rem; font-weight:700; text-transform:uppercase;
|
|
812
|
+
letter-spacing:.08em; color:var(--dim); }
|
|
813
|
+
/* The prompt a background session was named after — the strip's own line, now that the berth
|
|
814
|
+
around it carries the directory. One line, clipped: it is a sentence somebody typed, and
|
|
815
|
+
it is the only thing on the strip that has no length limit. */
|
|
816
|
+
.node .prompt { flex:1; min-width:0; color:var(--dim); font-size:.76rem;
|
|
817
|
+
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
818
|
+
/* Said, not shown: the four glyphs differ in silhouette, so a reader who cannot separate
|
|
627
819
|
two hues still has the state — but a screen reader is handed a bullet and nothing else. */
|
|
628
820
|
.sr { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
|
|
629
821
|
.dial { position:relative; width:5.5rem; height:5.5rem; }
|
|
@@ -674,8 +866,12 @@ export function renderPage(fleet, view = 'table') {
|
|
|
674
866
|
.why { font-size:.68rem; color:var(--dim); line-height:1.2; max-width:4.4rem; }
|
|
675
867
|
.why b { display:block; font-size:1.25rem; font-weight:400; }
|
|
676
868
|
.who { margin-top:.5rem; display:flex; align-items:baseline; gap:.3rem; max-width:100%; }
|
|
677
|
-
|
|
678
|
-
|
|
869
|
+
/* Two fields in one slot, and they are not the same fact. A live card names the SESSION —
|
|
870
|
+
the berth above it says the directory, and two sessions in one checkout are told apart by
|
|
871
|
+
nothing else. A replayed card has only the project: the ring never kept a name, for any
|
|
872
|
+
kind of session, so there is no berth behind the scrubber and no name to put in front. */
|
|
873
|
+
.who .name, .who .project { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
874
|
+
.node[data-state="busy"] .who .name, .node[data-state="busy"] .who .project { font-weight:700; }
|
|
679
875
|
.shape { font-size:.7rem; color:var(--dim); }
|
|
680
876
|
.node[data-state="busy"] .shape { color:var(--busy); }
|
|
681
877
|
.node[data-state="waiting"] .shape { color:var(--wait); }
|
|
@@ -686,27 +882,166 @@ export function renderPage(fleet, view = 'table') {
|
|
|
686
882
|
.sub.waiting-for { color:var(--wait); font-weight:600; }
|
|
687
883
|
.asof { font-size:.72rem; color:var(--dim); font-variant-numeric:tabular-nums; margin-top:.15rem; }
|
|
688
884
|
.asof.stale { color:var(--warn); font-weight:600; }
|
|
689
|
-
@media (max-width: 30rem) { .map { grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr)); gap:.6rem; } }
|
|
885
|
+
@media (max-width: 30rem) { .map.flat { grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr)); } .map { gap:.6rem; } }
|
|
690
886
|
|
|
691
|
-
/* Below this the table stops being a table
|
|
692
|
-
|
|
693
|
-
column would be a phone that
|
|
887
|
+
/* Below this the table stops being a table. What replaces it is the strip described down at
|
|
888
|
+
the tr rule: two lines per session rather than one card of eight labelled ones. Nothing is
|
|
889
|
+
dropped — a phone that hid the context column would be a phone that rendered "not measured"
|
|
890
|
+
as nothing at all — and the labels that go are the ones whose value says what it is on its
|
|
891
|
+
own, with the exceptions named where they are given a word back. */
|
|
694
892
|
@media (max-width: 46rem) {
|
|
695
893
|
body { padding:1.25rem .75rem; }
|
|
894
|
+
/* The summary's ISO stamp, spent. It is the widest thing on that line and the header two
|
|
895
|
+
lines above already says the same fact in the words a reader uses — "updated 3s ago",
|
|
896
|
+
counted by the shell whether or not a poll ever lands. Hidden rather than dropped: the
|
|
897
|
+
fragment still carries the exact second for anyone who goes looking for it. */
|
|
898
|
+
.meta .stamp { display:none; }
|
|
899
|
+
/* The handle, pinned under the thumb. The scrubber sits at the FOOT of the map and a map on
|
|
900
|
+
a phone is several screens tall: dragging it means the dials it moves are above the fold,
|
|
901
|
+
so the reader scrubs blind, lets go, scrolls up to see what changed and scrolls back. Held
|
|
902
|
+
at the bottom of the viewport, the hand and the thing it is changing are on screen at once.
|
|
903
|
+
|
|
904
|
+
Opaque and above what passes under it, or the dials scroll through the slider dragging
|
|
905
|
+
them. The negative margin gives it the page's own gutters back, so the bar reaches the
|
|
906
|
+
edges of the phone and the rule above it reads as an edge rather than a floating line.
|
|
907
|
+
|
|
908
|
+
The sentence under the handle stays. Hiding it for the length of a replay was the obvious
|
|
909
|
+
way to keep the bar short, and it silently undid a fix this file argues for forty lines
|
|
910
|
+
into coversText: two of its three parts are standing properties of the RECORD, not the
|
|
911
|
+
range — nothing replayed here is dated, and the past is drawn ungrouped — and they were
|
|
912
|
+
put in the reader's view precisely because they had lived "nowhere the reader can see it"
|
|
913
|
+
and an ungrouped map reads as a rendering that broke. A phone replaying is exactly when a
|
|
914
|
+
reader is staring at one. The bar is taller for it. */
|
|
915
|
+
body.replaying .replay:not([hidden]) { position:sticky; bottom:0; z-index:3;
|
|
916
|
+
background:var(--bg); border-top:1px solid var(--line);
|
|
917
|
+
padding:.55rem .75rem .8rem; margin:1rem -.75rem 0; }
|
|
696
918
|
.wrap { overflow-x:visible; }
|
|
697
|
-
table, tbody
|
|
919
|
+
table, tbody { display:block; }
|
|
698
920
|
table { min-width:0; }
|
|
699
921
|
thead { display:none; }
|
|
700
|
-
|
|
701
|
-
|
|
922
|
+
/* The card stops being eight labelled lines and becomes the strip the map already speaks.
|
|
923
|
+
Eight lines is 234px of phone: two and a half sessions fill the screen, and "is anything
|
|
924
|
+
waiting on me" costs four screens of scrolling. Two lines instead — who and in what
|
|
925
|
+
state, then the numbers, "ctx 65% · Opus 5 · medium · $20.79 · up 15h", which is the
|
|
926
|
+
line a docked agent has always printed on the map next door.
|
|
927
|
+
|
|
928
|
+
Nothing is dropped. The labels that go are the ones whose values wear their own name: a
|
|
929
|
+
"$", "%", a model, a state that is a word. The two that do not get one back below, in
|
|
930
|
+
the strip's own words rather than as a column heading.
|
|
931
|
+
|
|
932
|
+
The cell steps out of the layout entirely, with display:contents, so the row is the flex
|
|
933
|
+
container and every VALUE is one of its items. Anything else puts a box between the row
|
|
934
|
+
and the thing being placed, and the order below would have nothing to order. */
|
|
935
|
+
tr { display:flex; flex-wrap:wrap; align-items:baseline; column-gap:.4rem; row-gap:.05rem;
|
|
936
|
+
border:1px solid var(--line); border-left-width:3px; border-radius:8px;
|
|
937
|
+
padding:.5rem .75rem .55rem; margin-bottom:.55rem; }
|
|
938
|
+
/* white-space on the CELL, and not on the row: the desktop rule being undone is
|
|
939
|
+
td { white-space:nowrap }, and an explicit declaration on the cell beats anything the row
|
|
940
|
+
passes down — display:contents takes the cell out of the layout, not out of the cascade.
|
|
941
|
+
Put on the row instead, it read correctly and left the project name 128px past the phone. */
|
|
942
|
+
td { display:contents; white-space:normal; }
|
|
943
|
+
/* The column names are gone from this width, and there is no pseudo-element hiding them for
|
|
944
|
+
a screen reader: both ways of trying were measured against Chrome's accessibility tree and
|
|
945
|
+
neither works. Out of flow (the .sr recipe) the eight labels are read as ONE block after
|
|
946
|
+
the whole table, detached from every value they name — worse than silence. In flow at zero
|
|
947
|
+
size they are pruned from the tree entirely, and they still move the strip. What a reader
|
|
948
|
+
hears is the strip itself: "beacon, beacon-8c, waiting · permission prompt, ctx 65%, Opus
|
|
949
|
+
5, medium, $20.79, up 15h" — named for five of the eight, and unnamed for the project, the
|
|
950
|
+
session and the model. The desktop table names all eight in its thead, and so does every
|
|
951
|
+
JSON surface. Naming them here needs markup, and markup is not what this change is. */
|
|
952
|
+
/* The line break, as an item: zero-height, full-width, wedged between the state and the
|
|
953
|
+
first number. Without it the strip is a paragraph that reflows per session, and a column
|
|
954
|
+
of cards whose second line starts somewhere different each time cannot be scanned. */
|
|
955
|
+
tr::after { content:''; order:4; flex-basis:100%; height:0; }
|
|
702
956
|
tr[data-state="busy"] { border-left-color:var(--busy); }
|
|
703
957
|
tr[data-state="waiting"] { border-left-color:var(--wait); }
|
|
704
958
|
tr[data-state="unknown"] { border-left-color:var(--warn); }
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
.
|
|
959
|
+
/* The frames stop sharing a row, and the cards inside one stop being a fixed column so two
|
|
960
|
+
of them still fit across a phone. The berth keeps its hairline: a border around a card
|
|
961
|
+
inside a border around a directory is two hairlines, which is not the weight worth
|
|
962
|
+
spending a claim on — and dropping the frame here would drop the claim with it. */
|
|
963
|
+
.berth { width:100%; padding:.5rem .55rem .6rem; }
|
|
964
|
+
.berth-cards { gap:.5rem; }
|
|
965
|
+
/* min-width:0 for the reason the berth carries it, and here it is the width that was doing
|
|
966
|
+
the capping: a card's automatic minimum is its min-content width unless a specified size
|
|
967
|
+
suggests otherwise, and dropping the fixed column to auto drops that suggestion. A card
|
|
968
|
+
is then as wide as the name on it, and .who .name is one nowrap line — with a
|
|
969
|
+
background session named after its prompt, a name with no length limit. Not the exotic
|
|
970
|
+
case: when nothing in the fleet calls itself interactive, every row is drawn as a card. */
|
|
971
|
+
.berth-cards .node { flex:1 1 8.5rem; width:auto; min-width:0; }
|
|
972
|
+
/* A strip sharing a phone's width with a card is an ellipsis where the prompt was — the
|
|
973
|
+
one line saying what this agent was told to do is the first thing a narrow column takes
|
|
974
|
+
away. It spans the row instead, like the cells below it. The berth docks its own strips
|
|
975
|
+
full width at every size, so what this rule is left covering is the REPLAY's flat grid. */
|
|
976
|
+
.node[data-role="agent"] { grid-column:1 / -1; }
|
|
977
|
+
/* Line one: who, and in what state. The project leads and carries the weight; the session
|
|
978
|
+
name travels beside it in the page's grey. That order is deliberate and it is a red line
|
|
979
|
+
— a background session is NAMED AFTER ITS PROMPT, and a prompt set as the heading of a
|
|
980
|
+
card is a dashboard announcing what its agents were told to do, in the largest type on
|
|
981
|
+
the page. It also has no length limit, so it is the one value here allowed to wrap: the
|
|
982
|
+
page is content-box at this width and .wrap has given up its overflow-x, so a line
|
|
983
|
+
that refuses to break takes the whole document sideways.
|
|
984
|
+
|
|
985
|
+
The cell above hands back the white-space the desktop table takes; these two need the
|
|
986
|
+
other half of it, because both can arrive as ONE long token — a project is a directory's
|
|
987
|
+
basename, a background session's name is a prompt — and normal has nowhere to break a
|
|
988
|
+
word. min-width:0 for the same reason the berths carry it: a flex item's automatic
|
|
989
|
+
minimum is its min-content width, which without this is the whole unbroken string. */
|
|
990
|
+
td[data-label="Project"] .v { order:1; font-weight:600; min-width:0; overflow-wrap:anywhere; }
|
|
991
|
+
td[data-label="Session"] .v { order:2; flex:1 1 0; min-width:0; color:var(--dim);
|
|
992
|
+
white-space:normal; overflow-wrap:anywhere; }
|
|
993
|
+
/* The third value that can arrive as one unbroken token, and the one the two rules above
|
|
994
|
+
missed: a waiting reason is FREE TEXT, so "permission prompt: /Users/…/foo.ts" is a path
|
|
995
|
+
with no space to break at. Wrapping inside the pill (below) breaks a sentence and does
|
|
996
|
+
nothing for a path — at 320px an 84-character token laid the document out at 483px, 455 of
|
|
997
|
+
them this cell, which is the scroll bar the fix beside it had just closed. */
|
|
998
|
+
td[data-label="State"] .v { order:3; min-width:0; overflow-wrap:anywhere; }
|
|
999
|
+
/* The reason a session is waiting is free text: "permission prompt" fits on a phone and a
|
|
1000
|
+
sentence does not. Held nowrap, the pill is one unbreakable item on that first line,
|
|
1001
|
+
which is the same scroll bar by the other road. It wraps inside its own border instead,
|
|
1002
|
+
and the border stops being a capsule once it has three lines to go round: 99px on a box
|
|
1003
|
+
that tall is an ellipse whose curve crosses the words. Under half a single line's height,
|
|
1004
|
+
the radius is still clamped to a capsule on one line and merely rounded on three. */
|
|
1005
|
+
.pill { white-space:normal; border-radius:.9rem; }
|
|
1006
|
+
/* Line two: the numbers, each wearing a name of its own. "65%" alone under a line of prompt
|
|
1007
|
+
reads as how much of the prompt is done, which is the mistake the map's strip already had
|
|
1008
|
+
to fix; "$20.79" and "Opus 5" say what they are without help. */
|
|
1009
|
+
td[data-label="Context"] .v, td[data-label="Model"] .v, td[data-label="Effort"] .v,
|
|
1010
|
+
td[data-label="Cost"] .v, td[data-label="Uptime"] .v { font-size:.82rem; }
|
|
1011
|
+
td[data-label="Context"] .v { order:5; font-variant-numeric:tabular-nums; font-weight:600; }
|
|
1012
|
+
td[data-label="Context"] .v::before { content:'ctx '; color:var(--dim); font-weight:400; }
|
|
1013
|
+
/* The weight above is for a percentage. A session with no reading renders this same cell as
|
|
1014
|
+
"— not chained", and in the number's weight a missing measurement reads like a
|
|
1015
|
+
measurement — heavier here than the same words are on the desktop table. */
|
|
1016
|
+
td[data-label="Context"] .v .dim { font-weight:400; }
|
|
1017
|
+
/* Same two rules again, and the same argument: a model and an effort are not tarmac's own
|
|
1018
|
+
words. They are model.display_name and the effort out of a statusline payload, copied
|
|
1019
|
+
through verbatim and capped nowhere — a 120-character model laid the document out at
|
|
1020
|
+
814px. The three numbers below stay off this list, and NOT because of where they come
|
|
1021
|
+
from: a cost is copied out of that same payload and guarded even less (1e999 is legal
|
|
1022
|
+
JSON and reaches the cell as $Infinity, where a percentage that shape is refused). It is
|
|
1023
|
+
what they are PRINTED through that bounds them — a percentage clamped to 0..100 and
|
|
1024
|
+
floored, a duration, and a toFixed(2) that goes exponential long before it goes long.
|
|
1025
|
+
None can be a token wider than a phone, and a guard for that is prose that lies. */
|
|
1026
|
+
td[data-label="Model"] .v { order:6; min-width:0; overflow-wrap:anywhere; }
|
|
1027
|
+
td[data-label="Effort"] .v { order:7; color:var(--dim); min-width:0; overflow-wrap:anywhere; }
|
|
1028
|
+
td[data-label="Cost"] .v { order:8; font-variant-numeric:tabular-nums; }
|
|
1029
|
+
td[data-label="Uptime"] .v { order:9; color:var(--dim); font-variant-numeric:tabular-nums; }
|
|
1030
|
+
td[data-label="Model"] .v::before, td[data-label="Effort"] .v::before,
|
|
1031
|
+
td[data-label="Cost"] .v::before { content:'· '; color:var(--dim); font-weight:400; }
|
|
1032
|
+
td[data-label="Uptime"] .v::before { content:'· up '; color:var(--dim); font-weight:400; }
|
|
1033
|
+
/* A dash is not a value that wears its own name, and a session with no snapshot behind it
|
|
1034
|
+
has four of them at once: the percentage, the model, the effort and the cost all come out
|
|
1035
|
+
of one statusline frame, so they go missing together. That is not the corner case — it is
|
|
1036
|
+
every session until the status line has been chained and each one has drawn a frame, the
|
|
1037
|
+
state the page prints a warning about. As a strip it read "ctx — not chained · — · — · —",
|
|
1038
|
+
three anonymous dashes in a row, and the same happens one at a time for a session that
|
|
1039
|
+
reports no cost. Those three get their column word back; the other two already have one.
|
|
1040
|
+
The hook is the markup's own — a missing value is a .dim inside the cell's .v, and a
|
|
1041
|
+
present one never puts one there. */
|
|
1042
|
+
td[data-label="Model"] .v:has(.dim)::before { content:'· model '; }
|
|
1043
|
+
td[data-label="Effort"] .v:has(.dim)::before { content:'· effort '; }
|
|
1044
|
+
td[data-label="Cost"] .v:has(.dim)::before { content:'· cost '; }
|
|
710
1045
|
.bar { display:none; }
|
|
711
1046
|
}
|
|
712
1047
|
</style>
|
|
@@ -759,11 +1094,14 @@ export function renderPage(fleet, view = 'table') {
|
|
|
759
1094
|
would be a claim about nothing. -->
|
|
760
1095
|
<div class="limits" id="replay-limits" role="group" aria-label="account rate limits, at the minute being replayed" hidden></div>
|
|
761
1096
|
<div class="meta" id="replay-meta"></div>
|
|
762
|
-
<div class="map" id="replay-map"></div>
|
|
1097
|
+
<div class="map flat" id="replay-map"></div>
|
|
763
1098
|
</div>
|
|
764
1099
|
<!-- A dead handle is worse than no handle: this is revealed once the record is in hand, and
|
|
765
1100
|
what it says it covers is whatever the record answered with. -->
|
|
766
1101
|
<div class="replay" id="replay" hidden>
|
|
1102
|
+
<!-- "Replay", and nothing about how much of the day it holds: the range is the record's to
|
|
1103
|
+
state, in the sentence below, which is built around never calling ten minutes a day. -->
|
|
1104
|
+
<span class="replay-name">Replay</span>
|
|
767
1105
|
<button type="button" id="play">Play</button>
|
|
768
1106
|
<input type="range" id="scrub" min="0" max="0" step="1" value="0" disabled aria-label="Replay position">
|
|
769
1107
|
<div class="covers" id="covers"></div>
|
|
@@ -791,7 +1129,7 @@ export function renderPage(fleet, view = 'table') {
|
|
|
791
1129
|
* The replay below is the one exception, and it is one the issue asks for: scrubbing a day
|
|
792
1130
|
* has to be a lookup in samples the page already holds, or every pixel of a drag would be a
|
|
793
1131
|
* 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
|
|
1132
|
+
* browser — fed the same three words, the same four glyphs and the same dial geometry as the
|
|
795
1133
|
* server's, by interpolation rather than by copy, and executed by `test/replay-script`.
|
|
796
1134
|
*/
|
|
797
1135
|
export const REFRESH_MS = 5000;
|
|
@@ -826,6 +1164,22 @@ function pageScript(view) {
|
|
|
826
1164
|
var off = document.getElementById('offline'), why = document.getElementById('why');
|
|
827
1165
|
var limits = document.getElementById('limits');
|
|
828
1166
|
var last = Date.now(), failing = false, inFlight = false, since = 0, gen = 0;
|
|
1167
|
+
// How many polls in a row have come back with nothing usable, and when the last of them was.
|
|
1168
|
+
// On a phone the page is read on a radio, and one dropped request is a tunnel rather than an
|
|
1169
|
+
// outage — the banner frames the table off and says the fleet cannot be read, which is the
|
|
1170
|
+
// wrong thing to shout five seconds before the next answer lands. It waits for the second
|
|
1171
|
+
// consecutive miss; the age upstairs keeps counting meanwhile, so nothing on the page is
|
|
1172
|
+
// claiming to be fresher than it is.
|
|
1173
|
+
//
|
|
1174
|
+
// Consecutive means in a row IN TIME, which is why the stamp is here. A count cleared only by
|
|
1175
|
+
// a successful poll is not the same rule: a hidden tab issues no polls, so a miss from before
|
|
1176
|
+
// the reader locked their phone sat there for an hour, and the wake-up poll — the likeliest
|
|
1177
|
+
// miss of the session, fired while the radio is still reassociating — found it and raised the
|
|
1178
|
+
// banner over one dropped request. A miss further back than a few poll intervals starts the
|
|
1179
|
+
// count again. The window is bounded at both ends and neither end is arbitrary: below one
|
|
1180
|
+
// poll interval two real misses in a row would never meet, and above five a locked phone
|
|
1181
|
+
// comes back to a miss from minutes ago being called consecutive with this one.
|
|
1182
|
+
var misses = 0, missAt = 0, MISSES_BEFORE_BANNER = 2, MISS_WINDOW_MS = 3 * ${REFRESH_MS};
|
|
829
1183
|
|
|
830
1184
|
function ago(ms) {
|
|
831
1185
|
// A clock that steps backwards (an NTP correction, a laptop waking) must not produce
|
|
@@ -836,8 +1190,18 @@ function pageScript(view) {
|
|
|
836
1190
|
return m < 60 ? m + 'm' : Math.round(m / 60) + 'h';
|
|
837
1191
|
}
|
|
838
1192
|
|
|
1193
|
+
// Called for the one failure that is NOT a missed poll: a request the server accepted and
|
|
1194
|
+
// never answered. Twenty seconds of silence from a live connection is not a dropped packet,
|
|
1195
|
+
// so it says so at once, without the second miss the count is there to wait for. It does not
|
|
1196
|
+
// touch the count: a miss cannot take this banner back down, because a miss never assigns the
|
|
1197
|
+
// failing flag anything but true, and only an ANSWER puts it back to false.
|
|
839
1198
|
function fail(why_) {
|
|
840
1199
|
failing = true;
|
|
1200
|
+
// Retired, not merely dropped. Clearing the in-flight flag without moving the generation
|
|
1201
|
+
// left the abandoned request still ours, so the answer that arrived twenty seconds later was
|
|
1202
|
+
// swapped in and stamped "updated 0s ago" — the freshest label on the page over a fleet read
|
|
1203
|
+
// before the stall was declared. The manual has always said such an answer is discarded.
|
|
1204
|
+
gen += 1;
|
|
841
1205
|
why.textContent = why_;
|
|
842
1206
|
off.hidden = false;
|
|
843
1207
|
document.body.classList.toggle('failing', true);
|
|
@@ -897,10 +1261,20 @@ function pageScript(view) {
|
|
|
897
1261
|
if (src) limits.innerHTML = src.innerHTML;
|
|
898
1262
|
last = Date.now();
|
|
899
1263
|
failing = false;
|
|
1264
|
+
// Consecutive, not cumulative: two blips an hour apart are two blips, and a count that
|
|
1265
|
+
// never went back to zero would turn the second one into a permanent banner.
|
|
1266
|
+
misses = 0;
|
|
900
1267
|
});
|
|
901
1268
|
}).catch(function (e) {
|
|
902
1269
|
if (!mineStill()) return;
|
|
903
|
-
|
|
1270
|
+
if (Date.now() - missAt > MISS_WINDOW_MS) misses = 0;
|
|
1271
|
+
misses += 1;
|
|
1272
|
+
missAt = Date.now();
|
|
1273
|
+
// Raised here, never lowered here. Only an ANSWER says the server came back, so this
|
|
1274
|
+
// assigns true or nothing at all — derived both ways, the window that starts a fresh count
|
|
1275
|
+
// also cleared the alarm, and a reader who locked their phone for ten minutes while the
|
|
1276
|
+
// server was down unlocked onto a green page over a fleet nobody could read.
|
|
1277
|
+
if (misses >= MISSES_BEFORE_BANNER) failing = true;
|
|
904
1278
|
why.textContent = String((e && e.message) || e).slice(0, 200);
|
|
905
1279
|
}).then(function () {
|
|
906
1280
|
// Not ours to unlock: a request we were given up on must not clear a flag that a newer
|
|
@@ -928,7 +1302,7 @@ function pageScript(view) {
|
|
|
928
1302
|
var record = null, recordAt = 0, at = -1, replaying = false, playing = null, hgen = 0;
|
|
929
1303
|
|
|
930
1304
|
// The vocabulary and the geometry, handed over rather than written twice: three words for
|
|
931
|
-
// the three kinds of missing,
|
|
1305
|
+
// the three kinds of missing, four glyphs for the four states, one dial radius.
|
|
932
1306
|
var WHY = ${JSON.stringify(CTX_WHY)}, SHAPE = ${JSON.stringify(SHAPE)};
|
|
933
1307
|
var INTERACTIVE = ${JSON.stringify(INTERACTIVE)};
|
|
934
1308
|
var ENT = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
@@ -978,7 +1352,18 @@ function pageScript(view) {
|
|
|
978
1352
|
// A gap that says it is a gap is not a gap. The handle steps through readings, not
|
|
979
1353
|
// through minutes, and a record with holes in it is not a smooth walk.
|
|
980
1354
|
+ (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.'
|
|
1355
|
+
+ '. The record keeps each reading, not how old that reading was, so nothing replayed here is dated.'
|
|
1356
|
+
// The other thing the ring does not hold, said where the reader meets it: the argument
|
|
1357
|
+
// for an ungrouped replay was written in the README, the manual, the changelog and a
|
|
1358
|
+
// comment in this sheet, and nowhere the reader can see it. Shown less and told nothing,
|
|
1359
|
+
// a reader reads it as a rendering that broke.
|
|
1360
|
+
//
|
|
1361
|
+
// A standing property of the record, never an event. This line sits in the scrubber's own
|
|
1362
|
+
// block, outside the live fragment and outside the replay one, so it is on the page from
|
|
1363
|
+
// the moment the record lands — and a sentence saying the grouping had gone would be
|
|
1364
|
+
// printed under a live map with the grouping on it.
|
|
1365
|
+
+ ' It keeps a project name and never the directory a node was read in, so the past is'
|
|
1366
|
+
+ ' drawn ungrouped, in the order the sample carries.';
|
|
982
1367
|
}
|
|
983
1368
|
|
|
984
1369
|
function ready() {
|
|
@@ -1040,6 +1425,26 @@ function pageScript(view) {
|
|
|
1040
1425
|
// unescaped, and into the glyph slot as a function body.
|
|
1041
1426
|
var state = own(SHAPE, x.state) ? x.state : 'unknown';
|
|
1042
1427
|
var pct = typeof x.ctxPct === 'number' ? x.ctxPct : null;
|
|
1428
|
+
// The live view's rule about agents, in the copy of it that ships to the browser: a strip,
|
|
1429
|
+
// never a dial. A replay drawing agents as rings while the page one draws them as strips
|
|
1430
|
+
// would read as two kinds of thing — and the ring is the surface that can LEAST fill a
|
|
1431
|
+
// gauge, since it keeps a reading and never the terminal that produced it. No prompt line:
|
|
1432
|
+
// a background session is named after the prompt it was given, and the record stores no
|
|
1433
|
+
// names. What it does hold for one is a percentage and what it cost — printed like
|
|
1434
|
+
// anywhere else, the percentage labelled as the live strip labels it, since neither a ring
|
|
1435
|
+
// nor a column header is here to say which quantity it is. No model and no effort: a
|
|
1436
|
+
// sample is not a snapshot, and the record was never given either.
|
|
1437
|
+
if (role === 'agent') {
|
|
1438
|
+
return '<article class="node" data-role="agent" data-state="' + state + '" data-reading="undatable">'
|
|
1439
|
+
+ '<div class="who"><span class="shape" aria-hidden="true">' + SHAPE[state] + '</span>'
|
|
1440
|
+
+ '<span class="sr">' + state + '</span>'
|
|
1441
|
+
+ '<span class="project">' + esc(x.project) + '</span>'
|
|
1442
|
+
+ '<span class="kind">' + esc(x.kind) + '</span></div>'
|
|
1443
|
+
+ (state === 'waiting' && x.waitingFor ? '<div class="sub waiting-for">' + esc(x.waitingFor) + '</div>' : '')
|
|
1444
|
+
+ (pct === null ? '' : '<div class="sub">ctx ' + pct + '%</div>')
|
|
1445
|
+
+ (typeof x.costUsd === 'number' ? '<div class="sub">$' + x.costUsd.toFixed(2) + '</div>' : '')
|
|
1446
|
+
+ '</article>';
|
|
1447
|
+
}
|
|
1043
1448
|
// The ring keeps each reading and never how old that reading was, so the arc weight that
|
|
1044
1449
|
// says how much a reading may be believed cannot be earned here. It is not the live
|
|
1045
1450
|
// default either: this third value is de-weighted in the stylesheet, and never the warning
|
|
@@ -1144,9 +1549,11 @@ function pageScript(view) {
|
|
|
1144
1549
|
function nodesOf(s) {
|
|
1145
1550
|
var anchored = false, html = '', i;
|
|
1146
1551
|
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
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
1552
|
+
// In the order the sample carries, flat. The live map frames its nodes by working
|
|
1553
|
+
// directory; the ring holds a project name and never the directory it was read in, and a
|
|
1554
|
+
// basename is not a directory — a frame drawn on it would put two checkouts of one
|
|
1555
|
+
// repository behind one label. So the past keeps the order the fleet was sorted in, rather
|
|
1556
|
+
// than a grouping this page would have to invent a key for.
|
|
1150
1557
|
for (i = 0; i < s.sessions.length; i++) html += nodeOf(s.sessions[i], anchored);
|
|
1151
1558
|
return html;
|
|
1152
1559
|
}
|
|
@@ -1260,11 +1667,13 @@ function pageScript(view) {
|
|
|
1260
1667
|
function renderRow(r) {
|
|
1261
1668
|
const state = stateOf(r);
|
|
1262
1669
|
const word = stateLabel(state, r);
|
|
1263
|
-
// `data-label` is not decoration
|
|
1264
|
-
//
|
|
1265
|
-
//
|
|
1266
|
-
//
|
|
1267
|
-
//
|
|
1670
|
+
// `data-label` is not decoration, and no longer only for the reason it was: the header row is
|
|
1671
|
+
// gone below ~46rem, and what the attribute does there is CARRY THE STRIP. The per-column
|
|
1672
|
+
// rules select `td[data-label="…"] .v` for their `order`, for their wrapping, and for the five
|
|
1673
|
+
// `::before` prefixes that are the only column words left at that width — `ctx `, `· up `, and
|
|
1674
|
+
// `· model `/`· effort `/`· cost ` for a value that is a bare dash. One element per cell, since
|
|
1675
|
+
// `td` is `display:contents` there: the `.v` IS the row's flex item, and a second sibling in
|
|
1676
|
+
// one cell would be a second item placed on an `order` of its own.
|
|
1268
1677
|
return `<tr data-state="${state}">
|
|
1269
1678
|
<td data-label="Project" class="project"><span class="v">${esc(r.project)}</span></td>
|
|
1270
1679
|
<td data-label="Session" class="dim"><span class="v">${esc(r.name)}</span></td>
|
|
@@ -1300,21 +1709,45 @@ const stateLabel = (state, r) => state === 'waiting' && r.waitingFor ? `${stateW
|
|
|
1300
1709
|
*/
|
|
1301
1710
|
const CTX_WHY = { fresh: 'no turn yet', drift: 'schema drift', absent: 'not chained' };
|
|
1302
1711
|
/**
|
|
1303
|
-
* The map: one node per session,
|
|
1304
|
-
* its business — `renderLive` says that once, above both views, rather than
|
|
1305
|
-
* them render the same sentence and hide one of the two.
|
|
1712
|
+
* The map: one node per session, grouped into berths rather than laid out as a graph. An empty
|
|
1713
|
+
* fleet is not its business — `renderLive` says that once, above both views, rather than
|
|
1714
|
+
* letting each of them render the same sentence and hide one of the two.
|
|
1306
1715
|
*
|
|
1307
1716
|
* There are no edges because the sources publish no relationship between two sessions — the
|
|
1308
|
-
* one thing they do carry is the working directory, and that is
|
|
1309
|
-
*
|
|
1310
|
-
*
|
|
1717
|
+
* one thing they do carry is the working directory, and that is what a berth is drawn around.
|
|
1718
|
+
* A frame is the cheapest way to say "these were read in one place" and the hardest to
|
|
1719
|
+
* misread as a line between two of them.
|
|
1311
1720
|
*
|
|
1312
1721
|
* Everything a reader interprets is decided in `map.ts` and rendered here, on the server,
|
|
1313
1722
|
* for the same reason the table is: the rules that keep a reading honest are tested, and a
|
|
1314
1723
|
* copy of them re-derived in browser JavaScript would sit where this suite cannot reach.
|
|
1315
1724
|
*/
|
|
1316
1725
|
export function renderMap(fleet) {
|
|
1317
|
-
return `<div class="map">${buildMap(fleet).
|
|
1726
|
+
return `<div class="map berths">${buildMap(fleet).berths.map(renderBerth).join('')}</div>`;
|
|
1727
|
+
}
|
|
1728
|
+
/**
|
|
1729
|
+
* One berth: a frame, a label, the cards of the directory, and the strips docked under them.
|
|
1730
|
+
*
|
|
1731
|
+
* The label is the WHOLE of what the frame claims — these nodes were read in this directory.
|
|
1732
|
+
* Nothing in here says which node dispatched which, because `claude agents --json` publishes
|
|
1733
|
+
* no such field: no order, no position and no line inside the frame means "parent of". The day
|
|
1734
|
+
* that relation is published it is drawn between nodes already sitting side by side, and this
|
|
1735
|
+
* function is where it would go — inside a berth, without moving one.
|
|
1736
|
+
*
|
|
1737
|
+
* Named for the reader who is handed no border at all: the frame is a group with the project
|
|
1738
|
+
* for its name, and the heading says the same word for one navigating by headings. Each half
|
|
1739
|
+
* is drawn only if it has something in it, so an orphan agent's berth is a frame with a strip
|
|
1740
|
+
* in it rather than a frame with an empty row above one.
|
|
1741
|
+
*
|
|
1742
|
+
* `role="group"` explicitly, which is what a named `<section>` would NOT be: that is a region,
|
|
1743
|
+
* a landmark, and one per working directory turns a busy machine into a page of landmarks all
|
|
1744
|
+
* named after a basename — several of them possibly the same basename, since two checkouts of
|
|
1745
|
+
* `atlas` are two berths with one label. The name is what this frame is worth to a screen
|
|
1746
|
+
* reader; a place in the landmark index is not, and `group` is the idiom the page already uses
|
|
1747
|
+
* for every other named box on it.
|
|
1748
|
+
*/
|
|
1749
|
+
function renderBerth({ label, sessions, agents }) {
|
|
1750
|
+
return `<section class="berth" role="group" aria-label="${esc(label)}"><h2 class="berth-label">${esc(label)}</h2>${sessions.length === 0 ? '' : `<div class="berth-cards">${sessions.map(renderNode).join('')}</div>`}${agents.length === 0 ? '' : `<div class="berth-strips">${agents.map(renderNode).join('')}</div>`}</section>`;
|
|
1318
1751
|
}
|
|
1319
1752
|
/**
|
|
1320
1753
|
* One node. Five facts, in five channels that do not depend on colour alone: the arc is how
|
|
@@ -1326,6 +1759,11 @@ export function renderMap(fleet) {
|
|
|
1326
1759
|
* One state brings a caption with it. A waiting session is the only one where the shape
|
|
1327
1760
|
* leaves a question the source can answer — which human answer it is halted on — and it is
|
|
1328
1761
|
* printed directly under the name, not hidden in a title attribute nobody hovers on a phone.
|
|
1762
|
+
*
|
|
1763
|
+
* Two shapes, and the split is what a node HAS rather than what it is worth: a session has a
|
|
1764
|
+
* terminal, so the dial and its four facts are drawn for it. A background agent has no
|
|
1765
|
+
* terminal to draw a frame with, and gets the strip below — same data attributes, same glyph,
|
|
1766
|
+
* same words to a screen reader, and whatever its snapshot published, as text on one line.
|
|
1329
1767
|
*/
|
|
1330
1768
|
function renderNode({ row: r, role, state, reading, measured, pulse }) {
|
|
1331
1769
|
// The model owns "is there a number"; this reads its verdict rather than asking the row a
|
|
@@ -1341,18 +1779,50 @@ function renderNode({ row: r, role, state, reading, measured, pulse }) {
|
|
|
1341
1779
|
: reading === 'undated'
|
|
1342
1780
|
? `<div class="asof stale">! undated</div>`
|
|
1343
1781
|
: '';
|
|
1344
|
-
//
|
|
1345
|
-
//
|
|
1346
|
-
//
|
|
1782
|
+
// The strip. What it dropped was the dial, never the reading: the ring on an agent could
|
|
1783
|
+
// never fill — there is no terminal here to draw a statusline frame with — and the middle of
|
|
1784
|
+
// it read "not chained", the vocabulary of a repairable fault ("run `tarmac install`") said
|
|
1785
|
+
// about a session no install can ever cover. So no gauge, no dash, no reason where the
|
|
1786
|
+
// source published nothing, and the three fields it does publish about an agent in text: its
|
|
1787
|
+
// state, the kind it calls itself, and the prompt it was named after.
|
|
1788
|
+
//
|
|
1789
|
+
// Nor a halo: it is a ring drawn inside the dial, and this shape has neither. What it says —
|
|
1790
|
+
// a reading landed seconds ago — is the one claim on this page nobody can look away from,
|
|
1791
|
+
// and it is not the fact a strip exists to carry.
|
|
1792
|
+
//
|
|
1793
|
+
// Nor the project: the berth around this strip says the directory once, for every node in
|
|
1794
|
+
// it, and a strip that repeated it would print `harbor` four times inside one frame. What
|
|
1795
|
+
// the line spends itself on instead is what tells two agents in one berth apart — the prompt
|
|
1796
|
+
// it was named after, and the kind it calls itself. Nothing here points at a node beside it:
|
|
1797
|
+
// sharing a frame is sharing a directory, and that is all it has ever been.
|
|
1798
|
+
if (role === 'agent') {
|
|
1799
|
+
// The rule for the rest: the strip prints what that session's snapshot published, and
|
|
1800
|
+
// nothing where nothing was published. The percentage, the model and the effort come out
|
|
1801
|
+
// of one file — `buildFleet` reads all three off the same object — so an agent the join
|
|
1802
|
+
// found a payload for shows all of them, on one line, beside the reading's age when it is
|
|
1803
|
+
// one nobody should take for current. The number carries its own label: a card has a ring
|
|
1804
|
+
// around it and the table a column header over it, and a bare `61%` under a line of prompt
|
|
1805
|
+
// reads as how much of the prompt is done. Each part is dropped on its own field being
|
|
1806
|
+
// null — a snapshot with no turn behind it has a model in it and no percentage.
|
|
1807
|
+
const published = [pct === null ? null : `ctx ${pct}%`, r.model, r.effort]
|
|
1808
|
+
.filter((v) => v !== null && v !== '')
|
|
1809
|
+
.map(esc)
|
|
1810
|
+
.join(' · ');
|
|
1811
|
+
return `<article class="node" data-role="${role}" data-state="${state}" data-reading="${reading}">
|
|
1812
|
+
<div class="who"><span class="shape" aria-hidden="true">${SHAPE[state]}</span><span class="sr">${esc(stateWord(state, r))}</span><span class="prompt">${esc(r.name)}</span><span class="kind">${esc(r.kind)}</span></div>
|
|
1813
|
+
${state === 'waiting' && r.waitingFor ? `<div class="sub waiting-for">${esc(r.waitingFor)}</div>` : ''}
|
|
1814
|
+
${published === '' ? '' : `<div class="sub">${published}</div>`}
|
|
1815
|
+
${asOf}
|
|
1816
|
+
</article>`;
|
|
1817
|
+
}
|
|
1347
1818
|
return `<article class="node" data-role="${role}" data-state="${state}" data-reading="${reading}">
|
|
1348
1819
|
<div class="dial">
|
|
1349
1820
|
<svg viewBox="0 0 80 80" aria-hidden="true">${pulse ? `<circle class="halo" cx="40" cy="40" r="${DIAL_R}"/>` : ''}<circle class="track${measured ? '' : ' unmeasured'}" cx="40" cy="40" r="${DIAL_R}"/>${pct === null ? '' : arc(pct)}</svg>
|
|
1350
1821
|
<div class="val">${value}</div>
|
|
1351
1822
|
${pulse ? `<span class="sr">a reading just landed</span>` : ''}
|
|
1352
1823
|
</div>
|
|
1353
|
-
<div class="who"><span class="shape" aria-hidden="true">${SHAPE[state]}</span><span class="sr">${esc(stateWord(state, r))}</span><span class="
|
|
1824
|
+
<div class="who"><span class="shape" aria-hidden="true">${SHAPE[state]}</span><span class="sr">${esc(stateWord(state, r))}</span><span class="name">${esc(r.name)}</span></div>
|
|
1354
1825
|
${state === 'waiting' && r.waitingFor ? `<div class="sub waiting-for">${esc(r.waitingFor)}</div>` : ''}
|
|
1355
|
-
<div class="sub">${esc(r.name)}</div>
|
|
1356
1826
|
${r.kind === null || r.kind === INTERACTIVE ? '' : `<div class="sub">${esc(r.kind)}</div>`}
|
|
1357
1827
|
<div class="sub">${esc(r.model)}${r.effort === null ? '' : ` · ${esc(r.effort)}`}</div>
|
|
1358
1828
|
${asOf}
|