summaryrefslogtreecommitdiff
path: root/web/app.js
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/app.js
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/app.js')
-rw-r--r--web/app.js636
1 files changed, 636 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) {