/** * Budget Configuration Page (Feature 5 — Story 7) * * Full budget management: list, create, edit, delete. * Also includes anomaly detection configuration. */ import React, { useState, useCallback } from 'react'; import { useApi } from '../hooks/useApi'; import { listBudgets, createBudget, updateBudget, deleteBudget, getBudgetStatus, getAnomalyConfig, updateAnomalyConfig, type CostBudgetData, type CreateCostBudgetData, type CostBudgetStatusData, type CostAnomalyConfigData, type CostBudgetScope, type CostBudgetPeriod, type CostBudgetOnBreach, } from '../api/budgets'; // ─── Constants ────────────────────────────────────────────── const SCOPE_OPTIONS: { value: CostBudgetScope; label: string }[] = [ { value: 'session', label: 'Session' }, { value: 'agent', label: 'Agent' }, ]; const PERIOD_OPTIONS: { value: CostBudgetPeriod; label: string; scopes: CostBudgetScope[] }[] = [ { value: 'session', label: 'Per Session', scopes: ['session'] }, { value: 'daily', label: 'Daily', scopes: ['agent'] }, { value: 'weekly', label: 'Weekly', scopes: ['agent'] }, { value: 'monthly', label: 'Monthly', scopes: ['agent'] }, ]; const BREACH_OPTIONS: { value: CostBudgetOnBreach; label: string }[] = [ { value: 'alert', label: 'Alert Only' }, { value: 'pause_agent', label: 'Pause Agent' }, { value: 'downgrade_model', label: 'Downgrade Model' }, ]; type Tab = 'budgets' | 'anomaly'; function formatTimestamp(ts: string): string { try { return new Date(ts).toLocaleString(); } catch { return ts; } } // ─── Main Page ────────────────────────────────────────────── export default function BudgetConfig() { const [tab, setTab] = useState('budgets'); const [showForm, setShowForm] = useState(false); const [editingBudget, setEditingBudget] = useState(null); const [statusMap, setStatusMap] = useState>({}); const budgetsQuery = useApi(() => listBudgets(), []); const anomalyQuery = useApi(() => getAnomalyConfig(), []); const budgets = budgetsQuery.data?.budgets ?? []; const handleCreate = useCallback(async (data: CreateCostBudgetData) => { await createBudget(data); setShowForm(false); setEditingBudget(null); budgetsQuery.refetch(); }, [budgetsQuery]); const handleUpdate = useCallback(async (id: string, data: Partial) => { await updateBudget(id, data); setShowForm(false); setEditingBudget(null); budgetsQuery.refetch(); }, [budgetsQuery]); const handleDelete = useCallback(async (id: string) => { if (!confirm('Delete this budget?')) return; await deleteBudget(id); budgetsQuery.refetch(); }, [budgetsQuery]); const handleToggle = useCallback(async (budget: CostBudgetData) => { await updateBudget(budget.id, { enabled: !budget.enabled }); budgetsQuery.refetch(); }, [budgetsQuery]); const handleEdit = useCallback((budget: CostBudgetData) => { setEditingBudget(budget); setShowForm(true); }, []); const handleViewStatus = useCallback(async (id: string) => { try { const status = await getBudgetStatus(id); setStatusMap((prev) => ({ ...prev, [id]: status })); } catch { /* ignore */ } }, []); const handleAnomalyUpdate = useCallback(async (data: { multiplier?: number; minSessions?: number; enabled?: boolean }) => { await updateAnomalyConfig(data); anomalyQuery.refetch(); }, [anomalyQuery]); return (

💰 Cost Budgets

{tab === 'budgets' && ( )}
{/* Tabs */}
{/* Budget Form */} {showForm && tab === 'budgets' && ( handleUpdate(editingBudget.id, data) : handleCreate } onCancel={() => { setShowForm(false); setEditingBudget(null); }} /> )} {/* Budgets Tab */} {tab === 'budgets' && (
{budgets.length === 0 &&

No cost budgets configured yet.

} {budgets.length > 0 && ( )} {budgets.map((b) => { const status = statusMap[b.id]; return ( ); })}
Scope Agent Period Limit On Breach Enabled Status Actions
{b.scope} {b.agentId || '—'} {b.period} ${b.limitUsd.toFixed(2)} {b.onBreach.replace('_', ' ')} {b.enabled ? '● On' : '○ Off'} {status ? ( ) : ( )}
)} {/* Anomaly Tab */} {tab === 'anomaly' && ( )}
); } // ─── Budget Status Badge ──────────────────────────────────── export function BudgetStatusBadge({ currentSpend, limitUsd }: { currentSpend: number; limitUsd: number }) { const pct = limitUsd > 0 ? Math.min((currentSpend / limitUsd) * 100, 100) : 0; const color = pct >= 100 ? '#ef4444' : pct >= 80 ? '#f59e0b' : '#22c55e'; return (
${currentSpend.toFixed(2)} / ${limitUsd.toFixed(2)}
); } // ─── Budget Form ──────────────────────────────────────────── function BudgetForm({ budget, onSubmit, onCancel }: { budget: CostBudgetData | null; onSubmit: (data: CreateCostBudgetData) => void; onCancel: () => void; }) { const [scope, setScope] = useState(budget?.scope ?? 'session'); const [agentId, setAgentId] = useState(budget?.agentId ?? ''); const [period, setPeriod] = useState(budget?.period ?? 'session'); const [limitUsd, setLimitUsd] = useState(String(budget?.limitUsd ?? '1.00')); const [onBreach, setOnBreach] = useState(budget?.onBreach ?? 'alert'); const [downgradeTargetModel, setDowngradeTargetModel] = useState(budget?.downgradeTargetModel ?? ''); const [enabled, setEnabled] = useState(budget?.enabled ?? true); const availablePeriods = PERIOD_OPTIONS.filter((p) => p.scopes.includes(scope)); // Auto-fix period when scope changes const handleScopeChange = (newScope: CostBudgetScope) => { setScope(newScope); if (newScope === 'session') { setPeriod('session'); } else if (period === 'session') { setPeriod('daily'); } }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const data: CreateCostBudgetData = { scope, period, limitUsd: Number(limitUsd), onBreach, enabled, }; if (scope === 'agent' && agentId) data.agentId = agentId; if (onBreach === 'downgrade_model' && downgradeTargetModel) data.downgradeTargetModel = downgradeTargetModel; onSubmit(data); }; return (

{budget ? 'Edit Budget' : 'New Budget'}

{scope === 'agent' && ( )} {onBreach === 'downgrade_model' && ( )}
); } // ─── Anomaly Config Panel (Story 9) ──────────────────────── function AnomalyConfigPanel({ config, onSave }: { config: CostAnomalyConfigData | null; onSave: (data: { multiplier?: number; minSessions?: number; enabled?: boolean }) => void; }) { const [multiplier, setMultiplier] = useState(String(config?.multiplier ?? 3.0)); const [minSessions, setMinSessions] = useState(String(config?.minSessions ?? 5)); const [enabled, setEnabled] = useState(config?.enabled ?? true); const [saved, setSaved] = useState(false); // Sync if config loads after mount React.useEffect(() => { if (config) { setMultiplier(String(config.multiplier)); setMinSessions(String(config.minSessions)); setEnabled(config.enabled); } }, [config]); const handleSave = (e: React.FormEvent) => { e.preventDefault(); onSave({ multiplier: Number(multiplier), minSessions: Number(minSessions), enabled }); setSaved(true); setTimeout(() => setSaved(false), 2000); }; return (

🔍 Anomaly Detection Settings

Flag sessions whose cost exceeds a multiplier of the 7-day rolling average for the same agent.

{saved && ✓ Saved}
{config && (

Last updated: {formatTimestamp(config.updatedAt)}

)}
); } // ─── Styles ───────────────────────────────────────────────── const btnStyle: React.CSSProperties = { padding: '8px 16px', background: '#3b82f6', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '14px', }; const smallBtnStyle: React.CSSProperties = { padding: '4px 10px', background: 'transparent', border: '1px solid #d1d5db', borderRadius: '4px', cursor: 'pointer', fontSize: '13px', }; const linkBtnStyle: React.CSSProperties = { padding: '2px 6px', background: 'transparent', border: 'none', color: '#3b82f6', cursor: 'pointer', fontSize: '12px', textDecoration: 'underline', }; const tabStyle: React.CSSProperties = { padding: '8px 16px', background: 'transparent', border: '1px solid #d1d5db', borderRadius: '6px', cursor: 'pointer', fontSize: '14px', }; const tabActiveStyle: React.CSSProperties = { ...tabStyle, background: '#3b82f6', color: 'white', borderColor: '#3b82f6', }; const cardStyle: React.CSSProperties = { padding: '16px', border: '1px solid #e2e8f0', borderRadius: '8px', marginBottom: '12px', }; const thStyle: React.CSSProperties = { padding: '8px 12px', fontSize: '13px', color: '#64748b' }; const tdStyle: React.CSSProperties = { padding: '8px 12px', fontSize: '13px' }; const inputStyle: React.CSSProperties = { width: '100%', padding: '6px 10px', border: '1px solid #d1d5db', borderRadius: '4px', fontSize: '14px', marginTop: '4px', };