b673363351
Adds a working Dark/Light toggle (next to the language switcher, same row
as EN/VI) and re-themes the whole dashboard, not just the handful of
components that already used semantic tokens.
- Tailwind darkMode:"class" + CSS-variable color tokens (client/src/index.css,
tailwind.config.js): surface.0-5, border/border-light, accent/accent-hover,
fg.primary/secondary/muted, status.success/danger/warning. One class flip
on <html> re-themes everything — no per-element dark: variant pairs.
- useTheme() hook: localStorage-persisted, defaults to dark, no
prefers-color-scheme fallback (client/src/hooks/useTheme.ts).
- Mechanical, table-driven migration (scripts/migrate-color-tokens.mjs,
scripts/tokenize-status-colors.mjs, scripts/darken-status-colors.mjs) of
every raw neutral/gray/slate + emerald/red/amber Tailwind utility across
client/src onto the new tokens, so every badge/button/component pulls the
same shade per status/role instead of each picking its own.
- Palette values are the literal Radix Colors (radix-ui.com/colors) scale
constants — slate/blue/green/red/amber steps 1-12 — adopted after three
rounds of hand-picked values that kept overshooting (flat, then too dark,
then glaring); see docs/superpowers/specs/2026-07-31-color-redesign-
dark-light-mode-design.md for the full history and role mapping.
- PipelineMap: done/current/failed/passed-no-evidence/detected share one
visual language (border + text + translucent wash of the same status
color); `current` alone stays a solid accent fill, the one state that
gets to look bolder ("you are here").
- LaneCard: removed the stage/kind/auto-stage chips that duplicated the
Workspace lane-detail header already showing them.
Categorical/decorative hues (violet, indigo, cyan, teal, sky, rose, pink,
orange, yellow, and blue where it plays a role-coloring part e.g. message
bubbles) are deliberately out of scope — collapsing those onto shared
tokens would erase the distinction between different kinds of thing, not
a status.
847 lines
33 KiB
TypeScript
847 lines
33 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.
|
|
* @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.
|
|
*
|
|
* ## 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, 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() });
|
|
}
|
|
}
|
|
|
|
export function AlertsNotifications() {
|
|
const { t } = useTranslation("alerts");
|
|
const { t: ts } = useTranslation("settings");
|
|
|
|
const [tab, setTab] = useState<TabKey>("rules");
|
|
|
|
// Rules
|
|
const [rules, setRules] = useState<AlertRule[]>([]);
|
|
const [loadingRules, setLoadingRules] = useState(true);
|
|
const [formOpen, setFormOpen] = useState(false);
|
|
const [form, setForm] = useState<RuleFormState>(EMPTY_FORM);
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [confirmRule, setConfirmRule] = useState<AlertRule | null>(null);
|
|
|
|
// 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) {
|
|
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<RuleFormState>) => 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 (
|
|
<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={() => {
|
|
setFormOpen((open) => !open);
|
|
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 })}
|
|
className="input w-full appearance-none pr-8"
|
|
>
|
|
{RULE_TYPES.map((type) => (
|
|
<option key={type} value={type}>
|
|
{t(`ruleTypes.${type}`)}
|
|
</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>
|
|
|
|
<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="input w-full appearance-none pr-8"
|
|
>
|
|
<option value="working">working</option>
|
|
<option value="waiting">waiting</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.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>
|
|
)}
|
|
|
|
<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}
|
|
value={form.cooldown_seconds}
|
|
onChange={(e) => set({ cooldown_seconds: e.target.value })}
|
|
className="input w-40"
|
|
/>
|
|
</label>
|
|
<button
|
|
onClick={onCreateRule}
|
|
disabled={!canSubmit || saving}
|
|
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<Plus className="w-3.5 h-3.5" />
|
|
{saving ? t("rules.saving") : t("rules.create")}
|
|
</button>
|
|
</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")}
|
|
/>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{rules.map((rule) => (
|
|
<li
|
|
key={rule.id}
|
|
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={() => 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>
|
|
)}
|
|
</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={() => loadAlerts()}
|
|
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" />
|
|
</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")}
|
|
/>
|
|
) : (
|
|
<>
|
|
<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 && (
|
|
<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>
|
|
);
|
|
}
|