Central Verification Dashboard
This commit is contained in:
568
dashboard/src/App.tsx
Normal file
568
dashboard/src/App.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user