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