switchroom 0.12.5 → 0.12.6
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/package.json +1 -1
- package/dist/agent-scheduler/index.js +0 -12657
- package/dist/auth-broker/index.js +0 -14133
- package/dist/cli/autoaccept-poll.js +0 -128
- package/dist/cli/drive-write-pretool.mjs +0 -5451
- package/dist/cli/skill-validate-pretool.mjs +0 -7209
- package/dist/cli/switchroom.js +0 -76016
- package/dist/cli/ui/index.html +0 -1281
- package/dist/host-control/main.js +0 -16041
- package/dist/vault/approvals/kernel-server.js +0 -13121
- package/dist/vault/broker/server.js +0 -17177
- package/telegram-plugin/dist/bridge/bridge.js +0 -24996
- package/telegram-plugin/dist/gateway/gateway.js +0 -55545
- package/telegram-plugin/dist/server.js +0 -24786
package/dist/cli/ui/index.html
DELETED
|
@@ -1,1281 +0,0 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8">
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<title>Switchroom Fleet</title>
|
|
7
|
-
<style>
|
|
8
|
-
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
-
|
|
10
|
-
:root {
|
|
11
|
-
--bg: #0f1117;
|
|
12
|
-
--surface: #1a1d27;
|
|
13
|
-
--surface-hover: #22263a;
|
|
14
|
-
--border: #2a2e3e;
|
|
15
|
-
--text: #e1e4ed;
|
|
16
|
-
--text-dim: #8b8fa3;
|
|
17
|
-
--green: #34d399;
|
|
18
|
-
--red: #f87171;
|
|
19
|
-
--yellow: #fbbf24;
|
|
20
|
-
--blue: #60a5fa;
|
|
21
|
-
--radius: 10px;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
body {
|
|
25
|
-
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
26
|
-
background: var(--bg);
|
|
27
|
-
color: var(--text);
|
|
28
|
-
line-height: 1.5;
|
|
29
|
-
min-height: 100vh;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
header {
|
|
33
|
-
padding: 1.5rem 2rem;
|
|
34
|
-
border-bottom: 1px solid var(--border);
|
|
35
|
-
display: flex;
|
|
36
|
-
justify-content: space-between;
|
|
37
|
-
align-items: center;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
header h1 {
|
|
41
|
-
font-size: 1.25rem;
|
|
42
|
-
font-weight: 600;
|
|
43
|
-
letter-spacing: -0.02em;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
header h1 span { color: var(--text-dim); font-weight: 400; }
|
|
47
|
-
|
|
48
|
-
.refresh-info {
|
|
49
|
-
font-size: 0.8rem;
|
|
50
|
-
color: var(--text-dim);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
main {
|
|
54
|
-
max-width: 1200px;
|
|
55
|
-
margin: 0 auto;
|
|
56
|
-
padding: 1.5rem;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
.agents-grid {
|
|
60
|
-
display: grid;
|
|
61
|
-
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
|
62
|
-
gap: 1rem;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
.agent-card {
|
|
66
|
-
background: var(--surface);
|
|
67
|
-
border: 1px solid var(--border);
|
|
68
|
-
border-radius: var(--radius);
|
|
69
|
-
overflow: hidden;
|
|
70
|
-
transition: border-color 0.15s;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
.agent-card:hover { border-color: #3a3e4e; }
|
|
74
|
-
|
|
75
|
-
.card-header {
|
|
76
|
-
padding: 1rem 1.25rem;
|
|
77
|
-
display: flex;
|
|
78
|
-
align-items: center;
|
|
79
|
-
gap: 0.75rem;
|
|
80
|
-
cursor: pointer;
|
|
81
|
-
user-select: none;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
.status-dot {
|
|
85
|
-
width: 10px;
|
|
86
|
-
height: 10px;
|
|
87
|
-
border-radius: 50%;
|
|
88
|
-
flex-shrink: 0;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
.status-dot.active { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
|
92
|
-
.status-dot.inactive { background: var(--red); }
|
|
93
|
-
.status-dot.auth-warning { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); }
|
|
94
|
-
|
|
95
|
-
.agent-name {
|
|
96
|
-
font-weight: 600;
|
|
97
|
-
font-size: 1rem;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
.agent-topic {
|
|
101
|
-
font-size: 0.85rem;
|
|
102
|
-
color: var(--text-dim);
|
|
103
|
-
margin-left: auto;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
.card-meta {
|
|
107
|
-
padding: 0 1.25rem 1rem;
|
|
108
|
-
display: flex;
|
|
109
|
-
flex-wrap: wrap;
|
|
110
|
-
gap: 0.5rem 1.25rem;
|
|
111
|
-
font-size: 0.8rem;
|
|
112
|
-
color: var(--text-dim);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
.meta-item label {
|
|
116
|
-
color: var(--text-dim);
|
|
117
|
-
opacity: 0.7;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
.meta-item span { color: var(--text); }
|
|
121
|
-
|
|
122
|
-
.card-actions {
|
|
123
|
-
padding: 0.75rem 1.25rem;
|
|
124
|
-
border-top: 1px solid var(--border);
|
|
125
|
-
display: flex;
|
|
126
|
-
gap: 0.5rem;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
.btn {
|
|
130
|
-
padding: 0.4rem 0.9rem;
|
|
131
|
-
border: 1px solid var(--border);
|
|
132
|
-
border-radius: 6px;
|
|
133
|
-
background: transparent;
|
|
134
|
-
color: var(--text);
|
|
135
|
-
font-size: 0.78rem;
|
|
136
|
-
cursor: pointer;
|
|
137
|
-
transition: background 0.15s, border-color 0.15s;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
.btn:hover { background: var(--surface-hover); border-color: #4a4e5e; }
|
|
141
|
-
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
142
|
-
.btn.btn-start { color: var(--green); border-color: rgba(52,211,153,0.3); }
|
|
143
|
-
.btn.btn-start:hover { background: rgba(52,211,153,0.1); }
|
|
144
|
-
.btn.btn-stop { color: var(--red); border-color: rgba(248,113,113,0.3); }
|
|
145
|
-
.btn.btn-stop:hover { background: rgba(248,113,113,0.1); }
|
|
146
|
-
.btn.btn-restart { color: var(--blue); border-color: rgba(96,165,250,0.3); }
|
|
147
|
-
.btn.btn-restart:hover { background: rgba(96,165,250,0.1); }
|
|
148
|
-
|
|
149
|
-
.card-logs {
|
|
150
|
-
display: none;
|
|
151
|
-
border-top: 1px solid var(--border);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
.card-logs.open { display: block; }
|
|
155
|
-
|
|
156
|
-
.logs-header {
|
|
157
|
-
padding: 0.5rem 1.25rem;
|
|
158
|
-
display: flex;
|
|
159
|
-
justify-content: space-between;
|
|
160
|
-
align-items: center;
|
|
161
|
-
font-size: 0.8rem;
|
|
162
|
-
color: var(--text-dim);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
.logs-header button {
|
|
166
|
-
background: none;
|
|
167
|
-
border: none;
|
|
168
|
-
color: var(--blue);
|
|
169
|
-
cursor: pointer;
|
|
170
|
-
font-size: 0.78rem;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
.log-output {
|
|
174
|
-
padding: 0.75rem 1.25rem;
|
|
175
|
-
max-height: 300px;
|
|
176
|
-
overflow-y: auto;
|
|
177
|
-
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
|
|
178
|
-
font-size: 0.72rem;
|
|
179
|
-
line-height: 1.6;
|
|
180
|
-
white-space: pre-wrap;
|
|
181
|
-
word-break: break-all;
|
|
182
|
-
color: var(--text-dim);
|
|
183
|
-
background: rgba(0,0,0,0.2);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
.error-banner {
|
|
187
|
-
background: rgba(248,113,113,0.1);
|
|
188
|
-
border: 1px solid rgba(248,113,113,0.3);
|
|
189
|
-
color: var(--red);
|
|
190
|
-
padding: 0.75rem 1.25rem;
|
|
191
|
-
border-radius: var(--radius);
|
|
192
|
-
margin-bottom: 1rem;
|
|
193
|
-
font-size: 0.85rem;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
.loading {
|
|
197
|
-
text-align: center;
|
|
198
|
-
padding: 4rem;
|
|
199
|
-
color: var(--text-dim);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
nav.tabs {
|
|
203
|
-
display: flex;
|
|
204
|
-
gap: 0.25rem;
|
|
205
|
-
padding: 0 2rem;
|
|
206
|
-
border-bottom: 1px solid var(--border);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
nav.tabs button {
|
|
210
|
-
background: none;
|
|
211
|
-
border: none;
|
|
212
|
-
color: var(--text-dim);
|
|
213
|
-
padding: 0.75rem 1rem;
|
|
214
|
-
cursor: pointer;
|
|
215
|
-
font-size: 0.9rem;
|
|
216
|
-
border-bottom: 2px solid transparent;
|
|
217
|
-
margin-bottom: -1px;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
nav.tabs button.active {
|
|
221
|
-
color: var(--text);
|
|
222
|
-
border-bottom-color: var(--blue);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
nav.tabs button:hover { color: var(--text); }
|
|
226
|
-
|
|
227
|
-
.card-detail {
|
|
228
|
-
display: none;
|
|
229
|
-
border-top: 1px solid var(--border);
|
|
230
|
-
padding: 0.75rem 1.25rem;
|
|
231
|
-
font-size: 0.8rem;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
.card-detail.open { display: block; }
|
|
235
|
-
|
|
236
|
-
.card-detail h4 {
|
|
237
|
-
font-size: 0.72rem;
|
|
238
|
-
text-transform: uppercase;
|
|
239
|
-
letter-spacing: 0.05em;
|
|
240
|
-
color: var(--text-dim);
|
|
241
|
-
margin-bottom: 0.4rem;
|
|
242
|
-
margin-top: 0.75rem;
|
|
243
|
-
}
|
|
244
|
-
.card-detail h4:first-child { margin-top: 0; }
|
|
245
|
-
|
|
246
|
-
.chip {
|
|
247
|
-
display: inline-block;
|
|
248
|
-
padding: 0.15rem 0.5rem;
|
|
249
|
-
border-radius: 999px;
|
|
250
|
-
background: var(--surface-hover);
|
|
251
|
-
font-size: 0.72rem;
|
|
252
|
-
color: var(--text);
|
|
253
|
-
margin-right: 0.25rem;
|
|
254
|
-
margin-bottom: 0.25rem;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
.chip.missing { color: var(--yellow); border: 1px solid rgba(251,191,36,0.4); background: rgba(251,191,36,0.05); }
|
|
258
|
-
|
|
259
|
-
.config-pre {
|
|
260
|
-
background: rgba(0,0,0,0.3);
|
|
261
|
-
padding: 0.6rem;
|
|
262
|
-
border-radius: 6px;
|
|
263
|
-
max-height: 240px;
|
|
264
|
-
overflow: auto;
|
|
265
|
-
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
|
|
266
|
-
font-size: 0.7rem;
|
|
267
|
-
color: var(--text-dim);
|
|
268
|
-
white-space: pre;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
table.accounts-table {
|
|
272
|
-
width: 100%;
|
|
273
|
-
border-collapse: collapse;
|
|
274
|
-
font-size: 0.85rem;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
table.accounts-table th, table.accounts-table td {
|
|
278
|
-
padding: 0.6rem 0.75rem;
|
|
279
|
-
text-align: left;
|
|
280
|
-
border-bottom: 1px solid var(--border);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
table.accounts-table th {
|
|
284
|
-
font-size: 0.7rem;
|
|
285
|
-
text-transform: uppercase;
|
|
286
|
-
letter-spacing: 0.05em;
|
|
287
|
-
color: var(--text-dim);
|
|
288
|
-
font-weight: 500;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
.health-badge {
|
|
292
|
-
display: inline-block;
|
|
293
|
-
padding: 0.15rem 0.55rem;
|
|
294
|
-
border-radius: 999px;
|
|
295
|
-
font-size: 0.72rem;
|
|
296
|
-
font-weight: 500;
|
|
297
|
-
}
|
|
298
|
-
.health-badge.healthy { color: var(--green); background: rgba(52,211,153,0.12); }
|
|
299
|
-
.health-badge.expired,
|
|
300
|
-
.health-badge.missing-credentials,
|
|
301
|
-
.health-badge.missing-refresh-token { color: var(--red); background: rgba(248,113,113,0.12); }
|
|
302
|
-
.health-badge.quota-exhausted { color: var(--yellow); background: rgba(251,191,36,0.12); }
|
|
303
|
-
|
|
304
|
-
.quota-pct { font-variant-numeric: tabular-nums; }
|
|
305
|
-
.quota-pct.high { color: var(--red); }
|
|
306
|
-
.quota-pct.mid { color: var(--yellow); }
|
|
307
|
-
.quota-pct.stale { color: var(--text-dim); font-style: italic; }
|
|
308
|
-
.agent-list { font-size: 0.8rem; color: var(--text-dim); }
|
|
309
|
-
.agent-list.primary { color: var(--text); }
|
|
310
|
-
.usage-pill {
|
|
311
|
-
display: inline-block;
|
|
312
|
-
padding: 0.15rem 0.55rem;
|
|
313
|
-
border-radius: 999px;
|
|
314
|
-
font-size: 0.75rem;
|
|
315
|
-
font-weight: 500;
|
|
316
|
-
}
|
|
317
|
-
.usage-pill.primary {
|
|
318
|
-
color: var(--green);
|
|
319
|
-
background: rgba(52,211,153,0.12);
|
|
320
|
-
}
|
|
321
|
-
.modal-backdrop {
|
|
322
|
-
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
|
323
|
-
display: flex; align-items: center; justify-content: center; z-index: 100;
|
|
324
|
-
}
|
|
325
|
-
.modal {
|
|
326
|
-
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
|
|
327
|
-
padding: 1.25rem; min-width: 320px; max-width: 480px;
|
|
328
|
-
}
|
|
329
|
-
.modal h3 { margin: 0 0 0.75rem 0; }
|
|
330
|
-
.modal label { display: block; font-size: 0.8rem; color: var(--text-dim); margin: 0.5rem 0 0.25rem 0; }
|
|
331
|
-
.modal select, .modal input { width: 100%; padding: 0.4rem; background: rgba(0,0,0,0.3);
|
|
332
|
-
border: 1px solid var(--border); color: var(--text); border-radius: 4px; }
|
|
333
|
-
.modal-actions { margin-top: 1rem; display: flex; gap: 0.5rem; justify-content: flex-end; }
|
|
334
|
-
.toast {
|
|
335
|
-
position: fixed; bottom: 1.5rem; right: 1.5rem; padding: 0.75rem 1rem;
|
|
336
|
-
background: var(--card); border: 1px solid var(--border); border-radius: 6px;
|
|
337
|
-
font-size: 0.85rem; z-index: 200; max-width: 420px;
|
|
338
|
-
}
|
|
339
|
-
.toast.ok { border-color: var(--green); }
|
|
340
|
-
.toast.err { border-color: var(--red); }
|
|
341
|
-
|
|
342
|
-
.accounts-grid {
|
|
343
|
-
display: grid;
|
|
344
|
-
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
|
345
|
-
gap: 1rem;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
.account-card {
|
|
349
|
-
background: var(--surface);
|
|
350
|
-
border: 1px solid var(--border);
|
|
351
|
-
border-radius: var(--radius);
|
|
352
|
-
padding: 1rem 1.25rem;
|
|
353
|
-
transition: border-color 0.15s;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
.account-card:hover { border-color: #3a3e4e; }
|
|
357
|
-
|
|
358
|
-
.account-card-header {
|
|
359
|
-
display: flex;
|
|
360
|
-
align-items: center;
|
|
361
|
-
gap: 0.75rem;
|
|
362
|
-
margin-bottom: 0.75rem;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
.account-label {
|
|
366
|
-
font-weight: 600;
|
|
367
|
-
font-size: 1rem;
|
|
368
|
-
color: var(--text);
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
.account-usage {
|
|
372
|
-
font-size: 0.8rem;
|
|
373
|
-
color: var(--text-dim);
|
|
374
|
-
margin-bottom: 0.75rem;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
.accounts-grid {
|
|
378
|
-
display: grid;
|
|
379
|
-
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
380
|
-
gap: 1rem;
|
|
381
|
-
margin-top: 1rem;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
@media (max-width: 700px) {
|
|
385
|
-
.accounts-table-wrap { display: none; }
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
@media (min-width: 701px) {
|
|
389
|
-
.accounts-grid { display: none; }
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
@media (max-width: 600px) {
|
|
393
|
-
header { padding: 1rem; }
|
|
394
|
-
main { padding: 1rem; }
|
|
395
|
-
.agents-grid { grid-template-columns: 1fr; }
|
|
396
|
-
.accounts-grid { grid-template-columns: 1fr; }
|
|
397
|
-
.card-meta { gap: 0.3rem 1rem; }
|
|
398
|
-
nav.tabs { padding: 0 1rem; overflow-x: auto; }
|
|
399
|
-
}
|
|
400
|
-
</style>
|
|
401
|
-
</head>
|
|
402
|
-
<body>
|
|
403
|
-
<header>
|
|
404
|
-
<h1>Switchroom <span>Fleet</span></h1>
|
|
405
|
-
<div class="refresh-info">Refreshes every 10s</div>
|
|
406
|
-
</header>
|
|
407
|
-
<nav class="tabs">
|
|
408
|
-
<button id="tab-summary" class="active" onclick="switchTab('summary')">Summary</button>
|
|
409
|
-
<button id="tab-agents" onclick="switchTab('agents')">Agents</button>
|
|
410
|
-
<button id="tab-accounts" onclick="switchTab('accounts')">Accounts</button>
|
|
411
|
-
<button id="tab-system" onclick="switchTab('system')">System</button>
|
|
412
|
-
<button id="tab-google" onclick="switchTab('google')">Google</button>
|
|
413
|
-
<button id="tab-schedule" onclick="switchTab('schedule')">Schedule</button>
|
|
414
|
-
<button id="tab-approvals" onclick="switchTab('approvals')">Approvals</button>
|
|
415
|
-
</nav>
|
|
416
|
-
<main>
|
|
417
|
-
<div id="error"></div>
|
|
418
|
-
<div id="summary" class="loading">Loading summary…</div>
|
|
419
|
-
<div id="agents" style="display:none" class="loading">Loading agents...</div>
|
|
420
|
-
<div id="accounts" style="display:none"></div>
|
|
421
|
-
<div id="system" style="display:none"></div>
|
|
422
|
-
<div id="google" style="display:none"></div>
|
|
423
|
-
<div id="schedule" style="display:none"></div>
|
|
424
|
-
<div id="approvals" style="display:none"></div>
|
|
425
|
-
</main>
|
|
426
|
-
|
|
427
|
-
<script>
|
|
428
|
-
const API = window.location.origin;
|
|
429
|
-
const TOKEN = new URLSearchParams(window.location.search).get('token');
|
|
430
|
-
let agents = [];
|
|
431
|
-
let openLogs = new Set();
|
|
432
|
-
let openDetails = new Set();
|
|
433
|
-
let agentDetails = {};
|
|
434
|
-
let accounts = [];
|
|
435
|
-
let ws = null;
|
|
436
|
-
|
|
437
|
-
function authHeaders() {
|
|
438
|
-
const h = {};
|
|
439
|
-
if (TOKEN) h['Authorization'] = `Bearer ${TOKEN}`;
|
|
440
|
-
return h;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
async function fetchAgents() {
|
|
444
|
-
try {
|
|
445
|
-
const res = await fetch(`${API}/api/agents`, { headers: authHeaders() });
|
|
446
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
447
|
-
agents = await res.json();
|
|
448
|
-
render();
|
|
449
|
-
clearError();
|
|
450
|
-
} catch (err) {
|
|
451
|
-
showError(`Failed to fetch agents: ${err.message}`);
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
async function fetchAccounts() {
|
|
456
|
-
try {
|
|
457
|
-
const res = await fetch(`${API}/api/accounts`, { headers: authHeaders() });
|
|
458
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
459
|
-
accounts = await res.json();
|
|
460
|
-
renderAccounts();
|
|
461
|
-
clearError();
|
|
462
|
-
} catch (err) {
|
|
463
|
-
showError(`Failed to fetch accounts: ${err.message}`);
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
async function fetchAgentDetail(name) {
|
|
468
|
-
try {
|
|
469
|
-
const [accRes, subRes, cfgRes] = await Promise.all([
|
|
470
|
-
fetch(`${API}/api/agents/${encodeURIComponent(name)}/accounts`, { headers: authHeaders() }),
|
|
471
|
-
fetch(`${API}/api/agents/${encodeURIComponent(name)}/subagents`, { headers: authHeaders() }),
|
|
472
|
-
fetch(`${API}/api/agents/${encodeURIComponent(name)}/config`, { headers: authHeaders() }),
|
|
473
|
-
]);
|
|
474
|
-
agentDetails[name] = {
|
|
475
|
-
accounts: accRes.ok ? await accRes.json() : null,
|
|
476
|
-
subagents: subRes.ok ? await subRes.json() : null,
|
|
477
|
-
config: cfgRes.ok ? await cfgRes.json() : null,
|
|
478
|
-
};
|
|
479
|
-
render();
|
|
480
|
-
} catch (err) {
|
|
481
|
-
showError(`Failed to load detail for ${name}: ${err.message}`);
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
async function fetchSystemHealth() {
|
|
486
|
-
try {
|
|
487
|
-
const res = await fetch(`${API}/api/system-health`, { headers: authHeaders() });
|
|
488
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
489
|
-
renderSystemHealth(await res.json());
|
|
490
|
-
clearError();
|
|
491
|
-
} catch (err) {
|
|
492
|
-
showError(`Failed to fetch system health: ${err.message}`);
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
async function fetchGoogleAccounts() {
|
|
497
|
-
try {
|
|
498
|
-
const res = await fetch(`${API}/api/google-accounts`, { headers: authHeaders() });
|
|
499
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
500
|
-
renderGoogleAccounts(await res.json());
|
|
501
|
-
clearError();
|
|
502
|
-
} catch (err) {
|
|
503
|
-
showError(`Failed to fetch Google accounts: ${err.message}`);
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
async function fetchSchedule() {
|
|
508
|
-
try {
|
|
509
|
-
const res = await fetch(`${API}/api/schedule`, { headers: authHeaders() });
|
|
510
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
511
|
-
renderSchedule(await res.json());
|
|
512
|
-
clearError();
|
|
513
|
-
} catch (err) {
|
|
514
|
-
showError(`Failed to fetch schedule: ${err.message}`);
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
async function fetchApprovals() {
|
|
519
|
-
try {
|
|
520
|
-
const res = await fetch(`${API}/api/approvals`, { headers: authHeaders() });
|
|
521
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
522
|
-
renderApprovals(await res.json());
|
|
523
|
-
clearError();
|
|
524
|
-
} catch (err) {
|
|
525
|
-
showError(`Failed to fetch approvals: ${err.message}`);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
function switchTab(tab) {
|
|
530
|
-
const tabs = ['summary', 'agents', 'accounts', 'system', 'google', 'schedule', 'approvals'];
|
|
531
|
-
for (const t of tabs) {
|
|
532
|
-
document.getElementById(`tab-${t}`).classList.toggle('active', tab === t);
|
|
533
|
-
document.getElementById(t).style.display = tab === t ? '' : 'none';
|
|
534
|
-
}
|
|
535
|
-
if (tab === 'summary') fetchSummary();
|
|
536
|
-
if (tab === 'accounts') fetchAccounts();
|
|
537
|
-
if (tab === 'system') fetchSystemHealth();
|
|
538
|
-
if (tab === 'google') fetchGoogleAccounts();
|
|
539
|
-
if (tab === 'schedule') fetchSchedule();
|
|
540
|
-
if (tab === 'approvals') fetchApprovals();
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
// Fleet overview — aggregates the cheap endpoints client-side
|
|
544
|
-
// (one parallel fan-out; no new server work, no extra docker/probe
|
|
545
|
-
// cost beyond what those tabs already do). Each tile degrades
|
|
546
|
-
// independently if its source errors.
|
|
547
|
-
async function fetchSummary() {
|
|
548
|
-
const el = document.getElementById('summary');
|
|
549
|
-
const get = async (p) => {
|
|
550
|
-
try {
|
|
551
|
-
const r = await fetch(`${API}${p}`, { headers: authHeaders() });
|
|
552
|
-
return r.ok ? await r.json() : null;
|
|
553
|
-
} catch { return null; }
|
|
554
|
-
};
|
|
555
|
-
const [ag, sys, appr, sched, accts] = await Promise.all([
|
|
556
|
-
get('/api/agents'), get('/api/system-health'),
|
|
557
|
-
get('/api/approvals'), get('/api/schedule'), get('/api/accounts'),
|
|
558
|
-
]);
|
|
559
|
-
clearError();
|
|
560
|
-
const tile = (title, body, ok) => `
|
|
561
|
-
<div class="agent-card" style="min-width:220px">
|
|
562
|
-
<div class="card-header" style="cursor:default">
|
|
563
|
-
<span class="status-dot ${ok === null ? '' : ok ? 'active' : 'inactive'}" style="display:inline-block;vertical-align:middle"></span>
|
|
564
|
-
<span class="agent-name">${escapeHtml(title)}</span>
|
|
565
|
-
</div>
|
|
566
|
-
<div style="padding:0 1.25rem 1rem;font-size:0.9rem">${body}</div>
|
|
567
|
-
</div>`;
|
|
568
|
-
const dim = (s) => `<span style="color:var(--text-dim)">${escapeHtml(s)}</span>`;
|
|
569
|
-
|
|
570
|
-
// Agents up/total
|
|
571
|
-
let agentsTile;
|
|
572
|
-
if (Array.isArray(ag)) {
|
|
573
|
-
const up = ag.filter(x => x.active === 'active').length;
|
|
574
|
-
agentsTile = tile('Agents', `<strong>${up}/${ag.length}</strong> active`, up === ag.length);
|
|
575
|
-
} else agentsTile = tile('Agents', dim('unavailable'), null);
|
|
576
|
-
|
|
577
|
-
// Broker / hindsight / hostd from system-health
|
|
578
|
-
let brokerTile, hsTile, hostdTile;
|
|
579
|
-
if (sys && sys.broker) {
|
|
580
|
-
brokerTile = tile('Auth-broker',
|
|
581
|
-
sys.broker.reachable
|
|
582
|
-
? `reachable · active <strong>${escapeHtml(sys.broker.active || '—')}</strong><br>${sys.broker.accounts ?? '?'} accts · ${sys.broker.agents ?? '?'} agents`
|
|
583
|
-
: `<span style="color:var(--red)">unreachable</span>`,
|
|
584
|
-
!!sys.broker.reachable);
|
|
585
|
-
} else brokerTile = tile('Auth-broker', dim('unavailable'), null);
|
|
586
|
-
if (sys && sys.hindsight) {
|
|
587
|
-
hsTile = tile('Hindsight',
|
|
588
|
-
sys.hindsight.running
|
|
589
|
-
? `running<br>${escapeHtml(sys.hindsight.model || '?')} · ${sys.hindsight.mcpStateless == null ? '?' : sys.hindsight.mcpStateless ? 'stateless' : 'stateful'}`
|
|
590
|
-
: `<span style="color:var(--red)">not running</span>`,
|
|
591
|
-
!!sys.hindsight.running);
|
|
592
|
-
} else hsTile = tile('Hindsight', dim('unavailable'), null);
|
|
593
|
-
if (sys && sys.hostd) {
|
|
594
|
-
hostdTile = tile('Hostd',
|
|
595
|
-
sys.hostd.auditLogPresent
|
|
596
|
-
? `audit present · ${(sys.hostd.recent || []).length} recent`
|
|
597
|
-
: dim('no audit log yet'),
|
|
598
|
-
sys.hostd.auditLogPresent ? true : null);
|
|
599
|
-
} else hostdTile = tile('Hostd', dim('unavailable'), null);
|
|
600
|
-
|
|
601
|
-
// Approvals
|
|
602
|
-
let apprTile;
|
|
603
|
-
if (appr && appr.reachable !== false) {
|
|
604
|
-
const d = appr.decisions || [];
|
|
605
|
-
const active = d.filter(x => !x.revoked_at && !(x.ttl_expires_at && x.ttl_expires_at < Date.now())).length;
|
|
606
|
-
apprTile = tile('Approvals', `<strong>${active}</strong> active · ${d.length} total`, true);
|
|
607
|
-
} else apprTile = tile('Approvals', dim((appr && appr.error) ? 'kernel unreachable' : 'unavailable'), null);
|
|
608
|
-
|
|
609
|
-
// Schedule
|
|
610
|
-
let schedTile;
|
|
611
|
-
if (sched && Array.isArray(sched.entries)) {
|
|
612
|
-
const agents = new Set(sched.entries.map(e => e.agent));
|
|
613
|
-
schedTile = tile('Schedule', `<strong>${sched.entries.length}</strong> cron entr${sched.entries.length === 1 ? 'y' : 'ies'} · ${agents.size} agent(s)`, true);
|
|
614
|
-
} else schedTile = tile('Schedule', dim('unavailable'), null);
|
|
615
|
-
|
|
616
|
-
// Quota headroom — worst cached 5h/7d across accounts (no probe;
|
|
617
|
-
// shows "—" until someone refreshes on the Accounts tab).
|
|
618
|
-
let quotaTile;
|
|
619
|
-
if (Array.isArray(accts) && accts.length) {
|
|
620
|
-
const used = accts.filter(a => a.quotaUsage);
|
|
621
|
-
if (used.length === 0) {
|
|
622
|
-
quotaTile = tile('Quota', dim('not probed yet — see Accounts tab'), null);
|
|
623
|
-
} else {
|
|
624
|
-
const worst5 = Math.max(...used.map(a => a.quotaUsage.fiveHourPct));
|
|
625
|
-
const worst7 = Math.max(...used.map(a => a.quotaUsage.sevenDayPct));
|
|
626
|
-
const anyStale = accts.some(a => a.quotaStale);
|
|
627
|
-
quotaTile = tile('Quota (worst acct)',
|
|
628
|
-
`5h <strong>${Math.round(worst5)}%</strong> · 7d <strong>${Math.round(worst7)}%</strong>${anyStale ? '<br>' + dim('some stale') : ''}`,
|
|
629
|
-
worst5 < 90 && worst7 < 90);
|
|
630
|
-
}
|
|
631
|
-
} else quotaTile = tile('Quota', dim('unavailable'), null);
|
|
632
|
-
|
|
633
|
-
el.classList.remove('loading');
|
|
634
|
-
el.innerHTML = `<div class="agents-grid">${agentsTile}${brokerTile}${hsTile}${hostdTile}${apprTile}${schedTile}${quotaTile}</div>`;
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
function showError(msg) {
|
|
638
|
-
document.getElementById('error').innerHTML =
|
|
639
|
-
`<div class="error-banner">${escapeHtml(msg)}</div>`;
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
function clearError() {
|
|
643
|
-
document.getElementById('error').innerHTML = '';
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
function escapeHtml(s) {
|
|
647
|
-
// Must cover attribute context too: values are interpolated into
|
|
648
|
-
// both text nodes AND title="..." attributes. The old
|
|
649
|
-
// textContent→innerHTML trick escapes &<> but NOT quotes, so an
|
|
650
|
-
// agent-authored string like `" onmouseover="x` could break out
|
|
651
|
-
// of a title= attribute. Escape the full set explicitly.
|
|
652
|
-
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
|
653
|
-
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
654
|
-
}[c]));
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
function statusClass(agent) {
|
|
658
|
-
if (agent.active === 'active') {
|
|
659
|
-
if (agent.auth && agent.auth.expiresAt) {
|
|
660
|
-
const remaining = agent.auth.expiresAt - Date.now();
|
|
661
|
-
if (remaining > 0 && remaining < 60 * 60 * 1000) return 'auth-warning';
|
|
662
|
-
}
|
|
663
|
-
return 'active';
|
|
664
|
-
}
|
|
665
|
-
return 'inactive';
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
function formatUptime(timestamp) {
|
|
669
|
-
if (!timestamp) return '--';
|
|
670
|
-
const d = new Date(timestamp);
|
|
671
|
-
if (isNaN(d.getTime())) return timestamp;
|
|
672
|
-
const diff = Date.now() - d.getTime();
|
|
673
|
-
if (diff < 0) return 'just now';
|
|
674
|
-
const mins = Math.floor(diff / 60000);
|
|
675
|
-
if (mins < 60) return `${mins}m`;
|
|
676
|
-
const hrs = Math.floor(mins / 60);
|
|
677
|
-
if (hrs < 24) return `${hrs}h ${mins % 60}m`;
|
|
678
|
-
const days = Math.floor(hrs / 24);
|
|
679
|
-
return `${days}d ${hrs % 24}h`;
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
function render() {
|
|
683
|
-
const container = document.getElementById('agents');
|
|
684
|
-
if (agents.length === 0) {
|
|
685
|
-
container.innerHTML = '<div class="loading">No agents yet. Run <code>switchroom agent create <name> --profile <profile></code> to add one.</div>';
|
|
686
|
-
return;
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
container.className = 'agents-grid';
|
|
690
|
-
container.innerHTML = agents.map(a => `
|
|
691
|
-
<div class="agent-card" data-agent="${escapeHtml(a.name)}">
|
|
692
|
-
<div class="card-header" onclick="toggleLogs('${escapeHtml(a.name)}')">
|
|
693
|
-
<div class="status-dot ${statusClass(a)}" title="${escapeHtml(a.active)}"></div>
|
|
694
|
-
<div class="agent-name">${escapeHtml(a.name)}</div>
|
|
695
|
-
<div class="agent-topic">${a.topic_emoji ? escapeHtml(a.topic_emoji) + ' ' : ''}${escapeHtml(a.topic_name)}</div>
|
|
696
|
-
</div>
|
|
697
|
-
<div class="card-meta">
|
|
698
|
-
<div class="meta-item"><label>Status </label><span>${escapeHtml(a.active)}</span></div>
|
|
699
|
-
<div class="meta-item"><label>Uptime </label><span>${formatUptime(a.uptime)}</span></div>
|
|
700
|
-
<div class="meta-item"><label>Mem </label><span>${a.memory || '--'}</span></div>
|
|
701
|
-
<div class="meta-item"><label>Profile </label><span>${escapeHtml(a.extends)}</span></div>
|
|
702
|
-
<div class="meta-item"><label>Auth </label><span>${a.auth.authenticated ? '✓' : '✗'}</span></div>
|
|
703
|
-
<div class="meta-item"><label>Account </label><span>${a.primaryAccount ? escapeHtml(a.primaryAccount) : '<span style="color:var(--text-dim)">default</span>'}</span></div>
|
|
704
|
-
<div class="meta-item"><label>Collection </label><span>${escapeHtml(a.memoryCollection)}</span></div>
|
|
705
|
-
</div>
|
|
706
|
-
<div class="card-actions">
|
|
707
|
-
<button class="btn btn-start" onclick="agentAction('${escapeHtml(a.name)}','start')" ${a.active === 'active' ? 'disabled' : ''}>Start</button>
|
|
708
|
-
<button class="btn btn-stop" onclick="agentAction('${escapeHtml(a.name)}','stop')" ${a.active !== 'active' ? 'disabled' : ''}>Stop</button>
|
|
709
|
-
<button class="btn btn-restart" onclick="agentAction('${escapeHtml(a.name)}','restart')" ${a.active !== 'active' ? 'disabled' : ''}>Restart</button>
|
|
710
|
-
<button class="btn" onclick="toggleDetails('${escapeHtml(a.name)}')">${openDetails.has(a.name) ? 'Hide' : 'Details'}</button>
|
|
711
|
-
</div>
|
|
712
|
-
<div class="card-detail ${openDetails.has(a.name) ? 'open' : ''}" id="detail-${escapeHtml(a.name)}">
|
|
713
|
-
${renderAgentDetail(a.name)}
|
|
714
|
-
</div>
|
|
715
|
-
<div class="card-logs ${openLogs.has(a.name) ? 'open' : ''}" id="logs-${escapeHtml(a.name)}">
|
|
716
|
-
<div class="logs-header">
|
|
717
|
-
<span>Logs</span>
|
|
718
|
-
<button onclick="loadLogs('${escapeHtml(a.name)}')">Refresh</button>
|
|
719
|
-
</div>
|
|
720
|
-
<div class="log-output" id="log-output-${escapeHtml(a.name)}">Click to load logs...</div>
|
|
721
|
-
</div>
|
|
722
|
-
</div>
|
|
723
|
-
`).join('');
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
function renderAgentDetail(name) {
|
|
727
|
-
const d = agentDetails[name];
|
|
728
|
-
if (!d) return '<div style="color:var(--text-dim)">Loading…</div>';
|
|
729
|
-
const accounts = d.accounts;
|
|
730
|
-
const subagents = d.subagents;
|
|
731
|
-
const config = d.config;
|
|
732
|
-
|
|
733
|
-
let accountsHtml;
|
|
734
|
-
if (!accounts || accounts.assigned.length === 0) {
|
|
735
|
-
accountsHtml = '<div style="color:var(--text-dim)">No accounts assigned (falls back to <code>default</code>).</div>';
|
|
736
|
-
} else {
|
|
737
|
-
const byLabel = new Map((accounts.details || []).map(d => [d.label, d]));
|
|
738
|
-
accountsHtml = accounts.assigned.map((label, i) => {
|
|
739
|
-
const info = byLabel.get(label);
|
|
740
|
-
if (!info) return `<span class="chip missing" title="not in ~/.switchroom/accounts/">${escapeHtml(label)} · missing</span>`;
|
|
741
|
-
const tag = i === 0 ? ' · primary' : '';
|
|
742
|
-
return `<span class="chip"><span class="health-badge ${escapeHtml(info.health)}" style="margin-right:0.4rem">${escapeHtml(info.health)}</span>${escapeHtml(label)}${tag}</span>`;
|
|
743
|
-
}).join('');
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
let subHtml;
|
|
747
|
-
if (!subagents || subagents.length === 0) {
|
|
748
|
-
subHtml = '<div style="color:var(--text-dim)">No sub-agents tracked.</div>';
|
|
749
|
-
} else {
|
|
750
|
-
subHtml = subagents.map(s => `<span class="chip">${escapeHtml(s.name || s.id || '?')}${s.status ? ' · ' + escapeHtml(s.status) : ''}</span>`).join('');
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
const configText = config ? JSON.stringify(config, null, 2) : '(unavailable)';
|
|
754
|
-
|
|
755
|
-
return `
|
|
756
|
-
<h4>Accounts</h4>${accountsHtml}
|
|
757
|
-
<h4>Sub-agents</h4>${subHtml}
|
|
758
|
-
<h4>Resolved config</h4><pre class="config-pre">${escapeHtml(configText)}</pre>
|
|
759
|
-
`;
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
function renderAccounts() {
|
|
763
|
-
const container = document.getElementById('accounts');
|
|
764
|
-
if (!accounts || accounts.length === 0) {
|
|
765
|
-
container.innerHTML = '<div class="loading">No accounts. Run <code>switchroom auth account add <label></code>.</div>';
|
|
766
|
-
return;
|
|
767
|
-
}
|
|
768
|
-
// RFC-H shape: the API returns `usedBy` (agents bound via the
|
|
769
|
-
// fleet-active account or a per-agent override) and a broker
|
|
770
|
-
// `quota` AccountState ({exhausted, exhausted_until,
|
|
771
|
-
// threshold_violations, last_refreshed_at}). The pre-RFC-H
|
|
772
|
-
// primaryFor/fallbackFor lists and quota.fiveHourPct utilization
|
|
773
|
-
// fields no longer exist on the wire.
|
|
774
|
-
// usage % cell: live 5h/7d utilization from the last cached
|
|
775
|
-
// probe (cost-gated — see refreshQuota). null → "—" + a per-
|
|
776
|
-
// account ↻ that force-probes. quotaStale → value shown muted
|
|
777
|
-
// with ↻ to refresh.
|
|
778
|
-
const pctCell = (pct, label, stale) => {
|
|
779
|
-
if (pct == null) return '<span style="color:var(--text-dim)">—</span>';
|
|
780
|
-
let cls = 'quota-pct';
|
|
781
|
-
if (pct >= 90) cls += ' high';
|
|
782
|
-
else if (pct >= 70) cls += ' mid';
|
|
783
|
-
const v = `${Math.round(pct)}%`;
|
|
784
|
-
return stale
|
|
785
|
-
? `<span class="${cls}" title="stale — click ↻" style="opacity:.55">${v}</span>`
|
|
786
|
-
: `<span class="${cls}">${v}</span>`;
|
|
787
|
-
};
|
|
788
|
-
const enriched = accounts.map(a => {
|
|
789
|
-
const q = a.quota || null;
|
|
790
|
-
const u = a.quotaUsage || null;
|
|
791
|
-
const exhausted = q ? !!q.exhausted : null;
|
|
792
|
-
return {
|
|
793
|
-
a,
|
|
794
|
-
usedBy: a.usedBy || [],
|
|
795
|
-
fiveH: pctCell(u ? u.fiveHourPct : null, '5h', a.quotaStale),
|
|
796
|
-
sevenD: pctCell(u ? u.sevenDayPct : null, '7d', a.quotaStale),
|
|
797
|
-
fiveReset: u && u.fiveHourResetAt ? formatTimestamp(u.fiveHourResetAt) : '<span style="color:var(--text-dim)">—</span>',
|
|
798
|
-
captured: u && u.capturedAt
|
|
799
|
-
? formatTimestamp(u.capturedAt)
|
|
800
|
-
: '<span style="color:var(--text-dim)">never</span>',
|
|
801
|
-
exhaustedCell: q == null
|
|
802
|
-
? '<span style="color:var(--text-dim)">broker offline</span>'
|
|
803
|
-
: exhausted
|
|
804
|
-
? `<span class="quota-pct high">exhausted${q.exhausted_until ? ' → ' + formatTimestamp(q.exhausted_until) : ''}</span>`
|
|
805
|
-
: '<span class="quota-pct">ok</span>',
|
|
806
|
-
violations: q && q.threshold_violations
|
|
807
|
-
? `<span class="quota-pct mid">${q.threshold_violations}</span>`
|
|
808
|
-
: '<span style="color:var(--text-dim)">0</span>',
|
|
809
|
-
expires: formatTimestamp(a.expiresAt),
|
|
810
|
-
};
|
|
811
|
-
});
|
|
812
|
-
const refreshBtn = (label) =>
|
|
813
|
-
`<button class="btn" title="Probe live quota now (billed Anthropic call)" onclick="refreshQuota('${escapeHtml(label)}', true)">↻</button>`;
|
|
814
|
-
const rows = enriched.map(e => {
|
|
815
|
-
const { a, usedBy, fiveH, sevenD, captured, exhaustedCell, violations, expires } = e;
|
|
816
|
-
return `
|
|
817
|
-
<tr>
|
|
818
|
-
<td>${escapeHtml(a.label)}</td>
|
|
819
|
-
<td><span class="health-badge ${escapeHtml(a.health)}">${escapeHtml(a.health)}</span></td>
|
|
820
|
-
<td>${renderUsedByCell(usedBy)}</td>
|
|
821
|
-
<td>${fiveH}</td>
|
|
822
|
-
<td>${sevenD}</td>
|
|
823
|
-
<td title="quota probed at">${captured}</td>
|
|
824
|
-
<td>${exhaustedCell}</td>
|
|
825
|
-
<td>${violations}</td>
|
|
826
|
-
<td>${expires}</td>
|
|
827
|
-
<td>${refreshBtn(a.label)} <button class="btn" onclick="openPromoteModal('${escapeHtml(a.label)}')">Make fleet-active…</button></td>
|
|
828
|
-
</tr>
|
|
829
|
-
`;
|
|
830
|
-
}).join('');
|
|
831
|
-
const cards = enriched.map(e => {
|
|
832
|
-
const { a, usedBy, fiveH, sevenD, captured, exhaustedCell, violations, expires } = e;
|
|
833
|
-
return `
|
|
834
|
-
<div class="account-card">
|
|
835
|
-
<div class="account-card-header">
|
|
836
|
-
<div class="account-label">${escapeHtml(a.label)}</div>
|
|
837
|
-
<span class="health-badge ${escapeHtml(a.health)}" style="margin-left:auto">${escapeHtml(a.health)}</span>
|
|
838
|
-
</div>
|
|
839
|
-
<div class="account-usage">${renderUsedByCell(usedBy)}</div>
|
|
840
|
-
<div class="card-meta" style="padding:0">
|
|
841
|
-
<div class="meta-item"><label>5h usage </label><span>${fiveH}</span></div>
|
|
842
|
-
<div class="meta-item"><label>7d usage </label><span>${sevenD}</span></div>
|
|
843
|
-
<div class="meta-item"><label>Probed </label><span>${captured}</span></div>
|
|
844
|
-
<div class="meta-item"><label>Quota </label><span>${exhaustedCell}</span></div>
|
|
845
|
-
<div class="meta-item"><label>Threshold viols </label><span>${violations}</span></div>
|
|
846
|
-
<div class="meta-item"><label>Expires </label><span>${expires}</span></div>
|
|
847
|
-
</div>
|
|
848
|
-
<div style="margin-top:0.75rem">
|
|
849
|
-
${refreshBtn(a.label)}
|
|
850
|
-
<button class="btn" onclick="openPromoteModal('${escapeHtml(a.label)}')">Make fleet-active…</button>
|
|
851
|
-
</div>
|
|
852
|
-
</div>
|
|
853
|
-
`;
|
|
854
|
-
}).join('');
|
|
855
|
-
container.innerHTML = `
|
|
856
|
-
<div style="margin-bottom:0.6rem;display:flex;align-items:center;gap:0.75rem">
|
|
857
|
-
<button class="btn" onclick="refreshQuota(null, true)">↻ Refresh all quota</button>
|
|
858
|
-
<span style="font-size:0.78rem;color:var(--text-dim)">
|
|
859
|
-
5h/7d usage is cached (≤10 min); ↻ forces a live probe (one billed Anthropic call per account).
|
|
860
|
-
</span>
|
|
861
|
-
</div>
|
|
862
|
-
<div class="accounts-table-wrap">
|
|
863
|
-
<table class="accounts-table">
|
|
864
|
-
<thead><tr>
|
|
865
|
-
<th>Label</th><th>Health</th><th>Used by</th>
|
|
866
|
-
<th>5h %</th><th>7d %</th><th>Probed</th>
|
|
867
|
-
<th>Quota</th><th>Threshold viols</th><th>Expires</th><th></th>
|
|
868
|
-
</tr></thead>
|
|
869
|
-
<tbody>${rows}</tbody>
|
|
870
|
-
</table>
|
|
871
|
-
</div>
|
|
872
|
-
<div class="accounts-grid">${cards}</div>
|
|
873
|
-
`;
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
// Refresh cached quota. label=null → all accounts. force=true →
|
|
877
|
-
// bypass the server's 10-min TTL (the per-account / "all" buttons).
|
|
878
|
-
async function refreshQuota(label, force) {
|
|
879
|
-
try {
|
|
880
|
-
showToast(label ? `Probing ${label}…` : 'Probing all accounts…', true);
|
|
881
|
-
const res = await fetch(`${API}/api/accounts/quota/refresh`, {
|
|
882
|
-
method: 'POST',
|
|
883
|
-
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
|
884
|
-
body: JSON.stringify(label ? { labels: [label], force: !!force } : { force: !!force }),
|
|
885
|
-
});
|
|
886
|
-
const data = await res.json();
|
|
887
|
-
if (!res.ok || !data.ok) {
|
|
888
|
-
showToast(`Quota probe failed: ${data.error || `HTTP ${res.status}`}`, false);
|
|
889
|
-
} else {
|
|
890
|
-
showToast('Quota updated', true);
|
|
891
|
-
}
|
|
892
|
-
fetchAccounts(); // re-pull; handleGetAccounts now serves the fresh cache
|
|
893
|
-
} catch (err) {
|
|
894
|
-
showToast(`Quota probe failed: ${err.message}`, false);
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
function renderUsedByCell(usedBy) {
|
|
899
|
-
// RFC-H: a single fleet-active account plus optional per-agent
|
|
900
|
-
// overrides. `usedBy` is the flat list of agents currently bound
|
|
901
|
-
// to this account (either via fleet-active or an override). No
|
|
902
|
-
// more primary/fallback split — binding is singular post-RFC-H.
|
|
903
|
-
if (!usedBy || usedBy.length === 0) {
|
|
904
|
-
return '<span style="color:var(--text-dim)">unused</span>';
|
|
905
|
-
}
|
|
906
|
-
const MAX_INLINE = 4;
|
|
907
|
-
const title = `bound: ${usedBy.join(', ')}`;
|
|
908
|
-
const shown = usedBy.length <= MAX_INLINE
|
|
909
|
-
? usedBy.map(escapeHtml).join(', ')
|
|
910
|
-
: `${usedBy.slice(0, MAX_INLINE).map(escapeHtml).join(', ')} +${usedBy.length - MAX_INLINE}`;
|
|
911
|
-
return `<span class="usage-pill primary" title="${escapeHtml(title)}">${shown}</span>`;
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
function renderSystemHealth(h) {
|
|
915
|
-
const container = document.getElementById('system');
|
|
916
|
-
const b = h.broker || {};
|
|
917
|
-
const hs = h.hindsight || {};
|
|
918
|
-
const hd = h.hostd || {};
|
|
919
|
-
|
|
920
|
-
const dot = (ok) => `<span class="status-dot ${ok ? 'active' : 'inactive'}" style="display:inline-block;vertical-align:middle"></span>`;
|
|
921
|
-
const dim = (s) => `<span style="color:var(--text-dim)">${escapeHtml(s)}</span>`;
|
|
922
|
-
|
|
923
|
-
const brokerBody = b.reachable
|
|
924
|
-
? `<div class="card-meta" style="padding:0">
|
|
925
|
-
<div class="meta-item"><label>Fleet-active </label><span>${escapeHtml(b.active || '—')}</span></div>
|
|
926
|
-
<div class="meta-item"><label>Accounts </label><span>${b.accounts ?? '—'}</span></div>
|
|
927
|
-
<div class="meta-item"><label>Agents </label><span>${b.agents ?? '—'}</span></div>
|
|
928
|
-
<div class="meta-item"><label>Consumers </label><span>${b.consumers ?? '—'}</span></div>
|
|
929
|
-
</div>`
|
|
930
|
-
: `<div style="color:var(--red)">unreachable${b.error ? ' — ' + escapeHtml(b.error) : ''}</div>`;
|
|
931
|
-
|
|
932
|
-
const statelessCell = hs.mcpStateless == null
|
|
933
|
-
? dim('unknown')
|
|
934
|
-
: hs.mcpStateless ? 'stateless' : '<span style="color:var(--yellow)">stateful</span>';
|
|
935
|
-
const hindsightBody = `
|
|
936
|
-
<div class="card-meta" style="padding:0">
|
|
937
|
-
<div class="meta-item"><label>Container </label><span>${hs.containerStatus ? escapeHtml(hs.containerStatus) : dim('absent')}</span></div>
|
|
938
|
-
<div class="meta-item"><label>Model </label><span>${hs.model ? escapeHtml(hs.model) : dim('unknown')}</span></div>
|
|
939
|
-
<div class="meta-item"><label>Provider </label><span>${hs.provider ? escapeHtml(hs.provider) : dim('unknown')}</span></div>
|
|
940
|
-
<div class="meta-item"><label>MCP </label><span>${statelessCell}</span></div>
|
|
941
|
-
</div>`;
|
|
942
|
-
|
|
943
|
-
let hostdBody;
|
|
944
|
-
if (!hd.auditLogPresent) {
|
|
945
|
-
hostdBody = dim(hd.error ? `audit log error: ${hd.error}` : 'no audit log yet (hostd has handled no privileged verbs)');
|
|
946
|
-
} else if (!hd.recent || hd.recent.length === 0) {
|
|
947
|
-
hostdBody = dim('audit log present, no entries');
|
|
948
|
-
} else {
|
|
949
|
-
const rows = hd.recent.slice().reverse().map(e => {
|
|
950
|
-
const caller = e.caller && e.caller.kind === 'agent' ? escapeHtml(e.caller.name) : 'operator';
|
|
951
|
-
const ok = e.result === 'ok' || e.exit_code === 0;
|
|
952
|
-
return `<tr>
|
|
953
|
-
<td>${dot(ok)} ${escapeHtml(e.op || '?')}</td>
|
|
954
|
-
<td>${caller}</td>
|
|
955
|
-
<td>${escapeHtml(e.result || '?')}${e.exit_code != null ? ` (${e.exit_code})` : ''}</td>
|
|
956
|
-
<td>${escapeHtml(shortTs(e.ts))}</td>
|
|
957
|
-
</tr>`;
|
|
958
|
-
}).join('');
|
|
959
|
-
hostdBody = `<table class="accounts-table" style="margin-top:0.5rem">
|
|
960
|
-
<thead><tr><th>Verb</th><th>Caller</th><th>Result</th><th>When</th></tr></thead>
|
|
961
|
-
<tbody>${rows}</tbody></table>`;
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
container.innerHTML = `
|
|
965
|
-
<div class="agents-grid">
|
|
966
|
-
<div class="agent-card"><div class="card-header" style="cursor:default">
|
|
967
|
-
${dot(b.reachable)}<span class="agent-name">auth-broker</span></div>
|
|
968
|
-
<div style="padding:0 1.25rem 1rem">${brokerBody}</div></div>
|
|
969
|
-
<div class="agent-card"><div class="card-header" style="cursor:default">
|
|
970
|
-
${dot(hs.running)}<span class="agent-name">hindsight</span></div>
|
|
971
|
-
<div style="padding:0 1.25rem 1rem">${hindsightBody}</div></div>
|
|
972
|
-
</div>
|
|
973
|
-
<div class="agent-card" style="margin-top:1rem"><div class="card-header" style="cursor:default">
|
|
974
|
-
${dot(hd.auditLogPresent)}<span class="agent-name">hostd — recent privileged verbs</span></div>
|
|
975
|
-
<div style="padding:0 1.25rem 1rem">${hostdBody}</div></div>`;
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
function shortTs(ts) {
|
|
979
|
-
if (!ts) return '—';
|
|
980
|
-
// 2026-05-15T04:15:13.465Z → 2026-05-15 04:15:13
|
|
981
|
-
return String(ts).replace('T', ' ').replace(/\.\d+Z?$/, '').replace(/Z$/, '');
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
function renderGoogleAccounts(rows) {
|
|
985
|
-
const container = document.getElementById('google');
|
|
986
|
-
if (!rows || rows.length === 0) {
|
|
987
|
-
container.innerHTML = '<div class="loading">No Google accounts. Add one under <code>google_accounts:</code> in switchroom.yaml and run <code>switchroom auth google account add</code>.</div>';
|
|
988
|
-
return;
|
|
989
|
-
}
|
|
990
|
-
const dim = (s) => `<span style="color:var(--text-dim)">${escapeHtml(s)}</span>`;
|
|
991
|
-
const cards = rows.map(a => {
|
|
992
|
-
const expires = a.expiresAt ? formatTimestamp(a.expiresAt) : dim('—');
|
|
993
|
-
const scope = a.scope ? escapeHtml(a.scope) : dim('broker offline');
|
|
994
|
-
const known = a.brokerKnown
|
|
995
|
-
? '<span class="usage-pill primary">slot present</span>'
|
|
996
|
-
: '<span style="color:var(--yellow)">config-only (no broker slot)</span>';
|
|
997
|
-
const acl = (a.enabledFor && a.enabledFor.length)
|
|
998
|
-
? a.enabledFor.map(escapeHtml).join(', ')
|
|
999
|
-
: dim('no agents enabled');
|
|
1000
|
-
return `
|
|
1001
|
-
<div class="account-card">
|
|
1002
|
-
<div class="account-card-header">
|
|
1003
|
-
<div class="account-label">${escapeHtml(a.account)}</div>
|
|
1004
|
-
<span style="margin-left:auto">${known}</span>
|
|
1005
|
-
</div>
|
|
1006
|
-
<div class="account-usage"><label style="color:var(--text-dim);opacity:.7">Enabled for: </label>${acl}</div>
|
|
1007
|
-
<div class="card-meta" style="padding:0">
|
|
1008
|
-
<div class="meta-item"><label>Expires </label><span>${expires}</span></div>
|
|
1009
|
-
<div class="meta-item" title="${a.scope ? escapeHtml(a.scope) : ''}"><label>Scope </label><span>${a.scope ? escapeHtml(a.scope.split(' ').length + ' scope(s)') : dim('—')}</span></div>
|
|
1010
|
-
<div class="meta-item"><label>Client </label><span>${a.clientId ? escapeHtml(a.clientId.slice(0, 16) + '…') : dim('—')}</span></div>
|
|
1011
|
-
</div>
|
|
1012
|
-
</div>`;
|
|
1013
|
-
}).join('');
|
|
1014
|
-
container.innerHTML = `<div class="accounts-grid">${cards}</div>`;
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
function renderSchedule(data) {
|
|
1018
|
-
const container = document.getElementById('schedule');
|
|
1019
|
-
const entries = (data && data.entries) || [];
|
|
1020
|
-
const recent = (data && data.recentByAgent) || {};
|
|
1021
|
-
if (entries.length === 0) {
|
|
1022
|
-
container.innerHTML = '<div class="loading">No <code>schedule:</code> entries in any agent\'s cascade-resolved config.</div>';
|
|
1023
|
-
return;
|
|
1024
|
-
}
|
|
1025
|
-
const dim = (s) => `<span style="color:var(--text-dim)">${escapeHtml(s)}</span>`;
|
|
1026
|
-
// Group entries by agent.
|
|
1027
|
-
const byAgent = {};
|
|
1028
|
-
for (const e of entries) (byAgent[e.agent] ||= []).push(e);
|
|
1029
|
-
const cards = Object.keys(byAgent).sort().map(agent => {
|
|
1030
|
-
const rows = byAgent[agent].map(e => `
|
|
1031
|
-
<tr>
|
|
1032
|
-
<td><code>${escapeHtml(e.cron)}</code></td>
|
|
1033
|
-
<td title="${escapeHtml(e.prompt)}">${escapeHtml(e.prompt.length > 70 ? e.prompt.slice(0, 70) + '…' : e.prompt)}</td>
|
|
1034
|
-
</tr>`).join('');
|
|
1035
|
-
const fires = (recent[agent] || []).slice().reverse();
|
|
1036
|
-
const fireRows = fires.length === 0
|
|
1037
|
-
? `<tr><td colspan="3">${dim('no recorded fires yet')}</td></tr>`
|
|
1038
|
-
: fires.map(f => {
|
|
1039
|
-
const ok = f.exitCode === 0;
|
|
1040
|
-
return `<tr>
|
|
1041
|
-
<td><span class="status-dot ${ok ? 'active' : 'inactive'}" style="display:inline-block;vertical-align:middle"></span> ${escapeHtml(shortTs(new Date(f.startedAt).toISOString()))}</td>
|
|
1042
|
-
<td>${escapeHtml(String(f.outputSummary || '').slice(0, 80))}</td>
|
|
1043
|
-
<td>${ok ? 'ok' : 'exit ' + escapeHtml(String(f.exitCode))}</td>
|
|
1044
|
-
</tr>`;
|
|
1045
|
-
}).join('');
|
|
1046
|
-
return `
|
|
1047
|
-
<div class="agent-card" style="margin-bottom:1rem">
|
|
1048
|
-
<div class="card-header" style="cursor:default">
|
|
1049
|
-
<span class="agent-name">${escapeHtml(agent)}</span>
|
|
1050
|
-
<span class="agent-topic">${byAgent[agent].length} schedule entr${byAgent[agent].length === 1 ? 'y' : 'ies'}</span>
|
|
1051
|
-
</div>
|
|
1052
|
-
<div style="padding:0 1.25rem 1rem">
|
|
1053
|
-
<table class="accounts-table"><thead><tr><th>Cron</th><th>Prompt</th></tr></thead><tbody>${rows}</tbody></table>
|
|
1054
|
-
<div style="margin-top:0.6rem;font-size:0.8rem;color:var(--text-dim)">Recent fires (from scheduler.jsonl)</div>
|
|
1055
|
-
<table class="accounts-table"><thead><tr><th>When</th><th>Result</th><th>Exit</th></tr></thead><tbody>${fireRows}</tbody></table>
|
|
1056
|
-
</div>
|
|
1057
|
-
</div>`;
|
|
1058
|
-
}).join('');
|
|
1059
|
-
container.innerHTML = cards;
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
function renderApprovals(data) {
|
|
1063
|
-
const container = document.getElementById('approvals');
|
|
1064
|
-
const dim = (s) => `<span style="color:var(--text-dim)">${escapeHtml(s)}</span>`;
|
|
1065
|
-
if (!data || data.reachable === false) {
|
|
1066
|
-
const why = (data && data.error) ? escapeHtml(data.error) : 'approval-kernel not reachable from host';
|
|
1067
|
-
container.innerHTML = `<div class="loading">Approval ledger unavailable — ${why}</div>`;
|
|
1068
|
-
return;
|
|
1069
|
-
}
|
|
1070
|
-
const decisions = data.decisions || [];
|
|
1071
|
-
if (decisions.length === 0) {
|
|
1072
|
-
container.innerHTML = '<div class="loading">No approval decisions recorded yet.</div>';
|
|
1073
|
-
return;
|
|
1074
|
-
}
|
|
1075
|
-
const now = Date.now();
|
|
1076
|
-
const statusOf = (d) => {
|
|
1077
|
-
if (d.revoked_at) return ['revoked', 'inactive'];
|
|
1078
|
-
if (d.ttl_expires_at && d.ttl_expires_at < now) return ['expired', 'inactive'];
|
|
1079
|
-
return ['active', 'active'];
|
|
1080
|
-
};
|
|
1081
|
-
const rows = decisions.map(d => {
|
|
1082
|
-
const [label, dot] = statusOf(d);
|
|
1083
|
-
// Drive write scopes are the headline use of the approval flow —
|
|
1084
|
-
// make them visually distinct.
|
|
1085
|
-
const isDrive = /(^|:)(doc|drive|gdrive|sheets)/i.test(d.scope || '');
|
|
1086
|
-
const scopeCell = isDrive
|
|
1087
|
-
? `<span class="usage-pill primary">${escapeHtml(d.scope)}</span>`
|
|
1088
|
-
: escapeHtml(d.scope || '—');
|
|
1089
|
-
return `
|
|
1090
|
-
<tr>
|
|
1091
|
-
<td><span class="status-dot ${dot}" style="display:inline-block;vertical-align:middle"></span> ${escapeHtml(label)}</td>
|
|
1092
|
-
<td>${escapeHtml(d.agent_unit || '—')}</td>
|
|
1093
|
-
<td>${scopeCell}</td>
|
|
1094
|
-
<td>${escapeHtml(d.action || '—')}</td>
|
|
1095
|
-
<td>${escapeHtml(d.decision || '—')}</td>
|
|
1096
|
-
<td>${d.granted_at ? formatTimestamp(d.granted_at) : dim('—')}</td>
|
|
1097
|
-
<td>${d.ttl_expires_at ? formatTimestamp(d.ttl_expires_at) : dim('—')}</td>
|
|
1098
|
-
<td title="${d.revoke_reason ? escapeHtml(d.revoke_reason) : ''}">${d.revoked_at ? formatTimestamp(d.revoked_at) : dim('—')}</td>
|
|
1099
|
-
</tr>`;
|
|
1100
|
-
}).join('');
|
|
1101
|
-
container.innerHTML = `
|
|
1102
|
-
<div style="margin-bottom:0.6rem;font-size:0.8rem;color:var(--text-dim)">
|
|
1103
|
-
Read-only view via the kernel operator socket — ${decisions.length} decision(s). Drive scopes highlighted.
|
|
1104
|
-
</div>
|
|
1105
|
-
<div class="accounts-table-wrap">
|
|
1106
|
-
<table class="accounts-table">
|
|
1107
|
-
<thead><tr>
|
|
1108
|
-
<th>Status</th><th>Agent</th><th>Scope</th><th>Action</th>
|
|
1109
|
-
<th>Decision</th><th>Granted</th><th>TTL expires</th><th>Revoked</th>
|
|
1110
|
-
</tr></thead>
|
|
1111
|
-
<tbody>${rows}</tbody>
|
|
1112
|
-
</table>
|
|
1113
|
-
</div>`;
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
function openPromoteModal(label) {
|
|
1117
|
-
// RFC-H: there is no per-agent promote. Setting the fleet-active
|
|
1118
|
-
// account is a single fleet-wide knob (POST /api/auth/use); the
|
|
1119
|
-
// broker fans the new credentials out to every agent's per-agent
|
|
1120
|
-
// mirror. Confirm intent, then call.
|
|
1121
|
-
const html = `
|
|
1122
|
-
<div class="modal-backdrop" id="promote-backdrop">
|
|
1123
|
-
<div class="modal">
|
|
1124
|
-
<h3>Make <code>${escapeHtml(label)}</code> the fleet-active account</h3>
|
|
1125
|
-
<div style="margin-top:0.5rem; font-size:0.85rem;">
|
|
1126
|
-
Every agent without a per-agent <code>auth.override</code> will
|
|
1127
|
-
switch to <code>${escapeHtml(label)}</code>. The broker fans
|
|
1128
|
-
the credentials out immediately; agents pick them up on their
|
|
1129
|
-
next turn (no manual restart needed post-RFC-H).
|
|
1130
|
-
</div>
|
|
1131
|
-
<div class="modal-actions">
|
|
1132
|
-
<button class="btn" onclick="closePromoteModal()">Cancel</button>
|
|
1133
|
-
<button class="btn btn-restart" onclick="confirmPromote('${escapeHtml(label)}')">Make fleet-active</button>
|
|
1134
|
-
</div>
|
|
1135
|
-
</div>
|
|
1136
|
-
</div>`;
|
|
1137
|
-
document.body.insertAdjacentHTML('beforeend', html);
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
function closePromoteModal() {
|
|
1141
|
-
const el = document.getElementById('promote-backdrop');
|
|
1142
|
-
if (el) el.remove();
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
async function confirmPromote(label) {
|
|
1146
|
-
closePromoteModal();
|
|
1147
|
-
try {
|
|
1148
|
-
// RFC-H endpoint. Body carries the label (avoids URL-encoding
|
|
1149
|
-
// past the account-label charset). Response: { ok, active, fanned }.
|
|
1150
|
-
const res = await fetch(`${API}/api/auth/use`, {
|
|
1151
|
-
method: 'POST',
|
|
1152
|
-
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
|
1153
|
-
body: JSON.stringify({ account: label }),
|
|
1154
|
-
});
|
|
1155
|
-
const data = await res.json();
|
|
1156
|
-
if (!res.ok || !data.ok) {
|
|
1157
|
-
showToast(`Set fleet-active failed: ${data.error || `HTTP ${res.status}`}`, false);
|
|
1158
|
-
return;
|
|
1159
|
-
}
|
|
1160
|
-
const fanned = data.fanned || [];
|
|
1161
|
-
const msg = fanned.length > 0
|
|
1162
|
-
? `Fleet-active is now ${data.active}. Broker fanned to: ${fanned.join(', ')}.`
|
|
1163
|
-
: `Fleet-active is now ${data.active}.`;
|
|
1164
|
-
showToast(msg, true);
|
|
1165
|
-
fetchAccounts();
|
|
1166
|
-
} catch (err) {
|
|
1167
|
-
showToast(`Set fleet-active failed: ${err.message}`, false);
|
|
1168
|
-
}
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
function showToast(msg, ok) {
|
|
1172
|
-
const el = document.createElement('div');
|
|
1173
|
-
el.className = `toast ${ok ? 'ok' : 'err'}`;
|
|
1174
|
-
el.textContent = msg;
|
|
1175
|
-
document.body.appendChild(el);
|
|
1176
|
-
setTimeout(() => el.remove(), 6000);
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
function formatTimestamp(ms) {
|
|
1180
|
-
if (!ms) return '—';
|
|
1181
|
-
const d = new Date(ms);
|
|
1182
|
-
if (isNaN(d.getTime())) return '—';
|
|
1183
|
-
const diff = ms - Date.now();
|
|
1184
|
-
const abs = Math.abs(diff);
|
|
1185
|
-
const mins = Math.floor(abs / 60000);
|
|
1186
|
-
if (mins < 60) return diff < 0 ? `${mins}m ago` : `in ${mins}m`;
|
|
1187
|
-
const hrs = Math.floor(mins / 60);
|
|
1188
|
-
if (hrs < 24) return diff < 0 ? `${hrs}h ago` : `in ${hrs}h`;
|
|
1189
|
-
const days = Math.floor(hrs / 24);
|
|
1190
|
-
return diff < 0 ? `${days}d ago` : `in ${days}d`;
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
function toggleDetails(name) {
|
|
1194
|
-
if (openDetails.has(name)) {
|
|
1195
|
-
openDetails.delete(name);
|
|
1196
|
-
render();
|
|
1197
|
-
} else {
|
|
1198
|
-
openDetails.add(name);
|
|
1199
|
-
render();
|
|
1200
|
-
if (!agentDetails[name]) fetchAgentDetail(name);
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
async function toggleLogs(name) {
|
|
1205
|
-
if (openLogs.has(name)) {
|
|
1206
|
-
openLogs.delete(name);
|
|
1207
|
-
} else {
|
|
1208
|
-
openLogs.add(name);
|
|
1209
|
-
await loadLogs(name);
|
|
1210
|
-
}
|
|
1211
|
-
render();
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
async function loadLogs(name) {
|
|
1215
|
-
const el = document.getElementById(`log-output-${name}`);
|
|
1216
|
-
if (!el) return;
|
|
1217
|
-
el.textContent = 'Loading...';
|
|
1218
|
-
try {
|
|
1219
|
-
const res = await fetch(`${API}/api/agents/${encodeURIComponent(name)}/logs?lines=50`, { headers: authHeaders() });
|
|
1220
|
-
const data = await res.json();
|
|
1221
|
-
el.textContent = data.ok ? (data.logs || '(no output)') : `Error: ${data.error}`;
|
|
1222
|
-
el.scrollTop = el.scrollHeight;
|
|
1223
|
-
} catch (err) {
|
|
1224
|
-
el.textContent = `Failed: ${err.message}`;
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
async function agentAction(name, action) {
|
|
1229
|
-
try {
|
|
1230
|
-
const res = await fetch(`${API}/api/agents/${encodeURIComponent(name)}/${action}`, {
|
|
1231
|
-
method: 'POST',
|
|
1232
|
-
headers: authHeaders()
|
|
1233
|
-
});
|
|
1234
|
-
const data = await res.json();
|
|
1235
|
-
if (!data.ok) showError(`${action} ${name}: ${data.error}`);
|
|
1236
|
-
// Refresh after brief delay to let systemd settle
|
|
1237
|
-
setTimeout(fetchAgents, 1000);
|
|
1238
|
-
} catch (err) {
|
|
1239
|
-
showError(`${action} ${name}: ${err.message}`);
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
function connectWebSocket() {
|
|
1244
|
-
let wsUrl = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws`;
|
|
1245
|
-
if (TOKEN) wsUrl += `?token=${encodeURIComponent(TOKEN)}`;
|
|
1246
|
-
ws = new WebSocket(wsUrl);
|
|
1247
|
-
|
|
1248
|
-
ws.onmessage = (event) => {
|
|
1249
|
-
try {
|
|
1250
|
-
const msg = JSON.parse(event.data);
|
|
1251
|
-
if (msg.type === 'log' && msg.agent) {
|
|
1252
|
-
const el = document.getElementById(`log-output-${msg.agent}`);
|
|
1253
|
-
if (el && openLogs.has(msg.agent)) {
|
|
1254
|
-
el.textContent += msg.data;
|
|
1255
|
-
el.scrollTop = el.scrollHeight;
|
|
1256
|
-
}
|
|
1257
|
-
}
|
|
1258
|
-
} catch {}
|
|
1259
|
-
};
|
|
1260
|
-
|
|
1261
|
-
ws.onclose = () => {
|
|
1262
|
-
setTimeout(connectWebSocket, 3000);
|
|
1263
|
-
};
|
|
1264
|
-
|
|
1265
|
-
ws.onerror = () => {
|
|
1266
|
-
ws.close();
|
|
1267
|
-
};
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
// Init. Summary is the default visible tab; fetchAgents still runs
|
|
1271
|
-
// (populates the agents tab + keeps the 10s fleet poll warm + the
|
|
1272
|
-
// log WS depends on it). Summary is fetched on init + on tab-switch
|
|
1273
|
-
// only — deliberately NOT on the 10s interval (it fans out to
|
|
1274
|
-
// system-health etc.; on-demand refresh is enough).
|
|
1275
|
-
fetchSummary();
|
|
1276
|
-
fetchAgents();
|
|
1277
|
-
connectWebSocket();
|
|
1278
|
-
setInterval(fetchAgents, 10000);
|
|
1279
|
-
</script>
|
|
1280
|
-
</body>
|
|
1281
|
-
</html>
|