710 lines
32 KiB
TypeScript
710 lines
32 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
||
import {
|
||
api, exportProjectUrl, exportVideoUrl, fmtChainageRange, fmtDuration, fmtWhen, passDeltas,
|
||
setAuthToken, statusLabel,
|
||
type Activity, type Member, type ProjectSummary, type RoadType, 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";
|
||
import { MapView } from "./MapView";
|
||
import { ChainagePanel } from "./ChainagePanel";
|
||
|
||
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("");
|
||
// Videos panel: table ↔ map toggle + road types (labels for assignment/calibration).
|
||
const [videoView, setVideoView] = useState<"table" | "map">("table");
|
||
const [roadTypes, setRoadTypes] = useState<RoadType[]>([]);
|
||
const [rtAssign, setRtAssign] = 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, rt] = await Promise.all([
|
||
api.stats(id), api.videos(id), api.activity(id),
|
||
api.members(id).catch(() => [] as Member[]),
|
||
api.roadTypes(id).catch(() => [] as RoadType[]),
|
||
]);
|
||
setStats(s);
|
||
setVideos(v);
|
||
setActivity(a);
|
||
setMembers(m);
|
||
setRoadTypes(rt);
|
||
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]);
|
||
|
||
// Bulk-assign the picked road type to the selected videos (admin; "" clears).
|
||
const applyRoadType = useCallback(async () => {
|
||
if (!isAdmin || selected.size === 0 || projectId == null) return;
|
||
setBusy(true);
|
||
try {
|
||
await api.assignRoadType(projectId, [...selected], rtAssign);
|
||
setSelected(new Set());
|
||
await loadProject(projectId);
|
||
setError(null);
|
||
} catch (e) {
|
||
setError(String(e));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}, [isAdmin, selected, rtAssign, projectId, loadProject]);
|
||
|
||
// A deleted road-type label can leave a stale dropdown selection: the select
|
||
// LOOKS like "(clear road type)" but still sends the dead label, which the server
|
||
// rejects — making un-assign impossible. Snap back to '' whenever that happens.
|
||
useEffect(() => {
|
||
if (rtAssign !== "" && !roadTypes.some((r) => r.name === rtAssign)) setRtAssign("");
|
||
}, [roadTypes, rtAssign]);
|
||
|
||
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 annotation throughput
|
||
(most annotations in the least time), not by video count. */}
|
||
<section className="panel">
|
||
<h3>Verification leaderboard <span className="dim">· most annotations / min</span></h3>
|
||
<table>
|
||
<thead>
|
||
<tr><th>Rank</th><th>User</th><th>Annotations</th><th>Anno/min</th><th>Verified</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.annotations > 0 ? r.annotations : <span className="dim">0</span>}</td>
|
||
<td>{r.annotations_per_min > 0 ? r.annotations_per_min.toFixed(1) : <span className="dim">—</span>}</td>
|
||
<td>{r.videos > 0 ? r.videos : <span className="dim">0</span>}</td>
|
||
<td>{r.total_time_ms > 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 / map */}
|
||
<section className="panel">
|
||
<div className="panel-head">
|
||
<h3>
|
||
Videos ({videos.length})
|
||
<span className="viewtabs videos-toggle">
|
||
<button className={videoView === "table" ? "active" : ""} onClick={() => setVideoView("table")}>Table</button>
|
||
<button className={videoView === "map" ? "active" : ""} onClick={() => setVideoView("map")}>Map</button>
|
||
</span>
|
||
</h3>
|
||
<div className="panel-actions">
|
||
{isAdmin && projectId != null && (
|
||
<FoldersPicker projectId={projectId} onChange={() => loadProject(projectId)} />
|
||
)}
|
||
{isAdmin && videoView === "table" && (
|
||
<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>
|
||
<select value={rtAssign} onChange={(e) => setRtAssign(e.target.value)} title="Set the road type of the selected videos (or clear it)">
|
||
<option value="">(clear road type)</option>
|
||
{roadTypes.map((r) => <option key={r.name} value={r.name}>{r.name}</option>)}
|
||
</select>
|
||
<button className="btn" disabled={busy || selected.size === 0} onClick={applyRoadType}
|
||
title={rtAssign ? "Apply the picked road type to the selected videos" : "Remove the road type from the selected videos"}>
|
||
{rtAssign ? "Set road" : "Clear road"}
|
||
</button>
|
||
</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>
|
||
{videoView === "map" && projectId != null && (
|
||
<MapView projectId={projectId} isAdmin={isAdmin} onChanged={() => loadProject(projectId)} />
|
||
)}
|
||
{videoView === "table" && (
|
||
<div className="table-scroll">
|
||
<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>Road · Chainage</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}`}>{statusLabel(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.road_type ? <span className="badge road-tag">{v.road_type}</span> : <span className="dim">—</span>}
|
||
<div className="claim-line" title={
|
||
v.sr_chainage_start_m != null
|
||
? `sr (GPS-measured): ${fmtChainageRange(v.sr_chainage_start_m, v.sr_chainage_end_m)}`
|
||
: v.gps_status === "ok" ? "GPS scanned — chainage not calibrated yet"
|
||
: v.gps_status === "error" ? `GPS scan failed: ${v.gps_error ?? ""}`
|
||
: v.gps_status === "none" ? "no GPS metadata in this video"
|
||
: "GPS scan pending"
|
||
}>
|
||
{v.chainage_start_m != null
|
||
? fmtChainageRange(v.chainage_start_m, v.chainage_end_m)
|
||
: <span className="dim">{v.gps_status === "ok" ? "gps ✓" : v.gps_status === "pending" ? "gps …" : `gps ${v.gps_status}`}</span>}
|
||
</div>
|
||
<GpsPoints v={v} />
|
||
</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 || (v.passes?.length ?? 0) > 0) && (() => {
|
||
const deltas = passDeltas(v.imported_count, v.passes ?? []);
|
||
return (
|
||
<div className="imported-line" title="imported baseline → per-pass changes (verify / re-verify)">
|
||
<span className="dim">imported {v.imported_count}</span>
|
||
{deltas.length > 0
|
||
? deltas.map((d, i) => (
|
||
<span key={i} className={d.delta >= 0 ? "ann-add" : "ann-del"}
|
||
title={`${d.label} by ${d.username} → ${d.total} total`}>
|
||
{" · "}{d.label} {d.delta >= 0 ? "+" : ""}{d.delta}
|
||
</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 ? 13 : 12} className="dim">no videos ingested</td></tr>}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* Chainage calibration + road-type labels (admin) */}
|
||
{isAdmin && projectId != null && (
|
||
<ChainagePanel
|
||
projectId={projectId}
|
||
videos={videos}
|
||
roadTypes={roadTypes}
|
||
onChanged={() => loadProject(projectId)}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
</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)!]);
|
||
}
|
||
|
||
/** GPS fix count vs the ~1 fix/s expected for the video's duration. A mismatch gets
|
||
* a ⚠ that expands (on click) into a plain-language explanation of how the missing/
|
||
* extra data is handled — interpolation, start-stitching, end clamping. */
|
||
function GpsPoints({ v }: { v: VideoRow }) {
|
||
const [open, setOpen] = useState(false);
|
||
if (v.gps_status !== "ok" || v.gps_sample_count == null) return null;
|
||
const durS = v.duration_s ?? (v.frame_count && v.fps ? v.frame_count / v.fps : null);
|
||
const expected = durS ? Math.round(durS) : null;
|
||
const diff = expected != null ? v.gps_sample_count - expected : 0;
|
||
const off = expected != null && Math.abs(diff) > Math.max(3, expected * 0.05);
|
||
const bigGap = (v.gps_max_gap_s ?? 0) > 5;
|
||
return (
|
||
<div className="claim-line gps-pts">
|
||
{v.gps_sample_count} gps pts
|
||
{expected != null && <span className="dim"> / ~{expected}</span>}
|
||
{(off || bigGap) && (
|
||
<button
|
||
className="gps-warn"
|
||
onClick={() => setOpen((o) => !o)}
|
||
title="Fix count doesn't match the video duration — click to see how the gaps are handled"
|
||
>
|
||
⚠
|
||
</button>
|
||
)}
|
||
{open && (off || bigGap) && (
|
||
<div className="gps-explain" onClick={() => setOpen(false)}>
|
||
{diff < 0 || (!off && bigGap) ? (
|
||
<>
|
||
Expected ~{expected ?? "?"} fixes (1 per second of video), found {v.gps_sample_count}
|
||
{bigGap && <> — largest dropout <b>{v.gps_max_gap_s!.toFixed(0)} s</b></>}.
|
||
Positions inside a dropout are <b>time-interpolated</b>: each frame is placed
|
||
proportionally on a straight line between the two nearest real fixes (constant
|
||
speed assumed), and its chainage follows the same interpolation. A missing{" "}
|
||
<b>start</b> is stitched from the previous video via GPS timestamps when possible;
|
||
a missing <b>end</b> clamps to the last fix. The longer the dropout, the rougher
|
||
the approximation.
|
||
</>
|
||
) : diff > 0 ? (
|
||
<>
|
||
Found {v.gps_sample_count} fixes but only ~{expected} expected — the camera wrote
|
||
more than one fix per second (or duplicated samples). Extra fixes are used as-is;
|
||
nothing is approximated.
|
||
</>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|