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