@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/LICENSE +21 -0
- package/README.md +76 -0
- package/dist/cli/client.js +135 -0
- package/dist/cli/index.js +65 -0
- package/dist/daemon/dashboard-server.js +283 -0
- package/dist/daemon/index.js +48 -0
- package/dist/daemon/jump-chain.js +35 -0
- package/dist/daemon/keychain.js +46 -0
- package/dist/daemon/logstore.js +202 -0
- package/dist/daemon/registry.js +103 -0
- package/dist/daemon/socket-protocol.js +19 -0
- package/dist/daemon/socket-server.js +199 -0
- package/dist/daemon/ssh-manager.js +231 -0
- package/dist/shared/paths.js +17 -0
- package/dist/shared/types.js +1 -0
- package/package.json +71 -0
- package/public/app.js +1802 -0
- package/public/index.html +189 -0
- package/public/styles.css +838 -0
- package/scripts/com.srv-wrapper.daemon.plist +21 -0
- package/scripts/compact-log.mjs +140 -0
- package/scripts/install-launchd.sh +21 -0
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
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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()
|