diff options
| author | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-04 08:50:46 +0000 |
|---|---|---|
| committer | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-04 08:50:46 +0000 |
| commit | f8ae821240f33d615a9e91cdfeb6c026b7970782 (patch) | |
| tree | 458ce067db47a92248f163c26ce5fcf71299f3b0 /web | |
| parent | d105eca610b0e737c3313e4978d6a917b4f55d10 (diff) | |
feat(web,api): add Budget/Roles dashboard -- final phase of the harness redesign (Phase 9b)
Two new tabs, matching Phase 9a's conventions: Budget (per-provider spend
meters, escalation funnel, spend-over-time) and Roles (version history,
draft activation, readable escalation-ladder view) -- the human-facing
surface for the token-husbanding value proposition and the Phase 5/8
versioned role-config system.
New backend (minimal, additive, matching existing endpoint conventions --
unauthenticated like /api/budget and /api/roles/*):
- internal/storage/dashboard.go: QueryEscalationFunnel (executions grouped
by escalation_rung + agent, count + cost) and QuerySpendTimeseries (cost
per provider bucketed hourly/daily, mirroring QueryDashboardStats'
existing bucketing expressions). Documented honestly: rung 0 is not
exclusively "resolved locally" -- escalation_rung defaults to 0
uniformly, so non-role-typed executions (which never climb a ladder)
land there too, alongside role-typed tasks genuinely resolved at tier 0.
A role-only variant would need a join against tasks.config_json with no
queryable role column on executions -- intentionally out of scope.
- internal/api/dashboard.go: GET /api/escalation-funnel, GET
/api/spend-timeseries (both ?window=5h|24h|7d|<duration>, default 24h).
- internal/storage.ListRoleNames + GET /api/roles: the "which roles exist"
gap -- there was no way to discover role names before this, only to list
versions for a role you already knew the name of.
Chart-form decisions (dataviz skill, invoked before writing chart code):
horizontal stacked bar for the escalation funnel (rung order already
encodes the funnel shape positionally; color only needed for per-provider
segments within each rung); multi-line for spend-over-time; a fixed-order
categorical palette from the skill's validated palette.md slots for
provider identity (re-validated against this app's dark surface, passing);
a separate status (good/warning/critical) palette for budget meters,
deliberately distinct from both --state-* and the provider palette. Caught
and fixed a real bug during visual QA: converging near-zero end-labels on
the spend chart were overlapping (an anti-pattern the skill explicitly
flags) -- fixed with a 14px minimum-gap check before direct-labeling an
endpoint, leaning on the legend/tooltip otherwise.
Verified with a real running server, real seeded data (65 executions across
rungs 0-2 with a realistic provider mix, 2 roles with active/draft/retired
role_configs versions written directly via internal/storage), and a real
headless-browser session (reusing Phase 9a's Chromium/proxy scaffinding):
confirmed correct rung totals/percentages, provider legends, a 3-line spend
chart, live window-selector re-render, correct active-version highlighting
on the role panel, and a real Activate click on a draft version -- verified
via both DOM re-render and a direct backend GET that it truly persisted.
go build/vet/test -race -count=1 all pass, full suite. node --test
web/test/*.mjs: 291/291 passing (16 new).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs
Diffstat (limited to 'web')
| -rw-r--r-- | web/app.js | 636 | ||||
| -rw-r--r-- | web/index.html | 4 | ||||
| -rw-r--r-- | web/style.css | 299 | ||||
| -rw-r--r-- | web/test/dashboard.test.mjs | 154 |
4 files changed, 1093 insertions, 0 deletions
@@ -1366,6 +1366,12 @@ function renderActiveTab(allTasks) { case 'drops': renderDropsPanel(); break; + case 'budget': + renderBudgetPanel(); + break; + case 'roles': + renderRolesPanel(); + break; case 'settings': renderSettingsPanel(); break; @@ -4094,6 +4100,636 @@ function closeStoryModal() { if (modal) modal.close(); } +// ── Budget & Escalation dashboard (Phase 9b) ────────────────────────────────── +// Per-provider spend headroom (GET /api/budget, already wired for the header +// chips), the escalation funnel (GET /api/escalation-funnel — how much work +// resolved at each rung of a role's ladder), and spend-over-time +// (GET /api/spend-timeseries), all per the dataviz skill: provider identity is +// a categorical color job, so it gets a fixed-order palette distinct from the +// --state-* task-state tokens (which are constrained/reused elsewhere and +// pre-date this phase); everything is also always paired with a text +// label/legend so identity never depends on color alone. + +// Fixed categorical order + validated (dark-surface) hex per provider — see +// dataviz skill's palette.md categorical slots 1/2/3/4/5/6 (blue/aqua/yellow/ +// green/violet/red), run through scripts/validate_palette.js against this +// app's dark chart surface (~#0f172a): all 6 pass the lightness/chroma/ +// contrast checks, CVD separation lands in the 8-12 "floor" band, which is +// legal only paired with direct labels/legend — hence every render below +// carries a legend and/or text label, never color alone. +const PROVIDER_COLOR_ORDER = ['local', 'anthropic', 'google', 'groq', 'openrouter', 'openai']; +const PROVIDER_COLORS = { + local: '#3987e5', // blue + anthropic: '#199e70', // aqua + google: '#c98500', // yellow + groq: '#008300', // green + openrouter: '#9085e9', // violet + openai: '#e66767', // red +}; +const PROVIDER_COLOR_FALLBACK = '#898781'; // muted ink — any provider outside the fixed order + +export function colorForProvider(agent) { + return PROVIDER_COLORS[agent] || PROVIDER_COLOR_FALLBACK; +} + +export function providerLabel(agent) { + if (!agent) return 'Unknown'; + return agent.charAt(0).toUpperCase() + agent.slice(1); +} + +function sortProvidersCanonically(agents) { + return [...agents].sort((a, b) => { + const ia = PROVIDER_COLOR_ORDER.indexOf(a); + const ib = PROVIDER_COLOR_ORDER.indexOf(b); + return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib); + }); +} + +// Dataviz skill's "good/warning/critical" status palette (fixed, never +// themed, distinct from the categorical provider slots above) — used for the +// budget meter fill, since remaining-headroom severity is a status job, not +// an identity job. +const BUDGET_STATUS_GOOD = '#0ca30c'; +const BUDGET_STATUS_WARNING = '#fab219'; +const BUDGET_STATUS_CRITICAL = '#d03b3b'; + +function budgetStatusColor(fractionRemaining) { + if (fractionRemaining < 0.2) return BUDGET_STATUS_CRITICAL; + if (fractionRemaining < 0.5) return BUDGET_STATUS_WARNING; + return BUDGET_STATUS_GOOD; +} + +// computeEscalationFunnel reshapes the raw (rung, agent, count, cost_usd) +// aggregate rows from GET /api/escalation-funnel into per-rung totals plus a +// provider breakdown, sorted by rung ascending (rung 0 first — the harness's +// "resolved without escalating" story). +export function computeEscalationFunnel(buckets) { + const byRung = new Map(); + let grandTotal = 0; + for (const b of (buckets || [])) { + grandTotal += b.count; + let r = byRung.get(b.rung); + if (!r) { + r = { rung: b.rung, total: 0, cost: 0, byAgent: [] }; + byRung.set(b.rung, r); + } + r.total += b.count; + r.cost += b.cost_usd || 0; + r.byAgent.push({ agent: b.agent || '', count: b.count, cost: b.cost_usd || 0 }); + } + const rungs = Array.from(byRung.values()).sort((a, b) => a.rung - b.rung); + return { rungs, grandTotal }; +} + +// computeSpendSeries reshapes the raw (bucket, agent, cost_usd) points from +// GET /api/spend-timeseries into aligned per-provider arrays over a shared, +// sorted bucket axis (missing points fill as 0), plus the max value for +// scaling a chart's y-axis. +export function computeSpendSeries(points) { + const bucketsSet = new Set(); + const agentsSet = new Set(); + const byKey = new Map(); + for (const p of (points || [])) { + bucketsSet.add(p.bucket); + const agent = p.agent || ''; + agentsSet.add(agent); + byKey.set(`${p.bucket}|${agent}`, p.cost_usd || 0); + } + const buckets = Array.from(bucketsSet).sort(); + const agents = Array.from(agentsSet).sort(); + const series = {}; + let maxCost = 0; + for (const agent of agents) { + series[agent] = buckets.map(b => { + const v = byKey.get(`${b}|${agent}`) || 0; + if (v > maxCost) maxCost = v; + return v; + }); + } + return { buckets, agents, series, maxCost }; +} + +// formatBucketLabel turns a spend-timeseries bucket key — either an RFC3339 +// hour ("2026-07-02T14:00:00Z") or a calendar day ("2026-07-02") — into a +// short human label. +export function formatBucketLabel(bucket) { + if (!bucket) return ''; + if (bucket.includes('T')) { + return new Date(bucket).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit' }); + } + return new Date(bucket + 'T12:00:00Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + +function renderProviderLegend(agents, doc = document) { + const legend = doc.createElement('div'); + legend.className = 'provider-legend'; + for (const agent of sortProvidersCanonically(agents)) { + const item = doc.createElement('span'); + item.className = 'provider-legend-item'; + const swatch = doc.createElement('span'); + swatch.className = 'provider-legend-swatch'; + swatch.style.background = colorForProvider(agent); + item.appendChild(swatch); + const text = doc.createElement('span'); + text.textContent = providerLabel(agent); + item.appendChild(text); + legend.appendChild(item); + } + return legend; +} + +// renderEscalationFunnel builds the funnel chart: one horizontal bar per +// rung (bar length = share of the window's total executions — the "how much +// falls through each stage" story), stacked by provider within the bar. Bar +// length already encodes the funnel shape via ordinal position + decreasing +// magnitude, so rung identity needs no color of its own; provider identity +// (the stacked segments) is the one categorical color job here, backed by a +// legend and per-segment title tooltips (never color alone). +export function renderEscalationFunnel(funnelData, doc = (typeof document !== 'undefined' ? document : null)) { + if (doc == null) return null; + const wrap = doc.createElement('div'); + wrap.className = 'funnel-chart'; + + if (!funnelData.rungs.length) { + const empty = doc.createElement('p'); + empty.className = 'task-meta'; + empty.textContent = 'No executions in this window.'; + wrap.appendChild(empty); + return wrap; + } + + const maxTotal = Math.max(...funnelData.rungs.map(r => r.total), 1); + const allAgents = new Set(); + + for (const r of funnelData.rungs) { + const row = doc.createElement('div'); + row.className = 'funnel-row'; + + const label = doc.createElement('span'); + label.className = 'funnel-row-label'; + label.textContent = `Rung ${r.rung}`; + row.appendChild(label); + + // trackOuter is the full-width baseline; track is sized to this rung's + // share of the largest rung (the funnel taper), and holds the + // provider-colored segments distributed by flex-grow within it. + const trackOuter = doc.createElement('div'); + trackOuter.className = 'funnel-track-outer'; + + const track = doc.createElement('div'); + track.className = 'funnel-track'; + track.style.width = `${((r.total / maxTotal) * 100).toFixed(1)}%`; + + for (const a of sortProvidersCanonically(r.byAgent.map(x => x.agent)).map(agent => r.byAgent.find(x => x.agent === agent))) { + allAgents.add(a.agent); + const seg = doc.createElement('div'); + seg.className = 'funnel-seg'; + seg.style.background = colorForProvider(a.agent); + seg.style.flexGrow = String(a.count); + const pct = r.total > 0 ? Math.round((a.count / r.total) * 100) : 0; + seg.title = `${providerLabel(a.agent)}: ${a.count} (${pct}%)`; + track.appendChild(seg); + } + + trackOuter.appendChild(track); + row.appendChild(trackOuter); + + const countEl = doc.createElement('span'); + countEl.className = 'funnel-count'; + const pctOfGrand = funnelData.grandTotal > 0 ? Math.round((r.total / funnelData.grandTotal) * 100) : 0; + countEl.textContent = `${r.total} (${pctOfGrand}%)`; + row.appendChild(countEl); + + wrap.appendChild(row); + } + + wrap.appendChild(renderProviderLegend(Array.from(allAgents), doc)); + return wrap; +} + +// renderSpendTimeseries builds a multi-line SVG chart, one line per provider +// (categorical color, fixed order) — cost trend over time is the story, and +// "tell distinct series apart over time" is exactly the multi-line job per +// the dataviz skill's form table. Direct end-labels only when there are few +// enough series to not collide (<=4); a legend always carries identity +// regardless. Hover tooltips (native <title>) on every point per the skill's +// "ship a crosshair+tooltip on line/area" interaction guidance — kept to +// native titles rather than a custom crosshair overlay, consistent with this +// app's existing charts (stats tab's throughput/billing bars use the same +// col.title convention). +export function renderSpendTimeseries(seriesData, doc = (typeof document !== 'undefined' ? document : null)) { + if (doc == null) return null; + const { buckets, agents, series, maxCost } = seriesData; + const wrap = doc.createElement('div'); + wrap.className = 'spend-chart-wrap'; + + if (buckets.length === 0 || agents.length === 0) { + const empty = doc.createElement('p'); + empty.className = 'task-meta'; + empty.textContent = 'No spend data in this window.'; + wrap.appendChild(empty); + return wrap; + } + + const width = 640, height = 200; + const padL = 8, padR = 52, padT = 12, padB = 24; + const plotW = width - padL - padR; + const plotH = height - padT - padB; + const yMax = maxCost > 0 ? maxCost * 1.15 : 1; + + const xFor = i => (buckets.length > 1 ? padL + (i / (buckets.length - 1)) * plotW : padL + plotW / 2); + const yFor = v => (height - padB) - (v / yMax) * plotH; + + const svg = doc.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('viewBox', `0 0 ${width} ${height}`); + svg.setAttribute('width', '100%'); + svg.setAttribute('height', String(height)); + svg.classList.add('spend-chart-svg'); + + const axis = doc.createElementNS(SVG_NS, 'line'); + axis.setAttribute('x1', String(padL)); + axis.setAttribute('x2', String(width - padR)); + axis.setAttribute('y1', String(height - padB)); + axis.setAttribute('y2', String(height - padB)); + axis.setAttribute('class', 'spend-chart-axis'); + svg.appendChild(axis); + + const sortedAgents = sortProvidersCanonically(agents); + const directLabels = sortedAgents.length <= 4; + + // Pre-compute end-label eligibility: when two series' final values land + // close together in y, only the first (top-most) keeps its direct label; + // per the dataviz skill's "when end-labels collide, don't stack them" + // rule, the rest fall back to the legend + hover tooltip rather than + // being nudged apart (which would detach a label from its line). + const labelEligible = new Set(); + if (directLabels) { + const MIN_LABEL_GAP_PX = 14; + const candidates = sortedAgents + .map(agent => { + const values = series[agent] || []; + return values.length > 0 ? { agent, y: yFor(values[values.length - 1]) } : null; + }) + .filter(Boolean) + .sort((a, b) => a.y - b.y); + let lastLabeledY = -Infinity; + for (const c of candidates) { + if (c.y - lastLabeledY >= MIN_LABEL_GAP_PX) { + labelEligible.add(c.agent); + lastLabeledY = c.y; + } + } + } + + for (const agent of sortedAgents) { + const values = series[agent] || []; + const color = colorForProvider(agent); + const pointsAttr = values.map((v, i) => `${xFor(i).toFixed(1)},${yFor(v).toFixed(1)}`).join(' '); + + const poly = doc.createElementNS(SVG_NS, 'polyline'); + poly.setAttribute('points', pointsAttr); + poly.setAttribute('fill', 'none'); + poly.setAttribute('stroke', color); + poly.setAttribute('stroke-width', '2'); + poly.setAttribute('stroke-linejoin', 'round'); + poly.setAttribute('stroke-linecap', 'round'); + poly.classList.add('spend-chart-line'); + svg.appendChild(poly); + + values.forEach((v, i) => { + const dot = doc.createElementNS(SVG_NS, 'circle'); + dot.setAttribute('cx', xFor(i).toFixed(1)); + dot.setAttribute('cy', yFor(v).toFixed(1)); + dot.setAttribute('r', i === values.length - 1 ? '4' : '3'); + dot.setAttribute('fill', color); + dot.classList.add('spend-chart-dot'); + const title = doc.createElementNS(SVG_NS, 'title'); + title.textContent = `${providerLabel(agent)} · ${formatBucketLabel(buckets[i])}: $${v.toFixed(3)}`; + dot.appendChild(title); + svg.appendChild(dot); + }); + + if (labelEligible.has(agent) && values.length > 0) { + const last = values[values.length - 1]; + const label = doc.createElementNS(SVG_NS, 'text'); + label.setAttribute('x', (xFor(values.length - 1) + 6).toFixed(1)); + label.setAttribute('y', (yFor(last) + 4).toFixed(1)); + label.setAttribute('class', 'spend-chart-label'); + label.textContent = `$${last.toFixed(2)}`; + svg.appendChild(label); + } + } + + const tickIdxs = buckets.length > 1 ? [0, Math.floor((buckets.length - 1) / 2), buckets.length - 1] : [0]; + const seenTicks = new Set(); + for (const i of tickIdxs) { + if (seenTicks.has(i)) continue; + seenTicks.add(i); + const t = doc.createElementNS(SVG_NS, 'text'); + t.setAttribute('x', xFor(i).toFixed(1)); + t.setAttribute('y', String(height - 6)); + t.setAttribute('class', 'spend-chart-tick'); + t.setAttribute('text-anchor', i === 0 ? 'start' : (i === buckets.length - 1 ? 'end' : 'middle')); + t.textContent = formatBucketLabel(buckets[i]); + svg.appendChild(t); + } + + wrap.appendChild(svg); + wrap.appendChild(renderProviderLegend(sortedAgents, doc)); + return wrap; +} + +// renderBudgetMeters builds a bar/meter per limited provider showing spend +// vs. its rolling-window cap. Fill color is a status (severity) job — how +// much headroom is left — not an identity job, so it uses the dataviz +// skill's fixed good/warning/critical status palette (deliberately distinct +// from both the --state-* task tokens and the provider categorical palette +// above), always paired with the numeric label so severity never rides on +// color alone. +export function renderBudgetMeters(headrooms, doc = (typeof document !== 'undefined' ? document : null)) { + if (doc == null) return null; + const wrap = doc.createElement('div'); + wrap.className = 'budget-meters'; + + const limited = (headrooms || []).filter(h => h && h.limited); + if (limited.length === 0) { + const empty = doc.createElement('p'); + empty.className = 'task-meta'; + empty.textContent = 'No provider spend limits configured.'; + wrap.appendChild(empty); + return wrap; + } + + for (const h of limited) { + const row = doc.createElement('div'); + row.className = 'budget-meter-row'; + + const label = doc.createElement('div'); + label.className = 'budget-meter-label'; + const name = doc.createElement('span'); + name.textContent = providerLabel(h.provider); + const value = doc.createElement('span'); + value.className = 'budget-meter-value'; + const pctLeft = Math.round((h.fraction_remaining || 0) * 100); + value.textContent = `$${(h.spent_usd || 0).toFixed(2)} / $${(h.limit_usd || 0).toFixed(2)} · ${pctLeft}% left`; + label.append(name, value); + row.appendChild(label); + + const track = doc.createElement('div'); + track.className = 'budget-meter-track'; + const fill = doc.createElement('div'); + fill.className = 'budget-meter-fill'; + const spentPct = h.limit_usd > 0 ? Math.min(100, ((h.spent_usd || 0) / h.limit_usd) * 100) : 0; + fill.style.width = `${spentPct.toFixed(1)}%`; + fill.style.background = budgetStatusColor(h.fraction_remaining || 0); + track.appendChild(fill); + row.appendChild(track); + + wrap.appendChild(row); + } + + return wrap; +} + +function getBudgetWindow() { + return localStorage.getItem('budgetWindow') || '24h'; +} + +function setBudgetWindow(w) { + localStorage.setItem('budgetWindow', w); +} + +async function renderBudgetPanel() { + const panel = document.querySelector('[data-panel="budget"]'); + if (!panel) return; + + const win = getBudgetWindow(); + try { + const [headrooms, funnelRaw, spendRaw] = await Promise.all([ + fetch(`${BASE_PATH}/api/budget`).then(r => r.ok ? r.json() : []), + fetch(`${BASE_PATH}/api/escalation-funnel?window=${encodeURIComponent(win)}`).then(r => r.ok ? r.json() : []), + fetch(`${BASE_PATH}/api/spend-timeseries?window=${encodeURIComponent(win)}`).then(r => r.ok ? r.json() : []), + ]); + + panel.innerHTML = ''; + + // ── Provider spend headroom ── + const budgetSection = document.createElement('div'); + budgetSection.className = 'stats-section'; + const budgetHeading = document.createElement('h2'); + budgetHeading.textContent = 'Provider Spend Headroom'; + budgetSection.appendChild(budgetHeading); + budgetSection.appendChild(renderBudgetMeters(headrooms)); + panel.appendChild(budgetSection); + + // ── Window selector (shared by funnel + spend-over-time below) ── + const windowRow = document.createElement('div'); + windowRow.className = 'dashboard-window-row'; + const windowLabel = document.createElement('label'); + windowLabel.textContent = 'Window: '; + const windowSelect = document.createElement('select'); + windowSelect.className = 'agent-selector dashboard-window-select'; + for (const opt of [['5h', 'Last 5 hours'], ['24h', 'Last 24 hours'], ['7d', 'Last 7 days']]) { + const o = document.createElement('option'); + o.value = opt[0]; + o.textContent = opt[1]; + if (opt[0] === win) o.selected = true; + windowSelect.appendChild(o); + } + windowSelect.addEventListener('change', () => { + setBudgetWindow(windowSelect.value); + renderBudgetPanel(); + }); + windowLabel.appendChild(windowSelect); + windowRow.appendChild(windowLabel); + panel.appendChild(windowRow); + + // ── Escalation funnel ── + const funnelSection = document.createElement('div'); + funnelSection.className = 'stats-section'; + const funnelHeading = document.createElement('h2'); + funnelHeading.textContent = 'Escalation Funnel'; + funnelSection.appendChild(funnelHeading); + const funnelNote = document.createElement('p'); + funnelNote.className = 'task-meta funnel-note'; + funnelNote.textContent = 'Share of executions resolved at each escalation rung (rung 0 = first tier / local-first; higher rungs = escalated to a costlier provider). Non-role-typed tasks always show at rung 0 alongside role-typed tasks resolved there.'; + funnelSection.appendChild(funnelNote); + funnelSection.appendChild(renderEscalationFunnel(computeEscalationFunnel(funnelRaw))); + panel.appendChild(funnelSection); + + // ── Spend over time ── + const spendSection = document.createElement('div'); + spendSection.className = 'stats-section'; + const spendHeading = document.createElement('h2'); + spendHeading.textContent = 'Spend Over Time'; + spendSection.appendChild(spendHeading); + spendSection.appendChild(renderSpendTimeseries(computeSpendSeries(spendRaw))); + panel.appendChild(spendSection); + } catch (err) { + panel.innerHTML = `<div class="panel-fetch-error">Failed to load budget dashboard: ${err.message}</div>`; + } +} + +// ── Role/config management panel (Phase 9b) ─────────────────────────────────── +// Lists every role with at least one role_configs row (GET /api/roles), each +// role's version history (GET /api/roles/{role}/versions), and an Activate +// button on draft versions (POST /api/roles/{role}/activate?version=N) — the +// human-facing side of the Phase 8 retro loop. + +// formatEscalationLadder turns a role.RoleConfig's escalation_ladder into a +// small readable structure (tier index, selection mode, retry budget, and a +// "provider/model" string per candidate) instead of a raw JSON dump. +export function formatEscalationLadder(ladder) { + if (!ladder || ladder.length === 0) return []; + return ladder.map((tier, i) => ({ + tier: i, + mode: tier.selection_mode || 'round_robin', + maxRetries: tier.max_retries || 0, + candidates: (tier.candidates || []).map(c => (c.model ? `${c.provider}/${c.model}` : c.provider)), + })); +} + +function renderEscalationLadderTable(ladder, doc = document) { + const rows = formatEscalationLadder(ladder); + if (rows.length === 0) { + const empty = doc.createElement('p'); + empty.className = 'task-meta'; + empty.textContent = 'No escalation ladder configured.'; + return empty; + } + const list = doc.createElement('ol'); + list.className = 'escalation-ladder-list'; + for (const row of rows) { + const li = doc.createElement('li'); + li.className = 'escalation-ladder-item'; + const tierLabel = doc.createElement('span'); + tierLabel.className = 'escalation-ladder-tier'; + tierLabel.textContent = `Tier ${row.tier}`; + const candidates = doc.createElement('span'); + candidates.className = 'escalation-ladder-candidates'; + candidates.textContent = row.candidates.join(', ') || '(none)'; + const meta = doc.createElement('span'); + meta.className = 'escalation-ladder-meta'; + meta.textContent = `${row.mode}, max ${row.maxRetries} retr${row.maxRetries === 1 ? 'y' : 'ies'} before escalating`; + li.append(tierLabel, candidates, meta); + list.appendChild(li); + } + return list; +} + +async function handleActivateRoleVersionClick(roleName, version, btn) { + btn.disabled = true; + const original = btn.textContent; + btn.textContent = 'Activating…'; + try { + const res = await fetch(`${API_BASE}/api/roles/${encodeURIComponent(roleName)}/activate?version=${version}`, { method: 'POST' }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${res.status}`); + } + await renderRolesPanel(); + } catch (err) { + btn.disabled = false; + btn.textContent = original; + alert(`Failed to activate version ${version}: ${err.message}`); + } +} + +function renderRoleCard(roleName, versions, doc = document) { + const card = doc.createElement('div'); + card.className = 'role-card'; + + const header = doc.createElement('div'); + header.className = 'role-card-header'; + const title = doc.createElement('h3'); + title.textContent = roleName; + header.appendChild(title); + + const active = versions.find(v => v.status === 'active'); + const activeBadge = doc.createElement('span'); + activeBadge.className = 'role-active-badge' + (active ? '' : ' role-active-badge--none'); + activeBadge.textContent = active ? `Active: v${active.version}` : 'No active version'; + header.appendChild(activeBadge); + card.appendChild(header); + + if (active) { + const ladderSection = doc.createElement('div'); + ladderSection.className = 'role-ladder-section'; + const ladderLabel = doc.createElement('div'); + ladderLabel.className = 'panel-section-title'; + ladderLabel.textContent = `Escalation Ladder (v${active.version})`; + ladderSection.appendChild(ladderLabel); + ladderSection.appendChild(renderEscalationLadderTable(active.config && active.config.escalation_ladder, doc)); + card.appendChild(ladderSection); + } + + const list = doc.createElement('div'); + list.className = 'role-version-list'; + const sorted = [...versions].sort((a, b) => b.version - a.version); + for (const v of sorted) { + const row = doc.createElement('div'); + row.className = 'role-version-row role-version-row--' + v.status; + + const vLabel = doc.createElement('span'); + vLabel.className = 'role-version-num'; + vLabel.textContent = `v${v.version}`; + row.appendChild(vLabel); + + const statusBadge = doc.createElement('span'); + statusBadge.className = 'role-version-status role-version-status--' + v.status; + statusBadge.textContent = v.status; + row.appendChild(statusBadge); + + const proposedBy = doc.createElement('span'); + proposedBy.className = 'role-version-meta'; + proposedBy.textContent = v.proposed_by ? `by ${v.proposed_by}` : ''; + row.appendChild(proposedBy); + + const created = doc.createElement('span'); + created.className = 'role-version-meta'; + created.textContent = v.created_at ? formatDate(v.created_at) : ''; + row.appendChild(created); + + if (v.status === 'draft') { + const activateBtn = doc.createElement('button'); + activateBtn.className = 'btn-primary btn-sm'; + activateBtn.textContent = 'Activate'; + activateBtn.addEventListener('click', () => handleActivateRoleVersionClick(roleName, v.version, activateBtn)); + row.appendChild(activateBtn); + } + + list.appendChild(row); + } + card.appendChild(list); + + return card; +} + +async function renderRolesPanel() { + const panel = document.querySelector('[data-panel="roles"]'); + if (!panel) return; + + try { + const namesRes = await fetch(`${BASE_PATH}/api/roles`); + const names = namesRes.ok ? await namesRes.json() : []; + if (!names || names.length === 0) { + panel.innerHTML = '<div class="task-empty">No role configs yet.</div>'; + return; + } + + const versionsByRole = await Promise.all(names.map(name => + fetch(`${BASE_PATH}/api/roles/${encodeURIComponent(name)}/versions`).then(r => r.ok ? r.json() : []), + )); + + panel.innerHTML = ''; + names.forEach((name, i) => { + panel.appendChild(renderRoleCard(name, versionsByRole[i] || [])); + }); + } catch (err) { + panel.innerHTML = `<div class="panel-fetch-error">Failed to load roles: ${err.message}</div>`; + } +} + // ── Tab switching ───────────────────────────────────────────────────────────── function switchTab(name) { diff --git a/web/index.html b/web/index.html index 523edd9..32c029e 100644 --- a/web/index.html +++ b/web/index.html @@ -47,6 +47,8 @@ <button class="tab" data-tab="stories" title="Stories">📋</button> <button class="tab" data-tab="drops" title="Drops">📁</button> <button class="tab" data-tab="stats" title="Stats">📊</button> + <button class="tab" data-tab="budget" title="Budget & Escalation">💰</button> + <button class="tab" data-tab="roles" title="Roles">🎛️</button> <button class="tab" data-tab="settings" title="Settings">⚙️</button> </nav> <main id="app"> @@ -81,6 +83,8 @@ <div class="drops-panel"></div> </div> <div data-panel="stats" hidden></div> + <div data-panel="budget" hidden></div> + <div data-panel="roles" hidden></div> <div data-panel="settings" hidden> <p class="task-meta" style="padding:1rem">Settings coming soon.</p> </div> diff --git a/web/style.css b/web/style.css index b11faf6..a5fefb7 100644 --- a/web/style.css +++ b/web/style.css @@ -16,6 +16,14 @@ --text: #e2e8f0; --text-muted: #94a3b8; --accent: #38bdf8; + + /* Approximate solid chart surface (this app has no opaque surface token — + --surface/--bg are translucent over the rotating background image). + Used as the SVG chart background, unlabeled figure, and marker rings + (see marks-and-anatomy.md's "surface ring" spec) for the Phase 9b + budget/escalation charts; the dataviz palette validator was run against + this exact hex. */ + --chart-surface: #0f172a; } /* Reset */ @@ -2408,3 +2416,294 @@ dialog label select:focus { border-radius: 0; } } + +/* ── Budget & Escalation dashboard (Phase 9b) ────────────────────────────── + Provider identity uses a fixed categorical palette (PROVIDER_COLORS in + app.js), deliberately distinct from --state-* — see app.js's doc comment + for why and how it was validated. ── */ + +.provider-legend { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 1rem; + margin-top: 0.6rem; + font-size: 0.75rem; + color: var(--text-muted); +} + +.provider-legend-item { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.provider-legend-swatch { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 2px; + flex-shrink: 0; +} + +/* Budget meters */ +.budget-meters { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.budget-meter-row { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.budget-meter-label { + display: flex; + justify-content: space-between; + font-size: 0.82rem; +} + +.budget-meter-label > span:first-child { + font-weight: 600; + text-transform: capitalize; +} + +.budget-meter-value { + color: var(--text-muted); + font-size: 0.78rem; +} + +.budget-meter-track { + height: 10px; + border-radius: 999px; + background: var(--border); + overflow: hidden; +} + +.budget-meter-fill { + height: 100%; + border-radius: 999px; + transition: width 0.2s; +} + +/* Window selector shared by the funnel + spend-over-time charts */ +.dashboard-window-row { + margin: 0.5rem 0 1.5rem; + font-size: 0.85rem; + color: var(--text-muted); +} + +.dashboard-window-select { + margin-left: 0.35rem; +} + +.funnel-note { + margin-bottom: 0.75rem; + max-width: 60ch; +} + +/* Escalation funnel */ +.funnel-chart { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.funnel-row { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.funnel-row-label { + width: 60px; + flex-shrink: 0; + font-size: 0.8rem; + font-weight: 600; + color: var(--text-muted); +} + +.funnel-track-outer { + flex: 1; + min-width: 0; +} + +.funnel-track { + display: flex; + height: 22px; + border-radius: 4px; + overflow: hidden; + min-width: 4px; +} + +.funnel-seg { + height: 100%; +} + +/* The 2px surface-color gap between stacked segments (see + marks-and-anatomy.md's "surface gap" spec) — border rather than margin so + it doesn't shrink the flex-grow proportions. */ +.funnel-seg + .funnel-seg { + border-left: 2px solid var(--chart-surface); +} + +.funnel-count { + width: 90px; + flex-shrink: 0; + text-align: right; + font-size: 0.78rem; + color: var(--text-muted); + white-space: nowrap; +} + +/* Spend-over-time line chart */ +.spend-chart-wrap { + margin-top: 0.5rem; +} + +.spend-chart-svg { + background: var(--chart-surface); + border-radius: 8px; + display: block; +} + +.spend-chart-axis { + stroke: var(--border); + stroke-width: 1; +} + +.spend-chart-tick { + fill: var(--text-muted); + font-size: 9px; +} + +.spend-chart-label { + fill: var(--text-muted); + font-size: 9px; +} + +.spend-chart-dot { + stroke: var(--chart-surface); + stroke-width: 2; +} + +/* Role/config management panel */ +.role-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem 1.25rem; + margin-bottom: 1rem; +} + +.role-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.5rem; +} + +.role-card-header h3 { + font-size: 1rem; + font-weight: 700; + text-transform: capitalize; +} + +.role-active-badge { + font-size: 0.72rem; + font-weight: 600; + padding: 0.2em 0.6em; + border-radius: 999px; + background: var(--state-completed); + color: #0f172a; + white-space: nowrap; +} + +.role-active-badge--none { + background: var(--border); + color: var(--text-muted); +} + +.role-ladder-section { + margin-bottom: 0.85rem; +} + +.escalation-ladder-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-top: 0.35rem; +} + +.escalation-ladder-item { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem; + font-size: 0.82rem; + padding: 0.35rem 0.6rem; + background: rgba(148, 163, 184, 0.08); + border-radius: 6px; +} + +.escalation-ladder-tier { + font-weight: 700; + min-width: 3.5rem; +} + +.escalation-ladder-candidates { + font-family: ui-monospace, monospace; + font-size: 0.78rem; +} + +.escalation-ladder-meta { + color: var(--text-muted); + font-size: 0.75rem; + margin-left: auto; +} + +.role-version-list { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.role-version-row { + display: flex; + align-items: center; + gap: 0.6rem; + font-size: 0.82rem; + padding: 0.4rem 0.1rem; + border-top: 1px solid var(--border); +} + +.role-version-num { + font-weight: 700; + min-width: 2.5rem; +} + +.role-version-status { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 0.15em 0.5em; + border-radius: 999px; + color: #0f172a; +} + +.role-version-status--active { background: var(--state-completed); } +.role-version-status--draft { background: var(--state-queued); } +.role-version-status--retired { background: var(--border); color: var(--text-muted); } + +.role-version-meta { + color: var(--text-muted); + font-size: 0.75rem; +} + +.role-version-row .btn-primary.btn-sm { + margin-left: auto; +} diff --git a/web/test/dashboard.test.mjs b/web/test/dashboard.test.mjs new file mode 100644 index 0000000..0f96c08 --- /dev/null +++ b/web/test/dashboard.test.mjs @@ -0,0 +1,154 @@ +// dashboard.test.mjs — Unit tests for the Phase 9b budget/escalation +// dashboard's pure logic: reshaping the raw escalation-funnel and +// spend-timeseries aggregate rows, provider color/label lookup, and +// escalation-ladder formatting for the role/config panel. Mirrors +// stories-board.test.mjs's convention of testing pure computation only — +// DOM-building render functions are verified against a real running server +// (see Phase 9b's manual verification notes), not unit-tested here. +// +// Run with: node --test web/test/dashboard.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + computeEscalationFunnel, + computeSpendSeries, + formatBucketLabel, + colorForProvider, + providerLabel, + formatEscalationLadder, +} from '../app.js'; + +describe('computeEscalationFunnel', () => { + it('groups by rung, summing counts/cost and collecting per-agent breakdown', () => { + const buckets = [ + { rung: 0, agent: 'local', count: 5, cost_usd: 0 }, + { rung: 0, agent: 'anthropic', count: 1, cost_usd: 0.02 }, + { rung: 1, agent: 'anthropic', count: 2, cost_usd: 0.10 }, + ]; + const { rungs, grandTotal } = computeEscalationFunnel(buckets); + assert.equal(grandTotal, 8); + assert.equal(rungs.length, 2); + assert.equal(rungs[0].rung, 0); + assert.equal(rungs[0].total, 6); + assert.equal(rungs[0].byAgent.length, 2); + assert.equal(rungs[1].rung, 1); + assert.equal(rungs[1].total, 2); + assert.equal(rungs[1].cost, 0.10); + }); + + it('sorts rungs ascending regardless of input order', () => { + const buckets = [ + { rung: 2, agent: 'openai', count: 1, cost_usd: 0.5 }, + { rung: 0, agent: 'local', count: 3, cost_usd: 0 }, + { rung: 1, agent: 'google', count: 2, cost_usd: 0.1 }, + ]; + const { rungs } = computeEscalationFunnel(buckets); + assert.deepEqual(rungs.map(r => r.rung), [0, 1, 2]); + }); + + it('returns an empty rungs array and zero grand total for no data', () => { + const { rungs, grandTotal } = computeEscalationFunnel([]); + assert.deepEqual(rungs, []); + assert.equal(grandTotal, 0); + }); + + it('handles a missing/null buckets argument gracefully', () => { + const { rungs, grandTotal } = computeEscalationFunnel(undefined); + assert.deepEqual(rungs, []); + assert.equal(grandTotal, 0); + }); +}); + +describe('computeSpendSeries', () => { + it('aligns per-provider series over a shared sorted bucket axis, filling gaps with 0', () => { + const points = [ + { bucket: '2026-07-01', agent: 'local', cost_usd: 1.0 }, + { bucket: '2026-07-02', agent: 'local', cost_usd: 2.0 }, + { bucket: '2026-07-02', agent: 'anthropic', cost_usd: 0.5 }, + ]; + const { buckets, agents, series, maxCost } = computeSpendSeries(points); + assert.deepEqual(buckets, ['2026-07-01', '2026-07-02']); + assert.deepEqual(agents, ['anthropic', 'local']); + assert.deepEqual(series.local, [1.0, 2.0]); + assert.deepEqual(series.anthropic, [0, 0.5]); + assert.equal(maxCost, 2.0); + }); + + it('returns empty structures for no data', () => { + const { buckets, agents, series, maxCost } = computeSpendSeries([]); + assert.deepEqual(buckets, []); + assert.deepEqual(agents, []); + assert.deepEqual(series, {}); + assert.equal(maxCost, 0); + }); +}); + +describe('formatBucketLabel', () => { + it('formats an hourly RFC3339 bucket with the hour', () => { + const label = formatBucketLabel('2026-07-02T14:00:00Z'); + assert.match(label, /\d/); // exact format is locale-dependent; just confirm it's non-empty and date-like + }); + + it('formats a daily YYYY-MM-DD bucket', () => { + const label = formatBucketLabel('2026-07-02'); + assert.match(label, /\d/); + }); + + it('returns empty string for falsy input', () => { + assert.equal(formatBucketLabel(''), ''); + assert.equal(formatBucketLabel(null), ''); + }); +}); + +describe('colorForProvider / providerLabel', () => { + it('returns a distinct hex color for each known provider', () => { + const providers = ['local', 'anthropic', 'google', 'groq', 'openrouter', 'openai']; + const colors = new Set(providers.map(colorForProvider)); + assert.equal(colors.size, providers.length, 'expected all known providers to have distinct colors'); + for (const c of colors) assert.match(c, /^#[0-9a-f]{6}$/i); + }); + + it('falls back to a muted color for an unknown provider', () => { + assert.match(colorForProvider('some-future-provider'), /^#[0-9a-f]{6}$/i); + }); + + it('capitalizes provider labels', () => { + assert.equal(providerLabel('anthropic'), 'Anthropic'); + assert.equal(providerLabel('local'), 'Local'); + }); + + it('labels a missing/empty provider as Unknown', () => { + assert.equal(providerLabel(''), 'Unknown'); + assert.equal(providerLabel(undefined), 'Unknown'); + }); +}); + +describe('formatEscalationLadder', () => { + it('formats each tier with index, mode, retry budget, and candidate strings', () => { + const ladder = [ + { candidates: [{ provider: 'local', model: 'llama3.1:8b' }], selection_mode: 'single', max_retries: 2 }, + { candidates: [{ provider: 'anthropic', model: 'claude-haiku' }, { provider: 'google', model: 'gemini-flash' }], max_retries: 0 }, + ]; + const rows = formatEscalationLadder(ladder); + assert.equal(rows.length, 2); + assert.equal(rows[0].tier, 0); + assert.equal(rows[0].mode, 'single'); + assert.equal(rows[0].maxRetries, 2); + assert.deepEqual(rows[0].candidates, ['local/llama3.1:8b']); + + assert.equal(rows[1].tier, 1); + assert.equal(rows[1].mode, 'round_robin'); // default when unset + assert.deepEqual(rows[1].candidates, ['anthropic/claude-haiku', 'google/gemini-flash']); + }); + + it('formats a provider-only candidate (no model) without a trailing slash', () => { + const rows = formatEscalationLadder([{ candidates: [{ provider: 'local' }], max_retries: 0 }]); + assert.deepEqual(rows[0].candidates, ['local']); + }); + + it('returns an empty array for an empty/missing ladder', () => { + assert.deepEqual(formatEscalationLadder([]), []); + assert.deepEqual(formatEscalationLadder(undefined), []); + }); +}); |
