@nhic-lab/srv-wrapper 0.1.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/public/app.js ADDED
@@ -0,0 +1,1802 @@
1
+ /* srv ops console — list/detail shell.
2
+ Implements `Srv Console B.dc.html`. Vanilla, no build step.
3
+ Rendering rule (see docs dev-notes): the list and the detail re-render
4
+ independently, and the detail's terminal is never re-rendered while a run
5
+ streams — lines are appended to it in place. */
6
+
7
+ const MAX_TERM_LINES = 4000
8
+ const LIVE_GRACE_MS = 30000
9
+ const TEST_ALL_STUCK_TIMEOUT_MS = 15000
10
+ const HISTORY_PAGE = 500
11
+ const SERVER_RUNS = 6
12
+
13
+ const IMPORT_SAMPLE = `[
14
+ {
15
+ "id": "srv-c3",
16
+ "host": "10.0.3.11",
17
+ "port": 22,
18
+ "username": "deploy",
19
+ "authMethod": "key",
20
+ "keyPath": "~/.ssh/id_ed25519",
21
+ "jumpChain": ["bastion-eu"]
22
+ }
23
+ ]`
24
+
25
+ const state = {
26
+ view: localStorage.getItem('srv.lastView') || 'live',
27
+ query: '',
28
+ histStatus: 'any',
29
+ servers: [],
30
+ tests: new Map(), // server id -> { state: 'testing'|'online'|'offline'|'untested', error? }
31
+ live: new Map(), // requestId -> run
32
+ history: [], // run metadata only — output is fetched per run
33
+ historyLoaded: false,
34
+ historyTotal: 0,
35
+ runOutputs: new Map(), // run id -> full record with output
36
+ serverRuns: new Map(), // server id -> recent runs for that server
37
+ srvStatus: 'any',
38
+ selRun: null,
39
+ selHist: localStorage.getItem('srv.selHist') || null,
40
+ selServer: localStorage.getItem('srv.selServer') || null,
41
+ draft: false,
42
+ form: blankForm(),
43
+ formError: null,
44
+ testResult: null, // { state: 'pending'|'ok'|'fail', text }
45
+ importOpen: false,
46
+ confirmId: null,
47
+ paletteOpen: false,
48
+ paletteQuery: '',
49
+ palIndex: 0,
50
+ toasts: [],
51
+ }
52
+
53
+ // DOM nodes that live for the whole session
54
+ const el = {
55
+ app: document.getElementById('app'),
56
+ conn: document.getElementById('conn'),
57
+ tabLiveBadge: document.getElementById('tab-live-badge'),
58
+ tabServerCount: document.getElementById('tab-server-count'),
59
+ overline: document.getElementById('list-overline'),
60
+ title: document.getElementById('list-title'),
61
+ search: document.getElementById('list-search'),
62
+ histStatus: document.getElementById('hist-status'),
63
+ testAll: document.getElementById('test-all'),
64
+ srvStatus: document.getElementById('srv-status'),
65
+ tallies: document.getElementById('tallies'),
66
+ listMore: document.getElementById('list-more'),
67
+ listMoreNote: document.getElementById('list-more-note'),
68
+ loadMore: document.getElementById('load-more'),
69
+ themeToggle: document.getElementById('theme-toggle'),
70
+ addServer: document.getElementById('add-server'),
71
+ listScroll: document.getElementById('list-scroll'),
72
+ listRows: document.getElementById('list-rows'),
73
+ importBlock: document.getElementById('import-block'),
74
+ importToggle: document.getElementById('import-toggle'),
75
+ importBody: document.getElementById('import-body'),
76
+ importText: document.getElementById('import-text'),
77
+ detail: document.getElementById('detail'),
78
+ detailPane: document.getElementById('detail-pane'),
79
+ paneBack: document.getElementById('pane-back'),
80
+ paneBackLabel: document.getElementById('pane-back-label'),
81
+ toasts: document.getElementById('toasts'),
82
+ palette: document.getElementById('palette'),
83
+ paletteOverlay: document.getElementById('palette-overlay'),
84
+ paletteScrim: document.getElementById('palette-scrim'),
85
+ paletteInput: document.getElementById('palette-input'),
86
+ paletteList: document.getElementById('palette-list'),
87
+ confirm: document.getElementById('confirm-dialog'),
88
+ confirmOverlay: document.getElementById('confirm-overlay'),
89
+ confirmScrim: document.getElementById('confirm-scrim'),
90
+ confirmTitle: document.getElementById('confirm-title'),
91
+ }
92
+
93
+ // terminal DOM mirror for the currently rendered run
94
+ let term = { key: null, container: null, rows: [], stick: true }
95
+ let palRows = []
96
+
97
+ // ---------- small helpers ----------
98
+
99
+ function blankForm() {
100
+ return { id: '', host: '', port: '22', username: '', authMethod: 'key', secret: '', hops: [] }
101
+ }
102
+
103
+ function h(str) {
104
+ return String(str == null ? '' : str).replace(/[&<>"']/g, (c) => (
105
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
106
+ ))
107
+ }
108
+
109
+ function pad2(n) { return String(n).padStart(2, '0') }
110
+
111
+ function fmtStamp(ms) {
112
+ if (!ms) return '—'
113
+ const d = new Date(ms)
114
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
115
+ }
116
+
117
+ function fmtDate(ms) {
118
+ if (!ms) return '—'
119
+ const d = new Date(ms)
120
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
121
+ }
122
+
123
+ function fmtSecs(sec) {
124
+ if (sec < 60) return `${sec}s`
125
+ const m = Math.floor(sec / 60)
126
+ const s = sec % 60
127
+ return `${m}m ${s < 10 ? '0' + s : s}s`
128
+ }
129
+
130
+ function fmtElapsed(startedAt) {
131
+ return fmtSecs(Math.max(0, Math.round((Date.now() - startedAt) / 1000)))
132
+ }
133
+
134
+ function fmtDuration(startedAt, endedAt) {
135
+ if (!startedAt || !endedAt) return null
136
+ const ms = endedAt - startedAt
137
+ if (ms < 1000) return `${ms}ms`
138
+ const sec = Math.round(ms / 1000)
139
+ if (sec < 60) return `${(ms / 1000).toFixed(1)}s`
140
+ return fmtSecs(sec)
141
+ }
142
+
143
+ function relTime(ms) {
144
+ if (!ms) return '—'
145
+ const diff = Date.now() - ms
146
+ if (diff < 45000) return 'just now'
147
+ const min = Math.round(diff / 60000)
148
+ if (min < 60) return `${min} min ago`
149
+ const hr = Math.round(min / 60)
150
+ if (hr < 24) return hr === 1 ? '1 hr ago' : `${hr} hr ago`
151
+ const day = Math.round(hr / 24)
152
+ return day === 1 ? 'yesterday' : `${day} days ago`
153
+ }
154
+
155
+ // Remote output carries ANSI colour/cursor escapes that would otherwise render
156
+ // as literal garbage in the terminal panel.
157
+ const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g
158
+
159
+ function clean(str) {
160
+ return String(str).replace(ANSI_RE, '').replace(/\r/g, '').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '')
161
+ }
162
+
163
+ function fmtBytes(n) {
164
+ if (n == null) return ''
165
+ if (n < 1024) return `${n} B`
166
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`
167
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`
168
+ }
169
+
170
+ function serverById(id) {
171
+ return state.servers.find((s) => s.id === id)
172
+ }
173
+
174
+ function addrOf(id) {
175
+ const s = serverById(id)
176
+ return s ? `${s.username}@${s.host}:${s.port}` : '—'
177
+ }
178
+
179
+ function icon(paths, size = 14, extra = '') {
180
+ return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"${extra}>${paths}</svg>`
181
+ }
182
+
183
+ const ICON = {
184
+ plus: '<path d="M5 12h14M12 5v14"></path>',
185
+ x: '<path d="M18 6 6 18M6 6l12 12"></path>',
186
+ copy: '<rect width="14" height="14" x="8" y="8" rx="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>',
187
+ zap: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"></path>',
188
+ }
189
+
190
+ function toast(text, tone = 'neutral') {
191
+ const id = 't' + Date.now() + Math.random()
192
+ state.toasts.push({ id, text, tone })
193
+ renderToasts()
194
+ setTimeout(() => dismissToast(id), 4200)
195
+ }
196
+
197
+ function dismissToast(id) {
198
+ const i = state.toasts.findIndex((t) => t.id === id)
199
+ if (i === -1) return
200
+ state.toasts.splice(i, 1)
201
+ renderToasts()
202
+ }
203
+
204
+ function renderToasts() {
205
+ el.toasts.innerHTML = state.toasts.map((t) => `
206
+ <div class="toast facet-8">
207
+ <span class="toast-dot ${t.tone === 'ok' ? 'ok' : t.tone === 'fail' ? 'fail' : ''}"></span>
208
+ <span class="toast-text">${h(t.text)}</span>
209
+ <button type="button" class="toast-x" data-id="${h(t.id)}" title="Dismiss" aria-label="Dismiss">${icon(ICON.x, 13)}</button>
210
+ </div>`).join('')
211
+ el.toasts.querySelectorAll('.toast-x').forEach((b) => {
212
+ b.addEventListener('click', () => dismissToast(b.dataset.id))
213
+ })
214
+ }
215
+
216
+ // ---------- jump chain preview (mirrors the daemon's validator; the daemon
217
+ // re-validates authoritatively on submit) ----------
218
+
219
+ function resolveJumpPathLocal(targetId, proposedChain) {
220
+ const seen = new Set([targetId])
221
+ const path = []
222
+ const expand = (id) => {
223
+ if (seen.has(id)) throw new Error(`"${id}" would be reached more than once`)
224
+ seen.add(id)
225
+ const rec = serverById(id)
226
+ if (!rec) throw new Error(`unknown server id "${id}"`)
227
+ ;(rec.jumpChain || []).forEach(expand)
228
+ path.push(id)
229
+ }
230
+ proposedChain.forEach(expand)
231
+ path.push(targetId)
232
+ return path
233
+ }
234
+
235
+ function formTargetId() {
236
+ return state.draft ? (state.form.id.trim() || 'new-server') : (state.selServer || 'this server')
237
+ }
238
+
239
+ function hopChoices(idx) {
240
+ const target = formTargetId()
241
+ const chain = state.form.hops
242
+ return state.servers.filter((s) => {
243
+ if (s.id === target) return false
244
+ if (chain.some((id, j) => j !== idx && id === s.id)) return false
245
+ try {
246
+ resolveJumpPathLocal(target, chain.map((id, j) => (j === idx ? s.id : id)))
247
+ return true
248
+ } catch { return false }
249
+ })
250
+ }
251
+
252
+ // ---------- data loading ----------
253
+
254
+ async function loadServers() {
255
+ try {
256
+ const res = await fetch('/api/servers')
257
+ state.servers = await res.json()
258
+ } catch {
259
+ toast('Could not reach the daemon to list servers.', 'fail')
260
+ return
261
+ }
262
+ const ids = new Set(state.servers.map((s) => s.id))
263
+ for (const id of [...state.tests.keys()]) if (!ids.has(id)) state.tests.delete(id)
264
+ if (state.selServer && !ids.has(state.selServer)) state.selServer = null
265
+ renderTabs()
266
+ renderList()
267
+ if (state.view === 'servers') renderDetail()
268
+ }
269
+
270
+ /**
271
+ * Loads a page of run *metadata*. Output is never included here — fetching it
272
+ * for every run is what used to make this request fail outright once stored
273
+ * output passed V8's string ceiling.
274
+ */
275
+ let historyInFlight = false
276
+
277
+ async function loadHistory({ append = false } = {}) {
278
+ if (append && historyInFlight) return
279
+ const offset = append ? state.history.length : 0
280
+ // A refresh (e.g. after a run finishes) must not throw away pages the user
281
+ // already loaded, so it re-reads the whole loaded window, not just page one.
282
+ const limit = append ? HISTORY_PAGE : Math.max(HISTORY_PAGE, state.history.length)
283
+ historyInFlight = true
284
+ try {
285
+ const res = await fetch(`/api/history?limit=${limit}&offset=${offset}`)
286
+ if (!res.ok) throw new Error(`request failed (${res.status})`)
287
+ const rows = await res.json()
288
+ const total = Number(res.headers.get('X-Total-Count'))
289
+ state.historyTotal = Number.isFinite(total) ? total : rows.length
290
+ state.history = append ? state.history.concat(rows) : rows
291
+ state.historyLoaded = true
292
+ } catch (err) {
293
+ if (!state.historyLoaded) toast(`Could not load history: ${err.message}`, 'fail')
294
+ return
295
+ } finally {
296
+ historyInFlight = false
297
+ }
298
+ if (state.view === 'history') { renderList(); renderDetail() }
299
+ else if (state.view === 'servers') renderDetail()
300
+ }
301
+
302
+ /**
303
+ * Fetches (and caches) one run's output. The cache is bounded: each entry can
304
+ * be up to the 256 KB output cap, and a dashboard left open for a day would
305
+ * otherwise accumulate every run the user clicked.
306
+ */
307
+ const RUN_CACHE_MAX = 20
308
+
309
+ async function loadRunOutput(id) {
310
+ if (state.runOutputs.has(id)) return state.runOutputs.get(id)
311
+ const res = await fetch(`/api/history/${encodeURIComponent(id)}`)
312
+ if (!res.ok) throw new Error(`could not load output (${res.status})`)
313
+ const run = await res.json()
314
+ state.runOutputs.set(id, run)
315
+ // Map preserves insertion order, so the oldest key is the first one.
316
+ while (state.runOutputs.size > RUN_CACHE_MAX) {
317
+ state.runOutputs.delete(state.runOutputs.keys().next().value)
318
+ }
319
+ return run
320
+ }
321
+
322
+ /** Recent runs for one server, via the endpoint's serverId filter. */
323
+ async function loadServerRuns(id) {
324
+ try {
325
+ const res = await fetch(`/api/history?serverId=${encodeURIComponent(id)}&limit=${SERVER_RUNS}`)
326
+ if (!res.ok) throw new Error(String(res.status))
327
+ state.serverRuns.set(id, await res.json())
328
+ } catch {
329
+ state.serverRuns.set(id, [])
330
+ }
331
+ if (state.view === 'servers' && state.selServer === id) renderServerRuns()
332
+ }
333
+
334
+ // ---------- run/line model ----------
335
+
336
+ function newRun(requestId, msg) {
337
+ return {
338
+ requestId,
339
+ serverId: msg.serverId || '—',
340
+ agentLabel: msg.agentLabel || '—',
341
+ command: msg.command || null,
342
+ lines: [],
343
+ open: null,
344
+ startedAt: Date.now(),
345
+ done: false,
346
+ exitCode: undefined,
347
+ error: null,
348
+ }
349
+ }
350
+
351
+ /* Appends a chunk to a run's line list. Returns the DOM ops needed to mirror
352
+ the change, so the terminal can be updated without a full rebuild. */
353
+ function ingest(run, stream, rawChunk) {
354
+ const chunk = clean(rawChunk)
355
+ const ops = []
356
+ let buf = chunk
357
+ while (buf.length) {
358
+ const nl = buf.indexOf('\n')
359
+ const piece = nl === -1 ? buf : buf.slice(0, nl)
360
+ if (piece || nl === -1) {
361
+ if (run.open && run.open.stream === stream) {
362
+ run.lines[run.open.idx].text += piece
363
+ ops.push({ type: 'update', idx: run.open.idx })
364
+ } else {
365
+ run.lines.push({ stream, text: piece })
366
+ run.open = { stream, idx: run.lines.length - 1 }
367
+ ops.push({ type: 'append', idx: run.lines.length - 1 })
368
+ }
369
+ }
370
+ if (nl === -1) break
371
+ if (run.open && run.open.stream === stream) run.open = null
372
+ else if (!piece) {
373
+ run.lines.push({ stream, text: '' })
374
+ ops.push({ type: 'append', idx: run.lines.length - 1 })
375
+ }
376
+ buf = buf.slice(nl + 1)
377
+ }
378
+ if (run.lines.length > MAX_TERM_LINES) {
379
+ const drop = run.lines.length - MAX_TERM_LINES
380
+ run.lines.splice(0, drop)
381
+ if (run.open) run.open.idx -= drop
382
+ return [{ type: 'rebuild' }]
383
+ }
384
+ return ops
385
+ }
386
+
387
+ function historyLines(record) {
388
+ const text = clean(record.output || '')
389
+ if (!text) return []
390
+ const parts = text.split('\n')
391
+ if (parts.length && parts[parts.length - 1] === '') parts.pop()
392
+ return parts.map((t) => ({ stream: 'out', text: t }))
393
+ }
394
+
395
+ // ---------- status vocabulary ----------
396
+
397
+ function liveBadge(run) {
398
+ if (!run.done) return { label: 'running', tone: 'running' }
399
+ if (run.error) return { label: 'failed', tone: 'fail' }
400
+ if (run.exitCode === 0) return { label: 'exit 0', tone: 'ok' }
401
+ if (typeof run.exitCode === 'number') return { label: `exit ${run.exitCode}`, tone: 'fail' }
402
+ return { label: 'done', tone: 'untested' }
403
+ }
404
+
405
+ /* History rows carry exitCode === null both for runs still open and for runs
406
+ the daemon closed without a code (a failed connection, a stopped session).
407
+ endedAt is what separates the two. */
408
+ function histBadge(r) {
409
+ if (r.endedAt == null) return { label: 'running', tone: 'running' }
410
+ if (r.exitCode === 0) return { label: 'exit 0', tone: 'ok' }
411
+ if (r.exitCode == null) return { label: 'no exit code', tone: 'fail' }
412
+ return { label: `exit ${r.exitCode}`, tone: 'fail' }
413
+ }
414
+
415
+ /**
416
+ * Reachability for one server. An in-flight/just-finished result from this
417
+ * session wins; otherwise it falls back to what the daemon persisted, so the
418
+ * online/offline filter still means something after a refresh or restart.
419
+ */
420
+ function testMeta(id) {
421
+ const live = state.tests.get(id)
422
+ const rec = serverById(id)
423
+ let s = 'untested'
424
+ let error
425
+ let at
426
+ if (live && live.state === 'testing') {
427
+ s = 'testing'
428
+ } else if (live) {
429
+ s = live.state
430
+ error = live.error
431
+ at = live.at
432
+ } else if (rec && rec.lastTestAt) {
433
+ s = rec.lastTestOk ? 'online' : 'offline'
434
+ error = rec.lastTestError
435
+ at = rec.lastTestAt
436
+ }
437
+ return {
438
+ state: s,
439
+ label: { online: 'reachable', offline: 'unreachable', testing: 'testing…', untested: 'not tested' }[s],
440
+ tone: { online: 'ok', offline: 'fail', testing: 'testing', untested: 'untested' }[s],
441
+ error,
442
+ at,
443
+ }
444
+ }
445
+
446
+ const TALLY_LABELS = { online: 'Online', offline: 'Offline', untested: 'Not tested', testing: 'Testing' }
447
+
448
+ function renderTallies() {
449
+ if (state.view !== 'servers' || !state.servers.length) {
450
+ el.tallies.hidden = true
451
+ return
452
+ }
453
+ const counts = { online: 0, offline: 0, untested: 0, testing: 0 }
454
+ state.servers.forEach((srv) => { counts[testMeta(srv.id).state] += 1 })
455
+
456
+ const parts = ['online', 'offline', 'untested']
457
+ .map((k) => `<button type="button" class="tally" data-k="${k}" aria-pressed="${state.srvStatus === k}">
458
+ <span class="tally-dot ${k}"></span>${TALLY_LABELS[k]}<span class="tally-n">${counts[k]}</span>
459
+ </button>`)
460
+ // "Testing" is a transient state, not a filter — render it as a plain readout
461
+ if (counts.testing) {
462
+ parts.push(`<div class="tally" aria-live="polite">
463
+ <span class="tally-dot testing"></span>${TALLY_LABELS.testing}<span class="tally-n">${counts.testing}</span>
464
+ </div>`)
465
+ }
466
+ el.tallies.hidden = false
467
+ el.tallies.innerHTML = parts.join('')
468
+ el.tallies.querySelectorAll('.tally[data-k]').forEach((b) => {
469
+ b.addEventListener('click', () => {
470
+ state.srvStatus = state.srvStatus === b.dataset.k ? 'any' : b.dataset.k
471
+ el.srvStatus.value = state.srvStatus
472
+ renderList()
473
+ renderTallies()
474
+ })
475
+ })
476
+ }
477
+
478
+ // ---------- view switching ----------
479
+
480
+ function switchView(view) {
481
+ if (state.view === view) return
482
+ state.view = view
483
+ state.query = ''
484
+ state.srvStatus = 'any'
485
+ el.srvStatus.value = 'any'
486
+ state.draft = false
487
+ state.formError = null
488
+ state.testResult = null
489
+ el.search.value = ''
490
+ el.app.dataset.view = view
491
+ el.app.dataset.pane = 'list'
492
+ localStorage.setItem('srv.lastView', view)
493
+ if (view === 'history') loadHistory()
494
+ renderChrome()
495
+ renderList()
496
+ renderDetail()
497
+ }
498
+
499
+ function renderChrome() {
500
+ const v = state.view
501
+ document.querySelectorAll('.tab').forEach((t) => {
502
+ const active = t.dataset.view === v
503
+ t.classList.toggle('active', active)
504
+ if (active) t.setAttribute('aria-current', 'true')
505
+ else t.removeAttribute('aria-current')
506
+ })
507
+ el.overline.textContent = v === 'live' ? 'Watching' : v === 'history' ? 'Audit' : 'Registry'
508
+ el.title.textContent = v === 'live' ? 'Live activity' : v === 'history' ? 'History' : 'Servers'
509
+ el.search.placeholder = v === 'live'
510
+ ? 'Filter running by id or agent'
511
+ : v === 'history' ? 'Filter by id, agent or command' : 'Search id, host or user'
512
+ el.histStatus.hidden = v !== 'history'
513
+ el.srvStatus.hidden = v !== 'servers'
514
+ el.testAll.hidden = v !== 'servers'
515
+ el.addServer.hidden = v !== 'servers'
516
+ el.importBlock.hidden = v !== 'servers'
517
+ el.paneBackLabel.textContent = v === 'live' ? 'All runs' : v === 'history' ? 'All history' : 'All servers'
518
+ }
519
+
520
+ function renderTabs() {
521
+ const running = [...state.live.values()].filter((r) => !r.done).length
522
+ el.tabLiveBadge.hidden = running === 0
523
+ el.tabLiveBadge.textContent = String(running)
524
+ el.tabServerCount.textContent = String(state.servers.length)
525
+ }
526
+
527
+ // ---------- list ----------
528
+
529
+ function liveRuns() {
530
+ return [...state.live.values()].sort((a, b) => b.startedAt - a.startedAt)
531
+ }
532
+
533
+ function sortedHistory() {
534
+ return [...state.history].sort((a, b) => b.startedAt - a.startedAt)
535
+ }
536
+
537
+ function buildRows() {
538
+ const q = state.query.trim().toLowerCase()
539
+ if (state.view === 'live') {
540
+ return liveRuns()
541
+ .filter((r) => !q || `${r.serverId} ${r.agentLabel}`.toLowerCase().includes(q))
542
+ .map((r) => {
543
+ const b = liveBadge(r)
544
+ return {
545
+ key: r.requestId,
546
+ active: state.selRun === r.requestId,
547
+ dot: r.done ? (b.tone === 'fail' ? 'failed' : 'ended') : 'live',
548
+ title: r.serverId,
549
+ sub: r.command || 'interactive session',
550
+ meta: r.done ? 'moving to history' : fmtElapsed(r.startedAt),
551
+ live: !r.done,
552
+ startedAt: r.startedAt,
553
+ badge: r.done
554
+ ? `<span class="chip ${b.tone}">${h(b.label)}</span>`
555
+ : `<span class="agent-chip" title="${h(r.agentLabel)}">${h(r.agentLabel)}</span>`,
556
+ pick: () => { state.selRun = r.requestId; afterPick() },
557
+ }
558
+ })
559
+ }
560
+ if (state.view === 'history') {
561
+ return sortedHistory()
562
+ .filter((r) => {
563
+ if (state.histStatus === 'ok' && r.exitCode !== 0) return false
564
+ if (state.histStatus === 'fail' && !(r.exitCode > 0)) return false
565
+ if (state.histStatus === 'running' && r.endedAt != null) return false
566
+ if (!q) return true
567
+ return `${r.serverId} ${r.agentLabel} ${r.command || ''}`.toLowerCase().includes(q)
568
+ })
569
+ .map((r) => {
570
+ const b = histBadge(r)
571
+ return {
572
+ key: r.id,
573
+ active: state.selHist === r.id,
574
+ dot: b.tone === 'ok' ? 'online' : b.tone === 'running' ? 'live' : 'offline',
575
+ title: r.serverId,
576
+ sub: r.command || 'interactive session',
577
+ meta: relTime(r.startedAt),
578
+ badge: `<span class="chip ${b.tone}">${h(b.label)}</span>`,
579
+ pick: () => { state.selHist = r.id; localStorage.setItem('srv.selHist', r.id); afterPick() },
580
+ }
581
+ })
582
+ }
583
+ return state.servers
584
+ .filter((s) => !q || `${s.id} ${s.host} ${s.username}`.toLowerCase().includes(q))
585
+ .filter((s) => {
586
+ if (state.srvStatus === 'any') return true
587
+ const st = testMeta(s.id).state
588
+ // keep rows visible while their check is in flight, so a Test all does
589
+ // not empty the list out from under you
590
+ return st === 'testing' || st === state.srvStatus
591
+ })
592
+ .map((s) => {
593
+ const hops = (s.jumpChain || []).length
594
+ return {
595
+ key: s.id,
596
+ active: state.selServer === s.id && !state.draft,
597
+ dot: `lg ${testMeta(s.id).state}`,
598
+ title: s.id,
599
+ sub: `${s.username}@${s.host}:${s.port}`,
600
+ meta: hops ? (hops === 1 ? '1 hop' : `${hops} hops`) : 'direct',
601
+ badge: '',
602
+ pick: () => {
603
+ state.selServer = s.id
604
+ localStorage.setItem('srv.selServer', s.id)
605
+ state.draft = false
606
+ syncForm(s.id)
607
+ afterPick()
608
+ },
609
+ }
610
+ })
611
+ }
612
+
613
+ function afterPick() {
614
+ el.app.dataset.pane = 'detail'
615
+ renderList()
616
+ renderDetail()
617
+ }
618
+
619
+ const SRV_STATUS_TEXT = { online: 'online', offline: 'offline', untested: 'still untested' }
620
+
621
+ function emptyListText() {
622
+ if (state.query.trim()) return `Nothing matches “${h(state.query.trim())}”.`
623
+ // An empty list caused by a filter must not read like an empty registry.
624
+ if (state.view === 'servers' && state.srvStatus !== 'any') {
625
+ return `No servers are ${SRV_STATUS_TEXT[state.srvStatus]}.`
626
+ }
627
+ if (state.view === 'history' && state.histStatus !== 'any') {
628
+ return 'No runs match that outcome.'
629
+ }
630
+ if (state.view === 'live') return 'Nothing running right now.'
631
+ if (state.view === 'history') return state.historyLoaded ? 'No runs recorded yet.' : 'Loading…'
632
+ return 'No servers registered yet.'
633
+ }
634
+
635
+ function renderList() {
636
+ const rows = buildRows()
637
+
638
+ // keep a valid selection so the detail pane always has something to show
639
+ if (state.view === 'live') {
640
+ if (!rows.some((r) => r.key === state.selRun)) state.selRun = rows.length ? rows[0].key : null
641
+ } else if (state.view === 'history') {
642
+ if (!rows.some((r) => r.key === state.selHist)) state.selHist = rows.length ? rows[0].key : null
643
+ } else if (!state.draft) {
644
+ if (!rows.some((r) => r.key === state.selServer)) {
645
+ state.selServer = rows.length ? rows[0].key : null
646
+ if (state.selServer) syncForm(state.selServer)
647
+ }
648
+ }
649
+
650
+ const scroll = el.listScroll.scrollTop
651
+ if (!rows.length) {
652
+ el.listRows.innerHTML = `<div class="list-empty">${emptyListText()}</div>`
653
+ el.listScroll.scrollTop = 0
654
+ } else {
655
+ el.listRows.innerHTML = rows.map((r) => `
656
+ <button type="button" class="row${r.active ? ' active' : ''}" data-key="${h(r.key)}">
657
+ <span class="row-dot ${r.dot}"></span>
658
+ <span class="row-main">
659
+ <span class="row-top">
660
+ <span class="row-title">${h(r.title)}</span>
661
+ ${r.badge}
662
+ </span>
663
+ <span class="row-sub">${h(r.sub)}</span>
664
+ </span>
665
+ <span class="row-meta"${r.live ? ` data-elapsed="${r.startedAt}"` : ''}>${h(r.meta)}</span>
666
+ </button>`).join('')
667
+ el.listScroll.scrollTop = scroll
668
+ const byKey = new Map(rows.map((r) => [r.key, r]))
669
+ el.listRows.querySelectorAll('.row').forEach((node) => {
670
+ node.addEventListener('click', () => {
671
+ const row = byKey.get(node.dataset.key)
672
+ if (row) row.pick()
673
+ })
674
+ })
675
+ }
676
+ renderListMore()
677
+ renderTabs()
678
+ renderTallies()
679
+ }
680
+
681
+ /** "Load older runs" footer — history is paged, so older runs stay reachable. */
682
+ function renderListMore() {
683
+ const more = state.view === 'history' && state.history.length < state.historyTotal
684
+ el.listMore.hidden = !more
685
+ if (more) {
686
+ el.listMoreNote.textContent = `showing ${state.history.length} of ${state.historyTotal}`
687
+ }
688
+ }
689
+
690
+ // ---------- detail ----------
691
+
692
+ function emptyState(title, body) {
693
+ return `<div class="empty-state"><div>
694
+ <svg class="empty-zigzag" width="120" height="12" viewBox="0 0 120 12" aria-hidden="true"><path d="M0 10 L12 2 L24 10 L36 2 L48 10 L60 2 L72 10 L84 2 L96 10 L108 2 L120 10" fill="none" stroke="#E8A33D" stroke-width="2"></path></svg>
695
+ <h2 class="empty-title">${h(title)}</h2>
696
+ <p class="empty-body">${h(body)}</p>
697
+ </div></div>`
698
+ }
699
+
700
+ function renderDetail() {
701
+ term = { key: null, container: null, rows: [], stick: true }
702
+ el.detailPane.scrollTop = 0
703
+
704
+ if (state.view === 'live') {
705
+ const run = state.selRun ? state.live.get(state.selRun) : null
706
+ if (!run) {
707
+ el.detail.innerHTML = emptyState('Nothing running', 'Runs appear the moment an agent opens a session on one of your servers.')
708
+ return
709
+ }
710
+ const b = liveBadge(run)
711
+ renderRunDetail({
712
+ key: run.requestId,
713
+ badge: b,
714
+ id: run.requestId,
715
+ serverId: run.serverId,
716
+ stampLine: run.done
717
+ ? (run.error ? `Failed · ${run.error}` : 'Finished · kept here for 30s, then History')
718
+ : 'Streaming from the daemon',
719
+ agentLabel: run.agentLabel,
720
+ kindLabel: run.command ? 'Single exec' : 'Interactive session',
721
+ addr: addrOf(run.serverId),
722
+ timeLabel: run.done ? 'Ran for' : 'Elapsed',
723
+ timeValue: fmtElapsed(run.startedAt),
724
+ liveElapsed: !run.done ? run.startedAt : null,
725
+ command: run.command,
726
+ lines: run.lines,
727
+ live: true,
728
+ })
729
+ return
730
+ }
731
+
732
+ if (state.view === 'history') {
733
+ const r = state.selHist ? state.history.find((x) => x.id === state.selHist) : null
734
+ if (!r) {
735
+ el.detail.innerHTML = emptyState('No run selected', 'Pick a run on the left to read its output.')
736
+ return
737
+ }
738
+ const duration = fmtDuration(r.startedAt, r.endedAt)
739
+ const cached = state.runOutputs.get(r.id)
740
+ renderRunDetail({
741
+ key: r.id,
742
+ badge: histBadge(r),
743
+ id: r.id,
744
+ serverId: r.serverId,
745
+ stampLine: `${fmtStamp(r.startedAt)}${duration ? ` · ${duration}` : ''}`,
746
+ agentLabel: r.agentLabel,
747
+ kindLabel: r.kind === 'session' ? 'Interactive session' : 'Single exec',
748
+ addr: addrOf(r.serverId),
749
+ timeLabel: r.endedAt == null ? 'Started' : 'Finished',
750
+ timeValue: r.endedAt == null ? relTime(r.startedAt) : `${relTime(r.startedAt)}${duration ? ` · ${duration}` : ''}`,
751
+ liveElapsed: null,
752
+ command: r.command,
753
+ lines: cached ? historyLines(cached) : [],
754
+ live: false,
755
+ outputBytes: r.outputBytes,
756
+ truncated: r.truncated,
757
+ pendingOutput: !cached && r.hasOutput,
758
+ })
759
+
760
+ if (!cached && r.hasOutput) {
761
+ const wanted = r.id
762
+ loadRunOutput(r.id)
763
+ .then((run) => {
764
+ // the user may have picked another run while this was in flight
765
+ if (state.view !== 'history' || state.selHist !== wanted || term.key !== wanted) return
766
+ paintTerm(historyLines(run))
767
+ })
768
+ .catch((err) => {
769
+ if (term.key !== wanted || !term.container) return
770
+ term.container.innerHTML = ''
771
+ const note = document.createElement('div')
772
+ note.className = 'term-loading'
773
+ note.textContent = err.message
774
+ term.container.appendChild(note)
775
+ })
776
+ }
777
+ return
778
+ }
779
+
780
+ renderServerDetail()
781
+ }
782
+
783
+ function renderRunDetail(d) {
784
+ const hasErr = d.lines.some((l) => l.stream === 'err')
785
+ el.detail.innerHTML = `
786
+ <div>
787
+ <div class="run-head">
788
+ <span class="chip ${d.badge.tone}">${h(d.badge.label)}</span>
789
+ <span class="run-id">${h(d.id)}</span>
790
+ </div>
791
+ <h2 class="detail-h2">${h(d.serverId)}</h2>
792
+ <p class="detail-lead">${h(d.stampLine)}</p>
793
+ <div class="factgrid">
794
+ <div class="fact"><div class="fact-label">Agent</div><div class="fact-value">${h(d.agentLabel)}</div></div>
795
+ <div class="fact"><div class="fact-label">Mode</div><div class="fact-value">${h(d.kindLabel)}</div></div>
796
+ <div class="fact"><div class="fact-label">Target</div><div class="fact-value mono">${h(d.addr)}</div></div>
797
+ <div class="fact"><div class="fact-label">${h(d.timeLabel)}</div><div class="fact-value strong"${d.liveElapsed ? ` data-elapsed="${d.liveElapsed}"` : ''}>${h(d.timeValue)}</div></div>
798
+ </div>
799
+ <div class="cmdbar">
800
+ <span class="cmd-label">Command</span>
801
+ <code class="cmd-code">${h(d.command || '— none; the agent is driving an interactive shell')}</code>
802
+ </div>
803
+ <div class="outbar">
804
+ <span class="out-label">Output</span>
805
+ ${d.outputBytes ? `<span class="out-size">${h(fmtBytes(d.outputBytes))}</span>` : ''}
806
+ ${hasErr ? `
807
+ <span class="legend"><span class="legend-swatch"></span>stdout</span>
808
+ <span class="legend"><span class="legend-swatch err"></span>stderr</span>` : ''}
809
+ <button type="button" class="btn-copy" id="copy-output">${icon(ICON.copy, 13)} Copy</button>
810
+ </div>
811
+ ${d.truncated ? `<div class="term-notice">
812
+ ${icon('<circle cx="12" cy="12" r="10"></circle><path d="M12 8v4M12 16h.01"></path>', 15)}
813
+ <span>This run produced ${h(fmtBytes(d.outputBytes))}. The first and last 128 KB are kept; the middle was elided so the audit log stays usable.</span>
814
+ </div>` : ''}
815
+ <div class="term facet-14" id="term"></div>
816
+ </div>`
817
+
818
+ term = { key: d.key, container: document.getElementById('term'), rows: [], stick: true }
819
+ if (d.pendingOutput) {
820
+ const note = document.createElement('div')
821
+ note.className = 'term-loading'
822
+ note.textContent = 'loading output…'
823
+ term.container.appendChild(note)
824
+ } else {
825
+ paintTerm(d.lines)
826
+ }
827
+
828
+ term.container.addEventListener('scroll', () => {
829
+ const c = term.container
830
+ term.stick = c.scrollHeight - c.scrollTop - c.clientHeight < 24
831
+ })
832
+
833
+ document.getElementById('copy-output').addEventListener('click', async () => {
834
+ let lines = d.lines
835
+ if (!lines.length && d.pendingOutput) {
836
+ try {
837
+ lines = historyLines(await loadRunOutput(d.id))
838
+ } catch (err) {
839
+ toast(err.message, 'fail')
840
+ return
841
+ }
842
+ }
843
+ try {
844
+ await navigator.clipboard.writeText(lines.map((l) => l.text).join('\n'))
845
+ toast(`Output of ${d.id} copied to clipboard`, 'ok')
846
+ } catch {
847
+ toast('Could not copy — clipboard access denied.', 'fail')
848
+ }
849
+ })
850
+ }
851
+
852
+ function termRow(line, n) {
853
+ const row = document.createElement('div')
854
+ row.className = `term-line${line.stream === 'err' ? ' err' : ''}`
855
+ const num = document.createElement('span')
856
+ num.className = 'term-n'
857
+ num.textContent = String(n)
858
+ const txt = document.createElement('span')
859
+ txt.className = 'term-text'
860
+ txt.textContent = line.text
861
+ row.appendChild(num)
862
+ row.appendChild(txt)
863
+ return row
864
+ }
865
+
866
+ function paintTerm(lines) {
867
+ if (!term.container) return
868
+ term.container.innerHTML = ''
869
+ term.rows = []
870
+ if (!lines.length) {
871
+ const empty = document.createElement('div')
872
+ empty.className = 'term-empty'
873
+ empty.textContent = 'no output yet'
874
+ term.container.appendChild(empty)
875
+ return
876
+ }
877
+ const frag = document.createDocumentFragment()
878
+ lines.forEach((l, i) => {
879
+ const row = termRow(l, i + 1)
880
+ term.rows.push(row)
881
+ frag.appendChild(row)
882
+ })
883
+ term.container.appendChild(frag)
884
+ term.container.scrollTop = term.container.scrollHeight
885
+ }
886
+
887
+ function applyTermOps(run, ops) {
888
+ if (!term.container || term.key !== run.requestId) return
889
+ if (ops.some((o) => o.type === 'rebuild')) {
890
+ paintTerm(run.lines)
891
+ return
892
+ }
893
+ if (!term.rows.length && run.lines.length) {
894
+ paintTerm(run.lines)
895
+ } else {
896
+ for (const op of ops) {
897
+ if (op.type === 'append') {
898
+ const row = termRow(run.lines[op.idx], op.idx + 1)
899
+ term.rows.push(row)
900
+ term.container.appendChild(row)
901
+ } else {
902
+ const row = term.rows[op.idx]
903
+ if (row) row.lastChild.textContent = run.lines[op.idx].text
904
+ else paintTerm(run.lines)
905
+ }
906
+ }
907
+ }
908
+ if (term.stick) term.container.scrollTop = term.container.scrollHeight
909
+ }
910
+
911
+ // ---------- servers detail ----------
912
+
913
+ function syncForm(id) {
914
+ const s = serverById(id)
915
+ state.formError = null
916
+ state.testResult = null
917
+ if (!s) { state.form = blankForm(); return }
918
+ state.form = {
919
+ id: s.id,
920
+ host: s.host,
921
+ port: String(s.port),
922
+ username: s.username,
923
+ authMethod: s.authMethod,
924
+ secret: s.authMethod === 'key' ? (s.keyPath || '') : '',
925
+ hops: (s.jumpChain || []).slice(),
926
+ }
927
+ }
928
+
929
+ function pathPreview() {
930
+ const target = formTargetId()
931
+ const hops = state.form.hops.filter(Boolean)
932
+ if (!hops.length) return { text: `you → ${target}`, bad: false }
933
+ try {
934
+ const path = resolveJumpPathLocal(state.draft ? target : (state.selServer || target), hops)
935
+ const display = [...path.slice(0, -1), target]
936
+ return { text: ['you', ...display].join(' → '), bad: false }
937
+ } catch (err) {
938
+ return { text: `Cycle detected: ${err.message}`, bad: true }
939
+ }
940
+ }
941
+
942
+ function renderServerDetail() {
943
+ if (!state.draft && !state.selServer) {
944
+ el.detail.innerHTML = state.servers.length
945
+ ? emptyState('No server selected', 'Pick a server on the left to see its record.')
946
+ : emptyState('No servers yet', 'Register a server to give an agent an id it can run commands against.')
947
+ return
948
+ }
949
+
950
+ const s = state.draft ? null : serverById(state.selServer)
951
+ if (!state.draft && !s) {
952
+ el.detail.innerHTML = emptyState('No server selected', 'Pick a server on the left to see its record.')
953
+ return
954
+ }
955
+
956
+ const t = state.draft ? { state: 'untested', label: 'not tested', tone: 'untested', error: null } : testMeta(s.id)
957
+ const f = state.form
958
+ const isKey = f.authMethod === 'key'
959
+ if (s && !state.serverRuns.has(s.id)) loadServerRuns(s.id)
960
+ const pp = pathPreview()
961
+
962
+ el.detail.innerHTML = `
963
+ <div>
964
+ <div class="srv-head">
965
+ <span class="srv-dot ${t.state}"></span>
966
+ <h2 class="detail-h2 mono">${h(state.draft ? 'new server' : s.id)}</h2>
967
+ <span class="chip ${t.tone}">${h(t.label)}</span>
968
+ ${t.at ? `<span class="srv-checked">checked ${h(relTime(t.at))}</span>` : ''}
969
+ ${state.draft ? '' : `<button type="button" class="btn-plain" id="srv-test"${t.state === 'testing' ? ' disabled' : ''}>${t.state === 'testing' ? 'Testing…' : 'Test'}</button>`}
970
+ ${state.draft
971
+ ? '<button type="button" class="btn-del" id="srv-discard">Discard</button>'
972
+ : '<button type="button" class="btn-del" id="srv-delete">Delete</button>'}
973
+ </div>
974
+ <p class="detail-lead wide">Agents only ever see this id. Everything below stays on this machine.</p>
975
+
976
+ ${t.error ? `<div class="alert facet-14">
977
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#B3372B" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="M12 8v4M12 16h.01"></path></svg>
978
+ <span class="alert-text">${h(t.error)}</span>
979
+ </div>` : ''}
980
+
981
+ <div class="record">
982
+ <div class="factgrid wide">
983
+ <div class="fact"><div class="fact-label">Address</div><div class="fact-value mono">${h(state.draft ? '—' : `${s.username}@${s.host}:${s.port}`)}</div></div>
984
+ <div class="fact"><div class="fact-label">Registered</div><div class="fact-value mono">${h(state.draft ? '—' : fmtDate(s.createdAt))}</div></div>
985
+ <div class="fact"><div class="fact-label">Last edited</div><div class="fact-value mono">${h(state.draft ? '—' : fmtDate(s.updatedAt))}</div></div>
986
+ </div>
987
+ <div class="record-fp">
988
+ <div class="fact-label">Host key fingerprint</div>
989
+ <div class="record-fp-value">${h(state.draft || !s.hostKeyFingerprint ? 'Recorded on first successful connection' : s.hostKeyFingerprint)}</div>
990
+ </div>
991
+ </div>
992
+
993
+ <div class="section-rule">
994
+ <span class="section-label">Connection</span>
995
+ <span class="rule"></span>
996
+ <span class="section-note">Edits save to the daemon on submit</span>
997
+ </div>
998
+
999
+ <div class="editwrap">
1000
+ <div class="editshadow" aria-hidden="true"></div>
1001
+ <div class="editcard">
1002
+ ${state.draft ? `
1003
+ <div class="fieldrow one">
1004
+ <label class="field">
1005
+ <span class="field-label">Server id</span>
1006
+ <input class="input" id="f-id" value="${h(f.id)}" placeholder="srv-c3" autocomplete="off" spellcheck="false" />
1007
+ </label>
1008
+ </div>` : ''}
1009
+ <div class="fieldrow three">
1010
+ <label class="field">
1011
+ <span class="field-label">Host</span>
1012
+ <input class="input" id="f-host" value="${h(f.host)}" placeholder="10.0.0.4" autocomplete="off" spellcheck="false" />
1013
+ </label>
1014
+ <label class="field">
1015
+ <span class="field-label">Port</span>
1016
+ <input class="input" id="f-port" value="${h(f.port)}" inputmode="numeric" autocomplete="off" />
1017
+ </label>
1018
+ <label class="field">
1019
+ <span class="field-label">Username</span>
1020
+ <input class="input" id="f-user" value="${h(f.username)}" placeholder="deploy" autocomplete="off" spellcheck="false" />
1021
+ </label>
1022
+ </div>
1023
+ <div class="fieldrow two">
1024
+ <label class="field">
1025
+ <span class="field-label">Auth method</span>
1026
+ <select class="select" id="f-auth">
1027
+ <option value="key"${isKey ? ' selected' : ''}>Private key</option>
1028
+ <option value="password"${isKey ? '' : ' selected'}>Password</option>
1029
+ </select>
1030
+ </label>
1031
+ <label class="field">
1032
+ <span class="field-label" id="f-secret-label">${isKey ? 'Key path' : 'Password'}</span>
1033
+ <input class="input" id="f-secret" value="${h(f.secret)}" type="${isKey ? 'text' : 'password'}"
1034
+ placeholder="${isKey ? '~/.ssh/id_ed25519' : '••••••••'}" autocomplete="${isKey ? 'off' : 'new-password'}" spellcheck="false" />
1035
+ </label>
1036
+ </div>
1037
+
1038
+ <div class="hops-head">
1039
+ <span class="section-label">Via jump hosts</span>
1040
+ <button type="button" class="btn-hop" id="add-hop">${icon(ICON.plus, 12)} Add hop</button>
1041
+ </div>
1042
+ <div id="hops"></div>
1043
+ <div class="pathpreview${pp.bad ? ' bad' : ''}" id="path-preview">${h(pp.text)}</div>
1044
+
1045
+ ${state.testResult ? `<div class="testbox ${state.testResult.state === 'ok' ? 'ok' : state.testResult.state === 'fail' ? 'fail' : ''}">
1046
+ <span>${h(state.testResult.text)}</span>
1047
+ </div>` : ''}
1048
+ ${state.formError ? `<div class="form-error"><span>${h(state.formError)}</span></div>` : ''}
1049
+
1050
+ <div class="editactions">
1051
+ <button type="button" class="btn-test-conn" id="f-test">${icon(ICON.zap, 15)} Test connection</button>
1052
+ <span class="spacer"></span>
1053
+ ${state.draft ? '<button type="button" class="btn-ghost" id="f-cancel">Discard</button>' : ''}
1054
+ <button type="button" class="btn-primary facet-8" id="f-submit">${state.draft ? 'Register server' : 'Save changes'}</button>
1055
+ </div>
1056
+ </div>
1057
+ </div>
1058
+
1059
+ ${state.draft ? '' : `
1060
+ <div class="section-rule">
1061
+ <span class="section-label">Recent runs on this server</span>
1062
+ <span class="rule"></span>
1063
+ </div>
1064
+ <div id="srv-runs"></div>`}
1065
+ </div>`
1066
+
1067
+ renderHops()
1068
+ renderServerRuns()
1069
+ wireServerDetail()
1070
+ }
1071
+
1072
+ function renderServerRuns() {
1073
+ const box = document.getElementById('srv-runs')
1074
+ if (!box) return
1075
+ const id = state.selServer
1076
+ const runs = state.serverRuns.get(id)
1077
+ if (!runs) {
1078
+ box.innerHTML = '<div class="runs-none">Loading…</div>'
1079
+ return
1080
+ }
1081
+ if (!runs.length) {
1082
+ box.innerHTML = '<div class="runs-none">No runs recorded against this id yet.</div>'
1083
+ return
1084
+ }
1085
+ box.innerHTML = runs.map((r) => {
1086
+ const b = histBadge(r)
1087
+ return `<button type="button" class="runs-row" data-run="${h(r.id)}">
1088
+ <span class="chip ${b.tone}">${h(b.label)}</span>
1089
+ <code class="runs-row-cmd">${h(r.command || 'interactive session')}</code>
1090
+ <span class="runs-row-rel">${h(relTime(r.startedAt))}</span>
1091
+ </button>`
1092
+ }).join('')
1093
+ box.querySelectorAll('.runs-row').forEach((btn) => {
1094
+ btn.addEventListener('click', () => openHistoryRun(btn.dataset.run))
1095
+ })
1096
+ }
1097
+
1098
+ /**
1099
+ * Jumps to a run in History. The run may be older than the loaded page, so its
1100
+ * metadata is fetched and prepended when missing.
1101
+ */
1102
+ async function openHistoryRun(runId) {
1103
+ state.selHist = runId
1104
+ localStorage.setItem('srv.selHist', runId)
1105
+ if (!state.history.some((r) => r.id === runId)) {
1106
+ try {
1107
+ const run = await loadRunOutput(runId)
1108
+ state.history.unshift({
1109
+ id: run.id, serverId: run.serverId, agentLabel: run.agentLabel, kind: run.kind,
1110
+ command: run.command, exitCode: run.exitCode, startedAt: run.startedAt, endedAt: run.endedAt,
1111
+ outputBytes: run.outputBytes, truncated: run.truncated, hasOutput: (run.output || '').length > 0,
1112
+ })
1113
+ } catch { /* fall through: History will just auto-select its first row */ }
1114
+ }
1115
+ switchView('history')
1116
+ el.app.dataset.pane = 'detail'
1117
+ renderList()
1118
+ renderDetail()
1119
+ }
1120
+
1121
+ function renderHops() {
1122
+ const box = document.getElementById('hops')
1123
+ if (!box) return
1124
+ box.innerHTML = state.form.hops.map((val, idx) => {
1125
+ const opts = hopChoices(idx)
1126
+ const known = opts.some((o) => o.id === val)
1127
+ return `<div class="hop">
1128
+ <span class="hop-n">${idx + 1}</span>
1129
+ <select class="hop-select" data-idx="${idx}">
1130
+ ${known ? '' : `<option value="${h(val)}" selected>${h(val)}</option>`}
1131
+ ${opts.map((o) => `<option value="${h(o.id)}"${o.id === val ? ' selected' : ''}>${h(o.id)}</option>`).join('')}
1132
+ </select>
1133
+ <button type="button" class="hop-remove" data-idx="${idx}" title="Remove hop" aria-label="Remove hop ${idx + 1}">${icon(ICON.x, 13)}</button>
1134
+ </div>`
1135
+ }).join('')
1136
+
1137
+ box.querySelectorAll('.hop-select').forEach((sel) => {
1138
+ sel.addEventListener('change', () => {
1139
+ state.form.hops[Number(sel.dataset.idx)] = sel.value
1140
+ renderHops()
1141
+ updatePathPreview()
1142
+ })
1143
+ })
1144
+ box.querySelectorAll('.hop-remove').forEach((btn) => {
1145
+ btn.addEventListener('click', () => {
1146
+ state.form.hops.splice(Number(btn.dataset.idx), 1)
1147
+ renderHops()
1148
+ updatePathPreview()
1149
+ })
1150
+ })
1151
+
1152
+ const add = document.getElementById('add-hop')
1153
+ if (add) add.disabled = hopChoices(state.form.hops.length).length === 0
1154
+ }
1155
+
1156
+ function updatePathPreview() {
1157
+ const node = document.getElementById('path-preview')
1158
+ if (!node) return
1159
+ const pp = pathPreview()
1160
+ node.textContent = pp.text
1161
+ node.classList.toggle('bad', pp.bad)
1162
+ }
1163
+
1164
+ function wireServerDetail() {
1165
+ const on = (id, ev, fn) => {
1166
+ const node = document.getElementById(id)
1167
+ if (node) node.addEventListener(ev, fn)
1168
+ }
1169
+
1170
+ on('f-id', 'input', (e) => { state.form.id = e.target.value; updatePathPreview(); renderHops() })
1171
+ on('f-host', 'input', (e) => { state.form.host = e.target.value })
1172
+ on('f-port', 'input', (e) => { state.form.port = e.target.value })
1173
+ on('f-user', 'input', (e) => { state.form.username = e.target.value })
1174
+ on('f-secret', 'input', (e) => { state.form.secret = e.target.value })
1175
+
1176
+ on('f-auth', 'change', (e) => {
1177
+ state.form.authMethod = e.target.value
1178
+ const isKey = state.form.authMethod === 'key'
1179
+ state.form.secret = isKey && !state.draft ? ((serverById(state.selServer) || {}).keyPath || '') : ''
1180
+ const label = document.getElementById('f-secret-label')
1181
+ const input = document.getElementById('f-secret')
1182
+ if (label) label.textContent = isKey ? 'Key path' : 'Password'
1183
+ if (input) {
1184
+ input.type = isKey ? 'text' : 'password'
1185
+ input.placeholder = isKey ? '~/.ssh/id_ed25519' : '••••••••'
1186
+ input.autocomplete = isKey ? 'off' : 'new-password'
1187
+ input.value = state.form.secret
1188
+ }
1189
+ })
1190
+
1191
+ on('add-hop', 'click', () => {
1192
+ const opts = hopChoices(state.form.hops.length)
1193
+ if (!opts.length) return
1194
+ state.form.hops.push(opts[0].id)
1195
+ renderHops()
1196
+ updatePathPreview()
1197
+ })
1198
+
1199
+ on('srv-test', 'click', () => testServer(state.selServer))
1200
+ on('srv-delete', 'click', () => openConfirm(state.selServer))
1201
+ on('srv-discard', 'click', discardDraft)
1202
+ on('f-cancel', 'click', discardDraft)
1203
+ on('f-test', 'click', testFormConnection)
1204
+ on('f-submit', 'click', submitForm)
1205
+
1206
+ }
1207
+
1208
+ function discardDraft() {
1209
+ state.draft = false
1210
+ state.formError = null
1211
+ state.testResult = null
1212
+ if (state.selServer) syncForm(state.selServer)
1213
+ renderList()
1214
+ renderDetail()
1215
+ }
1216
+
1217
+ function startDraft() {
1218
+ state.draft = true
1219
+ state.form = blankForm()
1220
+ state.formError = null
1221
+ state.testResult = null
1222
+ el.app.dataset.pane = 'detail'
1223
+ renderList()
1224
+ renderDetail()
1225
+ const idInput = document.getElementById('f-id')
1226
+ if (idInput) idInput.focus()
1227
+ }
1228
+
1229
+ // ---------- form payload / submit ----------
1230
+
1231
+ function formPayload() {
1232
+ const f = state.form
1233
+ const payload = {
1234
+ id: (state.draft ? f.id : state.selServer || '').trim(),
1235
+ host: f.host.trim(),
1236
+ port: Number(f.port),
1237
+ username: f.username.trim(),
1238
+ authMethod: f.authMethod,
1239
+ jumpChain: f.hops.filter(Boolean),
1240
+ }
1241
+ if (f.authMethod === 'key') {
1242
+ payload.keyPath = f.secret.trim()
1243
+ payload.secret = ''
1244
+ } else {
1245
+ payload.secret = f.secret
1246
+ }
1247
+ return payload
1248
+ }
1249
+
1250
+ function validateLocally(payload) {
1251
+ if (!payload.id) return 'A server id is required.'
1252
+ if (!/^[a-zA-Z0-9._-]{1,64}$/.test(payload.id)) return 'Server id may only contain letters, numbers, dot, dash and underscore.'
1253
+ if (!payload.host) return 'A host is required.'
1254
+ if (!Number.isInteger(payload.port) || payload.port < 1 || payload.port > 65535) return 'Port must be a whole number between 1 and 65535.'
1255
+ if (!payload.username) return 'A username is required.'
1256
+ if (payload.authMethod === 'key' && !payload.keyPath) return 'A key path is required for private-key auth.'
1257
+ if (payload.authMethod === 'password' && state.draft && !payload.secret) return 'A password is required.'
1258
+ if (pathPreview().bad) return 'Resolve the jump-host cycle before saving.'
1259
+ return null
1260
+ }
1261
+
1262
+ async function submitForm() {
1263
+ const payload = formPayload()
1264
+ payload.isEdit = !state.draft
1265
+ const problem = validateLocally(payload)
1266
+ if (problem) {
1267
+ state.formError = problem
1268
+ renderDetail()
1269
+ return
1270
+ }
1271
+ const btn = document.getElementById('f-submit')
1272
+ if (btn) { btn.disabled = true; btn.textContent = state.draft ? 'Registering…' : 'Saving…' }
1273
+
1274
+ let res
1275
+ try {
1276
+ res = await fetch('/api/servers', {
1277
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
1278
+ })
1279
+ } catch (err) {
1280
+ state.formError = `Request failed: ${err.message}`
1281
+ renderDetail()
1282
+ return
1283
+ }
1284
+ if (!res.ok) {
1285
+ let message = `Request failed (${res.status})`
1286
+ try { const body = await res.json(); if (body && body.error) message = body.error } catch { /* keep status */ }
1287
+ state.formError = message
1288
+ renderDetail()
1289
+ return
1290
+ }
1291
+ const wasDraft = state.draft
1292
+ state.draft = false
1293
+ state.formError = null
1294
+ state.testResult = null
1295
+ state.selServer = payload.id
1296
+ localStorage.setItem('srv.selServer', payload.id)
1297
+ await loadServers()
1298
+ syncForm(payload.id)
1299
+ renderList()
1300
+ renderDetail()
1301
+ toast(wasDraft ? 'Server registered' : `${payload.id} updated`, 'ok')
1302
+ }
1303
+
1304
+ async function testFormConnection() {
1305
+ const payload = formPayload()
1306
+ const problem = validateLocally(payload)
1307
+ if (problem) {
1308
+ state.formError = problem
1309
+ renderDetail()
1310
+ return
1311
+ }
1312
+ state.formError = null
1313
+ state.testResult = { state: 'pending', text: 'Opening connection…' }
1314
+ renderDetail()
1315
+ const btn = document.getElementById('f-test')
1316
+ if (btn) btn.disabled = true
1317
+
1318
+ try {
1319
+ const res = await fetch('/api/servers/test', {
1320
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
1321
+ })
1322
+ const body = await res.json().catch(() => ({}))
1323
+ if (!res.ok) {
1324
+ state.testResult = { state: 'fail', text: body.error || `Request failed (${res.status})` }
1325
+ } else if (body.ok) {
1326
+ state.testResult = { state: 'ok', text: 'Connection succeeded' }
1327
+ } else {
1328
+ state.testResult = { state: 'fail', text: body.error || 'Connection failed' }
1329
+ }
1330
+ } catch (err) {
1331
+ state.testResult = { state: 'fail', text: `Request failed: ${err.message}` }
1332
+ }
1333
+ renderDetail()
1334
+ }
1335
+
1336
+ // ---------- connection tests ----------
1337
+
1338
+ async function testServer(id) {
1339
+ if (!id) return
1340
+ state.tests.set(id, { state: 'testing' })
1341
+ renderList()
1342
+ if (state.view === 'servers') renderDetail()
1343
+ try {
1344
+ const res = await fetch(`/api/servers/${encodeURIComponent(id)}/test`, { method: 'POST' })
1345
+ const body = await res.json().catch(() => ({}))
1346
+ if (!res.ok) throw new Error(body.error || `request failed (${res.status})`)
1347
+ state.tests.set(id, body.ok ? { state: 'online', at: Date.now() } : { state: 'offline', error: body.error, at: Date.now() })
1348
+ } catch (err) {
1349
+ state.tests.set(id, { state: 'offline', error: err.message, at: Date.now() })
1350
+ }
1351
+ renderList()
1352
+ if (state.view === 'servers') renderDetail()
1353
+ }
1354
+
1355
+ async function testAllServers() {
1356
+ if (!state.servers.length) return
1357
+ const ids = state.servers.map((s) => s.id)
1358
+ ids.forEach((id) => state.tests.set(id, { state: 'testing' }))
1359
+ el.testAll.disabled = true
1360
+ renderList()
1361
+ if (state.view === 'servers') renderDetail()
1362
+ try {
1363
+ const res = await fetch('/api/servers/test-all', { method: 'POST' })
1364
+ if (!res.ok) {
1365
+ const body = await res.json().catch(() => ({}))
1366
+ throw new Error(body.error || `request failed (${res.status})`)
1367
+ }
1368
+ toast(`Testing ${ids.length} server${ids.length === 1 ? '' : 's'}…`)
1369
+ // Results arrive over the WebSocket. If it drops mid-test they never do,
1370
+ // so clear anything still pending well past the daemon's own timeout.
1371
+ setTimeout(() => {
1372
+ const stuck = ids.filter((id) => (state.tests.get(id) || {}).state === 'testing')
1373
+ el.testAll.disabled = false
1374
+ if (!stuck.length) return
1375
+ stuck.forEach((id) => state.tests.delete(id))
1376
+ renderList()
1377
+ if (state.view === 'servers') renderDetail()
1378
+ toast(`${stuck.length} server${stuck.length === 1 ? '' : 's'} didn't report back — the daemon connection may have dropped.`, 'fail')
1379
+ }, TEST_ALL_STUCK_TIMEOUT_MS)
1380
+ } catch (err) {
1381
+ ids.forEach((id) => state.tests.delete(id))
1382
+ el.testAll.disabled = false
1383
+ renderList()
1384
+ if (state.view === 'servers') renderDetail()
1385
+ toast(`Could not start test-all: ${err.message}`, 'fail')
1386
+ }
1387
+ }
1388
+
1389
+ // ---------- delete ----------
1390
+
1391
+ let pendingDelete = null
1392
+
1393
+ function openConfirm(id) {
1394
+ if (!id) return
1395
+ pendingDelete = id
1396
+ el.confirmTitle.textContent = `Delete ${id}?`
1397
+ el.confirmOverlay.hidden = false
1398
+ document.getElementById('confirm-ok').focus()
1399
+ }
1400
+
1401
+ function closeConfirm() {
1402
+ pendingDelete = null
1403
+ el.confirmOverlay.hidden = true
1404
+ }
1405
+
1406
+ async function doDelete() {
1407
+ const id = pendingDelete
1408
+ closeConfirm()
1409
+ if (!id) return
1410
+ try {
1411
+ const res = await fetch(`/api/servers/${encodeURIComponent(id)}`, { method: 'DELETE' })
1412
+ if (!res.ok) throw new Error(`request failed (${res.status})`)
1413
+ } catch (err) {
1414
+ toast(`Failed to delete ${id}: ${err.message}`, 'fail')
1415
+ return
1416
+ }
1417
+ if (state.selServer === id) {
1418
+ state.selServer = null
1419
+ localStorage.removeItem('srv.selServer')
1420
+ }
1421
+ await loadServers()
1422
+ renderList()
1423
+ renderDetail()
1424
+ toast(`${id} deleted`)
1425
+ }
1426
+
1427
+ // ---------- bulk import ----------
1428
+
1429
+ async function doImport() {
1430
+ const raw = el.importText.value
1431
+ el.importText.classList.remove('invalid')
1432
+ let servers
1433
+ try {
1434
+ servers = JSON.parse(raw)
1435
+ } catch (err) {
1436
+ el.importText.classList.add('invalid')
1437
+ toast(`Invalid JSON: ${err.message}`, 'fail')
1438
+ return
1439
+ }
1440
+ if (!Array.isArray(servers)) {
1441
+ el.importText.classList.add('invalid')
1442
+ toast('Expected a JSON array of server objects.', 'fail')
1443
+ return
1444
+ }
1445
+
1446
+ let result
1447
+ try {
1448
+ const res = await fetch('/api/servers/bulk', {
1449
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ servers }),
1450
+ })
1451
+ result = await res.json().catch(() => null)
1452
+ if (!result) throw new Error(`import failed (${res.status})`)
1453
+ if (!res.ok) throw new Error(result.error || `import failed (${res.status})`)
1454
+ } catch (err) {
1455
+ toast(err.message, 'fail')
1456
+ return
1457
+ }
1458
+
1459
+ if (result.failed && result.failed.length) {
1460
+ const first = result.failed[0]
1461
+ const extra = result.failed.length > 1 ? ` (+${result.failed.length - 1} more)` : ''
1462
+ toast(`${result.succeeded.length} imported · ${result.failed.length} rejected — ${first.id || '?'}: ${first.error}${extra}`, 'fail')
1463
+ } else {
1464
+ toast(`${result.succeeded.length} server${result.succeeded.length === 1 ? '' : 's'} imported`, 'ok')
1465
+ el.importText.value = IMPORT_SAMPLE
1466
+ }
1467
+ await loadServers()
1468
+ }
1469
+
1470
+ // ---------- command palette ----------
1471
+
1472
+ function buildPalette() {
1473
+ const q = state.paletteQuery.trim().toLowerCase()
1474
+ const rows = [
1475
+ { kind: 'view', label: 'Live activity', hint: '1', run: () => switchView('live') },
1476
+ { kind: 'view', label: 'History', hint: '2', run: () => switchView('history') },
1477
+ { kind: 'view', label: 'Servers', hint: '3', run: () => switchView('servers') },
1478
+ { kind: 'action', label: 'Register a server', hint: 'add', run: () => { switchView('servers'); startDraft() } },
1479
+ ].filter((r) => !q || r.label.toLowerCase().includes(q))
1480
+
1481
+ state.servers.filter((s) => !q || s.id.toLowerCase().includes(q)).forEach((s) => {
1482
+ rows.push({
1483
+ kind: 'server',
1484
+ label: s.id,
1485
+ hint: `${s.username}@${s.host}`,
1486
+ run: () => {
1487
+ state.selServer = s.id
1488
+ localStorage.setItem('srv.selServer', s.id)
1489
+ state.draft = false
1490
+ syncForm(s.id)
1491
+ switchView('servers')
1492
+ el.app.dataset.pane = 'detail'
1493
+ renderList()
1494
+ renderDetail()
1495
+ },
1496
+ })
1497
+ })
1498
+ return rows
1499
+ }
1500
+
1501
+ function renderPalette() {
1502
+ palRows = buildPalette()
1503
+ if (state.palIndex >= palRows.length) state.palIndex = 0
1504
+ if (!palRows.length) {
1505
+ el.paletteList.innerHTML = '<div class="pal-empty">No matches.</div>'
1506
+ return
1507
+ }
1508
+ el.paletteList.innerHTML = palRows.map((r, i) => `
1509
+ <button type="button" class="pal-row${i === state.palIndex ? ' active' : ''}" data-idx="${i}">
1510
+ <span class="pal-kind">${h(r.kind)}</span>
1511
+ <span class="pal-label">${h(r.label)}</span>
1512
+ <span class="pal-hint">${h(r.hint)}</span>
1513
+ </button>`).join('')
1514
+ el.paletteList.querySelectorAll('.pal-row').forEach((b) => {
1515
+ b.addEventListener('click', () => runPalette(Number(b.dataset.idx)))
1516
+ })
1517
+ }
1518
+
1519
+ function openPalette() {
1520
+ state.paletteOpen = true
1521
+ state.paletteQuery = ''
1522
+ state.palIndex = 0
1523
+ el.paletteInput.value = ''
1524
+ el.paletteOverlay.hidden = false
1525
+ renderPalette()
1526
+ setTimeout(() => el.paletteInput.focus(), 20)
1527
+ }
1528
+
1529
+ function closePalette() {
1530
+ state.paletteOpen = false
1531
+ el.paletteOverlay.hidden = true
1532
+ }
1533
+
1534
+ function runPalette(idx) {
1535
+ const row = palRows[idx]
1536
+ if (!row) return
1537
+ closePalette()
1538
+ row.run()
1539
+ }
1540
+
1541
+ function movePalette(delta) {
1542
+ if (!palRows.length) return
1543
+ state.palIndex = (state.palIndex + delta + palRows.length) % palRows.length
1544
+ el.paletteList.querySelectorAll('.pal-row').forEach((b, i) => b.classList.toggle('active', i === state.palIndex))
1545
+ const active = el.paletteList.querySelector('.pal-row.active')
1546
+ if (active) active.scrollIntoView({ block: 'nearest' })
1547
+ }
1548
+
1549
+ // ---------- theme ----------
1550
+
1551
+ /* The inline script in index.html resolves the theme before first paint; this
1552
+ only handles switching and following the system while no explicit choice
1553
+ has been made. */
1554
+ function currentTheme() {
1555
+ return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'
1556
+ }
1557
+
1558
+ function applyTheme(theme) {
1559
+ document.documentElement.dataset.theme = theme
1560
+ el.themeToggle.setAttribute('aria-label', theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme')
1561
+ el.themeToggle.setAttribute('aria-pressed', String(theme === 'dark'))
1562
+ }
1563
+
1564
+ function setupTheme() {
1565
+ applyTheme(currentTheme())
1566
+ el.themeToggle.addEventListener('click', () => {
1567
+ const next = currentTheme() === 'dark' ? 'light' : 'dark'
1568
+ localStorage.setItem('srv.theme', next)
1569
+ applyTheme(next)
1570
+ })
1571
+ const mq = window.matchMedia('(prefers-color-scheme: dark)')
1572
+ const follow = (e) => {
1573
+ if (localStorage.getItem('srv.theme')) return // user chose explicitly
1574
+ applyTheme(e.matches ? 'dark' : 'light')
1575
+ }
1576
+ if (mq.addEventListener) mq.addEventListener('change', follow)
1577
+ else if (mq.addListener) mq.addListener(follow)
1578
+ }
1579
+
1580
+ // ---------- focus trap ----------
1581
+
1582
+ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
1583
+
1584
+ function activeOverlay() {
1585
+ if (!el.paletteOverlay.hidden) return el.palette
1586
+ if (!el.confirmOverlay.hidden) return el.confirm
1587
+ return null
1588
+ }
1589
+
1590
+ function trapFocus(container, e) {
1591
+ if (e.key !== 'Tab') return
1592
+ const items = [...container.querySelectorAll(FOCUSABLE)].filter((n) => n.offsetParent !== null)
1593
+ if (!items.length) return
1594
+ const first = items[0]
1595
+ const last = items[items.length - 1]
1596
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
1597
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
1598
+ }
1599
+
1600
+ // ---------- websocket ----------
1601
+
1602
+ let historyRefresh = null
1603
+
1604
+ function scheduleHistoryRefresh() {
1605
+ clearTimeout(historyRefresh)
1606
+ historyRefresh = setTimeout(loadHistory, 400)
1607
+ }
1608
+
1609
+ function setConn(name) {
1610
+ el.conn.dataset.state = name
1611
+ el.conn.querySelector('.conn-label').textContent =
1612
+ name === 'connected' ? 'Daemon connected' : name === 'connecting' ? 'Connecting…' : 'Daemon unreachable'
1613
+ }
1614
+
1615
+ function finishRun(run, exitCode, error) {
1616
+ if (run.done) return
1617
+ run.done = true
1618
+ run.exitCode = exitCode
1619
+ run.error = error || null
1620
+ run.open = null
1621
+ setTimeout(() => {
1622
+ // Capture this before renderList(), which reassigns selRun once the run
1623
+ // leaves the list — otherwise the detail pane keeps showing a dropped run.
1624
+ const wasSelected = state.selRun === run.requestId
1625
+ state.live.delete(run.requestId)
1626
+ if (state.view === 'live') {
1627
+ renderList()
1628
+ if (wasSelected) renderDetail()
1629
+ }
1630
+ renderTabs()
1631
+ }, LIVE_GRACE_MS)
1632
+ }
1633
+
1634
+ function connectSocket() {
1635
+ setConn('connecting')
1636
+ let ws
1637
+ try {
1638
+ ws = new WebSocket(`ws://${location.host}/api/live`)
1639
+ } catch {
1640
+ setTimeout(connectSocket, 2000)
1641
+ return
1642
+ }
1643
+
1644
+ ws.onopen = () => setConn('connected')
1645
+ ws.onclose = () => { setConn('disconnected'); setTimeout(connectSocket, 2000) }
1646
+ ws.onerror = () => ws.close()
1647
+
1648
+ ws.onmessage = (event) => {
1649
+ let msg
1650
+ try { msg = JSON.parse(event.data) } catch { return }
1651
+
1652
+ if (msg.type === 'stream') {
1653
+ let run = state.live.get(msg.requestId)
1654
+ const isNew = !run
1655
+ if (!run) {
1656
+ run = newRun(msg.requestId, msg)
1657
+ state.live.set(msg.requestId, run)
1658
+ }
1659
+ if (!run.command && msg.command) run.command = msg.command
1660
+ const ops = ingest(run, msg.stream === 'stderr' ? 'err' : 'out', msg.chunk || '')
1661
+ if (isNew) {
1662
+ if (state.view === 'live') {
1663
+ const hadSelection = state.selRun && state.live.has(state.selRun)
1664
+ renderList()
1665
+ if (!hadSelection || state.selRun === msg.requestId) renderDetail()
1666
+ } else {
1667
+ renderTabs()
1668
+ }
1669
+ } else if (state.view === 'live' && state.selRun === msg.requestId) {
1670
+ applyTermOps(run, ops)
1671
+ }
1672
+ return
1673
+ }
1674
+
1675
+ if (msg.type === 'done') {
1676
+ let run = state.live.get(msg.requestId)
1677
+ if (!run) {
1678
+ run = newRun(msg.requestId, msg)
1679
+ state.live.set(msg.requestId, run)
1680
+ finishRun(run, msg.exitCode, msg.error)
1681
+ if (state.view === 'live') { renderList(); renderDetail() } else renderTabs()
1682
+ } else {
1683
+ finishRun(run, msg.exitCode, msg.error)
1684
+ if (state.view === 'live') {
1685
+ renderList()
1686
+ if (state.selRun === msg.requestId) renderDetail()
1687
+ } else {
1688
+ renderTabs()
1689
+ }
1690
+ }
1691
+ scheduleHistoryRefresh()
1692
+ return
1693
+ }
1694
+
1695
+ if (msg.type === 'server_test_result') {
1696
+ const at = msg.at || Date.now()
1697
+ state.tests.set(msg.id, msg.ok ? { state: 'online', at } : { state: 'offline', error: msg.error, at })
1698
+ // Re-enable "Test all" as soon as the last result lands, rather than
1699
+ // making the user wait out the stuck-result fallback.
1700
+ if (![...state.tests.values()].some((t) => t.state === 'testing')) el.testAll.disabled = false
1701
+ renderList()
1702
+ if (state.view === 'servers') renderDetail()
1703
+ }
1704
+ }
1705
+ }
1706
+
1707
+ // ---------- ticking clocks ----------
1708
+
1709
+ setInterval(() => {
1710
+ document.querySelectorAll('[data-elapsed]').forEach((node) => {
1711
+ node.textContent = fmtElapsed(Number(node.dataset.elapsed))
1712
+ })
1713
+ }, 1000)
1714
+
1715
+ // ---------- static wiring ----------
1716
+
1717
+ document.querySelectorAll('.tab').forEach((tab) => {
1718
+ tab.addEventListener('click', () => {
1719
+ // Re-tapping the active tab returns to the list. Without this you can get
1720
+ // stranded in the detail pane on a single-column layout.
1721
+ if (state.view === tab.dataset.view) el.app.dataset.pane = 'list'
1722
+ else switchView(tab.dataset.view)
1723
+ })
1724
+ })
1725
+
1726
+ el.search.addEventListener('input', (e) => { state.query = e.target.value; renderList() })
1727
+ el.histStatus.addEventListener('change', (e) => { state.histStatus = e.target.value; renderList(); renderDetail() })
1728
+ el.srvStatus.addEventListener('change', (e) => { state.srvStatus = e.target.value; renderList(); renderTallies() })
1729
+ el.loadMore.addEventListener('click', async () => {
1730
+ el.loadMore.disabled = true
1731
+ el.loadMore.textContent = 'Loading…'
1732
+ await loadHistory({ append: true })
1733
+ el.loadMore.disabled = false
1734
+ el.loadMore.textContent = 'Load older runs'
1735
+ })
1736
+ el.testAll.addEventListener('click', testAllServers)
1737
+ el.addServer.addEventListener('click', startDraft)
1738
+ el.paneBack.addEventListener('click', () => { el.app.dataset.pane = 'list' })
1739
+
1740
+ el.importToggle.addEventListener('click', () => {
1741
+ state.importOpen = !state.importOpen
1742
+ el.importBlock.classList.toggle('open', state.importOpen)
1743
+ el.importBody.hidden = !state.importOpen
1744
+ el.importToggle.setAttribute('aria-expanded', String(state.importOpen))
1745
+ if (state.importOpen && !el.importText.value) el.importText.value = IMPORT_SAMPLE
1746
+ })
1747
+ document.getElementById('import-submit').addEventListener('click', doImport)
1748
+
1749
+ document.getElementById('open-palette').addEventListener('click', openPalette)
1750
+ el.paletteScrim.addEventListener('click', closePalette)
1751
+ el.paletteInput.addEventListener('input', (e) => {
1752
+ state.paletteQuery = e.target.value
1753
+ state.palIndex = 0
1754
+ renderPalette()
1755
+ })
1756
+ el.paletteInput.addEventListener('keydown', (e) => {
1757
+ if (e.key === 'ArrowDown') { e.preventDefault(); movePalette(1) }
1758
+ else if (e.key === 'ArrowUp') { e.preventDefault(); movePalette(-1) }
1759
+ else if (e.key === 'Enter') { e.preventDefault(); runPalette(state.palIndex) }
1760
+ })
1761
+
1762
+ document.getElementById('confirm-ok').addEventListener('click', doDelete)
1763
+ document.getElementById('confirm-cancel').addEventListener('click', closeConfirm)
1764
+ el.confirmScrim.addEventListener('click', closeConfirm)
1765
+
1766
+ document.addEventListener('keydown', (e) => {
1767
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
1768
+ e.preventDefault()
1769
+ if (state.paletteOpen) closePalette()
1770
+ else openPalette()
1771
+ return
1772
+ }
1773
+ if (e.key === 'Escape') {
1774
+ if (state.paletteOpen) closePalette()
1775
+ else if (!el.confirmOverlay.hidden) closeConfirm()
1776
+ else if (state.draft) discardDraft()
1777
+ return
1778
+ }
1779
+ const overlay = activeOverlay()
1780
+ if (overlay) { trapFocus(overlay, e); return }
1781
+ const tag = (document.activeElement && document.activeElement.tagName) || ''
1782
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (document.activeElement && document.activeElement.isContentEditable)) return
1783
+ if (e.key === '1') switchView('live')
1784
+ else if (e.key === '2') switchView('history')
1785
+ else if (e.key === '3') switchView('servers')
1786
+ })
1787
+
1788
+ // ---------- boot ----------
1789
+
1790
+ el.app.dataset.view = state.view
1791
+ el.importText.value = IMPORT_SAMPLE
1792
+ setupTheme()
1793
+ renderChrome()
1794
+ renderList()
1795
+ renderDetail()
1796
+ connectSocket()
1797
+ loadServers().then(() => {
1798
+ if (state.view === 'servers' && state.selServer) syncForm(state.selServer)
1799
+ renderList()
1800
+ renderDetail()
1801
+ })
1802
+ loadHistory()