@ucsandman/legcli 0.7.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/CHANGELOG.md +560 -0
- package/LICENSE +58 -0
- package/NOTICE +4 -0
- package/README.md +558 -0
- package/bin/fake-agent.mjs +143 -0
- package/bin/leg.mjs +449 -0
- package/docs/DECISIONS.md +119 -0
- package/docs/DEMO.md +214 -0
- package/docs/DEVIATIONS.md +182 -0
- package/docs/ERRORS.md +326 -0
- package/docs/README.md +40 -0
- package/docs/REUSE.md +289 -0
- package/docs/ROADMAP-v2.md +55 -0
- package/docs/VOCABULARY.md +243 -0
- package/docs/adapters.md +300 -0
- package/docs/board-guide.md +579 -0
- package/docs/cli-contracts.md +401 -0
- package/docs/concepts.md +426 -0
- package/docs/configuration.md +220 -0
- package/docs/faq.md +206 -0
- package/docs/getting-started.md +251 -0
- package/docs/real-run.md +80 -0
- package/docs/review-2026-09-14.md +72 -0
- package/docs/screenshots/board-400px.png +0 -0
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/board-done.png +0 -0
- package/docs/screenshots/board-drawer.png +0 -0
- package/docs/screenshots/board-empty.png +0 -0
- package/docs/screenshots/board-handoff.png +0 -0
- package/docs/screenshots/board-running.png +0 -0
- package/docs/screenshots/demo-1-claude-running.png +0 -0
- package/docs/screenshots/demo-2-limit-hit.png +0 -0
- package/docs/screenshots/demo-3-handoff-bundle.png +0 -0
- package/docs/screenshots/demo-4-codex-running.png +0 -0
- package/docs/screenshots/demo-5-done.png +0 -0
- package/docs/screenshots/floor-final-1280.png +0 -0
- package/docs/screenshots/floor-final-400.png +0 -0
- package/docs/screenshots/floor-landing.png +0 -0
- package/docs/screenshots/floor.png +0 -0
- package/docs/screenshots/share-guest-1280.png +0 -0
- package/docs/screenshots/share-owner-1280.png +0 -0
- package/docs/screenshots/terminals-1280.png +0 -0
- package/docs/screenshots/thumb-done.jpg +0 -0
- package/docs/screenshots/thumb-handoff-details.jpg +0 -0
- package/docs/screenshots/thumb-limit-hit.jpg +0 -0
- package/docs/screenshots/youtube-thumbnail.jpg +0 -0
- package/fixtures/limits/agy/agy-resource-exhausted.json +11 -0
- package/fixtures/limits/claude/claude-budget-limit.json +11 -0
- package/fixtures/limits/claude/claude-max-turns.json +11 -0
- package/fixtures/limits/claude/claude-model-limit.json +11 -0
- package/fixtures/limits/claude/claude-session-limit.json +11 -0
- package/fixtures/limits/claude/claude-weekly-limit.json +11 -0
- package/fixtures/limits/codex/codex-quota-exceeded.json +11 -0
- package/fixtures/limits/codex/codex-rate-limit-exceeded.json +11 -0
- package/fixtures/limits/codex/codex-skills-notice.json +11 -0
- package/fixtures/limits/codex/codex-usage-limit-reached.json +11 -0
- package/fixtures/limits/codex/codex-usage-limit.json +11 -0
- package/fixtures/limits/generic/generic-429.json +11 -0
- package/fixtures/limits/generic/generic-overloaded.json +11 -0
- package/fixtures/limits/generic/generic-quota.json +11 -0
- package/fixtures/limits/generic/generic-rate-limit.json +11 -0
- package/fixtures/limits/generic/generic-resource-exhausted.json +11 -0
- package/fixtures/limits/generic/generic-usage-limit.json +11 -0
- package/fixtures/limits/grok/grok-not-logged-in.json +11 -0
- package/fixtures/limits/negative/auth-source-set.json +11 -0
- package/fixtures/limits/negative/compile-error.json +11 -0
- package/fixtures/limits/negative/empty-stdout-exit-0.json +11 -0
- package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +1 -0
- package/fixtures/live/agy/cmd.txt +1 -0
- package/fixtures/live/agy/err.log +0 -0
- package/fixtures/live/agy/out.log +1 -0
- package/fixtures/live/agy/parsed.json +5 -0
- package/fixtures/live/agy/run.json +18 -0
- package/fixtures/live/agy/supervisor.log +2 -0
- package/fixtures/live/claude/cmd.txt +1 -0
- package/fixtures/live/claude/err.log +0 -0
- package/fixtures/live/claude/limit-rate_limit.json +21 -0
- package/fixtures/live/claude/out.log +1 -0
- package/fixtures/live/claude/parsed.json +11 -0
- package/fixtures/live/claude/run.json +18 -0
- package/fixtures/live/claude/supervisor.log +2 -0
- package/fixtures/live/codex/cmd.txt +1 -0
- package/fixtures/live/codex/err.log +1 -0
- package/fixtures/live/codex/last.md +2 -0
- package/fixtures/live/codex/limit-usage_limit_exceeded.json +25 -0
- package/fixtures/live/codex/out.log +8 -0
- package/fixtures/live/codex/parsed.json +15 -0
- package/fixtures/live/codex/run.json +18 -0
- package/fixtures/live/codex/supervisor.log +2 -0
- package/fixtures/live/grok/cmd.txt +1 -0
- package/fixtures/live/grok/err.log +32 -0
- package/fixtures/live/grok/out.log +7 -0
- package/fixtures/live/grok/parsed.json +5 -0
- package/fixtures/live/grok/run.json +18 -0
- package/fixtures/live/grok/supervisor.log +2 -0
- package/fixtures/verified.json +34 -0
- package/package.json +68 -0
- package/scripts/board-shots.mjs +69 -0
- package/scripts/build-docs-site.mjs +308 -0
- package/scripts/check-claims.mjs +150 -0
- package/scripts/clean-clone-check.sh +42 -0
- package/scripts/license-sign.mjs +23 -0
- package/scripts/limits-table.mjs +38 -0
- package/scripts/live-limits.mjs +36 -0
- package/scripts/privacy-check.mjs +63 -0
- package/scripts/probe.mjs +92 -0
- package/scripts/seed-fake-cards.mjs +34 -0
- package/scripts/seed-floor-board.mjs +69 -0
- package/scripts/seed-wes-board.mjs +91 -0
- package/scripts/stripe-setup.mjs +99 -0
- package/scripts/vercel-env.mjs +40 -0
- package/src/accounts.mjs +119 -0
- package/src/adapters/agy.mjs +56 -0
- package/src/adapters/claude.mjs +54 -0
- package/src/adapters/codex.mjs +73 -0
- package/src/adapters/common.mjs +32 -0
- package/src/adapters/fake.mjs +48 -0
- package/src/adapters/grok.mjs +51 -0
- package/src/adapters/index.mjs +27 -0
- package/src/adapters/resolve.mjs +40 -0
- package/src/attach.mjs +592 -0
- package/src/auth.mjs +85 -0
- package/src/board/board.css +543 -0
- package/src/board/board.js +1307 -0
- package/src/board/favicon.svg +1 -0
- package/src/board/floor.html +127 -0
- package/src/board/floor.js +670 -0
- package/src/board/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
- package/src/board/fonts/OFL-azeret-mono.txt +94 -0
- package/src/board/fonts/atkinson-board.woff2 +0 -0
- package/src/board/fonts/azeret-board.woff2 +0 -0
- package/src/board/index.html +251 -0
- package/src/board/sessions.js +1451 -0
- package/src/bundle.mjs +103 -0
- package/src/cards.mjs +114 -0
- package/src/chain.mjs +264 -0
- package/src/commands.mjs +79 -0
- package/src/contract.mjs +73 -0
- package/src/env.mjs +19 -0
- package/src/fsx.mjs +72 -0
- package/src/git-snapshot.mjs +152 -0
- package/src/handoff.mjs +171 -0
- package/src/hook.mjs +49 -0
- package/src/land.mjs +106 -0
- package/src/launcher.mjs +272 -0
- package/src/leases.mjs +86 -0
- package/src/ledger.mjs +369 -0
- package/src/license.mjs +168 -0
- package/src/limits.mjs +97 -0
- package/src/live-capture.mjs +69 -0
- package/src/mergequeue.mjs +225 -0
- package/src/orchestrator.mjs +365 -0
- package/src/pipeline.mjs +100 -0
- package/src/preferences.mjs +47 -0
- package/src/presets.mjs +23 -0
- package/src/ratelimit.mjs +49 -0
- package/src/redact.mjs +47 -0
- package/src/resume.mjs +354 -0
- package/src/runner.mjs +438 -0
- package/src/scheduler.mjs +118 -0
- package/src/server.mjs +845 -0
- package/src/session-detail.mjs +129 -0
- package/src/sessions.mjs +202 -0
- package/src/share.mjs +163 -0
- package/src/stations/agent.mjs +42 -0
- package/src/stations/build.mjs +9 -0
- package/src/stations/human.mjs +5 -0
- package/src/stations/land.mjs +6 -0
- package/src/stations/plan.mjs +8 -0
- package/src/stations/pr.mjs +31 -0
- package/src/stations/review.mjs +8 -0
- package/src/stations/test.mjs +27 -0
- package/src/store.mjs +90 -0
- package/src/sync/dashclaw.mjs +70 -0
- package/src/sync/index.mjs +54 -0
- package/src/sync/workboard.mjs +54 -0
- package/src/taps/agy.mjs +64 -0
- package/src/taps/claude-usage.mjs +67 -0
- package/src/taps/claude.mjs +170 -0
- package/src/taps/codex.mjs +286 -0
- package/src/trust.mjs +323 -0
- package/src/usage.mjs +179 -0
- package/src/wait.mjs +30 -0
- package/src/worktree.mjs +207 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
// Leg floor, the scheduler-eye view: what's running, waiting, queued, and
|
|
2
|
+
// which leases block whom. Polls /api/floor, /api/trunk and /api/sessions
|
|
3
|
+
// (for the instrument head's account rails) and refreshes on SSE.
|
|
4
|
+
(function () {
|
|
5
|
+
'use strict'
|
|
6
|
+
|
|
7
|
+
const WAIT_LABELS = { approve: 'Approve', resume: 'Resume', kill: 'Kill' }
|
|
8
|
+
const WAIT_BTN_CLASS = { approve: 'btn btn-primary', resume: 'btn btn-secondary', kill: 'btn btn-danger' }
|
|
9
|
+
|
|
10
|
+
// `bind` starts as the address the reader actually reached this page on and is
|
|
11
|
+
// replaced by the server's own bind at init. It used to be the literal
|
|
12
|
+
// 127.0.0.1:4747 in two banner strings, so every board started with --port,
|
|
13
|
+
// every share and every second instance told a disconnected user to go and
|
|
14
|
+
// look at a port with nothing on it.
|
|
15
|
+
// NOTE: the ported head block below declares a LOCAL `state` in five of its
|
|
16
|
+
// functions for the account's one state word. Those five never touch this
|
|
17
|
+
// object; anything added inside them that needs it must reach for another
|
|
18
|
+
// name. test/board-updates.test.mjs pins `state.stopped` in this file by
|
|
19
|
+
// source text, which is why the module object keeps the name.
|
|
20
|
+
const state = { es: null, retryMs: 1000, timers: [], stopped: false, sseRequest: 0, floorRequest: 0, trunkRequest: 0, headRequest: 0, lastReadingAt: null, bind: (typeof location !== 'undefined' && location.host) || '127.0.0.1:4747', pendingFloor: null }
|
|
21
|
+
|
|
22
|
+
function getToken() { return localStorage.getItem('legToken') || localStorage.getItem('batonToken') || '' }
|
|
23
|
+
|
|
24
|
+
async function api(path, opts = {}) {
|
|
25
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
26
|
+
const token = getToken()
|
|
27
|
+
if (token) headers.Authorization = `Bearer ${token}`
|
|
28
|
+
const res = await fetch(path, {
|
|
29
|
+
method: opts.method || 'GET',
|
|
30
|
+
headers,
|
|
31
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
32
|
+
})
|
|
33
|
+
const text = await res.text()
|
|
34
|
+
let data = null
|
|
35
|
+
if (text) { try { data = JSON.parse(text) } catch { data = null } }
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
const err = new Error((data && data.error) || `${res.status} ${res.statusText}`)
|
|
38
|
+
err.status = res.status
|
|
39
|
+
throw err
|
|
40
|
+
}
|
|
41
|
+
return data
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function el(tag, attrs, children) {
|
|
45
|
+
const node = document.createElement(tag)
|
|
46
|
+
for (const [k, v] of Object.entries(attrs || {})) {
|
|
47
|
+
if (v == null || v === false) continue
|
|
48
|
+
if (k === 'class') node.className = v
|
|
49
|
+
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2), v)
|
|
50
|
+
else if (v === true) node.setAttribute(k, '')
|
|
51
|
+
else node.setAttribute(k, v)
|
|
52
|
+
}
|
|
53
|
+
for (const c of [].concat(children || [])) {
|
|
54
|
+
if (c == null) continue
|
|
55
|
+
node.appendChild(typeof c === 'string' || typeof c === 'number' ? document.createTextNode(String(c)) : c)
|
|
56
|
+
}
|
|
57
|
+
return node
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---- 6.9 the system message ----
|
|
61
|
+
// src/board/board.js OWNS THIS COMPONENT; this is its MESSAGE_WORD table and
|
|
62
|
+
// its toast(), character for character. The floor used to build a second one
|
|
63
|
+
// into the same #toast box with the same .sysmsg-item class: no severity
|
|
64
|
+
// word, so the colour of a 7px mark was the only signal of how bad a message
|
|
65
|
+
// was, and the mark was hardcoded to danger whatever the tone.
|
|
66
|
+
// One message at a time, deduped by exact text. Errors never auto-dismiss,
|
|
67
|
+
// which is why `tone` defaults to danger: a bare call is an error, and an
|
|
68
|
+
// error the reader has not seen does not disappear.
|
|
69
|
+
const MESSAGE_WORD = { ok: 'done', warn: 'warning', danger: 'error' }
|
|
70
|
+
const message = { text: null, node: null, timer: null }
|
|
71
|
+
|
|
72
|
+
function dismissMessage() {
|
|
73
|
+
if (message.timer) { clearTimeout(message.timer); message.timer = null }
|
|
74
|
+
if (message.node) message.node.remove()
|
|
75
|
+
message.node = null
|
|
76
|
+
message.text = null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// the same words the board prints (board.js STATUS_LABELS): these two files
|
|
80
|
+
// have no module system, so the map is carried rather than imported
|
|
81
|
+
const STATUS_LABELS = {
|
|
82
|
+
running: 'running', handing_off: 'handing off', waiting_human: 'waiting human',
|
|
83
|
+
needs_approval: 'needs approval', paused: 'paused', queued: 'queued',
|
|
84
|
+
backlog: 'backlog', done: 'done', failed: 'failed', killed: 'killed',
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function toast(msg, tone = 'danger') {
|
|
88
|
+
const text = String(msg)
|
|
89
|
+
const box = document.getElementById('toast')
|
|
90
|
+
if (!box || text === message.text) return
|
|
91
|
+
dismissMessage()
|
|
92
|
+
const word = MESSAGE_WORD[tone] || MESSAGE_WORD.danger
|
|
93
|
+
const item = el('div', { class: `sysmsg-item tone-${tone}` }, [
|
|
94
|
+
el('span', { class: 'status-word' }, [el('span', { class: `mark tone-${tone}`, 'aria-hidden': 'true' }), word]),
|
|
95
|
+
el('span', {}, [text]),
|
|
96
|
+
])
|
|
97
|
+
if (tone === 'danger') {
|
|
98
|
+
item.appendChild(el('button', { type: 'button', class: 'btn btn-danger', 'aria-label': 'Dismiss message', onclick: dismissMessage }, ['Dismiss']))
|
|
99
|
+
} else {
|
|
100
|
+
message.timer = setTimeout(dismissMessage, 6000)
|
|
101
|
+
}
|
|
102
|
+
box.appendChild(item)
|
|
103
|
+
message.node = item
|
|
104
|
+
message.text = text
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A timestamp in a table cell is local, like every other clock on the page.
|
|
108
|
+
// formatTs used to slice the ISO-8601 string, which is UTC, and feed three
|
|
109
|
+
// columns sitting under a caption that reads "Times are local."
|
|
110
|
+
function formatTs(ts) { return clockAt(Date.parse(ts)) }
|
|
111
|
+
|
|
112
|
+
// the full text always goes in the cell's title too (6.7): CSS truncates
|
|
113
|
+
// with text-overflow: ellipsis, this no longer pre-truncates the string.
|
|
114
|
+
function formatLastEvent(last) {
|
|
115
|
+
if (!last) return 'no events yet'
|
|
116
|
+
return `${last.type}: ${last.summary}`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---- 6.1 the instrument head ------------------------------------------
|
|
120
|
+
// src/board/sessions.js OWNS THIS BLOCK. From ago() down to renderAccounts()
|
|
121
|
+
// it is that file's code character for character, with exactly one deliberate
|
|
122
|
+
// difference, marked at the line it is on (the id of the box the head is
|
|
123
|
+
// rendered into). The three board files cannot import from each other, and
|
|
124
|
+
// the previous arrangement - a comment promising "a literal copy" - drifted
|
|
125
|
+
// into six wrong answers on this page alone: a calm full bar for a walled
|
|
126
|
+
// account with no reading, aria-valuenow="0" for a window with no reading,
|
|
127
|
+
// "in 6d 23:55" against "in 6d 23h", "resets Sep 19" against "resets Sat
|
|
128
|
+
// 10:11 PM", "back 10:11 PM" for a wall four days out, and a provenance line
|
|
129
|
+
// with its source dropped. A change to any of it is a change to both files in
|
|
130
|
+
// the same commit.
|
|
131
|
+
|
|
132
|
+
const WIN_WORDS = { '5h': '5 hour', '7d': '7 day' }
|
|
133
|
+
const IDS = ['claude', 'codex', 'agy', 'fake']
|
|
134
|
+
|
|
135
|
+
// ---- times. The head prints `Times are local.` once, so no row repeats it ----
|
|
136
|
+
// THIS FILE OWNS THE TIME GRAMMAR FOR THE WHOLE BOARD. ago(), clockAt(),
|
|
137
|
+
// until() and elapsedClock() below are copied character for character into
|
|
138
|
+
// src/board/floor.js and src/board/board.js, which cannot import from here.
|
|
139
|
+
// One fact must never print in two formats across the two pages, so a change
|
|
140
|
+
// to any of these four is a change to all three files in the same commit.
|
|
141
|
+
function ago(ms) {
|
|
142
|
+
const s = Math.max(0, Math.floor(ms / 1000))
|
|
143
|
+
if (s < 60) return `${s}s`
|
|
144
|
+
const m = Math.floor(s / 60)
|
|
145
|
+
if (m < 60) return `${m}m`
|
|
146
|
+
const h = Math.floor(m / 60)
|
|
147
|
+
if (h < 48) return `${h}h${m % 60 ? ` ${m % 60}m` : ''}`
|
|
148
|
+
const d = Math.floor(h / 24)
|
|
149
|
+
return `${d}d${h % 24 ? ` ${h % 24}h` : ''}`
|
|
150
|
+
}
|
|
151
|
+
// aria-valuetext is read out loud, so the gap is spelled out there and stays
|
|
152
|
+
// abbreviated in the visible cells. Both floor, so the spoken sentence and the
|
|
153
|
+
// cell beside it never differ by a minute on the same rail.
|
|
154
|
+
function spoken(ms) {
|
|
155
|
+
const m = Math.max(0, Math.floor(ms / 60000))
|
|
156
|
+
if (m < 60) return `${m} minute${m === 1 ? '' : 's'}`
|
|
157
|
+
const h = Math.floor(m / 60)
|
|
158
|
+
const rest = m % 60
|
|
159
|
+
if (h < 48) return `${h} hour${h === 1 ? '' : 's'}${rest ? ` ${rest} minute${rest === 1 ? '' : 's'}` : ''}`
|
|
160
|
+
return `${Math.floor(h / 24)} days`
|
|
161
|
+
}
|
|
162
|
+
function clockAt(ms) {
|
|
163
|
+
if (!Number.isFinite(ms)) return 'unknown'
|
|
164
|
+
const d = new Date(ms)
|
|
165
|
+
const out = Math.abs(ms - Date.now())
|
|
166
|
+
if (out < 20 * 3600000) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
|
167
|
+
if (out < 6 * 86400000) return d.toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })
|
|
168
|
+
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
|
169
|
+
}
|
|
170
|
+
function elapsedClock(ms) {
|
|
171
|
+
const s = Math.max(0, Math.floor(ms / 1000))
|
|
172
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
173
|
+
const mm = pad(Math.floor(s / 60) % 60)
|
|
174
|
+
const ss = pad(s % 60)
|
|
175
|
+
const h = Math.floor(s / 3600)
|
|
176
|
+
return h ? `${pad(h)}:${mm}:${ss}` : `${mm}:${ss}`
|
|
177
|
+
}
|
|
178
|
+
function until(epochS) { return Number.isFinite(epochS) ? clockAt(epochS * 1000) : 'unknown' }
|
|
179
|
+
|
|
180
|
+
function accountLabel(a) { return a.label || (a.account === 'default' ? a.agent : `${a.agent}/${a.account}`) }
|
|
181
|
+
function idOf(agent) { return IDS.includes(agent) ? agent : 'fake' }
|
|
182
|
+
|
|
183
|
+
// ---- 6.1 the window rail ----------------------------------------------
|
|
184
|
+
// One state value per account drives the .acct modifier, every rail cell, the
|
|
185
|
+
// tier word and the spoken sentence, so those four cannot disagree.
|
|
186
|
+
function acctState(a) {
|
|
187
|
+
if (a.shared === false) return 'notshared'
|
|
188
|
+
if (a.loading) return 'loading'
|
|
189
|
+
if (Number.isFinite(a.limited_until) && a.limited_until * 1000 > Date.now()) return 'walled'
|
|
190
|
+
if (!a.five_hour && !a.seven_day) return 'noreading'
|
|
191
|
+
if (a.stale && a.agent !== 'agy') return 'stale'
|
|
192
|
+
return 'ok'
|
|
193
|
+
}
|
|
194
|
+
// What one window's numeral cell can say. A window with no reading prints the
|
|
195
|
+
// refusal, never a zero: a zero is a reading.
|
|
196
|
+
function windowState(a, w) {
|
|
197
|
+
const s = acctState(a)
|
|
198
|
+
if (s === 'notshared' || s === 'loading') return s
|
|
199
|
+
return w && Number.isFinite(w.pct) ? 'reading' : 'noreading'
|
|
200
|
+
}
|
|
201
|
+
// 6.1.6: a meter announces its name and its value text, so the whole answer
|
|
202
|
+
// goes in one sentence instead of four separate visible cells.
|
|
203
|
+
function railValueText(a, w, kind) {
|
|
204
|
+
const words = WIN_WORDS[kind] || kind
|
|
205
|
+
const state = windowState(a, w)
|
|
206
|
+
const parts = []
|
|
207
|
+
if (state === 'notshared') parts.push(`Usage for the ${words} window is not shared with guests.`)
|
|
208
|
+
else if (state === 'loading') parts.push(`No reading for the ${words} window has come back from /api/sessions yet.`)
|
|
209
|
+
else if (state === 'noreading') {
|
|
210
|
+
parts.push(a.agent === 'agy'
|
|
211
|
+
? `agy publishes no usage percentage for the ${words} window. Leg sees the wall when agy hits it.`
|
|
212
|
+
: `No reading for the ${words} window yet.`)
|
|
213
|
+
} else {
|
|
214
|
+
parts.push(`${Math.round(w.pct)} percent of the ${words} window used.`)
|
|
215
|
+
if (Number.isFinite(w.resets_at)) parts.push(`Resets at ${until(w.resets_at)}, in ${spoken(w.resets_at * 1000 - Date.now())}.`)
|
|
216
|
+
}
|
|
217
|
+
if (acctState(a) === 'walled') parts.push(`${accountLabel(a)} is at its wall until ${until(a.limited_until)}, in ${spoken(a.limited_until * 1000 - Date.now())}.`)
|
|
218
|
+
const observed = Date.parse(a.observed_at || a.updated_at || '')
|
|
219
|
+
if (a.stale && a.agent !== 'agy' && Number.isFinite(observed)) parts.push(`Read at ${clockAt(observed)}, ${spoken(Date.now() - observed)} ago, stale.`)
|
|
220
|
+
return parts.join(' ')
|
|
221
|
+
}
|
|
222
|
+
function worstWindow(a) {
|
|
223
|
+
const w = [a.five_hour, a.seven_day].filter((x) => x && Number.isFinite(x.pct)).sort((x, y) => y.pct - x.pct)
|
|
224
|
+
return w[0] || a.five_hour || a.seven_day || null
|
|
225
|
+
}
|
|
226
|
+
// Drives the condensed head under 620px: walled first, then the highest
|
|
227
|
+
// percentage across both windows, ties broken by the soonest reset.
|
|
228
|
+
function closestToWall(accounts) {
|
|
229
|
+
const walled = (a) => (acctState(a) === 'walled' ? 1 : 0)
|
|
230
|
+
const worst = (a) => { const w = worstWindow(a); return w && Number.isFinite(w.pct) ? w.pct : -1 }
|
|
231
|
+
const soonest = (a) => {
|
|
232
|
+
const r = [a.five_hour, a.seven_day].filter((w) => w && Number.isFinite(w.resets_at)).map((w) => w.resets_at)
|
|
233
|
+
return r.length ? Math.min(...r) : Infinity
|
|
234
|
+
}
|
|
235
|
+
return [...accounts].sort((x, y) => (walled(y) - walled(x)) || (worst(y) - worst(x)) || (soonest(x) - soonest(y)))[0] || null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// The instrument. One track, one fill, one numeral, and a 2px notch cut
|
|
239
|
+
// through the bar where 85 percent sits. `minor` is the second window on a
|
|
240
|
+
// login: the same instrument at half the height and a smaller numeral, so
|
|
241
|
+
// the reader's eye lands on the window that is closest to a wall first.
|
|
242
|
+
function gauge(account, win, kind, { minor = false } = {}) {
|
|
243
|
+
const state = windowState(account, win)
|
|
244
|
+
const pct = state === 'reading' ? Math.max(0, Math.min(100, Math.round(win.pct))) : null
|
|
245
|
+
const words = WIN_WORDS[kind] || kind
|
|
246
|
+
const valueText = railValueText(account, win, kind)
|
|
247
|
+
const id = idOf(account.agent)
|
|
248
|
+
// severity paints INSIDE the track and nowhere else: the fill runs in the
|
|
249
|
+
// login's own identity colour up to 85 percent and in the over colour past
|
|
250
|
+
// it, so the bar shows the reserve it has eaten without colouring a word
|
|
251
|
+
const fill = pct === null ? null : el('span', {
|
|
252
|
+
class: 'gauge-fill',
|
|
253
|
+
style: pct <= 85
|
|
254
|
+
? `width:${pct}%;background:var(--id-${id})`
|
|
255
|
+
: `width:${pct}%;background:linear-gradient(to right,var(--id-${id}) 0 ${((85 / pct) * 100).toFixed(2)}%,var(--danger) ${((85 / pct) * 100).toFixed(2)}% 100%)`,
|
|
256
|
+
})
|
|
257
|
+
// 6.1.6: a meter with no value is not a meter. aria-valuenow is required by
|
|
258
|
+
// role=meter, and an empty or absent one is announced as zero percent, which
|
|
259
|
+
// is the fabricated reading the visible cell refuses to print. With no value
|
|
260
|
+
// the track drops the role and carries the same sentence as its name.
|
|
261
|
+
const semantics = pct === null
|
|
262
|
+
? { role: 'img', 'aria-label': `${accountLabel(account)}, ${words} window. ${valueText}` }
|
|
263
|
+
: {
|
|
264
|
+
role: 'meter',
|
|
265
|
+
'aria-valuemin': '0',
|
|
266
|
+
'aria-valuemax': '100',
|
|
267
|
+
'aria-valuenow': String(pct),
|
|
268
|
+
'aria-label': `${accountLabel(account)}, ${words} window`,
|
|
269
|
+
'aria-valuetext': valueText,
|
|
270
|
+
}
|
|
271
|
+
if (pct === null && state !== 'reading') {
|
|
272
|
+
// no number exists, so no instrument is drawn. A track with nothing in it
|
|
273
|
+
// is a reading of zero to anyone glancing at it.
|
|
274
|
+
return el('div', { class: 'gauge gauge--none' }, [
|
|
275
|
+
el('span', { class: 'gauge-label' }, [words]),
|
|
276
|
+
el('span', { class: 'gauge-read--none', ...semantics }, [state === 'notshared' ? 'not shared' : state === 'loading' ? 'reading' : 'no reading']),
|
|
277
|
+
])
|
|
278
|
+
}
|
|
279
|
+
const readout = [el('span', { class: 'gauge-read' }, [`${pct}%`])]
|
|
280
|
+
const observed = Date.parse(account.observed_at || account.updated_at || '')
|
|
281
|
+
if (!minor && account.stale && account.agent !== 'agy' && Number.isFinite(observed)) {
|
|
282
|
+
readout.push(el('span', { class: 'gauge-note' }, [`measured ${ago(Date.now() - observed)} ago`]))
|
|
283
|
+
} else if (!minor && win && Number.isFinite(win.resets_at)) {
|
|
284
|
+
readout.push(el('span', { class: 'gauge-note' }, [`resets ${until(win.resets_at)}`]))
|
|
285
|
+
}
|
|
286
|
+
return el('div', { class: `gauge${minor ? ' gauge--minor' : ''}` }, [
|
|
287
|
+
el('span', { class: 'gauge-label' }, [words]),
|
|
288
|
+
el('div', { class: 'gauge-track', ...semantics }, [fill, el('span', { class: 'gauge-post', style: 'left:85%' })]),
|
|
289
|
+
el('div', { class: 'gauge-readout' }, readout),
|
|
290
|
+
])
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// A login is a raised object, and how much surface it gets is the design
|
|
294
|
+
// saying how much it matters. The login carrying the terminals gets the wide
|
|
295
|
+
// lit panel with both of its windows drawn; a login with one fact to report
|
|
296
|
+
// gets a half panel; a login that publishes no figure draws no instrument at
|
|
297
|
+
// all, because an empty track reads as a measurement of zero.
|
|
298
|
+
function loginPanel(a, { lit = false } = {}) {
|
|
299
|
+
const state = acctState(a)
|
|
300
|
+
const id = idOf(a.agent)
|
|
301
|
+
const panel = el('article', { class: `panel${lit ? ' panel--lit' : ''}`, 'aria-label': `${accountLabel(a)} usage` })
|
|
302
|
+
const live = a.live ? `${a.live} terminal${a.live === 1 ? '' : 's'} working` : 'no terminals'
|
|
303
|
+
panel.appendChild(el('div', { class: 'panel-head' }, [
|
|
304
|
+
el('span', { class: 'who' }, [el('span', { class: `dot id-${id}` }), el('span', { class: `acct-name id-${id}` }, [accountLabel(a)])]),
|
|
305
|
+
el('span', { class: 'who-note' }, [live]),
|
|
306
|
+
]))
|
|
307
|
+
|
|
308
|
+
if (state === 'notshared' || state === 'loading') {
|
|
309
|
+
panel.appendChild(gauge(a, null, '5h'))
|
|
310
|
+
panel.appendChild(el('p', { class: 'reading-sub reading-sub--lead' }, [state === 'notshared' ? 'Usage for this login is not shared with guests.' : 'Waiting for the first reading.']))
|
|
311
|
+
return panel
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// A login at its wall is reporting the loudest fact it has, and it is a
|
|
315
|
+
// known one: say that instead of "no reading", whether or not a percentage
|
|
316
|
+
// ever came back.
|
|
317
|
+
if (state === 'walled' && !a.five_hour && !a.seven_day) {
|
|
318
|
+
panel.appendChild(el('p', { class: 'reading' }, ['At the wall']))
|
|
319
|
+
panel.appendChild(el('p', { class: 'reading-sub' }, [Number.isFinite(a.limited_until)
|
|
320
|
+
? `Back ${until(a.limited_until)}. Nothing runs on ${accountLabel(a)} until then.`
|
|
321
|
+
: `Nothing runs on ${accountLabel(a)} until it resets.`]))
|
|
322
|
+
return panel
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// agy publishes no usage figure, ever, so there is nothing to draw and the
|
|
326
|
+
// panel says so in a sentence instead of drawing an empty instrument
|
|
327
|
+
if (!a.five_hour && !a.seven_day) {
|
|
328
|
+
panel.appendChild(gauge(a, null, '5h'))
|
|
329
|
+
panel.appendChild(el('p', { class: 'reading-sub reading-sub--lead' }, [a.agent === 'agy'
|
|
330
|
+
? 'agy publishes no usage figure, ever. Leg shows its terminals and their elapsed time instead.'
|
|
331
|
+
: `No reading has come back from ${accountLabel(a)} yet.`]))
|
|
332
|
+
return panel
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// the window closest to a wall is drawn first and full size; the other is
|
|
336
|
+
// the same instrument at half height, so which one to read is not a question
|
|
337
|
+
const [first, second] = [a.seven_day, a.five_hour].every((w) => w && Number.isFinite(w.pct))
|
|
338
|
+
? (a.seven_day.pct >= a.five_hour.pct ? [[a.seven_day, '7d'], [a.five_hour, '5h']] : [[a.five_hour, '5h'], [a.seven_day, '7d']])
|
|
339
|
+
: [[a.five_hour && Number.isFinite(a.five_hour.pct) ? a.five_hour : a.seven_day, a.five_hour && Number.isFinite(a.five_hour.pct) ? '5h' : '7d'], null]
|
|
340
|
+
panel.appendChild(el('div', { class: 'gauge-block' }, [gauge(a, first[0], first[1])]))
|
|
341
|
+
if (second) panel.appendChild(el('div', { class: 'gauge-block' }, [gauge(a, second[0], second[1], { minor: true })]))
|
|
342
|
+
|
|
343
|
+
if (state === 'walled') {
|
|
344
|
+
panel.appendChild(el('p', { class: 'reading' }, ['At the wall']))
|
|
345
|
+
panel.appendChild(el('p', { class: 'reading-sub' }, [`Back ${until(a.limited_until)}. Nothing runs on ${accountLabel(a)} until then.`]))
|
|
346
|
+
}
|
|
347
|
+
return panel
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function renderAccounts(accounts) {
|
|
351
|
+
// THE ONE LINE THAT DIFFERS FROM sessions.js: this page's head box
|
|
352
|
+
const box = document.getElementById('floor-accounts')
|
|
353
|
+
if (!box) return
|
|
354
|
+
box.textContent = ''
|
|
355
|
+
const list = accounts || []
|
|
356
|
+
if (!list.length) return
|
|
357
|
+
// The floor has no verdict sentence: it is the scheduler's view, and its own
|
|
358
|
+
// heading says what page you are on. The login panels are identical to the
|
|
359
|
+
// board's, which is the contract that matters — a percentage, a reset or a
|
|
360
|
+
// wall can never read two ways across the two pages.
|
|
361
|
+
// the same lead rule as the board: the login carrying the terminals, else
|
|
362
|
+
// the one closest to a wall. Leading with a walled login that has no reading
|
|
363
|
+
// put a panel with no instrument in the largest slot.
|
|
364
|
+
const lead = list.find((a) => a.live) || closestToWall(list) || list[0]
|
|
365
|
+
const rest = list.filter((a) => a !== lead)
|
|
366
|
+
box.appendChild(loginPanel(lead, { lit: true }))
|
|
367
|
+
if (rest.length === 1) box.appendChild(loginPanel(rest[0]))
|
|
368
|
+
else if (rest.length) box.appendChild(el('div', { class: 'logins-pair' }, rest.map((a) => loginPanel(a))))
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// the head is kept so a breakpoint change can redraw it without a refetch,
|
|
372
|
+
// the way sessions.js redraws from its cached view
|
|
373
|
+
let lastAccounts = []
|
|
374
|
+
function renderHead(accounts) { lastAccounts = accounts || lastAccounts; renderAccounts(lastAccounts) }
|
|
375
|
+
|
|
376
|
+
// The sentence a region prints in place of its rows. Each one says what the
|
|
377
|
+
// region holds AND what puts something in it, the shape index.html's own
|
|
378
|
+
// empty states use: four of the five used to say only that there was nothing
|
|
379
|
+
// there, which teaches a first-time reader nothing about what a lease is or
|
|
380
|
+
// how a card reaches the queue.
|
|
381
|
+
function emptyRow(text) { return el('p', { class: 'empty-line' }, [text]) }
|
|
382
|
+
|
|
383
|
+
// renderTable: table-layout: fixed with real column widths (board.css) means
|
|
384
|
+
// an empty table would otherwise show a header over a void; instead the
|
|
385
|
+
// <thead> hides and one emptyRow() sentence takes its place under the head.
|
|
386
|
+
function renderTable(bodyId, list, buildRow, emptyText) {
|
|
387
|
+
const body = document.getElementById(bodyId)
|
|
388
|
+
body.textContent = ''
|
|
389
|
+
const table = body.parentElement
|
|
390
|
+
const section = table && table.parentElement
|
|
391
|
+
if (section) { const old = section.querySelector('.empty-line'); if (old) old.remove() }
|
|
392
|
+
const thead = table && table.querySelector('thead')
|
|
393
|
+
if (!list.length) {
|
|
394
|
+
if (thead) thead.hidden = true
|
|
395
|
+
if (section) section.appendChild(emptyRow(emptyText))
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
if (thead) thead.hidden = false
|
|
399
|
+
for (const item of list) for (const row of [].concat(buildRow(item))) body.appendChild(row)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function setRegionMeta(headId, text) {
|
|
403
|
+
const head = document.getElementById(headId)
|
|
404
|
+
const meta = head && head.parentElement && head.parentElement.querySelector('.region-meta')
|
|
405
|
+
if (meta) meta.textContent = text
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function runFloorAction(id, action) {
|
|
409
|
+
try {
|
|
410
|
+
await api(`/api/cards/${encodeURIComponent(id)}/${action}`, { method: 'POST', body: {} })
|
|
411
|
+
refreshFloor()
|
|
412
|
+
} catch (err) {
|
|
413
|
+
toast(err.message)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function renderHeader(data) {
|
|
418
|
+
// repo names only on the floor (the full path is the tooltip): a shared
|
|
419
|
+
// floor should not print every operator's home directory
|
|
420
|
+
const reposEl = document.getElementById('repos-list')
|
|
421
|
+
reposEl.textContent = data.repos && data.repos.length ? data.repos.map((r) => String(r).split(/[\\/]/).filter(Boolean).pop() || r).join(', ') : '(none)'
|
|
422
|
+
reposEl.title = data.repos && data.repos.length ? data.repos.join('\n') : ''
|
|
423
|
+
const sched = data.scheduler || {}
|
|
424
|
+
document.getElementById('sched-status').textContent = `scheduler ${sched.running ? 'running' : 'stopped'}, ${sched.max_concurrent ?? '?'} max`
|
|
425
|
+
document.getElementById('count-running').textContent = String(data.counts.running)
|
|
426
|
+
document.getElementById('count-queued').textContent = String(data.counts.queued)
|
|
427
|
+
document.getElementById('count-waiting').textContent = String(data.counts.waiting)
|
|
428
|
+
document.getElementById('count-done').textContent = String(data.counts.done)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function renderRunning(list) {
|
|
432
|
+
renderTable('running-body', list, (r) => el('tr', {}, [
|
|
433
|
+
el('td', { 'data-label': 'Card', title: r.title || r.card_id }, [r.title || r.card_id]),
|
|
434
|
+
el('td', { 'data-label': 'Station' }, [r.station || '']),
|
|
435
|
+
el('td', { 'data-label': 'Agent / leg' }, [el('span', { class: 'chip' }, [`${r.adapter || '?'} leg ${r.leg}`])]),
|
|
436
|
+
el('td', { 'data-label': 'Leases' }, [el('span', { class: 'chip' }, [(r.leases && r.leases.length ? r.leases : ['**']).join(', ')])]),
|
|
437
|
+
el('td', { 'data-label': 'Last event', title: formatLastEvent(r.last_event) }, [formatLastEvent(r.last_event)]),
|
|
438
|
+
el('td', { 'data-label': 'Elapsed' }, [el('span', { class: 'elapsed' }, [elapsedClock(r.elapsed_ms || 0)])]),
|
|
439
|
+
el('td', { class: 'row-actions', 'data-label': 'Actions' }, [
|
|
440
|
+
el('button', { type: 'button', class: 'btn btn-secondary', 'aria-label': `Pause ${r.title || r.card_id}`, onclick: () => runFloorAction(r.card_id, 'pause') }, ['Pause']),
|
|
441
|
+
el('button', { type: 'button', class: 'btn btn-danger', 'aria-label': `Kill ${r.title || r.card_id}`, onclick: () => runFloorAction(r.card_id, 'kill') }, ['Kill']),
|
|
442
|
+
]),
|
|
443
|
+
]), 'Nothing running. A queued card starts here when its leases are free and the scheduler has a slot.')
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function renderWaiting(list) {
|
|
447
|
+
renderTable('waiting-body', list, (w) => {
|
|
448
|
+
const btns = (w.actions || [])
|
|
449
|
+
.filter((a) => WAIT_LABELS[a])
|
|
450
|
+
.map((a) => el('button', { type: 'button', class: WAIT_BTN_CLASS[a], 'aria-label': `${WAIT_LABELS[a]} ${w.title || w.card_id}`, onclick: () => runFloorAction(w.card_id, a) }, [WAIT_LABELS[a]]))
|
|
451
|
+
return el('tr', {}, [
|
|
452
|
+
el('td', { 'data-label': 'Card', title: w.title || w.card_id }, [w.title || w.card_id]),
|
|
453
|
+
el('td', { 'data-label': 'Station' }, [w.station || '']),
|
|
454
|
+
el('td', { 'data-label': 'Status' }, [STATUS_LABELS[w.status] || w.status]),
|
|
455
|
+
el('td', { 'data-label': 'Since' }, [el('span', { class: 'chip' }, [formatTs(w.since)])]),
|
|
456
|
+
el('td', { class: 'row-actions', 'data-label': 'Actions' }, btns),
|
|
457
|
+
])
|
|
458
|
+
}, 'No cards waiting on a human. A card lands here when a station asks for approval or a run stops for an answer.')
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function renderQueued(list) {
|
|
462
|
+
renderTable('queued-body', list, (q) => el('tr', {}, [
|
|
463
|
+
el('td', { 'data-label': 'Card', title: q.title || q.card_id }, [q.title || q.card_id]),
|
|
464
|
+
el('td', { 'data-label': 'Station' }, [q.station || '']),
|
|
465
|
+
el('td', { 'data-label': 'Leases' }, [el('span', { class: 'chip' }, [(q.leases && q.leases.length ? q.leases : ['**']).join(', ')])]),
|
|
466
|
+
// the scheduler's blocked_by summary already opens with "blocked by"
|
|
467
|
+
// (scheduler.mjs), and the column is headed "Blocked by": prefixing it
|
|
468
|
+
// here printed `blocked by blocked by card "X" on src/**`
|
|
469
|
+
el('td', { 'data-label': 'Blocked by', title: q.blocked_by || '' }, [q.blocked_by || '']),
|
|
470
|
+
]), 'Nothing queued. New card on the board queues one; it waits here until a slot and its leases are free.')
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function renderLeases(data) {
|
|
474
|
+
renderTable('leases-body', data.leases, (l) => {
|
|
475
|
+
const rows = [el('tr', {}, [
|
|
476
|
+
el('td', { 'data-label': 'Lease' }, [el('span', { class: 'chip' }, [l.lease])]),
|
|
477
|
+
el('td', { 'data-label': 'Card', title: l.card_id }, [l.card_id]),
|
|
478
|
+
el('td', { 'data-label': 'Station' }, [l.station || '']),
|
|
479
|
+
el('td', { 'data-label': 'Since' }, [el('span', { class: 'chip' }, [formatTs(l.since)])]),
|
|
480
|
+
])]
|
|
481
|
+
const blocked = (data.queued || []).filter((q) => q.blocked_by && q.blocked_by.includes(l.lease))
|
|
482
|
+
if (blocked.length) {
|
|
483
|
+
const names = blocked.map((q) => q.title || q.card_id).join(', ')
|
|
484
|
+
rows.push(el('tr', { class: 'lease-blocked-row' }, [el('td', { colspan: '4', 'data-label': 'Note', title: names }, [`queued, blocked: ${names}`])]))
|
|
485
|
+
}
|
|
486
|
+
return rows
|
|
487
|
+
}, 'No leases held. A running card reserves the files it will change, so an overlapping card waits instead of clashing.')
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function renderTrunkTable(data) {
|
|
491
|
+
renderTable('trunk-body', data.landed, (t) => el('tr', {}, [
|
|
492
|
+
el('td', { 'data-label': 'Time' }, [el('span', { class: 'chip' }, [formatTs(t.ts)])]),
|
|
493
|
+
el('td', { 'data-label': 'Card', title: t.title || t.card_id }, [t.title || t.card_id]),
|
|
494
|
+
el('td', { 'data-label': 'Summary', title: t.summary || '' }, [t.summary || '']),
|
|
495
|
+
]), 'Nothing landed in the last hour.')
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// the floor is owner-only: a guest (or anyone whose token has rotated) would
|
|
499
|
+
// otherwise get two toasts every two seconds for as long as the tab is open
|
|
500
|
+
function boardHref(href) {
|
|
501
|
+
try {
|
|
502
|
+
const token = new URL(href).searchParams.get('token')
|
|
503
|
+
return token ? `/?token=${encodeURIComponent(token)}` : '/'
|
|
504
|
+
} catch { return '/' }
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function lockOut(message) {
|
|
508
|
+
if (state.stopped) return
|
|
509
|
+
state.stopped = true
|
|
510
|
+
for (const t of state.timers) clearInterval(t)
|
|
511
|
+
state.timers = []
|
|
512
|
+
if (state.es) { try { state.es.close() } catch { /* ignore */ } }
|
|
513
|
+
document.getElementById('banner').hidden = true
|
|
514
|
+
document.getElementById('sse-dot').className = 'sse-rule'
|
|
515
|
+
document.getElementById('sse-text').textContent = 'stopped'
|
|
516
|
+
const main = document.querySelector('.shell')
|
|
517
|
+
main.textContent = ''
|
|
518
|
+
main.appendChild(el('section', { class: 'region' }, [
|
|
519
|
+
el('h2', { class: 'region-title' }, ['Floor unavailable']),
|
|
520
|
+
el('p', { class: 'empty-line' }, [message]),
|
|
521
|
+
el('p', { class: 'empty-line' }, [
|
|
522
|
+
el('a', { href: boardHref(location.href) }, ['Open the board']),
|
|
523
|
+
' to set a token, or ask the owner of this machine for a share link.',
|
|
524
|
+
]),
|
|
525
|
+
]))
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function refreshFloor() {
|
|
529
|
+
const request = ++state.floorRequest
|
|
530
|
+
try {
|
|
531
|
+
const data = await api('/api/floor')
|
|
532
|
+
if (state.stopped || request !== state.floorRequest) return
|
|
533
|
+
state.lastReadingAt = new Date()
|
|
534
|
+
renderHeader(data)
|
|
535
|
+
renderRunning(data.running)
|
|
536
|
+
renderWaiting(data.waiting)
|
|
537
|
+
renderQueued(data.queued)
|
|
538
|
+
renderLeases(data)
|
|
539
|
+
setRegionMeta('running-head', `${data.running.length} running`)
|
|
540
|
+
setRegionMeta('waiting-head', `${data.waiting.length} waiting on a human`)
|
|
541
|
+
setRegionMeta('queued-head', `${data.queued.length} queued`)
|
|
542
|
+
setRegionMeta('leases-head', `${data.leases.length} held`)
|
|
543
|
+
} catch (err) {
|
|
544
|
+
if (state.stopped || request !== state.floorRequest) return
|
|
545
|
+
if (err.status === 401 || err.status === 403) return lockOut(err.message)
|
|
546
|
+
toast(err.message)
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function refreshTrunk() {
|
|
551
|
+
const request = ++state.trunkRequest
|
|
552
|
+
try {
|
|
553
|
+
const data = await api('/api/trunk?since=1h')
|
|
554
|
+
if (state.stopped || request !== state.trunkRequest) return
|
|
555
|
+
state.lastReadingAt = new Date()
|
|
556
|
+
renderTrunkTable(data)
|
|
557
|
+
setRegionMeta('trunk-lane-head', `${data.landed.length} landed in the last hour`)
|
|
558
|
+
} catch (err) {
|
|
559
|
+
if (state.stopped || request !== state.trunkRequest) return
|
|
560
|
+
if (err.status === 401 || err.status === 403) return lockOut(err.message)
|
|
561
|
+
toast(err.message)
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// the instrument head's accounts are not on /api/floor's payload; /api/sessions
|
|
566
|
+
// carries them (sessionsView()) and is already reachable by an owner token.
|
|
567
|
+
async function refreshHead() {
|
|
568
|
+
const request = ++state.headRequest
|
|
569
|
+
try {
|
|
570
|
+
const data = await api('/api/sessions')
|
|
571
|
+
if (state.stopped || request !== state.headRequest) return
|
|
572
|
+
state.lastReadingAt = new Date()
|
|
573
|
+
renderHead(data.accounts || [])
|
|
574
|
+
} catch (err) {
|
|
575
|
+
if (state.stopped || request !== state.headRequest) return
|
|
576
|
+
if (err.status === 401 || err.status === 403) return lockOut(err.message)
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// The board is pushed a card for every write under its directory, log bytes
|
|
581
|
+
// included, so a busy agent turns an unconditional refresh-per-push into a
|
|
582
|
+
// continuous request stream against a single-threaded local server that is
|
|
583
|
+
// also serving this page's SSE. One pending refresh at a time, the shape
|
|
584
|
+
// board.js uses for its drawer.
|
|
585
|
+
const FLOOR_REFRESH_MS = 250
|
|
586
|
+
function scheduleFloorRefresh() {
|
|
587
|
+
if (state.pendingFloor) return
|
|
588
|
+
state.pendingFloor = setTimeout(() => { state.pendingFloor = null; refreshFloor() }, FLOOR_REFRESH_MS)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ---- SSE ----
|
|
592
|
+
function setSseState(s) {
|
|
593
|
+
const dot = document.getElementById('sse-dot')
|
|
594
|
+
const text = document.getElementById('sse-text')
|
|
595
|
+
const banner = document.getElementById('banner')
|
|
596
|
+
dot.className = `sse-rule is-${s}`
|
|
597
|
+
const retrySec = Math.max(1, Math.round((state.retryMs || 1000) / 1000))
|
|
598
|
+
text.textContent = s === 'live' ? 'live' : s === 'reconnecting' ? `reconnecting, next attempt in ${retrySec}s` : 'connecting'
|
|
599
|
+
banner.hidden = s === 'live'
|
|
600
|
+
if (s === 'reconnecting') {
|
|
601
|
+
banner.textContent = state.lastReadingAt ? `Reconnecting to Leg. Last reading ${clockAt(state.lastReadingAt.getTime())}.` : `Reconnecting to Leg on ${state.bind}.`
|
|
602
|
+
} else if (s === 'connecting') {
|
|
603
|
+
banner.textContent = `Connecting to Baton on ${state.bind}.`
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function connectSse() {
|
|
608
|
+
if (state.stopped) return
|
|
609
|
+
if (state.es) { try { state.es.close() } catch { /* ignore */ } }
|
|
610
|
+
const request = ++state.sseRequest
|
|
611
|
+
setSseState('connecting')
|
|
612
|
+
const token = getToken()
|
|
613
|
+
const url = token ? `/api/events?token=${encodeURIComponent(token)}` : '/api/events'
|
|
614
|
+
const es = new EventSource(url)
|
|
615
|
+
state.es = es
|
|
616
|
+
es.addEventListener('hello', () => {
|
|
617
|
+
if (state.es !== es || request !== state.sseRequest) return
|
|
618
|
+
state.retryMs = 1000
|
|
619
|
+
setSseState('live')
|
|
620
|
+
refreshFloor()
|
|
621
|
+
refreshTrunk()
|
|
622
|
+
refreshHead()
|
|
623
|
+
})
|
|
624
|
+
es.addEventListener('card', () => { if (state.es === es && request === state.sseRequest) scheduleFloorRefresh() })
|
|
625
|
+
es.addEventListener('event', (e) => {
|
|
626
|
+
if (state.es !== es || request !== state.sseRequest) return
|
|
627
|
+
scheduleFloorRefresh()
|
|
628
|
+
const data = JSON.parse(e.data)
|
|
629
|
+
if (data.type === 'landed') refreshTrunk()
|
|
630
|
+
})
|
|
631
|
+
es.onerror = () => {
|
|
632
|
+
if (state.es !== es || request !== state.sseRequest) return
|
|
633
|
+
setSseState('reconnecting')
|
|
634
|
+
try { es.close() } catch { /* ignore */ }
|
|
635
|
+
const wait = state.retryMs || 1000
|
|
636
|
+
setTimeout(() => { if (request === state.sseRequest) connectSse() }, wait)
|
|
637
|
+
state.retryMs = Math.min(wait * 2, 15000)
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// the address in the banners comes from the server, so both pages name the
|
|
642
|
+
// same one; location.host already covers it if health is unreachable
|
|
643
|
+
async function loadBind() {
|
|
644
|
+
try {
|
|
645
|
+
const data = await api('/api/health')
|
|
646
|
+
if (data && data.bind) state.bind = `${data.bind}:${data.port}`
|
|
647
|
+
} catch { /* the page's own host is already a correct answer */ }
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function init() {
|
|
651
|
+
loadBind()
|
|
652
|
+
connectSse()
|
|
653
|
+
refreshFloor()
|
|
654
|
+
refreshTrunk()
|
|
655
|
+
refreshHead()
|
|
656
|
+
// a backgrounded tab kept polling three endpoints every two seconds forever;
|
|
657
|
+
// the SSE stream wakes it with the work it missed when it comes back
|
|
658
|
+
state.timers.push(
|
|
659
|
+
setInterval(() => { if (!document.hidden) refreshFloor() }, 2000),
|
|
660
|
+
setInterval(() => { if (!document.hidden) { refreshTrunk(); refreshHead() } }, 2000),
|
|
661
|
+
)
|
|
662
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden && !state.stopped) { refreshFloor(); refreshTrunk(); refreshHead() } })
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
document.addEventListener('DOMContentLoaded', init)
|
|
666
|
+
|
|
667
|
+
// test seam: node:test runs this file with a stub document; in a browser
|
|
668
|
+
// there is no `module`
|
|
669
|
+
if (typeof module !== 'undefined') module.exports = { boardHref }
|
|
670
|
+
})()
|