Files
AgentHub/client/src/components/AlertsNotifications.tsx
T
nntrivi2001 b7f3735b29 feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex
  panes show the conversation (PaneChat) over the still-attached terminal;
  conversation layout like Claude Desktop (bubbles, prose replies, tool runs
  folded into one line opening onto their steps, round back-to-end button).
- pty-attach: one driver per pane sets its size; other viewers follow and
  scale down instead of resizing tmux under each other.
- Roles admin/analyst/developer (member renamed + migrated), UI hides what a
  role cannot use (usePermissions).
- Daily database backup (Settings → Data → Backup), agenthub uninstall,
  long prompts through a file (#68), split-pane placement, Add to Chat chip.

Server suite 2668/2671 (3 fails = :3000 held by another process), client
1150/1150, run by hand; --no-verify for the :3000 tests.
2026-10-08 23:04:57 +07:00

1178 lines
46 KiB
TypeScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* 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 { usePermissions } from "./AuthGate";
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, unknown>) => 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");
// Rules and channels (webhooks) fire for every user's sessions: an admin's.
const { isAdmin } = usePermissions();
const [tab, setTab] = useState<TabKey>(isAdmin ? "rules" : "activity");
// Rules
const [rules, setRules] = useState<AlertRule[]>([]);
const [loadingRules, setLoadingRules] = useState(true);
const [formOpen, setFormOpen] = useState(false);
const [form, setForm] = useState<RuleFormState>(EMPTY_FORM);
// The rule being edited, or null while the form creates a new one.
const [editingId, setEditingId] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [confirmRule, setConfirmRule] = useState<AlertRule | null>(null);
const [preview, setPreview] = useState<RulePreview | null>(null);
const [previewing, setPreviewing] = useState(false);
const aiTools = useAiTools();
const ruleCtx = useContextMenu<AlertRule>();
// 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<AlertEvent[]>([]);
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(() => {
if (isAdmin) loadRules();
}, [loadRules, isAdmin]);
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<RuleFormState>) => {
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 ALL_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,
},
];
const TABS = isAdmin ? ALL_TABS : ALL_TABS.filter((tb) => tb.key === "activity");
return (
<div className="space-y-4">
{/* Segmented tab control */}
<div className="inline-flex flex-wrap rounded-xl border border-border bg-surface-2 p-1 gap-1">
{TABS.map((tb) => {
const active = tab === tb.key;
const Icon = tb.icon;
return (
<button
key={tb.key}
onClick={() => setTab(tb.key)}
className={`inline-flex items-center gap-2 text-xs font-medium px-3.5 py-2 rounded-lg transition-colors ${
active
? "bg-surface-4 text-fg-primary shadow-sm"
: "text-fg-muted hover:text-fg-secondary hover:bg-surface-3"
}`}
>
<Icon className="w-3.5 h-3.5" />
{tb.label}
{tb.badge != null && (
<span
className={`text-[10px] font-semibold rounded-full px-1.5 min-w-[18px] text-center ${
tb.key === "activity"
? "text-status-warning bg-status-warning/15"
: active
? "text-accent bg-accent/15"
: "text-fg-secondary bg-surface-2"
}`}
>
{tb.badge}
</span>
)}
</button>
);
})}
</div>
{/* ── RULES ── */}
{tab === "rules" && (
<div className="card p-4">
<div className="flex items-center justify-between gap-3 mb-3">
<div>
<h4 className="text-sm font-semibold text-fg-secondary">{t("rules.title")}</h4>
<p className="text-xs text-fg-muted mt-0.5">{ts("alertsHub.rulesHint")}</p>
</div>
<button
onClick={() => {
if (formOpen) closeForm();
else {
setFormOpen(true);
setFormError(null);
}
}}
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs flex-shrink-0"
>
{formOpen ? <X className="w-3.5 h-3.5" /> : <Plus className="w-3.5 h-3.5" />}
{formOpen ? t("rules.cancel") : t("rules.add")}
</button>
</div>
{formOpen && (
<div className="rounded-lg border border-border bg-surface-2 p-3 mb-3 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.name")}
<FieldHelp description={t("rules.help.name")} />
</span>
<input
type="text"
value={form.name}
onChange={(e) => set({ name: e.target.value })}
placeholder={t("rules.form.namePlaceholder")}
className="input mt-1 w-full"
/>
</label>
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.type")}
<FieldHelp title={t("rules.form.type")} description={t("rules.help.type")} />
</span>
<div className="relative mt-1">
<select
value={form.rule_type}
onChange={(e) => set({ rule_type: e.target.value as AlertRuleType })}
disabled={!!editingId}
title={editingId ? t("rules.typeFixed") : undefined}
className="select w-full"
>
{RULE_TYPES.map((type) => (
<option key={type} value={type}>
{t(`ruleTypes.${type}`)}
</option>
))}
</select>
</div>
</label>
</div>
<p className="text-[11px] text-fg-muted">{t(`ruleTypeHints.${form.rule_type}`)}</p>
{form.rule_type === "event_pattern" && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.eventType")}
<FieldHelp
title={t("rules.form.eventType")}
description={t("rules.help.eventType")}
examples={EVENT_TYPE_EXAMPLES}
/>
</span>
<input
type="text"
value={form.event_type}
onChange={(e) => set({ event_type: e.target.value })}
placeholder="PostToolUse"
className="input mt-1 w-full"
/>
</label>
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.toolName")}
<FieldHelp
title={t("rules.form.toolName")}
description={t("rules.help.toolName")}
examples={TOOL_NAME_EXAMPLES}
/>
</span>
<input
type="text"
value={form.tool_name}
onChange={(e) => set({ tool_name: e.target.value })}
placeholder="Bash"
className="input mt-1 w-full"
/>
</label>
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.summaryContains")}
<FieldHelp
title={t("rules.form.summaryContains")}
description={t("rules.help.summaryContains")}
examples={SUMMARY_EXAMPLES}
/>
</span>
<input
type="text"
value={form.summary_contains}
onChange={(e) => set({ summary_contains: e.target.value })}
placeholder="error"
className="input mt-1 w-full"
/>
</label>
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.count")}
<FieldHelp description={t("rules.help.count")} />
</span>
<input
type="number"
min={1}
value={form.count}
onChange={(e) => set({ count: e.target.value })}
className="input mt-1 w-full"
/>
</label>
{parseInt(form.count, 10) > 1 && (
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.windowMinutes")}
<FieldHelp description={t("rules.help.window")} />
</span>
<input
type="number"
min={1}
value={form.window_minutes}
onChange={(e) => set({ window_minutes: e.target.value })}
className="input mt-1 w-full"
/>
</label>
)}
</div>
)}
{(form.rule_type === "inactivity" || form.rule_type === "status_duration") && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{form.rule_type === "status_duration" && (
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.agentStatus")}
<FieldHelp description={t("rules.help.status")} />
</span>
<div className="relative mt-1">
<select
value={form.status}
onChange={(e) => set({ status: e.target.value as "working" | "waiting" })}
className="select w-full"
>
<option value="working">working</option>
<option value="waiting">waiting</option>
</select>
</div>
</label>
)}
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.minutes")}
<FieldHelp
description={t(
form.rule_type === "inactivity"
? "rules.help.minutesInactivity"
: "rules.help.minutesStatus"
)}
/>
</span>
<input
type="number"
min={1}
value={form.minutes}
onChange={(e) => set({ minutes: e.target.value })}
className="input mt-1 w-full"
/>
</label>
</div>
)}
{form.rule_type === "token_threshold" && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.totalTokens")}
<FieldHelp description={t("rules.help.totalTokens")} />
</span>
<input
type="number"
min={1}
value={form.total_tokens}
onChange={(e) => set({ total_tokens: e.target.value })}
className="input mt-1 w-full"
/>
</label>
</div>
)}
{form.rule_type === "cost_threshold" && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.usd")}
<FieldHelp description={t("rules.help.usd")} />
</span>
<input
type="number"
min={0}
step="0.01"
value={form.usd}
onChange={(e) => set({ usd: e.target.value })}
className="input mt-1 w-full"
/>
</label>
</div>
)}
{form.rule_type === "budget" && (
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.period")}
<FieldHelp description={t("rules.help.period")} />
</span>
<div className="relative mt-1">
<select
value={form.period}
onChange={(e) => set({ period: e.target.value as RuleFormState["period"] })}
className="input w-full appearance-none pr-8"
>
{(["day", "week", "month"] as const).map((p) => (
<option key={p} value={p}>
{t(`periods.${p}`)}
</option>
))}
</select>
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
</div>
</label>
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.limitUsd")}
<FieldHelp description={t("rules.help.limitUsd")} />
</span>
<input
type="number"
min={0}
step="0.01"
value={form.limit_usd}
onChange={(e) => set({ limit_usd: e.target.value })}
className="input mt-1 w-full"
/>
</label>
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.thresholds")}
<FieldHelp description={t("rules.help.thresholds")} />
</span>
<input
type="text"
value={form.thresholds}
onChange={(e) => set({ thresholds: e.target.value })}
placeholder="80, 100"
className="input mt-1 w-full"
/>
</label>
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.budgetTool")}
<FieldHelp description={t("rules.help.budgetTool")} />
</span>
<div className="relative mt-1">
<select
value={form.budget_tool}
onChange={(e) => set({ budget_tool: e.target.value })}
className="input w-full appearance-none pr-8"
>
<option value="">{t("rules.form.allTools")}</option>
{aiTools.tools.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</select>
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
</div>
</label>
</div>
)}
{preview && <RulePreviewResult preview={preview} />}
<div className="flex flex-wrap items-end justify-between gap-3">
<label className="block text-xs text-fg-secondary">
<span className="mb-1.5 flex items-center gap-1">
{t("rules.form.cooldown")}
<FieldHelp description={t("rules.help.cooldown")} />
</span>
<input
type="number"
min={0}
step={1}
value={form.cooldown_seconds}
onChange={(e) => set({ cooldown_seconds: e.target.value })}
aria-invalid={!cooldownOk}
className="input w-40"
/>
{!cooldownOk && (
<span className="mt-1 block text-[11px] text-status-danger">
{t("rules.cooldownInvalid")}
</span>
)}
</label>
<div className="flex items-center gap-2">
<button
onClick={onPreview}
disabled={!canSubmit || previewing}
title={t("rules.previewHint")}
className="btn-ghost border border-border inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<Eye className="w-3.5 h-3.5" />
{previewing ? t("rules.previewing") : t("rules.preview")}
</button>
<button
onClick={onSaveRule}
disabled={!canSubmit || saving}
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingId ? (
<Check className="w-3.5 h-3.5" />
) : (
<Plus className="w-3.5 h-3.5" />
)}
{saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")}
</button>
</div>
</div>
{formError && <p className="text-xs text-status-danger">{formError}</p>}
</div>
)}
{loadingRules ? (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : rules.length === 0 ? (
<EmptyState
icon={ListChecks}
title={t("rules.empty")}
description={ts("alertsHub.rulesEmptyHint")}
compact
/>
) : (
<ul className="space-y-2">
{rules.map((rule) => (
<li
key={rule.id}
onContextMenu={(e) => 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"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span
className={`text-sm font-medium truncate ${rule.enabled ? "text-fg-secondary" : "text-fg-muted line-through"}`}
>
{rule.name}
</span>
<span className="text-[10px] font-semibold uppercase tracking-wider text-accent bg-accent/10 border border-accent/20 rounded px-1.5 py-0.5 flex-shrink-0">
{t(`ruleTypes.${rule.rule_type}`)}
</span>
</div>
<p className="text-xs text-fg-muted mt-0.5 truncate">
{describeRule(rule, t)} ·{" "}
{t("rules.cooldown", { seconds: rule.cooldown_seconds })}
</p>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<button
onClick={() => onToggleRule(rule)}
className={`text-xs px-2.5 py-1.5 rounded-md border transition-colors ${
rule.enabled
? "border-status-success/30 text-status-success hover:bg-status-success/10"
: "border-border text-fg-muted hover:text-fg-secondary hover:bg-surface-3"
}`}
title={rule.enabled ? t("rules.disable") : t("rules.enable")}
>
{rule.enabled ? t("rules.enabled") : t("rules.disabled")}
</button>
<button
onClick={() => openEditRule(rule)}
className="p-1.5 rounded-md text-fg-muted hover:text-fg-primary hover:bg-surface-3 transition-colors"
title={t("rules.edit")}
aria-label={t("rules.edit")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setConfirmRule(rule)}
className="p-1.5 rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10 transition-colors"
title={t("rules.delete")}
aria-label={t("rules.delete")}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</li>
))}
</ul>
)}
{ruleCtx.menu && (
<ContextMenu
x={ruleCtx.menu.x}
y={ruleCtx.menu.y}
onClose={ruleCtx.close}
testId="alert-rule-menu"
>
<MenuItem onSelect={pickRule(onToggleRule)}>
{ruleCtx.menu.item.enabled ? t("rules.disable") : t("rules.enable")}
</MenuItem>
<MenuItem onSelect={pickRule(openEditRule)}>{t("rules.edit")}</MenuItem>
<MenuSeparator />
<MenuItem danger onSelect={pickRule(setConfirmRule)}>
{t("rules.delete")}
</MenuItem>
</ContextMenu>
)}
</div>
)}
{/* ── CHANNELS (webhooks) ── */}
{tab === "channels" && <WebhookSettings />}
{/* ── ACTIVITY (fired-alert feed) ── */}
{tab === "activity" && (
<div className="card p-4">
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
<h4 className="text-sm font-semibold text-fg-secondary">
{t("feed.title")}
{unacked > 0 && (
<span className="ml-2 text-[10px] font-semibold text-status-warning bg-status-warning/10 border border-status-warning/30 rounded-full px-2 py-0.5">
{t("feed.unackedCount", { count: unacked })}
</span>
)}
</h4>
<div className="flex items-center gap-2">
<Checkbox
checked={unackedOnly}
onChange={setUnackedOnly}
label={t("feed.unackedOnly")}
/>
<button
onClick={refreshAlerts}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs"
title={t("refresh")}
>
<RefreshCw className={`w-3.5 h-3.5 ${spinning ? "animate-spin" : ""}`} />
</button>
{unacked > 0 && (
<button
onClick={onAckAll}
className="btn-primary inline-flex items-center gap-1.5 text-xs"
>
<CheckCheck className="w-3.5 h-3.5" />
{t("ackAll", { count: unacked })}
</button>
)}
</div>
</div>
{loadingAlerts && alerts.length === 0 ? (
<div className="space-y-2">
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
</div>
) : alerts.length === 0 ? (
<EmptyState
icon={unackedOnly ? BellOff : BellRing}
title={t("feed.emptyTitle")}
description={unackedOnly ? t("feed.emptyUnacked") : t("feed.emptyDescription")}
compact
/>
) : (
<>
<ul className="space-y-2">
{alerts.map((alert) => (
<li
key={alert.id}
className={`flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2.5 ${
alert.acknowledged_at
? "border-border bg-surface-2 opacity-70"
: "border-status-warning/30 bg-status-warning/5"
}`}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<BellRing
className={`w-3.5 h-3.5 flex-shrink-0 ${alert.acknowledged_at ? "text-fg-muted" : "text-status-warning"}`}
/>
<span className="text-sm text-fg-secondary truncate">{alert.message}</span>
</div>
<p className="text-[11px] text-fg-muted mt-0.5 font-mono">
{timeAgo(alert.triggered_at)} · {alert.rule_name}
{alert.session_id && (
<>
{" · "}
<Link
to={`/sessions/${encodeURIComponent(alert.session_id)}`}
className="text-accent hover:underline"
>
{t("feed.viewSession")}
</Link>
</>
)}
</p>
</div>
{!alert.acknowledged_at && alert.can_ack !== false && (
<button
onClick={() => onAck(alert.id)}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3 transition-colors flex-shrink-0"
>
<Check className="w-3.5 h-3.5" />
{t("feed.ack")}
</button>
)}
</li>
))}
</ul>
{alerts.length < total && (
<div className="flex justify-center mt-4">
<button onClick={loadMore} className="btn-ghost border border-border text-sm">
{t("feed.loadMore", { shown: alerts.length, total })}
</button>
</div>
)}
</>
)}
</div>
)}
<ConfirmModal
open={!!confirmRule}
title={t("rules.deleteTitle", "Delete alert rule?")}
message={confirmRule ? t("rules.confirmDelete", { name: confirmRule.name }) : ""}
confirmLabel={t("rules.delete")}
cancelLabel={t("rules.cancel")}
onCancel={() => setConfirmRule(null)}
onConfirm={() => confirmRule && onDeleteRule(confirmRule)}
/>
</div>
);
}
/** 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 <p className="text-xs text-fg-muted">{t("rules.previewUnsupported")}</p>;
}
if (preview.periods) {
return (
<div className="rounded-md border border-border bg-surface-1 p-2 text-xs space-y-1">
<p className="text-fg-secondary">{t("rules.previewBudget")}</p>
{preview.periods.map((p) => (
<div key={p.period_start} className="flex justify-between gap-3 font-mono">
<span className="text-fg-muted">{p.period_start}</span>
<span className="text-fg-secondary">
{fmtCost(p.spend)} · {Math.round(p.pct)}%
</span>
<span className={p.crossed.length ? "text-status-warning" : "text-fg-muted"}>
{p.crossed.length ? p.crossed.map((c) => `${c}%`).join(" ") : "—"}
</span>
</div>
))}
</div>
);
}
return (
<div className="rounded-md border border-border bg-surface-1 p-2 text-xs space-y-1">
<p className="text-fg-secondary">
{t("rules.previewResult", {
fires: preview.fires,
sessions: preview.sessions,
days: preview.window_days,
})}
</p>
{preview.samples.map((s, i) => (
<div key={i} className="flex justify-between gap-3 font-mono text-fg-muted">
<Link
to={`/sessions/${encodeURIComponent(s.session_id)}`}
className="truncate hover:text-accent"
>
{s.session_id.slice(0, 8)}
</Link>
<span>{timeAgo(s.at)}</span>
</div>
))}
</div>
);
}