"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 ( ); } function CorrelationDebugTab() { const [limit, setLimit] = useState(20); const [orphanHours, setOrphanHours] = useState(48); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); const preRef = useRef(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 = {}; const signalCounts: Record = {}; 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 (

Fetches recent incidents with per-call correlation debug fields, plus orphaned calls. Copy the JSON and paste it to Claude to diagnose correlation problems.

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" />
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" />
{error && (

{error}

)} {meta && (

{meta.incident_count} incidents · {meta.orphaned_call_count} orphaned calls · {meta.generated_at}

)} {meta && Object.keys(pathCounts).length > 0 && (

corr_path distribution

{Object.entries(pathCounts).sort((a, b) => b[1] - a[1]).map(([path, n]) => (
{path} {n}
))}

fit_signal distribution

{Object.keys(signalCounts).length === 0 ?

No signal data yet — deploy correlator update first

: Object.entries(signalCounts).sort((a, b) => b[1] - a[1]).map(([sig, n]) => (
{sig} {n}
)) }
)} {meta?.orphans_by_talkgroup && meta.orphans_by_talkgroup.length > 0 && (

orphans by talkgroup

{meta.orphans_by_talkgroup.map((tg) => (
{tg.talkgroup_name} ({tg.talkgroup_id}) {tg.count} total · {tg.no_type_count} no-type · {tg.sweep_exhausted_count} exhausted
))}
)} {json && (
          {json}
        
)}
); } function StaleCallsTab() { const [minutes, setMinutes] = useState(30); const [result, setResult] = useState<{ dry_run: boolean; count: number; call_ids: string[] } | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); async function run(dryRun: boolean) { setLoading(true); setError(null); setResult(null); try { const res = await c2api.closeStallCalls(minutes, dryRun); setResult(res); } catch (e) { setError(String(e)); } finally { setLoading(false); } } return (

Finds calls stuck in active status because a node rebooted before sending an end-call event. Preview first, then close.

setMinutes(Math.min(1440, 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" />
{error && (

{error}

)} {result && (

{result.dry_run ? "Preview: " : "Closed: "} 0 ? "text-amber-400" : "text-green-400"}> {result.count} stale call{result.count !== 1 ? "s" : ""} {result.count === 0 && — nothing to clear}

{result.call_ids.length > 0 && (
{result.call_ids.map((id) => (

{id}

))}
)}
)}
); } export default function AdminPage() { const { isAdmin } = useAuth(); const router = useRouter(); const [tab, setTab] = useState<"features" | "correlation" | "calls">("features"); const [flags, setFlags] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(null); const [error, setError] = useState(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 (

Admin

{(["features", "correlation", "calls"] as const).map((t) => ( ))}
{tab === "features" && (
{error && (

{error}

)} {loading ? (

Loading…

) : (
{FLAG_META.map(({ key, label, description }) => (

{label}

{description}

handleToggle(key, val)} disabled={saving === key} />
))}
)}
)} {tab === "correlation" && } {tab === "calls" && }
); }