Files
Claude-Code-Monitor/client/src/components/AlertsNotifications.tsx
T
nntrivi2001 7357070fb9 chore: remove unused desktop app, cloud deployment infra, and monitoring stack
Deletes desktop/ (Electron wrapper), deployments/ (Helm/Kustomize/
Terraform/CI for cloud deploy), and monitoring/ (Prometheus + Grafana
stack) along with DESKTOP.md, DEPLOYMENT.md, docker-compose.full.yml,
their npm scripts, and every dangling reference across README,
ARCHITECTURE, INSTALL, SETUP, docs/, and the repeated per-file
MODULE_GUIDE "Observability" boilerplate comment. The GET /api/metrics
endpoint itself is untouched — it's the dashboard's own route, not
part of the removed monitoring stack.
2026-08-11 12:16:54 +07:00

842 lines
32 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.
*
* ## 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>
);
}