Files
server-26/drb-frontend/app/admin/page.tsx
T
Logan CusanoandClaude Sonnet 5 5f85a878fa
Build & Deploy / Build & push images (push) Successful in 4m10s
Build & Deploy / Deploy Firestore rules & indexes (push) Failing after 3s
Build & Deploy / Deploy to VM (push) Successful in 1m56s
Build & Deploy / Report a failed deploy (push) Successful in 1s
admin: STT eval harness — record human-verified transcripts, measure real WER (#163)
Backend: three new routes on the calls router, deliberately separate from
PATCH /{call_id}/transcript (a production correction with real side effects
-- re-extraction, incident unlinking, vocabulary learning). This is pure
measurement and must never share that code path.

  GET  /calls/eval-queue        -- calls with a transcript but no
                                    eval_transcript yet, paged (same bounded-
                                    window-plus-cursor shape as /search)
  PUT  /{call_id}/eval-transcript -- records eval_transcript/_by/_at only;
                                      never touches transcript/transcript_corrected
  GET  /calls/eval-stats        -- eval_count + average word error rate of
                                    the raw and corrected machine transcripts
                                    against the human-verified ones

internal/wer.py: standard word-level Levenshtein WER. Returns None (not 0.0)
when the reference is empty -- a call nobody transcribed must not score as a
perfect match.

Frontend: a new "STT Eval" tab on /admin -- one call at a time, audio player,
a textarea pre-filled with the machine transcript to correct into ground
truth, Save & next / Skip, running WER stats at the top. Built for working a
handful of calls at a time over however many sittings it takes, not a
one-shot form: the queue auto-refills from where the last save left off.

Verified: 438 pass, 0 fail (12 new backend tests). Frontend is UNVERIFIED --
this box has no Node.js/npm (confirmed absent), so neither typecheck nor the
dev server could be run. Matches existing code patterns and the CallRecord/
c2api types by manual review only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 00:04:30 -04:00

1288 lines
48 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 { 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, CallRecord } 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 (
<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 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<UserRole, string> = {
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<UserRole, string> = { admin: "Admin", operator: "Operator", viewer: "Viewer" };
return (
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${ROLE_COLORS[role]}`}>
{labels[role]}
</span>
);
}
// ---------------------------------------------------------------------------
// AI Features tab
// ---------------------------------------------------------------------------
function FeaturesTab() {
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(() => {
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 (
<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>
);
}
// ---------------------------------------------------------------------------
// Correlation Debug tab
// ---------------------------------------------------------------------------
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;
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>
);
}
// ---------------------------------------------------------------------------
// User detail panel
// ---------------------------------------------------------------------------
function UserDetailPanel({
user,
onClose,
onUpdated,
currentUid,
}: {
user: UserRecord;
onClose: () => void;
onUpdated: (u: UserRecord) => void;
currentUid: string;
}) {
const [detail, setDetail] = useState<UserRecord>(user);
const [editRole, setEditRole] = useState<UserRole>(user.role);
const [editNodes, setEditNodes] = useState<string>(user.owned_node_ids.join(", "));
const [editName, setEditName] = useState<string>(user.display_name ?? "");
const [saving, setSaving] = useState(false);
const [toggling, setToggling] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-5 font-mono">
<div className="flex items-start justify-between">
<div>
<p className="text-white font-semibold">{detail.email}</p>
<p className="text-xs text-gray-500 mt-0.5">{detail.uid}</p>
</div>
<button onClick={onClose} className="text-gray-600 hover:text-gray-300 transition-colors text-xl leading-none">×</button>
</div>
{error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-xs">{error}</p>
</div>
)}
<div className="space-y-3">
<div>
<label className="text-xs text-gray-400 block mb-1">Display Name</label>
<input
value={editName}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Role</label>
<select
value={editRole}
onChange={(e) => setEditRole(e.target.value as UserRole)}
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"
>
<option value="admin">Admin — full access</option>
<option value="operator">Operator — owns nodes</option>
<option value="viewer">Viewer — read-only</option>
</select>
</div>
{editRole === "operator" && (
<div>
<label className="text-xs text-gray-400 block mb-1">
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span>
</label>
<input
value={editNodes}
onChange={(e) => 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"
/>
</div>
)}
<button
onClick={handleSave}
disabled={saving}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm px-4 py-1.5 rounded-lg transition-colors"
>
{saving ? "Saving…" : "Save changes"}
</button>
</div>
<div className="border-t border-gray-800 pt-4 space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-gray-500">Status</span>
<span className={detail.disabled ? "text-red-400" : "text-green-400"}>
{detail.disabled ? "Disabled" : "Active"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Discord</span>
<span className="text-gray-300">
{detail.discord_linked
? `@${detail.discord_username ?? detail.discord_user_id}`
: "Not linked"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Created</span>
<span className="text-gray-300">{fmtDate(detail.creation_time)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Last sign-in</span>
<span className="text-gray-300">{fmtDate(detail.last_sign_in)}</span>
</div>
</div>
{(detail.sessions?.length ?? 0) > 0 && (
<div className="border-t border-gray-800 pt-4">
<button
onClick={() => setShowSessions((v) => !v)}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors flex items-center gap-1"
>
<span>{showSessions ? "▲" : "▼"}</span>
<span>Login history ({detail.sessions?.length} recent)</span>
</button>
{showSessions && (
<div className="mt-3 space-y-1.5 max-h-48 overflow-y-auto">
{detail.sessions?.map((s) => (
<div key={s.session_id} className="text-xs text-gray-400 flex justify-between gap-4">
<span>{fmtDatetime(s.timestamp)}</span>
<span className="text-gray-600 truncate">{s.ip ?? "—"}</span>
</div>
))}
</div>
)}
</div>
)}
<div className="border-t border-gray-800 pt-4 flex gap-4 flex-wrap">
{!isSelf ? (
<>
<button
onClick={handleToggleDisabled}
disabled={toggling}
className="text-xs text-yellow-500 hover:text-yellow-400 disabled:opacity-50 transition-colors"
>
{toggling ? "…" : detail.disabled ? "Enable account" : "Disable account"}
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="text-xs text-red-500 hover:text-red-400 disabled:opacity-50 transition-colors"
>
{deleting ? "Deleting…" : "Delete user"}
</button>
</>
) : (
<p className="text-xs text-gray-600">Cannot disable or delete your own account.</p>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 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<UserRole>("viewer");
const [nodeIds, setNodeIds] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [inviteLink, setInviteLink] = useState<string | null>(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 (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 space-y-4 font-mono">
<h2 className="text-white font-semibold">User Created</h2>
<p className="text-xs text-gray-400">
Share this one-time invite link with the new user so they can set their password.
It expires after use.
</p>
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
</div>
<div className="flex gap-3">
<button
onClick={copyLink}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
>
{copied ? "Copied!" : "Copy link"}
</button>
<button
onClick={onClose}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg py-2 text-sm transition-colors"
>
Done
</button>
</div>
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 font-mono">
<h2 className="text-white font-semibold mb-4">Create User</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">
Display Name <span className="text-gray-600">(optional)</span>
</label>
<input
value={displayName}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Role</label>
<select
value={role}
onChange={(e) => setRole(e.target.value as UserRole)}
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"
>
<option value="admin">Admin — full access</option>
<option value="operator">Operator — owns nodes</option>
<option value="viewer">Viewer — read-only</option>
</select>
</div>
{role === "operator" && (
<div>
<label className="text-xs text-gray-400 block mb-1">
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span>
</label>
<input
value={nodeIds}
onChange={(e) => 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"
/>
</div>
)}
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 pt-1">
<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 ? "Creating…" : "Create user"}
</button>
<button
type="button"
onClick={onClose}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Users tab
// ---------------------------------------------------------------------------
function UsersTab({ currentUid }: { currentUid: string }) {
const [users, setUsers] = useState<UserRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedUid, setSelectedUid] = useState<string | null>(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 (
<div className="space-y-4">
{showCreate && (
<CreateUserModal
onClose={() => setShowCreate(false)}
onCreated={(u) => { handleCreated(u); setShowCreate(false); }}
/>
)}
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500 font-mono">{users.length} user{users.length !== 1 ? "s" : ""}</p>
<button
onClick={() => setShowCreate(true)}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
>
+ Create user
</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>
)}
{loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p>
) : users.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No users found.</p>
) : (
<div className="border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-xs font-mono">
<thead>
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
<th className="px-4 py-2.5 text-left">Email</th>
<th className="px-4 py-2.5 text-left hidden lg:table-cell">Name</th>
<th className="px-4 py-2.5 text-left">Role</th>
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Discord</th>
<th className="px-4 py-2.5 text-left hidden md:table-cell">Last sign-in</th>
<th className="px-4 py-2.5 text-left">Status</th>
<th className="px-4 py-2.5 w-16"></th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr
key={u.uid}
className={`border-t border-gray-800 transition-colors ${
selectedUid === u.uid ? "bg-gray-800/60" : "hover:bg-gray-900/60"
}`}
>
<td className="px-4 py-2.5 text-gray-200">{u.email ?? "—"}</td>
<td className="px-4 py-2.5 text-gray-400 hidden lg:table-cell">{u.display_name ?? "—"}</td>
<td className="px-4 py-2.5"><RoleBadge role={u.role} /></td>
<td className="px-4 py-2.5 text-gray-500 hidden sm:table-cell">
{u.discord_linked ? `@${u.discord_username ?? "linked"}` : "—"}
</td>
<td className="px-4 py-2.5 text-gray-500 hidden md:table-cell">{fmtDate(u.last_sign_in)}</td>
<td className="px-4 py-2.5">
{u.disabled
? <span className="text-red-500">Disabled</span>
: <span className="text-green-500">Active</span>
}
</td>
<td className="px-4 py-2.5 text-right">
<button
onClick={() => setSelectedUid(selectedUid === u.uid ? null : u.uid)}
className="text-indigo-400 hover:text-indigo-300 transition-colors"
>
{selectedUid === u.uid ? "Close" : "Edit"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{selectedUser && (
<UserDetailPanel
user={selectedUser}
onClose={() => setSelectedUid(null)}
onUpdated={handleUpdated}
currentUid={currentUid}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Audit Log tab
// ---------------------------------------------------------------------------
function AuditLogTab() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="space-y-4">
{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>
) : entries.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No audit entries yet.</p>
) : (
<>
<div className="border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-xs font-mono">
<thead>
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
<th className="px-4 py-2.5 text-left">Time</th>
<th className="px-4 py-2.5 text-left">Action</th>
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Actor</th>
<th className="px-4 py-2.5 text-left hidden md:table-cell">Target</th>
<th className="px-4 py-2.5 text-left">Details</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.log_id} className="border-t border-gray-800 hover:bg-gray-900/40">
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">{fmtDatetime(e.timestamp)}</td>
<td className={`px-4 py-2.5 whitespace-nowrap ${actionColor(e.action)}`}>{e.action}</td>
<td className="px-4 py-2.5 text-gray-400 hidden sm:table-cell">{e.actor_email}</td>
<td className="px-4 py-2.5 text-gray-400 hidden md:table-cell">{e.target_email ?? "—"}</td>
<td className="px-4 py-2.5 text-gray-600 max-w-xs truncate">
{Object.keys(e.details).length > 0
? Object.entries(e.details)
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
.join(" · ")
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
{hasMore && (
<button
onClick={loadMore}
disabled={loadingMore}
className="text-sm font-mono text-indigo-400 hover:text-indigo-300 disabled:opacity-50 transition-colors"
>
{loadingMore ? "Loading…" : "Load more"}
</button>
)}
</>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Stale Calls tab
// ---------------------------------------------------------------------------
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 || result.count === 0}
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"
>
{result && !result.dry_run ? "Closed" : result?.count ? `Close ${result.count} calls` : "Close 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>
);
}
// ---------------------------------------------------------------------------
// STT eval (server-26#163) — the eval harness for real transcription
// accuracy. Separate from patchTranscript's "fix this call" flow: this never
// re-runs extraction or touches an incident, it only records what was
// actually said next to what Whisper heard, so eval-stats can report a real
// WER instead of a guess. Built to be worked in short sessions, a handful of
// calls at a time, over however many sittings it takes — not a one-shot form.
// ---------------------------------------------------------------------------
function fmtPct(x: number | null | undefined): string {
return x === null || x === undefined ? "—" : `${(x * 100).toFixed(1)}%`;
}
function EvalStatsBar({ stats }: { stats: { eval_count: number; raw_wer: number | null; corrected_wer: number | null } | null }) {
return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 flex flex-wrap gap-x-8 gap-y-2">
<div>
<p className="text-xs text-gray-500 font-mono">Calls verified</p>
<p className="text-white text-lg font-mono">{stats?.eval_count ?? "—"}</p>
</div>
<div>
<p className="text-xs text-gray-500 font-mono">Raw WER (whisper-1)</p>
<p className="text-white text-lg font-mono">{fmtPct(stats?.raw_wer)}</p>
</div>
<div>
<p className="text-xs text-gray-500 font-mono">Corrected WER (shipped)</p>
<p className="text-white text-lg font-mono">{fmtPct(stats?.corrected_wer)}</p>
</div>
{stats && stats.eval_count > 0 && stats.eval_count < 20 && (
<p className="text-xs text-amber-400 font-mono self-end">
fewer than 20 calls — numbers will move a lot until this grows
</p>
)}
</div>
);
}
function SttEvalTab() {
const [stats, setStats] = useState<{ eval_count: number; raw_wer: number | null; corrected_wer: number | null } | null>(null);
const [queue, setQueue] = useState<CallRecord[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [exhausted, setExhausted] = useState(false);
const [draft, setDraft] = useState("");
const [loadingBatch, setLoadingBatch] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetching = useRef(false);
const current = queue[0] ?? null;
const refreshStats = useCallback(() => {
c2api.getEvalStats().then(setStats).catch(() => { /* stats are a nice-to-have, not load-bearing */ });
}, []);
const loadBatch = useCallback(async () => {
if (fetching.current) return;
fetching.current = true;
setLoadingBatch(true);
setError(null);
try {
const res = await c2api.getEvalQueue(5, cursor);
setQueue((q) => [...q, ...res.calls]);
setCursor(res.next_cursor);
if (res.calls.length === 0 && !res.next_cursor) setExhausted(true);
} catch (e) {
setError(String(e));
} finally {
setLoadingBatch(false);
fetching.current = false;
}
}, [cursor]);
useEffect(() => { refreshStats(); }, [refreshStats]);
// Auto-refill: whenever the local queue runs dry and there's more to scan
// (or we haven't checked yet), pull another batch. Covers the sparse-window
// case too — a page with matches:0 but a next_cursor just means "keep
// scanning", not "done", so this fires again on its own.
useEffect(() => {
if (queue.length === 0 && !exhausted) loadBatch();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queue.length, exhausted]);
useEffect(() => {
setDraft(current ? (current.transcript_corrected || current.transcript || "") : "");
}, [current]);
async function saveAndNext() {
if (!current) return;
setSaving(true);
setError(null);
try {
await c2api.putEvalTranscript(current.call_id, draft);
setQueue((q) => q.slice(1));
refreshStats();
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
}
function skip() {
setQueue((q) => q.slice(1));
}
return (
<div className="space-y-4">
<p className="text-xs text-gray-500 font-mono">
Listen to the audio, correct the transcript below until it matches what was actually said, then save.
This never touches the call&apos;s real transcript or re-runs anything — it only records ground truth
for measuring the pipeline. Do as many or as few as you have time for; it picks up where you left off.
</p>
<EvalStatsBar stats={stats} />
{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>
)}
{current ? (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-gray-400">
<span>{new Date(current.started_at).toLocaleString()}</span>
<span>{current.talkgroup_name || (current.talkgroup_id ? `TGID ${current.talkgroup_id}` : "unknown talkgroup")}</span>
</div>
{current.audio_url ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<audio controls src={current.audio_url} className="w-full h-9" />
) : (
<p className="text-xs text-gray-500 italic">No audio on this call — skip it.</p>
)}
<div>
<label className="text-xs text-gray-400 block mb-1">
Machine transcript (pre-filled) — correct it into what was actually said
</label>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={4}
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"
/>
</div>
<div className="flex gap-2">
<button
onClick={saveAndNext}
disabled={saving || !draft.trim()}
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"
>
{saving ? "Saving…" : "Save & next"}
</button>
<button
onClick={skip}
disabled={saving}
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"
>
Skip
</button>
</div>
</div>
) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
<p className="text-sm font-mono text-gray-400">
{loadingBatch ? "Loading calls…" : exhausted ? "Nothing left to verify right now — check back after more calls come in." : "Loading…"}
</p>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Main admin page
// ---------------------------------------------------------------------------
type AdminTab = "features" | "correlation" | "users" | "audit" | "calls" | "eval";
const TAB_LABELS: { key: AdminTab; label: string }[] = [
{ key: "features", label: "AI Features" },
{ key: "correlation", label: "Correlation Debug" },
{ key: "calls", label: "Calls" },
{ key: "eval", label: "STT Eval" },
{ key: "users", label: "Users" },
{ key: "audit", label: "Audit Log" },
];
export default function AdminPage() {
const { user, isAdmin, loading: authLoading } = useAuth();
const router = useRouter();
const [tab, setTab] = useState<AdminTab>("features");
// Wait for the claims to resolve before deciding. isAdmin is false for the
// first render of every cold load (typed URL, hard refresh, bookmark) while
// AuthProvider fetches the ID token, so a guard that ignores authLoading
// redirects the admin off their own page every time and only ever lets them
// in via an in-app link. Same shape as /nodes, /systems and /settings.
useEffect(() => {
if (!authLoading && !isAdmin) router.replace("/");
}, [authLoading, isAdmin, router]);
if (authLoading) return null;
if (!isAdmin) return null;
// Users/Audit tabs benefit from full width; everything else is narrow
const wide = tab === "users" || tab === "audit";
return (
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
<h1 className="text-white text-xl font-bold font-mono">Admin</h1>
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
{TAB_LABELS.map(({ key, label }) => (
<button
key={key}
onClick={() => setTab(key)}
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${
tab === key ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
}`}
>
{label}
</button>
))}
</div>
{tab === "features" && <FeaturesTab />}
{tab === "correlation" && <CorrelationDebugTab />}
{tab === "calls" && <StaleCallsTab />}
{tab === "eval" && <SttEvalTab />}
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
{tab === "audit" && <AuditLogTab />}
</div>
);
}