diff --git a/README.md b/README.md index bfb7541b..d43dcf17 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,7 @@ Comes with a sleek dark theme, responsive design, and intuitive navigation to ex 🔔 Settings · Alerts — rules-based alerting engine and outbound webhooks in one place: alert rules (event pattern / inactivity / stuck agent / token threshold) with per-rule cooldown, a live fired-alert feed, and 14 first-class webhook providers (Slack, Discord, Teams, Google Chat, Mattermost, Rocket.Chat, Telegram, PagerDuty, Opsgenie, Splunk On-Call, Zapier, Make, n8n, Pipedream) plus a generic JSON endpoint with optional HMAC signing

-The sidebar provides quick access to the Dashboard, Kanban Board, Sessions list, Activity Feed, Analytics, Workflows, and Settings. Each page is designed to give you deep insights into your Claude Code agent activity with real-time updates and rich visualizations. +The sidebar provides quick access to the Dashboard, Kanban Board, Sessions list, Activity Feed, Analytics, Budgets, Workflows, and Settings. Each page is designed to give you deep insights into your Claude Code agent activity with real-time updates and rich visualizations. --- @@ -281,6 +281,7 @@ The dashboard offers a comprehensive set of features to monitor and analyze your | **Session Detail** | Per-session real-time overview panel with active-agent banner (current tool + task), six tile counters (events with events/min rate, tool calls, subagents, compactions, errors, ticking duration), top-tool usage bars, subagent type breakdown, stacked token-flow strip, and event-type pill cloud — all live-refreshed on hook events. Below it: agent hierarchy tree, full event timeline with multi-dimension filters (status, event type, tool, agent, text search, date range), Pre/Post grouping by `tool_use_id`, human-readable summary block, tool-aware input/response renderers (terminal for Bash, unified diff for Edit, line-numbered code for Read/Write, match list for Grep, key/value card for MCP tools), and a Conversation tab that renders transcripts with markdown (headings, lists, blockquotes, tables, task lists), syntax-highlighted code blocks (js/ts, python, json, bash, html, css, sql, yaml, diff) with line numbers and copy-to-clipboard, and per-tool styled tool calls (Bash → terminal, Edit → side-by-side old/new, Write → file label, Read → path chip, Grep → pattern card) | | **Activity Feed** | Real-time streaming event log with pause/resume, multi-dimension filters (same toolbar as Session Detail plus a Session filter), server-driven "Load more" pagination, debounced filter-aware live refresh preserving the loaded page size, grouping toggle, origin prefix showing project › session › subagent, and a "Session →" button per row | | **Analytics** | Token usage, tool frequency, activity heatmap (centered, day-of-week aligned starting Sunday, day-name tooltips), session trends, live/offline connection indicator. While the analytics payload loads, the chart region (not just the stat tiles) shows **pulsing skeleton placeholders** that mirror the chart layout, so the page never flashes empty/zero charts | +| **Budgets** | Set USD spend limits per rolling period (daily / weekly / monthly) and get alerted before you overspend. Each budget shows live current-period spend with a status-colored progress bar (on-track / warning / over), remaining headroom, and per-threshold alert chips that light up once fired. Configurable alert thresholds (default 80% / 100%); a background scheduler fires web-push + native notifications and live-updates the page over WebSocket when a threshold is newly crossed, re-arming each new period. Spend reuses the same pricing rules as the cost views, so totals match Analytics | | **Live Updates** | WebSocket push -- no polling, instant UI updates | | **Auto-Discovery** | Sessions and agents are created automatically from hook events | | **History Import** | Imports sessions from `~/.claude/` on startup. Enhanced JSONL extraction: API errors (quota/rate/invalid_request), turn durations, entrypoint (cli/sdk-ts), permission modes, thinking block counts, usage extras (service_tier, speed, inference_geo), tool result errors, and subagent JSONL files (`subagents/agent-*.jsonl` with `.meta.json`). Backfills existing sessions on re-import. Recent JSONL files (< 10 min) are imported as "active" | @@ -592,6 +593,8 @@ flowchart LR | `NODE_ENV` | `development` | Set to `production` to serve the built client | | `DASHBOARD_UPDATE_CHECK` | _(enabled)_ | Set to `0` / `false` / `off` to disable periodic git upstream checks | | `DASHBOARD_UPDATE_CHECK_INTERVAL_MS` | `300000` (5 min) | Interval between automatic checks; floor 60 000 ms. Users can also click **Check now** in the update modal or in the sidebar to run one on demand. | +| `DASHBOARD_BUDGET_CHECK` | _(enabled)_ | Set to `0` / `false` / `off` to disable the periodic spend-budget evaluator | +| `DASHBOARD_BUDGET_CHECK_INTERVAL_MS` | `60000` (1 min) | Interval between budget re-evaluations; floor 15 000 ms | For git clones, the server periodically `git fetch`es `origin` and compares your checkout to `origin/master`, `origin/main`, or `origin/HEAD`. When you are behind, a message appears in the server terminal and a modal appears in the UI with the exact command to run. The dashboard never pulls or restarts itself — you copy the command, run it in a terminal, then restart the server the same way you started it. @@ -925,6 +928,31 @@ The OpenAPI document is generated from `server/openapi.js`, and Swagger UI is se | `GET` | `/api/pricing/cost` | Total cost across all sessions | | `GET` | `/api/pricing/cost/:id` | Cost breakdown for a specific session | +### Budgets + +Spend budgets let you cap token cost per rolling period and get alerted before +you overspend. Each budget has a period (`daily` / `weekly` / `monthly`), a USD +`limit_usd`, and a list of `alert_thresholds` (percent-of-limit points, default +`[80, 100]`). Current-period spend is computed from `token_usage` joined to each +session's start date (all period math in UTC) and reuses the same pricing rules +as `/api/pricing/cost`, so the numbers line up. `GET` responses include the live +`spent`, `remaining`, `pct`, `status` (`ok` / `warning` / `exceeded`), the +`period_start` / `period_end` window, and which thresholds have already fired +this period. + +| Method | Path | Description | +| -------- | ------------------ | ----------------------------------------------------------------- | +| `GET` | `/api/budgets` | List budgets with live current-period spend and status | +| `POST` | `/api/budgets` | Create a budget. Body: `{ period, limit_usd, label?, enabled?, alert_thresholds? }` | +| `PUT` | `/api/budgets/:id` | Update a budget (any subset of the create fields) | +| `DELETE` | `/api/budgets/:id` | Delete a budget and its alert state | + +A background scheduler (`server/budget-scheduler.js`) re-evaluates enabled +budgets on an interval and, when a threshold is newly crossed, fires a web-push + +native notification and broadcasts `budget_alert` / `budgets_updated` over the +WebSocket. Each threshold notifies at most once per period and re-arms when the +next period begins. + ### Workflows | Method | Path | Description | @@ -1727,6 +1755,7 @@ graph LR D["/sessions/:id"] --> DETAIL["SessionDetail
agents + timeline + cost"] A["/activity"] --> ACT["ActivityFeed
streaming event log"] AN["/analytics"] --> ANALYTICS["Analytics
tokens + heatmap + trends"] + BU["/budgets"] --> BUDGETS["Budgets
spend limits + threshold alerts"] WF["/workflows"] --> WORKFLOWS["Workflows
D3 visualizations + drill-in"] CC["/cc-config"] --> CCCONFIG["CcConfig
12-tab Claude Code config inspector + editor"] RUN["/run"] --> RUNPAGE["Run
spawn / resume / stream Claude subprocess"] diff --git a/client/src/App.tsx b/client/src/App.tsx index c0ce354a..33ad5da7 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -14,6 +14,7 @@ import { Sessions } from "./pages/Sessions"; import { SessionDetail } from "./pages/SessionDetail"; import { ActivityFeed } from "./pages/ActivityFeed"; import { Analytics } from "./pages/Analytics"; +import { Budgets } from "./pages/Budgets"; import { Workflows } from "./pages/Workflows"; import { Settings } from "./pages/Settings"; import { CcConfig } from "./pages/CcConfig"; @@ -44,6 +45,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 61728713..9e6d9b22 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -17,6 +17,7 @@ import { Workflow, Boxes, Play, + Wallet, Settings, Wifi, WifiOff, @@ -48,6 +49,7 @@ const NAV_KEYS = [ { to: "/sessions", icon: FolderOpen, key: "nav:sessions" }, { to: "/activity", icon: Activity, key: "nav:activityFeed" }, { to: "/analytics", icon: BarChart3, key: "nav:analytics" }, + { to: "/budgets", icon: Wallet, key: "nav:budgets" }, { to: "/workflows", icon: Workflow, key: "nav:workflows" }, { to: "/cc-config", icon: Boxes, key: "nav:ccConfig" }, { to: "/run", icon: Play, key: "nav:run" }, diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts index 6205e96e..8b827d21 100644 --- a/client/src/i18n/index.ts +++ b/client/src/i18n/index.ts @@ -26,6 +26,9 @@ import activity_vi from "./locales/vi/activity.json"; import analytics_en from "./locales/en/analytics.json"; import analytics_zh from "./locales/zh/analytics.json"; import analytics_vi from "./locales/vi/analytics.json"; +import budgets_en from "./locales/en/budgets.json"; +import budgets_zh from "./locales/zh/budgets.json"; +import budgets_vi from "./locales/vi/budgets.json"; import workflows_en from "./locales/en/workflows.json"; import workflows_zh from "./locales/zh/workflows.json"; import workflows_vi from "./locales/vi/workflows.json"; @@ -66,6 +69,7 @@ i18n sessions: sessions_en, activity: activity_en, analytics: analytics_en, + budgets: budgets_en, workflows: workflows_en, settings: settings_en, kanban: kanban_en, @@ -83,6 +87,7 @@ i18n sessions: sessions_zh, activity: activity_zh, analytics: analytics_zh, + budgets: budgets_zh, workflows: workflows_zh, settings: settings_zh, kanban: kanban_zh, @@ -100,6 +105,7 @@ i18n sessions: sessions_vi, activity: activity_vi, analytics: analytics_vi, + budgets: budgets_vi, workflows: workflows_vi, settings: settings_vi, kanban: kanban_vi, @@ -121,6 +127,7 @@ i18n "sessions", "activity", "analytics", + "budgets", "workflows", "settings", "kanban", diff --git a/client/src/i18n/locales/en/budgets.json b/client/src/i18n/locales/en/budgets.json new file mode 100644 index 00000000..d6b5bd6b --- /dev/null +++ b/client/src/i18n/locales/en/budgets.json @@ -0,0 +1,50 @@ +{ + "title": "Spend Budgets", + "subtitle": "Set USD limits per period and get alerted before you overspend", + "newBudget": "New budget", + "confirmDelete": "Delete this budget? This cannot be undone.", + "alertsAt": "Alerts at", + "thresholdFired": "Alert already sent this period", + "thresholdArmed": "Will alert when crossed", + "periodKey": "Current period: {{key}}", + "remaining": "{{amount}} left", + "over": "{{amount}} over", + "period": { + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly" + }, + "status": { + "ok": "On track", + "warning": "Warning", + "exceeded": "Over budget" + }, + "summary": { + "budgets": "Budgets", + "activeSpend": "Spent (active)", + "activeLimit": "Limit (active)", + "overBudget": "Over budget" + }, + "actions": { + "enable": "Enable", + "disable": "Disable" + }, + "form": { + "createTitle": "New budget", + "editTitle": "Edit budget", + "period": "Period", + "limit": "Limit (USD)", + "label": "Label (optional)", + "labelPlaceholder": "e.g. Personal cap, Team budget", + "thresholds": "Alert thresholds", + "enabled": "Enabled", + "create": "Create budget" + }, + "errors": { + "invalidLimit": "Enter a limit greater than 0" + }, + "empty": { + "title": "No budgets yet", + "description": "Create a daily, weekly, or monthly spend budget to track token cost and get alerted before you blow past your limit." + } +} diff --git a/client/src/i18n/locales/en/nav.json b/client/src/i18n/locales/en/nav.json index 8f89b09c..5428c5dc 100644 --- a/client/src/i18n/locales/en/nav.json +++ b/client/src/i18n/locales/en/nav.json @@ -6,6 +6,7 @@ "sessions": "Sessions", "activityFeed": "Activity Feed", "analytics": "Analytics", + "budgets": "Budgets", "workflows": "Workflows", "alerts": "Alerts", "ccConfig": "Claude Config", diff --git a/client/src/i18n/locales/vi/budgets.json b/client/src/i18n/locales/vi/budgets.json new file mode 100644 index 00000000..2f202cc1 --- /dev/null +++ b/client/src/i18n/locales/vi/budgets.json @@ -0,0 +1,50 @@ +{ + "title": "Ngân sách chi tiêu", + "subtitle": "Đặt giới hạn USD theo từng kỳ và được cảnh báo trước khi vượt mức", + "newBudget": "Ngân sách mới", + "confirmDelete": "Xóa ngân sách này? Không thể hoàn tác.", + "alertsAt": "Cảnh báo tại", + "thresholdFired": "Đã gửi cảnh báo trong kỳ này", + "thresholdArmed": "Sẽ cảnh báo khi vượt qua", + "periodKey": "Kỳ hiện tại: {{key}}", + "remaining": "Còn {{amount}}", + "over": "Vượt {{amount}}", + "period": { + "daily": "Hàng ngày", + "weekly": "Hàng tuần", + "monthly": "Hàng tháng" + }, + "status": { + "ok": "Trong tầm kiểm soát", + "warning": "Cảnh báo", + "exceeded": "Vượt ngân sách" + }, + "summary": { + "budgets": "Ngân sách", + "activeSpend": "Đã chi (đang bật)", + "activeLimit": "Giới hạn (đang bật)", + "overBudget": "Vượt ngân sách" + }, + "actions": { + "enable": "Bật", + "disable": "Tắt" + }, + "form": { + "createTitle": "Ngân sách mới", + "editTitle": "Sửa ngân sách", + "period": "Kỳ", + "limit": "Giới hạn (USD)", + "label": "Nhãn (tùy chọn)", + "labelPlaceholder": "vd: Giới hạn cá nhân, Ngân sách nhóm", + "thresholds": "Ngưỡng cảnh báo", + "enabled": "Đang bật", + "create": "Tạo ngân sách" + }, + "errors": { + "invalidLimit": "Nhập giới hạn lớn hơn 0" + }, + "empty": { + "title": "Chưa có ngân sách", + "description": "Tạo ngân sách chi tiêu hàng ngày, hàng tuần hoặc hàng tháng để theo dõi chi phí token và được cảnh báo trước khi vượt giới hạn." + } +} diff --git a/client/src/i18n/locales/vi/nav.json b/client/src/i18n/locales/vi/nav.json index 49fe0a1e..dd010423 100644 --- a/client/src/i18n/locales/vi/nav.json +++ b/client/src/i18n/locales/vi/nav.json @@ -6,6 +6,7 @@ "sessions": "Phiên", "activityFeed": "Luồng hoạt động", "analytics": "Phân tích", + "budgets": "Ngân sách", "workflows": "Quy trình", "alerts": "Cảnh báo", "ccConfig": "Cấu hình Claude", diff --git a/client/src/i18n/locales/zh/budgets.json b/client/src/i18n/locales/zh/budgets.json new file mode 100644 index 00000000..03f5c74d --- /dev/null +++ b/client/src/i18n/locales/zh/budgets.json @@ -0,0 +1,50 @@ +{ + "title": "支出预算", + "subtitle": "为每个周期设置美元上限,在超支前收到提醒", + "newBudget": "新建预算", + "confirmDelete": "删除此预算?此操作无法撤销。", + "alertsAt": "提醒阈值", + "thresholdFired": "本周期已发送提醒", + "thresholdArmed": "达到时将提醒", + "periodKey": "当前周期:{{key}}", + "remaining": "剩余 {{amount}}", + "over": "超出 {{amount}}", + "period": { + "daily": "每日", + "weekly": "每周", + "monthly": "每月" + }, + "status": { + "ok": "正常", + "warning": "警告", + "exceeded": "已超预算" + }, + "summary": { + "budgets": "预算数", + "activeSpend": "已花费(启用)", + "activeLimit": "上限(启用)", + "overBudget": "超预算数" + }, + "actions": { + "enable": "启用", + "disable": "停用" + }, + "form": { + "createTitle": "新建预算", + "editTitle": "编辑预算", + "period": "周期", + "limit": "上限(美元)", + "label": "标签(可选)", + "labelPlaceholder": "例如:个人上限、团队预算", + "thresholds": "提醒阈值", + "enabled": "启用", + "create": "创建预算" + }, + "errors": { + "invalidLimit": "请输入大于 0 的上限" + }, + "empty": { + "title": "暂无预算", + "description": "创建每日、每周或每月支出预算,跟踪 token 成本,并在超出上限前收到提醒。" + } +} diff --git a/client/src/i18n/locales/zh/nav.json b/client/src/i18n/locales/zh/nav.json index bbd0e565..f5b4ecb1 100644 --- a/client/src/i18n/locales/zh/nav.json +++ b/client/src/i18n/locales/zh/nav.json @@ -6,6 +6,7 @@ "sessions": "会话", "activityFeed": "活动流", "analytics": "分析", + "budgets": "预算", "workflows": "工作流", "alerts": "警报", "ccConfig": "Claude 配置", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 4368ef36..a5cf759d 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -9,6 +9,9 @@ import type { AlertEvent, AlertRule, Analytics, + Budget, + BudgetListResponse, + BudgetPeriod, CostResult, DashboardEvent, ModelPricing, @@ -375,6 +378,21 @@ export const api = { request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }), }, + budgets: { + list: () => request("/budgets"), + create: (data: BudgetCreateArgs) => + request<{ budget: Budget }>("/budgets", { + method: "POST", + body: JSON.stringify(data), + }), + update: (id: number, data: BudgetUpdateArgs) => + request<{ budget: Budget }>(`/budgets/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }), + remove: (id: number) => request<{ ok: boolean }>(`/budgets/${id}`, { method: "DELETE" }), + }, + alerts: { list: (params?: { unacked?: boolean; limit?: number; offset?: number }) => { const qs = new URLSearchParams(); @@ -468,6 +486,16 @@ export const api = { }, }; +export interface BudgetCreateArgs { + period: BudgetPeriod; + limit_usd: number; + label?: string | null; + enabled?: boolean; + alert_thresholds?: number[]; +} + +export type BudgetUpdateArgs = Partial; + function requestBackupsHelper(params?: { scope?: "user" | "project"; type?: CcArtifactType }) { const qs = new URLSearchParams(); if (params?.scope) qs.set("scope", params.scope); diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index 600cfded..83bf0e61 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -251,6 +251,50 @@ export interface CcConfigChangedPayload { paths?: string[]; } +export type BudgetPeriod = "daily" | "weekly" | "monthly"; +export type BudgetStatus = "ok" | "warning" | "exceeded"; + +export interface Budget { + id: number; + period: BudgetPeriod; + limit_usd: number; + enabled: boolean; + label: string | null; + alert_thresholds: number[]; + created_at?: string; + updated_at?: string; + period_start: string; + period_end: string; + period_key: string; + spent: number; + remaining: number; + pct: number; + status: BudgetStatus; + fired_thresholds: number[]; +} + +export interface BudgetListResponse { + budgets: Budget[]; + generated_at: string; +} + +export interface BudgetAlertPayload { + budget_id: number; + period: BudgetPeriod; + period_key: string; + threshold: number; + spent: number; + limit_usd: number; + pct: number; + status: BudgetStatus; + title: string; + body: string; +} + +export interface BudgetsUpdatedPayload { + budgets: Budget[]; +} + // ── Alerting ── export type AlertRuleType = "event_pattern" | "inactivity" | "status_duration" | "token_threshold"; @@ -393,6 +437,8 @@ export interface WSMessage { | "run_status" | "run_input_ack" | "cc_config_changed" + | "budget_alert" + | "budgets_updated" | "alert_triggered" | "alert_updated" | "workflow_upserted"; @@ -406,6 +452,8 @@ export interface WSMessage { | RunStatusPayload | RunInputAckPayload | CcConfigChangedPayload + | BudgetAlertPayload + | BudgetsUpdatedPayload | AlertEvent | WorkflowRun; timestamp: string; diff --git a/client/src/pages/Budgets.tsx b/client/src/pages/Budgets.tsx new file mode 100644 index 00000000..b6032035 --- /dev/null +++ b/client/src/pages/Budgets.tsx @@ -0,0 +1,533 @@ +/** + * @file Budgets.tsx + * @description Spend budgets page. Lets the user define USD spending limits per + * rolling period (daily / weekly / monthly), see live current-period spend with + * a progress bar, and manage alert thresholds. Updates in real time when the + * server broadcasts a budget alert and refreshes spend on a light poll. + * @author Son Nguyen + */ + +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { useTranslation } from "react-i18next"; +import { + Wallet, + Plus, + RefreshCw, + Pencil, + Trash2, + AlertTriangle, + CheckCircle2, + Power, +} from "lucide-react"; +import { api } from "../lib/api"; +import type { BudgetCreateArgs } from "../lib/api"; +import { eventBus } from "../lib/eventBus"; +import { fmtCostFull } from "../lib/format"; +import { EmptyState } from "../components/EmptyState"; +import { StatValueSkeleton } from "../components/Skeleton"; +import type { + Budget, + BudgetPeriod, + BudgetStatus, + BudgetsUpdatedPayload, + WSMessage, +} from "../lib/types"; + +const PERIODS: BudgetPeriod[] = ["daily", "weekly", "monthly"]; +const THRESHOLD_PRESETS = [50, 75, 80, 90, 100]; + +interface FormState { + period: BudgetPeriod; + limit_usd: string; + label: string; + thresholds: number[]; + enabled: boolean; +} + +const EMPTY_FORM: FormState = { + period: "monthly", + limit_usd: "", + label: "", + thresholds: [80, 100], + enabled: true, +}; + +function statusClasses(status: BudgetStatus): { bar: string; badge: string; text: string } { + switch (status) { + case "exceeded": + return { + bar: "bg-rose-500", + badge: "text-rose-300 bg-rose-500/10 border-rose-500/30", + text: "text-rose-400", + }; + case "warning": + return { + bar: "bg-amber-500", + badge: "text-amber-300 bg-amber-500/10 border-amber-500/30", + text: "text-amber-400", + }; + default: + return { + bar: "bg-emerald-500", + badge: "text-emerald-300 bg-emerald-500/10 border-emerald-500/30", + text: "text-emerald-400", + }; + } +} + +export function Budgets() { + const { t } = useTranslation("budgets"); + const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); + + const [budgets, setBudgets] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [busyId, setBusyId] = useState(null); + + const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [formError, setFormError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await api.budgets.list(); + setBudgets(res.budgets); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + // Live: server pushes a fresh list whenever an alert fires. Also poll lightly + // so spend keeps ticking even between threshold crossings. + useEffect(() => { + const unsub = eventBus.subscribe((msg: WSMessage) => { + if (msg.type === "budgets_updated") { + setBudgets((msg.data as BudgetsUpdatedPayload).budgets); + } else if (msg.type === "budget_alert") { + load(); + } + }); + const poll = window.setInterval(load, 20000); + return () => { + unsub(); + window.clearInterval(poll); + }; + }, [load]); + + const openCreate = () => { + setEditingId(null); + setForm(EMPTY_FORM); + setFormError(null); + setShowForm(true); + }; + + const openEdit = (b: Budget) => { + setEditingId(b.id); + setForm({ + period: b.period, + limit_usd: String(b.limit_usd), + label: b.label ?? "", + thresholds: b.alert_thresholds, + enabled: b.enabled, + }); + setFormError(null); + setShowForm(true); + }; + + const closeForm = () => { + setShowForm(false); + setEditingId(null); + }; + + const toggleThreshold = (value: number) => { + setForm((f) => { + const has = f.thresholds.includes(value); + const next = has ? f.thresholds.filter((v) => v !== value) : [...f.thresholds, value]; + return { ...f, thresholds: next.sort((a, b) => a - b) }; + }); + }; + + const submitForm = async () => { + const limit = Number(form.limit_usd); + if (!Number.isFinite(limit) || limit <= 0) { + setFormError(t("errors.invalidLimit")); + return; + } + const payload: BudgetCreateArgs = { + period: form.period, + limit_usd: limit, + label: form.label.trim() || null, + enabled: form.enabled, + alert_thresholds: form.thresholds.length > 0 ? form.thresholds : [100], + }; + setBusyId(editingId ?? "new"); + setFormError(null); + try { + if (editingId != null) { + await api.budgets.update(editingId, payload); + } else { + await api.budgets.create(payload); + } + closeForm(); + await load(); + } catch (e) { + setFormError(e instanceof Error ? e.message : String(e)); + } finally { + setBusyId(null); + } + }; + + const toggleEnabled = async (b: Budget) => { + setBusyId(b.id); + try { + await api.budgets.update(b.id, { enabled: !b.enabled }); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusyId(null); + } + }; + + const remove = async (b: Budget) => { + if (!window.confirm(t("confirmDelete"))) return; + setBusyId(b.id); + try { + await api.budgets.remove(b.id); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusyId(null); + } + }; + + const totals = useMemo(() => { + if (!budgets) return null; + const active = budgets.filter((b) => b.enabled); + return { + count: budgets.length, + spent: active.reduce((s, b) => s + b.spent, 0), + limit: active.reduce((s, b) => s + b.limit_usd, 0), + over: budgets.filter((b) => b.enabled && b.status === "exceeded").length, + }; + }, [budgets]); + + const periodLabel = (p: BudgetPeriod) => t(`period.${p}`); + + return ( +
+ {/* Header */} +
+
+
+ +
+
+
+

{t("title")}

+ {wsConnected ? ( + + + {t("common:live")} + + ) : null} +
+

{t("subtitle")}

+
+
+
+ + +
+
+ + {error ? ( +
+ + {error} +
+ ) : null} + + {/* Summary */} + {totals && totals.count > 0 ? ( +
+
+
{t("summary.budgets")}
+
{totals.count}
+
+
+
{t("summary.activeSpend")}
+
+ {fmtCostFull(totals.spent)} +
+
+
+
{t("summary.activeLimit")}
+
+ {fmtCostFull(totals.limit)} +
+
+
+
{t("summary.overBudget")}
+
0 ? "text-rose-400" : "text-emerald-400" + }`} + > + {totals.over} +
+
+
+ ) : null} + + {/* Create / edit form */} + {showForm ? ( +
+
+

+ {editingId != null ? t("form.editTitle") : t("form.createTitle")} +

+
+ +
+ + + + + +
+ +
+ {t("form.thresholds")} +
+ {THRESHOLD_PRESETS.map((v) => { + const active = form.thresholds.includes(v); + return ( + + ); + })} +
+
+ + + + {formError ?
{formError}
: null} + +
+ + +
+
+ ) : null} + + {/* Budget list */} + {budgets === null ? ( +
+ {[0, 1].map((i) => ( +
+ +
+ ))} +
+ ) : budgets.length === 0 ? ( + + + {t("newBudget")} + + } + /> + ) : ( +
+ {budgets.map((b) => { + const sc = statusClasses(b.status); + const width = Math.min(b.pct, 100); + const dimmed = !b.enabled; + return ( +
+
+
+
+

+ {b.label || periodLabel(b.period)} +

+ + {b.status === "exceeded" ? ( + + ) : b.status === "warning" ? ( + + ) : ( + + )} + {t(`status.${b.status}`)} + +
+

+ {b.label ? `${periodLabel(b.period)} · ` : ""} + {t("periodKey", { key: b.period_key })} +

+
+
+ + + +
+
+ + {/* Spend vs limit */} +
+
+ + {fmtCostFull(b.spent)} + + + / {fmtCostFull(b.limit_usd)} + +
+
+
+
+
+ {Math.round(b.pct)}% + + {b.remaining >= 0 + ? t("remaining", { amount: fmtCostFull(b.remaining) }) + : t("over", { amount: fmtCostFull(Math.abs(b.remaining)) })} + +
+
+ + {/* Thresholds */} +
+ {t("alertsAt")} + {b.alert_thresholds.map((th) => { + const fired = b.fired_thresholds.includes(th); + return ( + + {th}% + + ); + })} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/client/src/pages/__tests__/Budgets.test.tsx b/client/src/pages/__tests__/Budgets.test.tsx new file mode 100644 index 00000000..c82b6cda --- /dev/null +++ b/client/src/pages/__tests__/Budgets.test.tsx @@ -0,0 +1,102 @@ +/** + * @file Budgets.test.tsx + * @description Tests for the Budgets page: empty state, populated budget cards + * with live spend/progress, and opening the create form. + * @author Son Nguyen + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { Budgets } from "../Budgets"; +import type { Budget } from "../../lib/types"; + +let mockBudgets: Budget[] = []; +const listMock = vi.fn(() => + Promise.resolve({ budgets: mockBudgets, generated_at: "2026-06-05T00:00:00.000Z" }) +); + +vi.mock("../../lib/api", () => ({ + api: { + budgets: { + list: () => listMock(), + create: vi.fn(() => Promise.resolve({ budget: {} })), + update: vi.fn(() => Promise.resolve({ budget: {} })), + remove: vi.fn(() => Promise.resolve({ ok: true })), + }, + }, +})); + +vi.mock("../../lib/eventBus", () => ({ + eventBus: { + subscribe: vi.fn(() => () => {}), + onConnection: vi.fn(() => () => {}), + connected: false, + }, +})); + +function makeBudget(overrides: Partial = {}): Budget { + return { + id: 1, + period: "monthly", + limit_usd: 100, + enabled: true, + label: "Personal cap", + alert_thresholds: [80, 100], + period_start: "2026-06-01T00:00:00.000Z", + period_end: "2026-07-01T00:00:00.000Z", + period_key: "2026-06", + spent: 85, + remaining: 15, + pct: 85, + status: "warning", + fired_thresholds: [80], + ...overrides, + }; +} + +function renderPage() { + return render( + + + + ); +} + +beforeEach(() => { + mockBudgets = []; + listMock.mockClear(); +}); + +describe("Budgets page", () => { + it("renders the empty state when there are no budgets", async () => { + renderPage(); + expect(await screen.findByText("No budgets yet")).toBeInTheDocument(); + }); + + it("renders a budget card with spend, percentage and label", async () => { + mockBudgets = [makeBudget()]; + renderPage(); + + expect(await screen.findByText("Personal cap")).toBeInTheDocument(); + expect(screen.getByText("85%")).toBeInTheDocument(); + // Warning status badge (85% is past the 80% warn threshold, below 100%). + expect(screen.getByText("Warning")).toBeInTheDocument(); + // Fired threshold chip + armed threshold chip both render. + expect(screen.getByText("80%")).toBeInTheDocument(); + expect(screen.getByText("100%")).toBeInTheDocument(); + }); + + it("opens the create form when clicking New budget", async () => { + renderPage(); + await screen.findByText("No budgets yet"); + + const newButtons = screen.getAllByText("New budget"); + fireEvent.click(newButtons[0] as HTMLElement); + + await waitFor(() => { + expect(screen.getByText("Limit (USD)")).toBeInTheDocument(); + }); + expect(screen.getByText("Alert thresholds")).toBeInTheDocument(); + }); +}); diff --git a/mcp/README.md b/mcp/README.md index 5f3ec0d3..589cc7cb 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -132,6 +132,7 @@ graph TD AGT["Agents
list/get/create/update"] EVT["Events & Hooks
list events, ingest hook events"] PRC["Pricing & Cost
rules CRUD, cost queries, reset defaults"] + BUD["Budgets
list, create/update/delete
spend limits + thresholds"] MNT["Maintenance
cleanup, reimport, reinstall hooks,
clear-all-data (guarded)"] ROOT --> OBS @@ -139,6 +140,7 @@ graph TD ROOT --> AGT ROOT --> EVT ROOT --> PRC + ROOT --> BUD ROOT --> MNT ``` @@ -158,6 +160,7 @@ Read-focused tools: - `dashboard_get_pricing_rules` - `dashboard_get_total_cost` - `dashboard_get_session_cost` +- `dashboard_get_budgets` Mutation tools (require `MCP_DASHBOARD_ALLOW_MUTATIONS=true`): @@ -169,6 +172,9 @@ Mutation tools (require `MCP_DASHBOARD_ALLOW_MUTATIONS=true`): - `dashboard_upsert_pricing_rule` - `dashboard_delete_pricing_rule` - `dashboard_reset_pricing_defaults` +- `dashboard_create_budget` +- `dashboard_update_budget` +- `dashboard_delete_budget` - `dashboard_cleanup_data` - `dashboard_reimport_history` - `dashboard_reinstall_hooks` diff --git a/mcp/src/tools/domains/budget-tools.ts b/mcp/src/tools/domains/budget-tools.ts new file mode 100644 index 00000000..36564f54 --- /dev/null +++ b/mcp/src/tools/domains/budget-tools.ts @@ -0,0 +1,89 @@ +/** + * @file budget-tools.ts + * @description Tool registration for spend-budget functionality in the dashboard. + * Exposes read access to budgets (with live current-period spend and status) and, + * when mutations are enabled, create / update / delete operations. Mirrors the + * REST surface at /api/budgets. Input is validated with Zod before reaching the + * backend. + * @author Son Nguyen + */ + +import { z } from "zod"; +import { createToolRegistrar } from "../../core/tool-registry.js"; +import { assertMutationsEnabled } from "../../policy/tool-guards.js"; +import type { ToolContext } from "../../types/tool-context.js"; + +const periodSchema = z.enum(["daily", "weekly", "monthly"]); +const thresholdsSchema = z.array(z.number().int().min(1).max(100)).max(20); + +export function registerBudgetTools(context: ToolContext): void { + const { api, logger, server, config } = context; + const register = createToolRegistrar(server, logger); + + register( + "dashboard_get_budgets", + "List spend budgets with live current-period spend, percentage, status (ok/warning/exceeded), and remaining headroom.", + {}, + async () => api.get("/api/budgets") + ); + + register( + "dashboard_create_budget", + "Create a spend budget. period is daily/weekly/monthly, limit_usd is the USD ceiling, alert_thresholds are percent-of-limit alert points (default 80 and 100).", + { + period: periodSchema, + limit_usd: z.number().positive().max(1_000_000), + label: z.string().max(120).optional(), + enabled: z.boolean().optional(), + alert_thresholds: thresholdsSchema.optional(), + }, + async (args) => { + assertMutationsEnabled(config); + return api.post("/api/budgets", { + body: { + period: args.period, + limit_usd: args.limit_usd, + ...(args.label !== undefined ? { label: args.label } : {}), + ...(args.enabled !== undefined ? { enabled: args.enabled } : {}), + ...(args.alert_thresholds !== undefined + ? { alert_thresholds: args.alert_thresholds } + : {}), + }, + }); + } + ); + + register( + "dashboard_update_budget", + "Update an existing spend budget by id. Provide any subset of period, limit_usd, label, enabled, alert_thresholds.", + { + id: z.number().int().positive(), + period: periodSchema.optional(), + limit_usd: z.number().positive().max(1_000_000).optional(), + label: z.string().max(120).nullable().optional(), + enabled: z.boolean().optional(), + alert_thresholds: thresholdsSchema.optional(), + }, + async (args) => { + assertMutationsEnabled(config); + const { id, ...rest } = args; + const body: Record = {}; + for (const key of ["period", "limit_usd", "label", "enabled", "alert_thresholds"] as const) { + if (rest[key] !== undefined) body[key] = rest[key]; + } + return api.put(`/api/budgets/${encodeURIComponent(String(id))}`, { body }); + } + ); + + register( + "dashboard_delete_budget", + "Delete a spend budget by id.", + { + id: z.number().int().positive(), + }, + async (args) => { + assertMutationsEnabled(config); + return api.delete(`/api/budgets/${encodeURIComponent(String(args.id))}`); + } + ); +} diff --git a/mcp/src/tools/index.ts b/mcp/src/tools/index.ts index d191a601..214d8694 100644 --- a/mcp/src/tools/index.ts +++ b/mcp/src/tools/index.ts @@ -1,6 +1,6 @@ /** * @file index.ts - * @description Main entry point for registering all tools in the MCP application. This module imports and registers tools from various domains, including observability, session management, agent management, event handling, pricing, and maintenance. The registerAllTools function takes a ToolContext as an argument and calls the respective registration functions for each domain to ensure that all tools are properly set up and available for use within the application. + * @description Main entry point for registering all tools in the MCP application. This module imports and registers tools from various domains, including observability, session management, agent management, event handling, pricing, budgets, and maintenance. The registerAllTools function takes a ToolContext as an argument and calls the respective registration functions for each domain to ensure that all tools are properly set up and available for use within the application. * @author Son Nguyen */ @@ -10,6 +10,7 @@ import { registerSessionTools } from "./domains/session-tools.js"; import { registerAgentTools } from "./domains/agent-tools.js"; import { registerEventTools } from "./domains/event-tools.js"; import { registerPricingTools } from "./domains/pricing-tools.js"; +import { registerBudgetTools } from "./domains/budget-tools.js"; import { registerMaintenanceTools } from "./domains/maintenance-tools.js"; export function registerAllTools(context: ToolContext): void { @@ -18,5 +19,6 @@ export function registerAllTools(context: ToolContext): void { registerAgentTools(context); registerEventTools(context); registerPricingTools(context); + registerBudgetTools(context); registerMaintenanceTools(context); } diff --git a/server/__tests__/api.test.js b/server/__tests__/api.test.js index 9c2d5545..070f22f6 100644 --- a/server/__tests__/api.test.js +++ b/server/__tests__/api.test.js @@ -50,6 +50,8 @@ const EXPECTED_API_PATHS = [ "/api/import/upload", "/api/updates/status", "/api/updates/check", + "/api/budgets", + "/api/budgets/{id}", "/api/alerts", "/api/alerts/rules", "/api/alerts/rules/{id}", diff --git a/server/__tests__/budgets.test.js b/server/__tests__/budgets.test.js new file mode 100644 index 00000000..6f55cdfe --- /dev/null +++ b/server/__tests__/budgets.test.js @@ -0,0 +1,278 @@ +/** + * @file Tests for spend budgets: period-window math, current-period spend + * evaluation, threshold alert firing (fire-once-per-period), and the + * /api/budgets CRUD routes. + * @author Son Nguyen + */ + +const { describe, it, before, after, beforeEach } = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("path"); +const fs = require("fs"); +const os = require("os"); +const http = require("http"); + +const TEST_DB = path.join(os.tmpdir(), `dashboard-budgets-${Date.now()}-${process.pid}.db`); +process.env.DASHBOARD_DB_PATH = TEST_DB; +// Keep the scheduler from auto-running during route tests. +process.env.DASHBOARD_BUDGET_CHECK = "off"; + +const { createApp, startServer } = require("../index"); +const { db } = require("../db"); +const budgets = require("../lib/budgets"); + +let server; +let BASE; + +function fetch(urlPath, options = {}) { + return new Promise((resolve, reject) => { + const url = new URL(urlPath, BASE); + const req = http.request( + { + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + method: options.method || "GET", + headers: { "Content-Type": "application/json", ...options.headers }, + }, + (res) => { + let body = ""; + res.on("data", (c) => (body += c)); + res.on("end", () => { + let parsed; + try { + parsed = JSON.parse(body); + } catch { + parsed = body; + } + resolve({ status: res.statusCode, body: parsed }); + }); + } + ); + req.on("error", reject); + if (options.body) req.write(JSON.stringify(options.body)); + req.end(); + }); +} +const post = (p, b) => fetch(p, { method: "POST", body: b }); +const put = (p, b) => fetch(p, { method: "PUT", body: b }); +const del = (p) => fetch(p, { method: "DELETE" }); + +/** Insert a session + token usage so spend lands in the current period. */ +function seedSpend(sessionId, model, input, output, startedAt) { + db.prepare("INSERT INTO sessions (id, status, started_at) VALUES (?, 'completed', ?)").run( + sessionId, + startedAt + ); + db.prepare( + "INSERT INTO token_usage (session_id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens) VALUES (?, ?, ?, ?, 0, 0)" + ).run(sessionId, model, input, output); +} + +function resetData() { + db.pragma("foreign_keys = OFF"); + db.prepare("DELETE FROM budget_alert_state").run(); + db.prepare("DELETE FROM budgets").run(); + db.prepare("DELETE FROM token_usage").run(); + db.prepare("DELETE FROM sessions").run(); + db.pragma("foreign_keys = ON"); +} + +before(async () => { + server = await startServer(createApp(), 0); + BASE = `http://127.0.0.1:${server.address().port}`; +}); + +after(() => { + if (server) server.close(); + if (db) db.close(); + for (const suffix of ["", "-wal", "-shm"]) { + try { + fs.unlinkSync(TEST_DB + suffix); + } catch { + // ignore + } + } +}); + +beforeEach(() => resetData()); + +// ── Period windows ───────────────────────────────────────────────────────── +describe("budgets.periodWindow", () => { + it("computes a UTC daily window and key", () => { + const now = new Date("2026-06-05T13:30:00Z"); + const w = budgets.periodWindow("daily", now); + assert.equal(w.start, "2026-06-05T00:00:00.000Z"); + assert.equal(w.end, "2026-06-06T00:00:00.000Z"); + assert.equal(w.key, "2026-06-05"); + }); + + it("computes a Monday-start weekly window with ISO week key", () => { + // 2026-06-05 is a Friday → week starts Monday 2026-06-01. + const w = budgets.periodWindow("weekly", new Date("2026-06-05T13:30:00Z")); + assert.equal(w.start, "2026-06-01T00:00:00.000Z"); + assert.equal(w.end, "2026-06-08T00:00:00.000Z"); + assert.equal(w.key, "2026-W23"); + }); + + it("rolls weekly window back across a month boundary on Sunday", () => { + // 2026-03-01 is a Sunday → its ISO week started Monday 2026-02-23. + const w = budgets.periodWindow("weekly", new Date("2026-03-01T10:00:00Z")); + assert.equal(w.start, "2026-02-23T00:00:00.000Z"); + assert.equal(w.key, "2026-W09"); + }); + + it("computes a monthly window and key", () => { + const w = budgets.periodWindow("monthly", new Date("2026-06-05T13:30:00Z")); + assert.equal(w.start, "2026-06-01T00:00:00.000Z"); + assert.equal(w.end, "2026-07-01T00:00:00.000Z"); + assert.equal(w.key, "2026-06"); + }); +}); + +describe("budgets.parseThresholds", () => { + it("cleans, dedupes, sorts, and clamps", () => { + assert.deepEqual(budgets.parseThresholds("[100, 50, 50, 200, 0, 75]"), [50, 75, 100]); + }); + it("falls back to defaults on garbage", () => { + assert.deepEqual(budgets.parseThresholds("not json"), [80, 100]); + assert.deepEqual(budgets.parseThresholds([]), [80, 100]); + }); +}); + +// ── Spend + evaluation ────────────────────────────────────────────────────── +describe("budgets.evaluateBudget", () => { + it("sums spend within the period and computes pct/status", () => { + const now = new Date(); + // 1M input + 1M output for Opus 4.8 = $5 + $25 = $30. + seedSpend("s-now", "claude-opus-4-8", 1_000_000, 1_000_000, now.toISOString()); + // Spend from an old session must NOT count toward this month. + seedSpend("s-old", "claude-opus-4-8", 5_000_000, 5_000_000, "2000-01-01T00:00:00.000Z"); + + const id = db + .prepare( + "INSERT INTO budgets (period, limit_usd, alert_thresholds) VALUES ('monthly', 40, '[80,100]')" + ) + .run().lastInsertRowid; + const row = db.prepare("SELECT * FROM budgets WHERE id = ?").get(id); + const ev = budgets.evaluateBudget(db, row, now); + + assert.equal(ev.spent, 30); + assert.equal(ev.limit_usd, 40); + assert.equal(ev.pct, 75); + assert.equal(ev.status, "ok"); // below the 80% warn threshold + assert.equal(ev.remaining, 10); + }); + + it("marks status warning then exceeded as spend grows", () => { + const now = new Date(); + seedSpend("s1", "claude-opus-4-8", 1_000_000, 1_000_000, now.toISOString()); // $30 + const warnId = db + .prepare("INSERT INTO budgets (period, limit_usd) VALUES ('monthly', 35)") + .run().lastInsertRowid; + const overId = db + .prepare("INSERT INTO budgets (period, limit_usd) VALUES ('monthly', 20)") + .run().lastInsertRowid; + + const warn = budgets.evaluateBudget( + db, + db.prepare("SELECT * FROM budgets WHERE id = ?").get(warnId), + now + ); + const over = budgets.evaluateBudget( + db, + db.prepare("SELECT * FROM budgets WHERE id = ?").get(overId), + now + ); + assert.equal(warn.status, "warning"); + assert.equal(over.status, "exceeded"); + }); +}); + +// ── Alert firing ──────────────────────────────────────────────────────────── +describe("budgets.checkAndAlert", () => { + it("fires once per crossed period and re-fires only on a new period", () => { + const now = new Date(); + seedSpend("s1", "claude-opus-4-8", 1_000_000, 1_000_000, now.toISOString()); // $30 + db.prepare( + "INSERT INTO budgets (period, limit_usd, alert_thresholds) VALUES ('monthly', 20, '[80,100]')" + ).run(); + + const alerts = []; + const broadcasts = []; + const hooks = { + notify: (title, body, a) => alerts.push(a), + broadcast: (type, data) => broadcasts.push({ type, data }), + }; + + const first = budgets.checkAndAlert(db, now, hooks); + assert.equal(first.length, 1); + assert.equal(first[0].threshold, 100, "should report the highest crossed threshold"); + assert.equal(alerts.length, 1); + assert.ok(broadcasts.some((b) => b.type === "budget_alert")); + assert.ok(broadcasts.some((b) => b.type === "budgets_updated")); + + // Both 80 and 100 are recorded so neither re-fires this period. + const second = budgets.checkAndAlert(db, now, hooks); + assert.equal(second.length, 0, "no re-fire within the same period"); + + // A different period key (next month) re-arms the thresholds. + const nextMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 15)); + seedSpend("s2", "claude-opus-4-8", 1_000_000, 1_000_000, nextMonth.toISOString()); + const third = budgets.checkAndAlert(db, nextMonth, hooks); + assert.equal(third.length, 1, "re-fires in the new period"); + }); + + it("ignores disabled budgets", () => { + const now = new Date(); + seedSpend("s1", "claude-opus-4-8", 1_000_000, 1_000_000, now.toISOString()); + db.prepare("INSERT INTO budgets (period, limit_usd, enabled) VALUES ('monthly', 1, 0)").run(); + assert.equal(budgets.checkAndAlert(db, now, {}).length, 0); + }); +}); + +// ── Routes ────────────────────────────────────────────────────────────────── +describe("Budgets API", () => { + it("creates, lists, updates, and deletes a budget", async () => { + const created = await post("/api/budgets", { period: "monthly", limit_usd: 50 }); + assert.equal(created.status, 201); + assert.equal(created.body.budget.period, "monthly"); + assert.equal(created.body.budget.limit_usd, 50); + assert.deepEqual(created.body.budget.alert_thresholds, [80, 100]); + const id = created.body.budget.id; + + const listed = await fetch("/api/budgets"); + assert.equal(listed.status, 200); + assert.equal(listed.body.budgets.length, 1); + assert.ok(listed.body.generated_at); + + const updated = await put(`/api/budgets/${id}`, { + limit_usd: 75, + alert_thresholds: [50, 90], + enabled: false, + }); + assert.equal(updated.status, 200); + assert.equal(updated.body.budget.limit_usd, 75); + assert.equal(updated.body.budget.enabled, false); + assert.deepEqual(updated.body.budget.alert_thresholds, [50, 90]); + + const removed = await del(`/api/budgets/${id}`); + assert.equal(removed.status, 200); + assert.equal(removed.body.ok, true); + + const after = await fetch("/api/budgets"); + assert.equal(after.body.budgets.length, 0); + }); + + it("rejects invalid period and non-positive limit", async () => { + const badPeriod = await post("/api/budgets", { period: "yearly", limit_usd: 10 }); + assert.equal(badPeriod.status, 400); + const badLimit = await post("/api/budgets", { period: "daily", limit_usd: 0 }); + assert.equal(badLimit.status, 400); + }); + + it("404s when updating or deleting a missing budget", async () => { + assert.equal((await put("/api/budgets/999999", { limit_usd: 5 })).status, 404); + assert.equal((await del("/api/budgets/999999")).status, 404); + }); +}); diff --git a/server/budget-scheduler.js b/server/budget-scheduler.js new file mode 100644 index 00000000..d1849f68 --- /dev/null +++ b/server/budget-scheduler.js @@ -0,0 +1,82 @@ +/** + * @file Periodic spend-budget evaluation. On each tick it recomputes + * current-period spend for every enabled budget and fires web-push / native / + * websocket alerts when a configured threshold is newly crossed. Mirrors the + * fail-safe, env-gated shape of update-scheduler.js. + * @author Son Nguyen + */ + +const { db } = require("./db"); +const { checkAndAlert } = require("./lib/budgets"); +const { sendPushToAll, showNativeNotificationIfElectron } = require("./lib/push"); + +function isDisabled() { + const v = process.env.DASHBOARD_BUDGET_CHECK; + return v === "0" || v === "false" || v === "off"; +} + +function intervalMs() { + const n = Number.parseInt(process.env.DASHBOARD_BUDGET_CHECK_INTERVAL_MS || "", 10); + // Floor at 15s so a misconfigured value can't hammer the DB. + if (Number.isFinite(n) && n >= 15_000) return n; + return 60 * 1000; +} + +/** + * Start the budget scheduler. + * + * @param {{ broadcast: Function }} deps + * @returns {{ stop: () => void }} + */ +function startBudgetScheduler({ broadcast }) { + if (isDisabled()) return { stop: () => {} }; + + let stopped = false; + let initialTimer = null; + let intervalTimer = null; + + async function notify(title, body) { + // Native first (covers the desktop/Electron host), then web push for + // browser subscribers. Both are best-effort. + showNativeNotificationIfElectron(title, body); + try { + await sendPushToAll(db, title, body); + } catch { + // Push transport errors are non-fatal. + } + } + + function tick() { + if (stopped) return; + try { + const fired = checkAndAlert(db, new Date(), { broadcast, notify }); + if (fired.length > 0) { + for (const a of fired) { + console.log( + `[budgets] alert: budget #${a.budget_id} (${a.period}) crossed ${a.threshold}% — ` + + `$${a.spent.toFixed(2)} / $${a.limit_usd.toFixed(2)}` + ); + } + } + } catch (err) { + console.warn("[budgets] check failed:", err.message); + } + } + + // Small delay so startup isn't blocked by a cost computation. + initialTimer = setTimeout(tick, 5000); + initialTimer.unref?.(); + + intervalTimer = setInterval(tick, intervalMs()); + intervalTimer.unref?.(); + + return { + stop() { + stopped = true; + if (initialTimer) clearTimeout(initialTimer); + if (intervalTimer) clearInterval(intervalTimer); + }, + }; +} + +module.exports = { startBudgetScheduler }; diff --git a/server/db.js b/server/db.js index fb1b3263..dd478890 100644 --- a/server/db.js +++ b/server/db.js @@ -258,6 +258,34 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_dashboard_runs_started ON dashboard_runs(started_at DESC); CREATE INDEX IF NOT EXISTS idx_dashboard_runs_session ON dashboard_runs(session_id); + -- Spend budgets: user-defined USD ceilings per rolling period. The dashboard + -- evaluates current-period spend (token_usage joined to the owning session's + -- start date) and fires alerts when configured thresholds are crossed. + CREATE TABLE IF NOT EXISTS budgets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + period TEXT NOT NULL CHECK(period IN ('daily','weekly','monthly')), + limit_usd REAL NOT NULL CHECK(limit_usd > 0), + enabled INTEGER NOT NULL DEFAULT 1, + label TEXT, + alert_thresholds TEXT NOT NULL DEFAULT '[80,100]', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ); + + -- One row per (budget, period instance, threshold) that has already fired, + -- so a threshold notifies at most once per period. A new period_key + -- (e.g. next day/week/month) naturally re-arms every threshold. + CREATE TABLE IF NOT EXISTS budget_alert_state ( + budget_id INTEGER NOT NULL, + period_key TEXT NOT NULL, + threshold INTEGER NOT NULL, + fired_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (budget_id, period_key, threshold), + FOREIGN KEY (budget_id) REFERENCES budgets(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_budget_alert_state_budget ON budget_alert_state(budget_id, period_key); + -- Rules-based alerting engine. Rules are evaluated server-side: event-driven -- types (event_pattern, token_threshold) on hook ingest, time-based types -- (inactivity, status_duration) on a periodic sweep in server/lib/alerts.js. diff --git a/server/index.js b/server/index.js index 5b819a7b..864b4f5b 100644 --- a/server/index.js +++ b/server/index.js @@ -51,6 +51,7 @@ const importRouter = require("./routes/import"); const updatesRouter = require("./routes/updates"); const ccConfigRouter = require("./routes/cc-config"); const runRouter = require("./routes/run"); +const budgetsRouter = require("./routes/budgets"); const alertsRouter = require("./routes/alerts"); const webhooksRouter = require("./routes/webhooks"); @@ -75,6 +76,7 @@ function createApp() { app.use("/api/updates", updatesRouter); app.use("/api/cc-config", ccConfigRouter); app.use("/api/run", runRouter); + app.use("/api/budgets", budgetsRouter); app.use("/api/alerts", alertsRouter); app.use("/api/webhooks", webhooksRouter); app.get("/api/openapi.json", (_req, res) => { @@ -228,6 +230,12 @@ function startBackgroundServices() { const { startUpdateScheduler } = require("./update-scheduler"); const { broadcast } = require("./websocket"); startUpdateScheduler({ broadcast }); + try { + const { startBudgetScheduler } = require("./budget-scheduler"); + startBudgetScheduler({ broadcast }); + } catch (err) { + console.warn("budget scheduler failed to start:", err.message); + } try { const { startCcWatcher } = require("./lib/cc-watcher"); startCcWatcher({ broadcast }); diff --git a/server/lib/budgets.js b/server/lib/budgets.js new file mode 100644 index 00000000..cc4de97b --- /dev/null +++ b/server/lib/budgets.js @@ -0,0 +1,317 @@ +/** + * @file Spend-budget evaluation and alerting logic. Computes how much has been + * spent (in USD) within a rolling period (daily / weekly / monthly), compares it + * against user-defined limits, and fires threshold alerts at most once per + * period. All period math is done in UTC so the result is deterministic and + * matches how token timestamps are stored. + * @author Son Nguyen + */ + +const VALID_PERIODS = ["daily", "weekly", "monthly"]; +const DEFAULT_THRESHOLDS = [80, 100]; + +function pad2(n) { + return String(n).padStart(2, "0"); +} + +/** + * Normalise an `alert_thresholds` value (stored as a JSON string, or already an + * array) into a sorted, de-duplicated list of integer percentages in 1..100. + * Falls back to the default thresholds when the input is empty or unparseable. + * + * @param {string|number[]|null|undefined} raw + * @returns {number[]} + */ +function parseThresholds(raw) { + let arr = raw; + if (typeof raw === "string") { + try { + arr = JSON.parse(raw); + } catch { + arr = null; + } + } + if (!Array.isArray(arr)) return [...DEFAULT_THRESHOLDS]; + const cleaned = [ + ...new Set( + arr.map((v) => Math.round(Number(v))).filter((v) => Number.isFinite(v) && v >= 1 && v <= 100) + ), + ].sort((a, b) => a - b); + return cleaned.length > 0 ? cleaned : [...DEFAULT_THRESHOLDS]; +} + +/** Human noun for a period: daily → "day", weekly → "week", monthly → "month". */ +function periodNoun(period) { + if (period === "daily") return "day"; + if (period === "weekly") return "week"; + return "month"; +} + +/** + * ISO-8601 week parts for the Monday (UTC midnight) that begins a week. + * Returns { isoYear, isoWeek } where the year is the one owning the Thursday. + * + * @param {Date} mondayUTC + */ +function isoWeekParts(mondayUTC) { + // The Thursday of the same week decides which year the week belongs to. + const thursday = new Date(mondayUTC.getTime() + 3 * 86400000); + const isoYear = thursday.getUTCFullYear(); + const jan1 = new Date(Date.UTC(isoYear, 0, 1)); + const jan1Dow = jan1.getUTCDay(); // 0=Sun..6=Sat + const offsetToThursday = (4 - jan1Dow + 7) % 7; + const firstThursday = new Date(Date.UTC(isoYear, 0, 1 + offsetToThursday)); + const isoWeek = 1 + Math.round((thursday.getTime() - firstThursday.getTime()) / (7 * 86400000)); + return { isoYear, isoWeek }; +} + +/** + * Compute the [start, end) UTC window and a stable key for the period that + * contains `now`. The key changes exactly when a new period begins, which is + * what re-arms alert thresholds. + * + * @param {"daily"|"weekly"|"monthly"} period + * @param {Date} now + * @returns {{ start: string, end: string, key: string }} ISO timestamps + key + */ +function periodWindow(period, now) { + const y = now.getUTCFullYear(); + const m = now.getUTCMonth(); + const d = now.getUTCDate(); + + if (period === "daily") { + const start = new Date(Date.UTC(y, m, d)); + const end = new Date(Date.UTC(y, m, d + 1)); + return { + start: start.toISOString(), + end: end.toISOString(), + key: `${y}-${pad2(m + 1)}-${pad2(d)}`, + }; + } + + if (period === "weekly") { + const dow = now.getUTCDay(); // 0=Sun..6=Sat + const diffToMonday = dow === 0 ? -6 : 1 - dow; + const start = new Date(Date.UTC(y, m, d + diffToMonday)); + const end = new Date(start.getTime() + 7 * 86400000); + const { isoYear, isoWeek } = isoWeekParts(start); + return { + start: start.toISOString(), + end: end.toISOString(), + key: `${isoYear}-W${pad2(isoWeek)}`, + }; + } + + // monthly + const start = new Date(Date.UTC(y, m, 1)); + const end = new Date(Date.UTC(y, m + 1, 1)); + return { + start: start.toISOString(), + end: end.toISOString(), + key: `${y}-${pad2(m + 1)}`, + }; +} + +/** Round to 4 decimal places, matching the precision used by the pricing route. */ +function round4(n) { + return Math.round(n * 10000) / 10000; +} + +/** + * Sum USD spend for token usage whose owning session started within + * [startISO, endISO). Baseline (post-compaction) token counts are included so + * the figure matches the /api/pricing/cost total. + * + * @param {import("better-sqlite3").Database} db + * @param {string} startISO + * @param {string} endISO + * @returns {number} + */ +function spendInWindow(db, startISO, endISO) { + // Lazy require avoids a load-order cycle (pricing route also requires db). + const { calculateCost } = require("../routes/pricing"); + const rows = db + .prepare( + `SELECT tu.model AS model, + SUM(tu.input_tokens + tu.baseline_input) AS input_tokens, + SUM(tu.output_tokens + tu.baseline_output) AS output_tokens, + SUM(tu.cache_read_tokens + tu.baseline_cache_read) AS cache_read_tokens, + SUM(tu.cache_write_tokens + tu.baseline_cache_write) AS cache_write_tokens + FROM token_usage tu + JOIN sessions s ON s.id = tu.session_id + WHERE s.started_at >= ? AND s.started_at < ? + GROUP BY tu.model` + ) + .all(startISO, endISO); + const rules = db.prepare("SELECT * FROM model_pricing").all(); + return calculateCost(rows, rules).total_cost; +} + +/** Thresholds already fired for this budget's current period. */ +function firedThresholds(db, budgetId, periodKey) { + return db + .prepare("SELECT threshold FROM budget_alert_state WHERE budget_id = ? AND period_key = ?") + .all(budgetId, periodKey) + .map((r) => r.threshold) + .sort((a, b) => a - b); +} + +/** + * Evaluate a single budget row against current spend. + * + * @param {import("better-sqlite3").Database} db + * @param {object} b Raw `budgets` row. + * @param {Date} now + * @returns {object} The budget enriched with period window, spend, pct, status. + */ +function evaluateBudget(db, b, now) { + const win = periodWindow(b.period, now); + const spent = spendInWindow(db, win.start, win.end); + const limit = b.limit_usd; + const pct = limit > 0 ? (spent / limit) * 100 : 0; + const thresholds = parseThresholds(b.alert_thresholds); + const warnAt = thresholds.length > 0 ? Math.min(...thresholds) : 100; + + let status = "ok"; + if (pct >= 100) status = "exceeded"; + else if (pct >= warnAt) status = "warning"; + + return { + id: b.id, + period: b.period, + limit_usd: limit, + enabled: Boolean(b.enabled), + label: b.label ?? null, + alert_thresholds: thresholds, + created_at: b.created_at, + updated_at: b.updated_at, + period_start: win.start, + period_end: win.end, + period_key: win.key, + spent: round4(spent), + remaining: round4(limit - spent), + pct: Math.round(pct * 100) / 100, + status, + fired_thresholds: firedThresholds(db, b.id, win.key), + }; +} + +/** + * Evaluate every budget (enabled or not), oldest first. + * + * @param {import("better-sqlite3").Database} db + * @param {Date} now + * @returns {object[]} + */ +function evaluateAll(db, now) { + return db + .prepare("SELECT * FROM budgets ORDER BY created_at ASC, id ASC") + .all() + .map((b) => evaluateBudget(db, b, now)); +} + +/** + * Build the user-facing title/body for a fired alert. + * + * @param {object} ev Evaluated budget. + * @param {number} peak Highest crossed threshold. + */ +function alertMessage(ev, peak) { + const noun = periodNoun(ev.period); + const name = ev.label ? `${ev.label} (${ev.period})` : `${ev.period} budget`; + const title = peak >= 100 ? `Budget exceeded — ${name}` : `Budget at ${peak}% — ${name}`; + const body = `$${ev.spent.toFixed(2)} of $${ev.limit_usd.toFixed(2)} spent this ${noun} (${Math.round( + ev.pct + )}%).`; + return { title, body }; +} + +/** + * Evaluate enabled budgets and fire alerts for newly-crossed thresholds. + * A single notification is emitted per budget per check (for the highest + * crossed threshold), but every newly-crossed threshold is recorded so it + * won't fire again this period. + * + * @param {import("better-sqlite3").Database} db + * @param {Date} now + * @param {{ broadcast?: Function, notify?: Function }} [hooks] + * @returns {Array<{budget_id:number, period:string, period_key:string, threshold:number, spent:number, limit_usd:number, pct:number, status:string, title:string, body:string}>} + */ +function checkAndAlert(db, now, hooks = {}) { + const { broadcast, notify } = hooks; + const rows = db.prepare("SELECT * FROM budgets WHERE enabled = 1").all(); + const fired = []; + + const insertState = db.prepare( + "INSERT OR IGNORE INTO budget_alert_state (budget_id, period_key, threshold) VALUES (?, ?, ?)" + ); + + for (const b of rows) { + const ev = evaluateBudget(db, b, now); + const crossed = ev.alert_thresholds.filter((t) => ev.pct >= t); + if (crossed.length === 0) continue; + + const already = new Set(ev.fired_thresholds); + const newly = crossed.filter((t) => !already.has(t)); + if (newly.length === 0) continue; + + const record = db.transaction(() => { + for (const t of newly) insertState.run(b.id, ev.period_key, t); + }); + record(); + + const peak = Math.max(...crossed); + const { title, body } = alertMessage(ev, peak); + const alert = { + budget_id: b.id, + period: ev.period, + period_key: ev.period_key, + threshold: peak, + spent: ev.spent, + limit_usd: ev.limit_usd, + pct: ev.pct, + status: ev.status, + title, + body, + }; + fired.push(alert); + + if (typeof notify === "function") { + try { + notify(title, body, alert); + } catch { + // Notification transport failures must never break the check loop. + } + } + if (typeof broadcast === "function") { + try { + broadcast("budget_alert", alert); + } catch { + // Ignore broadcast failures. + } + } + } + + if (fired.length > 0 && typeof broadcast === "function") { + try { + broadcast("budgets_updated", { budgets: evaluateAll(db, now) }); + } catch { + // Ignore broadcast failures. + } + } + + return fired; +} + +module.exports = { + VALID_PERIODS, + DEFAULT_THRESHOLDS, + parseThresholds, + periodNoun, + periodWindow, + isoWeekParts, + spendInWindow, + evaluateBudget, + evaluateAll, + checkAndAlert, +}; diff --git a/server/openapi.js b/server/openapi.js index fd0dc880..9e54655d 100644 --- a/server/openapi.js +++ b/server/openapi.js @@ -69,6 +69,10 @@ function createOpenApiSpec() { description: "Detect upstream git changes so users can pull and restart manually (local dashboard installs)", }, + { + name: "Budgets", + description: "Spend budgets with per-period USD limits and threshold alerts", + }, { name: "Alerts", description: "Rules-based alerting: rule CRUD, fired-alert feed, acknowledgement", @@ -1217,6 +1221,93 @@ function createOpenApiSpec() { purged_agents: { type: "integer" }, }, }, + Budget: { + type: "object", + required: [ + "id", + "period", + "limit_usd", + "enabled", + "alert_thresholds", + "period_start", + "period_end", + "period_key", + "spent", + "remaining", + "pct", + "status", + "fired_thresholds", + ], + properties: { + id: { type: "integer" }, + period: { type: "string", enum: ["daily", "weekly", "monthly"] }, + limit_usd: { type: "number", description: "USD ceiling for the period" }, + enabled: { type: "boolean" }, + label: { type: "string", nullable: true }, + alert_thresholds: { + type: "array", + items: { type: "integer", minimum: 1, maximum: 100 }, + description: "Percent-of-limit points that trigger an alert", + }, + created_at: { type: "string", format: "date-time" }, + updated_at: { type: "string", format: "date-time" }, + period_start: { type: "string", format: "date-time" }, + period_end: { type: "string", format: "date-time" }, + period_key: { + type: "string", + description: "Stable identifier for the current period instance (e.g. 2026-06)", + }, + spent: { type: "number", description: "USD spent so far this period" }, + remaining: { type: "number", description: "limit_usd minus spent" }, + pct: { type: "number", description: "spent as a percentage of limit_usd" }, + status: { type: "string", enum: ["ok", "warning", "exceeded"] }, + fired_thresholds: { + type: "array", + items: { type: "integer" }, + description: "Thresholds already alerted for the current period", + }, + }, + }, + BudgetListResponse: { + type: "object", + required: ["budgets", "generated_at"], + properties: { + budgets: { type: "array", items: { $ref: "#/components/schemas/Budget" } }, + generated_at: { type: "string", format: "date-time" }, + }, + }, + BudgetMutationResponse: { + type: "object", + required: ["budget"], + properties: { budget: { $ref: "#/components/schemas/Budget" } }, + }, + BudgetCreateRequest: { + type: "object", + required: ["period", "limit_usd"], + properties: { + period: { type: "string", enum: ["daily", "weekly", "monthly"] }, + limit_usd: { type: "number", exclusiveMinimum: 0 }, + label: { type: "string", nullable: true }, + enabled: { type: "boolean" }, + alert_thresholds: { + type: "array", + items: { type: "integer", minimum: 1, maximum: 100 }, + }, + }, + }, + BudgetUpdateRequest: { + type: "object", + properties: { + period: { type: "string", enum: ["daily", "weekly", "monthly"] }, + limit_usd: { type: "number", exclusiveMinimum: 0 }, + label: { type: "string", nullable: true }, + enabled: { type: "boolean" }, + alert_thresholds: { + type: "array", + items: { type: "integer", minimum: 1, maximum: 100 }, + }, + }, + }, }, }, paths: { @@ -2716,6 +2807,110 @@ function createOpenApiSpec() { }, }, }, + "/api/budgets": { + get: { + tags: ["Budgets"], + summary: "List spend budgets with live current-period spend", + operationId: "listBudgets", + responses: { + 200: { + description: "Budgets and their evaluated state", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/BudgetListResponse" }, + }, + }, + }, + }, + }, + post: { + tags: ["Budgets"], + summary: "Create a spend budget", + operationId: "createBudget", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/BudgetCreateRequest" }, + }, + }, + }, + responses: { + 201: { + description: "Budget created", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/BudgetMutationResponse" }, + }, + }, + }, + 400: { + description: "Invalid request body", + content: { + "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }, + }, + }, + }, + }, + }, + "/api/budgets/{id}": { + put: { + tags: ["Budgets"], + summary: "Update a spend budget", + operationId: "updateBudget", + parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/BudgetUpdateRequest" }, + }, + }, + }, + responses: { + 200: { + description: "Budget updated", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/BudgetMutationResponse" }, + }, + }, + }, + 400: { + description: "Invalid request body", + content: { + "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }, + }, + }, + 404: { + description: "Budget not found", + content: { + "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }, + }, + }, + }, + }, + delete: { + tags: ["Budgets"], + summary: "Delete a spend budget", + operationId: "deleteBudget", + parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }], + responses: { + 200: { + description: "Budget deleted", + content: { + "application/json": { schema: { $ref: "#/components/schemas/DeleteOkResponse" } }, + }, + }, + 404: { + description: "Budget not found", + content: { + "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }, + }, + }, + }, + }, + }, }, ...(issuesUrl ? { diff --git a/server/routes/budgets.js b/server/routes/budgets.js new file mode 100644 index 00000000..e4961875 --- /dev/null +++ b/server/routes/budgets.js @@ -0,0 +1,148 @@ +/** + * @file Express router for spend budgets. Lets the dashboard create, read, + * update, and delete USD spending limits per rolling period (daily / weekly / + * monthly). GET responses include live current-period spend so the UI can draw + * progress without a second round-trip. Threshold alerts are fired separately + * by the budget scheduler. + * @author Son Nguyen + */ + +const { Router } = require("express"); +const { db } = require("../db"); +const { + VALID_PERIODS, + DEFAULT_THRESHOLDS, + parseThresholds, + evaluateAll, + evaluateBudget, +} = require("../lib/budgets"); + +const router = Router(); + +function badRequest(res, message) { + return res.status(400).json({ error: { code: "INVALID_INPUT", message } }); +} + +/** + * Validate the shared budget fields used by POST/PUT. Returns either + * `{ error }` or a normalised `{ value }` with the cleaned fields present in + * the body (partial for PUT). + */ +function validateBody(body, { partial }) { + const out = {}; + + if (!partial || body.period !== undefined) { + if (!VALID_PERIODS.includes(body.period)) { + return { error: `period must be one of: ${VALID_PERIODS.join(", ")}` }; + } + out.period = body.period; + } + + if (!partial || body.limit_usd !== undefined) { + const limit = Number(body.limit_usd); + if (!Number.isFinite(limit) || limit <= 0) { + return { error: "limit_usd must be a positive number" }; + } + out.limit_usd = limit; + } + + if (body.label !== undefined) { + if (body.label !== null && typeof body.label !== "string") { + return { error: "label must be a string or null" }; + } + out.label = body.label ? String(body.label).slice(0, 120) : null; + } + + if (body.alert_thresholds !== undefined) { + if (!Array.isArray(body.alert_thresholds)) { + return { error: "alert_thresholds must be an array of percentages (1-100)" }; + } + out.alert_thresholds = parseThresholds(body.alert_thresholds); + } + + if (body.enabled !== undefined) { + out.enabled = body.enabled ? 1 : 0; + } + + return { value: out }; +} + +// GET /api/budgets — list budgets with live current-period spend. +router.get("/", (_req, res) => { + const now = new Date(); + res.json({ budgets: evaluateAll(db, now), generated_at: now.toISOString() }); +}); + +// POST /api/budgets — create a budget. +router.post("/", (req, res) => { + const { value, error } = validateBody(req.body || {}, { partial: false }); + if (error) return badRequest(res, error); + + const thresholds = value.alert_thresholds ?? [...DEFAULT_THRESHOLDS]; + const enabled = value.enabled ?? 1; + + const info = db + .prepare( + "INSERT INTO budgets (period, limit_usd, enabled, label, alert_thresholds) VALUES (?, ?, ?, ?, ?)" + ) + .run(value.period, value.limit_usd, enabled, value.label ?? null, JSON.stringify(thresholds)); + + const row = db.prepare("SELECT * FROM budgets WHERE id = ?").get(info.lastInsertRowid); + res.status(201).json({ budget: evaluateBudget(db, row, new Date()) }); +}); + +// PUT /api/budgets/:id — update an existing budget. +router.put("/:id", (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return badRequest(res, "id must be an integer"); + + const existing = db.prepare("SELECT * FROM budgets WHERE id = ?").get(id); + if (!existing) { + return res.status(404).json({ error: { code: "NOT_FOUND", message: "Budget not found" } }); + } + + const { value, error } = validateBody(req.body || {}, { partial: true }); + if (error) return badRequest(res, error); + if (Object.keys(value).length === 0) { + return badRequest(res, "no updatable fields provided"); + } + + const next = { + period: value.period ?? existing.period, + limit_usd: value.limit_usd ?? existing.limit_usd, + enabled: value.enabled ?? existing.enabled, + label: value.label !== undefined ? value.label : existing.label, + alert_thresholds: + value.alert_thresholds !== undefined + ? JSON.stringify(value.alert_thresholds) + : existing.alert_thresholds, + }; + + db.prepare( + `UPDATE budgets + SET period = ?, limit_usd = ?, enabled = ?, label = ?, alert_thresholds = ?, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = ?` + ).run(next.period, next.limit_usd, next.enabled, next.label, next.alert_thresholds, id); + + const row = db.prepare("SELECT * FROM budgets WHERE id = ?").get(id); + res.json({ budget: evaluateBudget(db, row, new Date()) }); +}); + +// DELETE /api/budgets/:id — remove a budget (and its alert state via cascade). +router.delete("/:id", (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return badRequest(res, "id must be an integer"); + + const existing = db.prepare("SELECT id FROM budgets WHERE id = ?").get(id); + if (!existing) { + return res.status(404).json({ error: { code: "NOT_FOUND", message: "Budget not found" } }); + } + // budget_alert_state has ON DELETE CASCADE, but clear explicitly in case the + // host disabled foreign-key enforcement. + db.prepare("DELETE FROM budget_alert_state WHERE budget_id = ?").run(id); + db.prepare("DELETE FROM budgets WHERE id = ?").run(id); + res.json({ ok: true }); +}); + +module.exports = router;