@adrrr/tarmac 0.3.0 → 0.4.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 +24 -6
- package/dist/fleet.js +34 -1
- package/dist/history.js +88 -0
- package/dist/limits.js +62 -0
- package/dist/map.js +10 -1
- package/dist/render.js +602 -13
- package/dist/schema.js +1 -1
- package/dist/server.js +74 -3
- package/dist/sessions.js +15 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -87,6 +87,14 @@ does not mark same-origin — your cwd paths and costs never leave the machine.
|
|
|
87
87
|
**default** port walks up to the next free one and says so; a port you chose yourself — flag,
|
|
88
88
|
environment or config file — refuses instead, because you chose it.
|
|
89
89
|
|
|
90
|
+
In the header are the account's **two rate-limit gauges** — the five-hour window and the
|
|
91
|
+
seven-day one, each with its used percentage and its reset spelled as the time left. They are
|
|
92
|
+
page-level because that is what a rate limit is: one account, which every session below is
|
|
93
|
+
spending from. A fleet whose snapshots carry no limits says `— no reading` on a dotted rail
|
|
94
|
+
rather than drawing a window at 0%, and a reading past the freshness threshold is dated
|
|
95
|
+
`! 40m ago` — the countdown is recomputed every poll, the percentage is as old as its snapshot,
|
|
96
|
+
and a page that showed both as now would be lying with the moving one.
|
|
97
|
+
|
|
90
98
|
### The map
|
|
91
99
|
|
|
92
100
|
The tab in the header swaps the table for the same fleet as nodes — one per session, the arc
|
|
@@ -101,6 +109,15 @@ sharing its working directory, because the working directory is the only thing t
|
|
|
101
109
|
share; nothing is nested, and no edge is drawn for a relationship the sources do not publish.
|
|
102
110
|
Details in [the manual](docs/MANUAL.md#the-map).
|
|
103
111
|
|
|
112
|
+
Under the map is a **scrubber over the day this serve has seen**. Drag it and the dials render
|
|
113
|
+
the fleet as it was at that minute; press play and the day walks past. The record is fetched on
|
|
114
|
+
load, never per position, so scrubbing asks the server nothing. A replay never poses as the present: a banner names
|
|
115
|
+
the minute and holds the way back, the live fragment is hidden while it is up, halos stay off
|
|
116
|
+
because a sample never "just landed", and a session absent from a minute is absent from the
|
|
117
|
+
map. The range says what it truly covers — a serve ten minutes old offers ten minutes. The
|
|
118
|
+
account's gauges replay too, counted from the minute being shown rather than from now, so the
|
|
119
|
+
five-hour window can be watched draining and refilling across a day.
|
|
120
|
+
|
|
104
121
|
## Why it does not break
|
|
105
122
|
|
|
106
123
|
The usual ways to watch a Claude Code fleet read something Claude Code never promised would
|
|
@@ -111,7 +128,7 @@ tarmac reads two things instead:
|
|
|
111
128
|
|
|
112
129
|
| Source | What it gives | How solid |
|
|
113
130
|
|---|---|---|
|
|
114
|
-
| `claude agents --json` | which sessions exist, busy or
|
|
131
|
+
| `claude agents --json` | which sessions exist, busy, idle or waiting on you, cwd, uptime | a documented CLI surface (`--help`: *"Print active sessions … as a JSON array … for scripting"*) |
|
|
115
132
|
| the status line payload | context %, model, effort, cost | the JSON Claude Code hands to your own `statusLine.command` on every frame — **observed, not published as a schema** |
|
|
116
133
|
|
|
117
134
|
That second line is the honest caveat, and it is the reason the real defence is not
|
|
@@ -129,7 +146,7 @@ table is in [`docs/MANUAL.md`](docs/MANUAL.md).
|
|
|
129
146
|
| Command | What it does | Options |
|
|
130
147
|
|---|---|---|
|
|
131
148
|
| `tarmac list` | one-shot fleet table — the default, so bare `tarmac` runs it | `--home`, `--stale-after`, `--snapshots-dir`, `--claude-bin`, `--json`, `--watch` |
|
|
132
|
-
| `tarmac serve` | local dashboard, `GET /` for the table, `GET /map` for the map, `GET /live` for the fragment both refresh from, `GET /api/fleet` for JSON | `--home`, `--port`, `--stale-after`, `--snapshots-dir`, `--claude-bin` |
|
|
149
|
+
| `tarmac serve` | local dashboard, `GET /` for the table, `GET /map` for the map, `GET /live` for the fragment both refresh from, `GET /api/fleet` for JSON, `GET /api/history` for the last 24h of readings it took while it ran | `--home`, `--port`, `--stale-after`, `--snapshots-dir`, `--claude-bin` |
|
|
133
150
|
| `tarmac install` | chain the status line under `<home>/.claude/settings.json`, after confirmation | `--home`, `--yes` |
|
|
134
151
|
| `tarmac uninstall` | restore it, and say which of the four restore modes ran | `--home`, `--yes` |
|
|
135
152
|
|
|
@@ -170,10 +187,11 @@ filable.
|
|
|
170
187
|
|
|
171
188
|
## What it deliberately does not do
|
|
172
189
|
|
|
173
|
-
- **No "waiting for you"
|
|
174
|
-
a
|
|
175
|
-
|
|
176
|
-
|
|
190
|
+
- **No inferred "waiting for you".** `agents --json` reports `waiting` with a reason —
|
|
191
|
+
a permission prompt, an open dialog — and tarmac draws exactly that, on both surfaces.
|
|
192
|
+
What it will not do is guess at the rest: a session that asked you a question in prose
|
|
193
|
+
still reports `idle`, and the only way to know better is to read a transcript, which is
|
|
194
|
+
the one thing this tool will not do. The signal is as good as the surface, and no better.
|
|
177
195
|
- **No history.** Each run is a snapshot in time; context and cost curves come later.
|
|
178
196
|
- **No Windows.** The generated wrapper is POSIX `sh`.
|
|
179
197
|
- **No remote fleets.** It watches the machine it runs on.
|
package/dist/fleet.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { DEFAULT_STALE_AFTER_MS } from './config.js';
|
|
15
15
|
import { guardVersions } from './schema.js';
|
|
16
|
+
import { isWaiting } from './sessions.js';
|
|
16
17
|
import { SID_NAME } from './wrapper.js';
|
|
17
18
|
export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_STALE_AFTER_MS, discovery = null, }) {
|
|
18
19
|
const rows = sessions.map((s) => {
|
|
@@ -25,6 +26,7 @@ export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_ST
|
|
|
25
26
|
pid: s.pid,
|
|
26
27
|
kind: s.kind,
|
|
27
28
|
status: s.status,
|
|
29
|
+
waitingFor: s.waitingFor,
|
|
28
30
|
busy: s.busy,
|
|
29
31
|
uptimeMs: typeof s.startedAt === 'number' ? now - s.startedAt : null,
|
|
30
32
|
ctxState: t ? t.ctxState : 'absent',
|
|
@@ -76,7 +78,10 @@ export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_ST
|
|
|
76
78
|
// Tested tolerance from the fleet: `fresh` never counts, or a recycled fleet would
|
|
77
79
|
// raise this every single night.
|
|
78
80
|
schemaBroken: covered > 0 && drift === covered,
|
|
79
|
-
|
|
81
|
+
// Recomputed from the rows rather than taken from `discovery`, which may be null — so
|
|
82
|
+
// the exemption the reader grants a waiting session has to be granted again here, or
|
|
83
|
+
// the banner accuses a session both surfaces are drawing as waiting.
|
|
84
|
+
unknownStatus: rows.filter((r) => r.busy === null && !isWaiting(r)).length,
|
|
80
85
|
busy: rows.filter((r) => r.busy === true).length,
|
|
81
86
|
// A sum over 3 of 7 sessions is not the fleet's cost. Same rule as `sumUsage` one
|
|
82
87
|
// layer down: add only what is really a number, and count those — a payload with no
|
|
@@ -91,6 +96,34 @@ export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_ST
|
|
|
91
96
|
},
|
|
92
97
|
};
|
|
93
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* The account's rate limits, as this fleet's sessions report them.
|
|
101
|
+
*
|
|
102
|
+
* They belong to the ACCOUNT and not to any one session, but they arrive per snapshot — so the
|
|
103
|
+
* rows do not carry contradicting numbers, they carry the same number at different ages, and
|
|
104
|
+
* the youngest is the one still true. Same rule as everything else in this module: the freshest
|
|
105
|
+
* reading wins.
|
|
106
|
+
*
|
|
107
|
+
* A snapshot dated AFTER the clock that read it is refused rather than believed, which is the
|
|
108
|
+
* verdict `map.ts` reaches on the same value: an NTP correction or a mount whose time runs
|
|
109
|
+
* ahead does not produce a small age, it produces something that is not an age at all — and
|
|
110
|
+
* being negative it would beat every real reading for as long as the skew lasts.
|
|
111
|
+
*
|
|
112
|
+
* Here rather than in either of its readers: the ring samples it every minute and the page's
|
|
113
|
+
* header draws it, and two copies of "which session's word counts" is two answers about one
|
|
114
|
+
* account the day either one is touched.
|
|
115
|
+
*/
|
|
116
|
+
export function accountLimits(rows) {
|
|
117
|
+
let freshest = null;
|
|
118
|
+
for (const r of rows) {
|
|
119
|
+
if (r.rateLimits === null || r.snapshotAgeMs === null || r.snapshotAgeMs < 0)
|
|
120
|
+
continue;
|
|
121
|
+
if (freshest === null || r.snapshotAgeMs < freshest.ageMs) {
|
|
122
|
+
freshest = { rateLimits: r.rateLimits, ageMs: r.snapshotAgeMs };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return freshest;
|
|
126
|
+
}
|
|
94
127
|
// busy first, then unknown (it might be busy), then idle
|
|
95
128
|
const rank = (r) => (r.busy === true ? 0 : r.busy === null ? 1 : 2);
|
|
96
129
|
const round2 = (n) => Math.round(n * 100) / 100;
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// P5 — what the serve already read, kept for a day.
|
|
2
|
+
//
|
|
3
|
+
// The dashboard reads the whole fleet on every request and forgets it on the next one, so a
|
|
4
|
+
// number that moved is a number nobody can point at afterwards. This is that reading, held.
|
|
5
|
+
//
|
|
6
|
+
// In memory and nowhere else, by design. A fleet journal on disk is the one file this tool
|
|
7
|
+
// promised never to write — it would outlive the process that made it, sit in someone's home
|
|
8
|
+
// carrying session ids, working directories and costs, and turn "tarmac reads, it does not
|
|
9
|
+
// keep" into a sentence with an asterisk. What a serve remembers therefore starts when that
|
|
10
|
+
// serve started, which is exactly what `since` says out loud.
|
|
11
|
+
//
|
|
12
|
+
// The ring is bounded before it is anything else: a serve is left open for days, and one
|
|
13
|
+
// sample a minute for a day is a size, while "everything since Tuesday" is a leak with a
|
|
14
|
+
// nicer name.
|
|
15
|
+
import { stateOf } from './map.js';
|
|
16
|
+
import { isWaiting } from './sessions.js';
|
|
17
|
+
import { accountLimits } from './fleet.js';
|
|
18
|
+
/** One sample a minute. Not a setting: see `HISTORY_SLOTS`. */
|
|
19
|
+
export const HISTORY_CADENCE_MS = 60_000;
|
|
20
|
+
/**
|
|
21
|
+
* 24 hours of them. One minute and one day are the product — a cadence knob would let a
|
|
22
|
+
* reader ask this process to spawn `claude agents --json` every second, and a retention knob
|
|
23
|
+
* would let them ask it to hold a week of fleets in RAM. A page that needs to say what it
|
|
24
|
+
* covers reads `since`.
|
|
25
|
+
*/
|
|
26
|
+
export const HISTORY_SLOTS = 1440;
|
|
27
|
+
export function createHistory({ since, cadence }) {
|
|
28
|
+
const slots = [];
|
|
29
|
+
// Whether anything has aged out yet, which is the only thing that can move `since` off the
|
|
30
|
+
// moment the serve started. A ring that is exactly full has dropped nothing.
|
|
31
|
+
let dropped = false;
|
|
32
|
+
const push = (slot) => {
|
|
33
|
+
slots.push(slot);
|
|
34
|
+
// Trimmed on the way in and never on the way out: the array's own length is the
|
|
35
|
+
// guarantee, so no reader has to be told the ring is bounded for it to be true.
|
|
36
|
+
if (slots.length > HISTORY_SLOTS) {
|
|
37
|
+
slots.splice(0, slots.length - HISTORY_SLOTS);
|
|
38
|
+
dropped = true;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
record(fleet) {
|
|
43
|
+
const sample = sampleOf(fleet);
|
|
44
|
+
push({ t: sample.t, sample });
|
|
45
|
+
},
|
|
46
|
+
miss(t) {
|
|
47
|
+
push({ t, sample: null });
|
|
48
|
+
},
|
|
49
|
+
// Rebuilt per read, and never the ring itself: this is the only copy there is and no file
|
|
50
|
+
// to restore it from, so a reader that spliced what it was handed would edit the record.
|
|
51
|
+
read: () => {
|
|
52
|
+
const samples = slots.filter((s) => s.sample !== null).map((s) => s.sample);
|
|
53
|
+
return {
|
|
54
|
+
since: dropped ? slots[0].t : since,
|
|
55
|
+
cadence,
|
|
56
|
+
samples,
|
|
57
|
+
missed: slots.length - samples.length,
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A fleet reading, reduced to what a replay reads back.
|
|
64
|
+
*
|
|
65
|
+
* The names are the omission that matters, and it is not an oversight to be commented — it is
|
|
66
|
+
* pinned by a test. `claude agents --json` names a background session after the PROMPT it was
|
|
67
|
+
* given, so a fleet's names are a record of what its agents were told to do. The live view may
|
|
68
|
+
* show that, because someone is looking at their own screen in the present tense; a day of
|
|
69
|
+
* them, retained by a process and served back on a route, is a different object. No name for
|
|
70
|
+
* anyone, so there is no rule about kinds to get wrong later.
|
|
71
|
+
*/
|
|
72
|
+
function sampleOf({ rows, health }) {
|
|
73
|
+
return {
|
|
74
|
+
t: health.generatedAt,
|
|
75
|
+
sessions: rows.map((r) => ({
|
|
76
|
+
sid: r.sessionId,
|
|
77
|
+
project: r.project,
|
|
78
|
+
kind: r.kind,
|
|
79
|
+
state: stateOf(r),
|
|
80
|
+
waitingFor: isWaiting(r) ? r.waitingFor : null,
|
|
81
|
+
ctxState: r.ctxState,
|
|
82
|
+
ctxPct: r.ctxPct,
|
|
83
|
+
costUsd: r.costUsd,
|
|
84
|
+
})),
|
|
85
|
+
// The reading, without its age: the ring keeps each reading and never how old it was.
|
|
86
|
+
rateLimits: accountLimits(rows)?.rateLimits ?? null,
|
|
87
|
+
};
|
|
88
|
+
}
|
package/dist/limits.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// P3 — the account's two windows.
|
|
2
|
+
//
|
|
3
|
+
// Every session on the page spends from the same two allowances, so they belong to the page
|
|
4
|
+
// and not to a node: five hours, and seven days. The statusline payload carries both, as a
|
|
5
|
+
// used percentage and the epoch each one rolls over at.
|
|
6
|
+
//
|
|
7
|
+
// This module reads a shape someone else versions, which is why it reads it the way
|
|
8
|
+
// `snapshots.ts` reads the context window: the PRESENCE of a key says whether a number was
|
|
9
|
+
// ever taken, and the value says what it was. A key that is gone, or holding something that is
|
|
10
|
+
// not a percentage, is a schema that moved — and neither one is 0%. There is exactly one thing
|
|
11
|
+
// a dashboard must never do with an account's limits, and that is report a window it could not
|
|
12
|
+
// read as a window with room to spare.
|
|
13
|
+
//
|
|
14
|
+
// The reset is turned into a stretch of time HERE, against the clock the reading was taken
|
|
15
|
+
// with, so that no renderer has to go and find a clock of its own: the live page subtracts from
|
|
16
|
+
// the moment it read the fleet, and a replay subtracts from the minute its sample was taken.
|
|
17
|
+
// Both then have one rule about what a negative answer means.
|
|
18
|
+
/**
|
|
19
|
+
* The two windows, in the order they are read. A table rather than two hand-written branches:
|
|
20
|
+
* the page script draws the same pair in the browser for a replayed minute, and it is handed
|
|
21
|
+
* this list rather than keeping a second copy of the vocabulary.
|
|
22
|
+
*/
|
|
23
|
+
export const LIMIT_WINDOWS = [
|
|
24
|
+
{ key: 'five_hour', label: '5h', said: 'five-hour window' },
|
|
25
|
+
{ key: 'seven_day', label: '7d', said: 'seven-day window' },
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* How far from the reading a reset may land and still be one.
|
|
29
|
+
*
|
|
30
|
+
* The same discipline the percentage gets, one field over: `used_percentage` outside 0-100 is
|
|
31
|
+
* refused rather than drawn, and a `resets_at` outside a plausible distance is refused for the
|
|
32
|
+
* same reason. The longest window here is seven days, so nothing this account resets at is more
|
|
33
|
+
* than eight away — while the two ways this field can move are both far outside that: the same
|
|
34
|
+
* number in milliseconds lands fifty thousand years out, and `0`, the sentinel an unset field so
|
|
35
|
+
* often is, lands in 1970. Both were rendered with a straight face ("resets in 19656250d").
|
|
36
|
+
*/
|
|
37
|
+
export const RESET_HORIZON_MS = 8 * 24 * 3600 * 1000;
|
|
38
|
+
/** Both windows, always — a window that could not be read is a gauge that says so. */
|
|
39
|
+
export function readLimits(rateLimits, now) {
|
|
40
|
+
// `rate_limits: []` and `rate_limits: "none"` are legal JSON and not a pair of windows.
|
|
41
|
+
// Neither may reach the lookup below as something to index.
|
|
42
|
+
const ok = rateLimits !== null && typeof rateLimits === 'object' && !Array.isArray(rateLimits);
|
|
43
|
+
return LIMIT_WINDOWS.map(({ key, label, said }) => {
|
|
44
|
+
const w = ok ? rateLimits[key] : undefined;
|
|
45
|
+
const has = w !== null && typeof w === 'object' && !Array.isArray(w) && 'used_percentage' in w;
|
|
46
|
+
const v = has ? w.used_percentage : undefined;
|
|
47
|
+
// Present and null: a window whose number has not been taken yet. Absent, or holding
|
|
48
|
+
// something that is not a percentage: the shape moved. The discriminant is the key.
|
|
49
|
+
const pct = has && typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 100 ? Math.floor(v) : null;
|
|
50
|
+
const why = pct !== null ? null : rateLimits == null || (has && v === null) ? 'absent' : 'drift';
|
|
51
|
+
const at = has && typeof w.resets_at === 'number' && Number.isFinite(w.resets_at) ? w.resets_at : null;
|
|
52
|
+
const resetsInMs = at === null ? null : at * 1000 - now;
|
|
53
|
+
return {
|
|
54
|
+
key,
|
|
55
|
+
label,
|
|
56
|
+
said,
|
|
57
|
+
pct,
|
|
58
|
+
why,
|
|
59
|
+
resetsInMs: resetsInMs === null || Math.abs(resetsInMs) > RESET_HORIZON_MS ? null : resetsInMs,
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
}
|
package/dist/map.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// A view over the fleet `buildFleet` already produced. It opens no second source: every
|
|
4
4
|
// field below is derived from a row that is already on the page as a table line.
|
|
5
|
+
import { isWaiting } from './sessions.js';
|
|
5
6
|
/**
|
|
6
7
|
* How recently a snapshot must have landed for its node to pulse. Two of the page's poll
|
|
7
8
|
* intervals (5s): a session whose terminal keeps drawing frames keeps its heartbeat across
|
|
@@ -107,4 +108,12 @@ function readingOf(r) {
|
|
|
107
108
|
return 'undated';
|
|
108
109
|
return r.stale ? 'stale' : 'live';
|
|
109
110
|
}
|
|
110
|
-
|
|
111
|
+
/**
|
|
112
|
+
* The four words a node can be, out of a boolean that only has three answers.
|
|
113
|
+
*
|
|
114
|
+
* `waiting` is asked BEFORE the boolean's `null`, and that order is the point: a session
|
|
115
|
+
* halted until a human answers carries `busy: null` — "is it working" has no honest answer —
|
|
116
|
+
* which is the same null an unrecognised word gets. Left to the boolean alone, the one
|
|
117
|
+
* session that is blocked on YOU drew as the amber "tarmac does not know this word".
|
|
118
|
+
*/
|
|
119
|
+
export const stateOf = (r) => r.busy === true ? 'busy' : isWaiting(r) ? 'waiting' : r.busy === false ? 'idle' : 'unknown';
|
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 } 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
|
|
@@ -287,10 +304,86 @@ export function renderLive(fleet) {
|
|
|
287
304
|
<tbody>${rows.map(renderRow).join('')}</tbody>
|
|
288
305
|
</table></div></div>
|
|
289
306
|
<div class="view view-map">${renderMap(fleet)}</div>`;
|
|
290
|
-
return `<div
|
|
307
|
+
return `<div id="limits-src" hidden>${renderLimits(fleet)}</div>
|
|
308
|
+
<div class="meta">${health.sessions} session${health.sessions === 1 ? '' : 's'} · ${health.busy} busy · ${cost(health)} · ${esc(new Date(health.generatedAt).toISOString())}</div>
|
|
291
309
|
${warnings.map((w) => `<div class="warn">${esc(w)}</div>`).join('')}
|
|
292
310
|
${body}`;
|
|
293
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* The account's two windows, for the page's header.
|
|
314
|
+
*
|
|
315
|
+
* They are the one pair of numbers here that is not about a session: every session on the page
|
|
316
|
+
* spends from the same five-hour and seven-day allowance, so the gauges sit at the top of the
|
|
317
|
+
* page rather than on a node — and the fleet's own rule decides whose reading counts when the
|
|
318
|
+
* sessions carry the same number at different ages.
|
|
319
|
+
*
|
|
320
|
+
* Rendered into the FRAGMENT as well as into the shell, in a slot the script copies up on every
|
|
321
|
+
* swap. The header is the shell's — it has to survive a poll, the tabs and a replay — but the
|
|
322
|
+
* numbers are the fleet's, and the fleet is what the fragment carries. A gauge left in the shell
|
|
323
|
+
* alone would be as old as the tab.
|
|
324
|
+
*/
|
|
325
|
+
export function renderLimits({ rows, health }) {
|
|
326
|
+
const account = accountLimits(rows);
|
|
327
|
+
const gauges = readLimits(account === null ? null : account.rateLimits, health.generatedAt)
|
|
328
|
+
.map(gauge)
|
|
329
|
+
.join('');
|
|
330
|
+
// Dated when the snapshot behind it is past the threshold, exactly as the table dates a stale
|
|
331
|
+
// context. It matters more here than anywhere else on the page: the percentage is as old as
|
|
332
|
+
// that snapshot, while the countdown beside it is recomputed on every five-second re-render —
|
|
333
|
+
// so an undated pair puts a frozen number next to a visibly moving one and lets the reader
|
|
334
|
+
// assume both are now.
|
|
335
|
+
//
|
|
336
|
+
// Once for the two, not once each: both windows come out of the SAME snapshot, and the same
|
|
337
|
+
// fact said twice is noise. The replay has no equivalent — the ring keeps each reading and
|
|
338
|
+
// never how old it was, which is why nothing replayed on this page is dated.
|
|
339
|
+
const stale = account !== null && account.ageMs > health.staleAfterMs;
|
|
340
|
+
return gauges + (stale ? `<span class="stale">! ${esc(asOfAge(account.ageMs))} ago</span>` : '');
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* One window. Four things in a line: which window it is, a bar for the glance, the number that
|
|
344
|
+
* is authoritative, and how long is left. The bar is `aria-hidden` because it says nothing the
|
|
345
|
+
* number does not, and the abbreviation is replaced rather than doubled for a reader who hears
|
|
346
|
+
* the page — "5h" is a label on a screen and a syllable in an ear.
|
|
347
|
+
*/
|
|
348
|
+
function gauge(g) {
|
|
349
|
+
// No fill, ever, for a window nobody read: an empty bar is what an account at 0% wears, and
|
|
350
|
+
// "I could not look" must not be able to wear it. The same dotted emptiness as an unmeasured
|
|
351
|
+
// dial, in the shape a bar has.
|
|
352
|
+
const rail = g.pct === null
|
|
353
|
+
? `<span class="rail unmeasured" aria-hidden="true"></span>`
|
|
354
|
+
: `<span class="rail" aria-hidden="true"><i style="width:${g.pct}%"></i></span>`;
|
|
355
|
+
return (`<div class="gauge"><span class="lbl" aria-hidden="true">${g.label}</span><span class="sr">${g.said}</span>` +
|
|
356
|
+
`${rail}<span class="num">${g.pct === null ? dash() : `${g.pct}%`}</span>` +
|
|
357
|
+
`<span class="reset">${g.pct === null ? LIMIT_WHY[g.why] : resetWords(g.resetsInMs)}</span></div>`);
|
|
358
|
+
}
|
|
359
|
+
/** Which kind of missing a missing window is, in the two words both surfaces use. */
|
|
360
|
+
const LIMIT_WHY = { absent: 'no reading', drift: 'schema drift' };
|
|
361
|
+
/**
|
|
362
|
+
* The reset, as a stretch of time rather than as the epoch the payload carries.
|
|
363
|
+
*
|
|
364
|
+
* A negative one is not a countdown to be printed with a minus sign: the window rolled over
|
|
365
|
+
* after the reading that reported it, so the percentage beside these words belongs to a window
|
|
366
|
+
* that no longer exists. Saying that is the whole point of showing a reset at all.
|
|
367
|
+
*/
|
|
368
|
+
const resetWords = (ms) => ms === null ? `reset ${dash()}` : ms > 0 ? `resets in ${left(ms)}` : `reset was due ${left(-ms)} ago`;
|
|
369
|
+
/**
|
|
370
|
+
* How long, in the two units that matter at each scale. Deliberately finer than `duration()`
|
|
371
|
+
* next door, which floors a session's uptime to whole hours: five hours is a window someone
|
|
372
|
+
* plans the next hour around, and "resets in 2h" said anywhere between 2h00 and 2h59 is the
|
|
373
|
+
* kind of rounding that makes a reader stop believing the number.
|
|
374
|
+
*/
|
|
375
|
+
function left(ms) {
|
|
376
|
+
const m = Math.floor(ms / 60000);
|
|
377
|
+
if (m < 1)
|
|
378
|
+
return '<1m';
|
|
379
|
+
if (m < 60)
|
|
380
|
+
return `${m}m`;
|
|
381
|
+
const h = Math.floor(m / 60);
|
|
382
|
+
if (h < 24)
|
|
383
|
+
return m % 60 === 0 ? `${h}h` : `${h}h ${m % 60}m`;
|
|
384
|
+
const d = Math.floor(h / 24);
|
|
385
|
+
return h % 24 === 0 ? `${d}d` : `${d}d ${h % 24}h`;
|
|
386
|
+
}
|
|
294
387
|
/**
|
|
295
388
|
* What to say instead of a fleet. Discovery returning entries we could not identify is not
|
|
296
389
|
* an empty fleet, and saying "none found" would hide a schema change behind a calm, wrong
|
|
@@ -346,14 +439,20 @@ function ago(ms) {
|
|
|
346
439
|
return m < 60 ? `${m}m` : `${Math.round(m / 60)}h`;
|
|
347
440
|
}
|
|
348
441
|
export function renderPage(fleet, view = 'table') {
|
|
442
|
+
// The header's copy. `renderLive` below renders its own, out of this same fleet and through
|
|
443
|
+
// this same function — two calls of one pure renderer over one reading, which is what keeps
|
|
444
|
+
// the pair the reader sees and the pair the script will copy up from being two accounts.
|
|
445
|
+
const gauges = renderLimits(fleet);
|
|
349
446
|
return `<!doctype html>
|
|
350
447
|
<html lang="en"><head>
|
|
351
448
|
<meta charset="utf-8">
|
|
352
449
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
353
450
|
<title>tarmac — fleet</title>
|
|
354
451
|
<style>
|
|
355
|
-
|
|
356
|
-
|
|
452
|
+
/* --wait is a fourth hue rather than the warning one: a session blocked on a human is not
|
|
453
|
+
a fault, and painting it amber puts it in the same column as "tarmac cannot read this". */
|
|
454
|
+
:root { color-scheme: light dark; --fg:#111; --dim:#6b7280; --line:#e5e7eb; --bg:#fff; --warn:#b45309; --warnbg:#fffbeb; --busy:#047857; --wait:#1d4ed8; }
|
|
455
|
+
@media (prefers-color-scheme: dark) { :root { --fg:#e5e7eb; --dim:#9ca3af; --line:#374151; --bg:#0b0f14; --warn:#fbbf24; --warnbg:#231a06; --busy:#34d399; --wait:#93c5fd; } }
|
|
357
456
|
body { margin:0; padding:2rem 1.25rem; background:var(--bg); color:var(--fg);
|
|
358
457
|
font:14px/1.5 ui-sans-serif,-apple-system,"Segoe UI",sans-serif; }
|
|
359
458
|
header { display:flex; align-items:baseline; gap:1rem; flex-wrap:wrap; margin-bottom:1rem; }
|
|
@@ -377,11 +476,13 @@ export function renderPage(fleet, view = 'table') {
|
|
|
377
476
|
.pill { display:inline-block; font-size:.8rem; font-weight:600; padding:.05rem .5rem;
|
|
378
477
|
border:1px solid currentColor; border-radius:99px; white-space:nowrap; }
|
|
379
478
|
.pill.busy { color:var(--busy); }
|
|
479
|
+
.pill.waiting { color:var(--wait); }
|
|
380
480
|
.pill.unknown { color:var(--warn); }
|
|
381
481
|
.pill.idle { color:var(--dim); font-weight:400; }
|
|
382
482
|
/* The weight the sort deserves: busy rows carry an accent and a bold name. */
|
|
383
483
|
td:first-child { border-left:3px solid transparent; }
|
|
384
484
|
tr[data-state="busy"] td:first-child { border-left-color:var(--busy); }
|
|
485
|
+
tr[data-state="waiting"] td:first-child { border-left-color:var(--wait); }
|
|
385
486
|
tr[data-state="unknown"] td:first-child { border-left-color:var(--warn); }
|
|
386
487
|
tr[data-state="busy"] .project { font-weight:700; }
|
|
387
488
|
/* The bar reads a magnitude at a glance; the number beside it is what is authoritative.
|
|
@@ -405,6 +506,72 @@ export function renderPage(fleet, view = 'table') {
|
|
|
405
506
|
body[data-view="table"] .view-map { display:none; }
|
|
406
507
|
body[data-view="map"] .view-table { display:none; }
|
|
407
508
|
|
|
509
|
+
/* ── the account's two windows ───────────────────────────────────────────────────────
|
|
510
|
+
In the header, because a rate limit is the account's and not a node's. Slim on purpose:
|
|
511
|
+
the fleet is what the page is about, and these two numbers are the weather it flies in.
|
|
512
|
+
Laid out with flex behind the same :not([hidden]) guard the replay containers carry —
|
|
513
|
+
the replayed pair ships hidden, and a display in a stylesheet beats the attribute. */
|
|
514
|
+
.limits:not([hidden]) { display:flex; gap:1rem; flex-wrap:wrap; align-items:center; }
|
|
515
|
+
.gauge { display:flex; align-items:baseline; gap:.35rem; font-size:.8rem; }
|
|
516
|
+
/* Not upper-cased, alone among the small labels on this page: "5H" is not an hour, and a
|
|
517
|
+
unit that has been shouted reads as a different unit. */
|
|
518
|
+
.gauge .lbl { color:var(--dim); font-weight:600; letter-spacing:.04em; }
|
|
519
|
+
.gauge .num { font-variant-numeric:tabular-nums; font-weight:650; }
|
|
520
|
+
.gauge .reset { color:var(--dim); }
|
|
521
|
+
/* Same bargain as the row bars: a glance at a magnitude, in the quiet ink of a secondary
|
|
522
|
+
fact, beside the number that is the authority. Its own class rather than .bar — that one
|
|
523
|
+
is dropped below 46rem, where a card layout gives every value the name of its column, and
|
|
524
|
+
these two have no column to be named by. */
|
|
525
|
+
.gauge .rail { display:inline-block; width:3.5rem; height:.3rem; border-radius:99px;
|
|
526
|
+
background:var(--line); align-self:center; }
|
|
527
|
+
.gauge .rail > i { display:block; height:100%; border-radius:99px; background:var(--dim); }
|
|
528
|
+
/* Nothing was measured — the dotted track of an unmeasured dial, in the shape of a bar. An
|
|
529
|
+
empty rail is what an account at 0% wears, and the two must not match. */
|
|
530
|
+
.gauge .rail.unmeasured { background:repeating-linear-gradient(90deg,var(--line) 0 2px,transparent 2px 8px); }
|
|
531
|
+
/* The live pair goes down with the live fragment: they are about now, and left up they would
|
|
532
|
+
be the one present-tense number standing over a fleet three hours old. */
|
|
533
|
+
body.replaying #limits { display:none; }
|
|
534
|
+
/* The replayed pair leads the past fleet rather than sitting on top of its totals. */
|
|
535
|
+
#replay-limits { margin-bottom:.2rem; }
|
|
536
|
+
|
|
537
|
+
/* ── the scrubber ────────────────────────────────────────────────────────────────────
|
|
538
|
+
Under the map, and only under the map: the record holds what the MAP draws, so a
|
|
539
|
+
scrubber over the table would offer a drag onto rows it cannot fill.
|
|
540
|
+
Everything here lives in the shell for the same reason the tabs do: the /live fragment is
|
|
541
|
+
swapped into innerHTML every five seconds, and a handle inside it would be dragged back
|
|
542
|
+
to the present by a poll nobody asked for. */
|
|
543
|
+
body[data-view="table"] #replay, body[data-view="table"] #replay-view { display:none; }
|
|
544
|
+
/* One fleet at a time, and the whole fragment rather than only its map: the fragment's
|
|
545
|
+
header is the LIVE count, cost and timestamp, and hiding the map alone left it sitting
|
|
546
|
+
directly above the replayed one — two totals of two different moments, the pair dated
|
|
547
|
+
with the present. The warnings above them are about the present too. The failure banner
|
|
548
|
+
is in the shell, so a refresh that breaks mid-replay still says so. */
|
|
549
|
+
body.replaying #live { display:none; }
|
|
550
|
+
/* Both of these are laid out with flex, and both are hidden by the attribute until a script
|
|
551
|
+
raises them — so the display is refused to a hidden one explicitly. The hidden attribute
|
|
552
|
+
is only a UA rule of display:none, and any display a stylesheet gives the same element
|
|
553
|
+
beats it: unguarded, this page came up announcing a replay nobody had asked for. */
|
|
554
|
+
.replay:not([hidden]) { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; margin-top:1rem;
|
|
555
|
+
padding-top:.7rem; border-top:1px solid var(--line); }
|
|
556
|
+
.replay button { font:inherit; font-size:.8rem; color:var(--fg); background:transparent;
|
|
557
|
+
border:1px solid var(--line); border-radius:99px; padding:.15rem .8rem; cursor:pointer; }
|
|
558
|
+
.replay input[type="range"] { flex:1; min-width:10rem; accent-color:var(--dim); }
|
|
559
|
+
.replay input[type="range"]:disabled { opacity:.4; }
|
|
560
|
+
/* The two things the reader has to be able to read while dragging: the minute under the
|
|
561
|
+
handle, and what the whole range covers. Tabular, so neither jitters as it counts. */
|
|
562
|
+
#replay-at { font-variant-numeric:tabular-nums; font-weight:600; }
|
|
563
|
+
.replay .covers { flex-basis:100%; color:var(--dim); font-size:.75rem; }
|
|
564
|
+
/* The banner wears the warning style on purpose: a page showing a past minute as though it were the
|
|
565
|
+
fleet is the worst thing this dashboard could do, so it wears the loudest thing it has. */
|
|
566
|
+
/* Sticky, because the handle is at the bottom of a map that can be taller than the
|
|
567
|
+
viewport: a reader dragging with the "this is the past" banner scrolled off the top is
|
|
568
|
+
a reader the banner is not warning. */
|
|
569
|
+
.replaying-note:not([hidden]) { display:flex; align-items:baseline; gap:.6rem; flex-wrap:wrap;
|
|
570
|
+
position:sticky; top:0; z-index:1; }
|
|
571
|
+
.replaying-note button { font:inherit; font-size:.75rem; font-weight:600; color:inherit;
|
|
572
|
+
background:transparent; border:1px solid currentColor; border-radius:99px;
|
|
573
|
+
padding:.05rem .7rem; cursor:pointer; }
|
|
574
|
+
|
|
408
575
|
/* ── the map ─────────────────────────────────────────────────────────────────────────
|
|
409
576
|
One node per session. The arc is the context, its weight is how much that reading may
|
|
410
577
|
be believed, and the halo — the only thing on this page that moves — says a frame
|
|
@@ -413,6 +580,7 @@ export function renderPage(fleet, view = 'table') {
|
|
|
413
580
|
.node { border:1px solid var(--line); border-radius:10px; padding:.8rem .85rem .7rem;
|
|
414
581
|
display:flex; flex-direction:column; align-items:center; text-align:center; }
|
|
415
582
|
.node[data-state="busy"] { border-color:color-mix(in srgb, var(--busy) 45%, var(--line)); }
|
|
583
|
+
.node[data-state="waiting"] { border-color:color-mix(in srgb, var(--wait) 45%, var(--line)); }
|
|
416
584
|
/* An agent is a smaller body in the same system, next to the session it shares a directory
|
|
417
585
|
with — never inside it. Tinted rather than outlined, and hooked, so it reads as the
|
|
418
586
|
session's dependent without a line claiming a parentage the source never published. */
|
|
@@ -439,6 +607,11 @@ export function renderPage(fleet, view = 'table') {
|
|
|
439
607
|
stroke-dasharray, which is what carries the percentage. */
|
|
440
608
|
.node[data-reading="stale"] .arc, .node[data-reading="undated"] .arc {
|
|
441
609
|
stroke:var(--warn); stroke-width:2.5; opacity:.7; }
|
|
610
|
+
/* A replayed reading. Its EXTENT is what the record vouches for; its age is the one thing
|
|
611
|
+
the ring never kept, so it may not wear the solid arc that means "as current as a reading
|
|
612
|
+
gets" — nor the warning hue of a stale one, which would claim the opposite. Between the
|
|
613
|
+
two: full colour, a shade lighter, and no date underneath it. */
|
|
614
|
+
.node[data-reading="undatable"] .arc { stroke-width:4; opacity:.85; }
|
|
442
615
|
/* Nothing was measured. Keyed on the measurement and never on the age of the file: a
|
|
443
616
|
solid empty ring is what a session measured at 0% wears, and the two must not match. */
|
|
444
617
|
.track.unmeasured { stroke-dasharray:2 6; stroke-linecap:round; }
|
|
@@ -465,8 +638,12 @@ export function renderPage(fleet, view = 'table') {
|
|
|
465
638
|
.node[data-state="busy"] .who .project { font-weight:700; }
|
|
466
639
|
.shape { font-size:.7rem; color:var(--dim); }
|
|
467
640
|
.node[data-state="busy"] .shape { color:var(--busy); }
|
|
641
|
+
.node[data-state="waiting"] .shape { color:var(--wait); }
|
|
468
642
|
.node[data-state="unknown"] .shape { color:var(--warn); }
|
|
469
643
|
.sub { color:var(--dim); font-size:.76rem; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
644
|
+
/* The one caption that is not a footnote: it is why this node is not working, and it sits
|
|
645
|
+
directly under the name in the state's own hue rather than in the grey of the rest. */
|
|
646
|
+
.sub.waiting-for { color:var(--wait); font-weight:600; }
|
|
470
647
|
.asof { font-size:.72rem; color:var(--dim); font-variant-numeric:tabular-nums; margin-top:.15rem; }
|
|
471
648
|
.asof.stale { color:var(--warn); font-weight:600; }
|
|
472
649
|
@media (max-width: 30rem) { .map { grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr)); gap:.6rem; } }
|
|
@@ -483,6 +660,7 @@ export function renderPage(fleet, view = 'table') {
|
|
|
483
660
|
tr { border:1px solid var(--line); border-left-width:3px; border-radius:8px;
|
|
484
661
|
padding:.35rem .7rem; margin-bottom:.6rem; }
|
|
485
662
|
tr[data-state="busy"] { border-left-color:var(--busy); }
|
|
663
|
+
tr[data-state="waiting"] { border-left-color:var(--wait); }
|
|
486
664
|
tr[data-state="unknown"] { border-left-color:var(--warn); }
|
|
487
665
|
td, td:first-child { border:0; padding:.2rem 0; white-space:normal;
|
|
488
666
|
display:flex; justify-content:space-between; align-items:baseline; gap:1rem; }
|
|
@@ -503,6 +681,11 @@ export function renderPage(fleet, view = 'table') {
|
|
|
503
681
|
<a href="/"${view === 'table' ? ' aria-current="page"' : ''}>Table</a>
|
|
504
682
|
<a href="/map"${view === 'map' ? ' aria-current="page"' : ''}>Map</a>
|
|
505
683
|
</nav>
|
|
684
|
+
<!-- The account's two windows, page-level because that is what they are: a limit belongs to
|
|
685
|
+
the account every session below is spending from, not to any one of them. Their VALUES
|
|
686
|
+
come up from the fragment on every poll (the script's limits-src copy), so the header
|
|
687
|
+
structure can be the shell's without the numbers being as old as the tab. -->
|
|
688
|
+
<div class="limits" id="limits" role="group" aria-label="account rate limits">${gauges}</div>
|
|
506
689
|
<!-- Not "updated just now". If the script never runs — a policy-injected CSP without
|
|
507
690
|
'unsafe-inline', a script error — that text would stand as a permanent lie, and
|
|
508
691
|
<noscript> would not fire to correct it because JavaScript is enabled. The page's one
|
|
@@ -510,12 +693,42 @@ export function renderPage(fleet, view = 'table') {
|
|
|
510
693
|
<span class="freshness"><span class="pulse" aria-hidden="true"></span><span id="age">updated —</span></span>
|
|
511
694
|
</header>
|
|
512
695
|
<div class="warn offline" id="offline" hidden>
|
|
513
|
-
<strong>⚠ refresh failing</strong> — nothing
|
|
696
|
+
<strong>⚠ refresh failing</strong> — nothing on this page has moved since the time in the header.
|
|
514
697
|
<span id="why"></span>
|
|
515
698
|
</div>
|
|
699
|
+
<!-- The one claim on this page that could be a lie, so it is the loudest element on it and it
|
|
700
|
+
carries the minute it is showing. Hidden until a script raises it: with no script there
|
|
701
|
+
is no replay, and a banner about one would be a warning about nothing. -->
|
|
702
|
+
<!-- role="status" because it appears without a reload and without focus moving: drawn only,
|
|
703
|
+
it is the page's loudest claim and its most invisible one. -->
|
|
704
|
+
<div class="warn replaying-note" id="replaying" role="status" hidden>
|
|
705
|
+
<strong>⏹ replaying <span id="replay-at"></span></strong>
|
|
706
|
+
<span>— a reading from the past, not the fleet now.</span>
|
|
707
|
+
<button type="button" id="to-live">Back to live</button>
|
|
708
|
+
</div>
|
|
516
709
|
<noscript><div class="warn">JavaScript is off, so this page will not refresh itself. Reload it to see the fleet now.</div></noscript>
|
|
517
710
|
<div id="live">${renderLive(fleet)}</div>
|
|
518
|
-
|
|
711
|
+
<!-- Where the past is drawn: the shell's own map, in the place the live one occupies, so
|
|
712
|
+
that swapping the fragment underneath cannot repaint what the reader is scrubbing. -->
|
|
713
|
+
<div id="replay-view" hidden>
|
|
714
|
+
<!-- The same pair, for the minute under the reader's hand — and here rather than in the
|
|
715
|
+
header, where the live pair sits. The banner that says "this is the past" is below the
|
|
716
|
+
header: an account drawn above it would be the one past number on the page with nothing
|
|
717
|
+
over it saying so, and the first thing a screen reader reaches, long before the warning.
|
|
718
|
+
Hidden until a script raises it: with no script there is no replay, and an empty gauge
|
|
719
|
+
would be a claim about nothing. -->
|
|
720
|
+
<div class="limits" id="replay-limits" role="group" aria-label="account rate limits, at the minute being replayed" hidden></div>
|
|
721
|
+
<div class="meta" id="replay-meta"></div>
|
|
722
|
+
<div class="map" id="replay-map"></div>
|
|
723
|
+
</div>
|
|
724
|
+
<!-- A dead handle is worse than no handle: this is revealed once the record is in hand, and
|
|
725
|
+
what it says it covers is whatever the record answered with. -->
|
|
726
|
+
<div class="replay" id="replay" hidden>
|
|
727
|
+
<button type="button" id="play">Play</button>
|
|
728
|
+
<input type="range" id="scrub" min="0" max="0" step="1" value="0" disabled aria-label="Replay position">
|
|
729
|
+
<div class="covers" id="covers"></div>
|
|
730
|
+
</div>
|
|
731
|
+
<script>${pageScript(view)}</script>
|
|
519
732
|
</body></html>
|
|
520
733
|
`;
|
|
521
734
|
}
|
|
@@ -531,9 +744,15 @@ export function renderPage(fleet, view = 'table') {
|
|
|
531
744
|
* polled for a reader who is not there. A poll is the only one of the three where the client
|
|
532
745
|
* decides — so a hidden tab simply stops asking, and a waking one asks at once.
|
|
533
746
|
*
|
|
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.
|
|
747
|
+
* The page therefore owns exactly two facts about the present: when it last heard from the
|
|
748
|
+
* server, and whether the last attempt failed. Everything a reader interprets about NOW is
|
|
749
|
+
* rendered by `renderLive` on the server, where the suite can reach it.
|
|
750
|
+
*
|
|
751
|
+
* The replay below is the one exception, and it is one the issue asks for: scrubbing a day
|
|
752
|
+
* has to be a lookup in samples the page already holds, or every pixel of a drag would be a
|
|
753
|
+
* request and a `claude agents --json` behind it. So a second, smaller renderer lives in the
|
|
754
|
+
* browser — fed the same three words, the same three glyphs and the same dial geometry as the
|
|
755
|
+
* server's, by interpolation rather than by copy, and executed by `test/replay-script`.
|
|
537
756
|
*/
|
|
538
757
|
export const REFRESH_MS = 5000;
|
|
539
758
|
/**
|
|
@@ -542,10 +761,30 @@ export const REFRESH_MS = 5000;
|
|
|
542
761
|
* on the server side first and arrives with a real reason instead of this generic one.
|
|
543
762
|
*/
|
|
544
763
|
const STALL_MS = 20000;
|
|
545
|
-
|
|
764
|
+
/**
|
|
765
|
+
* How fast play walks the record — one reading per step, so a serve that has seen ten minutes
|
|
766
|
+
* plays for a second and a full day for two and a half minutes. It is a step interval and not
|
|
767
|
+
* a total duration on purpose: the samples are not evenly spaced (a minute the collector
|
|
768
|
+
* missed is a minute nobody recorded), so a fixed run time would silently speed up over the
|
|
769
|
+
* gaps and make the day look busier than it was.
|
|
770
|
+
*/
|
|
771
|
+
const PLAY_STEP_MS = 100;
|
|
772
|
+
/**
|
|
773
|
+
* The same walk for a reader who asked their system for less motion. Play is the one thing
|
|
774
|
+
* here that moves, and the honest answer to that preference is not to take the feature away —
|
|
775
|
+
* it is to stop flickering ten frames a second at someone who said that hurts.
|
|
776
|
+
*/
|
|
777
|
+
const PLAY_STEP_CALM_MS = 1000;
|
|
778
|
+
/**
|
|
779
|
+
* A function, not a constant: it reads the vocabulary and the geometry declared below it, and
|
|
780
|
+
* it takes the view because only one of the two has a scrubber to feed.
|
|
781
|
+
*/
|
|
782
|
+
function pageScript(view) {
|
|
783
|
+
return `
|
|
546
784
|
(function () {
|
|
547
785
|
var live = document.getElementById('live'), age = document.getElementById('age');
|
|
548
786
|
var off = document.getElementById('offline'), why = document.getElementById('why');
|
|
787
|
+
var limits = document.getElementById('limits');
|
|
549
788
|
var last = Date.now(), failing = false, inFlight = false, since = 0, gen = 0;
|
|
550
789
|
|
|
551
790
|
function ago(ms) {
|
|
@@ -607,6 +846,15 @@ const SCRIPT = `
|
|
|
607
846
|
if (body.trim() === '') throw new Error('The server answered with an empty page.');
|
|
608
847
|
if (!mineStill()) return;
|
|
609
848
|
live.innerHTML = body;
|
|
849
|
+
// The account's gauges, lifted out of the fragment and into the header where they
|
|
850
|
+
// belong. Here rather than in the fragment's own place on the page because a limit is
|
|
851
|
+
// the account's and not a session's; here rather than in the shell alone because the
|
|
852
|
+
// NUMBERS arrive with the fleet, and a five-hour window that stopped counting down
|
|
853
|
+
// would be the one thing on this page still claiming to be about now.
|
|
854
|
+
// Inside the accepted-answer branch on purpose: a body that was refused is a body
|
|
855
|
+
// nothing is read out of, the account's numbers included.
|
|
856
|
+
var src = document.getElementById('limits-src');
|
|
857
|
+
if (src) limits.innerHTML = src.innerHTML;
|
|
610
858
|
last = Date.now();
|
|
611
859
|
failing = false;
|
|
612
860
|
});
|
|
@@ -625,11 +873,340 @@ const SCRIPT = `
|
|
|
625
873
|
});
|
|
626
874
|
}
|
|
627
875
|
|
|
876
|
+
// ── the day behind the present ──────────────────────────────────────────────────────
|
|
877
|
+
//
|
|
878
|
+
// The record is asked for once, and every drag after that is a lookup in it. The state of
|
|
879
|
+
// the replay lives here rather than in the fragment for the same reason the tabs do: /live
|
|
880
|
+
// is swapped wholesale every five seconds, and the reader's hand is not the server's to move.
|
|
881
|
+
|
|
882
|
+
var replay = document.getElementById('replay'), scrub = document.getElementById('scrub');
|
|
883
|
+
var playBtn = document.getElementById('play'), covers = document.getElementById('covers');
|
|
884
|
+
var rview = document.getElementById('replay-view'), rmap = document.getElementById('replay-map');
|
|
885
|
+
var rmeta = document.getElementById('replay-meta'), note = document.getElementById('replaying');
|
|
886
|
+
var rlimits = document.getElementById('replay-limits');
|
|
887
|
+
var atEl = document.getElementById('replay-at'), toLive = document.getElementById('to-live');
|
|
888
|
+
var record = null, recordAt = 0, at = -1, replaying = false, playing = null, hgen = 0;
|
|
889
|
+
|
|
890
|
+
// The vocabulary and the geometry, handed over rather than written twice: three words for
|
|
891
|
+
// the three kinds of missing, three glyphs for the three states, one dial radius.
|
|
892
|
+
var WHY = ${JSON.stringify(CTX_WHY)}, SHAPE = ${JSON.stringify(SHAPE)};
|
|
893
|
+
var INTERACTIVE = ${JSON.stringify(INTERACTIVE)};
|
|
894
|
+
var ENT = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
895
|
+
var R = ${DIAL_R}, C = 2 * Math.PI * R;
|
|
896
|
+
var STEP = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
897
|
+
? ${PLAY_STEP_CALM_MS} : ${PLAY_STEP_MS};
|
|
898
|
+
|
|
899
|
+
function esc(v) {
|
|
900
|
+
if (v === null || v === undefined || v === '') return '<span class="dim">—</span>';
|
|
901
|
+
return String(v).replace(/[&<>"']/g, function (c) { return ENT[c]; });
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** A lookup that cannot be answered by Object.prototype. */
|
|
905
|
+
function own(table, key) {
|
|
906
|
+
return Object.prototype.hasOwnProperty.call(table, key);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function two(n) { return (n < 10 ? '0' : '') + n; }
|
|
910
|
+
function hhmm(t) { var d = new Date(t); return two(d.getHours()) + ':' + two(d.getMinutes()); }
|
|
911
|
+
|
|
912
|
+
// A day-long ring can straddle one midnight, and "09:14 – 08:59" reads as a span running
|
|
913
|
+
// backwards until the older edge says which day it is.
|
|
914
|
+
function edge(t, ref) {
|
|
915
|
+
return hhmm(t) + (new Date(t).getDate() === new Date(ref).getDate() ? '' : ' yesterday');
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// What the range covers, in the record's own terms. Never "a day": that is the size of the
|
|
919
|
+
// ring, and a serve ten minutes old has seen ten minutes.
|
|
920
|
+
function coversText() {
|
|
921
|
+
var n = record.samples.length;
|
|
922
|
+
if (n === 0) {
|
|
923
|
+
// A record empty because every reading FAILED is not a record that has just started, and
|
|
924
|
+
// this was the one branch that threw that away: ten hours of a collector that could not
|
|
925
|
+
// run read exactly like a serve thirty seconds old.
|
|
926
|
+
return record.missed
|
|
927
|
+
? 'Nothing recorded — this serve started at ' + hhmm(record.since) + ' and '
|
|
928
|
+
+ record.missed + ' minute' + (record.missed === 1 ? '' : 's') + ' were due and never read.'
|
|
929
|
+
: 'Nothing recorded yet — this serve started at ' + hhmm(record.since)
|
|
930
|
+
+ ' and takes a reading every ' + Math.round(record.cadence / 1000) + 's.';
|
|
931
|
+
}
|
|
932
|
+
var last = record.samples[n - 1].t;
|
|
933
|
+
return 'Covering ' + edge(record.since, last) + ' – ' + hhmm(last)
|
|
934
|
+
// Not "when the page loaded": the record is asked for again when a tab that has been
|
|
935
|
+
// away comes back, so the sentence names the last time this page asked rather than a
|
|
936
|
+
// moment it may be hours past.
|
|
937
|
+
+ ', as this page last had it — ' + n + ' reading' + (n === 1 ? '' : 's')
|
|
938
|
+
// A gap that says it is a gap is not a gap. The handle steps through readings, not
|
|
939
|
+
// through minutes, and a record with holes in it is not a smooth walk.
|
|
940
|
+
+ (record.missed ? ', ' + record.missed + ' minute' + (record.missed === 1 ? '' : 's') + ' with no reading' : '')
|
|
941
|
+
+ '. The record keeps each reading, not how old that reading was, so nothing replayed here is dated.';
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function ready() {
|
|
945
|
+
var n = record.samples.length;
|
|
946
|
+
replay.hidden = false;
|
|
947
|
+
covers.textContent = coversText();
|
|
948
|
+
scrub.max = String(n === 0 ? 0 : n - 1);
|
|
949
|
+
scrub.disabled = n === 0;
|
|
950
|
+
playBtn.disabled = n === 0;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Revealed, not hidden, when the record cannot be had: a scrubber that silently never
|
|
954
|
+
// appears is indistinguishable from one this build does not have.
|
|
955
|
+
function noRecord(said) {
|
|
956
|
+
replay.hidden = false;
|
|
957
|
+
scrub.disabled = true;
|
|
958
|
+
playBtn.disabled = true;
|
|
959
|
+
covers.textContent = said;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function load() {
|
|
963
|
+
// The same generation guard the fleet poll carries, for the same reason and one more.
|
|
964
|
+
// The replaying flag is read when the tab regains focus; the answer lands later, and
|
|
965
|
+
// a reader's hand can arrive in between — so the question is asked AGAIN at the moment of
|
|
966
|
+
// the swap. Without it the record was replaced under a live scrub: the handle pointing at
|
|
967
|
+
// one minute, the map drawing another, out of a record that no longer existed.
|
|
968
|
+
var mine = ++hgen;
|
|
969
|
+
return fetch('/api/history', { cache: 'no-store' }).then(function (res) {
|
|
970
|
+
// The same refusal the fragment makes, for the same reason: what comes back is parsed
|
|
971
|
+
// and drawn into this page, and loopback proves where bytes came from, not who wrote them.
|
|
972
|
+
if (!res.headers.get('X-Tarmac')) throw new Error('The answer on this port did not come from tarmac.');
|
|
973
|
+
return res.text().then(function (body) {
|
|
974
|
+
if (!res.ok) throw new Error(body.split('\\n').filter(Boolean).join(' ').slice(0, 200));
|
|
975
|
+
var got = JSON.parse(body);
|
|
976
|
+
if (!got || !got.samples) throw new Error('the record came back in a shape this page does not know');
|
|
977
|
+
if (mine !== hgen || replaying) return;
|
|
978
|
+
record = got;
|
|
979
|
+
recordAt = Date.now();
|
|
980
|
+
ready();
|
|
981
|
+
});
|
|
982
|
+
}).catch(function (e) {
|
|
983
|
+
if (mine !== hgen) return;
|
|
984
|
+
// A refresh is not a first load. Failing one is no reason to take away a record the page
|
|
985
|
+
// is already holding — and saying "the record could not be read" over one the reader is
|
|
986
|
+
// scrubbing would be false. The fleet poll's own banner already says the server is quiet.
|
|
987
|
+
if (record !== null) return;
|
|
988
|
+
noRecord('The record could not be read — ' + String((e && e.message) || e).slice(0, 200));
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// One node, out of what the ring holds and nothing more. No name, for any kind of session:
|
|
993
|
+
// a background session is named after the prompt it was given, and the ring stores none.
|
|
994
|
+
function nodeOf(x, anchored) {
|
|
995
|
+
// The map's own rule, in the map's own words: an absent kind is not evidence of an agent,
|
|
996
|
+
// and a fleet where nothing calls itself interactive is a fleet whose source moved.
|
|
997
|
+
var role = !anchored || x.kind === null || x.kind === undefined || x.kind === INTERACTIVE ? 'session' : 'agent';
|
|
998
|
+
// Own keys only. A bare read inherits from Object.prototype, so "constructor" and
|
|
999
|
+
// "toString" passed this guard and reached the markup below — into an attribute
|
|
1000
|
+
// unescaped, and into the glyph slot as a function body.
|
|
1001
|
+
var state = own(SHAPE, x.state) ? x.state : 'unknown';
|
|
1002
|
+
var pct = typeof x.ctxPct === 'number' ? x.ctxPct : null;
|
|
1003
|
+
// The ring keeps each reading and never how old that reading was, so the arc weight that
|
|
1004
|
+
// says how much a reading may be believed cannot be earned here. It is not the live
|
|
1005
|
+
// default either: this third value is de-weighted in the stylesheet, and never the warning
|
|
1006
|
+
// hue, which would claim the opposite — that the reading is known to be old.
|
|
1007
|
+
return '<article class="node" data-role="' + role + '" data-state="' + state + '" data-reading="undatable">'
|
|
1008
|
+
// No halo, ever. It means a frame landed moments ago, which is never true of a sample.
|
|
1009
|
+
+ '<div class="dial"><svg viewBox="0 0 80 80" aria-hidden="true">'
|
|
1010
|
+
+ '<circle class="track' + (pct === null ? ' unmeasured' : '') + '" cx="40" cy="40" r="' + R + '"/>'
|
|
1011
|
+
+ (pct === null ? '' : arcOf(pct))
|
|
1012
|
+
+ '</svg><div class="val">'
|
|
1013
|
+
+ (pct === null
|
|
1014
|
+
? '<span class="why"><b>—</b>' + esc(own(WHY, x.ctxState) ? WHY[x.ctxState] : 'no reading') + '</span>'
|
|
1015
|
+
: '<span class="pct">' + pct + '<i>%</i></span>')
|
|
1016
|
+
+ '</div></div>'
|
|
1017
|
+
+ '<div class="who"><span class="shape" aria-hidden="true">' + SHAPE[state] + '</span>'
|
|
1018
|
+
+ '<span class="sr">' + state + '</span>'
|
|
1019
|
+
+ '<span class="project">' + esc(x.project) + '</span></div>'
|
|
1020
|
+
// The one caption the ring can fill. Guarded on the state as well as on the field: a
|
|
1021
|
+
// reason left over beside another state is not a session waiting for anything, and esc()
|
|
1022
|
+
// answers an absent field with a dash, which would caption a node "waiting for —".
|
|
1023
|
+
+ (state === 'waiting' && x.waitingFor ? '<div class="sub waiting-for">' + esc(x.waitingFor) + '</div>' : '')
|
|
1024
|
+
+ (x.kind === null || x.kind === undefined || x.kind === INTERACTIVE ? '' : '<div class="sub">' + esc(x.kind) + '</div>')
|
|
1025
|
+
+ (typeof x.costUsd === 'number' ? '<div class="sub">$' + x.costUsd.toFixed(2) + '</div>' : '')
|
|
1026
|
+
+ '</article>';
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// The server's own arithmetic, off the server's own radius: a fraction of the real
|
|
1030
|
+
// circumference, never pathLength, so a browser that ignores it cannot close every ring
|
|
1031
|
+
// into a full context window.
|
|
1032
|
+
function arcOf(pct) {
|
|
1033
|
+
var filled = (Math.min(100, Math.max(0, pct)) / 100) * C;
|
|
1034
|
+
var r2 = function (n) { return Math.round(n * 100) / 100; };
|
|
1035
|
+
return '<circle class="arc" cx="40" cy="40" r="' + R + '" transform="rotate(-90 40 40)"'
|
|
1036
|
+
+ ' stroke-dasharray="' + r2(filled) + ' ' + r2(C - filled) + '"/>';
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// ── the account, as it stood that minute ────────────────────────────────────────────
|
|
1040
|
+
//
|
|
1041
|
+
// The second thing this page interprets twice, and for the same reason as the dials: a
|
|
1042
|
+
// replay is a lookup in samples the page already holds, and the ring holds the payload's own
|
|
1043
|
+
// rate_limits rather than anything rendered. The vocabulary, the dash and the two windows are
|
|
1044
|
+
// handed over below rather than written again; what is mirrored is the arithmetic, and a
|
|
1045
|
+
// test compares this output with the server's character for character.
|
|
1046
|
+
//
|
|
1047
|
+
// What it counts the reset against is the SAMPLE's own clock, never Date.now(). A reset is a
|
|
1048
|
+
// moment, and "how long is left" is a question about the minute being replayed: at 09:14 the
|
|
1049
|
+
// five-hour window had two hours to run, and it had two hours to run whatever time it is now.
|
|
1050
|
+
// Counted against the present, every reset in the record would read as long overdue the
|
|
1051
|
+
// moment it aged past — a page announcing an account over its limit for a day that ended.
|
|
1052
|
+
var LIMITS = ${JSON.stringify(LIMIT_WINDOWS)}, LIMIT_WHY = ${JSON.stringify(LIMIT_WHY)};
|
|
1053
|
+
var DASH = ${JSON.stringify(dash())};
|
|
1054
|
+
|
|
1055
|
+
function left(ms) {
|
|
1056
|
+
var m = Math.floor(ms / 60000);
|
|
1057
|
+
if (m < 1) return '<1m';
|
|
1058
|
+
if (m < 60) return m + 'm';
|
|
1059
|
+
var h = Math.floor(m / 60);
|
|
1060
|
+
if (h < 24) return m % 60 === 0 ? h + 'h' : h + 'h ' + (m % 60) + 'm';
|
|
1061
|
+
var d = Math.floor(h / 24);
|
|
1062
|
+
return h % 24 === 0 ? d + 'd' : d + 'd ' + (h % 24) + 'h';
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function gaugesOf(rl, now) {
|
|
1066
|
+
// Anything can be in a sample: rate_limits is a shape someone else versions, and the ring
|
|
1067
|
+
// stored whatever the payload had. None of it may throw in the header of a dashboard.
|
|
1068
|
+
var ok = rl !== null && rl !== undefined && typeof rl === 'object' && !Array.isArray(rl);
|
|
1069
|
+
var html = '';
|
|
1070
|
+
for (var i = 0; i < LIMITS.length; i++) {
|
|
1071
|
+
var w = ok ? rl[LIMITS[i].key] : undefined;
|
|
1072
|
+
var has = w !== null && w !== undefined && typeof w === 'object' && !Array.isArray(w) && 'used_percentage' in w;
|
|
1073
|
+
var v = has ? w.used_percentage : undefined;
|
|
1074
|
+
var pct = has && typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 100 ? Math.floor(v) : null;
|
|
1075
|
+
var at = has && typeof w.resets_at === 'number' && Number.isFinite(w.resets_at) ? w.resets_at : null;
|
|
1076
|
+
var ms = at === null ? null : at * 1000 - now;
|
|
1077
|
+
// The server's horizon, off the server's own number: a reset further from the reading than
|
|
1078
|
+
// the longest window can be is not this account's reset, whatever it says.
|
|
1079
|
+
if (ms !== null && Math.abs(ms) > ${RESET_HORIZON_MS}) ms = null;
|
|
1080
|
+
// Presence, never value: a window that is there and null is a number not taken yet, and
|
|
1081
|
+
// one that is gone is a schema that moved. Same discriminant as everywhere else here.
|
|
1082
|
+
// Read off rl and NOT off ok: rate_limits carrying something that is not a pair of
|
|
1083
|
+
// windows — an array, which the snapshot reader lets through — is a schema that moved,
|
|
1084
|
+
// not an account nobody measured. Written as !ok, this said the opposite of the server
|
|
1085
|
+
// about the very same minute.
|
|
1086
|
+
var why = pct !== null ? null : (rl === null || rl === undefined || (has && v === null)) ? 'absent' : 'drift';
|
|
1087
|
+
html += '<div class="gauge"><span class="lbl" aria-hidden="true">' + LIMITS[i].label + '</span>'
|
|
1088
|
+
+ '<span class="sr">' + LIMITS[i].said + '</span>'
|
|
1089
|
+
+ (pct === null
|
|
1090
|
+
? '<span class="rail unmeasured" aria-hidden="true"></span>'
|
|
1091
|
+
: '<span class="rail" aria-hidden="true"><i style="width:' + pct + '%"></i></span>')
|
|
1092
|
+
+ '<span class="num">' + (pct === null ? DASH : pct + '%') + '</span>'
|
|
1093
|
+
+ '<span class="reset">'
|
|
1094
|
+
+ (pct === null
|
|
1095
|
+
? LIMIT_WHY[why]
|
|
1096
|
+
: ms === null
|
|
1097
|
+
? 'reset ' + DASH
|
|
1098
|
+
: ms > 0 ? 'resets in ' + left(ms) : 'reset was due ' + left(-ms) + ' ago')
|
|
1099
|
+
+ '</span></div>';
|
|
1100
|
+
}
|
|
1101
|
+
return html;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function nodesOf(s) {
|
|
1105
|
+
var anchored = false, html = '', i;
|
|
1106
|
+
for (i = 0; i < s.sessions.length; i++) if (s.sessions[i].kind === INTERACTIVE) anchored = true;
|
|
1107
|
+
// In the order the sample carries. The live map places an agent beside the session it
|
|
1108
|
+
// shares a directory with; the ring holds no directory, so the past is drawn in the order
|
|
1109
|
+
// the fleet was sorted in rather than in a grouping this page would have to invent.
|
|
1110
|
+
for (i = 0; i < s.sessions.length; i++) html += nodeOf(s.sessions[i], anchored);
|
|
1111
|
+
return html;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// The fleet of that minute, counted from that minute. A partial sum is never presented as
|
|
1115
|
+
// the total, the same rule the live header follows.
|
|
1116
|
+
function metaOf(s) {
|
|
1117
|
+
var n = s.sessions.length, busy = 0, cost = 0, reporting = 0;
|
|
1118
|
+
for (var i = 0; i < n; i++) {
|
|
1119
|
+
if (s.sessions[i].state === 'busy') busy++;
|
|
1120
|
+
if (typeof s.sessions[i].costUsd === 'number') { cost += s.sessions[i].costUsd; reporting++; }
|
|
1121
|
+
}
|
|
1122
|
+
return n + ' session' + (n === 1 ? '' : 's') + ' · ' + busy + ' busy · '
|
|
1123
|
+
+ (reporting === 0 ? 'cost —'
|
|
1124
|
+
: '$' + cost.toFixed(2) + (reporting < n ? ' (' + reporting + '/' + n + ' reporting cost)' : ''));
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function draw(i) {
|
|
1128
|
+
var s = record && record.samples[i];
|
|
1129
|
+
if (!s) return;
|
|
1130
|
+
at = i;
|
|
1131
|
+
replaying = true;
|
|
1132
|
+
scrub.value = String(i);
|
|
1133
|
+
// The handle's own value is an index, so a reader who cannot see the banner would be read
|
|
1134
|
+
// "3" while the fleet on screen is three hours old. The minute travels with the handle.
|
|
1135
|
+
scrub.setAttribute('aria-valuetext', hhmm(s.t));
|
|
1136
|
+
atEl.textContent = hhmm(s.t);
|
|
1137
|
+
rmeta.textContent = metaOf(s);
|
|
1138
|
+
rmap.innerHTML = nodesOf(s);
|
|
1139
|
+
// The account of that minute, in the place the live pair occupies — which the body class
|
|
1140
|
+
// has just taken down. One allowance on screen at a time, and it is the one belonging to
|
|
1141
|
+
// the fleet being shown.
|
|
1142
|
+
rlimits.innerHTML = gaugesOf(s.rateLimits, s.t);
|
|
1143
|
+
rlimits.hidden = false;
|
|
1144
|
+
note.hidden = false;
|
|
1145
|
+
rview.hidden = false;
|
|
1146
|
+
document.body.classList.toggle('replaying', true);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function stopPlay() {
|
|
1150
|
+
if (playing) { clearInterval(playing); playing = null; }
|
|
1151
|
+
playBtn.textContent = 'Play';
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Back to now, in one gesture, with nothing of the past left behind a hidden attribute.
|
|
1155
|
+
// The position is NOT reset: the handle stays where the reader let go of it, so what it
|
|
1156
|
+
// shows and where play would pick up are the same place.
|
|
1157
|
+
function present() {
|
|
1158
|
+
stopPlay();
|
|
1159
|
+
replaying = false;
|
|
1160
|
+
note.hidden = true;
|
|
1161
|
+
rview.hidden = true;
|
|
1162
|
+
rlimits.hidden = true;
|
|
1163
|
+
rlimits.innerHTML = '';
|
|
1164
|
+
rmap.innerHTML = '';
|
|
1165
|
+
scrub.removeAttribute('aria-valuetext');
|
|
1166
|
+
document.body.classList.toggle('replaying', false);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function play() {
|
|
1170
|
+
if (playing) { stopPlay(); return; }
|
|
1171
|
+
if (!record || record.samples.length === 0) return;
|
|
1172
|
+
// From the top when there is nothing to resume: a play button that ends where it started
|
|
1173
|
+
// has played nothing.
|
|
1174
|
+
draw(at < 0 || at >= record.samples.length - 1 ? 0 : at);
|
|
1175
|
+
playBtn.textContent = 'Pause';
|
|
1176
|
+
playing = setInterval(function () {
|
|
1177
|
+
// It stops at the end rather than looping back: a day that restarts on its own is a
|
|
1178
|
+
// day whose beginning and end are impossible to tell apart.
|
|
1179
|
+
if (at >= record.samples.length - 1) { stopPlay(); return; }
|
|
1180
|
+
draw(at + 1);
|
|
1181
|
+
}, STEP);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
scrub.addEventListener('input', function () { stopPlay(); draw(Number(scrub.value)); });
|
|
1185
|
+
playBtn.addEventListener('click', play);
|
|
1186
|
+
toLive.addEventListener('click', present);
|
|
1187
|
+
|
|
628
1188
|
setInterval(tick, 1000);
|
|
629
1189
|
setInterval(function () { if (!document.hidden) poll(); }, ${REFRESH_MS});
|
|
630
|
-
document.addEventListener('visibilitychange', function () {
|
|
1190
|
+
document.addEventListener('visibilitychange', function () {
|
|
1191
|
+
if (document.hidden) return;
|
|
1192
|
+
poll();
|
|
1193
|
+
// The record was answered once, at load. A tab left alone all afternoon holds a record
|
|
1194
|
+
// that stops where the reader's attention did — so it is asked again on the way back in.
|
|
1195
|
+
// Never while a reader is scrubbing (the record under their hand is not ours to swap, and
|
|
1196
|
+
// the load asks that question again when the answer lands), and never for one younger than
|
|
1197
|
+
// a single slot. A record that could never be read at all is not retried here: it stays
|
|
1198
|
+
// null, the guard holds, and the reader has a sentence saying so rather than a page
|
|
1199
|
+
// quietly trying again forever.
|
|
1200
|
+
if (!replaying && record !== null && Date.now() - recordAt >= record.cadence) load();
|
|
1201
|
+
});
|
|
1202
|
+
// Only where there is a scrubber to feed. The table view hides these controls in CSS, and a
|
|
1203
|
+
// full ring is megabytes of session ids, projects and costs: fetching and parsing it to
|
|
1204
|
+
// write a sentence into an element with display:none is a cost paid on every load of the
|
|
1205
|
+
// page most people open first, for a control they cannot see.
|
|
1206
|
+
if (${view === 'map'}) load();
|
|
631
1207
|
})();
|
|
632
1208
|
`;
|
|
1209
|
+
}
|
|
633
1210
|
/**
|
|
634
1211
|
* The sort puts busy first, unknown next, idle last. This is where that order is given its
|
|
635
1212
|
* weight — an accent down the row and a bold name for the ones that are working, a quiet row
|
|
@@ -641,7 +1218,7 @@ const SCRIPT = `
|
|
|
641
1218
|
*/
|
|
642
1219
|
function renderRow(r) {
|
|
643
1220
|
const state = stateOf(r);
|
|
644
|
-
const word =
|
|
1221
|
+
const word = stateLabel(state, r);
|
|
645
1222
|
// `data-label` is not decoration: below ~46rem the columns stack, the header row is gone,
|
|
646
1223
|
// and a value whose column has no name is a bare "—" that could mean anything.
|
|
647
1224
|
// Every cell holds exactly ONE element. Stacked on a phone the label sits left and the
|
|
@@ -658,7 +1235,7 @@ function renderRow(r) {
|
|
|
658
1235
|
<td data-label="Uptime" class="num dim"><span class="v">${r.uptimeMs === null ? dash() : esc(duration(r.uptimeMs))}</span></td>
|
|
659
1236
|
</tr>`;
|
|
660
1237
|
}
|
|
661
|
-
const SHAPE = { busy: '●', unknown: '▲', idle: '○' };
|
|
1238
|
+
const SHAPE = { busy: '●', waiting: '◐', unknown: '▲', idle: '○' };
|
|
662
1239
|
/**
|
|
663
1240
|
* The state in words, for both surfaces — derived from the state the MODEL decided, never
|
|
664
1241
|
* from the row a second time. Two expressions for one fact on one element is how a node ends
|
|
@@ -668,6 +1245,13 @@ const SHAPE = { busy: '●', unknown: '▲', idle: '○' };
|
|
|
668
1245
|
* of keeping it is that someone reading the page can go and find out what `compacting` means.
|
|
669
1246
|
*/
|
|
670
1247
|
const stateWord = (state, r) => state === 'unknown' ? (r.status ?? 'unknown') : state;
|
|
1248
|
+
/**
|
|
1249
|
+
* The same word with the reason attached, for the table — which has one cell per session and
|
|
1250
|
+
* no room for a caption of its own. The map keeps them apart instead: the word is what a
|
|
1251
|
+
* screen reader is handed in place of the glyph, and repeating the reason there would read it
|
|
1252
|
+
* twice, once hidden and once out of the caption below it.
|
|
1253
|
+
*/
|
|
1254
|
+
const stateLabel = (state, r) => state === 'waiting' && r.waitingFor ? `${stateWord(state, r)} · ${r.waitingFor}` : stateWord(state, r);
|
|
671
1255
|
/**
|
|
672
1256
|
* Which kind of missing a missing percentage is. One lookup for both surfaces: the table
|
|
673
1257
|
* says it beside a dash, the map says it inside an empty dial, and a second copy of these
|
|
@@ -697,6 +1281,10 @@ export function renderMap(fleet) {
|
|
|
697
1281
|
* dial is no reading at all, the shape beside the name is the session's state, and the halo
|
|
698
1282
|
* says one landed moments ago. The words under them are the same ones the table uses for the
|
|
699
1283
|
* same conditions — including the halo's, which would otherwise live only in a drawing.
|
|
1284
|
+
*
|
|
1285
|
+
* One state brings a caption with it. A waiting session is the only one where the shape
|
|
1286
|
+
* leaves a question the source can answer — which human answer it is halted on — and it is
|
|
1287
|
+
* printed directly under the name, not hidden in a title attribute nobody hovers on a phone.
|
|
700
1288
|
*/
|
|
701
1289
|
function renderNode({ row: r, role, state, reading, measured, pulse }) {
|
|
702
1290
|
// The model owns "is there a number"; this reads its verdict rather than asking the row a
|
|
@@ -722,6 +1310,7 @@ function renderNode({ row: r, role, state, reading, measured, pulse }) {
|
|
|
722
1310
|
${pulse ? `<span class="sr">a reading just landed</span>` : ''}
|
|
723
1311
|
</div>
|
|
724
1312
|
<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>
|
|
1313
|
+
${state === 'waiting' && r.waitingFor ? `<div class="sub waiting-for">${esc(r.waitingFor)}</div>` : ''}
|
|
725
1314
|
<div class="sub">${esc(r.name)}</div>
|
|
726
1315
|
${r.kind === null || r.kind === INTERACTIVE ? '' : `<div class="sub">${esc(r.kind)}</div>`}
|
|
727
1316
|
<div class="sub">${esc(r.model)}${r.effort === null ? '' : ` · ${esc(r.effort)}`}</div>
|
package/dist/schema.js
CHANGED
package/dist/server.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import http from 'node:http';
|
|
6
6
|
import { reason, renderLive, renderPage } from './render.js';
|
|
7
7
|
import { SOURCE_PHRASE } from './config.js';
|
|
8
|
+
import { createHistory, HISTORY_CADENCE_MS } from './history.js';
|
|
8
9
|
/**
|
|
9
10
|
* On every answer, including the refusals and the 500s. The page swaps what this port returns
|
|
10
11
|
* into `innerHTML`, and loopback proves where an answer came from, never who wrote it: a
|
|
@@ -19,8 +20,36 @@ const PAGES = new Map([
|
|
|
19
20
|
['/', 'table'],
|
|
20
21
|
['/map', 'map'],
|
|
21
22
|
]);
|
|
22
|
-
export function createFleetServer({ collect }) {
|
|
23
|
-
|
|
23
|
+
export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS }) {
|
|
24
|
+
// What this serve has already read, kept for a day and never written down. `since` is the
|
|
25
|
+
// moment this server was made, not the first sample that landed: the span it covers is how
|
|
26
|
+
// long the process has been up, and an hour of it with nothing in it is a fact worth
|
|
27
|
+
// showing rather than an empty record pretending to be a young one.
|
|
28
|
+
const history = createHistory({ since: Date.now(), cadence: sampleEveryMs });
|
|
29
|
+
// One at a time. `claude agents --json` has a 15s deadline of its own, and a fleet slower
|
|
30
|
+
// than a slot would otherwise be answered with a queue of processes instead of one missed
|
|
31
|
+
// minute — the tick that finds a read still running counts the slot and stands down.
|
|
32
|
+
let reading = false;
|
|
33
|
+
const sample = async () => {
|
|
34
|
+
if (reading) {
|
|
35
|
+
history.miss(Date.now());
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
reading = true;
|
|
39
|
+
try {
|
|
40
|
+
history.record(await collect());
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// A collector that throws is the normal weather here: `claude` missing, a laptop that
|
|
44
|
+
// was asleep. It costs a slot and nothing else — a throw out of this timer would be an
|
|
45
|
+
// unhandled rejection, and `serve` runs unattended for hours.
|
|
46
|
+
history.miss(Date.now());
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
reading = false;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const server = http.createServer(async (req, res) => {
|
|
24
53
|
// Loopback binding alone does not stop a DNS-rebinding page in the user's own browser
|
|
25
54
|
// from reading /api/fleet — which carries cwd paths, session ids and costs.
|
|
26
55
|
if (!isLoopbackHost(req.headers.host)) {
|
|
@@ -48,11 +77,30 @@ export function createFleetServer({ collect }) {
|
|
|
48
77
|
// the tabs are plain links, so the view has to be somewhere a reload and a bookmark can
|
|
49
78
|
// both find it. There is no second fragment — one `/live` carries both views, which is
|
|
50
79
|
// what keeps them from ever showing readings of different ages.
|
|
51
|
-
if (!PAGES.has(url.pathname) &&
|
|
80
|
+
if (!PAGES.has(url.pathname) &&
|
|
81
|
+
url.pathname !== '/live' &&
|
|
82
|
+
url.pathname !== '/api/fleet' &&
|
|
83
|
+
url.pathname !== '/api/history') {
|
|
52
84
|
res.writeHead(404, { ...IDENTITY, 'content-type': 'text/plain; charset=utf-8' });
|
|
53
85
|
res.end('not found\n');
|
|
54
86
|
return;
|
|
55
87
|
}
|
|
88
|
+
// Served out of the ring, above the collect below and never through it: this route is
|
|
89
|
+
// what the serve has ALREADY read, and one that collected would let a scrubber spawn
|
|
90
|
+
// `claude agents --json` on every drag of its handle.
|
|
91
|
+
if (url.pathname === '/api/history') {
|
|
92
|
+
res.writeHead(200, {
|
|
93
|
+
...IDENTITY,
|
|
94
|
+
'content-type': 'application/json; charset=utf-8',
|
|
95
|
+
'cache-control': 'no-store',
|
|
96
|
+
});
|
|
97
|
+
// Not indented, alone among the JSON answers here. `/api/fleet` pretty-prints ONE
|
|
98
|
+
// reading, which a person reads in a terminal; this is up to 1440 of them, where the
|
|
99
|
+
// indentation is 40% of a body no human will ever open — megabytes of whitespace on
|
|
100
|
+
// every poll of the replay.
|
|
101
|
+
res.end(JSON.stringify(history.read()));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
56
104
|
// Read AND render inside the guard, and send nothing until there is something to send.
|
|
57
105
|
// Writing the 200 first and rendering after made the collector's failure a 500 and the
|
|
58
106
|
// renderer's failure a dead daemon: the headers were already on the wire, the throw
|
|
@@ -82,6 +130,29 @@ export function createFleetServer({ collect }) {
|
|
|
82
130
|
res.writeHead(200, { ...IDENTITY, 'content-type': type, 'cache-control': 'no-store' });
|
|
83
131
|
res.end(body);
|
|
84
132
|
});
|
|
133
|
+
// The sampler lives exactly as long as the serving does. Started at construction it kept
|
|
134
|
+
// reading the fleet for a server whose `listen` had refused — a port named on the command
|
|
135
|
+
// line and taken — into a ring no request could ever reach; and never cleared, it did the
|
|
136
|
+
// same for every server a suite had closed behind it.
|
|
137
|
+
//
|
|
138
|
+
// `close` is the only shutdown this module has. `tarmac serve` itself has no graceful one:
|
|
139
|
+
// Ctrl-C ends the process, which takes the timer with it. Unref'ing is the belt to that
|
|
140
|
+
// braces — it keeps the sampler from being a reason `node --test`, or anything else that
|
|
141
|
+
// embeds this server, stays alive — and it is deliberately not the thing relied on.
|
|
142
|
+
let sampler = null;
|
|
143
|
+
server.on('listening', () => {
|
|
144
|
+
if (sampler !== null)
|
|
145
|
+
return;
|
|
146
|
+
sampler = setInterval(() => void sample(), sampleEveryMs);
|
|
147
|
+
sampler.unref();
|
|
148
|
+
});
|
|
149
|
+
server.on('close', () => {
|
|
150
|
+
if (sampler === null)
|
|
151
|
+
return;
|
|
152
|
+
clearInterval(sampler);
|
|
153
|
+
sampler = null;
|
|
154
|
+
});
|
|
155
|
+
return server;
|
|
85
156
|
}
|
|
86
157
|
/**
|
|
87
158
|
* How far past a port NOBODY CHOSE `serve` may walk. A corridor, not a search: wide enough
|
package/dist/sessions.js
CHANGED
|
@@ -27,6 +27,15 @@ const KNOWN_STATUS = new Map([
|
|
|
27
27
|
['working', true],
|
|
28
28
|
['done', false],
|
|
29
29
|
]);
|
|
30
|
+
/**
|
|
31
|
+
* The word for a session halted until a human answers, and the only one with a field saying
|
|
32
|
+
* which answer. It stays out of the map above — the boolean it would have to fill still has
|
|
33
|
+
* no honest value — but it is no longer counted as a word we failed to recognise: it has a
|
|
34
|
+
* state of its own on both surfaces, and a reason to print beside it.
|
|
35
|
+
*/
|
|
36
|
+
const WAITING = 'waiting';
|
|
37
|
+
/** Whether this reading is halted on a human. The one status the renderers treat as a state. */
|
|
38
|
+
export const isWaiting = (s) => s.status === WAITING;
|
|
30
39
|
/** @param text raw stdout of `claude agents --json` */
|
|
31
40
|
export function parseAgents(text) {
|
|
32
41
|
let raw;
|
|
@@ -54,7 +63,11 @@ export function parseAgents(text) {
|
|
|
54
63
|
// is what the dispatcher believes about an agent whose process may not be on this machine.
|
|
55
64
|
const status = typeof entry.status === 'string' ? entry.status : typeof entry.state === 'string' ? entry.state : null;
|
|
56
65
|
const busy = KNOWN_STATUS.has(status) ? KNOWN_STATUS.get(status) : null;
|
|
57
|
-
|
|
66
|
+
// Two different questions, and only the second one is a blind spot: `busy` is null here
|
|
67
|
+
// for a word we cannot answer the boolean with, and `waiting` is one of those — while
|
|
68
|
+
// being a word this tool knows by name. Counting it would put "reports a status tarmac
|
|
69
|
+
// does not know" on the banner over a session tarmac is drawing, captioned, as waiting.
|
|
70
|
+
if (busy === null && status !== WAITING)
|
|
58
71
|
health.unknownStatus += 1;
|
|
59
72
|
sessions.push({
|
|
60
73
|
sessionId,
|
|
@@ -64,6 +77,7 @@ export function parseAgents(text) {
|
|
|
64
77
|
kind: typeof entry.kind === 'string' ? entry.kind : null,
|
|
65
78
|
startedAt: typeof entry.startedAt === 'number' ? entry.startedAt : null,
|
|
66
79
|
status,
|
|
80
|
+
waitingFor: typeof entry.waitingFor === 'string' ? entry.waitingFor : null,
|
|
67
81
|
busy,
|
|
68
82
|
});
|
|
69
83
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adrrr/tarmac",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Fleet observability for Claude Code — reads documented surfaces only, never an internal format",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
37
37
|
"build": "npm run clean && tsc -p tsconfig.build.json",
|
|
38
38
|
"typecheck": "tsc -p tsconfig.json",
|
|
39
|
-
"test": "npm run typecheck && node --test \"test/*.test.ts\"",
|
|
39
|
+
"test": "npm run typecheck && node --test --test-timeout=120000 --test-force-exit \"test/*.test.ts\"",
|
|
40
40
|
"fixtures:capture": "node scripts/capture-fixtures.ts",
|
|
41
41
|
"prepack": "npm run build",
|
|
42
42
|
"prepublishOnly": "npm run build && TARMAC_REQUIRE_DASH=1 npm test && node dist/cli.js --help > /dev/null"
|