/** * @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. * @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. * * ## Observability * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and * Docker Compose profiles are documented in `monitoring/README.md`. * * ## 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. * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `.claude/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, ListChecks, 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 { timeAgo } from "../lib/format"; import type { AlertEvent, AlertRule, AlertRuleType, 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", ]; 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; 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", cooldown_seconds: "300", }; 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) }; } } 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() }); } } 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); const [formError, setFormError] = useState(null); const [saving, setSaving] = useState(false); const [confirmRule, setConfirmRule] = useState(null); // 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) { console.error("Failed to load alert rules:", err); } finally { setLoadingRules(false); } }, []); 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) { console.error("Failed to load alerts:", err); } finally { setLoadingAlerts(false); } }, [unackedOnly]); 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) { console.error("Failed to load more alerts:", err); } }, [unackedOnly, alerts.length]); 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]); const set = (patch: Partial) => setForm((prev) => ({ ...prev, ...patch })); const onCreateRule = async () => { if (saving) return; setSaving(true); setFormError(null); try { const cooldown = parseInt(form.cooldown_seconds, 10); await api.alerts.rules.create({ name: form.name.trim(), rule_type: form.rule_type, config: buildConfig(form), cooldown_seconds: Number.isFinite(cooldown) && cooldown >= 0 ? cooldown : 300, }); setForm(EMPTY_FORM); setFormOpen(false); 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) { console.error("Failed to toggle alert rule:", err); } }; const onDeleteRule = async (rule: AlertRule) => { try { await api.alerts.rules.remove(rule.id); setConfirmRule(null); loadRules(); loadAlerts(); } catch (err) { console.error("Failed to delete alert rule:", err); } }; const onAck = async (id: number) => { try { await api.alerts.ack(id); loadAlerts(); } catch (err) { console.error("Failed to acknowledge alert:", err); } }; const onAckAll = async () => { try { await api.alerts.ackAll(); loadAlerts(); } catch (err) { console.error("Failed to acknowledge alerts:", 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); const canSubmit = form.name.trim().length > 0 && (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)); 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" && (
)}
{formError &&

{formError}

}
)} {loadingRules ? (
) : rules.length === 0 ? ( ) : (
    {rules.map((rule) => (
  • {rule.name} {t(`ruleTypes.${rule.rule_type}`)}

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

  • ))}
)}
)} {/* ── 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)} />
); }