"use client"; import { useAuth } from "@/components/AuthProvider"; import { c2api } from "@/lib/c2api"; import { useEffect, useState, useRef, useCallback } from "react"; import { useRouter } from "next/navigation"; import type { UserRecord, AuditEntry, UserRole } from "@/lib/types"; // --------------------------------------------------------------------------- // Shared primitives // --------------------------------------------------------------------------- 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 fmtDate(iso: string | null | undefined) { if (!iso) return "—"; return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); } function fmtDatetime(iso: string | null | undefined) { if (!iso) return "—"; return new Date(iso).toLocaleString("en-US", { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", }); } const ROLE_COLORS: Record = { admin: "bg-indigo-900 text-indigo-300", operator: "bg-green-900 text-green-300", viewer: "bg-gray-800 text-gray-400", }; function RoleBadge({ role }: { role: UserRole }) { const labels: Record = { admin: "Admin", operator: "Operator", viewer: "Viewer" }; return ( {labels[role]} ); } // --------------------------------------------------------------------------- // AI Features tab // --------------------------------------------------------------------------- function FeaturesTab() { const [flags, setFlags] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(null); const [error, setError] = useState(null); useEffect(() => { c2api.getFeatureFlags() .then((f) => setFlags(f as unknown as FeatureFlags)) .catch((e) => setError(String(e))) .finally(() => setLoading(false)); }, []); 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); } } return (
{error && (

{error}

)} {loading ? (

Loading…

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

{label}

{description}

handleToggle(key, val)} disabled={saving === key} />
))}
)}
); } // --------------------------------------------------------------------------- // Correlation Debug tab // --------------------------------------------------------------------------- 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; 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}
        
)}
); } // --------------------------------------------------------------------------- // User detail panel // --------------------------------------------------------------------------- function UserDetailPanel({ user, onClose, onUpdated, currentUid, }: { user: UserRecord; onClose: () => void; onUpdated: (u: UserRecord) => void; currentUid: string; }) { const [detail, setDetail] = useState(user); const [editRole, setEditRole] = useState(user.role); const [editNodes, setEditNodes] = useState(user.owned_node_ids.join(", ")); const [editName, setEditName] = useState(user.display_name ?? ""); const [saving, setSaving] = useState(false); const [toggling, setToggling] = useState(false); const [deleting, setDeleting] = useState(false); const [error, setError] = useState(null); const [showSessions, setShowSessions] = useState(false); // Fetch full detail (sessions) lazily useEffect(() => { c2api.getUser(user.uid) .then((d) => setDetail(d)) .catch(() => {}); }, [user.uid]); async function handleSave() { setSaving(true); setError(null); const nodes = editRole === "operator" ? editNodes.split(",").map((s) => s.trim()).filter(Boolean) : []; try { const updated = await c2api.updateUser(user.uid, { role: editRole, owned_node_ids: nodes, display_name: editName || undefined, }); onUpdated(updated); setDetail((d) => ({ ...d, ...updated })); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setSaving(false); } } async function handleToggleDisabled() { setToggling(true); setError(null); try { if (detail.disabled) { await c2api.enableUser(user.uid); } else { await c2api.disableUser(user.uid); } const next = { ...detail, disabled: !detail.disabled }; setDetail(next); onUpdated(next); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setToggling(false); } } async function handleDelete() { if (!confirm(`Permanently delete ${detail.email}? This cannot be undone.`)) return; setDeleting(true); setError(null); try { await c2api.deleteUser(user.uid); onUpdated({ ...detail, uid: "__deleted__" }); onClose(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setDeleting(false); } } const isSelf = user.uid === currentUid; return (

{detail.email}

{detail.uid}

{error && (

{error}

)}
setEditName(e.target.value)} className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500" placeholder="Full name" />
{editRole === "operator" && (
setEditNodes(e.target.value)} className="w-full 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" placeholder="node-abc123, node-def456" />
)}
Status {detail.disabled ? "Disabled" : "Active"}
Discord {detail.discord_linked ? `@${detail.discord_username ?? detail.discord_user_id}` : "Not linked"}
Created {fmtDate(detail.creation_time)}
Last sign-in {fmtDate(detail.last_sign_in)}
{(detail.sessions?.length ?? 0) > 0 && (
{showSessions && (
{detail.sessions?.map((s) => (
{fmtDatetime(s.timestamp)} {s.ip ?? "—"}
))}
)}
)}
{!isSelf ? ( <> ) : (

Cannot disable or delete your own account.

)}
); } // --------------------------------------------------------------------------- // Create User modal // --------------------------------------------------------------------------- function CreateUserModal({ onClose, onCreated, }: { onClose: () => void; onCreated: (u: UserRecord) => void; }) { const [email, setEmail] = useState(""); const [displayName, setDisplayName] = useState(""); const [role, setRole] = useState("viewer"); const [nodeIds, setNodeIds] = useState(""); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [inviteLink, setInviteLink] = useState(null); const [copied, setCopied] = useState(false); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setSaving(true); setError(null); const owned_node_ids = role === "operator" ? nodeIds.split(",").map((s) => s.trim()).filter(Boolean) : []; try { const created = await c2api.createUser({ email, role, display_name: displayName || undefined, owned_node_ids, }); onCreated(created); if (created.invite_link) { setInviteLink(created.invite_link); } else { onClose(); } } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setSaving(false); } } function copyLink() { if (!inviteLink) return; navigator.clipboard?.writeText(inviteLink).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); } if (inviteLink) { return (

User Created

Share this one-time invite link with the new user so they can set their password. It expires after use.

{inviteLink}

); } return (

Create User

setEmail(e.target.value)} required className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" placeholder="user@example.com" />
setDisplayName(e.target.value)} className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" placeholder="Jane Smith" />
{role === "operator" && (
setNodeIds(e.target.value)} required className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" placeholder="node-abc123, node-def456" />
)} {error &&

{error}

}
); } // --------------------------------------------------------------------------- // Users tab // --------------------------------------------------------------------------- function UsersTab({ currentUid }: { currentUid: string }) { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedUid, setSelectedUid] = useState(null); const [showCreate, setShowCreate] = useState(false); const loadUsers = useCallback(async () => { try { const data = await c2api.listUsers(); setUsers(data); } catch (e) { setError(String(e)); } finally { setLoading(false); } }, []); useEffect(() => { loadUsers(); }, [loadUsers]); function handleUpdated(updated: UserRecord) { if (updated.uid === "__deleted__") { setUsers((prev) => prev.filter((u) => u.uid !== selectedUid)); setSelectedUid(null); } else { setUsers((prev) => prev.map((u) => u.uid === updated.uid ? { ...u, ...updated } : u)); } } function handleCreated(created: UserRecord) { setUsers((prev) => [...prev, created]); } const selectedUser = users.find((u) => u.uid === selectedUid); return (
{showCreate && ( setShowCreate(false)} onCreated={(u) => { handleCreated(u); setShowCreate(false); }} /> )}

{users.length} user{users.length !== 1 ? "s" : ""}

{error && (

{error}

)} {loading ? (

Loading…

) : users.length === 0 ? (

No users found.

) : (
{users.map((u) => ( ))}
Email Name Role Discord Last sign-in Status
{u.email ?? "—"} {u.display_name ?? "—"} {u.discord_linked ? `@${u.discord_username ?? "linked"}` : "—"} {fmtDate(u.last_sign_in)} {u.disabled ? Disabled : Active }
)} {selectedUser && ( setSelectedUid(null)} onUpdated={handleUpdated} currentUid={currentUid} /> )}
); } // --------------------------------------------------------------------------- // Audit Log tab // --------------------------------------------------------------------------- function AuditLogTab() { const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(true); const [error, setError] = useState(null); const PAGE = 50; useEffect(() => { c2api.getAuditLog(PAGE, 0) .then((data) => { setEntries(data); setHasMore(data.length === PAGE); }) .catch((e) => setError(String(e))) .finally(() => setLoading(false)); }, []); async function loadMore() { setLoadingMore(true); try { const more = await c2api.getAuditLog(PAGE, entries.length); setEntries((prev) => [...prev, ...more]); setHasMore(more.length === PAGE); } catch (e) { setError(String(e)); } finally { setLoadingMore(false); } } function actionColor(action: string) { if (action.includes("delete")) return "text-red-400"; if (action.includes("disable")) return "text-yellow-400"; if (action.includes("create")) return "text-green-400"; return "text-indigo-400"; } return (
{error && (

{error}

)} {loading ? (

Loading…

) : entries.length === 0 ? (

No audit entries yet.

) : ( <>
{entries.map((e) => ( ))}
Time Action Actor Target Details
{fmtDatetime(e.timestamp)} {e.action} {e.actor_email} {e.target_email ?? "—"} {Object.keys(e.details).length > 0 ? Object.entries(e.details) .map(([k, v]) => `${k}: ${JSON.stringify(v)}`) .join(" · ") : "—"}
{hasMore && ( )} )}
); } // --------------------------------------------------------------------------- // Main admin page // --------------------------------------------------------------------------- type AdminTab = "features" | "correlation" | "users" | "audit"; const TAB_LABELS: { key: AdminTab; label: string }[] = [ { key: "features", label: "AI Features" }, { key: "correlation", label: "Correlation Debug" }, { key: "users", label: "Users" }, { key: "audit", label: "Audit Log" }, ]; export default function AdminPage() { const { user, isAdmin } = useAuth(); const router = useRouter(); const [tab, setTab] = useState("features"); useEffect(() => { if (!isAdmin) router.replace("/dashboard"); }, [isAdmin, router]); if (!isAdmin) return null; // Users/Audit tabs benefit from full width; AI Features / Correlation are narrow const wide = tab === "users" || tab === "audit"; return (

Admin

{TAB_LABELS.map(({ key, label }) => ( ))}
{tab === "features" && } {tab === "correlation" && } {tab === "users" && } {tab === "audit" && }
); }