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 (
+