/** * @file AlertsNotifications.tsx * @description Unified "Alerts" control center embedded in the Settings page * (replaces the standalone /alerts route). A segmented tab UI * combines three concerns that used to be split across a page and a panel: * • Rules - define what conditions trigger an alert * • Channels - webhook targets that receive fired alerts (Slack/Discord/…) * • Activity - the live fired-alert feed with acknowledge controls * Tab badges reflect live state (rule count, unacked alert count), and the feed * + counts refetch on alert_triggered / alert_updated WebSocket messages. * A right-click on a rule opens its Enable/Disable and Delete as a menu. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Internal dependencies * - `../lib/api` * - `../lib/eventBus` * - `./EmptyState` * - `./Skeleton` * - `./WebhookSettings` * - `./ConfirmModal` * - `./Checkbox` * - `./FieldHelp` * - `../lib/format` * - `../lib/types` * * ## Public surface * - `AlertsNotifications` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **AlertsNotifications** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { useCallback, useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { BellRing, BellOff, Check, CheckCheck, ChevronDown, Eye, ListChecks, Pencil, Plus, RefreshCw, Trash2, Webhook, X, } from "lucide-react"; import { api } from "../lib/api"; import { eventBus } from "../lib/eventBus"; import { EmptyState } from "./EmptyState"; import { Skeleton } from "./Skeleton"; import { WebhookSettings } from "./WebhookSettings"; import { ConfirmModal } from "./ConfirmModal"; import { Checkbox } from "./Checkbox"; import { FieldHelp } from "./FieldHelp"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu"; import { fmtCost, timeAgo } from "../lib/format"; import { toast } from "../lib/toast"; import { useAiTools } from "../hooks/useAiTools"; import { useSpin } from "../hooks/useSpin"; import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types"; const PAGE_SIZE = 25; // Example values surfaced in the field-help tooltips so users know what to type. // These are the Claude Code hook event types and common built-in tool names. const EVENT_TYPE_EXAMPLES = [ "PreToolUse", "PostToolUse", "Stop", "SubagentStop", "Notification", "SessionStart", "SessionEnd", "UserPromptSubmit", ]; const TOOL_NAME_EXAMPLES = [ "Bash", "Read", "Edit", "Write", "Grep", "Glob", "Task", "WebFetch", "WebSearch", "TodoWrite", ]; const SUMMARY_EXAMPLES = ["error", "permission", "timeout", "rate limit", "denied"]; const RULE_TYPES: AlertRuleType[] = [ "event_pattern", "inactivity", "status_duration", "token_threshold", "cost_threshold", "budget", ]; type TabKey = "rules" | "channels" | "activity"; interface RuleFormState { name: string; rule_type: AlertRuleType; event_type: string; tool_name: string; summary_contains: string; count: string; window_minutes: string; minutes: string; status: "working" | "waiting"; total_tokens: string; usd: string; period: "day" | "week" | "month"; limit_usd: string; thresholds: string; budget_tool: string; cooldown_seconds: string; } const EMPTY_FORM: RuleFormState = { name: "", rule_type: "event_pattern", event_type: "", tool_name: "", summary_contains: "", count: "1", window_minutes: "5", minutes: "10", status: "working", total_tokens: "1000000", usd: "5", period: "month", limit_usd: "100", thresholds: "80, 100", budget_tool: "", cooldown_seconds: "300", }; /** A rule's stored values as form fields, for editing it. */ function formFromRule(rule: AlertRule): RuleFormState { const c = rule.config; const str = (v: unknown, fallback: string) => (v == null ? fallback : String(v)); return { name: rule.name, rule_type: rule.rule_type, event_type: str(c.event_type, ""), tool_name: str(c.tool_name, ""), summary_contains: str(c.summary_contains, ""), count: str(c.count, EMPTY_FORM.count), window_minutes: str(c.window_minutes, EMPTY_FORM.window_minutes), minutes: str(c.minutes, EMPTY_FORM.minutes), status: c.status === "waiting" ? "waiting" : "working", total_tokens: str(c.total_tokens, EMPTY_FORM.total_tokens), usd: str(c.usd, EMPTY_FORM.usd), period: c.period === "day" || c.period === "week" ? c.period : "month", limit_usd: str(c.limit_usd, EMPTY_FORM.limit_usd), thresholds: Array.isArray(c.thresholds) ? c.thresholds.join(", ") : EMPTY_FORM.thresholds, budget_tool: str(c.tool, ""), cooldown_seconds: String(rule.cooldown_seconds), }; } const errText = (err: unknown) => (err instanceof Error ? err.message : String(err)); /** "80, 100" → [80, 100]; null when any part is not a whole percent 1..1000. */ function parseThresholds(text: string): number[] | null { const parts = text .split(",") .map((p) => p.trim()) .filter(Boolean); const nums = parts.map((p) => Number(p)); if (nums.length === 0 || nums.length > 10) return null; return nums.every((n) => Number.isInteger(n) && n > 0 && n <= 1000) ? nums : null; } function buildConfig(form: RuleFormState): AlertRule["config"] { switch (form.rule_type) { case "event_pattern": { const config: AlertRule["config"] = {}; if (form.event_type.trim()) config.event_type = form.event_type.trim(); if (form.tool_name.trim()) config.tool_name = form.tool_name.trim(); if (form.summary_contains.trim()) config.summary_contains = form.summary_contains.trim(); const count = parseInt(form.count, 10); config.count = Number.isFinite(count) && count > 0 ? count : 1; if (config.count > 1) { const window = parseFloat(form.window_minutes); config.window_minutes = Number.isFinite(window) && window > 0 ? window : 5; } return config; } case "inactivity": return { minutes: parseFloat(form.minutes) }; case "status_duration": return { status: form.status, minutes: parseFloat(form.minutes) }; case "token_threshold": return { total_tokens: parseInt(form.total_tokens, 10) }; case "cost_threshold": return { usd: parseFloat(form.usd) }; case "budget": { const config: AlertRule["config"] = { period: form.period, limit_usd: parseFloat(form.limit_usd), thresholds: parseThresholds(form.thresholds) ?? [80, 100], tz_offset: new Date().getTimezoneOffset(), }; if (form.budget_tool) config.tool = form.budget_tool; return config; } } } function describeRule(rule: AlertRule, t: (key: string, opts?: Record) => string) { const c = rule.config; switch (rule.rule_type) { case "event_pattern": { const parts = [ c.event_type && `event=${c.event_type}`, c.tool_name && `tool=${c.tool_name}`, c.summary_contains && `summary~"${c.summary_contains}"`, ].filter(Boolean); const base = parts.join(" · "); return (c.count ?? 1) > 1 ? t("ruleDesc.eventPatternCount", { pattern: base, count: c.count, window: c.window_minutes, }) : t("ruleDesc.eventPattern", { pattern: base }); } case "inactivity": return t("ruleDesc.inactivity", { minutes: c.minutes }); case "status_duration": return t("ruleDesc.statusDuration", { status: c.status, minutes: c.minutes }); case "token_threshold": return t("ruleDesc.tokenThreshold", { tokens: (c.total_tokens ?? 0).toLocaleString() }); case "cost_threshold": return t("ruleDesc.costThreshold", { usd: fmtCost(c.usd ?? 0) }); case "budget": return t("ruleDesc.budget", { limit: fmtCost(c.limit_usd ?? 0), period: t(`periods.${c.period ?? "month"}`), thresholds: (c.thresholds ?? []).join("%, ") + "%", }); } } export function AlertsNotifications() { const { t } = useTranslation("alerts"); const { t: ts } = useTranslation("settings"); const [tab, setTab] = useState("rules"); // Rules const [rules, setRules] = useState([]); const [loadingRules, setLoadingRules] = useState(true); const [formOpen, setFormOpen] = useState(false); const [form, setForm] = useState(EMPTY_FORM); // The rule being edited, or null while the form creates a new one. const [editingId, setEditingId] = useState(null); const [formError, setFormError] = useState(null); const [saving, setSaving] = useState(false); const [confirmRule, setConfirmRule] = useState(null); const [preview, setPreview] = useState(null); const [previewing, setPreviewing] = useState(false); const aiTools = useAiTools(); const ruleCtx = useContextMenu(); // A rule-menu item: close the menu, then act on the rule it was opened on. const pickRule = (fn: (rule: AlertRule) => unknown) => () => { const rule = ruleCtx.menu?.item; ruleCtx.close(); if (rule) fn(rule); }; // Feed const [alerts, setAlerts] = useState([]); const [total, setTotal] = useState(0); const [unacked, setUnacked] = useState(0); const [unackedOnly, setUnackedOnly] = useState(false); const [loadingAlerts, setLoadingAlerts] = useState(true); const loadRules = useCallback(async () => { setLoadingRules(true); try { const res = await api.alerts.rules.list(); setRules(res.rules); } catch (err) { toast.error(t("errors.loadRules", { error: errText(err) })); } finally { setLoadingRules(false); } }, [t]); const loadAlerts = useCallback(async () => { setLoadingAlerts(true); try { const res = await api.alerts.list({ unacked: unackedOnly || undefined, limit: PAGE_SIZE, offset: 0, }); setAlerts(res.alerts); setTotal(res.total); setUnacked(res.unacked); } catch (err) { toast.error(t("errors.loadAlerts", { error: errText(err) })); } finally { setLoadingAlerts(false); } }, [unackedOnly, t]); const [spinning, refreshAlerts] = useSpin(loadAlerts); const loadMore = useCallback(async () => { try { const res = await api.alerts.list({ unacked: unackedOnly || undefined, limit: PAGE_SIZE, offset: alerts.length, }); setAlerts((prev) => [...prev, ...res.alerts]); setTotal(res.total); setUnacked(res.unacked); } catch (err) { toast.error(t("errors.loadAlerts", { error: errText(err) })); } }, [unackedOnly, alerts.length, t]); useEffect(() => { loadRules(); }, [loadRules]); useEffect(() => { loadAlerts(); }, [loadAlerts]); // Live updates: any fired/acked alert refreshes the feed + counts regardless // of which tab is open, so the Activity badge stays accurate. useEffect(() => { return eventBus.subscribe((msg: WSMessage) => { if (msg.type === "alert_triggered" || msg.type === "alert_updated") { loadAlerts(); } }); }, [loadAlerts]); // Any edit makes a shown preview stale. const set = (patch: Partial) => { setPreview(null); setForm((prev) => ({ ...prev, ...patch })); }; const onPreview = async () => { if (previewing) return; setPreviewing(true); setFormError(null); try { const cooldown = parseInt(form.cooldown_seconds, 10); setPreview( await api.alerts.rules.preview({ rule_type: form.rule_type, config: buildConfig(form), cooldown_seconds: Number.isFinite(cooldown) && cooldown >= 0 ? cooldown : 300, }) ); } catch (err) { setFormError(err instanceof Error ? err.message : String(err)); } finally { setPreviewing(false); } }; const closeForm = () => { setForm(EMPTY_FORM); setPreview(null); setEditingId(null); setFormOpen(false); setFormError(null); }; const openEditRule = (rule: AlertRule) => { setForm(formFromRule(rule)); setPreview(null); setEditingId(rule.id); setFormError(null); setFormOpen(true); }; const onSaveRule = async () => { if (saving || !canSubmit) return; setSaving(true); setFormError(null); try { const body = { name: form.name.trim(), config: buildConfig(form), cooldown_seconds: cooldownVal, }; if (editingId) await api.alerts.rules.update(editingId, body); else await api.alerts.rules.create({ ...body, rule_type: form.rule_type }); closeForm(); loadRules(); } catch (err) { setFormError(err instanceof Error ? err.message : String(err)); } finally { setSaving(false); } }; const onToggleRule = async (rule: AlertRule) => { try { await api.alerts.rules.update(rule.id, { enabled: !rule.enabled }); loadRules(); } catch (err) { toast.error(t("errors.toggleRule", { error: errText(err) })); } }; const onDeleteRule = async (rule: AlertRule) => { try { await api.alerts.rules.remove(rule.id); setConfirmRule(null); loadRules(); loadAlerts(); } catch (err) { setConfirmRule(null); toast.error(t("errors.deleteRule", { error: errText(err) })); } }; const onAck = async (id: number) => { try { await api.alerts.ack(id); loadAlerts(); } catch (err) { toast.error(t("errors.ack", { error: errText(err) })); } }; const onAckAll = async () => { try { await api.alerts.ackAll(); loadAlerts(); } catch (err) { toast.error(t("errors.ack", { error: errText(err) })); } }; // Mirror the server-side validation so obviously invalid rules never make it // to a request. const minutesVal = parseFloat(form.minutes); const tokensVal = parseInt(form.total_tokens, 10); const countVal = parseInt(form.count, 10); const windowVal = parseFloat(form.window_minutes); // Cooldown is a whole number of seconds ≥ 0 — never silently replaced by 300. const cooldownOk = /^\d+$/.test(form.cooldown_seconds.trim()); const cooldownVal = cooldownOk ? parseInt(form.cooldown_seconds, 10) : 0; const canSubmit = form.name.trim().length > 0 && cooldownOk && (form.rule_type !== "event_pattern" || (Boolean(form.event_type.trim() || form.tool_name.trim() || form.summary_contains.trim()) && Number.isFinite(countVal) && countVal > 0 && (countVal <= 1 || (Number.isFinite(windowVal) && windowVal > 0)))) && ((form.rule_type !== "inactivity" && form.rule_type !== "status_duration") || (Number.isFinite(minutesVal) && minutesVal > 0)) && (form.rule_type !== "token_threshold" || (Number.isFinite(tokensVal) && tokensVal > 0)) && (form.rule_type !== "cost_threshold" || parseFloat(form.usd) > 0) && (form.rule_type !== "budget" || (parseFloat(form.limit_usd) > 0 && parseThresholds(form.thresholds) !== null)); const TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [ { key: "rules", label: ts("alertsHub.tabRules"), icon: ListChecks, badge: rules.length || undefined, }, { key: "channels", label: ts("alertsHub.tabChannels"), icon: Webhook }, { key: "activity", label: ts("alertsHub.tabActivity"), icon: BellRing, badge: unacked || undefined, }, ]; return (
{/* Segmented tab control */}
{TABS.map((tb) => { const active = tab === tb.key; const Icon = tb.icon; return ( ); })}
{/* ── RULES ── */} {tab === "rules" && (

{t("rules.title")}

{ts("alertsHub.rulesHint")}

{formOpen && (

{t(`ruleTypeHints.${form.rule_type}`)}

{form.rule_type === "event_pattern" && (
{parseInt(form.count, 10) > 1 && ( )}
)} {(form.rule_type === "inactivity" || form.rule_type === "status_duration") && (
{form.rule_type === "status_duration" && ( )}
)} {form.rule_type === "token_threshold" && (
)} {form.rule_type === "cost_threshold" && (
)} {form.rule_type === "budget" && (
)} {preview && }
{formError &&

{formError}

}
)} {loadingRules ? (
) : rules.length === 0 ? ( ) : (
    {rules.map((rule) => (
  • ruleCtx.open(e, rule)} className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-surface-2 px-3 py-2.5" >
    {rule.name} {t(`ruleTypes.${rule.rule_type}`)}

    {describeRule(rule, t)} ·{" "} {t("rules.cooldown", { seconds: rule.cooldown_seconds })}

  • ))}
)} {ruleCtx.menu && ( {ruleCtx.menu.item.enabled ? t("rules.disable") : t("rules.enable")} {t("rules.edit")} {t("rules.delete")} )}
)} {/* ── CHANNELS (webhooks) ── */} {tab === "channels" && } {/* ── ACTIVITY (fired-alert feed) ── */} {tab === "activity" && (

{t("feed.title")} {unacked > 0 && ( {t("feed.unackedCount", { count: unacked })} )}

{unacked > 0 && ( )}
{loadingAlerts && alerts.length === 0 ? (
) : alerts.length === 0 ? ( ) : ( <>
    {alerts.map((alert) => (
  • {alert.message}

    {timeAgo(alert.triggered_at)} · {alert.rule_name} {alert.session_id && ( <> {" · "} {t("feed.viewSession")} )}

    {!alert.acknowledged_at && ( )}
  • ))}
{alerts.length < total && (
)} )}
)} setConfirmRule(null)} onConfirm={() => confirmRule && onDeleteRule(confirmRule)} />
); } /** What a rule would have fired, from POST /api/alerts/rules/preview. */ function RulePreviewResult({ preview }: { preview: RulePreview }) { const { t } = useTranslation("alerts"); if (!preview.supported) { return

{t("rules.previewUnsupported")}

; } if (preview.periods) { return (

{t("rules.previewBudget")}

{preview.periods.map((p) => (
{p.period_start} {fmtCost(p.spend)} · {Math.round(p.pct)}% {p.crossed.length ? p.crossed.map((c) => `${c}%`).join(" ") : "—"}
))}
); } return (

{t("rules.previewResult", { fires: preview.fires, sessions: preview.sessions, days: preview.window_days, })}

{preview.samples.map((s, i) => (
{s.session_id.slice(0, 8)} {timeAgo(s.at)}
))}
); }