diff options
Diffstat (limited to 'web/app.js')
| -rw-r--r-- | web/app.js | 636 |
1 files changed, 636 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) { |
