Files
server-26/drb-frontend/app/admin/page.tsx
T
Logan 4b7d9dd49a feat: enrich correlation debug with fit_signal and orphan breakdown
_call_fits_incident now returns (bool, signal_str) so each correlation
decision records exactly what evidence fired: unit_overlap, vehicle_overlap,
location_proximity, time_fallback, tactical_default, or the corresponding
false-return variants (unit_loc_conflict, content_divergence, etc.).

- corr_fit_signal and corr_matched_units written to call docs for
  fast/single and fast/disambig paths
- Admin debug endpoint exposes the new fields in calls_detail
- Orphan section adds orphans_by_talkgroup summary (count, no-type count,
  sweep-exhausted count per TGID) and raises orphan limit 100 → 250
- Admin page shows corr_path and fit_signal distribution panels above raw
  JSON; time_fallback highlighted in yellow as a diagnostic marker

No correlation logic changed — diagnostic data only.
2026-05-25 12:54:34 -04:00

336 lines
12 KiB
TypeScript

"use client";
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
import { useEffect, useState, useRef } from "react";
import { useRouter } from "next/navigation";
interface FeatureFlags {
stt_enabled: boolean;
correlation_enabled: boolean;
summaries_enabled: boolean;
vocabulary_learning_enabled: boolean;
}
const FLAG_META: { key: keyof FeatureFlags; label: string; description: string }[] = [
{
key: "stt_enabled",
label: "Speech-to-Text (Whisper)",
description: "Transcribe call audio via OpenAI Whisper. When off, calls are recorded and stored but no transcript is generated.",
},
{
key: "correlation_enabled",
label: "Incident Correlation",
description: "Run scene extraction and incident correlation on each call. When off, calls are logged but not linked to incidents.",
},
{
key: "summaries_enabled",
label: "Incident Summaries",
description: "Generate AI summaries for active incidents on each summarizer pass. Auto-resolve sweep is also paused when off.",
},
{
key: "vocabulary_learning_enabled",
label: "Vocabulary Learning",
description: "Run the background vocabulary induction loop that proposes new STT terms from recent transcripts.",
},
];
function Toggle({
enabled,
onChange,
disabled,
}: {
enabled: boolean;
onChange: (val: boolean) => void;
disabled: boolean;
}) {
return (
<button
onClick={() => onChange(!enabled)}
disabled={disabled}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none disabled:opacity-50 ${
enabled ? "bg-indigo-600" : "bg-gray-700"
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${
enabled ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
);
}
function CorrelationDebugTab() {
const [limit, setLimit] = useState(20);
const [orphanHours, setOrphanHours] = useState(48);
const [data, setData] = useState<unknown>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const preRef = useRef<HTMLPreElement>(null);
async function handleFetch() {
setLoading(true);
setError(null);
setData(null);
setCopied(false);
try {
const result = await c2api.getCorrelationDebug(limit, orphanHours);
setData(result);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}
function handleCopy() {
if (!data) return;
const text = JSON.stringify(data, null, 2);
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}).catch(() => fallbackCopy(text));
} else {
fallbackCopy(text);
}
}
function fallbackCopy(text: string) {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
const json = data ? JSON.stringify(data, null, 2) : null;
const meta = data as {
incident_count?: number;
orphaned_call_count?: number;
generated_at?: string;
incidents?: Array<{ calls_detail?: Array<{ corr_path?: string; corr_fit_signal?: string }> }>;
orphans_by_talkgroup?: Array<{ talkgroup_id?: number; talkgroup_name?: string; count: number; no_type_count: number; sweep_exhausted_count: number }>;
} | null;
// Aggregate corr_path and corr_fit_signal counts across all incident calls.
const pathCounts: Record<string, number> = {};
const signalCounts: Record<string, number> = {};
if (meta?.incidents) {
for (const inc of meta.incidents) {
for (const call of inc.calls_detail ?? []) {
const p = call.corr_path ?? "unknown";
pathCounts[p] = (pathCounts[p] ?? 0) + 1;
if (call.corr_fit_signal) {
signalCounts[call.corr_fit_signal] = (signalCounts[call.corr_fit_signal] ?? 0) + 1;
}
}
}
}
return (
<div className="space-y-4">
<p className="text-xs text-gray-500 font-mono">
Fetches recent incidents with per-call correlation debug fields, plus orphaned calls.
Copy the JSON and paste it to Claude to diagnose correlation problems.
</p>
<div className="flex flex-wrap items-end gap-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Incidents (limit)</label>
<input
type="number"
min={1} max={100}
value={limit}
onChange={(e) => setLimit(Math.min(100, Math.max(1, Number(e.target.value))))}
className="w-24 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Orphan scan window (hours)</label>
<input
type="number"
min={1} max={168}
value={orphanHours}
onChange={(e) => setOrphanHours(Math.min(168, Math.max(1, Number(e.target.value))))}
className="w-28 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
/>
</div>
<button
onClick={handleFetch}
disabled={loading}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
>
{loading ? "Fetching…" : "Fetch"}
</button>
</div>
{error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-sm font-mono">{error}</p>
</div>
)}
{meta && (
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500 font-mono">
{meta.incident_count} incidents · {meta.orphaned_call_count} orphaned calls · {meta.generated_at}
</p>
<button
onClick={handleCopy}
className="text-xs font-mono px-3 py-1.5 rounded-lg border border-gray-700 bg-gray-900 text-gray-300 hover:text-white transition-colors"
>
{copied ? "Copied!" : "Copy JSON"}
</button>
</div>
)}
{meta && Object.keys(pathCounts).length > 0 && (
<div className="grid grid-cols-2 gap-3">
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1">
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">corr_path distribution</p>
{Object.entries(pathCounts).sort((a, b) => b[1] - a[1]).map(([path, n]) => (
<div key={path} className="flex justify-between text-xs font-mono">
<span className="text-gray-300">{path}</span>
<span className="text-indigo-400">{n}</span>
</div>
))}
</div>
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1">
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">fit_signal distribution</p>
{Object.keys(signalCounts).length === 0
? <p className="text-xs text-gray-600 font-mono">No signal data yet deploy correlator update first</p>
: Object.entries(signalCounts).sort((a, b) => b[1] - a[1]).map(([sig, n]) => (
<div key={sig} className="flex justify-between text-xs font-mono">
<span className={sig === "time_fallback" ? "text-yellow-400" : "text-gray-300"}>{sig}</span>
<span className="text-indigo-400">{n}</span>
</div>
))
}
</div>
</div>
)}
{meta?.orphans_by_talkgroup && meta.orphans_by_talkgroup.length > 0 && (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1">
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">orphans by talkgroup</p>
{meta.orphans_by_talkgroup.map((tg) => (
<div key={String(tg.talkgroup_id)} className="flex justify-between text-xs font-mono">
<span className="text-gray-300">{tg.talkgroup_name} <span className="text-gray-600">({tg.talkgroup_id})</span></span>
<span className="text-gray-400">
{tg.count} total · <span className="text-gray-500">{tg.no_type_count} no-type</span> · <span className="text-red-400">{tg.sweep_exhausted_count} exhausted</span>
</span>
</div>
))}
</div>
)}
{json && (
<pre
ref={preRef}
className="bg-gray-950 border border-gray-800 rounded-xl p-4 text-xs text-gray-300 font-mono overflow-auto max-h-[60vh] whitespace-pre"
>
{json}
</pre>
)}
</div>
);
}
export default function AdminPage() {
const { isAdmin } = useAuth();
const router = useRouter();
const [tab, setTab] = useState<"features" | "correlation">("features");
const [flags, setFlags] = useState<FeatureFlags | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isAdmin) {
router.replace("/dashboard");
return;
}
c2api.getFeatureFlags()
.then((f) => setFlags(f as unknown as FeatureFlags))
.catch((e) => setError(String(e)))
.finally(() => setLoading(false));
}, [isAdmin, router]);
async function handleToggle(key: keyof FeatureFlags, value: boolean) {
if (!flags) return;
setSaving(key);
setError(null);
try {
const updated = await c2api.setFeatureFlags({ [key]: value });
setFlags(updated as unknown as FeatureFlags);
} catch (e) {
setError(String(e));
} finally {
setSaving(null);
}
}
if (!isAdmin) return null;
return (
<div className="max-w-2xl space-y-6">
<h1 className="text-white text-xl font-bold font-mono">Admin</h1>
<div className="flex gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
{(["features", "correlation"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${
tab === t ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
}`}
>
{t === "features" ? "AI Features" : "Correlation Debug"}
</button>
))}
</div>
{tab === "features" && (
<section className="space-y-3">
{error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-sm font-mono">{error}</p>
</div>
)}
{loading ? (
<p className="text-gray-500 text-sm font-mono">Loading</p>
) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
{FLAG_META.map(({ key, label, description }) => (
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<p className="text-white text-sm font-semibold">{label}</p>
<p className="text-gray-500 text-xs mt-0.5 leading-snug">{description}</p>
</div>
<Toggle
enabled={flags?.[key] ?? true}
onChange={(val) => handleToggle(key, val)}
disabled={saving === key}
/>
</div>
))}
</div>
)}
</section>
)}
{tab === "correlation" && <CorrelationDebugTab />}
</div>
);
}