@miller-tech/uap 1.134.0 → 1.135.2
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/.tsbuildinfo +1 -1
- package/dist/cli/deliver.js +11 -2
- package/dist/cli/deliver.js.map +1 -1
- package/dist/dashboard/controls.d.ts +66 -0
- package/dist/dashboard/controls.d.ts.map +1 -0
- package/dist/dashboard/controls.js +188 -0
- package/dist/dashboard/controls.js.map +1 -0
- package/dist/dashboard/data-service.d.ts +20 -0
- package/dist/dashboard/data-service.d.ts.map +1 -1
- package/dist/dashboard/data-service.js +47 -0
- package/dist/dashboard/data-service.js.map +1 -1
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +81 -5
- package/dist/dashboard/server.js.map +1 -1
- package/dist/delivery/acceptance-judge.d.ts +1 -1
- package/dist/delivery/acceptance-judge.d.ts.map +1 -1
- package/dist/delivery/acceptance-judge.js +78 -5
- package/dist/delivery/acceptance-judge.js.map +1 -1
- package/dist/delivery/convergence-loop.d.ts +5 -0
- package/dist/delivery/convergence-loop.d.ts.map +1 -1
- package/dist/delivery/convergence-loop.js +11 -0
- package/dist/delivery/convergence-loop.js.map +1 -1
- package/dist/delivery/run-state.d.ts +12 -0
- package/dist/delivery/run-state.d.ts.map +1 -1
- package/dist/delivery/run-state.js +58 -1
- package/dist/delivery/run-state.js.map +1 -1
- package/docs/DASHBOARD_UPLIFT_SPEC.md +171 -0
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +27 -0
- package/web/dash/core.js +398 -0
- package/web/dash/styles.css +307 -0
- package/web/dash/tab-deliver.js +12 -0
- package/web/dash/tab-memory.js +12 -0
- package/web/dash/tab-models.js +12 -0
- package/web/dash/tab-policies.js +12 -0
- package/web/dash/tabs.js +581 -0
- package/web/dashboard.html +12 -1303
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# UAP Dashboard Uplift — Architecture & UI Spec
|
|
2
|
+
|
|
3
|
+
Goal: replace the monolithic `web/dashboard.html` with a modular, tabbed dashboard that shows
|
|
4
|
+
aggregate high-level info AND per-agent/session drill-down, covers every UAP feature, supports
|
|
5
|
+
drill in/out + expand/collapse, and adds full-lifecycle management (start/stop/delete/etc) for
|
|
6
|
+
tasks, epics, orchestration, deliver runs, and agents.
|
|
7
|
+
|
|
8
|
+
## Design system — UAP Console (MANDATORY, design gate enforced)
|
|
9
|
+
Use ONLY these tokens. No off-token hex, no off-scale spacing.
|
|
10
|
+
```
|
|
11
|
+
--bg:#0d1117; --surface:#161b22; --surface-raised:#21262d; --border:#30363d;
|
|
12
|
+
--text:#c9d1d9; --text-muted:#8b949e; --text-subtle:#484f58; --primary:#58a6ff;
|
|
13
|
+
```
|
|
14
|
+
Existing accent tokens already in the page (keep, they are on-system):
|
|
15
|
+
`--cyan --green --yellow --red --purple --orange`.
|
|
16
|
+
Fonts: 'SF Mono','Fira Code','Cascadia Code',monospace for data; system sans for prose.
|
|
17
|
+
Spacing scale: 4,8,12,16,24,32 px only. Radius: var(--radius) (6px). Dark theme only.
|
|
18
|
+
|
|
19
|
+
## File layout (each file is one deliver mission — no shared-file clobbering)
|
|
20
|
+
```
|
|
21
|
+
web/dashboard.html thin shell: <head> loads styles.css + vendored uPlot; body has
|
|
22
|
+
header, tab bar, one <main id="view"> mount, loads core.js (module).
|
|
23
|
+
web/dash/core.js data layer + app shell: WS/SSE/poll channels, global `state` (last
|
|
24
|
+
snapshot), tab router (hash-based #overview,#tasks,...), renderActiveTab(),
|
|
25
|
+
confirm-dialog, toast, esc(), fmt helpers, MUTATION token+headers,
|
|
26
|
+
api() POST helper, expand/collapse helpers, drill navigation (goAgent(id)).
|
|
27
|
+
web/dash/charts.js uPlot chart + sparkline builders (ported 1:1 from current behaviour).
|
|
28
|
+
web/dash/styles.css all CSS (ported + new tab/drawer/table/control styles), tokens only.
|
|
29
|
+
web/dash/tab-overview.js aggregate KPIs + hero charts + live summaries with drill links.
|
|
30
|
+
web/dash/tab-tasks.js Tasks & Epics: kanban + table + create/edit/status/assignee/delete + epic ledger.
|
|
31
|
+
web/dash/tab-agents.js Agents & Sessions: agent grid -> per-agent drawer; session history -> per-session drawer.
|
|
32
|
+
web/dash/tab-orchestration.js mission->epic->task tree + agents + orchestrate on/off/auto + ledger controls.
|
|
33
|
+
web/dash/tab-deliver.js deliver run registry list + launch form + cancel/resume + per-run telemetry.
|
|
34
|
+
web/dash/tab-policies.js policies table (existing controls) + compliance + audit + live events.
|
|
35
|
+
web/dash/tab-models.js models/routing config + session usage + routing decisions.
|
|
36
|
+
web/dash/tab-memory.js L1-L4 + compression + hit/miss gauge + recent queries + savings-by-influence.
|
|
37
|
+
```
|
|
38
|
+
Server change: `server.ts` static handler currently serves `/vendor/`; extend it to ALSO serve
|
|
39
|
+
`/dash/` (same path-traversal guard, content types add `.css`, `.js`).
|
|
40
|
+
|
|
41
|
+
## Module contract (how tabs plug in)
|
|
42
|
+
`core.js` owns everything shared and exposes a global `UAP` object on `window`:
|
|
43
|
+
- `UAP.state` — the latest `/api/dashboard` snapshot (or null).
|
|
44
|
+
- `UAP.registerTab(id, {label, render(root, state), onEnter?, onLeave?})` — each tab-*.js calls this at load.
|
|
45
|
+
- `UAP.el(tag, attrs, ...children)` — tiny hyperscript DOM builder (avoids innerHTML XSS; sets textContent).
|
|
46
|
+
- `UAP.esc(s)`, `UAP.fmtNum(n)`, `UAP.fmtBytes(n)`, `UAP.fmtUsd(n)`, `UAP.fmtDur(ms)`, `UAP.timeAgo(iso)`.
|
|
47
|
+
- `UAP.api(path, body?)` — POST JSON with the mutation token header; returns parsed JSON or throws; shows toast on error.
|
|
48
|
+
- `UAP.confirm(message, {danger}) : Promise<bool>` — modal confirm; ALL destructive controls await it.
|
|
49
|
+
- `UAP.toast(msg, kind)` — kind: ok|warn|err.
|
|
50
|
+
- `UAP.drawer(title, contentNode)` / `UAP.closeDrawer()` — right-side slide-in panel for drill-down detail.
|
|
51
|
+
- `UAP.goto(hash)` — switch tabs programmatically (e.g. click agent row on Overview -> #agents + open drawer).
|
|
52
|
+
- `UAP.collapsible(title, bodyNode, {open}) : Node` — reusable expand/collapse section.
|
|
53
|
+
- `UAP.onSnapshot(fn)` — subscribe to every new snapshot (tabs use this to live-update if active).
|
|
54
|
+
- `UAP.liveEvents` — capped array of SSE activity events (for policies tab live feed).
|
|
55
|
+
|
|
56
|
+
core.js loads all tab-*.js as `<script type=module>` (or dynamic import) AFTER defining UAP, then
|
|
57
|
+
renders the tab from `location.hash` (default #overview). Tab bar buttons set the hash.
|
|
58
|
+
On each snapshot: if the active tab is live, re-render it; always update header + tab badges.
|
|
59
|
+
|
|
60
|
+
Data channels: reuse the current three (WS primary at ws://host, SSE /api/events for the activity
|
|
61
|
+
feed + snapshot fallback, poll /api/dashboard every REFRESH_MS when WS closed). Reconnect w/ backoff.
|
|
62
|
+
The `__UAP_DASHBOARD_TOKEN__` and `__UAP_DASH_REFRESH_MS__` placeholders are substituted server-side
|
|
63
|
+
in dashboard.html (already handled by server.ts) — read them from a small inline bootstrap script in
|
|
64
|
+
the shell that sets window.__UAP_BOOT = {token, refreshMs} before core.js loads.
|
|
65
|
+
|
|
66
|
+
## Tabs — content & interactions
|
|
67
|
+
|
|
68
|
+
### Overview (aggregate landing)
|
|
69
|
+
- KPI row (stat tiles): Tasks done/total + % ; Active agents ; Active deliver runs ; Orchestration
|
|
70
|
+
progress % ; Tokens saved ; Cost saved $ ; Memory entries ; Policy block-rate. Each tile clickable ->
|
|
71
|
+
goto its tab.
|
|
72
|
+
- Hero charts (uPlot): Tasks & Agents over time; Compression & Memory over time (from timeSeries).
|
|
73
|
+
- Live summaries (each a compact table with a "drill" affordance): Active Agents (click row ->
|
|
74
|
+
#agents drawer), Active Deliver Runs (click -> #deliver), Orchestration ledger mini-progress
|
|
75
|
+
(click -> #orchestration), Recent activity (last N events).
|
|
76
|
+
|
|
77
|
+
### Tasks & Epics
|
|
78
|
+
- Toolbar: "+ New Task" (opens form: title, type[task|epic|bug|...], priority, assignee), filter by
|
|
79
|
+
status, expand/collapse-all groups.
|
|
80
|
+
- Kanban (5 cols open/in_progress/blocked/done/wont_do) grouped into families by groupId (port
|
|
81
|
+
current logic incl. depth indent + breadcrumb). Each card: click -> drawer with detail + controls:
|
|
82
|
+
change status (select), set assignee, claim, close(done), DELETE (danger-confirm). Epic-typed
|
|
83
|
+
cards also show ledger children + "advance/fail item" + "reset ledger" (danger).
|
|
84
|
+
- Endpoints: POST /api/tasks (create), POST /api/tasks/:id/update {status?,assignee?,priority?,title?},
|
|
85
|
+
POST /api/tasks/:id/close, POST /api/tasks/:id/delete, POST /api/tasks/:id/claim {agentId}.
|
|
86
|
+
Epics/ledger: POST /api/ledger/item/:id {status:done|failed|pending}, POST /api/ledger/reset,
|
|
87
|
+
POST /api/ledger/init {mission, items:[...]} (optional).
|
|
88
|
+
|
|
89
|
+
### Agents & Sessions
|
|
90
|
+
- Agents grid: card per coordination.agents / session.agents (id,name,type,status badge, task, model,
|
|
91
|
+
tokens, cost). Click -> right drawer per-agent detail: full token IO (in/out), model, duration,
|
|
92
|
+
taskCount, skills chips (from coordination.skillsPerAgent[id]), patterns (patternsPerAgent[id]),
|
|
93
|
+
routing decisions if linkable. Control: Deregister agent (danger-confirm) -> POST /api/agents/:id/deregister.
|
|
94
|
+
Toolbar: "Clean stale agents" -> POST /api/agents/clean.
|
|
95
|
+
- Sessions history table (sessions[]): per row tokens/cost/agents/tasks/model/status; click -> drawer
|
|
96
|
+
with the session's detail (for the CURRENT/live session use `state.session` rich object incl agents,
|
|
97
|
+
modelBreakdown, skills, patterns, deploys, step progress bar).
|
|
98
|
+
|
|
99
|
+
### Orchestration
|
|
100
|
+
- Header: orchestrator toggle (on/off/auto) -> POST /api/orchestrator {state:on|off|auto} (writes
|
|
101
|
+
.uap.json deliver.orchestrate). Show current from state.models or a new field.
|
|
102
|
+
- Active build ledger block (orchestrationTree.ledger): mission, pct, done/total, items w/ deps +
|
|
103
|
+
per-item advance/fail controls (reuse ledger endpoints).
|
|
104
|
+
- Mission tree (orchestrationTree.missions recursive): expand/collapse nodes, show assigned agents,
|
|
105
|
+
status badges. Node controls where it maps to a task: jump to task drawer.
|
|
106
|
+
|
|
107
|
+
### Deliver
|
|
108
|
+
- Run registry list (NEW data: state.deliverRuns from listRuns()): runId, status
|
|
109
|
+
(running|delivered|failed|interrupted), mission/instruction, phase, taskId, updatedAt.
|
|
110
|
+
- Controls per run: Cancel (running only, danger-confirm) -> POST /api/deliver/:runId/cancel
|
|
111
|
+
(writes stop-file; server also attempts PID kill). Resume (interrupted) -> POST /api/deliver/:runId/resume.
|
|
112
|
+
- Launch form: instruction textarea + options (model preset, max-turns) -> POST /api/deliver/launch
|
|
113
|
+
{instruction, model?, maxTurns?} spawns `uap deliver` detached; returns runId. Show a warning that
|
|
114
|
+
this spawns a host process.
|
|
115
|
+
- Live telemetry: for a running run, show its RunCoordinator status from the agent registry (the
|
|
116
|
+
agent whose task text carries "turn N: X% of gates").
|
|
117
|
+
|
|
118
|
+
### Policies & Compliance (port existing + keep controls)
|
|
119
|
+
- Policies table: name/category/level/stage/status + toggle button + stage select + level select
|
|
120
|
+
(existing endpoints /api/policy/:id/{toggle,stage,level}).
|
|
121
|
+
- Enforcement stages bar chart. Compliance: block-rate sparkline, failuresByMechanism bars,
|
|
122
|
+
recentFailures table, auditTrail list, Live Events SSE feed.
|
|
123
|
+
|
|
124
|
+
### Models
|
|
125
|
+
- Roles (planner/executor/reviewer/fallback), strategy, availableModels, enabled, costOptimization,
|
|
126
|
+
routingMatrix (handle BOTH shapes: string tier->model AND {planner,executor}), routingRules.
|
|
127
|
+
- sessionUsage table (model/tasks/in/out/cost/success%), recentRoutingDecisions table, totalCost.
|
|
128
|
+
|
|
129
|
+
### Memory
|
|
130
|
+
- L1/L2/L3(Qdrant)/L4 cards, compression block, hit/miss gauge + hit-rate sparkline, recentQueries
|
|
131
|
+
table, savingsByInfluence (summary + per-influence table with quality badges).
|
|
132
|
+
|
|
133
|
+
## Backend — new files & edits
|
|
134
|
+
```
|
|
135
|
+
src/dashboard/controls/tasks.ts handleTaskCreate/Update/Close/Delete/Claim (import TaskService)
|
|
136
|
+
src/dashboard/controls/epics.ts handleLedgerItem/Reset/Init (import completion-ledger)
|
|
137
|
+
src/dashboard/controls/orchestrator.ts handleOrchestratorToggle (import modifyUapConfig)
|
|
138
|
+
src/dashboard/controls/deliver.ts handleDeliverLaunch/Cancel/Resume + listDeliverRuns
|
|
139
|
+
src/dashboard/controls/agents.ts handleAgentDeregister/CleanStale (import CoordinationService)
|
|
140
|
+
```
|
|
141
|
+
- `run-state.ts`: add `listRuns(projectRoot): DeliverRunState[]` (readdir deliverRunsDir + loadRunState each).
|
|
142
|
+
- `convergence-loop.ts`: add a cooperative STOP-FILE check per turn (next to the existing guidanceFile
|
|
143
|
+
poll). Stop-file path = `<projectRoot>/.uap/deliver-runs/<runId>/STOP`. If present, break the loop,
|
|
144
|
+
set run status 'interrupted', return. (Cancel endpoint writes this file; also SIGTERM the PID if known.)
|
|
145
|
+
- `data-service.ts`: add `deliverRuns` (from listRuns) and `ledger` (loadLedger) to DashboardData +
|
|
146
|
+
the interface. Add `getAgentDetail(cwd, agentId)` returning the per-agent detail (from session.agents
|
|
147
|
+
or coordination) for drawer use (optional — drawer can also filter client-side from snapshot).
|
|
148
|
+
- `server.ts`: extend static handler to `/dash/`; add the POST routes above, ALL behind
|
|
149
|
+
`mutationAuthorized(req)` (return denyMutation on fail); parse body via readBody/parseJsonBody;
|
|
150
|
+
validate inputs; return JSON. Destructive ops must be idempotent-safe and never throw unhandled.
|
|
151
|
+
|
|
152
|
+
## Security & safety
|
|
153
|
+
- Every new mutation route: `if (!mutationAuthorized(req)) return denyMutation(res);` FIRST.
|
|
154
|
+
- Deliver launch spawns a subprocess — bind only when host is localhost is NOT enforced, but the token
|
|
155
|
+
gate is the control. Log launches. Detach the child (unref) so the dash server isn't its parent lifetime.
|
|
156
|
+
- Cancel: write stop-file (cooperative) THEN best-effort SIGTERM the recorded PID if the run-state has one.
|
|
157
|
+
- Confirm dialog on client for every delete/cancel/reset/deregister.
|
|
158
|
+
|
|
159
|
+
## Tests (vitest, test/) — at least:
|
|
160
|
+
- run-state listRuns returns runs sorted newest-first; empty when dir absent.
|
|
161
|
+
- convergence stop-file: a loop with a pre-written STOP file exits with status interrupted after <=1 turn.
|
|
162
|
+
- server control routes: task create/update/delete happy-path + 401 without token; ledger item; agent
|
|
163
|
+
deregister; orchestrator toggle writes config. (Use ephemeral port, temp cwd.)
|
|
164
|
+
- data-service: getDashboardData includes deliverRuns + ledger keys.
|
|
165
|
+
|
|
166
|
+
## Verification
|
|
167
|
+
- npm run build clean; tsc --noEmit clean; vitest green; design gate green (tokens only).
|
|
168
|
+
- Browser smoke: start `uap dash serve`, load each tab, exercise one mutation per domain, confirm
|
|
169
|
+
drill-in drawer opens and closes, expand/collapse works, live update ticks.
|
|
170
|
+
```
|
|
171
|
+
```
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
@@ -1350,6 +1350,7 @@ class SessionMonitor:
|
|
|
1350
1350
|
deferral_streak: int = 0 # consecutive no-tool turns deferring the work (Fix A)
|
|
1351
1351
|
deferral_break_fires: int = 0 # monotonic count of forced deferral-breaks (Fix A)
|
|
1352
1352
|
tool_starvation_streak: int = 0 # Consecutive forced turns with no tool_calls produced
|
|
1353
|
+
last_request_msg_count: int = 0 # Message count of the previous request (compaction-boundary detection)
|
|
1353
1354
|
malformed_tool_streak: int = 0 # consecutive malformed pseudo tool payloads
|
|
1354
1355
|
invalid_tool_call_streak: int = 0 # consecutive invalid tool arg payloads
|
|
1355
1356
|
required_tool_miss_streak: int = 0 # required tool turns with no tool call
|
|
@@ -9815,6 +9816,32 @@ async def messages(request: Request):
|
|
|
9815
9816
|
last_text,
|
|
9816
9817
|
)
|
|
9817
9818
|
|
|
9819
|
+
# --- Compaction-boundary detection: a client-side auto-compact collapses
|
|
9820
|
+
# the conversation (observed live 2026-07-10: msgs 61 -> 3) into a fresh
|
|
9821
|
+
# epoch whose last assistant message is the text-only SUMMARY. Anti-spin
|
|
9822
|
+
# counters accumulated before the boundary (consecutive_forced_count,
|
|
9823
|
+
# starvation/no-write streaks, tool-state machine) then misfire on the
|
|
9824
|
+
# very first post-compact turn — the starvation breaker stripped tools
|
|
9825
|
+
# from a brand-new epoch because the summary "looked like" a text-only
|
|
9826
|
+
# stall. A halving-plus collapse of the message count is the boundary
|
|
9827
|
+
# signal; reset the per-conversation spin state so the new epoch starts
|
|
9828
|
+
# clean. (Token accounting is NOT reset — record_request below re-measures
|
|
9829
|
+
# the compacted size naturally.)
|
|
9830
|
+
prev_msg_count = getattr(monitor, "last_request_msg_count", 0)
|
|
9831
|
+
if prev_msg_count >= 8 and n_messages <= prev_msg_count // 2:
|
|
9832
|
+
monitor.reset_tool_turn_state(reason="compaction_boundary")
|
|
9833
|
+
monitor.consecutive_forced_count = 0
|
|
9834
|
+
monitor.no_progress_streak = 0
|
|
9835
|
+
monitor.tool_starvation_streak = 0
|
|
9836
|
+
monitor.consecutive_no_write_turns = 0
|
|
9837
|
+
logger.info(
|
|
9838
|
+
"COMPACTION BOUNDARY: message count collapsed %d -> %d; reset "
|
|
9839
|
+
"tool-turn/anti-spin state for the fresh epoch",
|
|
9840
|
+
prev_msg_count,
|
|
9841
|
+
n_messages,
|
|
9842
|
+
)
|
|
9843
|
+
monitor.last_request_msg_count = n_messages
|
|
9844
|
+
|
|
9818
9845
|
# --- Option F: Estimate tokens and record in session monitor ---
|
|
9819
9846
|
estimated_tokens = estimate_total_tokens(body)
|
|
9820
9847
|
monitor.record_request(estimated_tokens)
|
package/web/dash/core.js
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/* UAP Dashboard core — app shell, data channels, tab router, shared UI helpers.
|
|
2
|
+
* Loaded before tabs.js. tabs.js registers each tab then calls UAP.start(). */
|
|
3
|
+
(function () {
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
var BOOT = window.__UAP_BOOT || { token: '', refreshMs: 2000 };
|
|
7
|
+
var WS_URL = 'ws://' + location.host;
|
|
8
|
+
var API_URL = location.origin;
|
|
9
|
+
var MUTATION_HEADERS = { 'X-Uap-Dashboard-Token': BOOT.token || '' };
|
|
10
|
+
var REFRESH_MS = Number(BOOT.refreshMs) > 0 ? Number(BOOT.refreshMs) : 2000;
|
|
11
|
+
|
|
12
|
+
// ── Canonical tab order (bar is built from this; tabs.js supplies renderers) ──
|
|
13
|
+
var TAB_DEFS = [
|
|
14
|
+
{ id: 'overview', label: 'Overview' },
|
|
15
|
+
{ id: 'tasks', label: 'Tasks & Epics' },
|
|
16
|
+
{ id: 'agents', label: 'Agents & Sessions' },
|
|
17
|
+
{ id: 'orchestration', label: 'Orchestration' },
|
|
18
|
+
{ id: 'deliver', label: 'Deliver' },
|
|
19
|
+
{ id: 'policies', label: 'Policies' },
|
|
20
|
+
{ id: 'models', label: 'Models' },
|
|
21
|
+
{ id: 'memory', label: 'Memory' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// ─────────────────────────── helpers ───────────────────────────
|
|
25
|
+
function esc(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }
|
|
26
|
+
function fmtNum(n) { if (typeof n !== 'number' || isNaN(n)) { n = Number(n) || 0; } return Math.round(n).toLocaleString(); }
|
|
27
|
+
function fmtBytes(b) { b = b || 0; if (b < 1024) return b + ' B'; if (b < 1048576) return Math.round(b / 1024) + ' KB'; return (b / 1048576).toFixed(1) + ' MB'; }
|
|
28
|
+
function fmtKB(b) { return Math.round((b || 0) / 1024) + ' KB'; }
|
|
29
|
+
function fmtUsd(n) { return '$' + (typeof n === 'number' ? n.toFixed(4) : '0.0000'); }
|
|
30
|
+
function fmtDur(ms) { ms = ms || 0; var s = Math.floor(ms / 1000); if (s < 60) return s + 's'; var m = Math.floor(s / 60); if (m < 60) return m + 'm'; var h = Math.floor(m / 60); return h + 'h ' + (m % 60) + 'm'; }
|
|
31
|
+
function timeAgo(iso) {
|
|
32
|
+
if (!iso) return '-';
|
|
33
|
+
var t = new Date(iso).getTime(); if (isNaN(t)) return '-';
|
|
34
|
+
var d = Date.now() - t; if (d < 0) d = 0;
|
|
35
|
+
var s = Math.floor(d / 1000); if (s < 60) return s + 's ago';
|
|
36
|
+
var m = Math.floor(s / 60); if (m < 60) return m + 'm ago';
|
|
37
|
+
var h = Math.floor(m / 60); if (h < 24) return h + 'h ago';
|
|
38
|
+
return Math.floor(h / 24) + 'd ago';
|
|
39
|
+
}
|
|
40
|
+
function shortTime(iso) { if (!iso) return '-'; var d = new Date(iso); return isNaN(d.getTime()) ? String(iso).slice(11, 19) : d.toLocaleTimeString(); }
|
|
41
|
+
function capitalize(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : ''; }
|
|
42
|
+
var MODEL_NAMES = { 'fable-5': 'Fable 5', 'opus-4.8': 'Claude Opus 4.8', 'opus-4.6': 'Claude Opus 4.6', 'sonnet-5': 'Claude Sonnet 5', 'sonnet-4.6': 'Claude Sonnet 4.6', 'haiku-4.5': 'Claude Haiku 4.5', 'qwen36-a3b': 'Qwen 3.6 35B A3B', 'qwen35-a3b': 'Qwen 3.5 35B A3B', 'qwen35': 'Qwen 3.5 35B A3B', 'gpt-5.4': 'GPT 5.4', 'gpt-5.3-codex': 'GPT 5.3 Codex' };
|
|
43
|
+
function modelName(id) { return MODEL_NAMES[id] || id || 'unknown'; }
|
|
44
|
+
|
|
45
|
+
// Hyperscript DOM builder (textContent-safe; pass {html} only for pre-escaped strings).
|
|
46
|
+
function el(tag, attrs) {
|
|
47
|
+
var e = document.createElement(tag), i;
|
|
48
|
+
if (attrs) {
|
|
49
|
+
for (var k in attrs) {
|
|
50
|
+
if (!Object.prototype.hasOwnProperty.call(attrs, k)) continue;
|
|
51
|
+
var v = attrs[k];
|
|
52
|
+
if (v == null) continue;
|
|
53
|
+
if (k === 'class') e.className = v;
|
|
54
|
+
else if (k === 'text') e.textContent = v;
|
|
55
|
+
else if (k === 'html') e.innerHTML = v;
|
|
56
|
+
else if (k === 'style' && typeof v === 'object') { for (var sk in v) e.style[sk] = v[sk]; }
|
|
57
|
+
else if (k === 'dataset' && typeof v === 'object') { for (var dk in v) e.dataset[dk] = v[dk]; }
|
|
58
|
+
else if (k.slice(0, 2) === 'on' && typeof v === 'function') e.addEventListener(k.slice(2).toLowerCase(), v);
|
|
59
|
+
else e.setAttribute(k, v);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (i = 2; i < arguments.length; i++) {
|
|
63
|
+
var kids = arguments[i];
|
|
64
|
+
if (!Array.isArray(kids)) kids = [kids];
|
|
65
|
+
for (var j = 0; j < kids.length; j++) {
|
|
66
|
+
var kid = kids[j];
|
|
67
|
+
if (kid == null || kid === false) continue;
|
|
68
|
+
if (kid && kid.nodeType) e.appendChild(kid);
|
|
69
|
+
else e.appendChild(document.createTextNode(String(kid)));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return e;
|
|
73
|
+
}
|
|
74
|
+
function clear(node) { while (node && node.firstChild) node.removeChild(node.firstChild); return node; }
|
|
75
|
+
|
|
76
|
+
// ─────────────────────────── toast ───────────────────────────
|
|
77
|
+
function toast(msg, kind) {
|
|
78
|
+
var c = document.getElementById('toast-container');
|
|
79
|
+
if (!c) return;
|
|
80
|
+
var t = el('div', { class: 'toast ' + (kind || 'ok'), text: msg });
|
|
81
|
+
c.appendChild(t);
|
|
82
|
+
setTimeout(function () { t.remove(); }, 3200);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─────────────────────────── modal (confirm + form) ───────────────────────────
|
|
86
|
+
function modalShell(build) {
|
|
87
|
+
return new Promise(function (resolve) {
|
|
88
|
+
var backdrop = el('div', { class: 'modal-backdrop' });
|
|
89
|
+
function close(val) { backdrop.remove(); document.removeEventListener('keydown', onKey); resolve(val); }
|
|
90
|
+
function onKey(e) { if (e.key === 'Escape') close(null); }
|
|
91
|
+
document.addEventListener('keydown', onKey);
|
|
92
|
+
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(null); });
|
|
93
|
+
backdrop.appendChild(build(close));
|
|
94
|
+
document.body.appendChild(backdrop);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function confirmDialog(message, opts) {
|
|
98
|
+
opts = opts || {};
|
|
99
|
+
return modalShell(function (close) {
|
|
100
|
+
return el('div', { class: 'modal' },
|
|
101
|
+
el('h3', { text: opts.title || 'Confirm' }),
|
|
102
|
+
el('div', { class: 'modal-msg', text: message }),
|
|
103
|
+
el('div', { class: 'modal-actions' },
|
|
104
|
+
el('button', { class: 'btn', onclick: function () { close(false); } }, 'Cancel'),
|
|
105
|
+
el('button', { class: 'btn ' + (opts.danger ? 'btn-danger' : 'btn-primary'), onclick: function () { close(true); } }, opts.okLabel || (opts.danger ? 'Delete' : 'Confirm'))
|
|
106
|
+
)
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
// fields: [{name,label,type:'text|textarea|select|number',options?,value?,placeholder?}]
|
|
111
|
+
function formDialog(title, fields, opts) {
|
|
112
|
+
opts = opts || {};
|
|
113
|
+
return modalShell(function (close) {
|
|
114
|
+
var inputs = {};
|
|
115
|
+
var fieldNodes = fields.map(function (f) {
|
|
116
|
+
var input;
|
|
117
|
+
if (f.type === 'textarea') input = el('textarea', { placeholder: f.placeholder || '' });
|
|
118
|
+
else if (f.type === 'select') { input = el('select'); (f.options || []).forEach(function (o) { var v = typeof o === 'string' ? o : o.value; var lb = typeof o === 'string' ? o : o.label; input.appendChild(el('option', { value: v }, lb)); }); }
|
|
119
|
+
else input = el('input', { type: f.type || 'text', placeholder: f.placeholder || '' });
|
|
120
|
+
if (f.value != null) input.value = f.value;
|
|
121
|
+
inputs[f.name] = input;
|
|
122
|
+
return el('label', { class: 'field' }, f.label || f.name, input);
|
|
123
|
+
});
|
|
124
|
+
return el('div', { class: 'modal' },
|
|
125
|
+
el('h3', { text: title }),
|
|
126
|
+
el('div', { class: 'modal-fields' }, fieldNodes),
|
|
127
|
+
el('div', { class: 'modal-actions' },
|
|
128
|
+
el('button', { class: 'btn', onclick: function () { close(null); } }, 'Cancel'),
|
|
129
|
+
el('button', { class: 'btn btn-primary', onclick: function () { var out = {}; for (var n in inputs) out[n] = inputs[n].value; close(out); } }, opts.okLabel || 'Submit')
|
|
130
|
+
)
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─────────────────────────── drawer ───────────────────────────
|
|
136
|
+
var drawerEl = null, drawerBackdrop = null;
|
|
137
|
+
function ensureDrawer() {
|
|
138
|
+
if (drawerEl) return;
|
|
139
|
+
drawerBackdrop = el('div', { class: 'drawer-backdrop', onclick: closeDrawer });
|
|
140
|
+
drawerEl = el('div', { class: 'drawer', role: 'dialog', 'aria-modal': 'true' },
|
|
141
|
+
el('div', { class: 'drawer-head' }, el('h3', { id: 'drawer-title' }), el('button', { class: 'drawer-close', title: 'Close', onclick: closeDrawer }, '×')),
|
|
142
|
+
el('div', { class: 'drawer-body', id: 'drawer-body' })
|
|
143
|
+
);
|
|
144
|
+
document.body.appendChild(drawerBackdrop);
|
|
145
|
+
document.body.appendChild(drawerEl);
|
|
146
|
+
}
|
|
147
|
+
function drawer(title, contentNode) {
|
|
148
|
+
ensureDrawer();
|
|
149
|
+
drawerEl.querySelector('#drawer-title').textContent = title;
|
|
150
|
+
var body = drawerEl.querySelector('#drawer-body');
|
|
151
|
+
clear(body);
|
|
152
|
+
if (typeof contentNode === 'string') body.innerHTML = contentNode; else if (contentNode) body.appendChild(contentNode);
|
|
153
|
+
drawerEl.classList.add('open');
|
|
154
|
+
drawerBackdrop.classList.add('open');
|
|
155
|
+
}
|
|
156
|
+
function closeDrawer() { if (drawerEl) { drawerEl.classList.remove('open'); drawerBackdrop.classList.remove('open'); } }
|
|
157
|
+
|
|
158
|
+
// ─────────────────────────── collapsible ───────────────────────────
|
|
159
|
+
function collapsible(title, bodyNode, opts) {
|
|
160
|
+
opts = opts || {};
|
|
161
|
+
var open = !!opts.open;
|
|
162
|
+
var header = el('div', { class: 'collapsible-header' + (open ? ' open' : '') }, title);
|
|
163
|
+
var inner = el('div', { class: 'cb-inner' });
|
|
164
|
+
if (typeof bodyNode === 'string') inner.innerHTML = bodyNode; else if (bodyNode) inner.appendChild(bodyNode);
|
|
165
|
+
var body = el('div', { class: 'collapsible-body' + (open ? ' open' : '') }, inner);
|
|
166
|
+
header.addEventListener('click', function () { header.classList.toggle('open'); body.classList.toggle('open'); });
|
|
167
|
+
return el('div', { class: 'collapsible' }, header, body);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─────────────────────────── api ───────────────────────────
|
|
171
|
+
function api(path, body) {
|
|
172
|
+
var opts = { method: 'POST', headers: Object.assign({ 'Content-Type': 'application/json' }, MUTATION_HEADERS) };
|
|
173
|
+
if (body !== undefined) opts.body = JSON.stringify(body);
|
|
174
|
+
return fetch(API_URL + path, opts).then(function (r) {
|
|
175
|
+
return r.json().then(function (j) {
|
|
176
|
+
if (!r.ok) throw new Error((j && j.error) || ('HTTP ' + r.status));
|
|
177
|
+
return j;
|
|
178
|
+
}, function () { if (!r.ok) throw new Error('HTTP ' + r.status); return {}; });
|
|
179
|
+
}).catch(function (e) { toast(e.message || 'Request failed', 'err'); throw e; });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─────────────────────────── charts (uPlot) ───────────────────────────
|
|
183
|
+
var uplotReady = typeof uPlot !== 'undefined';
|
|
184
|
+
var CC = { done: '#3fb950', inProg: '#58a6ff', blocked: '#f85149', open: '#484f58', agents: '#bc8cff', raw: '#58a6ff', ctx: '#3fb950', hitRate: '#bc8cff', deploy: '#58a6ff', blockRate: '#f85149' };
|
|
185
|
+
var chartReg = {}; // id -> { u, host }
|
|
186
|
+
function tsUnix(arr) { return arr.map(function (p) { return new Date(p.timestamp).getTime() / 1000; }); }
|
|
187
|
+
function addTooltip(u, colors, labels, fmtVal) {
|
|
188
|
+
var tt = el('div', { class: 'u-tooltip' }); tt.style.display = 'none'; u.root.appendChild(tt);
|
|
189
|
+
u.hooks.setCursor = u.hooks.setCursor || [];
|
|
190
|
+
u.hooks.setCursor.push(function () {
|
|
191
|
+
var idx = u.cursor.idx, left = u.cursor.left, top = u.cursor.top;
|
|
192
|
+
if (idx == null) { tt.style.display = 'none'; return; }
|
|
193
|
+
var date = new Date(u.data[0][idx] * 1000);
|
|
194
|
+
var rows = '<div class="tt-time">' + date.toLocaleTimeString() + '</div>';
|
|
195
|
+
for (var i = 1; i < u.series.length; i++) {
|
|
196
|
+
if (!u.series[i].show) continue;
|
|
197
|
+
var val = u.data[i][idx]; if (val == null) continue;
|
|
198
|
+
var c = colors[i - 1] || '#fff', l = labels[i - 1] || '';
|
|
199
|
+
var v = fmtVal ? fmtVal(val, i) : val.toLocaleString();
|
|
200
|
+
rows += '<div class="tt-row"><span class="tt-dot" style="background:' + c + '"></span><span class="tt-label">' + l + '</span><span class="tt-val">' + v + '</span></div>';
|
|
201
|
+
}
|
|
202
|
+
tt.innerHTML = rows; tt.style.display = ''; tt.style.left = (left + 16) + 'px'; tt.style.top = Math.max(0, top - 10) + 'px';
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
var AXIS = { stroke: '#484f58', grid: { stroke: '#21262d' }, ticks: { stroke: '#21262d' }, font: '10px SF Mono,monospace' };
|
|
206
|
+
function heroOpts(kind, w) {
|
|
207
|
+
if (kind === 'tasks') return {
|
|
208
|
+
width: w, height: 260, cursor: { sync: { key: 'uap' }, focus: { prox: 30 } },
|
|
209
|
+
scales: { x: { time: true }, y: { min: 0 }, agents: { min: 0 } },
|
|
210
|
+
axes: [AXIS, Object.assign({}, AXIS, { size: 50 }), { stroke: CC.agents, grid: { show: false }, side: 1, font: '10px SF Mono,monospace', size: 50, scale: 'agents' }],
|
|
211
|
+
series: [{}, { label: 'Done', stroke: CC.done, fill: CC.done + '30', width: 2 }, { label: 'In Prog', stroke: CC.inProg, fill: CC.inProg + '30', width: 2 }, { label: 'Blocked', stroke: CC.blocked, fill: CC.blocked + '20', width: 2 }, { label: 'Open', stroke: CC.open, fill: CC.open + '20', width: 1, dash: [4, 2] }, { label: 'Agents', stroke: CC.agents, width: 2, dash: [6, 3], scale: 'agents' }],
|
|
212
|
+
};
|
|
213
|
+
return {
|
|
214
|
+
width: w, height: 260, cursor: { sync: { key: 'uap' }, focus: { prox: 30 } },
|
|
215
|
+
scales: { x: { time: true }, y: { min: 0 }, pct: { min: 0, max: 100 } },
|
|
216
|
+
axes: [AXIS, Object.assign({}, AXIS, { size: 50, values: function (_, v) { return v.map(function (x) { return x + ' KB'; }); } }), { stroke: CC.hitRate, grid: { show: false }, side: 1, font: '10px SF Mono,monospace', size: 50, scale: 'pct', values: function (_, v) { return v.map(function (x) { return x + '%'; }); } }],
|
|
217
|
+
series: [{}, { label: 'Raw', stroke: CC.raw, fill: CC.raw + '20', width: 2 }, { label: 'Compressed', stroke: CC.ctx, fill: CC.ctx + '20', width: 2 }, { label: 'Hit Rate', stroke: CC.hitRate, width: 2, dash: [6, 3], scale: 'pct' }],
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
var parseHR = function (p) { var h = p.memoryHitsMisses && p.memoryHitsMisses.hitRate; return typeof h === 'string' ? parseFloat(h) || 0 : h || 0; };
|
|
221
|
+
var parseBR = function (p) { var b = p.compliance && p.compliance.blockRate; return typeof b === 'string' ? parseFloat(b) || 0 : b || 0; };
|
|
222
|
+
function heroData(kind, ts) {
|
|
223
|
+
var x = tsUnix(ts);
|
|
224
|
+
if (kind === 'tasks') return [x, ts.map(function (p) { return (p.tasks && p.tasks.done) || 0; }), ts.map(function (p) { return (p.tasks && p.tasks.inProgress) || 0; }), ts.map(function (p) { return (p.tasks && p.tasks.blocked) || 0; }), ts.map(function (p) { return (p.tasks && p.tasks.open) || 0; }), ts.map(function (p) { return (p.coordination && p.coordination.activeAgents) || 0; })];
|
|
225
|
+
return [x, ts.map(function (p) { return Math.round(((p.compression && p.compression.rawBytes) || 0) / 1024); }), ts.map(function (p) { return Math.round(((p.compression && p.compression.contextBytes) || 0) / 1024); }), ts.map(parseHR)];
|
|
226
|
+
}
|
|
227
|
+
function syncHero(id, kind, ts) {
|
|
228
|
+
if (!uplotReady || !ts || ts.length < 2) return;
|
|
229
|
+
var host = document.getElementById(id); if (!host) return;
|
|
230
|
+
var reg = chartReg[id], data = heroData(kind, ts);
|
|
231
|
+
if (!reg || reg.host !== host || !document.body.contains(reg.u.root)) {
|
|
232
|
+
if (reg && reg.u) { try { reg.u.destroy(); } catch (e) {} }
|
|
233
|
+
clear(host);
|
|
234
|
+
var u = new uPlot(heroOpts(kind, host.clientWidth || 600), data, host);
|
|
235
|
+
addTooltip(u, kind === 'tasks' ? [CC.done, CC.inProg, CC.blocked, CC.open, CC.agents] : [CC.raw, CC.ctx, CC.hitRate], kind === 'tasks' ? ['Done', 'In Progress', 'Blocked', 'Open', 'Active Agents'] : ['Raw KB', 'Compressed KB', 'Hit Rate %'], kind === 'tasks' ? null : function (v, i) { return i === 3 ? v + '%' : v + ' KB'; });
|
|
236
|
+
chartReg[id] = { u: u, host: host };
|
|
237
|
+
} else { reg.u.setData(data); }
|
|
238
|
+
seedLegend(chartReg[id].u);
|
|
239
|
+
}
|
|
240
|
+
function syncSpark(id, ts, fn, color, label) {
|
|
241
|
+
if (!uplotReady || !ts || ts.length < 2) return;
|
|
242
|
+
var host = document.getElementById(id); if (!host) return;
|
|
243
|
+
var reg = chartReg[id], data = [tsUnix(ts), ts.map(fn)];
|
|
244
|
+
if (!reg || reg.host !== host || !document.body.contains(reg.u.root)) {
|
|
245
|
+
if (reg && reg.u) { try { reg.u.destroy(); } catch (e) {} }
|
|
246
|
+
clear(host);
|
|
247
|
+
var u = new uPlot({ width: host.clientWidth || 300, height: 60, cursor: { sync: { key: 'uap' }, focus: { prox: 30 }, points: { show: false } }, legend: { show: false }, scales: { x: { time: true }, y: { min: 0 } }, axes: [{ show: false }, { show: false }], series: [{}, { stroke: color, fill: color + '25', width: 1.5 }] }, data, host);
|
|
248
|
+
chartReg[id] = { u: u, host: host, label: label };
|
|
249
|
+
} else { reg.u.setData(data); }
|
|
250
|
+
seedLegend(chartReg[id].u);
|
|
251
|
+
}
|
|
252
|
+
function seedLegend(u) { try { if (u && u.data && u.data[0] && u.data[0].length) u.setLegend({ idx: u.data[0].length - 1 }); } catch (e) {} }
|
|
253
|
+
function resetCharts() { for (var id in chartReg) { try { chartReg[id].u.destroy(); } catch (e) {} } chartReg = {}; }
|
|
254
|
+
var charts = { syncHero: syncHero, syncSpark: syncSpark, parseHR: parseHR, parseBR: parseBR, CC: CC, ready: uplotReady };
|
|
255
|
+
|
|
256
|
+
// ─────────────────────────── tab registry + router ───────────────────────────
|
|
257
|
+
var tabs = {}; // id -> { label, render, update }
|
|
258
|
+
var activeId = null, activeRoot = null, enteredId = null;
|
|
259
|
+
function registerTab(id, def) { tabs[id] = def; }
|
|
260
|
+
function buildTabBar() {
|
|
261
|
+
var bar = document.getElementById('tabbar'); if (!bar) return;
|
|
262
|
+
clear(bar);
|
|
263
|
+
TAB_DEFS.forEach(function (d) {
|
|
264
|
+
var badge = el('span', { class: 'tab-badge', id: 'tabbadge-' + d.id, style: { display: 'none' } });
|
|
265
|
+
var btn = el('button', { class: 'tab', id: 'tab-' + d.id, onclick: function () { goto(d.id); } }, d.label, badge);
|
|
266
|
+
bar.appendChild(btn);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
function setActiveTabButton(id) {
|
|
270
|
+
TAB_DEFS.forEach(function (d) { var b = document.getElementById('tab-' + d.id); if (b) b.classList.toggle('active', d.id === id); });
|
|
271
|
+
}
|
|
272
|
+
function currentHashTab() { var h = (location.hash || '').replace(/^#/, ''); return tabs[h] ? h : 'overview'; }
|
|
273
|
+
function goto(id) { if (location.hash.replace(/^#/, '') === id) { onRoute(); } else { location.hash = id; } }
|
|
274
|
+
function onRoute() {
|
|
275
|
+
var id = currentHashTab();
|
|
276
|
+
activeId = id; activeRoot = document.getElementById('view');
|
|
277
|
+
setActiveTabButton(id);
|
|
278
|
+
if (!activeRoot) return;
|
|
279
|
+
clear(activeRoot);
|
|
280
|
+
enteredId = id;
|
|
281
|
+
var def = tabs[id];
|
|
282
|
+
if (!def) { activeRoot.appendChild(el('div', { class: 'empty' }, 'Loading…')); return; }
|
|
283
|
+
try { def.render(activeRoot, state || {}); } catch (e) { activeRoot.appendChild(el('div', { class: 'empty', text: 'Render error: ' + (e.message || e) })); console.error(e); }
|
|
284
|
+
}
|
|
285
|
+
function refreshActive() {
|
|
286
|
+
if (!activeId || !activeRoot) return;
|
|
287
|
+
var def = tabs[activeId]; if (!def) return;
|
|
288
|
+
try {
|
|
289
|
+
if (def.update && enteredId === activeId) def.update(activeRoot, state || {});
|
|
290
|
+
else { clear(activeRoot); enteredId = activeId; def.render(activeRoot, state || {}); }
|
|
291
|
+
} catch (e) { console.error('tab update', e); }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ─────────────────────────── snapshot + header ───────────────────────────
|
|
295
|
+
var state = null;
|
|
296
|
+
var snapSubs = [];
|
|
297
|
+
function onSnapshot(fn) { snapSubs.push(fn); }
|
|
298
|
+
function badge(id, count, alert) {
|
|
299
|
+
var b = document.getElementById('tabbadge-' + id); if (!b) return;
|
|
300
|
+
if (!count) { b.style.display = 'none'; return; }
|
|
301
|
+
b.style.display = ''; b.textContent = count; b.classList.toggle('alert', !!alert);
|
|
302
|
+
}
|
|
303
|
+
function updateHeader(d) {
|
|
304
|
+
var sys = d.system || {};
|
|
305
|
+
setText('sys-version', sys.version || '?');
|
|
306
|
+
setText('sys-branch', sys.branch || '?');
|
|
307
|
+
var el2 = document.getElementById('sys-dirty');
|
|
308
|
+
if (el2) { var dd = sys.dirty || 0; el2.textContent = dd > 0 ? dd + ' files' : 'clean'; el2.className = 'val ' + (dd > 0 ? 'yellow' : 'green'); }
|
|
309
|
+
if (d.timestamp) setText('data-ts', new Date(d.timestamp).toLocaleTimeString());
|
|
310
|
+
}
|
|
311
|
+
function updateBadges(d) {
|
|
312
|
+
var coord = d.coordination || {}, tasks = d.tasks || {}, runs = d.deliverRuns || [];
|
|
313
|
+
badge('agents', coord.activeAgents || 0);
|
|
314
|
+
badge('tasks', (tasks.inProgress || 0) + (tasks.blocked || 0));
|
|
315
|
+
badge('deliver', runs.filter(function (r) { return r.status === 'running'; }).length);
|
|
316
|
+
var blocks = (d.compliance && d.compliance.totalBlocks) || 0;
|
|
317
|
+
badge('policies', blocks, blocks > 0);
|
|
318
|
+
}
|
|
319
|
+
// A missing/incompatible better-sqlite3 binding makes every DB-backed panel
|
|
320
|
+
// silently empty. Surface it as a fixed banner instead of a dead-looking UI.
|
|
321
|
+
function renderHealth(hh) {
|
|
322
|
+
var existing = document.getElementById('db-health-banner');
|
|
323
|
+
if (!hh || hh.ok) { if (existing) existing.remove(); return; }
|
|
324
|
+
if (!existing) {
|
|
325
|
+
existing = el('div', { id: 'db-health-banner', role: 'alert', style: { position: 'fixed', top: '0', left: '0', right: '0', zIndex: '2000', background: 'var(--red)', color: '#fff', padding: '8px 16px', fontSize: '12px', textAlign: 'center' } });
|
|
326
|
+
document.body.appendChild(existing);
|
|
327
|
+
document.body.style.paddingTop = '34px';
|
|
328
|
+
}
|
|
329
|
+
existing.textContent = '\u26A0 Dashboard database unavailable — panels read empty. ' + (hh.remediation || hh.error || '');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function applySnapshot(d) {
|
|
333
|
+
if (!d) return;
|
|
334
|
+
state = d; UAP.state = d;
|
|
335
|
+
try { updateHeader(d); } catch (e) {}
|
|
336
|
+
try { updateBadges(d); } catch (e) {}
|
|
337
|
+
try { renderHealth(d.health); } catch (e) {}
|
|
338
|
+
refreshActive();
|
|
339
|
+
for (var i = 0; i < snapSubs.length; i++) { try { snapSubs[i](d); } catch (e) {} }
|
|
340
|
+
}
|
|
341
|
+
function setText(id, val) { var e = document.getElementById(id); if (e) e.textContent = String(val); }
|
|
342
|
+
|
|
343
|
+
// ─────────────────────────── data channels ───────────────────────────
|
|
344
|
+
var ws = null, reconnectTimer = null, reconnectDelay = 1000, MAX_DELAY = 30000;
|
|
345
|
+
function setConn(cls, txt, title) {
|
|
346
|
+
var dot = document.getElementById('ws-status'); if (dot) { dot.className = 'status-dot ' + cls; dot.setAttribute('aria-label', 'Connection: ' + txt); }
|
|
347
|
+
var info = document.getElementById('refresh-info'); if (info) info.textContent = txt;
|
|
348
|
+
if (title) document.title = 'UAP Dashboard - ' + title;
|
|
349
|
+
}
|
|
350
|
+
function connectWS() {
|
|
351
|
+
clearTimeout(reconnectTimer);
|
|
352
|
+
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
|
|
353
|
+
ws = new WebSocket(WS_URL);
|
|
354
|
+
ws.onopen = function () { reconnectDelay = 1000; setConn('connected', 'Live', 'Live'); };
|
|
355
|
+
ws.onmessage = function (e) { try { applySnapshot(JSON.parse(e.data)); setConn('connected', 'Live - ' + new Date().toLocaleTimeString(), 'Live'); } catch (err) { console.error(err); } };
|
|
356
|
+
ws.onclose = function () { setConn('disconnected', 'Disconnected - reconnecting…', 'Disconnected'); reconnectTimer = setTimeout(connectWS, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 1.5, MAX_DELAY); };
|
|
357
|
+
ws.onerror = function () { ws.close(); };
|
|
358
|
+
}
|
|
359
|
+
var eventSource = null, liveEvents = [];
|
|
360
|
+
function connectSSE() {
|
|
361
|
+
try {
|
|
362
|
+
eventSource = new EventSource(API_URL + '/api/events');
|
|
363
|
+
eventSource.onmessage = function (e) {
|
|
364
|
+
try { var ev = JSON.parse(e.data); if (ev.id != null && liveEvents.some(function (x) { return x.id === ev.id; })) return; liveEvents.unshift(ev); if (liveEvents.length > 40) liveEvents.pop(); for (var i = 0; i < evSubs.length; i++) { try { evSubs[i](liveEvents); } catch (_) {} } } catch (_) {}
|
|
365
|
+
};
|
|
366
|
+
eventSource.addEventListener('snapshot', function (e) { if (ws && ws.readyState === WebSocket.OPEN) return; try { applySnapshot(JSON.parse(e.data)); setConn('connected', 'Live (SSE) - ' + new Date().toLocaleTimeString(), 'Live'); } catch (_) {} });
|
|
367
|
+
eventSource.onerror = function () { eventSource.close(); setTimeout(connectSSE, 5000); };
|
|
368
|
+
} catch (_) {}
|
|
369
|
+
}
|
|
370
|
+
var evSubs = [];
|
|
371
|
+
function onEvents(fn) { evSubs.push(fn); }
|
|
372
|
+
function startPoll() {
|
|
373
|
+
setInterval(function () {
|
|
374
|
+
if (ws && ws.readyState === WebSocket.OPEN) return;
|
|
375
|
+
fetch(API_URL + '/api/dashboard').then(function (r) { return r.ok ? r.json() : null; }).then(function (d) { if (d) { applySnapshot(d); setConn('polling', 'Polling - ' + new Date().toLocaleTimeString(), 'Polling'); } }).catch(function () {});
|
|
376
|
+
}, REFRESH_MS);
|
|
377
|
+
}
|
|
378
|
+
var resizeT;
|
|
379
|
+
window.addEventListener('resize', function () { clearTimeout(resizeT); resizeT = setTimeout(function () { resetCharts(); refreshActive(); }, 300); });
|
|
380
|
+
window.addEventListener('hashchange', onRoute);
|
|
381
|
+
|
|
382
|
+
function start() {
|
|
383
|
+
buildTabBar();
|
|
384
|
+
onRoute();
|
|
385
|
+
// Kick an immediate fetch so the first paint doesn't wait for the WS handshake.
|
|
386
|
+
fetch(API_URL + '/api/dashboard').then(function (r) { return r.ok ? r.json() : null; }).then(function (d) { if (d && !state) applySnapshot(d); }).catch(function () {});
|
|
387
|
+
connectWS();
|
|
388
|
+
connectSSE();
|
|
389
|
+
startPoll();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
window.UAP = {
|
|
393
|
+
get state() { return state; }, set state(v) { state = v; },
|
|
394
|
+
esc: esc, fmtNum: fmtNum, fmtBytes: fmtBytes, fmtKB: fmtKB, fmtUsd: fmtUsd, fmtDur: fmtDur, timeAgo: timeAgo, shortTime: shortTime, capitalize: capitalize, modelName: modelName,
|
|
395
|
+
el: el, clear: clear, toast: toast, confirm: confirmDialog, form: formDialog, drawer: drawer, closeDrawer: closeDrawer, collapsible: collapsible,
|
|
396
|
+
api: api, registerTab: registerTab, goto: goto, onSnapshot: onSnapshot, onEvents: onEvents, get liveEvents() { return liveEvents; }, charts: charts, start: start, API_URL: API_URL,
|
|
397
|
+
};
|
|
398
|
+
})();
|