419 lines
15 KiB
TypeScript
419 lines
15 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>
|
|
);
|
|
}
|
|
|
|
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<string | null>(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 (
|
|
<div className="space-y-5">
|
|
<p className="text-xs text-gray-500 font-mono">
|
|
Finds calls stuck in <span className="text-gray-300">active</span> status because a node rebooted before sending an end-call event.
|
|
Preview first, then close.
|
|
</p>
|
|
|
|
<div className="flex flex-wrap items-end gap-4">
|
|
<div>
|
|
<label className="text-xs text-gray-400 block mb-1">Older than (minutes)</label>
|
|
<input
|
|
type="number"
|
|
min={1} max={1440}
|
|
value={minutes}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={() => run(true)}
|
|
disabled={loading}
|
|
className="bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
|
>
|
|
{loading ? "Working…" : "Preview"}
|
|
</button>
|
|
<button
|
|
onClick={() => run(false)}
|
|
disabled={loading || result === null}
|
|
className="bg-red-700 hover:bg-red-600 disabled:opacity-50 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
|
>
|
|
Close {result && !result.dry_run ? "Done" : result?.count ? `${result.count} calls` : "calls"}
|
|
</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>
|
|
)}
|
|
|
|
{result && (
|
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-2">
|
|
<p className="text-sm font-mono text-white">
|
|
{result.dry_run ? "Preview: " : "Closed: "}
|
|
<span className={result.count > 0 ? "text-amber-400" : "text-green-400"}>
|
|
{result.count} stale call{result.count !== 1 ? "s" : ""}
|
|
</span>
|
|
{result.count === 0 && <span className="text-gray-500"> — nothing to clear</span>}
|
|
</p>
|
|
{result.call_ids.length > 0 && (
|
|
<div className="max-h-40 overflow-y-auto space-y-0.5">
|
|
{result.call_ids.map((id) => (
|
|
<p key={id} className="text-xs font-mono text-gray-400">{id}</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function AdminPage() {
|
|
const { isAdmin } = useAuth();
|
|
const router = useRouter();
|
|
const [tab, setTab] = useState<"features" | "correlation" | "calls">("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", "calls"] 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" : t === "correlation" ? "Correlation Debug" : "Calls"}
|
|
</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 />}
|
|
{tab === "calls" && <StaleCallsTab />}
|
|
</div>
|
|
);
|
|
}
|