Central Verification Dashboard

This commit is contained in:
2026-06-16 14:32:21 +05:30
commit 7cb85d5ebc
37 changed files with 9665 additions and 0 deletions

568
dashboard/src/App.tsx Normal file
View File

@@ -0,0 +1,568 @@
import { useCallback, useEffect, useState } from "react";
import {
api, exportProjectUrl, exportVideoUrl, fmtDuration, fmtWhen, setAuthToken,
type Activity, type Member, type ProjectSummary, type Stats, type UserRow, type VideoRow,
} from "./api";
import { RemarkCell } from "./RemarkCell";
import { AdminPanel } from "./AdminPanel";
import { MembersPicker } from "./MembersPicker";
import { FoldersPicker } from "./FoldersPicker";
import { SignIn } from "./SignIn";
const TOKEN_KEY = "central-token";
const POLL_MS = 10_000;
const IDLE_MS = 10 * 60_000; // auto-logout after 10 minutes of inactivity
type Auth = { username: string; role: string };
export function App() {
// ---- auth gate ----
const [token, setToken] = useState<string>(() => localStorage.getItem(TOKEN_KEY) ?? "");
const [auth, setAuth] = useState<Auth | null>(null);
const [authChecked, setAuthChecked] = useState(false);
const [expiredReason, setExpiredReason] = useState<string | null>(null);
const isAdmin = auth?.role === "admin";
const signOut = useCallback((reason?: string) => {
setAuth(null);
setToken("");
setAuthToken("");
localStorage.removeItem(TOKEN_KEY);
setExpiredReason(reason ?? null);
}, []);
const onSignedIn = useCallback((t: string, me: Auth) => {
setAuthToken(t);
localStorage.setItem(TOKEN_KEY, t);
setToken(t);
setAuth(me);
setExpiredReason(null);
}, []);
// Restore a persisted session on load (silent whoami — no audit spam).
useEffect(() => {
let cancel = false;
(async () => {
if (token) {
setAuthToken(token);
try {
const me = await api.whoami(token);
if (!cancel) setAuth(me);
} catch {
if (!cancel) { setAuthToken(""); localStorage.removeItem(TOKEN_KEY); }
}
}
if (!cancel) setAuthChecked(true);
})();
return () => { cancel = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Idle auto-logout: any activity resets a 10-minute timer.
useEffect(() => {
if (!auth) return;
let timer = 0;
const reset = () => {
clearTimeout(timer);
timer = window.setTimeout(
() => signOut("Your session expired because of inactivity. Please sign in again."),
IDLE_MS,
);
};
const events = ["mousemove", "mousedown", "keydown", "scroll", "touchstart", "click"];
events.forEach((e) => window.addEventListener(e, reset, { passive: true }));
reset();
return () => { clearTimeout(timer); events.forEach((e) => window.removeEventListener(e, reset)); };
}, [auth, signOut]);
// ---- dashboard data ----
const [view, setView] = useState<"dashboard" | "admin">("dashboard");
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [projectId, setProjectId] = useState<number | null>(null);
const [stats, setStats] = useState<Stats | null>(null);
const [activity, setActivity] = useState<Activity | null>(null);
const [videos, setVideos] = useState<VideoRow[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// Assignment (admin): selected video ids; the dropdown lists the project's members.
const [selected, setSelected] = useState<Set<number>>(new Set());
const [members, setMembers] = useState<Member[]>([]);
const [assignee, setAssignee] = useState("");
// All ml_support users (admin only) — the pool the members picker chooses from.
const [users, setUsers] = useState<UserRow[]>([]);
const mlUsers = users.filter((u) => u.role === "ml_support");
useEffect(() => {
if (!isAdmin) { setUsers([]); return; }
api.listUsers().then(setUsers).catch(() => setUsers([]));
}, [isAdmin]);
const toggleSel = (id: number) =>
setSelected((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
const loadProject = useCallback(async (id: number) => {
try {
const [s, v, a, m] = await Promise.all([
api.stats(id), api.videos(id), api.activity(id),
api.members(id).catch(() => [] as Member[]),
]);
setStats(s);
setVideos(v);
setActivity(a);
setMembers(m);
setError(null);
} catch (e) {
setError(String(e));
}
}, []);
const loadProjects = useCallback(async () => {
try {
const p = await api.projects();
setProjects(p);
setProjectId((cur) => cur ?? (p[0]?.id ?? null));
setError(null);
} catch (e) {
setError(String(e));
}
}, []);
const assignSelected = useCallback(async () => {
if (!isAdmin || selected.size === 0 || projectId == null) return;
setBusy(true);
try {
await api.assign(projectId, [...selected], assignee);
setSelected(new Set());
await loadProject(projectId);
setError(null);
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}, [isAdmin, selected, assignee, projectId, loadProject]);
useEffect(() => { if (auth) void loadProjects(); }, [auth, loadProjects]);
useEffect(() => {
if (!auth || projectId == null) return;
setSelected(new Set()); // clear selection when switching projects
void loadProject(projectId);
const t = setInterval(() => { void loadProject(projectId); void loadProjects(); }, POLL_MS);
return () => clearInterval(t);
}, [auth, projectId, loadProject, loadProjects]);
const timer = useCallback(async (action: "start" | "stop" | "reset") => {
if (projectId == null) return;
if (action === "reset" && !window.confirm("Reset the project timer back to zero?")) return;
try {
await api.setTimer(projectId, action);
await loadProject(projectId);
} catch (e) {
setError(String(e));
}
}, [projectId, loadProject]);
const toggleIgnore = useCallback(async (v: VideoRow) => {
if (!isAdmin || projectId == null) return;
try {
await api.setIgnore(v.id, !v.ignored);
await loadProject(projectId);
} catch (e) {
setError(String(e));
}
}, [isAdmin, projectId, loadProject]);
const reingest = useCallback(async () => {
if (projectId == null) return;
setBusy(true);
try {
await api.ingest(projectId);
await loadProject(projectId);
await loadProjects();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}, [projectId, loadProject, loadProjects]);
if (!authChecked) {
return <div className="signin-screen"><div className="dim">Loading</div></div>;
}
if (!auth) {
return <SignIn onSignedIn={onSignedIn} reason={expiredReason} />;
}
const project = projects.find((p) => p.id === projectId) ?? null;
const ov = stats?.overview;
const groups = groupByClient(projects);
return (
<div className="page">
<header className="topbar">
<div className="brand">Verification Dashboard</div>
<div className="viewtabs">
<button className={view === "dashboard" ? "active" : ""} onClick={() => setView("dashboard")}>Dashboard</button>
<button className={view === "admin" ? "active" : ""} onClick={() => setView("admin")}>Admin</button>
</div>
{view === "dashboard" && (
<>
<select
value={projectId ?? ""}
onChange={(e) => setProjectId(Number(e.target.value))}
disabled={projects.length === 0}
>
{groups.map(([client, ps]) => (
<optgroup key={client} label={client}>
{ps.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</optgroup>
))}
</select>
<button onClick={reingest} disabled={busy || projectId == null}>
{busy ? "Ingesting…" : "Re-ingest"}
</button>
</>
)}
<span className="spacer" />
{view === "dashboard" && project && (
<span className="dim path" title={project.source_path}>
{project.client_name ? `${project.client_name} · ` : ""}{project.source_path}
</span>
)}
<span className="whoami" title={`role: ${auth.role}`}>{auth.username} · {auth.role}</span>
<button className="signout-btn" onClick={() => signOut()}>Sign out</button>
</header>
{error && <div className="error"> {error}</div>}
{view === "admin" && <AdminPanel token={token} role={auth.role} onChanged={loadProjects} />}
{view === "dashboard" && (
<>
{/* Overview */}
{ov && (
<section className="cards">
<Card label="Total videos" value={ov.total} />
<Card label="Pending" value={ov.pending} tone="warn" />
<Card label="Annotated" value={ov.annotated} tone="info" />
<Card label="Verified" value={ov.verified} tone="ok" />
<div className="card progress-card">
<div className="card-label">Verified progress</div>
<div className="bar">
<div className="bar-fill" style={{ width: `${ov.pct_done}%` }} />
</div>
<div className="card-value sm">{ov.pct_done.toFixed(1)}%
{stats?.timeline.eta_ms != null && (
<span className="dim"> · ETA ~{fmtDuration(stats.timeline.eta_ms)}</span>
)}
</div>
</div>
</section>
)}
{/* Project time & estimate (driven by the assigned users) */}
{stats && ov && (
<section className="panel time-panel">
<div className="panel-head">
<h3>Project time &amp; estimate</h3>
{isAdmin && projectId != null && (
<div className="timer-controls">
<span className={`timer-state ${stats.timeline.time_running ? "on" : "off"}`}>
{stats.timeline.time_running ? "● running" : "⏸ stopped"}
</span>
{stats.timeline.time_running
? <button className="btn" onClick={() => timer("stop")}>Stop</button>
: <button className="btn" onClick={() => timer("start")}>Start</button>}
<button className="btn danger" onClick={() => timer("reset")}>Reset</button>
</div>
)}
</div>
<div className="time-grid">
<div className="tcell big">
<span className="dim">Total project time</span>
<b>{fmtDuration(stats.timeline.elapsed_ms)} {stats.timeline.all_verified ? "✓" : ""}</b>
<span className="dim sub">inception {stats.timeline.all_verified ? "all verified" : "now (ongoing)"}</span>
</div>
<div className="tcell">
<span className="dim">Total hours all users</span>
<b>{fmtDuration(stats.timeline.total_spent_ms)}</b>
<span className="dim sub">annotation + verify, summed</span>
</div>
<div className="tcell">
<span className="dim">Assigned users</span>
<b>{stats.timeline.active_workers}</b>
<span className="dim sub">{stats.timeline.active_workers === 0 ? "assign users to estimate" : "drives the estimate"}</span>
</div>
<div className="tcell">
<span className="dim">Avg per video</span>
<b>{fmtDuration(stats.timeline.avg_video_ms)}</b>
<span className="dim sub">over verified videos</span>
</div>
<div className="tcell big">
<span className="dim">Estimated time to finish</span>
<b>{stats.timeline.eta_ms != null ? fmtDuration(stats.timeline.eta_ms) : "—"}</b>
<span className="dim sub">
{ov.total - ov.verified} left ÷ {Math.max(1, stats.timeline.active_workers)} user(s) @ {fmtDuration(stats.timeline.avg_video_ms)}/video
</span>
</div>
</div>
</section>
)}
<div className="grid2">
{/* Leaderboard + project members picker */}
<section className="panel">
<div className="panel-head">
<h3>Per-user leaderboard</h3>
{isAdmin && projectId != null && (
<MembersPicker
projectId={projectId}
users={mlUsers}
members={members}
onChange={() => loadProject(projectId)}
/>
)}
</div>
{isAdmin && members.length === 0 && (
<div className="members-cta">No users assigned to this project yet use <b>+ Assign users</b> to pick ml_support, then assign them videos.</div>
)}
<table>
<thead>
<tr><th>User</th><th>Videos</th><th>Annotations</th><th>Total time</th><th>Avg / video</th></tr>
</thead>
<tbody>
{stats?.leaderboard.map((r) => (
<tr key={r.user}>
<td>{r.user}</td>
<td>{r.videos}</td>
<td>{r.annotations}</td>
<td>{fmtDuration(r.total_time_ms)}</td>
<td>{fmtDuration(r.avg_time_ms)}</td>
</tr>
))}
{(!stats || stats.leaderboard.length === 0) && (
<tr><td colSpan={5} className="dim">no annotators yet</td></tr>
)}
</tbody>
</table>
</section>
{/* Verification pace (rate-based, replaces the old per-day bars) */}
<section className="panel">
<h3>Verification pace</h3>
{stats && ov ? (() => {
const t = stats.timeline;
const perDay = t.recent_window_days > 0 ? t.recent_verified / t.recent_window_days : 0;
const remaining = ov.total - ov.verified;
const paceDays = perDay > 0 ? Math.ceil(remaining / perDay) : null;
return (
<div className="pace-grid">
<div className="pcell">
<span className="dim">Verified / day</span>
<b>{perDay.toFixed(1)}</b>
<span className="dim sub">last {t.recent_window_days} days ({t.recent_verified} done)</span>
</div>
<div className="pcell">
<span className="dim">Avg per video</span>
<b>{fmtDuration(t.avg_video_ms)}</b>
<span className="dim sub">over verified videos</span>
</div>
<div className="pcell big">
<span className="dim">Finish at recent pace</span>
<b>{paceDays != null ? `~${paceDays} day${paceDays === 1 ? "" : "s"}` : "—"}</b>
<span className="dim sub">{perDay > 0 ? `${remaining} left ÷ ${perDay.toFixed(1)}/day (all users' actual output)` : "no recent activity"}</span>
</div>
<div className="pcell big">
<span className="dim">Est. to finish · {Math.max(1, t.active_workers)} user{t.active_workers === 1 ? "" : "s"}</span>
<b>{t.eta_ms != null ? fmtDuration(t.eta_ms) : "—"}</b>
<span className="dim sub">{remaining} left × {fmtDuration(t.avg_video_ms)} ÷ {Math.max(1, t.active_workers)} user{t.active_workers === 1 ? "" : "s"}</span>
</div>
</div>
);
})() : <div className="dim">no data yet</div>}
</section>
</div>
<div className="grid2">
{/* Verification leaderboard — assigned users ranked by completion speed */}
<section className="panel">
<h3>Verification leaderboard <span className="dim">· fastest first</span></h3>
<table>
<thead>
<tr><th>Rank</th><th>User</th><th>Verified</th><th>Speed (avg/video)</th><th>Annotations</th><th>Total time</th></tr>
</thead>
<tbody>
{stats?.verifiers.map((r) => (
<tr key={r.user} className={r.rank === 1 ? "rank-top" : undefined}>
<td>{r.rank > 0 ? (r.rank === 1 ? "🥇 1" : `#${r.rank}`) : <span className="dim"></span>}</td>
<td>{r.user}</td>
<td>{r.videos > 0 ? r.videos : <span className="dim">0</span>}</td>
<td>{r.videos > 0 ? fmtDuration(r.avg_time_ms) : <span className="dim"></span>}</td>
<td>{r.annotations > 0 ? r.annotations : <span className="dim">0</span>}</td>
<td>{r.videos > 0 ? fmtDuration(r.total_time_ms) : <span className="dim"></span>}</td>
</tr>
))}
{(!stats || stats.verifiers.length === 0) && (
<tr><td colSpan={6} className="dim">no assigned users yet</td></tr>
)}
</tbody>
</table>
</section>
{/* Recent activity (live claims + event feed) */}
<section className="panel">
<h3>Recent activity</h3>
{activity && activity.active_claims.length > 0 && (
<div className="active-claims">
{activity.active_claims.map((c) => (
<div className="active-claim" key={c.video_id} title={`lease expires ${fmtWhen(c.lease_expires_at)}`}>
🔒 <b>{c.claimed_by}</b> has <span className="mono">{c.file_name}</span> <span className="dim">· since {fmtWhen(c.claimed_at)}</span>
</div>
))}
</div>
)}
<div className="activity-feed">
{activity?.recent_events.map((e, i) => (
<div className="activity-row" key={i}>
<span className={`evt evt-${e.event}`}>{e.event}</span>
<span className="mono">{e.file_name}</span>
<span className="dim">{e.username} · {fmtWhen(e.at)}</span>
</div>
))}
{(!activity || activity.recent_events.length === 0) && <div className="dim">no activity yet</div>}
</div>
</section>
</div>
{/* Per-video table */}
<section className="panel">
<div className="panel-head">
<h3>Videos ({videos.length})</h3>
<div className="panel-actions">
{isAdmin && projectId != null && (
<FoldersPicker projectId={projectId} onChange={() => loadProject(projectId)} />
)}
{isAdmin && (
<span className="assign-bar">
<span className="dim">{selected.size} selected </span>
<select value={assignee} onChange={(e) => setAssignee(e.target.value)} title="Assign to a project member (or unassign)">
<option value="">(unassign)</option>
{members.map((m) => <option key={m.username} value={m.username}>{m.username}</option>)}
</select>
<button className="btn" disabled={busy || selected.size === 0 || (assignee !== "" && members.length === 0)} onClick={assignSelected}>Assign</button>
{members.length === 0 && <span className="dim"> add members in Admin first</span>}
</span>
)}
{projectId != null && videos.some((v) => v.annotation_count > 0) && (
<a className="btn" href={exportProjectUrl(projectId)} download title="Download all annotations in this project as one JSON">
Export all
</a>
)}
</div>
</div>
<table>
<thead>
<tr>
{isAdmin && <th><input type="checkbox" title="select all"
checked={selected.size > 0 && selected.size === videos.length}
onChange={(e) => setSelected(e.target.checked ? new Set(videos.map((v) => v.id)) : new Set())} /></th>}
<th>#</th><th>Folder</th><th>File</th><th>Status</th><th>Annotator</th><th>Annotations</th>
<th>Review</th><th>Verify time</th><th>Resolution</th><th>Annotated</th><th>Remarks</th>
</tr>
</thead>
<tbody>
{videos.map((v, idx) => (
<tr key={v.id} className={`${selected.has(v.id) ? "row-sel " : ""}${v.ignored ? "row-ignored" : ""}`}>
{isAdmin && <td><input type="checkbox" checked={selected.has(v.id)} onChange={() => toggleSel(v.id)} /></td>}
<td className="dim">{idx + 1}</td>
<td className="dim mono" title={v.rel_path}>{subfolder(v.rel_path) || "—"}</td>
<td className="mono">{v.file_name}</td>
<td>
<span className={`badge ${v.workflow_status}`}>{v.workflow_status}</span>
{v.ignored && <span className="badge ignored-tag" title={v.ignored_by ? `ignored by ${v.ignored_by}` : "ignored"}>ignored</span>}
{isAdmin && (
<button className="ignore-btn" onClick={() => toggleIgnore(v)}
title={v.ignored ? "Restore this video" : "Ignore this video (cross it out)"}>
{v.ignored ? "undo ignore" : "ignore"}
</button>
)}
{v.claimed_by ? (
<div className="claim-line" title={v.lease_expires_at ? `lease expires ${fmtWhen(v.lease_expires_at)}` : undefined}>
🔒 claimed by <b>{v.claimed_by}</b>{v.claimed_at ? ` · ${fmtWhen(v.claimed_at)}` : ""}
</div>
) : v.workflow_status === "assigned" && v.assigned_to ? (
<div className="claim-line"> assigned to <b>{v.assigned_to}</b></div>
) : v.completed_at ? (
<div className="claim-line" title={`last push ${fmtWhen(v.completed_at)}`}> <b>{v.verifiers || v.completed_by}</b> · {fmtWhen(v.completed_at)}</div>
) : null}
</td>
<td>{v.verifiers || v.primary_annotator || (v.assigned_to ? <span className="dim">assigned: {v.assigned_to}</span> : "—")}</td>
<td>
{v.annotation_count > 0
? <a href={exportVideoUrl(v.id)} download title="Download this video's annotations (JSON)">{v.annotation_count} </a>
: 0}
{v.imported_count > 0 && (
<div className="imported-line" title="annotations present when verification started (imported baseline)">
<span className="dim">imported {v.imported_count}</span>
{v.annotation_count < v.imported_count &&
<span className="ann-del" title={`${v.imported_count - v.annotation_count} deleted by the user`}> · {v.imported_count - v.annotation_count}</span>}
{v.annotation_count > v.imported_count &&
<span className="ann-add" title={`${v.annotation_count - v.imported_count} added by the user`}> · +{v.annotation_count - v.imported_count}</span>}
</div>
)}
</td>
<td>{v.review_count > 0
? <span className="review-flag" title={`${v.review_count} annotation(s) flagged for review`}>🚩 {v.review_count}</span>
: <span className="dim"></span>}</td>
<td title="total across all passes">{fmtDuration(v.verify_time_ms)}</td>
<td className="dim">{v.width && v.height ? `${v.width}×${v.height}` : "—"}</td>
<td className="dim">{v.annotated_at ? v.annotated_at.slice(0, 10) : "—"}</td>
<td className="remark-col">
<RemarkCell
videoId={v.id}
initial={v.remarks}
handRaised={v.hand_raised}
handBy={v.hand_raised_by}
/>
</td>
</tr>
))}
{videos.length === 0 && <tr><td colSpan={isAdmin ? 12 : 11} className="dim">no videos ingested</td></tr>}
</tbody>
</table>
</section>
</>
)}
</div>
);
}
/** The subfolder portion of a rel_path (everything before the last '/'), or "" at the root. */
function subfolder(rel: string): string {
const i = rel.lastIndexOf("/");
return i >= 0 ? rel.slice(0, i) : "";
}
/** Group projects under their client name, preserving the server's ordering. */
function groupByClient(projects: ProjectSummary[]): [string, ProjectSummary[]][] {
const order: string[] = [];
const map = new Map<string, ProjectSummary[]>();
for (const p of projects) {
const key = p.client_name || "—";
if (!map.has(key)) { map.set(key, []); order.push(key); }
map.get(key)!.push(p);
}
return order.map((k) => [k, map.get(k)!]);
}
function Card({ label, value, tone }: { label: string; value: number; tone?: string }) {
return (
<div className={`card ${tone ?? ""}`}>
<div className="card-label">{label}</div>
<div className="card-value">{value}</div>
</div>
);
}