fix(frontend): finish the token migration — raw grey classes -> theme tokens
UI_REDESIGN chunk 4 was never finished: 39 app/component files still used raw Tailwind greys, kept readable in light mode only by the !important override sheet. In dark mode text-gray-600 is ~2.3:1 on surface, and the light override maps it to #94a3b8 (~2.6:1 on white) — unreadable in both. Mechanical mapping (886 classes): gray-100/200 -> ink, 300/400 -> ink-2, 500/600/700 -> ink-muted, bg 950/900/800 -> page/surface/raised, border 800 -> line, 700/600 -> line-strong, plus hover/divide/placeholder. Alpha suffixes kept (tokens are color-mix, so /60 works). bg-gray-700, text-white and marketing pages left alone. text-[9px] -> text-[10px]. Frontend unverified: no Node on the authoring box (#207). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3968b49b80
commit
bf206334e3
+109
-109
@@ -85,7 +85,7 @@ function fmtDatetime(iso: string | null | undefined) {
|
||||
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",
|
||||
viewer: "bg-raised text-ink-2",
|
||||
};
|
||||
|
||||
function RoleBadge({ role }: { role: UserRole }) {
|
||||
@@ -136,14 +136,14 @@ function FeaturesTab() {
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||
) : (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
||||
<div className="bg-surface border border-line rounded-xl divide-y divide-line">
|
||||
{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>
|
||||
<p className="text-ink-muted text-xs mt-0.5 leading-snug">{description}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
enabled={flags?.[key] ?? true}
|
||||
@@ -238,30 +238,30 @@ function CorrelationDebugTab() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-gray-500 font-mono">
|
||||
<p className="text-xs text-ink-muted 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>
|
||||
<label className="text-xs text-ink-2 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"
|
||||
className="w-24 bg-raised border border-line-strong 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>
|
||||
<label className="text-xs text-ink-2 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"
|
||||
className="w-28 bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@@ -281,12 +281,12 @@ function CorrelationDebugTab() {
|
||||
|
||||
{meta && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-gray-500 font-mono">
|
||||
<p className="text-xs text-ink-muted 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"
|
||||
className="text-xs font-mono px-3 py-1.5 rounded-lg border border-line-strong bg-surface text-ink-2 hover:text-white transition-colors"
|
||||
>
|
||||
{copied ? "Copied!" : "Copy JSON"}
|
||||
</button>
|
||||
@@ -295,22 +295,22 @@ function CorrelationDebugTab() {
|
||||
|
||||
{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>
|
||||
<div className="bg-surface border border-line rounded-xl p-4 space-y-1">
|
||||
<p className="text-xs text-ink-2 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-ink-2">{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>
|
||||
<div className="bg-surface border border-line rounded-xl p-4 space-y-1">
|
||||
<p className="text-xs text-ink-2 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>
|
||||
? <p className="text-xs text-ink-muted 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={sig === "time_fallback" ? "text-yellow-400" : "text-ink-2"}>{sig}</span>
|
||||
<span className="text-indigo-400">{n}</span>
|
||||
</div>
|
||||
))
|
||||
@@ -320,13 +320,13 @@ function CorrelationDebugTab() {
|
||||
)}
|
||||
|
||||
{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>
|
||||
<div className="bg-surface border border-line rounded-xl p-4 space-y-1">
|
||||
<p className="text-xs text-ink-2 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 className="text-ink-2">{tg.talkgroup_name} <span className="text-ink-muted">({tg.talkgroup_id})</span></span>
|
||||
<span className="text-ink-2">
|
||||
{tg.count} total · <span className="text-ink-muted">{tg.no_type_count} no-type</span> · <span className="text-red-400">{tg.sweep_exhausted_count} exhausted</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -336,7 +336,7 @@ function CorrelationDebugTab() {
|
||||
{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"
|
||||
className="bg-page border border-line rounded-xl p-4 text-xs text-ink-2 font-mono overflow-auto max-h-[60vh] whitespace-pre"
|
||||
>
|
||||
{json}
|
||||
</pre>
|
||||
@@ -455,13 +455,13 @@ function UserDetailPanel({
|
||||
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="bg-surface border border-line-strong 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>
|
||||
<p className="text-xs text-ink-muted 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>
|
||||
<button onClick={onClose} className="text-ink-muted hover:text-ink transition-colors text-xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -472,21 +472,21 @@ function UserDetailPanel({
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Display Name</label>
|
||||
<label className="text-xs text-ink-2 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"
|
||||
className="w-full bg-raised border border-line-strong 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>
|
||||
<label className="text-xs text-ink-2 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"
|
||||
className="w-full bg-raised border border-line-strong 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>
|
||||
@@ -496,13 +496,13 @@ function UserDetailPanel({
|
||||
|
||||
{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 className="text-xs text-ink-2 block mb-1">
|
||||
Owned Node IDs <span className="text-ink-muted">(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"
|
||||
className="w-full bg-raised border border-line-strong 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>
|
||||
@@ -517,10 +517,10 @@ function UserDetailPanel({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-800 pt-4 space-y-2 text-xs">
|
||||
<div className="border-t border-line pt-4 space-y-2 text-xs">
|
||||
<div className="flex justify-between items-center gap-3">
|
||||
<span className="text-gray-500">Organization</span>
|
||||
<span className={`font-mono truncate ${detail.org_id && detail.org_id === myOrgId ? "text-gray-300" : "text-yellow-400"}`}>
|
||||
<span className="text-ink-muted">Organization</span>
|
||||
<span className={`font-mono truncate ${detail.org_id && detail.org_id === myOrgId ? "text-ink-2" : "text-yellow-400"}`}>
|
||||
{!detail.org_id
|
||||
? "None: sees no data"
|
||||
: detail.org_id === myOrgId
|
||||
@@ -538,34 +538,34 @@ function UserDetailPanel({
|
||||
</button>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Status</span>
|
||||
<span className="text-ink-muted">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">
|
||||
<span className="text-ink-muted">Discord</span>
|
||||
<span className="text-ink-2">
|
||||
{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>
|
||||
<span className="text-ink-muted">Created</span>
|
||||
<span className="text-ink-2">{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>
|
||||
<span className="text-ink-muted">Last sign-in</span>
|
||||
<span className="text-ink-2">{fmtDate(detail.last_sign_in)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(detail.sessions?.length ?? 0) > 0 && (
|
||||
<div className="border-t border-gray-800 pt-4">
|
||||
<div className="border-t border-line pt-4">
|
||||
<button
|
||||
onClick={() => setShowSessions((v) => !v)}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 transition-colors flex items-center gap-1"
|
||||
className="text-xs text-ink-muted hover:text-ink transition-colors flex items-center gap-1"
|
||||
>
|
||||
<span>{showSessions ? "▲" : "▼"}</span>
|
||||
<span>Login history ({detail.sessions?.length} recent)</span>
|
||||
@@ -573,9 +573,9 @@ function UserDetailPanel({
|
||||
{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">
|
||||
<div key={s.session_id} className="text-xs text-ink-2 flex justify-between gap-4">
|
||||
<span>{fmtDatetime(s.timestamp)}</span>
|
||||
<span className="text-gray-600 truncate">{s.ip ?? "—"}</span>
|
||||
<span className="text-ink-muted truncate">{s.ip ?? "—"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -583,7 +583,7 @@ function UserDetailPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-gray-800 pt-4 flex gap-4 flex-wrap">
|
||||
<div className="border-t border-line pt-4 flex gap-4 flex-wrap">
|
||||
{!isSelf ? (
|
||||
<>
|
||||
<button
|
||||
@@ -602,7 +602,7 @@ function UserDetailPanel({
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600">Cannot disable or delete your own account.</p>
|
||||
<p className="text-xs text-ink-muted">Cannot disable or delete your own account.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -667,19 +667,19 @@ function CreateUserModal({
|
||||
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">
|
||||
<div className="bg-surface border border-line-strong 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">
|
||||
<p className="text-xs text-ink-2">
|
||||
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">
|
||||
<div className="bg-raised border border-line-strong 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"
|
||||
className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
|
||||
>
|
||||
{copied ? "Copied!" : "Copy link"}
|
||||
</button>
|
||||
@@ -697,37 +697,37 @@ function CreateUserModal({
|
||||
|
||||
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">
|
||||
<div className="bg-surface border border-line-strong 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>
|
||||
<label className="text-xs text-ink-2 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"
|
||||
className="w-full bg-raised border border-line-strong 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 className="text-xs text-ink-2 block mb-1">
|
||||
Display Name <span className="text-ink-muted">(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"
|
||||
className="w-full bg-raised border border-line-strong 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>
|
||||
<label className="text-xs text-ink-2 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"
|
||||
className="w-full bg-raised border border-line-strong 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>
|
||||
@@ -736,14 +736,14 @@ function CreateUserModal({
|
||||
</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 className="text-xs text-ink-2 block mb-1">
|
||||
Owned Node IDs <span className="text-ink-muted">(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"
|
||||
className="w-full bg-raised border border-line-strong 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>
|
||||
@@ -760,7 +760,7 @@ function CreateUserModal({
|
||||
<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"
|
||||
className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -820,7 +820,7 @@ function UsersTab({ currentUid }: { currentUid: string }) {
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-gray-500 font-mono">{users.length} user{users.length !== 1 ? "s" : ""}</p>
|
||||
<p className="text-xs text-ink-muted 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"
|
||||
@@ -836,14 +836,14 @@ function UsersTab({ currentUid }: { currentUid: string }) {
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||
) : users.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono">No users found.</p>
|
||||
<p className="text-ink-muted text-sm font-mono">No users found.</p>
|
||||
) : (
|
||||
<div className="border border-gray-800 rounded-xl overflow-hidden">
|
||||
<div className="border border-line rounded-xl overflow-hidden">
|
||||
<table className="w-full text-xs font-mono">
|
||||
<thead>
|
||||
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
|
||||
<tr className="bg-surface text-ink-muted 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>
|
||||
@@ -857,17 +857,17 @@ function UsersTab({ currentUid }: { currentUid: string }) {
|
||||
{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"
|
||||
className={`border-t border-line transition-colors ${
|
||||
selectedUid === u.uid ? "bg-raised/60" : "hover:bg-raised/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 text-ink">{u.email ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 text-ink-2 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">
|
||||
<td className="px-4 py-2.5 text-ink-muted 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 text-ink-muted 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>
|
||||
@@ -952,15 +952,15 @@ function AuditLogTab() {
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono">No audit entries yet.</p>
|
||||
<p className="text-ink-muted text-sm font-mono">No audit entries yet.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="border border-gray-800 rounded-xl overflow-hidden">
|
||||
<div className="border border-line rounded-xl overflow-hidden">
|
||||
<table className="w-full text-xs font-mono">
|
||||
<thead>
|
||||
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
|
||||
<tr className="bg-surface text-ink-muted 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>
|
||||
@@ -970,12 +970,12 @@ function AuditLogTab() {
|
||||
</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>
|
||||
<tr key={e.log_id} className="border-t border-line hover:bg-raised/40">
|
||||
<td className="px-4 py-2.5 text-ink-muted 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">
|
||||
<td className="px-4 py-2.5 text-ink-2 hidden sm:table-cell">{e.actor_email}</td>
|
||||
<td className="px-4 py-2.5 text-ink-2 hidden md:table-cell">{e.target_email ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 text-ink-muted max-w-xs truncate">
|
||||
{Object.keys(e.details).length > 0
|
||||
? Object.entries(e.details)
|
||||
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
|
||||
@@ -1029,26 +1029,26 @@ function StaleCallsTab() {
|
||||
|
||||
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.
|
||||
<p className="text-xs text-ink-muted font-mono">
|
||||
Finds calls stuck in <span className="text-ink-2">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>
|
||||
<label className="text-xs text-ink-2 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"
|
||||
className="w-28 bg-raised border border-line-strong 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"
|
||||
className="bg-raised hover:bg-gray-700 disabled:opacity-50 border border-line-strong text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? "Working…" : "Preview"}
|
||||
</button>
|
||||
@@ -1068,18 +1068,18 @@ function StaleCallsTab() {
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-2">
|
||||
<div className="bg-surface border border-line 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>}
|
||||
{result.count === 0 && <span className="text-ink-muted"> — 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>
|
||||
<p key={id} className="text-xs font-mono text-ink-2">{id}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -1104,17 +1104,17 @@ function fmtPct(x: number | null | undefined): string {
|
||||
|
||||
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 className="bg-surface border border-line 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-xs text-ink-muted 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-xs text-ink-muted 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-xs text-ink-muted 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 && (
|
||||
@@ -1197,7 +1197,7 @@ function SttEvalTab() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-gray-500 font-mono">
|
||||
<p className="text-xs text-ink-muted font-mono">
|
||||
Listen to the audio, correct the transcript below until it matches what was actually said, then save.
|
||||
This never touches the call'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.
|
||||
@@ -1212,8 +1212,8 @@ function SttEvalTab() {
|
||||
)}
|
||||
|
||||
{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">
|
||||
<div className="bg-surface border border-line 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-ink-2">
|
||||
<span>{new Date(current.started_at).toLocaleString()}</span>
|
||||
<span>{current.talkgroup_name || (current.talkgroup_id ? `TGID ${current.talkgroup_id}` : "unknown talkgroup")}</span>
|
||||
</div>
|
||||
@@ -1222,18 +1222,18 @@ function SttEvalTab() {
|
||||
// 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>
|
||||
<p className="text-xs text-ink-muted italic">No audio on this call — skip it.</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">
|
||||
<label className="text-xs text-ink-2 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"
|
||||
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1248,15 +1248,15 @@ function SttEvalTab() {
|
||||
<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"
|
||||
className="bg-raised hover:bg-gray-700 disabled:opacity-50 border border-line-strong 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">
|
||||
<div className="bg-surface border border-line rounded-xl p-4">
|
||||
<p className="text-sm font-mono text-ink-2">
|
||||
{loadingBatch ? "Loading calls…" : exhausted ? "Nothing left to verify right now — check back after more calls come in." : "Loading…"}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1312,13 +1312,13 @@ export default function AdminPage() {
|
||||
<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">
|
||||
<div className="flex flex-wrap gap-1 bg-surface border border-line 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"
|
||||
tab === key ? "bg-raised text-white" : "text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
|
||||
Reference in New Issue
Block a user