Files
server-26/drb-frontend/app/alerts/page.tsx
T
Logan CusanoandClaude Opus 5.5 f16f0a9f93 fix(frontend): readable API errors and no more silent action failures (#284, #286)
#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>
2026-10-25 21:38:13 -04:00

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>
);
}