switchroom 0.15.26 → 0.15.28
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/dist/cli/switchroom.js +1098 -413
- package/dist/cli/ui/apple-touch-icon.png +0 -0
- package/dist/cli/ui/favicon-32.png +0 -0
- package/dist/cli/ui/favicon.ico +0 -0
- package/dist/cli/ui/index.html +685 -60
- package/dist/host-control/main.js +279 -7
- package/package.json +3 -2
- package/telegram-plugin/dist/gateway/gateway.js +21 -4
package/dist/cli/ui/index.html
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>Switchroom Fleet</title>
|
|
7
|
+
<link rel="icon" href="/favicon.ico" sizes="any">
|
|
8
|
+
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
|
|
9
|
+
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
|
7
10
|
<style>
|
|
8
11
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
12
|
|
|
@@ -227,6 +230,57 @@
|
|
|
227
230
|
font-size: 0.85rem;
|
|
228
231
|
}
|
|
229
232
|
|
|
233
|
+
/* ProblemDetail block — an actionable error/degraded state with a
|
|
234
|
+
remediation. Reuses the error-banner palette but in a muted card so it
|
|
235
|
+
reads as "here is what to do", not just "something is broken". */
|
|
236
|
+
.problem {
|
|
237
|
+
background: rgba(248,113,113,0.08);
|
|
238
|
+
border: 1px solid rgba(248,113,113,0.28);
|
|
239
|
+
border-radius: var(--radius);
|
|
240
|
+
padding: 0.75rem 1rem;
|
|
241
|
+
margin-bottom: 1rem;
|
|
242
|
+
font-size: 0.85rem;
|
|
243
|
+
}
|
|
244
|
+
.problem.muted {
|
|
245
|
+
background: var(--surface-hover);
|
|
246
|
+
border-color: var(--border);
|
|
247
|
+
}
|
|
248
|
+
.problem-title { font-weight: 600; color: var(--text); }
|
|
249
|
+
.problem.muted .problem-title { color: var(--text); }
|
|
250
|
+
.problem:not(.muted) .problem-title { color: var(--red); }
|
|
251
|
+
.problem-detail { color: var(--text-dim); margin-top: 0.25rem; }
|
|
252
|
+
.problem-remediation { margin-top: 0.55rem; }
|
|
253
|
+
.problem-remediation .rem-label { color: var(--text-dim); display: block; margin-bottom: 0.3rem; }
|
|
254
|
+
.problem-cmd {
|
|
255
|
+
display: inline-flex;
|
|
256
|
+
align-items: center;
|
|
257
|
+
gap: 0.5rem;
|
|
258
|
+
flex-wrap: wrap;
|
|
259
|
+
}
|
|
260
|
+
.problem-cmd code {
|
|
261
|
+
background: rgba(0,0,0,0.35);
|
|
262
|
+
border: 1px solid var(--border);
|
|
263
|
+
border-radius: 6px;
|
|
264
|
+
padding: 0.3rem 0.5rem;
|
|
265
|
+
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
|
|
266
|
+
font-size: 0.75rem;
|
|
267
|
+
color: var(--text);
|
|
268
|
+
user-select: all;
|
|
269
|
+
word-break: break-all;
|
|
270
|
+
}
|
|
271
|
+
.problem-copy {
|
|
272
|
+
padding: 0.25rem 0.6rem;
|
|
273
|
+
border: 1px solid var(--border);
|
|
274
|
+
border-radius: 6px;
|
|
275
|
+
background: transparent;
|
|
276
|
+
color: var(--blue);
|
|
277
|
+
font-size: 0.72rem;
|
|
278
|
+
cursor: pointer;
|
|
279
|
+
flex-shrink: 0;
|
|
280
|
+
}
|
|
281
|
+
.problem-copy:hover { background: var(--surface-hover); }
|
|
282
|
+
.problem-remediation a { color: var(--blue); }
|
|
283
|
+
|
|
230
284
|
.loading {
|
|
231
285
|
text-align: center;
|
|
232
286
|
padding: 4rem;
|
|
@@ -507,15 +561,30 @@
|
|
|
507
561
|
|
|
508
562
|
async function fetchAgentDetail(name) {
|
|
509
563
|
try {
|
|
510
|
-
const [accRes, subRes, cfgRes] = await Promise.all([
|
|
564
|
+
const [accRes, subRes, cfgRes, turnsRes] = await Promise.all([
|
|
511
565
|
fetch(`${API}/api/agents/${encodeURIComponent(name)}/accounts`, { headers: authHeaders() }),
|
|
512
566
|
fetch(`${API}/api/agents/${encodeURIComponent(name)}/subagents`, { headers: authHeaders() }),
|
|
513
567
|
fetch(`${API}/api/agents/${encodeURIComponent(name)}/config`, { headers: authHeaders() }),
|
|
568
|
+
// Recent turns — resilient: a failure leaves turns null and the
|
|
569
|
+
// other sections still render (see renderAgentDetailInner).
|
|
570
|
+
fetch(`${API}/api/agents/${encodeURIComponent(name)}/turns?limit=10`, { headers: authHeaders() }),
|
|
514
571
|
]);
|
|
572
|
+
// The /turns success response is a BARE Turn[] array (same
|
|
573
|
+
// convention as /accounts + /subagents above); only the error
|
|
574
|
+
// path sends { ok:false, error }. So read the array directly —
|
|
575
|
+
// null on a non-ok response / malformed payload.
|
|
576
|
+
let turns = null;
|
|
577
|
+
if (turnsRes.ok) {
|
|
578
|
+
try {
|
|
579
|
+
const body = await turnsRes.json();
|
|
580
|
+
turns = Array.isArray(body) ? body : null;
|
|
581
|
+
} catch { turns = null; }
|
|
582
|
+
}
|
|
515
583
|
agentDetails[name] = {
|
|
516
584
|
accounts: accRes.ok ? await accRes.json() : null,
|
|
517
585
|
subagents: subRes.ok ? await subRes.json() : null,
|
|
518
586
|
config: cfgRes.ok ? await cfgRes.json() : null,
|
|
587
|
+
turns,
|
|
519
588
|
};
|
|
520
589
|
render();
|
|
521
590
|
} catch (err) {
|
|
@@ -548,11 +617,7 @@
|
|
|
548
617
|
function renderMemoryHealth(m) {
|
|
549
618
|
const container = document.getElementById('memory');
|
|
550
619
|
if (!m.reachable) {
|
|
551
|
-
container.innerHTML =
|
|
552
|
-
<span class="status-dot inactive" style="display:inline-block;vertical-align:middle"></span>
|
|
553
|
-
<strong> Hindsight unreachable</strong>
|
|
554
|
-
<div style="color:var(--text-dim);margin-top:.5rem">${escapeHtml(m.url || '')} is not serving — agent memory (recall, retain, mental models) is down.</div>
|
|
555
|
-
</div>`;
|
|
620
|
+
container.innerHTML = renderProblem(problemFor('hindsight-down', { url: m.url }));
|
|
556
621
|
return;
|
|
557
622
|
}
|
|
558
623
|
const statusDot = (s) => `<span class="status-dot ${s === 'ok' ? 'active' : s === 'warn' ? 'auth-warning' : 'inactive'}" style="display:inline-block;vertical-align:middle"></span>`;
|
|
@@ -563,7 +628,16 @@
|
|
|
563
628
|
if (isNaN(d)) return '';
|
|
564
629
|
return d < 1 ? 'today' : `${Math.round(d)}d ago`;
|
|
565
630
|
};
|
|
631
|
+
// A bank "has a user-profile model" if any mental model is named
|
|
632
|
+
// like user-profile (mirrors hindsight's exact-name check, but
|
|
633
|
+
// tolerant of the "User Profile" display variant for the UI hint).
|
|
634
|
+
const hasUserProfile = (b) => (b.mentalModels || []).some(mm =>
|
|
635
|
+
/^user[- ]?profile$/i.test(String(mm.name || '')));
|
|
636
|
+
// JSON for a single-quoted onclick attribute: escape the quote
|
|
637
|
+
// chars that could break out of the attribute (', &, <, >).
|
|
638
|
+
const attrJson = (v) => escapeHtml(JSON.stringify(v));
|
|
566
639
|
const cards = (m.banks || []).map(b => {
|
|
640
|
+
const bankJs = attrJson(b.bank);
|
|
567
641
|
const models = (b.mentalModels || []).map(mm => {
|
|
568
642
|
const ts = mm.lastRefreshedAt || mm.createdAt;
|
|
569
643
|
const stale = ts && (Date.now() - Date.parse(ts)) > 7 * 86400000;
|
|
@@ -575,6 +649,38 @@
|
|
|
575
649
|
const corruptLine = (b.corruptedMentalModelNames || []).length > 0
|
|
576
650
|
? `<div style="color:var(--red);margin-top:.4rem">⚠ corrupted mental model(s): ${escapeHtml(b.corruptedMentalModelNames.join(', '))} — content is an LLM failure message; refresh once quota recovers</div>`
|
|
577
651
|
: '';
|
|
652
|
+
// --- Remediation buttons (audit ranks 7 + 22) ---
|
|
653
|
+
// Each triggers an HTTP poke at hindsight; hindsight does the LLM
|
|
654
|
+
// work on its own provider — the dashboard makes NO model call.
|
|
655
|
+
const buttons = [];
|
|
656
|
+
// Reprocess the extraction-gap docs.
|
|
657
|
+
if (b.recentUnextractedCount > 0) {
|
|
658
|
+
const n = Math.min(b.recentUnextractedCount, (b.unextractedDocIds || []).length) || b.recentUnextractedCount;
|
|
659
|
+
buttons.push(`<button class="btn" type="button" onclick='memReprocess(${bankJs}, this)'>Reprocess ${n} doc${n === 1 ? '' : 's'}</button>`);
|
|
660
|
+
}
|
|
661
|
+
// Refresh each corrupted/stale model (map corrupted NAME → id).
|
|
662
|
+
const affectedIds = new Set();
|
|
663
|
+
for (const name of (b.corruptedMentalModelNames || [])) {
|
|
664
|
+
const mm = (b.mentalModels || []).find(x => x.name === name);
|
|
665
|
+
if (mm && mm.id) affectedIds.add(mm.id);
|
|
666
|
+
}
|
|
667
|
+
for (const mm of (b.mentalModels || [])) {
|
|
668
|
+
const ts = mm.lastRefreshedAt || mm.createdAt;
|
|
669
|
+
const stale = ts && (Date.now() - Date.parse(ts)) > 7 * 86400000;
|
|
670
|
+
if (stale && mm.id) affectedIds.add(mm.id);
|
|
671
|
+
}
|
|
672
|
+
for (const mm of (b.mentalModels || [])) {
|
|
673
|
+
if (!affectedIds.has(mm.id)) continue;
|
|
674
|
+
const idJs = attrJson(mm.id), nameJs = attrJson(mm.name);
|
|
675
|
+
buttons.push(`<button class="btn" type="button" onclick='memRefreshModel(${bankJs}, ${idJs}, ${nameJs}, this)'>Refresh ${escapeHtml(mm.name)}</button>`);
|
|
676
|
+
}
|
|
677
|
+
// Build the user-profile model when the bank has data but no profile.
|
|
678
|
+
if (b.totalDocuments > 0 && !hasUserProfile(b)) {
|
|
679
|
+
buttons.push(`<button class="btn" type="button" onclick='memBuildProfile(${bankJs}, this)'>Build profile</button>`);
|
|
680
|
+
}
|
|
681
|
+
const buttonRow = buttons.length
|
|
682
|
+
? `<div class="rem-actions" style="margin-top:.6rem;display:flex;flex-wrap:wrap;gap:.4rem">${buttons.join('')}</div>`
|
|
683
|
+
: '';
|
|
578
684
|
return `<div class="agent-card">
|
|
579
685
|
<div class="card-header" style="cursor:default">
|
|
580
686
|
${statusDot(b.status)}<span class="agent-name">${escapeHtml(b.bank)}</span>
|
|
@@ -591,12 +697,59 @@
|
|
|
591
697
|
${models ? `<div class="card-meta" style="padding:.4rem 0 0">${models}</div>` : ''}
|
|
592
698
|
${corruptLine}
|
|
593
699
|
${gapLine}
|
|
700
|
+
${buttonRow}
|
|
594
701
|
</div>
|
|
595
702
|
</div>`;
|
|
596
703
|
}).join('');
|
|
597
704
|
container.innerHTML = `<div class="agents-grid">${cards || '<div style="color:var(--text-dim)">No agent banks configured.</div>'}</div>`;
|
|
598
705
|
}
|
|
599
706
|
|
|
707
|
+
// --- Memory remediation actions ---
|
|
708
|
+
// Each pokes a hindsight REST/MCP endpoint via the web's POST routes.
|
|
709
|
+
// Hindsight does the LLM extraction on its OWN claude-code provider;
|
|
710
|
+
// the dashboard never calls a model. A confirm() gates each because
|
|
711
|
+
// it consumes subscription quota (low-risk, but explicit).
|
|
712
|
+
const REM_CONFIRM = 'This triggers memory re-processing on the subscription. Continue?';
|
|
713
|
+
|
|
714
|
+
// `label` names the action for the failure toast; `successMsg(data)`
|
|
715
|
+
// builds the success toast from the response body.
|
|
716
|
+
async function memRemediate(btn, url, payload, working, label, successMsg) {
|
|
717
|
+
if (!confirm(REM_CONFIRM)) return;
|
|
718
|
+
const orig = btn ? btn.textContent : '';
|
|
719
|
+
if (btn) { btn.disabled = true; btn.textContent = working; }
|
|
720
|
+
try {
|
|
721
|
+
const res = await fetch(`${API}${url}`, {
|
|
722
|
+
method: 'POST',
|
|
723
|
+
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
|
724
|
+
body: JSON.stringify(payload),
|
|
725
|
+
});
|
|
726
|
+
const data = await res.json().catch(() => ({}));
|
|
727
|
+
if (!res.ok || !data.ok) {
|
|
728
|
+
showToast(`${label} failed: ${data.error || `HTTP ${res.status}`}`, false);
|
|
729
|
+
} else {
|
|
730
|
+
showToast(successMsg(data), true);
|
|
731
|
+
}
|
|
732
|
+
} catch (err) {
|
|
733
|
+
showToast(`${label} failed: ${err.message}`, false);
|
|
734
|
+
} finally {
|
|
735
|
+
if (btn) { btn.disabled = false; btn.textContent = orig; }
|
|
736
|
+
fetchMemoryHealth();
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function memReprocess(bank, btn) {
|
|
741
|
+
memRemediate(btn, '/api/memory/reprocess', { bank }, 'Reprocessing…', 'Reprocess',
|
|
742
|
+
(d) => `Reprocess triggered (${d.triggered ?? 0} doc${(d.triggered ?? 0) === 1 ? '' : 's'}${d.failed ? `, ${d.failed} failed` : ''})`);
|
|
743
|
+
}
|
|
744
|
+
function memRefreshModel(bank, modelId, name, btn) {
|
|
745
|
+
memRemediate(btn, '/api/memory/refresh-model', { bank, modelId }, 'Refreshing…', 'Refresh',
|
|
746
|
+
() => `Refresh triggered for ${name}`);
|
|
747
|
+
}
|
|
748
|
+
function memBuildProfile(bank, btn) {
|
|
749
|
+
memRemediate(btn, '/api/memory/build-profile', { bank }, 'Building…', 'Build profile',
|
|
750
|
+
() => 'Profile build triggered');
|
|
751
|
+
}
|
|
752
|
+
|
|
600
753
|
async function fetchConnections() {
|
|
601
754
|
// Each fetch falls back independently (.catch → default). A single
|
|
602
755
|
// network blip — e.g. one endpoint momentarily unreachable — must NOT
|
|
@@ -783,10 +936,21 @@
|
|
|
783
936
|
}
|
|
784
937
|
|
|
785
938
|
async function fetchApprovals() {
|
|
939
|
+
// Fetch the kernel decision ledger AND the standing capability grants
|
|
940
|
+
// in parallel, INDEPENDENTLY: a failure of one must not blank the
|
|
941
|
+
// other (mirror the resilient `safe` pattern in fetchConnections).
|
|
942
|
+
// Standing grants (the broker grants DB) are the section that's
|
|
943
|
+
// actually useful — the kernel ledger is honestly empty on most
|
|
944
|
+
// installs.
|
|
945
|
+
const safe = (p, fallback) =>
|
|
946
|
+
p.then(r => r.ok ? r.json() : fallback).catch(() => fallback);
|
|
786
947
|
try {
|
|
787
|
-
const
|
|
788
|
-
|
|
789
|
-
|
|
948
|
+
const [approvals, grants] = await Promise.all([
|
|
949
|
+
safe(fetch(`${API}/api/approvals`, { headers: authHeaders() }), null),
|
|
950
|
+
safe(fetch(`${API}/api/grants`, { headers: authHeaders() }),
|
|
951
|
+
{ reachable: false, grants: [], error: 'Failed to fetch standing grants.' }),
|
|
952
|
+
]);
|
|
953
|
+
renderApprovals(approvals, grants);
|
|
790
954
|
clearError();
|
|
791
955
|
} catch (err) {
|
|
792
956
|
showError(`Failed to fetch approvals: ${err.message}`);
|
|
@@ -808,22 +972,29 @@
|
|
|
808
972
|
if (tab === 'approvals') fetchApprovals();
|
|
809
973
|
}
|
|
810
974
|
|
|
811
|
-
// Fleet overview —
|
|
812
|
-
// (
|
|
813
|
-
//
|
|
814
|
-
// independently
|
|
975
|
+
// Fleet overview — ONE round-trip to the server-side aggregate
|
|
976
|
+
// (/api/summary), which pulls each part through the same per-tab
|
|
977
|
+
// cache so a Summary open warms the tabs and they can never disagree.
|
|
978
|
+
// Each tile still degrades independently: the server nulls any part
|
|
979
|
+
// whose producer threw, and a whole-fetch failure leaves the prior
|
|
980
|
+
// render in place.
|
|
815
981
|
async function fetchSummary() {
|
|
816
982
|
const el = document.getElementById('summary');
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
983
|
+
let summary;
|
|
984
|
+
try {
|
|
985
|
+
const r = await fetch(`${API}/api/summary`, { headers: authHeaders() });
|
|
986
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
987
|
+
summary = await r.json();
|
|
988
|
+
} catch (err) {
|
|
989
|
+
// Degraded: keep whatever's already rendered (don't blank the
|
|
990
|
+
// tab on a transient blip) and surface the error.
|
|
991
|
+
el.classList.remove('loading');
|
|
992
|
+
showError(`Failed to fetch summary: ${err.message}`);
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
const ag = summary.agents, sys = summary.systemHealth,
|
|
996
|
+
appr = summary.approvals, sched = summary.schedule,
|
|
997
|
+
accts = summary.accounts;
|
|
827
998
|
clearError();
|
|
828
999
|
const tile = (title, body, ok) => `
|
|
829
1000
|
<div class="agent-card" style="min-width:220px">
|
|
@@ -899,7 +1070,37 @@
|
|
|
899
1070
|
} else quotaTile = tile('Quota', dim('unavailable'), null);
|
|
900
1071
|
|
|
901
1072
|
el.classList.remove('loading');
|
|
902
|
-
|
|
1073
|
+
// "data as of" — the summary's freshness is its OLDEST part (server
|
|
1074
|
+
// sends the min dataAsOf). A tiny muted line; refreshed on every
|
|
1075
|
+
// tab-open. `formatTimestamp` renders it as "Ns/Nm ago".
|
|
1076
|
+
const asOf = (typeof summary.dataAsOf === 'number' && summary.dataAsOf > 0)
|
|
1077
|
+
? `<div style="color:var(--text-dim);font-size:.75rem;margin:.25rem 0 .5rem">data as of ${escapeHtml(formatTimestamp(summary.dataAsOf))}</div>`
|
|
1078
|
+
: '';
|
|
1079
|
+
el.innerHTML = `${renderAttention(summary.attention)}${asOf}<div class="agents-grid">${agentsTile}${brokerTile}${hsTile}${hostdTile}${apprTile}${schedTile}${quotaTile}</div>`;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// "Needs attention" triage strip — the FIRST block on the Summary tab.
|
|
1083
|
+
// The server derives `attention: AttentionItem[]` (severity-sorted,
|
|
1084
|
+
// capped) from the parts the summary already carries; each item is
|
|
1085
|
+
// clickable → switchTab(item.tab). Reuses the .problem styling
|
|
1086
|
+
// (red title for critical/warn, muted for info). Empty → a calm
|
|
1087
|
+
// "nothing needs attention" line. Every interpolated value is escaped.
|
|
1088
|
+
function renderAttention(items) {
|
|
1089
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
1090
|
+
return `<div class="problem muted" style="margin-bottom:.75rem"><div class="problem-title">✓ Nothing needs attention</div></div>`;
|
|
1091
|
+
}
|
|
1092
|
+
const rows = items.map((it) => {
|
|
1093
|
+
const sev = (it && it.severity) || 'info';
|
|
1094
|
+
const muted = sev === 'info' ? ' muted' : '';
|
|
1095
|
+
const tab = String((it && it.tab) || 'summary');
|
|
1096
|
+
const title = `<div class="problem-title">${escapeHtml((it && it.title) || 'Attention')}</div>`;
|
|
1097
|
+
const detail = it && it.detail
|
|
1098
|
+
? `<div class="problem-detail">${escapeHtml(it.detail)}</div>`
|
|
1099
|
+
: '';
|
|
1100
|
+
// Whole row clickable → jump to the tab to act on it.
|
|
1101
|
+
return `<div class="problem${muted}" role="button" tabindex="0" style="cursor:pointer;margin-bottom:.4rem" onclick="switchTab('${escapeHtml(tab)}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();switchTab('${escapeHtml(tab)}')}">${title}${detail}</div>`;
|
|
1102
|
+
}).join('');
|
|
1103
|
+
return `<div style="margin-bottom:.75rem">${rows}</div>`;
|
|
903
1104
|
}
|
|
904
1105
|
|
|
905
1106
|
function showError(msg) {
|
|
@@ -922,6 +1123,206 @@
|
|
|
922
1123
|
}[c]));
|
|
923
1124
|
}
|
|
924
1125
|
|
|
1126
|
+
// ───────────────────────────────────────────────────────────────────
|
|
1127
|
+
// ProblemDetail — actionable error/degraded rendering.
|
|
1128
|
+
//
|
|
1129
|
+
// A ProblemDetail is `{ title, detail?, remediation? }` where
|
|
1130
|
+
// `remediation = { kind, label, value }` and kind ∈
|
|
1131
|
+
// 'command' | 'link' | 'telegram' | 'none'. renderProblem() turns it into
|
|
1132
|
+
// a styled block; problemFor() is a small catalog mapping a KNOWN
|
|
1133
|
+
// condition → a ProblemDetail (with the next-step command baked in).
|
|
1134
|
+
//
|
|
1135
|
+
// XSS: every server-derived string (title/detail/error text) goes through
|
|
1136
|
+
// escapeHtml. Remediation `value` for 'command' is always a static
|
|
1137
|
+
// literal from the catalog, but it's escaped anyway for defence in depth.
|
|
1138
|
+
// ───────────────────────────────────────────────────────────────────
|
|
1139
|
+
|
|
1140
|
+
// Copy a command to the clipboard. Degrades gracefully where the
|
|
1141
|
+
// Clipboard API is unavailable (insecure context / old browser): the
|
|
1142
|
+
// <code> is `user-select:all` so the operator can still copy by hand.
|
|
1143
|
+
function copyProblemCmd(btn) {
|
|
1144
|
+
const text = btn && btn.getAttribute('data-copy');
|
|
1145
|
+
if (text == null) return;
|
|
1146
|
+
const flash = (label) => {
|
|
1147
|
+
const prev = btn.textContent;
|
|
1148
|
+
btn.textContent = label;
|
|
1149
|
+
setTimeout(() => { btn.textContent = prev; }, 1200);
|
|
1150
|
+
};
|
|
1151
|
+
try {
|
|
1152
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
1153
|
+
navigator.clipboard.writeText(text).then(() => flash('Copied'), () => flash('Copy failed'));
|
|
1154
|
+
} else {
|
|
1155
|
+
flash('Select & copy');
|
|
1156
|
+
}
|
|
1157
|
+
} catch {
|
|
1158
|
+
flash('Select & copy');
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Allowlist link schemes for remediation hrefs. escapeHtml prevents
|
|
1163
|
+
// attribute breakout but NOT a `javascript:`/`data:` scheme; today no
|
|
1164
|
+
// catalog entry uses a server-derived href, but guard it so a future
|
|
1165
|
+
// `link`/`telegram` remediation can never become an injection vector.
|
|
1166
|
+
function safeHref(url) {
|
|
1167
|
+
const u = String(url || '').trim();
|
|
1168
|
+
return /^(https?:|tg:|mailto:)/i.test(u) ? u : '#';
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function renderProblem(problem) {
|
|
1172
|
+
if (!problem) return '';
|
|
1173
|
+
const muted = problem.muted ? ' muted' : '';
|
|
1174
|
+
const title = `<div class="problem-title">${escapeHtml(problem.title || 'Problem')}</div>`;
|
|
1175
|
+
const detail = problem.detail
|
|
1176
|
+
? `<div class="problem-detail">${escapeHtml(problem.detail)}</div>`
|
|
1177
|
+
: '';
|
|
1178
|
+
let rem = '';
|
|
1179
|
+
const r = problem.remediation;
|
|
1180
|
+
if (r && r.kind && r.kind !== 'none') {
|
|
1181
|
+
if (r.kind === 'command') {
|
|
1182
|
+
const cmd = escapeHtml(r.value || '');
|
|
1183
|
+
// data-copy holds the RAW (unescaped) command for clipboard write;
|
|
1184
|
+
// attribute context is still escaped via escapeHtml so the value
|
|
1185
|
+
// can't break out of the attribute.
|
|
1186
|
+
rem = `<div class="problem-remediation">
|
|
1187
|
+
${r.label ? `<span class="rem-label">${escapeHtml(r.label)}</span>` : ''}
|
|
1188
|
+
<span class="problem-cmd"><code>${cmd}</code><button class="problem-copy" type="button" data-copy="${escapeHtml(r.value || '')}" onclick="copyProblemCmd(this)">Copy</button></span>
|
|
1189
|
+
</div>`;
|
|
1190
|
+
} else if (r.kind === 'link') {
|
|
1191
|
+
rem = `<div class="problem-remediation">
|
|
1192
|
+
<a href="${escapeHtml(safeHref(r.value))}" target="_blank" rel="noopener">${escapeHtml(r.label || r.value || 'Open')}</a>
|
|
1193
|
+
</div>`;
|
|
1194
|
+
} else if (r.kind === 'telegram') {
|
|
1195
|
+
const note = r.value
|
|
1196
|
+
? `<a href="${escapeHtml(safeHref(r.value))}" target="_blank" rel="noopener">${escapeHtml(r.label || 'Act in Telegram')}</a>`
|
|
1197
|
+
: escapeHtml(r.label || 'Act from Telegram (no model call is made from the dashboard).');
|
|
1198
|
+
rem = `<div class="problem-remediation">${note}</div>`;
|
|
1199
|
+
}
|
|
1200
|
+
} else if (r && r.label) {
|
|
1201
|
+
// kind:'none' but a label hint was supplied (e.g. a "Refresh" nudge).
|
|
1202
|
+
rem = `<div class="problem-remediation"><span class="rem-label">${escapeHtml(r.label)}</span></div>`;
|
|
1203
|
+
}
|
|
1204
|
+
return `<div class="problem${muted}">${title}${detail}${rem}</div>`;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// Catalog: known condition → ProblemDetail. Keep small + readable. Error
|
|
1208
|
+
// strings are pattern-matched defensively (case-insensitive, optional).
|
|
1209
|
+
function problemFor(kind, ctx) {
|
|
1210
|
+
ctx = ctx || {};
|
|
1211
|
+
switch (kind) {
|
|
1212
|
+
case 'hindsight-down':
|
|
1213
|
+
return {
|
|
1214
|
+
title: 'Hindsight (agent memory) is not serving',
|
|
1215
|
+
detail: (ctx.url ? `${ctx.url} is not responding. ` : '') +
|
|
1216
|
+
'Agent memory — recall, retain, mental models — is down until it recovers.',
|
|
1217
|
+
remediation: {
|
|
1218
|
+
kind: 'command',
|
|
1219
|
+
label: 'Run on the host:',
|
|
1220
|
+
value: 'switchroom memory setup --stop && switchroom memory setup',
|
|
1221
|
+
},
|
|
1222
|
+
};
|
|
1223
|
+
case 'broker-socket-absent':
|
|
1224
|
+
return {
|
|
1225
|
+
title: 'Auth-broker socket not mounted into the dashboard',
|
|
1226
|
+
detail: (ctx.error ? `${ctx.error} ` : '') +
|
|
1227
|
+
'The web container has no broker socket — re-mount it so the dashboard can read live account state.',
|
|
1228
|
+
remediation: {
|
|
1229
|
+
kind: 'command',
|
|
1230
|
+
label: 'Re-mount on the host:',
|
|
1231
|
+
value: 'switchroom web install',
|
|
1232
|
+
},
|
|
1233
|
+
};
|
|
1234
|
+
case 'broker-down':
|
|
1235
|
+
return {
|
|
1236
|
+
title: 'Auth-broker unreachable',
|
|
1237
|
+
detail: (ctx.error ? `${ctx.error} ` : '') +
|
|
1238
|
+
'The socket is present but the broker is not answering.',
|
|
1239
|
+
remediation: {
|
|
1240
|
+
kind: 'command',
|
|
1241
|
+
label: 'Check broker status on the host:',
|
|
1242
|
+
value: 'switchroom vault broker status',
|
|
1243
|
+
},
|
|
1244
|
+
};
|
|
1245
|
+
case 'hostd-no-audit':
|
|
1246
|
+
return {
|
|
1247
|
+
muted: true,
|
|
1248
|
+
title: 'hostd has handled no privileged verbs yet',
|
|
1249
|
+
detail: 'This is informational — the audit log is empty because no privileged operation has run, not because hostd is broken.',
|
|
1250
|
+
remediation: { kind: 'none' },
|
|
1251
|
+
};
|
|
1252
|
+
case 'schedule-degraded':
|
|
1253
|
+
return {
|
|
1254
|
+
title: 'Schedule view is incomplete',
|
|
1255
|
+
detail: ctx.reason
|
|
1256
|
+
? `${ctx.reason} Showing the base-config-only view (per-agent cascade overrides may be missing).`
|
|
1257
|
+
: 'hostd is unreachable — showing the base-config-only view (per-agent cascade overrides may be missing).',
|
|
1258
|
+
remediation: {
|
|
1259
|
+
kind: 'command',
|
|
1260
|
+
label: 'Diagnose on the host:',
|
|
1261
|
+
value: 'switchroom doctor',
|
|
1262
|
+
},
|
|
1263
|
+
};
|
|
1264
|
+
case 'approvals-socket-absent':
|
|
1265
|
+
return {
|
|
1266
|
+
title: 'Approval-kernel operator socket not present',
|
|
1267
|
+
detail: (ctx.error ? `${ctx.error} ` : '') +
|
|
1268
|
+
'The dashboard has no operator socket to read the approval ledger — re-apply to (re)create it.',
|
|
1269
|
+
remediation: {
|
|
1270
|
+
kind: 'command',
|
|
1271
|
+
label: 'Run on the host:',
|
|
1272
|
+
value: 'switchroom apply',
|
|
1273
|
+
},
|
|
1274
|
+
};
|
|
1275
|
+
case 'approvals-kernel-down':
|
|
1276
|
+
return {
|
|
1277
|
+
title: 'Approval-kernel unreachable',
|
|
1278
|
+
detail: (ctx.error ? `${ctx.error} ` : '') +
|
|
1279
|
+
'The kernel socket is present but the kernel is not answering.',
|
|
1280
|
+
remediation: {
|
|
1281
|
+
kind: 'command',
|
|
1282
|
+
label: 'Check the singletons on the host:',
|
|
1283
|
+
value: 'docker compose -p switchroom ps',
|
|
1284
|
+
},
|
|
1285
|
+
};
|
|
1286
|
+
case 'grants-unreachable':
|
|
1287
|
+
return {
|
|
1288
|
+
title: 'Standing capability grants unavailable',
|
|
1289
|
+
detail: (ctx.error ? `${ctx.error} ` : '') +
|
|
1290
|
+
'The vault-broker (which owns the grants DB) is not host-reachable, so the operator\'s standing grants can\'t be listed.',
|
|
1291
|
+
remediation: {
|
|
1292
|
+
kind: 'command',
|
|
1293
|
+
label: 'Check broker status on the host:',
|
|
1294
|
+
value: 'switchroom vault broker status',
|
|
1295
|
+
},
|
|
1296
|
+
};
|
|
1297
|
+
case 'connections-degraded':
|
|
1298
|
+
return {
|
|
1299
|
+
muted: true,
|
|
1300
|
+
title: 'Live broker data unavailable — showing configured accounts only',
|
|
1301
|
+
detail: 'These accounts are from the config; the broker did not return live slot state, so connection status may be stale.',
|
|
1302
|
+
remediation: { kind: 'none', label: 'Refresh the tab once the broker is reachable to see live status.' },
|
|
1303
|
+
};
|
|
1304
|
+
default:
|
|
1305
|
+
return { title: String(kind || 'Problem'), remediation: { kind: 'none' } };
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// Classify an auth-broker error string into the right catalog condition.
|
|
1310
|
+
// "socket file not found" / ENOENT / "no such file" ⇒ socket not mounted;
|
|
1311
|
+
// anything else (connection refused, etc.) ⇒ broker is down.
|
|
1312
|
+
function brokerProblem(error) {
|
|
1313
|
+
return /not found|enoent|no such file/i.test(String(error || ''))
|
|
1314
|
+
? problemFor('broker-socket-absent', { error })
|
|
1315
|
+
: problemFor('broker-down', { error });
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// Classify an approvals error string. "operator socket not present" /
|
|
1319
|
+
// operatorUid ⇒ socket absent (apply); otherwise kernel is down.
|
|
1320
|
+
function approvalsProblem(error) {
|
|
1321
|
+
return /not present|operatoruid/i.test(String(error || ''))
|
|
1322
|
+
? problemFor('approvals-socket-absent', { error })
|
|
1323
|
+
: problemFor('approvals-kernel-down', { error });
|
|
1324
|
+
}
|
|
1325
|
+
|
|
925
1326
|
function statusClass(agent) {
|
|
926
1327
|
if (agent.active === 'active') {
|
|
927
1328
|
if (agent.auth && agent.auth.expiresAt) {
|
|
@@ -958,7 +1359,7 @@
|
|
|
958
1359
|
container.innerHTML = agents.map(a => `
|
|
959
1360
|
<div class="agent-card" data-agent="${escapeHtml(a.name)}">
|
|
960
1361
|
<div class="card-header" onclick="toggleLogs('${escapeHtml(a.name)}')">
|
|
961
|
-
<div class="status-dot ${statusClass(a)}" title="${escapeHtml(a.active)}"></div>
|
|
1362
|
+
<div class="status-dot ${statusClass(a)}" title="${escapeHtml(a.active)} · bridge heartbeat (not a claude-liveness signal)"></div>
|
|
962
1363
|
<div class="agent-name">${escapeHtml(a.name)}</div>
|
|
963
1364
|
<div class="agent-topic">${a.topic_emoji ? escapeHtml(a.topic_emoji) + ' ' : ''}${escapeHtml(a.topic_name)}</div>
|
|
964
1365
|
</div>
|
|
@@ -966,6 +1367,7 @@
|
|
|
966
1367
|
<div class="meta-item"><label>Status </label><span>${escapeHtml(a.active)}</span></div>
|
|
967
1368
|
<div class="meta-item"><label>Uptime </label><span>${formatUptime(a.uptime)}</span></div>
|
|
968
1369
|
<div class="meta-item"><label>Mem </label><span>${a.memory || '--'}</span></div>
|
|
1370
|
+
<div class="meta-item"><label>Last turn </label><span>${a.lastTurnAt ? formatTimestamp(a.lastTurnAt) : '—'}</span></div>
|
|
969
1371
|
<div class="meta-item"><label>Profile </label><span>${escapeHtml(a.extends)}</span></div>
|
|
970
1372
|
<div class="meta-item"><label>Auth </label><span>${a.auth.authenticated ? '✓' : '✗'}</span></div>
|
|
971
1373
|
<div class="meta-item"><label>Account </label><span>${a.primaryAccount ? escapeHtml(a.primaryAccount) : '<span style="color:var(--text-dim)">default</span>'}</span></div>
|
|
@@ -992,23 +1394,39 @@
|
|
|
992
1394
|
}
|
|
993
1395
|
|
|
994
1396
|
function renderAgentDetail(name) {
|
|
1397
|
+
// Wrapped so a single malformed detail can NEVER throw into render()'s
|
|
1398
|
+
// agents.map and blank the whole grid. (The accounts.assigned crash this
|
|
1399
|
+
// replaces did exactly that: it read a field the API renamed to
|
|
1400
|
+
// {active, details} post-RFC-H, threw a TypeError on every open panel,
|
|
1401
|
+
// and aborted the map → the entire Agents tab went blank on each poll.)
|
|
1402
|
+
try {
|
|
1403
|
+
return renderAgentDetailInner(name);
|
|
1404
|
+
} catch (err) {
|
|
1405
|
+
return `<div style="color:var(--red)">Detail render failed: ${escapeHtml((err && err.message) || String(err))}</div>`;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function renderAgentDetailInner(name) {
|
|
995
1410
|
const d = agentDetails[name];
|
|
996
1411
|
if (!d) return '<div style="color:var(--text-dim)">Loading…</div>';
|
|
997
1412
|
const accounts = d.accounts;
|
|
998
1413
|
const subagents = d.subagents;
|
|
999
1414
|
const config = d.config;
|
|
1000
1415
|
|
|
1416
|
+
// handleGetAgentAccounts returns { active: string|null, details: AccountInfo[] }
|
|
1417
|
+
// — the single bound account (fleet-active or per-agent auth.override),
|
|
1418
|
+
// NOT the pre-RFC-H assigned[] list.
|
|
1001
1419
|
let accountsHtml;
|
|
1002
|
-
|
|
1003
|
-
|
|
1420
|
+
const activeLabel = accounts && accounts.active;
|
|
1421
|
+
if (!activeLabel) {
|
|
1422
|
+
accountsHtml = '<div style="color:var(--text-dim)">No per-agent account override — uses the fleet-active account.</div>';
|
|
1004
1423
|
} else {
|
|
1005
|
-
const
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
}).join('');
|
|
1424
|
+
const info = (accounts.details || []).find(x => x && x.label === activeLabel);
|
|
1425
|
+
if (!info) {
|
|
1426
|
+
accountsHtml = `<span class="chip missing" title="bound account not found in ~/.switchroom/accounts/">${escapeHtml(activeLabel)} · missing</span>`;
|
|
1427
|
+
} else {
|
|
1428
|
+
accountsHtml = `<span class="chip"><span class="health-badge ${escapeHtml(info.health)}" style="margin-right:0.4rem">${escapeHtml(info.health)}</span>${escapeHtml(activeLabel)} · bound</span>`;
|
|
1429
|
+
}
|
|
1012
1430
|
}
|
|
1013
1431
|
|
|
1014
1432
|
let subHtml;
|
|
@@ -1018,11 +1436,37 @@
|
|
|
1018
1436
|
subHtml = subagents.map(s => `<span class="chip">${escapeHtml(s.name || s.id || '?')}${s.status ? ' · ' + escapeHtml(s.status) : ''}</span>`).join('');
|
|
1019
1437
|
}
|
|
1020
1438
|
|
|
1439
|
+
// Recent turns — each Turn carries `started_at` (unix ms),
|
|
1440
|
+
// `ended_at` (null while in-flight), and `user_prompt_preview` (the
|
|
1441
|
+
// first ~200 chars of the user message). We render the start time,
|
|
1442
|
+
// an ended/in-flight status, and a truncated prompt. A null `turns`
|
|
1443
|
+
// (fetch failed) renders nothing different from "no turns" — the
|
|
1444
|
+
// other sections are unaffected either way.
|
|
1445
|
+
const turns = d.turns;
|
|
1446
|
+
let turnsHtml;
|
|
1447
|
+
if (!Array.isArray(turns) || turns.length === 0) {
|
|
1448
|
+
turnsHtml = '<div style="color:var(--text-dim)">No turns recorded.</div>';
|
|
1449
|
+
} else {
|
|
1450
|
+
turnsHtml = turns.map(t => {
|
|
1451
|
+
const when = escapeHtml(formatTimestamp(t.started_at));
|
|
1452
|
+
const status = t.ended_at ? 'ended' : 'in-flight';
|
|
1453
|
+
const raw = (t.user_prompt_preview || '').replace(/\s+/g, ' ').trim();
|
|
1454
|
+
const summary = raw
|
|
1455
|
+
? escapeHtml(raw.length > 80 ? raw.slice(0, 80) + '…' : raw)
|
|
1456
|
+
: '<span style="color:var(--text-dim)">(no prompt)</span>';
|
|
1457
|
+
return `<div style="margin-bottom:.4rem">
|
|
1458
|
+
<span style="color:var(--text-dim);font-size:.8rem">${when} · ${escapeHtml(status)}</span><br>
|
|
1459
|
+
<span>${summary}</span>
|
|
1460
|
+
</div>`;
|
|
1461
|
+
}).join('');
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1021
1464
|
const configText = config ? JSON.stringify(config, null, 2) : '(unavailable)';
|
|
1022
1465
|
|
|
1023
1466
|
return `
|
|
1024
|
-
<h4>
|
|
1467
|
+
<h4>Account</h4>${accountsHtml}
|
|
1025
1468
|
<h4>Sub-agents</h4>${subHtml}
|
|
1469
|
+
<h4>Recent turns</h4>${turnsHtml}
|
|
1026
1470
|
<h4>Resolved config</h4><pre class="config-pre">${escapeHtml(configText)}</pre>
|
|
1027
1471
|
`;
|
|
1028
1472
|
}
|
|
@@ -1071,7 +1515,7 @@
|
|
|
1071
1515
|
fiveH: pctCell(u ? u.fiveHourPct : null, '5h', a.quotaStale, u ? u.fiveHourResetAt : null),
|
|
1072
1516
|
sevenD: pctCell(u ? u.sevenDayPct : null, '7d', a.quotaStale, u ? u.sevenDayResetAt : null),
|
|
1073
1517
|
captured: u && u.capturedAt
|
|
1074
|
-
? formatTimestamp(u.capturedAt)
|
|
1518
|
+
? `${formatTimestamp(u.capturedAt)}${a.quotaUsageSource === 'broker-cache' ? '<div style="font-size:.7rem;color:var(--text-dim)">broker cache (free)</div>' : ''}`
|
|
1075
1519
|
: '<span style="color:var(--text-dim)">never</span>',
|
|
1076
1520
|
exhaustedCell: q == null
|
|
1077
1521
|
? '<span style="color:var(--text-dim)">broker offline</span>'
|
|
@@ -1202,41 +1646,74 @@
|
|
|
1202
1646
|
<div class="meta-item"><label>Agents </label><span>${b.agents ?? '—'}</span></div>
|
|
1203
1647
|
<div class="meta-item"><label>Consumers </label><span>${b.consumers ?? '—'}</span></div>
|
|
1204
1648
|
</div>`
|
|
1205
|
-
:
|
|
1649
|
+
: renderProblem(brokerProblem(b.error));
|
|
1206
1650
|
|
|
1207
1651
|
const statelessCell = hs.mcpStateless == null
|
|
1208
1652
|
? dim('unknown')
|
|
1209
1653
|
: hs.mcpStateless ? 'stateless' : '<span style="color:var(--yellow)">stateful</span>';
|
|
1210
|
-
const
|
|
1654
|
+
const hindsightMeta = `
|
|
1211
1655
|
<div class="card-meta" style="padding:0">
|
|
1212
1656
|
<div class="meta-item"><label>Container </label><span>${hs.containerStatus ? escapeHtml(hs.containerStatus) : dim('absent')}</span></div>
|
|
1213
1657
|
<div class="meta-item"><label>Model </label><span>${hs.model ? escapeHtml(hs.model) : dim('unknown')}</span></div>
|
|
1214
1658
|
<div class="meta-item"><label>Provider </label><span>${hs.provider ? escapeHtml(hs.provider) : dim('unknown')}</span></div>
|
|
1215
1659
|
<div class="meta-item"><label>MCP </label><span>${statelessCell}</span></div>
|
|
1216
1660
|
</div>`;
|
|
1661
|
+
// When hindsight isn't serving, lead with the actionable remediation,
|
|
1662
|
+
// then still show the (now-stale) container/model meta beneath it.
|
|
1663
|
+
const hindsightBody = hs.running === false
|
|
1664
|
+
? renderProblem(problemFor('hindsight-down', {})) + hindsightMeta
|
|
1665
|
+
: hindsightMeta;
|
|
1217
1666
|
|
|
1218
1667
|
let hostdBody;
|
|
1219
1668
|
if (!hd.auditLogPresent) {
|
|
1220
|
-
|
|
1669
|
+
// A genuine read error stays a red dim line; the benign "empty audit
|
|
1670
|
+
// log" case is an informational ProblemDetail (not an error state).
|
|
1671
|
+
hostdBody = hd.error
|
|
1672
|
+
? `<div style="color:var(--red)">${escapeHtml('audit log error: ' + hd.error)}</div>`
|
|
1673
|
+
: renderProblem(problemFor('hostd-no-audit', {}));
|
|
1221
1674
|
} else if (!hd.recent || hd.recent.length === 0) {
|
|
1222
1675
|
hostdBody = dim('audit log present, no entries');
|
|
1223
1676
|
} else {
|
|
1677
|
+
// Tri-state, not a binary ok/!ok — async mutations log a `started`
|
|
1678
|
+
// row (exit_code null) that is NOT a failure; painting it red made the
|
|
1679
|
+
// panel a wall of false-alarm red. Map: error/non-zero exit → red,
|
|
1680
|
+
// denied → grey, started/in-flight → blue, completed+exit0 → green.
|
|
1681
|
+
const verbStatus = (e) => {
|
|
1682
|
+
if (e.result === 'denied') return { cls: 'denied', dotStyle: 'background:var(--text-dim)', label: 'denied' };
|
|
1683
|
+
if (e.result === 'error' || (e.exit_code != null && e.exit_code !== 0))
|
|
1684
|
+
return { cls: 'err', dotStyle: 'background:var(--red)', label: e.result || 'error' };
|
|
1685
|
+
if (e.result === 'started' || e.exit_code == null)
|
|
1686
|
+
return { cls: 'started', dotStyle: 'background:var(--blue);box-shadow:0 0 6px var(--blue)', label: e.result || 'in progress' };
|
|
1687
|
+
return { cls: 'ok', dotStyle: 'background:var(--green);box-shadow:0 0 6px var(--green)', label: e.result || 'ok' };
|
|
1688
|
+
};
|
|
1224
1689
|
const rows = hd.recent.slice().reverse().map(e => {
|
|
1225
1690
|
const caller = e.caller && e.caller.kind === 'agent' ? escapeHtml(e.caller.name) : 'operator';
|
|
1226
|
-
const
|
|
1691
|
+
const st = verbStatus(e);
|
|
1692
|
+
// The one diagnostic field — stderr_tail/error — was previously
|
|
1693
|
+
// dropped, making EROFS/reconcile failures unreadable. Surface it.
|
|
1694
|
+
const detail = e.stderr_tail || e.error;
|
|
1695
|
+
const detailRow = (st.cls === 'err' || st.cls === 'denied') && detail
|
|
1696
|
+
? `<tr><td colspan="4" style="color:var(--red);font-size:.72rem;white-space:pre-wrap;word-break:break-word;padding-top:0">${escapeHtml(String(detail).slice(0, 400))}</td></tr>`
|
|
1697
|
+
: '';
|
|
1227
1698
|
return `<tr>
|
|
1228
|
-
<td
|
|
1699
|
+
<td><span class="status-dot" style="display:inline-block;vertical-align:middle;${st.dotStyle}"></span> ${escapeHtml(e.op || '?')}</td>
|
|
1229
1700
|
<td>${caller}</td>
|
|
1230
|
-
<td>${escapeHtml(
|
|
1701
|
+
<td>${escapeHtml(st.label)}${e.exit_code != null ? ` (${e.exit_code})` : ''}</td>
|
|
1231
1702
|
<td>${escapeHtml(shortTs(e.ts))}</td>
|
|
1232
|
-
</tr
|
|
1703
|
+
</tr>${detailRow}`;
|
|
1233
1704
|
}).join('');
|
|
1234
1705
|
hostdBody = `<table class="accounts-table" style="margin-top:0.5rem">
|
|
1235
1706
|
<thead><tr><th>Verb</th><th>Caller</th><th>Result</th><th>When</th></tr></thead>
|
|
1236
1707
|
<tbody>${rows}</tbody></table>`;
|
|
1237
1708
|
}
|
|
1238
1709
|
|
|
1710
|
+
// Subtle freshness hint: the cached object carries `stale:true` when
|
|
1711
|
+
// it's being served past its TTL while a background refresh runs.
|
|
1712
|
+
const updating = h.stale
|
|
1713
|
+
? `<span style="color:var(--text-dim);font-size:.7rem;margin-left:.4rem">(updating…)</span>`
|
|
1714
|
+
: '';
|
|
1239
1715
|
container.innerHTML = `
|
|
1716
|
+
${updating ? `<div style="margin-bottom:.4rem">System health ${updating}</div>` : ''}
|
|
1240
1717
|
<div class="agents-grid">
|
|
1241
1718
|
<div class="agent-card"><div class="card-header" style="cursor:default">
|
|
1242
1719
|
${dot(b.reachable)}<span class="agent-name">auth-broker</span></div>
|
|
@@ -1267,9 +1744,18 @@
|
|
|
1267
1744
|
function renderOAuthAccountCard(a, opts) {
|
|
1268
1745
|
const provider = (opts && opts.provider) || '';
|
|
1269
1746
|
const agentNames = (opts && opts.agentNames) || [];
|
|
1270
|
-
|
|
1747
|
+
// expiresAt is the SHORT-LIVED access-token expiry, NOT connection
|
|
1748
|
+
// health. For a broker-known slot the refresh token keeps it alive, so a
|
|
1749
|
+
// past value means "lazily refreshes on next use" — rendering it as
|
|
1750
|
+
// "Expires 23d ago" made healthy connected accounts look broken (the
|
|
1751
|
+
// "connections aren't accurate" complaint). Don't alarm on it.
|
|
1752
|
+
const expires = !a.expiresAt
|
|
1753
|
+
? _dimC('—')
|
|
1754
|
+
: (a.brokerKnown && a.expiresAt < Date.now())
|
|
1755
|
+
? '<span style="color:var(--text-dim)">auto-refreshes</span>'
|
|
1756
|
+
: formatTimestamp(a.expiresAt);
|
|
1271
1757
|
const known = a.brokerKnown
|
|
1272
|
-
? '<span class="usage-pill primary">
|
|
1758
|
+
? '<span class="usage-pill primary">connected</span>'
|
|
1273
1759
|
: '<span style="color:var(--yellow)">config-only (no broker slot)</span>';
|
|
1274
1760
|
const acl = (a.enabledFor && a.enabledFor.length)
|
|
1275
1761
|
? a.enabledFor.map(escapeHtml).join(', ')
|
|
@@ -1395,15 +1881,41 @@
|
|
|
1395
1881
|
${fullAccessBanner}${notionBody}
|
|
1396
1882
|
</div>`;
|
|
1397
1883
|
|
|
1398
|
-
|
|
1884
|
+
// Tab-level degraded banner: when every OAuth account (Google +
|
|
1885
|
+
// Microsoft) is config-only (no live broker slot) and there's at least
|
|
1886
|
+
// one, the broker data is unavailable — surface that ABOVE the sections
|
|
1887
|
+
// without blanking the config-only cards.
|
|
1888
|
+
const oauth = [...google, ...microsoft];
|
|
1889
|
+
const allConfigOnly = oauth.length > 0 && oauth.every(a => a.brokerKnown === false);
|
|
1890
|
+
const degradedBanner = allConfigOnly
|
|
1891
|
+
? renderProblem(problemFor('connections-degraded', {}))
|
|
1892
|
+
: '';
|
|
1893
|
+
|
|
1894
|
+
container.innerHTML = degradedBanner + googleSection + microsoftSection + notionSection;
|
|
1399
1895
|
}
|
|
1400
1896
|
|
|
1401
1897
|
function renderSchedule(data) {
|
|
1402
1898
|
const container = document.getElementById('schedule');
|
|
1403
1899
|
const entries = (data && data.entries) || [];
|
|
1404
1900
|
const recent = (data && data.recentByAgent) || {};
|
|
1901
|
+
const degraded = !!(data && data.degraded);
|
|
1902
|
+
const truncated = !!(data && data.truncated);
|
|
1903
|
+
// Degraded banner: hostd is unreachable, so this is the base-config-only
|
|
1904
|
+
// view (per-agent cascade overrides may be absent). Render it ABOVE the
|
|
1905
|
+
// cards — never instead of them — so the operator sees both the
|
|
1906
|
+
// incompleteness AND whatever entries we DO have.
|
|
1907
|
+
const degradedBanner = degraded
|
|
1908
|
+
? renderProblem(problemFor('schedule-degraded', { reason: data && data.reason }))
|
|
1909
|
+
: '';
|
|
1910
|
+
const truncatedNote = truncated
|
|
1911
|
+
? '<div style="font-size:.78rem;color:var(--text-dim);margin-bottom:.75rem">(some entries trimmed to fit)</div>'
|
|
1912
|
+
: '';
|
|
1405
1913
|
if (entries.length === 0) {
|
|
1406
|
-
|
|
1914
|
+
// A bare "No schedule entries" is misleading when hostd is just down —
|
|
1915
|
+
// show the actionable degraded banner instead.
|
|
1916
|
+
container.innerHTML = degraded
|
|
1917
|
+
? degradedBanner
|
|
1918
|
+
: '<div class="loading">No <code>schedule:</code> entries in any agent\'s cascade-resolved config.</div>';
|
|
1407
1919
|
return;
|
|
1408
1920
|
}
|
|
1409
1921
|
const dim = (s) => `<span style="color:var(--text-dim)">${escapeHtml(s)}</span>`;
|
|
@@ -1440,21 +1952,84 @@
|
|
|
1440
1952
|
</div>
|
|
1441
1953
|
</div>`;
|
|
1442
1954
|
}).join('');
|
|
1443
|
-
container.innerHTML = cards;
|
|
1955
|
+
container.innerHTML = degradedBanner + truncatedNote + cards;
|
|
1444
1956
|
}
|
|
1445
1957
|
|
|
1446
|
-
function renderApprovals(data) {
|
|
1958
|
+
function renderApprovals(data, grants) {
|
|
1447
1959
|
const container = document.getElementById('approvals');
|
|
1960
|
+
// Two independent sections: the operator's REAL standing capability
|
|
1961
|
+
// grants (broker grants DB — the actually-useful one), then the
|
|
1962
|
+
// kernel decision ledger (honestly empty on most installs). They
|
|
1963
|
+
// degrade independently — one being unreachable never blanks the
|
|
1964
|
+
// other.
|
|
1965
|
+
container.innerHTML =
|
|
1966
|
+
renderStandingGrants(grants) + renderApprovalDecisions(data);
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
// Standing capability grants from the vault-broker grants DB — what
|
|
1970
|
+
// `switchroom vault grants` lists. write_allow grants are the sensitive
|
|
1971
|
+
// ones (highlighted), mirroring the Drive-scope highlight below.
|
|
1972
|
+
function renderStandingGrants(grants) {
|
|
1448
1973
|
const dim = (s) => `<span style="color:var(--text-dim)">${escapeHtml(s)}</span>`;
|
|
1974
|
+
const heading = '<h3 style="margin:0 0 0.5rem;font-size:0.95rem">Standing capability grants</h3>';
|
|
1975
|
+
if (!grants || grants.reachable === false) {
|
|
1976
|
+
return heading +
|
|
1977
|
+
renderProblem(problemFor('grants-unreachable', { error: grants && grants.error }));
|
|
1978
|
+
}
|
|
1979
|
+
const rows = grants.grants || [];
|
|
1980
|
+
if (rows.length === 0) {
|
|
1981
|
+
return heading +
|
|
1982
|
+
'<div class="loading" style="color:var(--text-dim)">No standing capability grants.</div>';
|
|
1983
|
+
}
|
|
1984
|
+
const now = Date.now();
|
|
1985
|
+
const chipList = (keys) => (keys && keys.length)
|
|
1986
|
+
? keys.map(k => `<span class="chip">${escapeHtml(k)}</span>`).join(' ')
|
|
1987
|
+
: dim('—');
|
|
1988
|
+
const body = rows.map(g => {
|
|
1989
|
+
const expired = g.expiresAt != null && g.expiresAt < now;
|
|
1990
|
+
const writeCell = (g.writeKeys && g.writeKeys.length)
|
|
1991
|
+
? `<span class="usage-pill primary" title="write grants are the sensitive ones">write</span> ` +
|
|
1992
|
+
g.writeKeys.map(k => `<span class="chip">${escapeHtml(k)}</span>`).join(' ')
|
|
1993
|
+
: dim('read-only');
|
|
1994
|
+
const expiresCell = g.expiresAt == null
|
|
1995
|
+
? dim('never')
|
|
1996
|
+
: `<span style="${expired ? 'color:var(--text-dim)' : ''}">${formatTimestamp(g.expiresAt)}${expired ? ' (expired)' : ''}</span>`;
|
|
1997
|
+
return `
|
|
1998
|
+
<tr>
|
|
1999
|
+
<td>${escapeHtml(g.agent || '—')}</td>
|
|
2000
|
+
<td>${chipList(g.keys)}</td>
|
|
2001
|
+
<td>${writeCell}</td>
|
|
2002
|
+
<td>${expiresCell}</td>
|
|
2003
|
+
<td>${g.createdAt ? formatTimestamp(g.createdAt) : dim('—')}</td>
|
|
2004
|
+
<td>${g.description ? escapeHtml(g.description) : dim('—')}</td>
|
|
2005
|
+
</tr>`;
|
|
2006
|
+
}).join('');
|
|
2007
|
+
return heading + `
|
|
2008
|
+
<div style="margin-bottom:0.6rem;font-size:0.8rem;color:var(--text-dim)">
|
|
2009
|
+
Read-only view via the vault-broker operator socket — ${rows.length} grant(s). Write grants highlighted.
|
|
2010
|
+
</div>
|
|
2011
|
+
<div class="accounts-table-wrap">
|
|
2012
|
+
<table class="accounts-table">
|
|
2013
|
+
<thead><tr>
|
|
2014
|
+
<th>Agent</th><th>Keys (read)</th><th>Write</th>
|
|
2015
|
+
<th>Expires</th><th>Granted</th><th>Description</th>
|
|
2016
|
+
</tr></thead>
|
|
2017
|
+
<tbody>${body}</tbody>
|
|
2018
|
+
</table>
|
|
2019
|
+
</div>`;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
// Kernel decision ledger (RFC B) — kept as-is. Honestly empty on most
|
|
2023
|
+
// installs (the daily Allow/Deny taps don't write it); that's correct.
|
|
2024
|
+
function renderApprovalDecisions(data) {
|
|
2025
|
+
const dim = (s) => `<span style="color:var(--text-dim)">${escapeHtml(s)}</span>`;
|
|
2026
|
+
const heading = '<h3 style="margin:1.25rem 0 0.5rem;font-size:0.95rem">Approval decision ledger</h3>';
|
|
1449
2027
|
if (!data || data.reachable === false) {
|
|
1450
|
-
|
|
1451
|
-
container.innerHTML = `<div class="loading">Approval ledger unavailable — ${why}</div>`;
|
|
1452
|
-
return;
|
|
2028
|
+
return heading + renderProblem(approvalsProblem(data && data.error));
|
|
1453
2029
|
}
|
|
1454
2030
|
const decisions = data.decisions || [];
|
|
1455
2031
|
if (decisions.length === 0) {
|
|
1456
|
-
|
|
1457
|
-
return;
|
|
2032
|
+
return heading + '<div class="loading">No approval decisions recorded yet.</div>';
|
|
1458
2033
|
}
|
|
1459
2034
|
const now = Date.now();
|
|
1460
2035
|
const statusOf = (d) => {
|
|
@@ -1482,7 +2057,7 @@
|
|
|
1482
2057
|
<td title="${d.revoke_reason ? escapeHtml(d.revoke_reason) : ''}">${d.revoked_at ? formatTimestamp(d.revoked_at) : dim('—')}</td>
|
|
1483
2058
|
</tr>`;
|
|
1484
2059
|
}).join('');
|
|
1485
|
-
|
|
2060
|
+
return heading + `
|
|
1486
2061
|
<div style="margin-bottom:0.6rem;font-size:0.8rem;color:var(--text-dim)">
|
|
1487
2062
|
Read-only view via the kernel operator socket — ${decisions.length} decision(s). Drive scopes highlighted.
|
|
1488
2063
|
</div>
|
|
@@ -1604,9 +2179,15 @@
|
|
|
1604
2179
|
async function toggleLogs(name) {
|
|
1605
2180
|
if (openLogs.has(name)) {
|
|
1606
2181
|
openLogs.delete(name);
|
|
2182
|
+
unsubscribeLogs(name);
|
|
1607
2183
|
} else {
|
|
1608
2184
|
openLogs.add(name);
|
|
2185
|
+
// One-shot paint for instant feedback, then subscribe to the
|
|
2186
|
+
// hostd-poll live stream (replaces the panel every few seconds).
|
|
1609
2187
|
await loadLogs(name);
|
|
2188
|
+
render();
|
|
2189
|
+
subscribeLogs(name);
|
|
2190
|
+
return;
|
|
1610
2191
|
}
|
|
1611
2192
|
render();
|
|
1612
2193
|
}
|
|
@@ -1641,9 +2222,14 @@
|
|
|
1641
2222
|
}
|
|
1642
2223
|
|
|
1643
2224
|
function connectWebSocket() {
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
2225
|
+
const wsUrl = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws`;
|
|
2226
|
+
// Browsers can't set Authorization on a WS upgrade, but they CAN set
|
|
2227
|
+
// Sec-WebSocket-Protocol. The server already reads the bearer token
|
|
2228
|
+
// from that header (extractBearerToken → ["bearer", token]) and
|
|
2229
|
+
// ignores any ?token= query param — so ride the subprotocol. No
|
|
2230
|
+
// token (Tailscale-identified bind) → no subprotocol → the server
|
|
2231
|
+
// falls back to the Tailscale-User-Login header, unchanged.
|
|
2232
|
+
ws = TOKEN ? new WebSocket(wsUrl, ['bearer', TOKEN]) : new WebSocket(wsUrl);
|
|
1647
2233
|
|
|
1648
2234
|
ws.onmessage = (event) => {
|
|
1649
2235
|
try {
|
|
@@ -1651,13 +2237,31 @@
|
|
|
1651
2237
|
if (msg.type === 'log' && msg.agent) {
|
|
1652
2238
|
const el = document.getElementById(`log-output-${msg.agent}`);
|
|
1653
2239
|
if (el && openLogs.has(msg.agent)) {
|
|
1654
|
-
|
|
2240
|
+
// hostd-poll stream sends REPLACE (the latest tail). The
|
|
2241
|
+
// legacy docker-follow stream appended; keep both shapes so
|
|
2242
|
+
// an old payload (no `replace`) still works.
|
|
2243
|
+
if (msg.replace) {
|
|
2244
|
+
el.textContent = msg.data || '(no output)';
|
|
2245
|
+
} else {
|
|
2246
|
+
el.textContent += msg.data;
|
|
2247
|
+
}
|
|
1655
2248
|
el.scrollTop = el.scrollHeight;
|
|
1656
2249
|
}
|
|
2250
|
+
} else if (msg.type === 'log_error' && msg.agent) {
|
|
2251
|
+
const el = document.getElementById(`log-output-${msg.agent}`);
|
|
2252
|
+
if (el && openLogs.has(msg.agent)) {
|
|
2253
|
+
el.textContent = `Error: ${msg.data || 'log stream unavailable'}`;
|
|
2254
|
+
}
|
|
1657
2255
|
}
|
|
1658
2256
|
} catch {}
|
|
1659
2257
|
};
|
|
1660
2258
|
|
|
2259
|
+
ws.onopen = () => {
|
|
2260
|
+
// Re-subscribe any panels that were left open across a reconnect
|
|
2261
|
+
// so a dropped socket doesn't leave a permanently-dead log panel.
|
|
2262
|
+
for (const name of openLogs) subscribeLogs(name);
|
|
2263
|
+
};
|
|
2264
|
+
|
|
1661
2265
|
ws.onclose = () => {
|
|
1662
2266
|
setTimeout(connectWebSocket, 3000);
|
|
1663
2267
|
};
|
|
@@ -1667,6 +2271,20 @@
|
|
|
1667
2271
|
};
|
|
1668
2272
|
}
|
|
1669
2273
|
|
|
2274
|
+
// Send a {subscribe} for an agent's live-log poll stream, guarded on
|
|
2275
|
+
// an OPEN socket (a closed socket re-subscribes via ws.onopen above).
|
|
2276
|
+
function subscribeLogs(name) {
|
|
2277
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
2278
|
+
try { ws.send(JSON.stringify({ type: 'subscribe', agent: name })); } catch {}
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
function unsubscribeLogs(name) {
|
|
2283
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
2284
|
+
try { ws.send(JSON.stringify({ type: 'unsubscribe', agent: name })); } catch {}
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
|
|
1670
2288
|
// Init. Summary is the default visible tab; fetchAgents still runs
|
|
1671
2289
|
// (populates the agents tab + keeps the 10s fleet poll warm + the
|
|
1672
2290
|
// log WS depends on it). Summary is fetched on init + on tab-switch
|
|
@@ -1675,7 +2293,14 @@
|
|
|
1675
2293
|
fetchSummary();
|
|
1676
2294
|
fetchAgents();
|
|
1677
2295
|
connectWebSocket();
|
|
1678
|
-
|
|
2296
|
+
// Fleet poll — gated on tab visibility. A phone with the dashboard
|
|
2297
|
+
// backgrounded (or a laptop tab the operator switched away from)
|
|
2298
|
+
// shouldn't keep hitting the host every 10s; the cache absorbs the
|
|
2299
|
+
// cost, but skipping the request entirely is strictly better. The
|
|
2300
|
+
// next foregrounded tick refreshes immediately.
|
|
2301
|
+
setInterval(() => {
|
|
2302
|
+
if (document.visibilityState === 'visible') fetchAgents();
|
|
2303
|
+
}, 10000);
|
|
1679
2304
|
</script>
|
|
1680
2305
|
</body>
|
|
1681
2306
|
</html>
|