summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorClaude Sonnet 5 <noreply@anthropic.com>2026-07-04 08:50:46 +0000
committerClaude Sonnet 5 <noreply@anthropic.com>2026-07-04 08:50:46 +0000
commitf8ae821240f33d615a9e91cdfeb6c026b7970782 (patch)
tree458ce067db47a92248f163c26ce5fcf71299f3b0 /web
parentd105eca610b0e737c3313e4978d6a917b4f55d10 (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.js636
-rw-r--r--web/index.html4
-rw-r--r--web/style.css299
-rw-r--r--web/test/dashboard.test.mjs154
4 files changed, 1093 insertions, 0 deletions
diff --git a/web/app.js b/web/app.js
index 6f45253..894aaec 100644
--- a/web/app.js
+++ b/web/app.js
@@ -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 &amp; 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), []);
+ });
+});