Files
server-26/drb-frontend/app/systems/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

2045 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, useState, Fragment } from "react";
import { useRouter } from "next/navigation";
import { useSystems } from "@/lib/useSystems";
import { c2api } from "@/lib/c2api";
import { useAuth } from "@/components/AuthProvider";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import type {
AreaContext,
LocalKnowledgeEntry,
SystemRecord,
TalkgroupPending,
VocabularyPendingTerm,
} from "@/lib/types";
// ── P25 structured config types ───────────────────────────────────────────────
interface TalkgroupEntry {
id: string;
name: string;
tag: string;
// Local knowledge for the transcript corrector (server-26#36). Optional on
// every talkgroup: unset means "inherit the system's", which is the whole
// point of the scope rule — talkgroup narrows, it does not replace.
vocabulary?: string[];
area_context?: AreaContext;
}
/** Comma-separated text field <-> string[], the shape the API stores. */
const listToText = (v?: string[]) => (v ?? []).join(", ");
const textToList = (v: string) =>
v.split(",").map((x) => x.trim()).filter(Boolean);
/**
* Local knowledge is one entry per line, `term — meaning`.
*
* A bare term is valid — half the information is still worth having, and
* forcing a meaning would make people invent one. An em dash, a spaced hyphen
* or an equals sign all separate; whichever comes first wins.
*/
const knowledgeToText = (v?: LocalKnowledgeEntry[]) =>
(v ?? []).map((e) => (e.meaning ? `${e.term} — ${e.meaning}` : e.term)).join("\n");
function textToKnowledge(v: string): LocalKnowledgeEntry[] {
const out: LocalKnowledgeEntry[] = [];
for (const line of v.split("\n")) {
const match = line.match(/^(.*?)\s*(?:—|–|\s-\s|=)\s*(.*)$/);
const term = (match ? match[1] : line).trim();
const meaning = match ? match[2].trim() : "";
if (term) out.push(meaning ? { term, meaning } : { term });
}
return out;
}
/**
* Fold the pre-#36 shape forward on load.
*
* `roads[]` and `landmarks[]` were the original fields and real systems still
* have them stored. Showing them as local-knowledge lines means an operator
* sees what they already entered instead of an empty box, and the next save
* writes them in the new shape.
*/
function migrateArea(a?: AreaContext & { roads?: string[]; landmarks?: string[] }): AreaContext {
if (!a) return {};
const legacy = [...(a.roads ?? []), ...(a.landmarks ?? [])].map((term) => ({ term }));
if (!legacy.length) return a;
const seen = new Set((a.local_knowledge ?? []).map((e) => e.term.toLowerCase()));
const { roads: _roads, landmarks: _landmarks, ...rest } = a;
return {
...rest,
local_knowledge: [
...(a.local_knowledge ?? []),
...legacy.filter((e) => !seen.has(e.term.toLowerCase())),
],
};
}
/**
* Drop an area_context whose every field is blank, so we don't store noise.
*
* Also strips the derived anchor: `center`/`radius_km`/`resolved_*` belong to
* the backend, which geocodes them from the place and merges them back. Sending
* them up would be the frontend deciding what is in a system document.
*/
function cleanArea(a?: AreaContext): AreaContext | undefined {
if (!a) return undefined;
const out: AreaContext = {};
if (a.municipality?.trim()) out.municipality = a.municipality.trim();
if (a.county?.trim()) out.county = a.county.trim();
if (a.state?.trim()) out.state = a.state.trim();
if (a.local_knowledge?.length) out.local_knowledge = a.local_knowledge;
return Object.keys(out).length ? out : undefined;
}
/** What the backend resolved this area to, in one line. */
function anchorLabel(a?: AreaContext): string {
if (!a) return "";
const place = [a.municipality, a.county, a.state].filter(Boolean).join(", ");
if (!place) return "no area set — location checking is off for this scope";
if (a.center && a.radius_km) return `anchored to ${place}, ${Math.round(a.radius_km)} km radius`;
if (a.resolved_from) return `${place} — too wide to check locations against, so that check is skipped`;
return `${place} — not yet resolved`;
}
// ── System kinds (node-26#24) ─────────────────────────────────────────────────
// One choice in the form; stored as `type` plus, for P25, `config.mode`.
// A P25 system with no mode is trunked — every system saved before this.
type SystemKind = "p25_trunked" | "p25_conventional" | "NBFM" | "DMR";
const KIND_OPTIONS: { value: SystemKind; label: string }[] = [
{ value: "p25_trunked", label: "P25 trunked" },
{ value: "p25_conventional", label: "P25 conventional" },
{ value: "NBFM", label: "Analog (NBFM)" },
{ value: "DMR", label: "DMR" },
];
function kindOf(type: string | undefined, config?: Record<string, unknown>): SystemKind {
if (type === "NBFM" || type === "DMR") return type;
return config?.mode === "conventional" ? "p25_conventional" : "p25_trunked";
}
function typeOf(kind: SystemKind): string {
return kind === "p25_trunked" || kind === "p25_conventional" ? "P25" : kind;
}
function kindLabel(s: SystemRecord): string {
const k = kindOf(s.type, s.config);
return KIND_OPTIONS.find((o) => o.value === k)?.label ?? s.type;
}
/** One fixed voice channel: P25 conventional (up to 2) or analog (exactly 1). */
interface ChannelEntry {
freq: string; // MHz
name: string;
nac: string; // hex, P25 conventional only; blank = any
}
const MAX_CONVENTIONAL_CHANNELS = 2;
function recordToChannels(c: Record<string, unknown>): ChannelEntry[] {
if (Array.isArray(c.channels)) {
return (c.channels as Array<{ freq?: number | string; name?: string; nac?: string | number }>).map((ch) => ({
freq: ch.freq != null ? String(ch.freq) : "",
name: ch.name ?? "",
nac: ch.nac != null ? String(ch.nac) : "",
}));
}
return [];
}
function channelsToRecord(chs: ChannelEntry[], withNac: boolean) {
return chs
.filter((ch) => ch.freq.trim() && !isNaN(parseFloat(ch.freq)))
.map((ch) => ({
freq: parseFloat(ch.freq),
name: ch.name.trim(),
...(withNac && ch.nac.trim() ? { nac: ch.nac.trim() } : {}),
}));
}
interface P25Config {
nac: string;
system_id: string;
wacn: string;
control_channels: string;
voice_channels: string;
channels: ChannelEntry[]; // conventional only
talkgroups: TalkgroupEntry[];
}
const DEFAULT_P25: P25Config = {
nac: "",
system_id: "",
wacn: "",
control_channels: "",
voice_channels: "",
channels: [{ freq: "", name: "", nac: "" }],
talkgroups: [],
};
const TG_TAGS = ["fire", "police", "ems", "transit", "public works", "other"];
function recordToP25Config(c: Record<string, unknown>): P25Config {
return {
nac: String(c.nac ?? ""),
system_id: String(c.system_id ?? ""),
wacn: String(c.wacn ?? ""),
control_channels: Array.isArray(c.control_channels)
? (c.control_channels as number[]).join(", ")
: "",
voice_channels: Array.isArray(c.voice_channels)
? (c.voice_channels as number[]).join(", ")
: "",
channels: Array.isArray(c.channels) && c.channels.length
? recordToChannels(c)
: [{ freq: "", name: "", nac: "" }],
talkgroups: Array.isArray(c.talkgroups)
? (c.talkgroups as Array<{
id: number; name: string; tag: string;
vocabulary?: string[]; area_context?: AreaContext;
}>).map((tg) => ({
id: String(tg.id),
name: tg.name,
tag: tg.tag ?? "other",
vocabulary: tg.vocabulary ?? [],
area_context: migrateArea(tg.area_context),
}))
: [],
};
}
function p25ConfigToRecord(p: P25Config, conventional = false): Record<string, unknown> {
const parseFreqs = (s: string) =>
s
.split(",")
.map((f) => parseFloat(f.trim()))
.filter((f) => !isNaN(f));
const talkgroups = talkgroupsToRecord(p.talkgroups);
if (conventional) {
// No control/voice channel lists: the server rejects a conventional
// system that carries control channels (node-26#24).
return { mode: "conventional", channels: channelsToRecord(p.channels, true), talkgroups };
}
return {
nac: p.nac,
system_id: p.system_id ? parseInt(p.system_id, 10) : undefined,
wacn: p.wacn,
control_channels: parseFreqs(p.control_channels),
voice_channels: parseFreqs(p.voice_channels),
talkgroups,
};
}
function talkgroupsToRecord(tgs: TalkgroupEntry[]) {
return tgs
.filter((tg) => tg.id && tg.name)
.map((tg) => {
const area = cleanArea(tg.area_context);
const vocab = (tg.vocabulary ?? []).filter(Boolean);
return {
id: parseInt(tg.id, 10),
name: tg.name,
tag: tg.tag,
// Omitted rather than written empty: an absent key is what
// resolve_context() reads as "inherit from the system".
...(vocab.length ? { vocabulary: vocab } : {}),
...(area ? { area_context: area } : {}),
};
});
}
interface AnalogConfig {
channel: ChannelEntry;
}
function recordToAnalog(c: Record<string, unknown>): AnalogConfig {
const chs = recordToChannels(c);
if (chs.length) return { channel: chs[0] };
// Saved before node-26#24: the one frequency lived in control_channels.
const legacy = Array.isArray(c.control_channels) ? (c.control_channels as number[])[0] : undefined;
return { channel: { freq: legacy != null ? String(legacy) : "", name: "", nac: "" } };
}
function analogToRecord(a: AnalogConfig): Record<string, unknown> {
if (isNaN(parseFloat(a.channel.freq))) {
throw new Error("Enter the analog frequency in MHz, e.g. 154.4300");
}
// The server mirrors the frequency into control_channels for older nodes.
return { channels: channelsToRecord([a.channel], false), talkgroups: [] };
}
// ── RadioReference parser types ───────────────────────────────────────────────
interface RRTalkgroup {
dec: number;
alphaTag: string;
description: string;
tag: string;
}
interface RRCategory {
name: string;
talkgroups: RRTalkgroup[];
}
interface RRSystem {
name: string;
location: string;
sysIds: string;
systemType: string;
categories: RRCategory[];
}
function mapRRTag(rrTag: string): string {
const t = rrTag.toLowerCase();
if (t.includes("fire")) return "fire";
if (t.includes("law") || t.includes("police")) return "police";
if (t.includes("ems") || t.includes("emergency medical")) return "ems";
if (t.includes("transport") || t.includes("transit")) return "transit";
if (t.includes("public works")) return "public works";
return "other";
}
function parseRadioReference(html: string): RRSystem | null {
const doc = new DOMParser().parseFromString(html, "text/html");
// Validate: RadioReference system pages have rrlblue header cells
if (!doc.querySelector(".rrlblue")) return null;
// System info table (first table with rrlblue headers)
const infoMap: Record<string, string> = {};
const infoTable = doc.querySelector("table.table-sm.table-bordered");
if (infoTable) {
infoTable.querySelectorAll("tr").forEach((row) => {
const th = row.querySelector("th.rrlblue");
const td = row.querySelector("td");
if (th && td) infoMap[th.textContent?.trim() ?? ""] = td.textContent?.trim() ?? "";
});
}
const name = infoMap["System Name"] ?? doc.title ?? "Unknown System";
const location = infoMap["Location"] ?? "";
const sysIds = infoMap["System IDs"] ?? "";
const systemType = infoMap["System Type"] ?? "";
// Talkgroup tables — find all with class rrdbTable or datatable-lite
// For each, find the nearest preceding h5 to use as category name
const tgTables = Array.from(
doc.querySelectorAll("table.rrdbTable, table.datatable-lite")
) as HTMLTableElement[];
const allH5s = Array.from(doc.querySelectorAll("h5")) as HTMLElement[];
function categoryForTable(table: HTMLTableElement): string {
// Find the last h5 that appears before this table in document order
let best: HTMLElement | null = null;
for (const h5 of allH5s) {
const pos = h5.compareDocumentPosition(table);
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) best = h5;
}
if (!best) return "Uncategorized";
const clone = best.cloneNode(true) as HTMLElement;
clone.querySelectorAll("div, button, span.badge").forEach((el) => el.remove());
return clone.textContent?.trim() || "Uncategorized";
}
const categories: RRCategory[] = [];
for (const table of tgTables) {
// Confirm it has the expected talkgroup columns (DEC, HEX, Mode, Alpha Tag, …)
const headers = Array.from(table.querySelectorAll("thead th")).map((th) =>
th.textContent?.trim().toLowerCase()
);
if (!headers.includes("dec") && !headers.includes("hex")) continue;
const catName = categoryForTable(table);
const talkgroups: RRTalkgroup[] = [];
table.querySelectorAll("tbody tr").forEach((row) => {
const cells = Array.from(row.querySelectorAll("td"));
if (cells.length < 6) return;
// DEC cell may wrap in a Broadcastify link
const decText =
cells[0].querySelector("a")?.textContent?.trim() ??
cells[0].textContent?.trim() ??
"";
const dec = parseInt(decText.replace(/\D/g, ""), 10);
if (isNaN(dec)) return;
const alphaTag = cells[3].textContent?.trim() ?? "";
const description = cells[4].textContent?.trim() ?? "";
const tag = cells[5].textContent?.trim() ?? "";
talkgroups.push({ dec, alphaTag, description, tag });
});
if (talkgroups.length > 0) {
// Merge into an existing category with same name if present
const existing = categories.find((c) => c.name === catName);
if (existing) {
existing.talkgroups.push(...talkgroups);
} else {
categories.push({ name: catName, talkgroups });
}
}
}
if (categories.length === 0) return null;
return { name, location, sysIds, systemType, categories };
}
// ── RadioReference import modal ───────────────────────────────────────────────
function RRImportModal({
system,
onImport,
onCancel,
}: {
system: RRSystem;
onImport: (tgs: TalkgroupEntry[]) => void;
onCancel: () => void;
}) {
const [selected, setSelected] = useState<Set<string>>(
() => new Set(system.categories.map((c) => c.name))
);
function toggle(name: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name); else next.add(name);
return next;
});
}
function handleImport() {
const tgs: TalkgroupEntry[] = [];
for (const cat of system.categories) {
if (!selected.has(cat.name)) continue;
for (const tg of cat.talkgroups) {
tgs.push({
id: String(tg.dec),
name: `${cat.name.split(" - ")[0]} - ${tg.description || tg.alphaTag}`,
tag: mapRRTag(tg.tag),
});
}
}
onImport(tgs);
}
const total = system.categories.reduce((s, c) => s + c.talkgroups.length, 0);
const selectedCount = system.categories
.filter((c) => selected.has(c.name))
.reduce((s, c) => s + c.talkgroups.length, 0);
return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div className="bg-surface border border-line-strong rounded-xl w-full max-w-lg max-h-[90vh] flex flex-col font-mono">
{/* Header */}
<div className="px-5 pt-5 pb-4 border-b border-line">
<h2 className="text-white font-semibold">{system.name}</h2>
<p className="text-xs text-ink-muted mt-0.5">
{system.systemType}{system.location ? ` · ${system.location}` : ""}
</p>
{system.sysIds && (
<p className="text-xs text-ink-muted mt-0.5">System IDs: {system.sysIds}</p>
)}
<p className="text-xs text-ink-muted mt-1">
{system.categories.length} categor{system.categories.length !== 1 ? "ies" : "y"} · {total} talkgroups
</p>
</div>
{/* Category list */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1.5">
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-ink-2 uppercase tracking-wider">Talkgroup Categories</p>
<div className="flex gap-3 text-xs text-indigo-400">
<button
type="button"
onClick={() => setSelected(new Set(system.categories.map((c) => c.name)))}
className="hover:text-indigo-300 transition-colors"
>
All
</button>
<button
type="button"
onClick={() => setSelected(new Set())}
className="hover:text-indigo-300 transition-colors"
>
None
</button>
</div>
</div>
{system.categories.map((cat) => (
<label key={cat.name} className="flex items-center gap-3 cursor-pointer group py-0.5">
<input
type="checkbox"
checked={selected.has(cat.name)}
onChange={() => toggle(cat.name)}
className="rounded border-line-strong bg-raised accent-indigo-500"
/>
<span className="text-sm text-ink flex-1 group-hover:text-white transition-colors truncate">
{cat.name}
</span>
<span className="text-xs text-ink-muted shrink-0">{cat.talkgroups.length} TGs</span>
</label>
))}
</div>
{/* Footer */}
<div className="px-5 pb-5 pt-4 border-t border-line flex gap-3">
<button
type="button"
onClick={handleImport}
disabled={selectedCount === 0}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
>
Import {selectedCount} talkgroup{selectedCount !== 1 ? "s" : ""}
</button>
<button
type="button"
onClick={onCancel}
className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
);
}
/** True when this talkgroup overrides anything, so the row can say so. */
function hasLocalKnowledge(tg: TalkgroupEntry): boolean {
return Boolean((tg.vocabulary ?? []).length || cleanArea(tg.area_context));
}
/** One labelled field in the local-knowledge grid. */
function LocalKnowledgeField({
label,
value,
onChange,
placeholder,
multiline,
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
multiline?: boolean;
}) {
const cls =
"w-full mt-0.5 bg-surface border border-line-strong rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-indigo-500";
return (
<label className="block">
<span className="text-xs text-ink-muted font-sans">{label}</span>
{multiline ? (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={4}
className={`${cls} font-mono resize-y`}
/>
) : (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={cls}
/>
)}
</label>
);
}
// ── Talkgroup table editor ────────────────────────────────────────────────────
function TalkgroupEditor({
talkgroups,
onChange,
}: {
talkgroups: TalkgroupEntry[];
onChange: (tgs: TalkgroupEntry[]) => void;
}) {
const [showPaste, setShowPaste] = useState(false);
const [pasteText, setPasteText] = useState("");
const [rrSystem, setRrSystem] = useState<RRSystem | null>(null);
const [rrError, setRrError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<number | null>(null);
const rrInputRef = useRef<HTMLInputElement>(null);
function addRow() {
onChange([...talkgroups, { id: "", name: "", tag: "other" }]);
}
function updateArea(i: number, field: "municipality" | "county" | "state", value: string) {
const updated = [...talkgroups];
updated[i] = { ...updated[i], area_context: { ...updated[i].area_context, [field]: value } };
onChange(updated);
}
function updateAreaKnowledge(i: number, value: string) {
const updated = [...talkgroups];
updated[i] = {
...updated[i],
area_context: { ...updated[i].area_context, local_knowledge: textToKnowledge(value) },
};
onChange(updated);
}
function updateRowList(i: number, field: "vocabulary", value: string) {
const updated = [...talkgroups];
updated[i] = { ...updated[i], [field]: textToList(value) };
onChange(updated);
}
function removeRow(i: number) {
onChange(talkgroups.filter((_, idx) => idx !== i));
}
function updateRow(i: number, field: keyof TalkgroupEntry, value: string) {
const updated = [...talkgroups];
updated[i] = { ...updated[i], [field]: value };
onChange(updated);
}
function handlePasteImport() {
const lines = pasteText.trim().split("\n");
const parsed: TalkgroupEntry[] = lines
.map((line) => {
const parts = line.includes("\t") ? line.split("\t") : line.split(",");
const id = parts[0]?.trim() ?? "";
const name = parts[1]?.trim() ?? "";
const rawTag = parts[2]?.trim()?.toLowerCase() ?? "other";
const tag = TG_TAGS.includes(rawTag) ? rawTag : "other";
return { id, name, tag };
})
.filter((e) => e.id && e.name);
onChange([...talkgroups, ...parsed]);
setPasteText("");
setShowPaste(false);
}
async function handleRRFile(file: File) {
setRrError(null);
const html = await file.text();
const parsed = parseRadioReference(html);
if (!parsed) {
setRrError(
"This doesn't look like a RadioReference trunked system page. " +
"Download the HTML from a system page on radioreference.com and try again."
);
return;
}
setRrSystem(parsed);
}
function handleRRImport(newTgs: TalkgroupEntry[]) {
onChange([...talkgroups, ...newTgs]);
setRrSystem(null);
setRrError(null);
}
return (
<div className="space-y-2">
{rrSystem && (
<RRImportModal
system={rrSystem}
onImport={handleRRImport}
onCancel={() => setRrSystem(null)}
/>
)}
<div className="flex items-center justify-between">
<label className="text-xs text-ink-2">
Talkgroups{talkgroups.length > 0 && <span className="text-ink-muted ml-1">({talkgroups.length})</span>}
</label>
<div className="flex gap-3">
{/* RadioReference file import */}
<button
type="button"
onClick={() => rrInputRef.current?.click()}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
RadioReference import
</button>
<input
ref={rrInputRef}
type="file"
accept=".html,.htm"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleRRFile(f);
e.target.value = "";
}}
/>
<button
type="button"
onClick={() => setShowPaste(!showPaste)}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
{showPaste ? "Cancel paste" : "Paste import"}
</button>
<button
type="button"
onClick={addRow}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
+ Add row
</button>
</div>
</div>
{rrError && (
<p className="text-xs text-red-400 bg-red-950/30 border border-red-900/50 rounded px-3 py-2">
{rrError}
</p>
)}
{showPaste && (
<div className="space-y-2 p-3 bg-raised rounded-lg border border-line-strong">
<p className="text-xs text-ink-muted">
Paste rows from RadioReference — tab- or comma-separated:{" "}
<span className="text-ink-2">ID, Name, Tag</span>
<br />Tags: fire · police · ems · transit · public works · other
</p>
<textarea
value={pasteText}
onChange={(e) => setPasteText(e.target.value)}
rows={6}
placeholder={"1234\tFire Dispatch\tfire\n5678\tPolice Zone 1\tpolice\n9012\tEMS\tems"}
className="w-full bg-surface border border-line-strong rounded px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
/>
<button
type="button"
onClick={handlePasteImport}
disabled={!pasteText.trim()}
className="bg-indigo-700 hover:bg-indigo-600 disabled:opacity-50 text-white px-3 py-1.5 rounded text-xs font-semibold transition-colors"
>
Import rows
</button>
</div>
)}
{talkgroups.length > 0 ? (
<div className="border border-line rounded-lg overflow-hidden">
<table className="w-full text-xs font-mono">
<thead>
<tr className="bg-raised text-ink-2">
<th className="px-3 py-1.5 text-left w-20">Dec ID</th>
<th className="px-3 py-1.5 text-left">Name</th>
<th className="px-3 py-1.5 text-left w-28">Tag</th>
<th className="px-3 py-1.5 w-8"></th>
</tr>
</thead>
<tbody>
{talkgroups.map((tg, i) => (
<Fragment key={i}>
<tr className="border-t border-line hover:bg-raised/30">
<td className="px-2 py-1">
<input
value={tg.id}
onChange={(e) => updateRow(i, "id", e.target.value)}
className="w-full bg-transparent text-white focus:outline-none"
placeholder="1234"
/>
</td>
<td className="px-2 py-1">
<input
value={tg.name}
onChange={(e) => updateRow(i, "name", e.target.value)}
className="w-full bg-transparent text-white focus:outline-none"
placeholder="Fire Dispatch"
/>
</td>
<td className="px-2 py-1">
<select
value={tg.tag}
onChange={(e) => updateRow(i, "tag", e.target.value)}
className="w-full bg-surface text-ink-2 focus:outline-none rounded px-1"
>
{TG_TAGS.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</td>
<td className="px-2 py-1 text-center">
<button
type="button"
title="Local knowledge for the transcript corrector"
onClick={() => setExpanded(expanded === i ? null : i)}
className={`transition-colors font-bold mr-2 ${
hasLocalKnowledge(tg) ? "text-indigo-400" : "text-ink-muted hover:text-ink"
}`}
>
{expanded === i ? "▾" : "▸"}
</button>
<button
type="button"
onClick={() => removeRow(i)}
className="text-ink-muted hover:text-red-400 transition-colors font-bold"
>
×
</button>
</td>
</tr>
{/* Local knowledge, collapsed by default: a system can carry 125
talkgroups and most inherit the system's context rather than
override it. */}
{expanded === i && (
<tr className="border-t border-line bg-surface/60">
<td colSpan={4} className="px-3 py-3">
<p className="text-xs text-ink-muted mb-2 font-sans">
Given to the transcript corrector for this talkgroup only, ranked
<span className="text-ink-2"> above</span> the system&apos;s own list.
Leave blank to inherit the system&apos;s.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<LocalKnowledgeField
label="Municipality"
value={tg.area_context?.municipality ?? ""}
onChange={(v) => updateArea(i, "municipality", v)}
placeholder="Ossining"
/>
<LocalKnowledgeField
label="County"
value={tg.area_context?.county ?? ""}
onChange={(v) => updateArea(i, "county", v)}
placeholder="Westchester"
/>
<LocalKnowledgeField
label="State"
value={tg.area_context?.state ?? ""}
onChange={(v) => updateArea(i, "state", v)}
placeholder="New York"
/>
<div className="md:col-span-2">
<LocalKnowledgeField
label="Local knowledge — one per line, term — what it is"
multiline
value={knowledgeToText(tg.area_context?.local_knowledge)}
onChange={(v) => updateAreaKnowledge(i, v)}
placeholder={
"Snowden Avenue — residential street\nSing Sing — state prison\n11-X-ray — MTA PD patrol unit"
}
/>
</div>
<div className="md:col-span-2">
<LocalKnowledgeField
label="Unit call signs & local terms"
value={listToText(tg.vocabulary)}
onChange={(v) => updateRowList(i, "vocabulary", v)}
placeholder="Post 4, 11-X-ray, Car 7"
/>
</div>
</div>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
) : (
<p className="text-xs text-ink-muted italic py-1">
No talkgroups — add rows, paste from RadioReference, or use the RadioReference import button.
</p>
)}
</div>
);
}
// ── P25 structured form ───────────────────────────────────────────────────────
function P25Form({ value, onChange }: { value: P25Config; onChange: (v: P25Config) => void }) {
return (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs text-ink-2 block mb-1">NAC (hex)</label>
<input
value={value.nac}
onChange={(e) => onChange({ ...value, nac: e.target.value })}
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="0x293"
/>
</div>
<div>
<label className="text-xs text-ink-2 block mb-1">System ID</label>
<input
value={value.system_id}
onChange={(e) => onChange({ ...value, system_id: e.target.value })}
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="50513"
/>
</div>
<div>
<label className="text-xs text-ink-2 block mb-1">WACN (hex)</label>
<input
value={value.wacn}
onChange={(e) => onChange({ ...value, wacn: e.target.value })}
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="0xBEE00"
/>
</div>
</div>
<div>
<label className="text-xs text-ink-2 block mb-1">
Control Channels <span className="text-ink-muted">(MHz, comma-separated)</span>
</label>
<input
value={value.control_channels}
onChange={(e) => onChange({ ...value, control_channels: e.target.value })}
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="851.0125, 851.5125, 852.0125"
/>
</div>
<div>
<label className="text-xs text-ink-2 block mb-1">
Voice Channels <span className="text-ink-muted">(MHz, comma-separated — leave blank for auto-discovery)</span>
</label>
<input
value={value.voice_channels}
onChange={(e) => onChange({ ...value, voice_channels: e.target.value })}
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="Optional"
/>
</div>
<TalkgroupEditor
talkgroups={value.talkgroups}
onChange={(tgs) => onChange({ ...value, talkgroups: tgs })}
/>
</div>
);
}
// ── Fixed-channel editors (node-26#24) ────────────────────────────────────────
const INPUT = "w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500";
function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v: P25Config) => void }) {
const chs = value.channels;
const set = (i: number, field: keyof ChannelEntry, v: string) =>
onChange({ ...value, channels: chs.map((ch, j) => (j === i ? { ...ch, [field]: v } : ch)) });
return (
<div className="space-y-4">
<p className="text-xs text-ink-muted">
Fixed voice channels with no control channel — RadioReference lists them as conventional
frequencies with mode P25 (or &quot;DE&quot;). A node decodes up to {MAX_CONVENTIONAL_CHANNELS} on
one SDR, within about 1.5 MHz of each other. Each channel&apos;s calls are labelled with its name.
</p>
<div className="space-y-2">
<div className="grid grid-cols-[1fr_1.5fr_0.8fr_auto] gap-2 text-xs text-ink-2">
<span>Frequency (MHz)</span>
<span>Channel name</span>
<span>NAC (hex, optional)</span>
<span className="w-16" />
</div>
{chs.map((ch, i) => (
<div key={i} className="grid grid-cols-[1fr_1.5fr_0.8fr_auto] gap-2 items-center">
<input
aria-label={`Channel ${i + 1} frequency in MHz`}
value={ch.freq}
onChange={(e) => set(i, "freq", e.target.value)}
className={INPUT}
placeholder="460.4000"
inputMode="decimal"
required={i === 0}
/>
<input
aria-label={`Channel ${i + 1} name`}
value={ch.name}
onChange={(e) => set(i, "name", e.target.value)}
className={INPUT}
placeholder="PD Dispatch"
/>
<input
aria-label={`Channel ${i + 1} NAC`}
value={ch.nac}
onChange={(e) => set(i, "nac", e.target.value)}
className={INPUT}
placeholder="any"
/>
<button
type="button"
onClick={() => onChange({ ...value, channels: chs.filter((_, j) => j !== i) })}
disabled={chs.length === 1}
className="w-16 text-xs text-red-500 hover:text-red-400 disabled:opacity-30 transition-colors"
>
Remove
</button>
</div>
))}
{chs.length < MAX_CONVENTIONAL_CHANNELS && (
<button
type="button"
onClick={() => onChange({ ...value, channels: [...chs, { freq: "", name: "", nac: "" }] })}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
+ Add channel
</button>
)}
</div>
<TalkgroupEditor
talkgroups={value.talkgroups}
onChange={(tgs) => onChange({ ...value, talkgroups: tgs })}
/>
<p className="text-xs text-ink-muted -mt-2">
Talkgroups are optional here: only needed if the radios send distinct talkgroup IDs you want named.
</p>
</div>
);
}
function AnalogForm({ value, onChange }: { value: AnalogConfig; onChange: (v: AnalogConfig) => void }) {
const ch = value.channel;
return (
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="analog-freq" className="text-xs text-ink-2 block mb-1">Frequency (MHz)</label>
<input
id="analog-freq"
value={ch.freq}
onChange={(e) => onChange({ channel: { ...ch, freq: e.target.value } })}
className={INPUT}
placeholder="154.4300"
inputMode="decimal"
required
/>
</div>
<div>
<label htmlFor="analog-name" className="text-xs text-ink-2 block mb-1">Channel name</label>
<input
id="analog-name"
value={ch.name}
onChange={(e) => onChange({ channel: { ...ch, name: e.target.value } })}
className={INPUT}
placeholder="Fire Dispatch"
/>
</div>
</div>
);
}
// ── Main system form ──────────────────────────────────────────────────────────
function SystemForm({
initial,
createOnly,
onSave,
onCancel,
}: {
initial?: SystemRecord;
createOnly?: boolean;
onSave: () => void;
onCancel: () => void;
}) {
const [name, setName] = useState(initial?.name ?? "");
const [kind, setKind] = useState<SystemKind>(initial ? kindOf(initial.type, initial.config) : "p25_trunked");
const [p25, setP25] = useState<P25Config>(
initial?.type === "P25" && initial.config
? recordToP25Config(initial.config)
: DEFAULT_P25
);
const [analog, setAnalog] = useState<AnalogConfig>(
initial?.type === "NBFM" && initial.config
? recordToAnalog(initial.config)
: { channel: { freq: "", name: "", nac: "" } }
);
const [rawJson, setRawJson] = useState(
initial?.type === "DMR" && initial?.config
? JSON.stringify(initial.config, null, 2)
: "{}"
);
// DMR has no structured form (and no decoder on the node yet).
const [showRaw, setShowRaw] = useState(initial?.type === "DMR");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
function structuredRecord(k: SystemKind): Record<string, unknown> {
if (k === "NBFM") return analogToRecord(analog);
return p25ConfigToRecord(p25, k === "p25_conventional");
}
function handleKindChange(k: SystemKind) {
setKind(k);
setShowRaw(k === "DMR");
}
function toggleRaw() {
if (!showRaw) {
setRawJson(JSON.stringify(structuredRecord(kind), null, 2));
} else {
try {
const parsed = JSON.parse(rawJson);
if (kind === "NBFM") setAnalog(recordToAnalog(parsed));
else setP25(recordToP25Config(parsed));
} catch {
// keep current structured state if parse fails
}
}
setShowRaw(!showRaw);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
const type = typeOf(kind);
let config: Record<string, unknown>;
if (!showRaw) {
config = structuredRecord(kind);
} else {
config = JSON.parse(rawJson);
// The kind picker is the source of truth for trunked vs conventional.
if (kind === "p25_conventional") config = { ...config, mode: "conventional" };
if (kind === "p25_trunked" && config.mode === "conventional") {
const { mode: _drop, ...rest } = config;
void _drop;
config = rest;
}
}
if (initial && !createOnly) {
await c2api.updateSystem(initial.system_id, { name, type, config });
} else {
await c2api.createSystem({ name, type, config });
}
onSave();
} catch (err) {
setError(err instanceof Error ? err.message : "Invalid config or save failed.");
} finally {
setSaving(false);
}
}
const title = initial && !createOnly ? "Edit System" : createOnly ? "Duplicate System" : "New System";
return (
<form onSubmit={handleSubmit} className="bg-surface border border-line-strong rounded-xl p-5 space-y-4 font-mono">
<h3 className="text-white font-semibold">{title}</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-ink-2 block mb-1">Name</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
required
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"
placeholder="Westchester County P25"
/>
</div>
<div>
<label htmlFor="system-kind" className="text-xs text-ink-2 block mb-1">Type</label>
<select
id="system-kind"
value={kind}
onChange={(e) => handleKindChange(e.target.value as SystemKind)}
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"
>
{KIND_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
</div>
{kind === "DMR" && (
<p className="text-xs text-amber-400">DMR isn&apos;t decoded by nodes yet — a node assigned this system won&apos;t start.</p>
)}
{!showRaw && kind === "p25_trunked" ? (
<P25Form value={p25} onChange={setP25} />
) : !showRaw && kind === "p25_conventional" ? (
<ConventionalForm value={p25} onChange={setP25} />
) : !showRaw && kind === "NBFM" ? (
<AnalogForm value={analog} onChange={setAnalog} />
) : (
<div>
<label className="text-xs text-ink-2 block mb-1">Config (JSON)</label>
<textarea
value={rawJson}
onChange={(e) => setRawJson(e.target.value)}
rows={10}
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
/>
</div>
)}
{kind !== "DMR" && (
<button
type="button"
onClick={toggleRaw}
className="text-xs text-ink-muted hover:text-ink-2 transition-colors"
>
{showRaw ? "← Use structured form" : "Edit raw JSON →"}
</button>
)}
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3">
<button
type="submit"
disabled={saving}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
>
{saving ? "Saving…" : "Save"}
</button>
<button
type="button"
onClick={onCancel}
className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
>
Cancel
</button>
</div>
</form>
);
}
// ── Preferred bot token panel ─────────────────────────────────────────────────
interface TokenOption {
token_id: string;
name: string;
in_use: boolean;
}
function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; initialTokenId?: string | null }) {
const [preferredId, setPreferredId] = useState<string | null>(initialTokenId ?? null);
const [tokens, setTokens] = useState<TokenOption[] | null>(null);
const [saving, setSaving] = useState(false);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
async function load() {
if (tokens !== null) return;
try {
const data = await c2api.getTokens();
setTokens(data as TokenOption[]);
} catch (err) {
// An error used to render as "No tokens in pool." — a false empty state.
setError(err instanceof Error ? err.message : "Could not load radio bots.");
setTokens([]);
}
}
function toggle() {
if (!open) load();
setOpen((v) => !v);
}
async function handleSet(tokenId: string) {
setSaving(true);
try {
await c2api.setPreferredToken(tokenId, systemId);
setPreferredId(tokenId);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save the preference.");
} finally {
setSaving(false);
}
}
async function handleClear() {
if (!preferredId) return;
setSaving(true);
try {
await c2api.setPreferredToken(preferredId, "_none");
setPreferredId(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not clear the preference.");
} finally {
setSaving(false);
}
}
const currentToken = tokens?.find((t) => t.token_id === preferredId);
return (
<div className="mt-3 border-t border-line pt-3">
<button
onClick={toggle}
className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
>
<span>{open ? "▲" : "▼"}</span>
<span>
Preferred Bot Token
{preferredId && <span className="ml-1.5 text-indigo-400">● set</span>}
</span>
</button>
{open && (
<div className="mt-3 space-y-2 font-mono text-xs">
{tokens === null ? (
<p className="text-ink-muted italic">Loading tokens…</p>
) : error ? (
<p className="text-red-400">{error}</p>
) : tokens.length === 0 ? (
<p className="text-ink-muted italic">No tokens in pool.</p>
) : (
<>
<p className="text-ink-muted">
When a node on this system joins a voice channel, this token is tried first.
</p>
<div className="space-y-1.5">
{tokens.map((t) => (
<label key={t.token_id} className="flex items-center gap-2.5 cursor-pointer">
<input
type="radio"
name={`preferred-token-${systemId}`}
checked={preferredId === t.token_id}
onChange={() => handleSet(t.token_id)}
disabled={saving}
className="accent-indigo-500"
/>
<span className={`flex-1 ${t.in_use && preferredId !== t.token_id ? "text-ink-muted" : "text-ink-2"}`}>
{t.name}
{t.in_use && <span className="ml-1.5 text-green-600">in use</span>}
</span>
</label>
))}
</div>
{preferredId && (
<button
onClick={handleClear}
disabled={saving}
className="text-ink-muted hover:text-ink-2 transition-colors disabled:opacity-50"
>
Clear preference (use any free token)
</button>
)}
{!preferredId && (
<p className="text-ink-muted">No preference — any free token will be used.</p>
)}
{currentToken && (
<p className="text-indigo-500">Preferred: {currentToken.name}</p>
)}
</>
)}
</div>
)}
</div>
);
}
// ── Per-system AI flags panel ─────────────────────────────────────────────────
interface SystemAiFlags {
stt_enabled?: boolean;
correlation_enabled?: boolean;
}
function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: SystemAiFlags }) {
const [flags, setFlags] = useState<SystemAiFlags>(initial);
const [saving, setSaving] = useState<string | null>(null);
const [open, setOpen] = useState(false);
async function handleToggle(key: keyof SystemAiFlags, value: boolean) {
setSaving(key);
try {
const result = await c2api.setSystemAiFlags(systemId, { [key]: value });
setFlags(result.ai_flags as SystemAiFlags);
} finally {
setSaving(null);
}
}
async function handleClear(key: keyof SystemAiFlags) {
setSaving(key);
try {
const result = await c2api.setSystemAiFlags(systemId, { [key]: null });
setFlags(result.ai_flags as SystemAiFlags);
} finally {
setSaving(null);
}
}
const rows: { key: keyof SystemAiFlags; label: string }[] = [
{ key: "stt_enabled", label: "Speech-to-Text" },
{ key: "correlation_enabled", label: "Incident Correlation" },
];
return (
<div className="mt-3 border-t border-line pt-3">
<button
onClick={() => setOpen((v) => !v)}
className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
>
<span>{open ? "▲" : "▼"}</span>
<span>AI Flags</span>
{(flags.stt_enabled === false || flags.correlation_enabled === false) && (
<span className="ml-1.5 text-yellow-600 font-bold">!</span>
)}
</button>
{open && (
<div className="mt-3 space-y-2 font-mono text-xs">
{rows.map(({ key, label }) => {
const override = flags[key];
const isSet = override !== undefined;
const isOn = override !== false;
return (
<div key={key} className="flex items-center gap-3">
<button
onClick={() => handleToggle(key, !isOn)}
disabled={saving === key}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-50 ${
isOn ? "bg-indigo-600" : "bg-gray-700"
}`}
>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${isOn ? "translate-x-4" : "translate-x-0.5"}`} />
</button>
<span className="text-ink-2 flex-1">{label}</span>
{isSet ? (
<button
onClick={() => handleClear(key)}
disabled={saving === key}
className="text-ink-muted hover:text-ink-2 transition-colors"
title="Clear override (inherit global)"
>
reset
</button>
) : (
<span className="text-ink-muted">inherits global</span>
)}
</div>
);
})}
<p className="text-ink-muted pt-1">Overrides apply on top of global AI flags. "reset" restores global default.</p>
</div>
)}
</div>
);
}
// ── Source call audio player ──────────────────────────────────────────────────
function SourceCallPlayer({ callId }: { callId: string }) {
const [call, setCall] = useState<{ audio_url?: string | null; transcript?: string | null; transcript_corrected?: string | null } | null>(null);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
async function toggle() {
if (!open && !call) {
setLoading(true);
try {
const c = await c2api.getCall(callId);
setCall(c as unknown as typeof call);
} finally {
setLoading(false);
}
}
setOpen((v) => !v);
}
const transcript = call?.transcript_corrected || call?.transcript;
return (
<div className="text-xs">
<button
onClick={toggle}
disabled={loading}
className="text-indigo-500 hover:text-indigo-400 transition-colors disabled:opacity-50"
title={callId}
>
{loading ? "loading…" : open ? "▲ source" : "▶ source"}
</button>
{open && call && (
<div className="mt-1.5 space-y-1 pl-2 border-l border-line-strong">
{call.audio_url ? (
<audio src={call.audio_url} controls className="w-full" style={{ height: "1.75rem" }} />
) : (
<p className="text-ink-muted italic">No audio</p>
)}
{transcript && (
<>
<p className="text-ink-muted italic line-clamp-2">{transcript}</p>
{/* Gate A / A2 (server-26#46) — this is a raw pipeline transcript. */}
<MachineOutputNotice variant="inline" className="text-[10px]" />
</>
)}
</div>
)}
</div>
);
}
// ── Area context panel ────────────────────────────────────────────────────────
/**
* System-wide ground truth for the transcript corrector (server-26#36).
*
* This is the fallback every talkgroup inherits. A talkgroup that covers one
* municipality inside a multi-county system overrides it from the talkgroup
* table in the edit form, and its entries rank above these.
*/
function AreaContextPanel({ systemId }: { systemId: string }) {
const [open, setOpen] = useState(false);
const [area, setArea] = useState<AreaContext | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function toggle() {
const next = !open;
setOpen(next);
if (!next || area !== null) return;
setLoading(true);
try {
const data = await c2api.getAreaContext(systemId);
setArea(migrateArea(data.area_context));
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}
async function save() {
if (!area) return;
setSaving(true);
setError(null);
try {
// The response carries the anchor this edit produced, so the readout
// below reflects what the backend actually resolved rather than what was
// typed.
const saved = await c2api.updateAreaContext(systemId, area);
setArea(saved.area_context ?? area);
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
}
const filled = area
? [area.municipality, area.county, area.state, area.local_knowledge?.length].filter(Boolean).length
: 0;
return (
<div className="mt-3 border-t border-line pt-3">
<button
onClick={toggle}
className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
>
<span>{open ? "▲" : "▼"}</span>
<span>
Local Area
{area !== null && (
<span className="text-ink-muted ml-1">
({filled > 0 ? `${filled} field${filled === 1 ? "" : "s"} set` : "not set"})
</span>
)}
</span>
</button>
{open && (
<div className="mt-3 space-y-3 text-xs">
{loading && <p className="text-ink-muted italic font-mono">Loading…</p>}
{area && (
<>
<p className="text-ink-muted">
Given to the transcript corrector for every talkgroup on this system.
Whisper mishears local names constantly — naming them here is what lets
them be put back.
</p>
<p className="text-ink-muted">
Fill this in <span className="text-ink-2">only if it is true of every
talkgroup</span> on the system. One town, one department — fill it once here.
A system spanning several counties — leave it blank and describe each
talkgroup in the edit form instead.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
<LocalKnowledgeField
label="Municipality"
value={area.municipality ?? ""}
onChange={(v) => setArea({ ...area, municipality: v })}
placeholder="Ossining"
/>
<LocalKnowledgeField
label="County"
value={area.county ?? ""}
onChange={(v) => setArea({ ...area, county: v })}
placeholder="Westchester"
/>
<LocalKnowledgeField
label="State"
value={area.state ?? ""}
onChange={(v) => setArea({ ...area, state: v })}
placeholder="New York"
/>
</div>
<LocalKnowledgeField
label="Local knowledge — one per line, term — what it is"
multiline
value={knowledgeToText(area.local_knowledge)}
onChange={(v) => setArea({ ...area, local_knowledge: textToKnowledge(v) })}
placeholder={
"Route 9 — main north-south highway\nPhelps — Phelps Hospital, Sleepy Hollow\nthe flats — low-lying area by the river"
}
/>
{/* The anchor is the backend's answer to what was typed above, and
it decides whether location checking runs at all — so it is
worth showing rather than leaving as invisible state. */}
<p className="text-ink-muted font-mono">{anchorLabel(area)}</p>
<button
type="button"
onClick={save}
disabled={saving}
className="bg-indigo-700 hover:bg-indigo-600 disabled:opacity-50 text-white px-3 py-1.5 rounded text-xs font-semibold transition-colors"
>
{saving ? "Saving…" : "Save area"}
</button>
</>
)}
{error && <p className="text-red-400 font-mono">{error}</p>}
</div>
)}
</div>
);
}
// ── Vocabulary panel ──────────────────────────────────────────────────────────
/**
* Terms the place verifier and the induction loop proposed, per talkgroup.
*
* Approval is always a person's decision and always lands on the talkgroup that
* proposed it — nothing here promotes a term to the system (server-26#37). A
* wrong term on one channel misleads one channel; the same term system-wide
* misleads every channel on it, including one on the far side of a statewide
* system.
*/
function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
const [open, setOpen] = useState(false);
const [rows, setRows] = useState<TalkgroupPending[] | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function load() {
try {
const data = await c2api.getTalkgroupPending(systemId);
setRows(data.talkgroups ?? []);
} catch (e) {
setError(String(e));
}
}
async function toggle() {
const next = !open;
setOpen(next);
if (next && rows === null) await load();
}
async function act(talkgroupId: number, term: string, approve: boolean) {
setBusy(`${talkgroupId}:${term}`);
setError(null);
try {
if (approve) await c2api.approveTalkgroupTerm(systemId, talkgroupId, term);
else await c2api.dismissTalkgroupTerm(systemId, talkgroupId, term);
await load();
} catch (e) {
setError(String(e));
} finally {
setBusy(null);
}
}
const total = (rows ?? []).reduce((n, r) => n + r.pending.length, 0);
return (
<div className="mt-3 border-t border-line pt-3">
<button
onClick={toggle}
className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
>
<span>{open ? "▲" : "▼"}</span>
<span>
Proposed Terms
{rows !== null && (
<span className={total > 0 ? "text-indigo-400 ml-1" : "text-ink-muted ml-1"}>
({total > 0 ? `${total} awaiting review` : "none"})
</span>
)}
</span>
</button>
{open && (
<div className="mt-3 space-y-3 text-xs">
{rows === null && <p className="text-ink-muted italic font-mono">Loading…</p>}
{rows !== null && total === 0 && (
<p className="text-ink-muted italic">
Nothing proposed yet. Terms appear here when a corrected place name
turns out to be real nearby, or when the induction loop spots one in
recent traffic.
</p>
)}
{(rows ?? []).map((row) => (
<div key={row.talkgroup_id}>
<p className="text-ink-2 font-mono mb-1">
{row.talkgroup_name || `TGID ${row.talkgroup_id}`}
</p>
<ul className="space-y-1">
{row.pending.map((p) => (
<li
key={p.term}
className="flex items-start gap-2 bg-surface/60 border border-line rounded px-2 py-1"
>
<span className="flex-1">
<span className="text-white font-mono">{p.term}</span>
{p.meaning && <span className="text-ink-muted"> — {p.meaning}</span>}
<span className="text-ink-muted ml-2">{p.source}</span>
</span>
<button
type="button"
disabled={busy === `${row.talkgroup_id}:${p.term}`}
onClick={() => act(row.talkgroup_id, p.term, true)}
className="text-emerald-400 hover:text-emerald-300 disabled:opacity-40 font-semibold"
>
Add
</button>
<button
type="button"
disabled={busy === `${row.talkgroup_id}:${p.term}`}
onClick={() => act(row.talkgroup_id, p.term, false)}
className="text-ink-muted hover:text-red-400 disabled:opacity-40 font-semibold"
>
Drop
</button>
</li>
))}
</ul>
</div>
))}
{error && <p className="text-red-400 font-mono">{error}</p>}
</div>
)}
</div>
);
}
function VocabularyPanel({ systemId }: { systemId: string }) {
const [vocab, setVocab] = useState<string[] | null>(null);
const [pending, setPending] = useState<VocabularyPendingTerm[]>([]);
const [bootstrapped, setBootstrapped] = useState(false);
const [loading, setLoading] = useState(false);
const [bootstrapping, setBootstrapping] = useState(false);
const [newTerm, setNewTerm] = useState("");
const [adding, setAdding] = useState(false);
const [open, setOpen] = useState(false);
async function load() {
if (vocab !== null) return;
setLoading(true);
try {
const data = await c2api.getVocabulary(systemId);
setVocab(data.vocabulary);
setPending(data.vocabulary_pending);
setBootstrapped(data.vocabulary_bootstrapped);
} finally {
setLoading(false);
}
}
function toggle() {
if (!open) load();
setOpen((v) => !v);
}
async function handleBootstrap() {
setBootstrapping(true);
try {
const result = await c2api.bootstrapVocabulary(systemId);
const data = await c2api.getVocabulary(systemId);
setVocab(data.vocabulary);
setPending(data.vocabulary_pending);
setBootstrapped(true);
alert(`Bootstrap added ${result.added} term(s).`);
} finally {
setBootstrapping(false);
}
}
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
const term = newTerm.trim();
if (!term) return;
setAdding(true);
try {
await c2api.addVocabularyTerm(systemId, term);
setVocab((v) => (v ? [...v, term] : [term]));
setNewTerm("");
} finally {
setAdding(false);
}
}
async function handleRemove(term: string) {
await c2api.removeVocabularyTerm(systemId, term);
setVocab((v) => (v ?? []).filter((t) => t !== term));
}
async function handleApprove(term: string) {
await c2api.approvePendingTerm(systemId, term);
setVocab((v) => (v ? [...v, term] : [term]));
setPending((p) => p.filter((t) => t.term !== term));
}
async function handleDismiss(term: string) {
await c2api.dismissPendingTerm(systemId, term);
setPending((p) => p.filter((t) => t.term !== term));
}
return (
<div className="mt-3 border-t border-line pt-3">
<button
onClick={toggle}
className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
>
<span>{open ? "▲" : "▼"}</span>
<span>
Vocabulary
{vocab !== null && (
<span className="text-ink-muted ml-1">
({vocab.length} terms{pending.length > 0 ? `, ${pending.length} pending` : ""})
</span>
)}
</span>
</button>
{open && (
<div className="mt-3 space-y-3 font-mono text-xs">
{loading && <p className="text-ink-muted italic">Loading…</p>}
{!loading && vocab !== null && (
<>
<div className="flex items-center gap-3">
<button
onClick={handleBootstrap}
disabled={bootstrapping}
className="bg-indigo-800 hover:bg-indigo-700 disabled:opacity-50 text-indigo-200 px-3 py-1.5 rounded-lg text-xs transition-colors"
>
{bootstrapping ? "Bootstrapping…" : bootstrapped ? "Re-bootstrap with AI" : "Bootstrap with AI"}
</button>
<span className="text-ink-muted">GPT-4o generates local knowledge (agencies, units, streets)</span>
</div>
<div>
<p className="text-ink-muted uppercase tracking-wider mb-1.5">Approved ({vocab.length})</p>
{vocab.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{vocab.map((term) => (
<span key={term} className="inline-flex items-center gap-1 bg-raised text-ink-2 px-2 py-0.5 rounded-full">
{term}
<button
onClick={() => handleRemove(term)}
className="text-ink-muted hover:text-red-400 transition-colors leading-none"
>
×
</button>
</span>
))}
</div>
) : (
<p className="text-ink-muted italic">No terms yet — bootstrap or add manually.</p>
)}
</div>
<form onSubmit={handleAdd} className="flex gap-2">
<input
value={newTerm}
onChange={(e) => setNewTerm(e.target.value)}
placeholder="Add term (e.g. 5-baker, YVAC)"
className="flex-1 bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
/>
<button
type="submit"
disabled={adding || !newTerm.trim()}
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-ink px-3 py-1.5 rounded-lg transition-colors"
>
Add
</button>
</form>
{pending.length > 0 && (
<div>
<p className="text-ink-muted uppercase tracking-wider mb-1.5">
Induction suggestions ({pending.length})
</p>
<div className="space-y-2">
{pending.map((p) => (
<div key={p.term} className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-ink-2 flex-1">{p.term}</span>
<span className="text-ink-muted">{p.source}</span>
<button onClick={() => handleApprove(p.term)} className="text-green-500 hover:text-green-400 transition-colors px-1">✓</button>
<button onClick={() => handleDismiss(p.term)} className="text-ink-muted hover:text-red-400 transition-colors px-1">✕</button>
</div>
{p.source_call_ids && p.source_call_ids.length > 0 && (
<div className="pl-1 space-y-1">
{p.source_call_ids.map((id: string) => (
<Fragment key={id}>
<SourceCallPlayer callId={id} />
</Fragment>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
</>
)}
</div>
)}
</div>
);
}
// ── Systems list page ─────────────────────────────────────────────────────────
export default function SystemsPage() {
const { isAdmin, isOperator, loading: authLoading } = useAuth();
const router = useRouter();
const { systems, loading } = useSystems();
useEffect(() => {
if (!authLoading && !isAdmin && !isOperator) router.replace("/");
}, [authLoading, isAdmin, isOperator, router]);
const [editing, setEditing] = useState<SystemRecord | null | "new">(null);
const [editIsDuplicate, setEditIsDuplicate] = useState(false);
// Every hook must run before this guard. React tracks hooks by call order,
// so returning early on the first render and then reaching a useState on the
// next one is error #310 ("rendered more hooks than during the previous
// render") -- which crashed this whole page to a blank client-exception
// screen the moment auth resolved.
if (authLoading || (!isAdmin && !isOperator)) return null;
async function handleDelete(id: string) {
if (!confirm("Delete this system?")) return;
try { await c2api.deleteSystem(id); }
catch (e) { alert(`Couldn't delete the system: ${e instanceof Error ? e.message : String(e)}`); }
}
function openEdit(s: SystemRecord) {
setEditing(s);
setEditIsDuplicate(false);
}
function openDuplicate(s: SystemRecord) {
setEditing({ ...s, name: `Copy of ${s.name}` });
setEditIsDuplicate(true);
}
function closeEdit() {
setEditing(null);
setEditIsDuplicate(false);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white font-mono">Systems</h1>
<button
onClick={() => { setEditing("new"); setEditIsDuplicate(false); }}
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
>
+ New System
</button>
</div>
{editing && (
<SystemForm
initial={editing === "new" ? undefined : editing}
createOnly={editIsDuplicate}
onSave={closeEdit}
onCancel={closeEdit}
/>
)}
{loading ? (
<p className="text-ink-muted text-sm font-mono">Loading…</p>
) : systems.length === 0 ? (
<p className="text-ink-muted text-sm font-mono">No systems defined yet.</p>
) : (
<div className="space-y-3">
{systems.map((s) => {
const tgCount = Array.isArray(s.config.talkgroups)
? (s.config.talkgroups as unknown[]).length
: null;
const conventional = kindOf(s.type, s.config) === "p25_conventional";
const ccCount = !conventional && s.type === "P25" && Array.isArray(s.config.control_channels)
? (s.config.control_channels as unknown[]).length
: null;
const chCount = (conventional || s.type === "NBFM") && Array.isArray(s.config.channels)
? (s.config.channels as unknown[]).length
: null;
return (
<div key={s.system_id} className="bg-surface border border-line rounded-lg p-4 font-mono">
<div className="flex items-start justify-between">
<div>
<p className="text-white font-semibold">{s.name}</p>
<p className="text-xs text-ink-muted">{s.system_id}</p>
{(s.config.nac || ccCount !== null || chCount !== null || tgCount !== null) && (
<p className="text-xs text-ink-muted mt-0.5 space-x-2">
{!!s.config.nac && <span>NAC {String(s.config.nac)}</span>}
{ccCount !== null && <span>· {ccCount} CC</span>}
{chCount !== null && <span>· {chCount} ch</span>}
{tgCount !== null && <span>· {tgCount} TGs</span>}
</p>
)}
</div>
<span className="text-xs bg-raised border border-line-strong text-ink-2 px-2 py-0.5 rounded">
{kindLabel(s)}
</span>
</div>
<div className="mt-3 flex gap-3">
<button
onClick={() => openEdit(s)}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
Edit
</button>
<button
onClick={() => openDuplicate(s)}
className="text-xs text-ink-muted hover:text-ink transition-colors"
>
Duplicate
</button>
<button
onClick={() => handleDelete(s.system_id)}
className="text-xs text-red-500 hover:text-red-400 transition-colors"
>
Delete
</button>
</div>
{/* GET /tokens and PUT /tokens/{id}/prefer are platform-admin only
(server-26#204 item 6), so for anyone else this panel could
neither list the pool nor save a choice (server-26#213 item 4). */}
{isAdmin && <PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />}
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
<AreaContextPanel systemId={s.system_id} />
<TalkgroupPendingPanel systemId={s.system_id} />
<VocabularyPanel systemId={s.system_id} />
</div>
);
})}
</div>
)}
</div>
);
}