#286: request() now throws C2ApiError — message is FastAPI's `detail` or a plain sentence for the status (401/403/404/429/5xx), with toString() so the many String(e) call sites read cleanly. status/body stay on the error; the Discord parsers rebuild the legacy "C2 API error N: body" text via legacyErrorText(), so their invite/code parsing is unchanged. #284: Resolve, Regenerate summary, alert-rule toggle/delete/load, Acknowledge, trip deletes, system delete and Restart OP25 now surface failures (ErrorBanner where the page has one, alert() where the page already uses it). Rules tab no longer says "No alert rules configured" when the load failed. Confirms added for Resolve (no reopen path exists), rule delete and trip delete. Reopen deliberately not built — manual resolve doesn't set resolved_at, so reopening is a lifecycle change. Frontend unverified: no Node on the authoring box (#207). Closes #286 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
323 lines
14 KiB
TypeScript
323 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useAuth } from "@/components/AuthProvider";
|
|
import { useAlerts } from "@/lib/useAlerts";
|
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
|
import { c2api } from "@/lib/c2api";
|
|
import { errorMessage } from "@/lib/c2error";
|
|
import { ErrorBanner } from "@/components/ui/EmptyState";
|
|
import type { AlertRule } from "@/lib/types";
|
|
|
|
function fmtTime(iso: string) {
|
|
try { return new Date(iso).toLocaleString(); } catch { return iso; }
|
|
}
|
|
|
|
function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
|
const [rules, setRules] = useState<AlertRule[]>([]);
|
|
const [loaded, setLoaded] = useState(false);
|
|
const [name, setName] = useState("");
|
|
const [keywords, setKeywords] = useState("");
|
|
const [tgIds, setTgIds] = useState("");
|
|
const [webhook, setWebhook] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
// Load and toggle/delete failures (server-26#284) — separate from the create
|
|
// form's own `error`, and it keeps the list from claiming "no rules" when
|
|
// the fetch actually failed.
|
|
const [listError, setListError] = useState<string | null>(null);
|
|
|
|
async function load() {
|
|
if (loaded) return;
|
|
try {
|
|
const data = await c2api.getAlertRules() as AlertRule[];
|
|
setRules(data);
|
|
setLoaded(true);
|
|
setListError(null);
|
|
} catch (e) {
|
|
setListError(`Couldn't load alert rules: ${errorMessage(e)}`);
|
|
}
|
|
}
|
|
|
|
// Load once when this tab mounts (load() self-guards on `loaded`).
|
|
useEffect(() => { load(); }, []);
|
|
|
|
async function handleCreate(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
const body = {
|
|
name,
|
|
keywords: keywords.split(",").map((k) => k.trim()).filter(Boolean),
|
|
talkgroup_ids: tgIds.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n)),
|
|
enabled: true,
|
|
discord_webhook: webhook || null,
|
|
};
|
|
const created = await c2api.createAlertRule(body) as AlertRule;
|
|
setRules((prev) => [created, ...prev]);
|
|
setName(""); setKeywords(""); setTgIds(""); setWebhook("");
|
|
} catch (e) {
|
|
setError(`Failed to create rule: ${errorMessage(e)}`);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleToggle(rule: AlertRule) {
|
|
try {
|
|
await c2api.updateAlertRule(rule.rule_id, { enabled: !rule.enabled });
|
|
setRules((prev) => prev.map((r) => r.rule_id === rule.rule_id ? { ...r, enabled: !r.enabled } : r));
|
|
setListError(null);
|
|
} catch (e) { setListError(`Couldn't update "${rule.name}": ${errorMessage(e)}`); }
|
|
}
|
|
|
|
async function handleDelete(id: string) {
|
|
if (!window.confirm("Delete this alert rule?")) return;
|
|
try {
|
|
await c2api.deleteAlertRule(id);
|
|
setRules((prev) => prev.filter((r) => r.rule_id !== id));
|
|
setListError(null);
|
|
} catch (e) { setListError(`Couldn't delete the rule: ${errorMessage(e)}`); }
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{isAdmin && (
|
|
<form onSubmit={handleCreate} className="bg-surface border border-line rounded-xl p-5 space-y-4">
|
|
<h3 className="text-white font-mono text-sm font-bold">New Alert Rule</h3>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-xs text-ink-2 block mb-1">Name</label>
|
|
<input
|
|
required value={name} onChange={(e) => setName(e.target.value)}
|
|
placeholder="e.g. Structure Fire"
|
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-ink-2 block mb-1">Keywords (comma-separated)</label>
|
|
<input
|
|
value={keywords} onChange={(e) => setKeywords(e.target.value)}
|
|
placeholder="fire, smoke, structure"
|
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-ink-2 block mb-1">Talkgroup IDs (comma-separated)</label>
|
|
<input
|
|
value={tgIds} onChange={(e) => setTgIds(e.target.value)}
|
|
placeholder="9048, 9600"
|
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-ink-2 block mb-1">Discord Webhook URL (optional)</label>
|
|
<input
|
|
value={webhook} onChange={(e) => setWebhook(e.target.value)}
|
|
placeholder="https://discord.com/api/webhooks/…"
|
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
|
<button
|
|
type="submit" disabled={saving}
|
|
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm rounded-lg px-4 py-2 transition-colors"
|
|
>
|
|
{saving ? "Creating…" : "Create Rule"}
|
|
</button>
|
|
</form>
|
|
)}
|
|
|
|
{listError && <ErrorBanner message={listError} />}
|
|
|
|
<div className="bg-surface border border-line rounded-xl overflow-hidden">
|
|
{!loaded ? (
|
|
<p className="text-ink-muted text-sm font-mono p-4">{listError ? "Rules unavailable." : "Loading…"}</p>
|
|
) : rules.length === 0 ? (
|
|
<p className="text-ink-muted text-sm font-mono p-4">No alert rules configured.</p>
|
|
) : (
|
|
<table className="w-full text-left">
|
|
<thead>
|
|
<tr className="border-b border-line text-xs text-ink-muted uppercase">
|
|
<th className="px-4 py-3">Name</th>
|
|
<th className="px-4 py-3">Keywords</th>
|
|
<th className="px-4 py-3">Talkgroups</th>
|
|
<th className="px-4 py-3">Webhook</th>
|
|
<th className="px-4 py-3">Enabled</th>
|
|
{isAdmin && <th className="px-4 py-3"></th>}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rules.map((rule) => (
|
|
<tr key={rule.rule_id} className="border-b border-line">
|
|
<td className="px-4 py-3 text-white text-sm">{rule.name}</td>
|
|
<td className="px-4 py-3 text-ink-2 text-xs font-mono">{rule.keywords.join(", ") || "—"}</td>
|
|
<td className="px-4 py-3 text-ink-2 text-xs font-mono">{rule.talkgroup_ids.join(", ") || "—"}</td>
|
|
<td className="px-4 py-3 text-ink-2 text-xs">
|
|
{rule.discord_webhook ? (
|
|
<span className="text-green-400">configured</span>
|
|
) : "—"}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{isAdmin ? (
|
|
<button
|
|
onClick={() => handleToggle(rule)}
|
|
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
|
|
rule.enabled
|
|
? "bg-green-900 text-green-300 hover:bg-green-800"
|
|
: "bg-raised text-ink-2 hover:bg-gray-700"
|
|
}`}
|
|
>
|
|
{rule.enabled ? "enabled" : "disabled"}
|
|
</button>
|
|
) : (
|
|
<span className={`text-xs ${rule.enabled ? "text-green-400" : "text-ink-muted"}`}>
|
|
{rule.enabled ? "enabled" : "disabled"}
|
|
</span>
|
|
)}
|
|
</td>
|
|
{isAdmin && (
|
|
<td className="px-4 py-3">
|
|
<button
|
|
onClick={() => handleDelete(rule.rule_id)}
|
|
className="text-xs text-red-400 hover:text-red-300 transition-colors"
|
|
>
|
|
Delete
|
|
</button>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function AlertsPage() {
|
|
const { isAdmin } = useAuth();
|
|
const { alerts, loading, error } = useAlerts();
|
|
const [tab, setTab] = useState<"events" | "rules">("events");
|
|
const [ackError, setAckError] = useState<string | null>(null);
|
|
|
|
async function handleAcknowledge(id: string) {
|
|
setAckError(null);
|
|
try {
|
|
await c2api.acknowledgeAlert(id);
|
|
} catch (e) { setAckError(`Couldn't acknowledge: ${errorMessage(e)}`); }
|
|
}
|
|
|
|
const unacked = alerts.filter((a) => !a.acknowledged);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-4">
|
|
<h1 className="text-white text-xl font-bold font-mono">Alerts</h1>
|
|
{unacked.length > 0 && (
|
|
<span className="text-xs bg-red-900 text-red-300 px-2 py-0.5 rounded-full font-mono">
|
|
{unacked.length} unacknowledged
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
|
|
{(["events", ...(isAdmin ? ["rules"] : [])] as const).map((t) => (
|
|
<button
|
|
key={t}
|
|
onClick={() => setTab(t as "events" | "rules")}
|
|
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors capitalize ${
|
|
tab === t ? "bg-raised text-white" : "text-ink-muted hover:text-ink"
|
|
}`}
|
|
>
|
|
{t === "events" ? "Triggered Alerts" : "Rules"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{tab === "events" && ackError && <ErrorBanner message={ackError} />}
|
|
|
|
{tab === "events" && (
|
|
loading ? (
|
|
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
|
) : error ? (
|
|
<p className="text-red-400 text-sm font-mono">
|
|
{/requires an index|PERMISSION_DENIED|insufficient permissions/i.test(error)
|
|
? "Couldn't load alerts — a database index or security rule isn't deployed on the server yet (server-26 #13 / #51)."
|
|
: `Couldn't load alerts: ${error}`}
|
|
</p>
|
|
) : alerts.length === 0 ? (
|
|
<p className="text-ink-muted text-sm font-mono">No alerts triggered yet.</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{/* Gate A / A2 (server-26#46) — the Snippet column is transcript text,
|
|
and the keyword match that fired the alert was made against it. */}
|
|
<MachineOutputNotice
|
|
variant="inline"
|
|
detail="alerts match against automated transcripts and may fire on, or miss, the wrong words."
|
|
/>
|
|
<div className="bg-surface border border-line rounded-xl overflow-hidden">
|
|
<table className="w-full text-left">
|
|
<thead>
|
|
<tr className="border-b border-line text-xs text-ink-muted uppercase">
|
|
<th className="px-4 py-3">Rule</th>
|
|
<th className="px-4 py-3">Talkgroup</th>
|
|
<th className="px-4 py-3">Matched</th>
|
|
<th className="px-4 py-3">Snippet</th>
|
|
<th className="px-4 py-3">Time</th>
|
|
<th className="px-4 py-3">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{alerts.map((alert) => (
|
|
<tr
|
|
key={alert.alert_id}
|
|
className={`border-b border-line ${alert.acknowledged ? "opacity-50" : ""}`}
|
|
>
|
|
<td className="px-4 py-3 text-white text-sm">{alert.rule_name}</td>
|
|
<td className="px-4 py-3 text-ink-2 text-xs font-mono">
|
|
{alert.talkgroup_name || alert.talkgroup_id || "—"}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex flex-wrap gap-1">
|
|
{alert.matched_keywords.map((kw) => (
|
|
<span key={kw} className="text-xs bg-red-900 text-red-300 px-1.5 py-0.5 rounded">
|
|
{kw}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 text-ink-2 text-xs font-mono max-w-xs truncate">
|
|
{alert.transcript_snippet || "—"}
|
|
</td>
|
|
<td className="px-4 py-3 text-ink-2 text-xs font-mono">{fmtTime(alert.triggered_at)}</td>
|
|
<td className="px-4 py-3">
|
|
{alert.acknowledged ? (
|
|
<span className="text-xs text-ink-muted">acked</span>
|
|
) : (
|
|
<button
|
|
onClick={() => handleAcknowledge(alert.alert_id)}
|
|
className="text-xs bg-raised hover:bg-gray-700 text-ink-2 px-2 py-1 rounded transition-colors"
|
|
>
|
|
Acknowledge
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
)}
|
|
|
|
{tab === "rules" && <RulesTab isAdmin={isAdmin} />}
|
|
</div>
|
|
);
|
|
}
|