switchroom 0.16.29 → 0.16.46
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/agent-scheduler/index.js +101 -84
- package/dist/auth-broker/index.js +100 -83
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +102 -85
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +62124 -60406
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +1000 -256
- package/dist/vault/approvals/kernel-server.js +141 -121
- package/dist/vault/broker/server.js +163 -143
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +3 -2
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +59 -3
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/bridge/bridge.js +132 -114
- package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
- package/telegram-plugin/dist/server.js +180 -163
- package/telegram-plugin/format.ts +551 -20
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1524 -129
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +33 -2
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +79 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -0
- package/telegram-plugin/tests/format-consistency.test.ts +223 -0
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
- package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
- package/telegram-plugin/tests/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/model-command.test.ts +213 -47
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +121 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +31 -116
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +474 -17
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
- package/telegram-plugin/uat/uat-driver.test.ts +297 -0
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +289 -0
- package/telegram-plugin/voice-presynth.ts +242 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- package/telegram-plugin/worker-activity-feed.ts +22 -5
package/dist/cli/ui/index.html
CHANGED
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
--yellow: #fbbf24;
|
|
23
23
|
--blue: #60a5fa;
|
|
24
24
|
--radius: 10px;
|
|
25
|
+
/* Names the mono stack already used inline throughout this file. */
|
|
26
|
+
--mono: "SF Mono", "Fira Code", "Cascadia Code", ui-monospace, monospace;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
body {
|
|
@@ -554,6 +556,438 @@
|
|
|
554
556
|
.mm-attn { border-left: 3px solid var(--red); padding: .4rem .6rem; margin: .15rem 0 .35rem; background: rgba(248,113,113,.07); border-radius: 0 6px 6px 0; font-size: .86em; }
|
|
555
557
|
.mm-attn.warn { border-left-color: var(--yellow); background: rgba(251,191,36,.06); }
|
|
556
558
|
.mm-actions { display: flex; flex-wrap: wrap; gap: .4rem; }
|
|
559
|
+
|
|
560
|
+
/* ==================================================================
|
|
561
|
+
Fleet Health redesign — worst-first triage (.fh-* + related).
|
|
562
|
+
Namespaced; ported from the design handoff mock. Reuses the
|
|
563
|
+
dashboard's existing tokens (+ the new --mono var).
|
|
564
|
+
================================================================== */
|
|
565
|
+
/* ---------- Page header: owner + scan + L3 summary ---------- */
|
|
566
|
+
.fh-top {
|
|
567
|
+
display: flex; flex-wrap: wrap; align-items: flex-start;
|
|
568
|
+
gap: 1rem 1.5rem; margin-bottom: 1.25rem;
|
|
569
|
+
}
|
|
570
|
+
.fh-summary { flex: 1 1 380px; min-width: 300px; }
|
|
571
|
+
.fh-summary .fh-lede {
|
|
572
|
+
font-size: 1.05rem; color: var(--text); line-height: 1.45; text-wrap: pretty;
|
|
573
|
+
}
|
|
574
|
+
.fh-summary .fh-lede b { color: var(--text); font-weight: 600; }
|
|
575
|
+
.fh-attribution {
|
|
576
|
+
margin-top: 0.55rem; font-size: 0.8rem; color: var(--text-dim);
|
|
577
|
+
font-family: var(--mono); display: flex; flex-wrap: wrap; gap: 0.15rem 0.9rem;
|
|
578
|
+
}
|
|
579
|
+
.fh-attribution .k { opacity: 0.7; }
|
|
580
|
+
.fh-attribution .v { color: var(--text); }
|
|
581
|
+
.fh-attribution .sep { color: var(--border); }
|
|
582
|
+
|
|
583
|
+
/* fleet posture: how many jobs are rotting vs clean, at a glance */
|
|
584
|
+
.fh-posture {
|
|
585
|
+
flex: 0 0 auto; display: flex; gap: 0.6rem;
|
|
586
|
+
}
|
|
587
|
+
.fh-stat {
|
|
588
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
589
|
+
border-radius: var(--radius); padding: 0.6rem 0.9rem; min-width: 76px;
|
|
590
|
+
}
|
|
591
|
+
.fh-stat .n { font-size: 1.5rem; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.1; }
|
|
592
|
+
.fh-stat .l { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-dim); margin-top: 0.15rem; }
|
|
593
|
+
.fh-stat.attn { border-color: rgba(248,113,113,0.35); }
|
|
594
|
+
.fh-stat.attn .n { color: var(--red); }
|
|
595
|
+
.fh-stat.clean .n { color: var(--green); }
|
|
596
|
+
|
|
597
|
+
/* ---------- Score explainer (collapsible) ---------- */
|
|
598
|
+
.fh-explain {
|
|
599
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
600
|
+
border-radius: var(--radius); margin-bottom: 1.25rem; overflow: hidden;
|
|
601
|
+
}
|
|
602
|
+
.fh-explain > summary {
|
|
603
|
+
list-style: none; cursor: pointer; user-select: none;
|
|
604
|
+
padding: 0.7rem 1rem; display: flex; align-items: center; gap: 0.5rem;
|
|
605
|
+
font-size: 0.85rem;
|
|
606
|
+
}
|
|
607
|
+
.fh-explain > summary::-webkit-details-marker { display: none; }
|
|
608
|
+
.fh-explain > summary .caret { color: var(--text-dim); font-size: 0.7rem; transition: transform 0.15s; }
|
|
609
|
+
.fh-explain[open] > summary .caret { transform: rotate(90deg); }
|
|
610
|
+
.fh-explain > summary .formula {
|
|
611
|
+
margin-left: auto; font-family: var(--mono); font-size: 0.76rem; color: var(--text-dim);
|
|
612
|
+
}
|
|
613
|
+
.fh-explain > summary .formula em { color: var(--blue); font-style: normal; }
|
|
614
|
+
.fh-explain-body { padding: 0 1rem 0.9rem; }
|
|
615
|
+
.fh-factors { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.6rem; }
|
|
616
|
+
.fh-factor {
|
|
617
|
+
background: var(--surface-hover); border-radius: 8px; padding: 0.6rem 0.7rem;
|
|
618
|
+
border-top: 2px solid var(--fc, var(--border));
|
|
619
|
+
}
|
|
620
|
+
.fh-factor .fn { font-weight: 600; font-size: 0.85rem; }
|
|
621
|
+
.fh-factor .fd { color: var(--text-dim); font-size: 0.76rem; margin-top: 0.2rem; line-height: 1.4; }
|
|
622
|
+
.fh-factor.sev { --fc: var(--red); }
|
|
623
|
+
.fh-factor.freq { --fc: var(--yellow); }
|
|
624
|
+
.fh-factor.reach { --fc: var(--blue); }
|
|
625
|
+
.fh-factor.rec { --fc: var(--green); }
|
|
626
|
+
|
|
627
|
+
/* ---------- Section headings ---------- */
|
|
628
|
+
.fh-band {
|
|
629
|
+
display: flex; align-items: baseline; gap: 0.5rem;
|
|
630
|
+
margin: 0 0 0.7rem; font-size: 0.72rem; text-transform: uppercase;
|
|
631
|
+
letter-spacing: 0.08em; color: var(--text-dim);
|
|
632
|
+
}
|
|
633
|
+
.fh-band .n { color: var(--text); opacity: 0.55; font-variant-numeric: tabular-nums; }
|
|
634
|
+
.fh-band + .fh-band { margin-top: 0; }
|
|
635
|
+
.fh-attention-list { display: flex; flex-direction: column; gap: 0.6rem; margin-bottom: 1.75rem; }
|
|
636
|
+
|
|
637
|
+
/* ---------- Triage row (one job spec record) ---------- */
|
|
638
|
+
.fh-rec {
|
|
639
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
640
|
+
border-radius: var(--radius); overflow: hidden; transition: border-color 0.15s;
|
|
641
|
+
}
|
|
642
|
+
.fh-rec:hover { border-color: #3a3e4e; }
|
|
643
|
+
.fh-rec.worst { border-color: rgba(248,113,113,0.4); }
|
|
644
|
+
.fh-rec.worst .fh-rec-head::before {
|
|
645
|
+
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--red);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
.fh-rec-head {
|
|
649
|
+
position: relative; display: grid;
|
|
650
|
+
grid-template-columns: 3.2rem 1fr auto; align-items: center;
|
|
651
|
+
gap: 0.9rem; padding: 0.85rem 1.1rem 0.85rem 1.25rem; cursor: pointer; user-select: none;
|
|
652
|
+
}
|
|
653
|
+
/* rank + score cluster */
|
|
654
|
+
.fh-rank { text-align: center; }
|
|
655
|
+
.fh-rank .score {
|
|
656
|
+
font-size: 1.35rem; font-weight: 700; font-variant-numeric: tabular-nums;
|
|
657
|
+
line-height: 1; letter-spacing: -0.02em;
|
|
658
|
+
}
|
|
659
|
+
.fh-rank .rk { font-family: var(--mono); font-size: 0.62rem; color: var(--text-dim); letter-spacing: 0.1em; margin-top: 0.15rem; }
|
|
660
|
+
|
|
661
|
+
.fh-rec-main { min-width: 0; }
|
|
662
|
+
.fh-rec-title { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
|
663
|
+
.fh-job {
|
|
664
|
+
font-family: var(--mono); font-size: 0.95rem; font-weight: 600; color: var(--text);
|
|
665
|
+
letter-spacing: -0.01em;
|
|
666
|
+
}
|
|
667
|
+
.fh-rec-sub {
|
|
668
|
+
margin-top: 0.28rem; font-size: 0.78rem; color: var(--text-dim);
|
|
669
|
+
display: flex; flex-wrap: wrap; gap: 0.1rem 0.8rem; font-family: var(--mono);
|
|
670
|
+
}
|
|
671
|
+
/* one-line intent: what this job spec promises the operator */
|
|
672
|
+
.fh-desc {
|
|
673
|
+
margin-top: 0.3rem; font-size: 0.82rem; color: var(--text-dim);
|
|
674
|
+
line-height: 1.4; max-width: 62ch; text-wrap: pretty;
|
|
675
|
+
}
|
|
676
|
+
.fh-rec-sub .v { color: var(--text); }
|
|
677
|
+
.fh-rec-sub .sep { color: var(--border); }
|
|
678
|
+
|
|
679
|
+
/* score-composition strip — the "why it ranks here" microviz */
|
|
680
|
+
.fh-compose { display: flex; align-items: center; gap: 0.55rem; margin-top: 0.5rem; flex-wrap: wrap; }
|
|
681
|
+
.fh-fpill {
|
|
682
|
+
display: inline-flex; align-items: baseline; gap: 0.28rem;
|
|
683
|
+
font-family: var(--mono); font-size: 0.72rem; color: var(--text-dim);
|
|
684
|
+
background: var(--surface-hover); border: 1px solid var(--border);
|
|
685
|
+
border-radius: 6px; padding: 0.12rem 0.42rem; white-space: nowrap;
|
|
686
|
+
}
|
|
687
|
+
.fh-fpill b { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
688
|
+
.fh-fpill .lab { opacity: 0.7; }
|
|
689
|
+
.fh-fpill.sev b { color: var(--red); }
|
|
690
|
+
.fh-fpill.freq b { color: var(--yellow); }
|
|
691
|
+
.fh-fpill.reach b { color: var(--blue); }
|
|
692
|
+
.fh-fpill.rec b { color: var(--green); }
|
|
693
|
+
.fh-times { color: var(--border); font-size: 0.7rem; }
|
|
694
|
+
|
|
695
|
+
.fh-rec-right { display: flex; align-items: center; gap: 0.75rem; }
|
|
696
|
+
.fh-caret { color: var(--text-dim); font-size: 0.8rem; transition: transform 0.15s; }
|
|
697
|
+
.fh-rec.open .fh-caret { transform: rotate(90deg); }
|
|
698
|
+
|
|
699
|
+
/* severity badge */
|
|
700
|
+
.sev-badge {
|
|
701
|
+
display: inline-flex; align-items: center; gap: 0.32rem;
|
|
702
|
+
padding: 0.15rem 0.55rem; border-radius: 999px;
|
|
703
|
+
font-size: 0.72rem; font-weight: 500; white-space: nowrap;
|
|
704
|
+
}
|
|
705
|
+
.sev-badge .dot { width: 7px; height: 7px; border-radius: 50%; }
|
|
706
|
+
.sev-badge.s3 { color: var(--red); background: rgba(248,113,113,0.12); }
|
|
707
|
+
.sev-badge.s3 .dot { background: var(--red); box-shadow: 0 0 6px var(--red); }
|
|
708
|
+
.sev-badge.s2 { color: var(--yellow); background: rgba(251,191,36,0.12); }
|
|
709
|
+
.sev-badge.s2 .dot { background: var(--yellow); }
|
|
710
|
+
.sev-badge.s1 { color: var(--blue); background: rgba(96,165,250,0.12); }
|
|
711
|
+
.sev-badge.s1 .dot { background: var(--blue); }
|
|
712
|
+
|
|
713
|
+
.issue-count {
|
|
714
|
+
font-size: 0.78rem; color: var(--text-dim); font-variant-numeric: tabular-nums;
|
|
715
|
+
white-space: nowrap;
|
|
716
|
+
}
|
|
717
|
+
.issue-count b { color: var(--text); font-weight: 600; }
|
|
718
|
+
|
|
719
|
+
/* ---------- Drill-down: distinct issues ---------- */
|
|
720
|
+
.fh-issues { display: none; border-top: 1px solid var(--border); padding: 0.4rem 1.1rem 0.9rem 1.25rem; }
|
|
721
|
+
.fh-rec.open .fh-issues { display: block; }
|
|
722
|
+
|
|
723
|
+
.fh-issue { padding: 0.8rem 0; border-bottom: 1px solid var(--border); }
|
|
724
|
+
.fh-issue:last-child { border-bottom: none; }
|
|
725
|
+
.fh-issue-top { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
|
726
|
+
|
|
727
|
+
/* failure-mode taxonomy chip */
|
|
728
|
+
.mode-chip {
|
|
729
|
+
font-family: var(--mono); font-size: 0.72rem; padding: 0.1rem 0.45rem;
|
|
730
|
+
border-radius: 5px; border: 1px solid var(--border); color: var(--text);
|
|
731
|
+
background: var(--surface-hover); white-space: nowrap;
|
|
732
|
+
}
|
|
733
|
+
.mode-chip.s3 { border-color: rgba(248,113,113,0.4); color: var(--red); }
|
|
734
|
+
.mode-chip.s2 { border-color: rgba(251,191,36,0.4); color: var(--yellow); }
|
|
735
|
+
.mode-chip.s1 { border-color: rgba(96,165,250,0.4); color: var(--blue); }
|
|
736
|
+
|
|
737
|
+
.status-tag {
|
|
738
|
+
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em;
|
|
739
|
+
padding: 0.1rem 0.45rem; border-radius: 999px; font-weight: 500;
|
|
740
|
+
}
|
|
741
|
+
.status-tag.open { color: var(--text-dim); border: 1px solid var(--border); }
|
|
742
|
+
.status-tag.verify { color: var(--yellow); background: rgba(251,191,36,0.1); }
|
|
743
|
+
.status-tag.closed { color: var(--green); background: rgba(52,211,153,0.1); }
|
|
744
|
+
|
|
745
|
+
.gh-link {
|
|
746
|
+
margin-left: auto; font-family: var(--mono); font-size: 0.74rem;
|
|
747
|
+
color: var(--blue); text-decoration: none; display: inline-flex; align-items: center; gap: 0.25rem;
|
|
748
|
+
padding: 0.1rem 0.4rem; border-radius: 5px;
|
|
749
|
+
}
|
|
750
|
+
.gh-link:hover { background: var(--surface-hover); }
|
|
751
|
+
|
|
752
|
+
.fh-issue-meta {
|
|
753
|
+
margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.35rem 1.1rem;
|
|
754
|
+
font-size: 0.78rem; color: var(--text-dim); font-family: var(--mono);
|
|
755
|
+
align-items: center;
|
|
756
|
+
}
|
|
757
|
+
.fh-issue-meta .v { color: var(--text); }
|
|
758
|
+
/* frequency trend: count + a self-verifying count-drop when resolving */
|
|
759
|
+
.fh-trend { display: inline-flex; align-items: center; gap: 0.3rem; }
|
|
760
|
+
.fh-trend .from { color: var(--text-dim); text-decoration: line-through; opacity: 0.7; }
|
|
761
|
+
.fh-trend .arrow { color: var(--green); }
|
|
762
|
+
.fh-trend .to { color: var(--green); font-weight: 600; }
|
|
763
|
+
.reach-agents { display: inline-flex; gap: 0.25rem; flex-wrap: wrap; }
|
|
764
|
+
.reach-agents .ag {
|
|
765
|
+
background: rgba(96,165,250,0.1); color: var(--blue); border-radius: 999px;
|
|
766
|
+
padding: 0.05rem 0.45rem; font-size: 0.72rem;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/* why it failed: the model-free root-cause read, above the raw artifacts */
|
|
770
|
+
.fh-why {
|
|
771
|
+
font-size: 0.82rem; color: var(--text); line-height: 1.5; text-wrap: pretty;
|
|
772
|
+
background: rgba(248,113,113,0.06); border-left: 2px solid rgba(248,113,113,0.5);
|
|
773
|
+
border-radius: 0 6px 6px 0; padding: 0.5rem 0.7rem; margin-bottom: 0.55rem;
|
|
774
|
+
}
|
|
775
|
+
.fh-why-lab {
|
|
776
|
+
display: block; font-family: var(--mono); font-size: 0.66rem; text-transform: uppercase;
|
|
777
|
+
letter-spacing: 0.06em; color: var(--red); opacity: 0.85; margin-bottom: 0.25rem;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/* evidence: the hard artifacts (turn_id join-key + log pointer) */
|
|
781
|
+
.fh-evidence { margin-top: 0.55rem; }
|
|
782
|
+
.fh-evidence-h {
|
|
783
|
+
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em;
|
|
784
|
+
color: var(--text-dim); margin-bottom: 0.35rem; display: flex; align-items: center; gap: 0.4rem;
|
|
785
|
+
}
|
|
786
|
+
.fh-evidence-h .tier {
|
|
787
|
+
color: var(--green); border: 1px solid rgba(52,211,153,0.3);
|
|
788
|
+
border-radius: 4px; padding: 0 0.3rem; font-size: 0.62rem; letter-spacing: 0.04em; white-space: nowrap;
|
|
789
|
+
}
|
|
790
|
+
.occ {
|
|
791
|
+
display: grid; grid-template-columns: auto 1fr; gap: 0.5rem 0.75rem; align-items: center;
|
|
792
|
+
background: rgba(0,0,0,0.22); border: 1px solid var(--border); border-radius: 6px;
|
|
793
|
+
padding: 0.4rem 0.6rem; font-family: var(--mono); font-size: 0.73rem;
|
|
794
|
+
}
|
|
795
|
+
.occ + .occ { margin-top: 0.3rem; }
|
|
796
|
+
.occ .occ-agent { color: var(--text); font-weight: 600; }
|
|
797
|
+
.occ .occ-body { min-width: 0; display: flex; flex-wrap: wrap; gap: 0.15rem 0.7rem; align-items: center; }
|
|
798
|
+
.occ .tid { color: var(--yellow); }
|
|
799
|
+
.occ .ptr { color: var(--text-dim); word-break: break-all; }
|
|
800
|
+
.occ .ptr .g { color: var(--text); }
|
|
801
|
+
.occ .more { color: var(--text-dim); font-family: var(--mono); font-size: 0.73rem; padding-left: 0.6rem; }
|
|
802
|
+
|
|
803
|
+
/* ---------- Healthy jobs: the quiet majority ---------- */
|
|
804
|
+
.fh-healthy-grid {
|
|
805
|
+
display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 0.4rem;
|
|
806
|
+
}
|
|
807
|
+
.fh-hc {
|
|
808
|
+
display: flex; align-items: center; gap: 0.55rem;
|
|
809
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
|
810
|
+
padding: 0.55rem 0.7rem; font-size: 0.8rem;
|
|
811
|
+
}
|
|
812
|
+
.fh-hc .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex-shrink: 0; align-self: flex-start; margin-top: 0.35rem; }
|
|
813
|
+
.fh-hc-text { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 0.1rem; }
|
|
814
|
+
.fh-hc .job { font-family: var(--mono); color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
815
|
+
.fh-hc .hd { font-size: 0.72rem; color: var(--text-dim); line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
816
|
+
.fh-hc .when { margin-left: auto; color: var(--text-dim); font-size: 0.7rem; white-space: nowrap; align-self: flex-start; margin-top: 0.1rem; }
|
|
817
|
+
|
|
818
|
+
@media (max-width: 640px) {
|
|
819
|
+
.fh-factors { grid-template-columns: repeat(2, 1fr); }
|
|
820
|
+
.fh-rec-head { grid-template-columns: 2.8rem 1fr; }
|
|
821
|
+
.fh-rec-right { grid-column: 1 / -1; justify-content: flex-start; padding-left: 3.7rem; }
|
|
822
|
+
.fh-explain > summary .formula { display: none; }
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/* ==================================================================
|
|
826
|
+
Summary cockpit redesign (.sm-*). Namespaced; ported from mock.
|
|
827
|
+
================================================================== */
|
|
828
|
+
/* ---------- cockpit header: posture verdict + freshness ---------- */
|
|
829
|
+
.sm-verdict {
|
|
830
|
+
display: flex; align-items: center; gap: 0.85rem; margin-bottom: 0.35rem;
|
|
831
|
+
}
|
|
832
|
+
.sm-verdict .pulse { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
|
|
833
|
+
.sm-verdict.attn .pulse { background: var(--red); box-shadow: 0 0 10px var(--red); }
|
|
834
|
+
.sm-verdict.ok .pulse { background: var(--green); box-shadow: 0 0 10px var(--green); }
|
|
835
|
+
.sm-verdict h2 { font-size: 1.35rem; font-weight: 600; letter-spacing: -0.02em; }
|
|
836
|
+
.sm-verdict h2 b { color: var(--red); }
|
|
837
|
+
.sm-asof { font-size: 0.75rem; color: var(--text-dim); margin-bottom: 1.1rem; font-family: var(--mono); }
|
|
838
|
+
|
|
839
|
+
.sm-band { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin: 0 0 0.6rem; display: flex; align-items: baseline; gap: 0.45rem; }
|
|
840
|
+
.sm-band .n { color: var(--text); opacity: 0.55; }
|
|
841
|
+
|
|
842
|
+
/* ---------- attention hero: what needs you, worst-first ---------- */
|
|
843
|
+
.sm-attn-list { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1.6rem; }
|
|
844
|
+
.sm-attn {
|
|
845
|
+
display: flex; align-items: flex-start; gap: 0.7rem; text-align: left;
|
|
846
|
+
width: 100%; background: var(--surface); border: 1px solid var(--border);
|
|
847
|
+
border-left-width: 3px; border-radius: var(--radius); padding: 0.75rem 0.95rem;
|
|
848
|
+
cursor: pointer; transition: background 0.12s, border-color 0.12s; color: inherit; font: inherit;
|
|
849
|
+
}
|
|
850
|
+
.sm-attn:hover { background: var(--surface-hover); }
|
|
851
|
+
.sm-attn.critical { border-left-color: var(--red); }
|
|
852
|
+
.sm-attn.warn { border-left-color: var(--yellow); }
|
|
853
|
+
.sm-attn.info { border-left-color: var(--blue); }
|
|
854
|
+
.sm-attn .sev {
|
|
855
|
+
font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 600;
|
|
856
|
+
padding: 0.15rem 0.45rem; border-radius: 5px; margin-top: 0.1rem; white-space: nowrap; font-family: var(--mono);
|
|
857
|
+
}
|
|
858
|
+
.sm-attn.critical .sev { color: var(--red); background: rgba(248,113,113,0.12); }
|
|
859
|
+
.sm-attn.warn .sev { color: var(--yellow); background: rgba(251,191,36,0.12); }
|
|
860
|
+
.sm-attn.info .sev { color: var(--blue); background: rgba(96,165,250,0.12); }
|
|
861
|
+
.sm-attn-body { min-width: 0; flex: 1; }
|
|
862
|
+
.sm-attn-title { font-weight: 600; font-size: 0.9rem; }
|
|
863
|
+
.sm-attn-detail { color: var(--text-dim); font-size: 0.82rem; margin-top: 0.15rem; text-wrap: pretty; }
|
|
864
|
+
.sm-attn-go { color: var(--text-dim); font-size: 0.75rem; margin-top: 0.2rem; align-self: center; white-space: nowrap; font-family: var(--mono); }
|
|
865
|
+
.sm-attn:hover .sm-attn-go { color: var(--blue); }
|
|
866
|
+
.sm-attn.calm { border-left-color: var(--green); cursor: default; }
|
|
867
|
+
.sm-attn.calm:hover { background: var(--surface); }
|
|
868
|
+
|
|
869
|
+
/* ---------- vitals: the things that actually bite ---------- */
|
|
870
|
+
.sm-vitals { display: grid; grid-template-columns: 1.4fr 1fr; gap: 0.75rem; margin-bottom: 1.6rem; }
|
|
871
|
+
.sm-vcard { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 0.95rem 1.1rem; }
|
|
872
|
+
.sm-vcard .vh { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 0.6rem; display: flex; align-items: center; gap: 0.4rem; }
|
|
873
|
+
.sm-vcard .vh a { margin-left: auto; color: var(--text-dim); font-size: 0.72rem; text-decoration: none; cursor: pointer; }
|
|
874
|
+
.sm-vcard .vh a:hover { color: var(--blue); }
|
|
875
|
+
|
|
876
|
+
/* agents posture */
|
|
877
|
+
.sm-agents-n { font-size: 2rem; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; line-height: 1; }
|
|
878
|
+
.sm-agents-n .of { font-size: 1rem; color: var(--text-dim); font-weight: 400; }
|
|
879
|
+
.sm-agents-sub { color: var(--text-dim); font-size: 0.82rem; margin-top: 0.25rem; }
|
|
880
|
+
.sm-dotrow { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 0.7rem; }
|
|
881
|
+
.sm-dotrow .d { width: 12px; height: 12px; border-radius: 3px; }
|
|
882
|
+
.sm-dotrow .d.up { background: var(--green); }
|
|
883
|
+
.sm-dotrow .d.down { background: var(--red); }
|
|
884
|
+
.sm-dotrow .d.warn { background: var(--yellow); }
|
|
885
|
+
|
|
886
|
+
/* quota gauges (shared with Accounts tab) */
|
|
887
|
+
.sm-quota-acct { font-size: 0.82rem; color: var(--text-dim); margin-bottom: 0.55rem; }
|
|
888
|
+
.sm-quota-acct b { color: var(--text); font-family: var(--mono); }
|
|
889
|
+
.gauge { margin-bottom: 0.55rem; }
|
|
890
|
+
.gauge:last-child { margin-bottom: 0; }
|
|
891
|
+
.gauge-top { display: flex; justify-content: space-between; align-items: baseline; font-size: 0.78rem; margin-bottom: 0.22rem; }
|
|
892
|
+
.gauge-top .lab { color: var(--text-dim); }
|
|
893
|
+
.gauge-top .val { font-variant-numeric: tabular-nums; font-weight: 600; font-family: var(--mono); }
|
|
894
|
+
.gauge-track { height: 7px; border-radius: 4px; background: rgba(255,255,255,0.08); overflow: hidden; }
|
|
895
|
+
.gauge-fill { height: 100%; border-radius: 4px; }
|
|
896
|
+
.val.lo, .gauge-fill.lo { color: var(--green); }
|
|
897
|
+
.gauge-fill.lo { background: var(--green); }
|
|
898
|
+
.val.mid { color: var(--yellow); }
|
|
899
|
+
.gauge-fill.mid { background: var(--yellow); }
|
|
900
|
+
.val.hi { color: var(--red); }
|
|
901
|
+
.gauge-fill.hi { background: var(--red); }
|
|
902
|
+
.val.na { color: var(--text-dim); }
|
|
903
|
+
.gauge-reset { font-size: 0.7rem; color: var(--text-dim); margin-left: 0.4rem; font-weight: 400; }
|
|
904
|
+
.gauge.stale .val, .gauge.stale .gauge-fill { opacity: 0.5; }
|
|
905
|
+
|
|
906
|
+
/* ---------- services rail: quiet infra, compact ---------- */
|
|
907
|
+
.sm-services { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 0.5rem; }
|
|
908
|
+
.sm-svc { display: flex; align-items: center; gap: 0.6rem; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem 0.8rem; }
|
|
909
|
+
.sm-svc .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
910
|
+
.sm-svc .dot.ok { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
|
911
|
+
.sm-svc .dot.bad { background: var(--red); }
|
|
912
|
+
.sm-svc .dot.idle { background: var(--text-dim); }
|
|
913
|
+
.sm-svc-text { min-width: 0; }
|
|
914
|
+
.sm-svc-name { font-size: 0.85rem; font-weight: 600; }
|
|
915
|
+
.sm-svc-meta { font-size: 0.72rem; color: var(--text-dim); font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
916
|
+
|
|
917
|
+
@media (max-width: 640px) {
|
|
918
|
+
.sm-vitals { grid-template-columns: 1fr; }
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/* ==================================================================
|
|
922
|
+
Accounts quota-triage redesign (.ac-* / .acct-*). Namespaced;
|
|
923
|
+
ported from mock. Reuses the shared .gauge-* rules above.
|
|
924
|
+
================================================================== */
|
|
925
|
+
/* ---------- top control bar ---------- */
|
|
926
|
+
.ac-bar { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.25rem; flex-wrap: wrap; }
|
|
927
|
+
.ac-bar .hint { font-size: 0.78rem; color: var(--text-dim); }
|
|
928
|
+
.ac-posture { display: flex; gap: 0.6rem; margin-left: auto; }
|
|
929
|
+
.ac-stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 0.5rem 0.85rem; text-align: center; min-width: 70px; }
|
|
930
|
+
.ac-stat .n { font-size: 1.35rem; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.1; }
|
|
931
|
+
.ac-stat .l { font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-dim); }
|
|
932
|
+
.ac-stat.attn { border-color: rgba(248,113,113,0.35); }
|
|
933
|
+
.ac-stat.attn .n { color: var(--red); }
|
|
934
|
+
.ac-stat.clean .n { color: var(--green); }
|
|
935
|
+
|
|
936
|
+
.ac-band { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin: 0 0 0.7rem; display: flex; align-items: baseline; gap: 0.45rem; }
|
|
937
|
+
.ac-band .n { color: var(--text); opacity: 0.55; }
|
|
938
|
+
.ac-list { display: flex; flex-direction: column; gap: 0.7rem; margin-bottom: 1.75rem; }
|
|
939
|
+
|
|
940
|
+
/* ---------- account row ---------- */
|
|
941
|
+
.acct {
|
|
942
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
|
943
|
+
padding: 1rem 1.15rem; position: relative; overflow: hidden; transition: border-color 0.15s;
|
|
944
|
+
}
|
|
945
|
+
.acct:hover { border-color: #3a3e4e; }
|
|
946
|
+
.acct.worst { border-color: rgba(248,113,113,0.4); }
|
|
947
|
+
.acct.worst::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--red); }
|
|
948
|
+
|
|
949
|
+
.acct-head { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
|
|
950
|
+
.acct-label { font-family: var(--mono); font-size: 1rem; font-weight: 600; letter-spacing: -0.01em; }
|
|
951
|
+
.acct-fleet { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--blue); border: 1px solid rgba(96,165,250,0.4); border-radius: 5px; padding: 0.08rem 0.4rem; }
|
|
952
|
+
/* health-badge: additive to the existing base rule — the redesign
|
|
953
|
+
gives the badge a leading dot, so switch to inline-flex + gap and
|
|
954
|
+
add the .missing / .exhausted variants the mock uses. */
|
|
955
|
+
.health-badge { display: inline-flex; align-items: center; gap: 0.3rem; }
|
|
956
|
+
.health-badge .dot { width: 7px; height: 7px; border-radius: 50%; }
|
|
957
|
+
.health-badge.healthy .dot { background: var(--green); }
|
|
958
|
+
.health-badge.expired, .health-badge.missing { color: var(--red); background: rgba(248,113,113,0.12); }
|
|
959
|
+
.health-badge.expired .dot, .health-badge.missing .dot { background: var(--red); }
|
|
960
|
+
.health-badge.exhausted { color: var(--yellow); background: rgba(251,191,36,0.12); }
|
|
961
|
+
.health-badge.exhausted .dot { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); }
|
|
962
|
+
.acct-actions { margin-left: auto; display: flex; gap: 0.4rem; }
|
|
963
|
+
|
|
964
|
+
/* exhaustion / expiry callout */
|
|
965
|
+
.acct-alert {
|
|
966
|
+
margin-top: 0.7rem; font-size: 0.82rem; line-height: 1.45;
|
|
967
|
+
border-radius: 0 6px 6px 0; padding: 0.5rem 0.7rem;
|
|
968
|
+
}
|
|
969
|
+
.acct-alert .lab { display: block; font-family: var(--mono); font-size: 0.64rem; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.2rem; }
|
|
970
|
+
.acct-alert.crit { background: rgba(248,113,113,0.07); border-left: 2px solid var(--red); }
|
|
971
|
+
.acct-alert.crit .lab { color: var(--red); }
|
|
972
|
+
.acct-alert.warn { background: rgba(251,191,36,0.06); border-left: 2px solid var(--yellow); }
|
|
973
|
+
.acct-alert.warn .lab { color: var(--yellow); }
|
|
974
|
+
|
|
975
|
+
/* quota gauges */
|
|
976
|
+
.acct-quota { display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem 1.4rem; margin-top: 0.8rem; }
|
|
977
|
+
|
|
978
|
+
/* footer meta: blast radius + secondary facts */
|
|
979
|
+
.acct-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem 1.3rem; margin-top: 0.85rem; padding-top: 0.7rem; border-top: 1px solid var(--border); font-size: 0.78rem; color: var(--text-dim); }
|
|
980
|
+
.acct-foot .k { opacity: 0.7; font-family: var(--mono); font-size: 0.72rem; }
|
|
981
|
+
.used-by { display: inline-flex; gap: 0.25rem; flex-wrap: wrap; }
|
|
982
|
+
.used-by .ag { background: rgba(96,165,250,0.1); color: var(--blue); border-radius: 999px; padding: 0.05rem 0.5rem; font-size: 0.74rem; }
|
|
983
|
+
.used-by .none { color: var(--text-dim); font-style: italic; }
|
|
984
|
+
.acct-foot .probed { font-family: var(--mono); }
|
|
985
|
+
.acct-foot .probed .src { opacity: 0.7; }
|
|
986
|
+
|
|
987
|
+
@media (max-width: 640px) {
|
|
988
|
+
.acct-quota { grid-template-columns: 1fr; }
|
|
989
|
+
.ac-posture { margin-left: 0; }
|
|
990
|
+
}
|
|
557
991
|
</style>
|
|
558
992
|
</head>
|
|
559
993
|
<body>
|
|
@@ -570,6 +1004,7 @@
|
|
|
570
1004
|
<button id="tab-connections" onclick="switchTab('connections')">Connections</button>
|
|
571
1005
|
<button id="tab-schedule" onclick="switchTab('schedule')">Schedule</button>
|
|
572
1006
|
<button id="tab-approvals" onclick="switchTab('approvals')">Approvals</button>
|
|
1007
|
+
<button id="tab-fleet-health" onclick="switchTab('fleet-health')">Fleet Health</button>
|
|
573
1008
|
</nav>
|
|
574
1009
|
<main>
|
|
575
1010
|
<div id="error"></div>
|
|
@@ -581,6 +1016,7 @@
|
|
|
581
1016
|
<div id="connections" style="display:none"></div>
|
|
582
1017
|
<div id="schedule" style="display:none"></div>
|
|
583
1018
|
<div id="approvals" style="display:none"></div>
|
|
1019
|
+
<div id="fleet-health" style="display:none"></div>
|
|
584
1020
|
</main>
|
|
585
1021
|
|
|
586
1022
|
<script>
|
|
@@ -591,6 +1027,10 @@
|
|
|
591
1027
|
let openDetails = new Set();
|
|
592
1028
|
let agentDetails = {};
|
|
593
1029
|
let accounts = [];
|
|
1030
|
+
// The broker's current fleet-active account label, stashed by fetchSummary
|
|
1031
|
+
// (there's no fleet-active flag on /api/accounts). Drives the Accounts tab's
|
|
1032
|
+
// "fleet-active" pill; null until Summary has loaded at least once.
|
|
1033
|
+
let fleetActiveLabel = null;
|
|
594
1034
|
let ws = null;
|
|
595
1035
|
|
|
596
1036
|
function authHeaders() {
|
|
@@ -1257,10 +1697,228 @@
|
|
|
1257
1697
|
}
|
|
1258
1698
|
}
|
|
1259
1699
|
|
|
1700
|
+
async function fetchFleetHealth() {
|
|
1701
|
+
try {
|
|
1702
|
+
const res = await fetch(`${API}/api/fleet-health`, { headers: authHeaders() });
|
|
1703
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
1704
|
+
renderFleetHealth(await res.json());
|
|
1705
|
+
clearError();
|
|
1706
|
+
} catch (err) {
|
|
1707
|
+
showError(`Failed to fetch fleet health: ${err.message}`);
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// Fleet Health — the 23 job specs ranked worst-first by priority_score
|
|
1712
|
+
// (severity × frequency × reach × recency). Each spec drills down to its
|
|
1713
|
+
// distinct issues; each issue links to its occurrences (turn_ids + log
|
|
1714
|
+
// pointers) and its GitHub issue. Empty state when no owner agent is
|
|
1715
|
+
// assigned yet (feature inert). Design: reference/rfcs/fleet-health.md.
|
|
1716
|
+
function renderFleetHealth(data) {
|
|
1717
|
+
const el = document.getElementById('fleet-health');
|
|
1718
|
+
|
|
1719
|
+
if (!data || data.empty) {
|
|
1720
|
+
el.innerHTML =
|
|
1721
|
+
`<div class="loading" style="color:var(--text-dim)">${escapeHtml((data && data.reason) || 'No fleet-health ledger yet.')}</div>`;
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// Fleet-health timestamps are ISO strings on the wire; formatTimestamp
|
|
1726
|
+
// wants epoch ms, so parse first. Date.parse(null/undefined) → NaN →
|
|
1727
|
+
// formatTimestamp returns "—", preserving the mock's "no value" behaviour.
|
|
1728
|
+
const rel = (iso) => formatTimestamp(Date.parse(iso));
|
|
1729
|
+
const SEV_LABEL = { 3: 'severe', 2: 'moderate', 1: 'minor', 0: 'clear' };
|
|
1730
|
+
const scoreColor = (s) => s >= 15 ? 'var(--red)' : s >= 8 ? 'var(--yellow)' : s > 0 ? 'var(--blue)' : 'var(--text-dim)';
|
|
1731
|
+
// `factors` mirrors the read-model scoring inputs but isn't always on the
|
|
1732
|
+
// wire; derive per the RFC when absent (see reference/rfcs/fleet-health.md).
|
|
1733
|
+
const factorsFor = (iss) => {
|
|
1734
|
+
if (iss.factors) return iss.factors;
|
|
1735
|
+
const ms = Date.parse(iss.recency);
|
|
1736
|
+
let rec = 0.1;
|
|
1737
|
+
if (!isNaN(ms)) {
|
|
1738
|
+
const ageDays = (Date.now() - ms) / 86400000;
|
|
1739
|
+
rec = ageDays <= 1 ? 1.0 : Math.max(0.1, 1.0 - (ageDays / 30) * 0.9);
|
|
1740
|
+
}
|
|
1741
|
+
return {
|
|
1742
|
+
freq: Math.log10(1 + (iss.frequency || 0)),
|
|
1743
|
+
reach: (iss.reach || []).length,
|
|
1744
|
+
rec,
|
|
1745
|
+
};
|
|
1746
|
+
};
|
|
1747
|
+
|
|
1748
|
+
// `last_deep_dive` is a PER-RECORD field, not a top-level payload field —
|
|
1749
|
+
// derive the fleet's most-recent deep-dive as the max across records.
|
|
1750
|
+
const lastDeepDiveMs = data.records.reduce((acc, r) => {
|
|
1751
|
+
const t = Date.parse(r.last_deep_dive);
|
|
1752
|
+
return isNaN(t) ? acc : Math.max(acc, t);
|
|
1753
|
+
}, NaN);
|
|
1754
|
+
|
|
1755
|
+
const attention = data.records.filter((r) => r.open_issue_count > 0);
|
|
1756
|
+
const healthy = data.records.filter((r) => r.open_issue_count === 0);
|
|
1757
|
+
// `issues` is not coerced server-side (only `priority_score` is), so a
|
|
1758
|
+
// record with open_issue_count > 0 but missing/undefined `issues` would
|
|
1759
|
+
// throw on deref and blank the whole tab — guard to [] at every read.
|
|
1760
|
+
const issuesOf = (r) => Array.isArray(r.issues) ? r.issues : [];
|
|
1761
|
+
const worstSev = (r) => Math.max(0, ...issuesOf(r).filter(i => i.status !== 'closed').map(i => i.severity));
|
|
1762
|
+
|
|
1763
|
+
// ---- page header: L3 summary + attribution + posture stats ----
|
|
1764
|
+
const top = `
|
|
1765
|
+
<div class="fh-top">
|
|
1766
|
+
<div class="fh-summary">
|
|
1767
|
+
<div class="fh-lede">${data.summary ? escapeHtml(data.summary) : 'No synthesis yet.'}</div>
|
|
1768
|
+
<div class="fh-attribution">
|
|
1769
|
+
<span><span class="k">owner</span> <span class="v">@${escapeHtml(data.owner_agent || 'unassigned')}</span></span>
|
|
1770
|
+
<span class="sep">·</span>
|
|
1771
|
+
<span><span class="k">last scan</span> <span class="v">${rel(data.generated_at)}</span></span>
|
|
1772
|
+
<span class="sep">·</span>
|
|
1773
|
+
<span><span class="k">last deep-dive</span> <span class="v">${formatTimestamp(lastDeepDiveMs)}</span></span>
|
|
1774
|
+
<span class="sep">·</span>
|
|
1775
|
+
<span><span class="k">jobs tracked</span> <span class="v">${data.records.length}</span></span>
|
|
1776
|
+
</div>
|
|
1777
|
+
</div>
|
|
1778
|
+
<div class="fh-posture">
|
|
1779
|
+
<div class="fh-stat attn"><div class="n">${attention.length}</div><div class="l">need attention</div></div>
|
|
1780
|
+
<div class="fh-stat clean"><div class="n">${healthy.length}</div><div class="l">clean</div></div>
|
|
1781
|
+
</div>
|
|
1782
|
+
</div>`;
|
|
1783
|
+
|
|
1784
|
+
// ---- score explainer (why the ranking is trustworthy) ----
|
|
1785
|
+
const explain = `
|
|
1786
|
+
<details class="fh-explain">
|
|
1787
|
+
<summary>
|
|
1788
|
+
<span class="caret">▶</span>
|
|
1789
|
+
<span>How the ranking works — impact, not raw event count</span>
|
|
1790
|
+
<span class="formula">priority = <em>severity</em> × <em>frequency</em> × <em>reach</em> × <em>recency</em></span>
|
|
1791
|
+
</summary>
|
|
1792
|
+
<div class="fh-explain-body">
|
|
1793
|
+
<div class="fh-factors">
|
|
1794
|
+
<div class="fh-factor sev"><div class="fn">Severity <span style="color:var(--text-dim);font-weight:400">1–3</span></div><div class="fd">Did the wrong thing or nothing while reporting success = 3. From the failure-mode taxonomy, not a guess.</div></div>
|
|
1795
|
+
<div class="fh-factor freq"><div class="fn">Frequency</div><div class="fd">log₁₀(1 + count) over the 30-day window — 282 vs 20 is a real gap without swamping the rest.</div></div>
|
|
1796
|
+
<div class="fh-factor reach"><div class="fn">Reach</div><div class="fd">Distinct agents hit. A fleet-wide failure outranks a one-agent quirk.</div></div>
|
|
1797
|
+
<div class="fh-factor rec"><div class="fn">Recency</div><div class="fd">1.0 within 24h, decaying to 0.1 at the window edge. A fixed issue sinks; this is what closes the loop.</div></div>
|
|
1798
|
+
</div>
|
|
1799
|
+
<div style="margin-top:0.7rem;font-size:0.78rem;color:var(--text-dim)">Every factor comes from the model-free nightly sensor — no model judgment enters the score. Evidence is <span style="color:var(--green)">hard-artifact</span> (a structured turns.jsonl record or a precise gateway signature), independently verifiable by the turn id.</div>
|
|
1800
|
+
</div>
|
|
1801
|
+
</details>`;
|
|
1802
|
+
|
|
1803
|
+
// ---- attention list ----
|
|
1804
|
+
const rows = attention.map((r, idx) => {
|
|
1805
|
+
const recIssues = issuesOf(r);
|
|
1806
|
+
const sev = worstSev(r);
|
|
1807
|
+
const worst = idx === 0 ? ' worst' : '';
|
|
1808
|
+
// drive the composition strip off the worst OPEN issue
|
|
1809
|
+
const lead = recIssues.filter(i => i.status !== 'closed')
|
|
1810
|
+
.sort((a, b) => b.severity - a.severity || b.frequency - a.frequency)[0] || recIssues[0];
|
|
1811
|
+
// `lead` can be undefined when open_issue_count > 0 but the issues
|
|
1812
|
+
// array is empty/absent — skip the composition strip rather than throw.
|
|
1813
|
+
const f = lead ? factorsFor(lead) : null;
|
|
1814
|
+
const compose = lead && f ? `
|
|
1815
|
+
<div class="fh-compose" title="priority = severity × frequency × reach × recency">
|
|
1816
|
+
<span class="fh-fpill sev"><b>${Number(lead.severity)}</b><span class="lab">sev</span></span>
|
|
1817
|
+
<span class="fh-times">×</span>
|
|
1818
|
+
<span class="fh-fpill freq"><b>${f.freq.toFixed(2)}</b><span class="lab">freq · ${Number(lead.frequency)}</span></span>
|
|
1819
|
+
<span class="fh-times">×</span>
|
|
1820
|
+
<span class="fh-fpill reach"><b>${f.reach}</b><span class="lab">reach</span></span>
|
|
1821
|
+
<span class="fh-times">×</span>
|
|
1822
|
+
<span class="fh-fpill rec"><b>${f.rec.toFixed(1)}</b><span class="lab">recency</span></span>
|
|
1823
|
+
</div>` : '';
|
|
1824
|
+
|
|
1825
|
+
const issues = recIssues.map((iss) => {
|
|
1826
|
+
// Ledger numerics are not coerced server-side; normalize before
|
|
1827
|
+
// interpolating so a non-numeric value can't leak into markup.
|
|
1828
|
+
const occTotal = Number(iss.occ_total ?? iss.frequency ?? (iss.occurrences || []).length) || 0;
|
|
1829
|
+
const freqFrom = iss.freq_from != null ? Number(iss.freq_from) : null;
|
|
1830
|
+
const trend = freqFrom != null
|
|
1831
|
+
? `<span class="fh-trend"><span class="from">${freqFrom}</span><span class="arrow">→</span><span class="to">${occTotal}</span></span> <span style="opacity:.6">since fix</span>`
|
|
1832
|
+
: `<span class="v">${occTotal}</span> in window`;
|
|
1833
|
+
const occ = (iss.occurrences || []).map((o) => `
|
|
1834
|
+
<div class="occ">
|
|
1835
|
+
<span class="occ-agent">${escapeHtml(o.agent)}</span>
|
|
1836
|
+
<div class="occ-body">
|
|
1837
|
+
<span class="tid">${escapeHtml(o.turn_id)}</span>
|
|
1838
|
+
<span class="ptr">${escapeHtml(o.log_pointer || '').replace(/^([^:]+):/, '<span class="g">$1</span>:')}</span>
|
|
1839
|
+
</div>
|
|
1840
|
+
</div>`).join('');
|
|
1841
|
+
const remaining = occTotal - (iss.occurrences || []).length;
|
|
1842
|
+
const more = remaining > 0 ? `<div class="more">+${remaining} more occurrence${remaining === 1 ? '' : 's'} joined by turn id</div>` : '';
|
|
1843
|
+
const statusCls = iss.status === 'resolved-pending-verify' ? 'verify' : iss.status === 'closed' ? 'closed' : 'open';
|
|
1844
|
+
const statusTxt = iss.status === 'resolved-pending-verify' ? 'verifying' : (iss.status || 'open');
|
|
1845
|
+
const ghLink = iss.gh_issue != null
|
|
1846
|
+
? `<a class="gh-link" href="https://github.com/switchroom/switchroom/issues/${encodeURIComponent(iss.gh_issue)}" target="_blank" rel="noopener">#${escapeHtml(String(iss.gh_issue))} ↗</a>`
|
|
1847
|
+
: '';
|
|
1848
|
+
return `
|
|
1849
|
+
<div class="fh-issue">
|
|
1850
|
+
<div class="fh-issue-top">
|
|
1851
|
+
<span class="mode-chip s${Number(iss.severity) || 0}">${escapeHtml(iss.failure_mode)}</span>
|
|
1852
|
+
<span class="status-tag ${statusCls}">${escapeHtml(statusTxt)}</span>
|
|
1853
|
+
${ghLink}
|
|
1854
|
+
</div>
|
|
1855
|
+
<div class="fh-issue-meta">
|
|
1856
|
+
<span><span class="k" style="opacity:.7">freq</span> ${trend}</span>
|
|
1857
|
+
<span><span class="k" style="opacity:.7">reach</span> <span class="reach-agents">${(iss.reach || []).map(a => `<span class="ag">${escapeHtml(a)}</span>`).join('')}</span></span>
|
|
1858
|
+
<span><span class="k" style="opacity:.7">latest</span> <span class="v">${rel(iss.recency)}</span></span>
|
|
1859
|
+
</div>
|
|
1860
|
+
<div class="fh-evidence">
|
|
1861
|
+
<div class="fh-evidence-h">evidence <span class="tier">hard-artifact</span></div>
|
|
1862
|
+
${iss.detail ? `<div class="fh-why"><span class="fh-why-lab">why it failed</span>${escapeHtml(iss.detail)}</div>` : ''}
|
|
1863
|
+
${occ}${more}
|
|
1864
|
+
</div>
|
|
1865
|
+
</div>`;
|
|
1866
|
+
}).join('');
|
|
1867
|
+
|
|
1868
|
+
return `
|
|
1869
|
+
<div class="fh-rec${worst}" data-open="0">
|
|
1870
|
+
<div class="fh-rec-head" onclick="this.parentNode.classList.toggle('open')">
|
|
1871
|
+
<div class="fh-rank">
|
|
1872
|
+
<div class="score" style="color:${scoreColor(r.priority_score)}">${r.priority_score.toFixed(1)}</div>
|
|
1873
|
+
<div class="rk">#${String(idx + 1).padStart(2, '0')}</div>
|
|
1874
|
+
</div>
|
|
1875
|
+
<div class="fh-rec-main">
|
|
1876
|
+
<div class="fh-rec-title">
|
|
1877
|
+
<span class="fh-job">${escapeHtml(r.job_spec)}</span>
|
|
1878
|
+
</div>
|
|
1879
|
+
<div class="fh-rec-sub">
|
|
1880
|
+
<span><span class="k" style="opacity:.7">scanned</span> <span class="v">${rel(r.last_scanned)}</span></span>
|
|
1881
|
+
<span class="sep">·</span>
|
|
1882
|
+
<span><span class="k" style="opacity:.7">deep-dive</span> <span class="v">${rel(r.last_deep_dive)}</span></span>
|
|
1883
|
+
</div>
|
|
1884
|
+
${compose}
|
|
1885
|
+
</div>
|
|
1886
|
+
<div class="fh-rec-right">
|
|
1887
|
+
<span class="sev-badge s${sev}"><span class="dot"></span>${SEV_LABEL[sev]}</span>
|
|
1888
|
+
<span class="issue-count"><b>${r.open_issue_count}</b> open</span>
|
|
1889
|
+
<span class="fh-caret">▶</span>
|
|
1890
|
+
</div>
|
|
1891
|
+
</div>
|
|
1892
|
+
<div class="fh-issues">${issues}</div>
|
|
1893
|
+
</div>`;
|
|
1894
|
+
}).join('');
|
|
1895
|
+
|
|
1896
|
+
// ---- healthy strip ----
|
|
1897
|
+
const healthyCards = healthy.map((r) => `
|
|
1898
|
+
<div class="fh-hc" title="${escapeHtml(r.job_spec)}">
|
|
1899
|
+
<span class="dot"></span>
|
|
1900
|
+
<div class="fh-hc-text">
|
|
1901
|
+
<span class="job">${escapeHtml(r.job_spec)}</span>
|
|
1902
|
+
</div>
|
|
1903
|
+
<span class="when">${rel(r.last_scanned)}</span>
|
|
1904
|
+
</div>`).join('');
|
|
1905
|
+
|
|
1906
|
+
el.innerHTML = `
|
|
1907
|
+
${top}
|
|
1908
|
+
${explain}
|
|
1909
|
+
<div class="fh-band">Needs attention <span class="n">${attention.length}</span> · ranked worst-first</div>
|
|
1910
|
+
<div class="fh-attention-list">${rows}</div>
|
|
1911
|
+
<div class="fh-band">Clean <span class="n">${healthy.length}</span> · scanned, no open issues</div>
|
|
1912
|
+
<div class="fh-healthy-grid">${healthyCards}</div>`;
|
|
1913
|
+
|
|
1914
|
+
// Open the worst offender by default so the operator lands on the evidence.
|
|
1915
|
+
el.querySelector('.fh-rec.worst')?.classList.add('open');
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1260
1918
|
// Canonical tab list — kept in sync with the nav buttons above and with
|
|
1261
1919
|
// SPA_TAB_ROUTES in src/web/server.ts (the server serves the SPA shell for
|
|
1262
1920
|
// these paths so a reload/deep-link doesn't 404).
|
|
1263
|
-
const TAB_NAMES = ['summary', 'agents', 'accounts', 'system', 'memory', 'connections', 'schedule', 'approvals'];
|
|
1921
|
+
const TAB_NAMES = ['summary', 'agents', 'accounts', 'system', 'memory', 'connections', 'schedule', 'approvals', 'fleet-health'];
|
|
1264
1922
|
|
|
1265
1923
|
// Derive the active tab from the URL path (`/connections` → 'connections',
|
|
1266
1924
|
// '/' → 'summary'). Unknown paths fall back to summary.
|
|
@@ -1291,6 +1949,7 @@
|
|
|
1291
1949
|
if (tab === 'connections') fetchConnections();
|
|
1292
1950
|
if (tab === 'schedule') fetchSchedule();
|
|
1293
1951
|
if (tab === 'approvals') fetchApprovals();
|
|
1952
|
+
if (tab === 'fleet-health') fetchFleetHealth();
|
|
1294
1953
|
}
|
|
1295
1954
|
|
|
1296
1955
|
// Back/forward: restore the tab from the history entry (or the path).
|
|
@@ -1322,87 +1981,106 @@
|
|
|
1322
1981
|
appr = summary.approvals, sched = summary.schedule,
|
|
1323
1982
|
accts = summary.accounts;
|
|
1324
1983
|
clearError();
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1984
|
+
el.classList.remove('loading');
|
|
1985
|
+
|
|
1986
|
+
// Stash the broker's fleet-active account label so the Accounts tab can
|
|
1987
|
+
// flag it (there's no fleet-active flag on /api/accounts itself).
|
|
1988
|
+
if (sys && sys.broker && sys.broker.active) fleetActiveLabel = sys.broker.active;
|
|
1989
|
+
|
|
1990
|
+
const agList = Array.isArray(ag) ? ag : [];
|
|
1991
|
+
const up = agList.filter(a => a.active === 'active').length;
|
|
1992
|
+
const attnItems = Array.isArray(summary.attention) ? summary.attention : [];
|
|
1993
|
+
const crit = attnItems.filter(a => a.severity === 'critical').length;
|
|
1994
|
+
const warn = attnItems.filter(a => a.severity === 'warn').length;
|
|
1995
|
+
const attnCount = attnItems.length;
|
|
1996
|
+
|
|
1997
|
+
// ---- posture verdict ----
|
|
1998
|
+
const verdict = attnCount === 0
|
|
1999
|
+
? `<div class="sm-verdict ok"><span class="pulse"></span><h2>Fleet is healthy</h2></div>`
|
|
2000
|
+
: `<div class="sm-verdict attn"><span class="pulse"></span><h2><b>${crit || attnCount}</b> ${crit ? 'critical' : 'item' + (attnCount === 1 ? '' : 's')}${crit && warn ? ` · ${warn} warning${warn === 1 ? '' : 's'}` : ''} need${(crit || attnCount) === 1 ? 's' : ''} you</h2></div>`;
|
|
2001
|
+
const acctCount = (sys && sys.broker && sys.broker.accounts != null) ? sys.broker.accounts : '—';
|
|
2002
|
+
const asOfVal = (typeof summary.dataAsOf === 'number' && summary.dataAsOf > 0)
|
|
2003
|
+
? formatTimestamp(summary.dataAsOf) : '—';
|
|
2004
|
+
const asOf = `<div class="sm-asof">data as of ${escapeHtml(asOfVal)} · ${agList.length} agents · ${escapeHtml(String(acctCount))} accounts</div>`;
|
|
2005
|
+
|
|
2006
|
+
// ---- attention hero (renderAttention builds the worst-first list) ----
|
|
2007
|
+
const attnHtml = renderAttention(attnItems);
|
|
2008
|
+
|
|
2009
|
+
// ---- vitals: agents + quota ----
|
|
2010
|
+
const dots = agList.map(a => `<span class="d ${a.active === 'active' ? 'up' : 'down'}" title="${escapeHtml(a.name)} — ${escapeHtml(a.active)}"></span>`).join('');
|
|
2011
|
+
const down = agList.length - up;
|
|
2012
|
+
const agentsCard = `
|
|
2013
|
+
<div class="sm-vcard">
|
|
2014
|
+
<div class="vh">Agents <a onclick="switchTab('agents')">agents →</a></div>
|
|
2015
|
+
<div class="sm-agents-n">${up}<span class="of"> / ${agList.length}</span> <span class="of">active</span></div>
|
|
2016
|
+
<div class="sm-agents-sub">${down === 0 ? 'all running' : `${down} down — ${agList.filter(a => a.active !== 'active').map(a => escapeHtml(a.name)).join(', ')}`}</div>
|
|
2017
|
+
<div class="sm-dotrow">${dots}</div>
|
|
1332
2018
|
</div>`;
|
|
1333
|
-
const dim = (s) => `<span style="color:var(--text-dim)">${escapeHtml(s)}</span>`;
|
|
1334
2019
|
|
|
1335
|
-
//
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
const active = d.filter(x => !x.revoked_at && !(x.ttl_expires_at && x.ttl_expires_at < Date.now())).length;
|
|
1371
|
-
apprTile = tile('Approvals', `<strong>${active}</strong> active · ${d.length} total`, true);
|
|
1372
|
-
} else apprTile = tile('Approvals', dim((appr && appr.error) ? 'kernel unreachable' : 'unavailable'), null);
|
|
1373
|
-
|
|
1374
|
-
// Schedule
|
|
1375
|
-
let schedTile;
|
|
1376
|
-
if (sched && Array.isArray(sched.entries)) {
|
|
1377
|
-
const agents = new Set(sched.entries.map(e => e.agent));
|
|
1378
|
-
schedTile = tile('Schedule', `<strong>${sched.entries.length}</strong> cron entr${sched.entries.length === 1 ? 'y' : 'ies'} · ${agents.size} agent(s)`, true);
|
|
1379
|
-
} else schedTile = tile('Schedule', dim('unavailable'), null);
|
|
1380
|
-
|
|
1381
|
-
// Quota headroom — worst cached 5h/7d across accounts (no probe;
|
|
1382
|
-
// shows "—" until someone refreshes on the Accounts tab).
|
|
1383
|
-
let quotaTile;
|
|
1384
|
-
if (Array.isArray(accts) && accts.length) {
|
|
1385
|
-
const used = accts.filter(a => a.quotaUsage);
|
|
1386
|
-
if (used.length === 0) {
|
|
1387
|
-
quotaTile = tile('Quota', dim('not probed yet — see Accounts tab'), null);
|
|
1388
|
-
} else {
|
|
1389
|
-
const worst5 = Math.max(...used.map(a => a.quotaUsage.fiveHourPct));
|
|
1390
|
-
const worst7 = Math.max(...used.map(a => a.quotaUsage.sevenDayPct));
|
|
1391
|
-
const anyStale = accts.some(a => a.quotaStale);
|
|
1392
|
-
quotaTile = tile('Quota (worst acct)',
|
|
1393
|
-
`5h <strong>${Math.round(worst5)}%</strong> · 7d <strong>${Math.round(worst7)}%</strong>${anyStale ? '<br>' + dim('some stale') : ''}`,
|
|
1394
|
-
worst5 < 90 && worst7 < 90);
|
|
1395
|
-
}
|
|
1396
|
-
} else quotaTile = tile('Quota', dim('unavailable'), null);
|
|
2020
|
+
// Quota — worst probed account, computed client-side from the accounts
|
|
2021
|
+
// payload (same Math.max(5h,7d) worst-across-accounts as the old tile).
|
|
2022
|
+
const qband = (p) => p >= 90 ? 'hi' : p >= 70 ? 'mid' : 'lo';
|
|
2023
|
+
const accts2 = Array.isArray(accts) ? accts : [];
|
|
2024
|
+
const usedAccts = accts2.filter(a => a.quotaUsage);
|
|
2025
|
+
let quotaCard;
|
|
2026
|
+
if (accts2.length === 0) {
|
|
2027
|
+
quotaCard = `<div class="sm-vcard"><div class="vh">Quota headroom <a onclick="switchTab('accounts')">accounts →</a></div><div class="sm-quota-acct">unavailable</div></div>`;
|
|
2028
|
+
} else if (usedAccts.length === 0) {
|
|
2029
|
+
quotaCard = `<div class="sm-vcard"><div class="vh">Quota headroom <a onclick="switchTab('accounts')">accounts →</a></div><div class="sm-quota-acct">not probed yet — see accounts tab</div></div>`;
|
|
2030
|
+
} else {
|
|
2031
|
+
const pk = (a) => a.quota && a.quota.exhausted ? 1000 : Math.max(a.quotaUsage.fiveHourPct, a.quotaUsage.sevenDayPct);
|
|
2032
|
+
const worst = usedAccts.slice().sort((a, b) => pk(b) - pk(a))[0];
|
|
2033
|
+
const q = {
|
|
2034
|
+
label: worst.label,
|
|
2035
|
+
fiveHourPct: worst.quotaUsage.fiveHourPct,
|
|
2036
|
+
sevenDayPct: worst.quotaUsage.sevenDayPct,
|
|
2037
|
+
exhausted: !!(worst.quota && worst.quota.exhausted),
|
|
2038
|
+
exhaustedUntil: (worst.quota && worst.quota.exhausted_until) || null,
|
|
2039
|
+
};
|
|
2040
|
+
const gauge = (lab, pct, reset) => {
|
|
2041
|
+
const b = qband(pct);
|
|
2042
|
+
return `<div class="gauge">
|
|
2043
|
+
<div class="gauge-top"><span class="lab">${lab}${reset ? `<span class="gauge-reset">resets ${escapeHtml(formatTimestamp(reset))}</span>` : ''}</span><span class="val ${b}">${Math.round(pct)}%</span></div>
|
|
2044
|
+
<div class="gauge-track"><span class="gauge-fill ${b}" style="width:${Math.min(100, pct)}%"></span></div>
|
|
2045
|
+
</div>`;
|
|
2046
|
+
};
|
|
2047
|
+
quotaCard = `
|
|
2048
|
+
<div class="sm-vcard">
|
|
2049
|
+
<div class="vh">Quota headroom <a onclick="switchTab('accounts')">accounts →</a></div>
|
|
2050
|
+
<div class="sm-quota-acct">worst account · <b>${escapeHtml(q.label || '—')}</b>${q.exhausted ? ` · <span style="color:var(--red)">exhausted</span>` : ''}</div>
|
|
2051
|
+
${gauge('5-hour window', q.fiveHourPct ?? 0, q.exhausted ? q.exhaustedUntil : null)}
|
|
2052
|
+
${gauge('7-day window', q.sevenDayPct ?? 0, null)}
|
|
2053
|
+
</div>`;
|
|
2054
|
+
}
|
|
1397
2055
|
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
const
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
2056
|
+
// ---- services rail ----
|
|
2057
|
+
const svc = (name, ok, meta, idle) => `
|
|
2058
|
+
<div class="sm-svc"><span class="dot ${idle ? 'idle' : ok ? 'ok' : 'bad'}"></span>
|
|
2059
|
+
<div class="sm-svc-text"><div class="sm-svc-name">${escapeHtml(name)}</div><div class="sm-svc-meta">${meta}</div></div></div>`;
|
|
2060
|
+
const b = (sys && sys.broker) || {}, hs = (sys && sys.hindsight) || {}, hd = (sys && sys.hostd) || {};
|
|
2061
|
+
// Approvals active grants — same non-revoked/non-expired count the old
|
|
2062
|
+
// tile used (the payload carries `decisions`, not a pre-derived count).
|
|
2063
|
+
const apprReachable = !!(appr && appr.reachable !== false);
|
|
2064
|
+
const apprDecisions = (appr && appr.decisions) || [];
|
|
2065
|
+
const activeGrants = apprDecisions.filter(x => !x.revoked_at && !(x.ttl_expires_at && x.ttl_expires_at < Date.now())).length;
|
|
2066
|
+
const schedEntries = (sched && Array.isArray(sched.entries)) ? sched.entries : [];
|
|
2067
|
+
const schedAgents = new Set(schedEntries.map(e => e.agent)).size;
|
|
2068
|
+
const services = [
|
|
2069
|
+
svc('auth-broker', !!b.reachable, b.reachable ? `active ${escapeHtml(b.active || '—')} · ${b.accounts ?? '?'} accts` : 'unreachable'),
|
|
2070
|
+
svc('hindsight', !!hs.running, hs.running ? `${escapeHtml(hs.model || '?')} · ${hs.mcpStateless ? 'stateless' : 'stateful'}` : 'not running'),
|
|
2071
|
+
svc('hostd', !!hd.auditLogPresent, hd.auditLogPresent ? `${(hd.recent || []).length} recent verbs` : 'no audit log'),
|
|
2072
|
+
svc('schedule', schedEntries.length > 0, `${schedEntries.length} crons · ${schedAgents} agents`),
|
|
2073
|
+
svc('approvals', apprReachable, apprReachable ? `${activeGrants} active grants` : 'unreachable'),
|
|
2074
|
+
].join('');
|
|
2075
|
+
|
|
2076
|
+
el.innerHTML = `
|
|
2077
|
+
${verdict}${asOf}
|
|
2078
|
+
<div class="sm-band">Needs attention <span class="n">${attnCount}</span> · worst-first</div>
|
|
2079
|
+
<div class="sm-attn-list">${attnHtml}</div>
|
|
2080
|
+
<div class="sm-band">Fleet vitals</div>
|
|
2081
|
+
<div class="sm-vitals">${agentsCard}${quotaCard}</div>
|
|
2082
|
+
<div class="sm-band">Services <span class="n">all nominal</span></div>
|
|
2083
|
+
<div class="sm-services">${services}</div>`;
|
|
1406
2084
|
}
|
|
1407
2085
|
|
|
1408
2086
|
// "Needs attention" triage strip — the FIRST block on the Summary tab.
|
|
@@ -1413,20 +2091,24 @@
|
|
|
1413
2091
|
// "nothing needs attention" line. Every interpolated value is escaped.
|
|
1414
2092
|
function renderAttention(items) {
|
|
1415
2093
|
if (!Array.isArray(items) || items.length === 0) {
|
|
1416
|
-
return `<
|
|
2094
|
+
return `<button class="sm-attn calm"><span class="sev" style="color:var(--green);background:rgba(52,211,153,.12)">clear</span><div class="sm-attn-body"><div class="sm-attn-title">Nothing needs attention</div><div class="sm-attn-detail">Every job spec, account, and service is within tolerance.</div></div></button>`;
|
|
1417
2095
|
}
|
|
1418
|
-
const
|
|
2096
|
+
const rank = { critical: 0, warn: 1, info: 2 };
|
|
2097
|
+
const sorted = items.slice().sort((a, b) => (rank[a.severity] ?? 9) - (rank[b.severity] ?? 9));
|
|
2098
|
+
return sorted.map((it) => {
|
|
1419
2099
|
const sev = (it && it.severity) || 'info';
|
|
1420
|
-
const muted = sev === 'info' ? ' muted' : '';
|
|
1421
2100
|
const tab = String((it && it.tab) || 'summary');
|
|
1422
|
-
const title = `<div class="problem-title">${escapeHtml((it && it.title) || 'Attention')}</div>`;
|
|
1423
|
-
const detail = it && it.detail
|
|
1424
|
-
? `<div class="problem-detail">${escapeHtml(it.detail)}</div>`
|
|
1425
|
-
: '';
|
|
1426
2101
|
// Whole row clickable → jump to the tab to act on it.
|
|
1427
|
-
return
|
|
2102
|
+
return `
|
|
2103
|
+
<button class="sm-attn ${escapeHtml(sev)}" onclick="switchTab('${escapeHtml(tab)}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();switchTab('${escapeHtml(tab)}')}">
|
|
2104
|
+
<span class="sev">${escapeHtml(sev)}</span>
|
|
2105
|
+
<div class="sm-attn-body">
|
|
2106
|
+
<div class="sm-attn-title">${escapeHtml((it && it.title) || 'Attention')}</div>
|
|
2107
|
+
${it && it.detail ? `<div class="sm-attn-detail">${escapeHtml(it.detail)}</div>` : ''}
|
|
2108
|
+
</div>
|
|
2109
|
+
<span class="sm-attn-go">${escapeHtml(tab)} →</span>
|
|
2110
|
+
</button>`;
|
|
1428
2111
|
}).join('');
|
|
1429
|
-
return `<div style="margin-bottom:.75rem">${rows}</div>`;
|
|
1430
2112
|
}
|
|
1431
2113
|
|
|
1432
2114
|
function showError(msg) {
|
|
@@ -1828,124 +2510,121 @@
|
|
|
1828
2510
|
}
|
|
1829
2511
|
|
|
1830
2512
|
function renderAccounts() {
|
|
1831
|
-
const
|
|
2513
|
+
const el = document.getElementById('accounts');
|
|
1832
2514
|
if (!accounts || accounts.length === 0) {
|
|
1833
|
-
|
|
2515
|
+
el.innerHTML = '<div class="loading">No accounts. Run <code>switchroom auth account add <label></code>.</div>';
|
|
1834
2516
|
return;
|
|
1835
2517
|
}
|
|
1836
|
-
// RFC-H shape: the API returns `usedBy`
|
|
1837
|
-
// fleet-active account or a per-agent override)
|
|
1838
|
-
// `quota` AccountState ({exhausted, exhausted_until,
|
|
1839
|
-
// threshold_violations
|
|
1840
|
-
//
|
|
1841
|
-
//
|
|
1842
|
-
//
|
|
1843
|
-
|
|
1844
|
-
//
|
|
1845
|
-
//
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
let cls = 'quota-pct';
|
|
1853
|
-
if (pct >= 90) cls += ' high';
|
|
1854
|
-
else if (pct >= 70) cls += ' mid';
|
|
1855
|
-
const v = `${Math.round(pct)}%`;
|
|
1856
|
-
const reset = resetAt
|
|
1857
|
-
? `<div style="font-size:.72rem;color:var(--text-dim);font-weight:normal;margin-top:.1rem">resets ${formatTimestamp(resetAt)}</div>`
|
|
1858
|
-
: '';
|
|
1859
|
-
const pctSpan = stale
|
|
1860
|
-
? `<span class="${cls}" title="stale — click ↻" style="opacity:.55">${v}</span>`
|
|
1861
|
-
: `<span class="${cls}">${v}</span>`;
|
|
1862
|
-
return reset ? `<div>${pctSpan}${reset}</div>` : pctSpan;
|
|
2518
|
+
// Worst-first quota triage. RFC-H shape: the API returns `usedBy`
|
|
2519
|
+
// (agents bound via the fleet-active account or a per-agent override),
|
|
2520
|
+
// a broker `quota` AccountState ({exhausted, exhausted_until,
|
|
2521
|
+
// threshold_violations}), `quotaUsage` (5h/7d + reset/capturedAt ms),
|
|
2522
|
+
// `quotaUsageSource`, `quotaStale`, `health`, and `expiresAt` (ms).
|
|
2523
|
+
// Timestamps are already epoch-ms on the wire, so formatTimestamp
|
|
2524
|
+
// takes them directly (the mock's relTime maps to formatTimestamp).
|
|
2525
|
+
const qband = (p) => p == null ? 'na' : p >= 90 ? 'hi' : p >= 70 ? 'mid' : 'lo';
|
|
2526
|
+
// health → badge bucket (exhausted always wins). Server sends
|
|
2527
|
+
// "missing-credentials"/"missing-refresh-token" → collapse to "missing".
|
|
2528
|
+
const healthBucket = (a) => {
|
|
2529
|
+
if (a.quota && a.quota.exhausted) return 'exhausted';
|
|
2530
|
+
const h = a.health || '';
|
|
2531
|
+
if (h === 'expired') return 'expired';
|
|
2532
|
+
if (h.indexOf('missing') === 0) return 'missing';
|
|
2533
|
+
return 'healthy';
|
|
1863
2534
|
};
|
|
1864
|
-
const
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
const
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
<
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
2535
|
+
const isExpiredish = (a) => a.health === 'expired' || (a.health || '').indexOf('missing') === 0;
|
|
2536
|
+
// higher = more urgent → sorts to top
|
|
2537
|
+
const pressure = (a) => {
|
|
2538
|
+
if (a.quota && a.quota.exhausted) return 100 + ((a.quotaUsage && a.quotaUsage.fiveHourPct) || 0);
|
|
2539
|
+
// expired-but-unused isn't more urgent than a near-limit account that's
|
|
2540
|
+
// actively serving agents — rank it below live quota pressure.
|
|
2541
|
+
if (isExpiredish(a)) return (a.usedBy && a.usedBy.length ? 95 : 80);
|
|
2542
|
+
const u = a.quotaUsage;
|
|
2543
|
+
return u ? Math.max(u.fiveHourPct, u.sevenDayPct) : -1;
|
|
2544
|
+
};
|
|
2545
|
+
const needsAttention = (a) => (a.quota && a.quota.exhausted) || isExpiredish(a)
|
|
2546
|
+
|| (a.quotaUsage && Math.max(a.quotaUsage.fiveHourPct, a.quotaUsage.sevenDayPct) >= 85);
|
|
2547
|
+
|
|
2548
|
+
const gauge = (lab, pct, reset, stale) => {
|
|
2549
|
+
const b = qband(pct);
|
|
2550
|
+
const val = pct == null ? '—' : Math.round(pct) + '%';
|
|
2551
|
+
return `<div class="gauge ${stale ? 'stale' : ''}">
|
|
2552
|
+
<div class="gauge-top"><span class="lab">${lab}${reset && pct != null ? `<span class="gauge-reset">resets ${escapeHtml(formatTimestamp(reset))}</span>` : ''}</span><span class="val ${b}">${val}${stale ? ' ·stale' : ''}</span></div>
|
|
2553
|
+
<div class="gauge-track"><span class="gauge-fill ${b}" style="width:${pct == null ? 0 : Math.min(100, pct)}%"></span></div>
|
|
2554
|
+
</div>`;
|
|
2555
|
+
};
|
|
2556
|
+
|
|
2557
|
+
const sorted = accounts.slice().sort((a, b) => pressure(b) - pressure(a));
|
|
2558
|
+
const attention = sorted.filter(needsAttention);
|
|
2559
|
+
const clean = sorted.filter(a => !needsAttention(a));
|
|
2560
|
+
|
|
2561
|
+
const card = (a, worst) => {
|
|
2562
|
+
const hb = healthBucket(a);
|
|
2563
|
+
const hlabel = (a.quota && a.quota.exhausted) ? 'quota-exhausted' : (a.health || 'healthy');
|
|
2564
|
+
const fleetActive = fleetActiveLabel != null && a.label === fleetActiveLabel;
|
|
2565
|
+
const usedBy = a.usedBy || [];
|
|
2566
|
+
// alert callout
|
|
2567
|
+
let alert = '';
|
|
2568
|
+
if (a.quota && a.quota.exhausted) {
|
|
2569
|
+
alert = `<div class="acct-alert crit"><span class="lab">quota exhausted</span>5-hour window is spent — bound agents' next turns will fail until it resets <b>${escapeHtml(formatTimestamp(a.quota.exhausted_until))}</b> (${escapeHtml(usedBy.join(', ')) || 'no agents bound'}).</div>`;
|
|
2570
|
+
} else if (isExpiredish(a)) {
|
|
2571
|
+
alert = `<div class="acct-alert crit"><span class="lab">token expired</span>Re-auth needed — <code>switchroom auth account refresh ${escapeHtml(a.label)}</code>.${usedBy.length ? '' : ' No agents are currently bound, so nothing is failing yet.'}</div>`;
|
|
2572
|
+
} else if (a.quotaUsage && Math.max(a.quotaUsage.fiveHourPct, a.quotaUsage.sevenDayPct) >= 85) {
|
|
2573
|
+
alert = `<div class="acct-alert warn"><span class="lab">approaching limit</span>7-day window at ${Math.round(a.quotaUsage.sevenDayPct)}% — expect throttling for ${escapeHtml(usedBy.join(', ')) || 'bound agents'} soon.</div>`;
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
const u = a.quotaUsage;
|
|
2577
|
+
const quota = u
|
|
2578
|
+
? `<div class="acct-quota">
|
|
2579
|
+
${gauge('5-hour', u.fiveHourPct, u.fiveHourResetAt, a.quotaStale)}
|
|
2580
|
+
${gauge('7-day', u.sevenDayPct, u.sevenDayResetAt, a.quotaStale)}
|
|
2581
|
+
</div>`
|
|
2582
|
+
: `<div class="acct-quota"><div style="grid-column:1/-1;font-size:.8rem;color:var(--text-dim)">Quota not probed${isExpiredish(a) ? ' — token expired' : ''}. <span style="color:var(--blue);cursor:pointer" onclick="refreshQuota('${escapeHtml(a.label)}', true)">↻ probe now</span></div></div>`;
|
|
2583
|
+
|
|
2584
|
+
const usedByHtml = usedBy.length
|
|
2585
|
+
? `<span class="used-by">${usedBy.map(n => `<span class="ag">${escapeHtml(n)}</span>`).join('')}</span>`
|
|
2586
|
+
: `<span class="used-by"><span class="none">unused</span></span>`;
|
|
2587
|
+
const probed = (u && u.capturedAt)
|
|
2588
|
+
? `<span class="probed">${escapeHtml(formatTimestamp(u.capturedAt))} <span class="src">${a.quotaUsageSource === 'broker-cache' ? '(cache, free)' : '(live probe)'}</span></span>`
|
|
2589
|
+
: `<span class="probed">never</span>`;
|
|
2590
|
+
const viol = (a.quota && a.quota.threshold_violations)
|
|
2591
|
+
? `<span style="color:var(--yellow)">${a.quota.threshold_violations}</span>` : '0';
|
|
2592
|
+
|
|
1908
2593
|
return `
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
<div class="
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
<div
|
|
2594
|
+
<div class="acct ${worst ? 'worst' : ''}">
|
|
2595
|
+
<div class="acct-head">
|
|
2596
|
+
<span class="acct-label">${escapeHtml(a.label)}</span>
|
|
2597
|
+
${fleetActive ? `<span class="acct-fleet">fleet-active</span>` : ''}
|
|
2598
|
+
<span class="health-badge ${hb}"><span class="dot"></span>${escapeHtml(hlabel)}</span>
|
|
2599
|
+
<span class="acct-actions">
|
|
2600
|
+
<button class="btn" title="Probe live quota now (billed Anthropic call)" onclick="refreshQuota('${escapeHtml(a.label)}', true)">↻ Probe</button>
|
|
2601
|
+
${fleetActive ? '' : `<button class="btn" onclick="openPromoteModal('${escapeHtml(a.label)}')">Make fleet-active…</button>`}
|
|
2602
|
+
</span>
|
|
2603
|
+
</div>
|
|
2604
|
+
${alert}
|
|
2605
|
+
${quota}
|
|
2606
|
+
<div class="acct-foot">
|
|
2607
|
+
<span><span class="k">used by</span> ${usedByHtml}</span>
|
|
2608
|
+
<span><span class="k">probed</span> ${probed}</span>
|
|
2609
|
+
<span><span class="k">threshold viols</span> ${viol}</span>
|
|
2610
|
+
<span><span class="k">token expires</span> ${escapeHtml(formatTimestamp(a.expiresAt))}</span>
|
|
2611
|
+
</div>
|
|
2612
|
+
</div>`;
|
|
2613
|
+
};
|
|
2614
|
+
|
|
2615
|
+
el.innerHTML = `
|
|
2616
|
+
<div class="ac-bar">
|
|
1932
2617
|
<button class="btn" onclick="refreshQuota(null, true)">↻ Refresh all quota</button>
|
|
1933
|
-
<span
|
|
1934
|
-
|
|
2618
|
+
<span class="hint">5h/7d usage is cached (≤10 min); ↻ forces a live probe — one billed call per account.</span>
|
|
2619
|
+
<span class="ac-posture">
|
|
2620
|
+
<span class="ac-stat attn"><span class="n">${attention.length}</span><span class="l">need attention</span></span>
|
|
2621
|
+
<span class="ac-stat clean"><span class="n">${clean.length}</span><span class="l">healthy</span></span>
|
|
1935
2622
|
</span>
|
|
1936
2623
|
</div>
|
|
1937
|
-
|
|
1938
|
-
<
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
<th>5h %</th><th>7d %</th><th>Probed</th>
|
|
1942
|
-
<th>Quota</th><th>Threshold viols</th><th>Expires</th><th></th>
|
|
1943
|
-
</tr></thead>
|
|
1944
|
-
<tbody>${rows}</tbody>
|
|
1945
|
-
</table>
|
|
1946
|
-
</div>
|
|
1947
|
-
<div class="accounts-grid">${cards}</div>
|
|
1948
|
-
`;
|
|
2624
|
+
${attention.length ? `<div class="ac-band">Needs attention <span class="n">${attention.length}</span> · worst-first</div>
|
|
2625
|
+
<div class="ac-list">${attention.map((a, i) => card(a, i === 0)).join('')}</div>` : ''}
|
|
2626
|
+
<div class="ac-band">Healthy <span class="n">${clean.length}</span> · within tolerance</div>
|
|
2627
|
+
<div class="ac-list">${clean.map(a => card(a, false)).join('') || '<div style="color:var(--text-dim);font-size:.85rem">No healthy accounts.</div>'}</div>`;
|
|
1949
2628
|
}
|
|
1950
2629
|
|
|
1951
2630
|
// Refresh cached quota. label=null → all accounts. force=true →
|
|
@@ -1970,22 +2649,6 @@
|
|
|
1970
2649
|
}
|
|
1971
2650
|
}
|
|
1972
2651
|
|
|
1973
|
-
function renderUsedByCell(usedBy) {
|
|
1974
|
-
// RFC-H: a single fleet-active account plus optional per-agent
|
|
1975
|
-
// overrides. `usedBy` is the flat list of agents currently bound
|
|
1976
|
-
// to this account (either via fleet-active or an override). No
|
|
1977
|
-
// more primary/fallback split — binding is singular post-RFC-H.
|
|
1978
|
-
if (!usedBy || usedBy.length === 0) {
|
|
1979
|
-
return '<span style="color:var(--text-dim)">unused</span>';
|
|
1980
|
-
}
|
|
1981
|
-
const MAX_INLINE = 4;
|
|
1982
|
-
const title = `bound: ${usedBy.join(', ')}`;
|
|
1983
|
-
const shown = usedBy.length <= MAX_INLINE
|
|
1984
|
-
? usedBy.map(escapeHtml).join(', ')
|
|
1985
|
-
: `${usedBy.slice(0, MAX_INLINE).map(escapeHtml).join(', ')} +${usedBy.length - MAX_INLINE}`;
|
|
1986
|
-
return `<span class="usage-pill primary" title="${escapeHtml(title)}">${shown}</span>`;
|
|
1987
|
-
}
|
|
1988
|
-
|
|
1989
2652
|
function renderSystemHealth(h) {
|
|
1990
2653
|
const container = document.getElementById('system');
|
|
1991
2654
|
const b = h.broker || {};
|