summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-05-26 20:34:09 +0000
committerClaude <noreply@anthropic.com>2026-05-26 20:34:09 +0000
commitab4b364954af08fa602388495ca425eaef0abf74 (patch)
treec0806a57295270b4d86f097c3d0b6a0e1d631201 /web
parent32715355fe2eed321df4f7083dfe580d35f8a62a (diff)
feat(api,web): budget headroom endpoint + UI chips (Phase 6)
Adds GET /api/budget returning per-provider rolling-window headroom (empty when budget gating is unconfigured), wired from serve.go via SetBudget. The web UI polls it and renders a chip per limited provider in the header, flagging any under 20% remaining. New pure JS helpers formatBudgetHeadroom/ renderBudgetHeadroom are unit-tested; the endpoint is covered by Go handler tests (empty/reports/500). UI render not browser-verified in this environment. Completes Phase 6: spend accounting + dispatcher gating + observability surface. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39
Diffstat (limited to 'web')
-rw-r--r--web/app.js48
-rw-r--r--web/index.html1
-rw-r--r--web/style.css21
-rw-r--r--web/test/budget.test.mjs58
4 files changed, 128 insertions, 0 deletions
diff --git a/web/app.js b/web/app.js
index 8a2662f..7f96e09 100644
--- a/web/app.js
+++ b/web/app.js
@@ -198,6 +198,53 @@ export async function fetchTaskEvents(taskId, fetchImpl = (typeof fetch !== 'und
}
}
+// ── Budget headroom ─────────────────────────────────────────────────────────
+// Renders the per-provider rolling-window spend headroom from GET /api/budget.
+
+// formatBudgetHeadroom turns one provider's headroom into a short label.
+// Returns '' for unlimited providers (nothing to show).
+export function formatBudgetHeadroom(h) {
+ if (!h || !h.limited) return '';
+ const pct = Math.round((h.fraction_remaining || 0) * 100);
+ const remaining = (h.remaining_usd || 0).toFixed(2);
+ const limit = (h.limit_usd || 0).toFixed(2);
+ const name = h.provider ? h.provider.charAt(0).toUpperCase() + h.provider.slice(1) : '?';
+ return `${name}: ${pct}% left ($${remaining} of $${limit})`;
+}
+
+// renderBudgetHeadroom builds a <span> chip per limited provider, flagging
+// providers under 20% remaining with a --low modifier. Returns the container.
+export function renderBudgetHeadroom(headrooms, doc = (typeof document !== 'undefined' ? document : null)) {
+ if (doc == null) return null;
+ const wrap = doc.createElement('div');
+ wrap.className = 'budget-bar';
+ for (const h of headrooms || []) {
+ if (!h || !h.limited) continue;
+ const chip = doc.createElement('span');
+ chip.className = 'budget-chip' + ((h.fraction_remaining || 0) < 0.2 ? ' budget-chip--low' : '');
+ chip.textContent = formatBudgetHeadroom(h);
+ wrap.appendChild(chip);
+ }
+ return wrap;
+}
+
+// loadBudget fetches headroom and injects chips into #budget-bar. Best-effort.
+async function loadBudget(fetchImpl = (typeof fetch !== 'undefined' ? fetch : null)) {
+ if (!fetchImpl || typeof document === 'undefined') return;
+ const slot = document.getElementById('budget-bar');
+ if (!slot) return;
+ try {
+ const resp = await fetchImpl(`${BASE_PATH}/api/budget`);
+ if (!resp.ok) return;
+ const headrooms = await resp.json();
+ const rendered = renderBudgetHeadroom(headrooms);
+ slot.innerHTML = '';
+ if (rendered) for (const c of rendered.children) slot.appendChild(c);
+ } catch {
+ // budget display is non-critical; ignore.
+ }
+}
+
function truncateToWordBoundary(text, maxLen = 120) {
if (!text || text.length <= maxLen) return text;
const cut = text.lastIndexOf(' ', maxLen);
@@ -1297,6 +1344,7 @@ function renderActiveTab(allTasks) {
async function poll() {
try {
+ loadBudget(); // fire-and-forget; budget can change independently of tasks
const health = await fetchHealth();
const serverUpdate = health.last_updated;
diff --git a/web/index.html b/web/index.html
index 8a705cc..5aa7b44 100644
--- a/web/index.html
+++ b/web/index.html
@@ -37,6 +37,7 @@
<button id="btn-notifications" class="btn-secondary" title="Enable push notifications">🔔</button>
<button id="btn-start-next" class="btn-secondary">Start Next</button>
</div>
+ <div id="budget-bar" class="budget-bar"></div>
</header>
<nav class="tab-bar">
<button class="tab active" data-tab="queue" title="Queue">⏳</button>
diff --git a/web/style.css b/web/style.css
index f4a9d91..b6f0484 100644
--- a/web/style.css
+++ b/web/style.css
@@ -2034,3 +2034,24 @@ dialog label select:focus {
font-size: 0.85rem;
padding: 6px 10px;
}
+
+/* Budget headroom chips */
+.budget-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+}
+.budget-chip {
+ font-size: 0.72rem;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ color: var(--text-muted);
+ white-space: nowrap;
+}
+.budget-chip--low {
+ border-color: var(--state-budget-exceeded);
+ color: var(--state-budget-exceeded);
+}
diff --git a/web/test/budget.test.mjs b/web/test/budget.test.mjs
new file mode 100644
index 0000000..35d5256
--- /dev/null
+++ b/web/test/budget.test.mjs
@@ -0,0 +1,58 @@
+// budget.test.mjs — Unit tests for budget headroom display.
+//
+// Run with: node --test web/test/budget.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { formatBudgetHeadroom, renderBudgetHeadroom } from '../app.js';
+
+function makeDoc() {
+ return {
+ createElement(tag) {
+ return {
+ tag,
+ className: '',
+ textContent: '',
+ children: [],
+ appendChild(child) { this.children.push(child); return child; },
+ };
+ },
+ };
+}
+
+describe('formatBudgetHeadroom', () => {
+ it('formats a limited provider with percent and dollars', () => {
+ assert.equal(
+ formatBudgetHeadroom({ provider: 'claude', limited: true, limit_usd: 10, remaining_usd: 6, fraction_remaining: 0.6 }),
+ 'Claude: 60% left ($6.00 of $10.00)',
+ );
+ });
+
+ it('returns empty string for unlimited providers', () => {
+ assert.equal(formatBudgetHeadroom({ provider: 'local', limited: false }), '');
+ assert.equal(formatBudgetHeadroom(null), '');
+ });
+});
+
+describe('renderBudgetHeadroom', () => {
+ it('renders one chip per limited provider, skipping unlimited', () => {
+ const wrap = renderBudgetHeadroom([
+ { provider: 'claude', limited: true, limit_usd: 10, remaining_usd: 6, fraction_remaining: 0.6 },
+ { provider: 'local', limited: false },
+ ], makeDoc());
+ assert.equal(wrap.children.length, 1);
+ assert.equal(wrap.children[0].className, 'budget-chip');
+ assert.match(wrap.children[0].textContent, /Claude: 60% left/);
+ });
+
+ it('flags providers under 20% remaining with --low', () => {
+ const wrap = renderBudgetHeadroom([
+ { provider: 'gemini', limited: true, limit_usd: 5, remaining_usd: 0.5, fraction_remaining: 0.1 },
+ ], makeDoc());
+ assert.equal(wrap.children[0].className, 'budget-chip budget-chip--low');
+ });
+
+ it('returns null when doc is null', () => {
+ assert.equal(renderBudgetHeadroom([], null), null);
+ });
+});