Central Verification Dashboard
This commit is contained in:
3
dashboard/.dockerignore
Normal file
3
dashboard/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
11
dashboard/Dockerfile
Normal file
11
dashboard/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
12
dashboard/index.html
Normal file
12
dashboard/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Verification Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
18
dashboard/nginx.conf
Normal file
18
dashboard/nginx.conf
Normal file
@@ -0,0 +1,18 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API calls to the server container (same Docker network).
|
||||
location /api/ {
|
||||
proxy_pass http://server:8080/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
1888
dashboard/package-lock.json
generated
Normal file
1888
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
dashboard/package.json
Normal file
22
dashboard/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "central-dashboard",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
460
dashboard/src/AdminPanel.tsx
Normal file
460
dashboard/src/AdminPanel.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api, fmtBytes, type AuditRow, type Client, type Member, type ProjectSummary, type StorageInfo, type UserRow } from "./api";
|
||||
|
||||
/** Admin/collaborator console. The signed-in token (from the app gate) is used for
|
||||
* everything — there's no separate login here. Role-aware: ml_support can add
|
||||
* projects + change a project's sync directory; admins additionally create/delete
|
||||
* clients, delete projects, and manage (create / reset-token / delete) collaborators. */
|
||||
export function AdminPanel({ role, onChanged }: { token: string; role: string; onChanged: () => void }) {
|
||||
const isAdmin = role === "admin";
|
||||
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [projects, setProjects] = useState<ProjectSummary[]>([]);
|
||||
const [users, setUsers] = useState<UserRow[]>([]);
|
||||
const [audit, setAudit] = useState<AuditRow[]>([]);
|
||||
const [storage, setStorage] = useState<StorageInfo | null>(null);
|
||||
|
||||
const [notice, setNotice] = useState<{ kind: "ok" | "err"; msg: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// form state
|
||||
const [clientName, setClientName] = useState("");
|
||||
const [projClientId, setProjClientId] = useState<number | "">("");
|
||||
const [projName, setProjName] = useState("");
|
||||
const [projKind, setProjKind] = useState<"nfs" | "local">("nfs");
|
||||
const [projPath, setProjPath] = useState("");
|
||||
const [manageId, setManageId] = useState<number | "">("");
|
||||
const [manageKind, setManageKind] = useState<"nfs" | "local">("nfs");
|
||||
const [managePath, setManagePath] = useState("");
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [memberPick, setMemberPick] = useState("");
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [newDisplay, setNewDisplay] = useState("");
|
||||
const [newRole, setNewRole] = useState<"ml_support" | "admin">("ml_support");
|
||||
const [createdToken, setCreatedToken] = useState<{ username: string; token: string } | null>(null);
|
||||
const [editing, setEditing] = useState<{ id: number; display_name: string; role: "ml_support" | "admin" } | null>(null);
|
||||
|
||||
const refreshUsers = useCallback(async () => {
|
||||
try { setUsers(await api.listUsers()); } catch { setUsers([]); }
|
||||
try { setStorage(await api.storage()); } catch { setStorage(null); }
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [c, p, a] = await Promise.all([api.clients(), api.projects(), api.audit(50)]);
|
||||
setClients(c);
|
||||
setProjects(p);
|
||||
setAudit(a);
|
||||
} catch (e) {
|
||||
setNotice({ kind: "err", msg: String(e) });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void refresh(); }, [refresh]);
|
||||
useEffect(() => { if (isAdmin) void refreshUsers(); }, [isAdmin, refreshUsers]);
|
||||
|
||||
// When a project is picked in "Manage", load its current sync dir + members.
|
||||
const loadMembers = useCallback(async (id: number) => {
|
||||
try { setMembers(await api.members(id)); } catch { setMembers([]); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const p = projects.find((x) => x.id === manageId);
|
||||
if (p) {
|
||||
setManagePath(p.source_path);
|
||||
setManageKind(p.source_kind === "nfs" ? "nfs" : "local");
|
||||
}
|
||||
if (manageId !== "" && isAdmin) void loadMembers(Number(manageId));
|
||||
else setMembers([]);
|
||||
}, [manageId, projects, isAdmin, loadMembers]);
|
||||
|
||||
const run = async (fn: () => Promise<string>, needAdmin = false) => {
|
||||
if (needAdmin && !isAdmin) { setNotice({ kind: "err", msg: "admin only" }); return; }
|
||||
setBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const msg = await fn();
|
||||
setNotice({ kind: "ok", msg });
|
||||
await refresh();
|
||||
if (isAdmin) void refreshUsers();
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setNotice({ kind: "err", msg: String(e) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createClient = () => run(async () => {
|
||||
const c = await api.createClient(clientName.trim());
|
||||
setClientName("");
|
||||
return `Created client '${c.name}'`;
|
||||
}, true);
|
||||
|
||||
const deleteClient = (c: Client) => {
|
||||
// Re-affirmation: typing the exact client name confirms a destructive cascade.
|
||||
const typed = window.prompt(
|
||||
`Delete client "${c.name}" and ALL its ${c.project_count} project(s), videos and annotations?\n\nThis cannot be undone. Type the client name to confirm:`,
|
||||
);
|
||||
if (typed == null) return;
|
||||
if (typed.trim() !== c.name) { setNotice({ kind: "err", msg: "name didn't match — deletion cancelled" }); return; }
|
||||
void run(async () => {
|
||||
const r = await api.deleteClient(c.id);
|
||||
return `Deleted client '${c.name}' (${r.projects_removed} project(s) removed)`;
|
||||
}, true);
|
||||
};
|
||||
|
||||
const createProject = () => run(async () => {
|
||||
if (projClientId === "") throw new Error("pick a client");
|
||||
await api.createProject(
|
||||
{ client_id: Number(projClientId), name: projName.trim(), source_path: projPath.trim(), source_kind: projKind },
|
||||
);
|
||||
const msg = `Created project '${projName.trim()}'`;
|
||||
setProjName(""); setProjPath("");
|
||||
return msg;
|
||||
});
|
||||
|
||||
const saveSyncDir = () => run(async () => {
|
||||
if (manageId === "") throw new Error("pick a project");
|
||||
await api.updateProjectSource(Number(manageId), { source_path: managePath.trim(), source_kind: manageKind });
|
||||
return `Sync directory updated`;
|
||||
});
|
||||
|
||||
const syncProject = () => run(async () => {
|
||||
if (manageId === "") throw new Error("pick a project");
|
||||
const r = await api.ingest(Number(manageId));
|
||||
return `Synced — ingested ${r.ingested} video(s)`;
|
||||
});
|
||||
|
||||
const deleteProject = () => {
|
||||
if (manageId === "") { setNotice({ kind: "err", msg: "pick a project" }); return; }
|
||||
const p = projects.find((x) => x.id === manageId);
|
||||
if (!p) return;
|
||||
if (!window.confirm(`Delete project "${p.name}" and all its videos + annotations? This cannot be undone.`)) return;
|
||||
void run(async () => {
|
||||
await api.deleteProject(p.id);
|
||||
setManageId("");
|
||||
return `Deleted project '${p.name}'`;
|
||||
}, true);
|
||||
};
|
||||
|
||||
const addMember = () => {
|
||||
if (manageId === "" || !memberPick) return;
|
||||
void run(async () => {
|
||||
await api.addMember(Number(manageId), memberPick);
|
||||
const u = memberPick;
|
||||
setMemberPick("");
|
||||
await loadMembers(Number(manageId));
|
||||
return `Added '${u}' to the project`;
|
||||
}, true);
|
||||
};
|
||||
|
||||
const removeMember = (username: string) => {
|
||||
if (manageId === "") return;
|
||||
if (!confirm(`Remove "${username}" from this project? Their assignments here are cleared.`)) return;
|
||||
void run(async () => {
|
||||
await api.removeMember(Number(manageId), username);
|
||||
await loadMembers(Number(manageId));
|
||||
return `Removed '${username}' from the project`;
|
||||
}, true);
|
||||
};
|
||||
|
||||
const addUser = () => run(async () => {
|
||||
const u = await api.createUser({ username: newUsername.trim(), display_name: newDisplay.trim(), role: newRole });
|
||||
setCreatedToken({ username: u.username, token: u.token });
|
||||
setNewUsername(""); setNewDisplay("");
|
||||
return `Added ${u.role} '${u.username}'`;
|
||||
}, true);
|
||||
|
||||
const resetToken = (u: UserRow) => {
|
||||
if (!confirm(`Generate a new access token for "${u.username}"? Their current token stops working immediately.`)) return;
|
||||
void run(async () => {
|
||||
const r = await api.resetUserToken(u.id);
|
||||
setCreatedToken({ username: r.username, token: r.token });
|
||||
return `New token generated for '${r.username}'`;
|
||||
}, true);
|
||||
};
|
||||
|
||||
const saveEdit = () => {
|
||||
if (!editing) return;
|
||||
void run(async () => {
|
||||
const r = await api.updateUser(editing.id, { display_name: editing.display_name.trim(), role: editing.role });
|
||||
setEditing(null);
|
||||
return `Updated user → ${r.display_name} (${r.role})`;
|
||||
}, true);
|
||||
};
|
||||
|
||||
const deleteUser = (u: UserRow) => {
|
||||
if (!confirm(`Delete ${u.role} "${u.username}"? Their token stops working immediately. Past work keeps their name.`)) return;
|
||||
void run(async () => {
|
||||
await api.deleteUser(u.id);
|
||||
return `Deleted user '${u.username}'`;
|
||||
}, true);
|
||||
};
|
||||
|
||||
const pathHint = projKind === "nfs"
|
||||
? "/volume4/Saudi_Video_Sync (or nfs://host/volume4/Share)"
|
||||
: "Container path (e.g. /data/Microsoft/India)";
|
||||
const manageHint = manageKind === "nfs"
|
||||
? "/volume4/Saudi_Video_Sync (or nfs://host/volume4/Share)"
|
||||
: "Container path (e.g. /data/...)";
|
||||
|
||||
return (
|
||||
<div className="admin">
|
||||
{!isAdmin && (
|
||||
<p className="dim" style={{ margin: "0 0 12px" }}>
|
||||
ML support access: you can add projects and change a project's sync directory.
|
||||
Creating/deleting clients, deleting projects, and managing collaborators is admin-only.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{notice && (
|
||||
<div className={notice.kind === "ok" ? "notice-ok" : "error"}>
|
||||
{notice.kind === "ok" ? "✓ " : "⚠ "}{notice.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid2">
|
||||
{/* Create client — admin only */}
|
||||
{isAdmin && (
|
||||
<section className="panel">
|
||||
<h3>Create client</h3>
|
||||
<div className="form-col">
|
||||
<label>Client name</label>
|
||||
<input placeholder="e.g. Microsoft" value={clientName} onChange={(e) => setClientName(e.target.value)} />
|
||||
<button className="btn primary" disabled={busy || !clientName.trim()} onClick={createClient}>
|
||||
Create client
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Create project — all */}
|
||||
<section className="panel">
|
||||
<h3>Create project</h3>
|
||||
<div className="form-col">
|
||||
<label>Client</label>
|
||||
<select value={projClientId} onChange={(e) => setProjClientId(e.target.value === "" ? "" : Number(e.target.value))}>
|
||||
<option value="">— pick a client —</option>
|
||||
{clients.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<label>Project name</label>
|
||||
<input placeholder="e.g. India" value={projName} onChange={(e) => setProjName(e.target.value)} />
|
||||
<label>Storage</label>
|
||||
<select value={projKind} onChange={(e) => setProjKind(e.target.value as "nfs" | "local")}>
|
||||
<option value="nfs">NFS share</option>
|
||||
<option value="local">Local / mounted path</option>
|
||||
</select>
|
||||
<label>{projKind === "nfs" ? "NAS export path" : "Container path"}</label>
|
||||
<input className="mono" placeholder={pathHint} value={projPath} onChange={(e) => setProjPath(e.target.value)} />
|
||||
<button
|
||||
className="btn primary"
|
||||
disabled={busy || projClientId === "" || !projName.trim() || !projPath.trim()}
|
||||
onClick={createProject}
|
||||
>
|
||||
Create project
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Clients list with delete — admin only */}
|
||||
{isAdmin && (
|
||||
<section className="panel">
|
||||
<h3>Clients</h3>
|
||||
<table>
|
||||
<thead><tr><th>Client</th><th>Projects</th><th>Created</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{clients.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td>{c.name}</td>
|
||||
<td>{c.project_count}</td>
|
||||
<td className="dim">{c.created_at.slice(0, 10)}</td>
|
||||
<td><button className="btn danger" disabled={busy} onClick={() => deleteClient(c)}>Delete</button></td>
|
||||
</tr>
|
||||
))}
|
||||
{clients.length === 0 && <tr><td colSpan={4} className="dim">no clients yet</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Manage / sync a project — all (delete is admin only) */}
|
||||
<section className="panel">
|
||||
<h3>Manage project — change sync directory & sync</h3>
|
||||
<div className="form-col" style={{ maxWidth: 560 }}>
|
||||
<label>Project</label>
|
||||
<select value={manageId} onChange={(e) => setManageId(e.target.value === "" ? "" : Number(e.target.value))}>
|
||||
<option value="">— pick a project —</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.client_name ? `${p.client_name} / ${p.name}` : p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{manageId !== "" && (
|
||||
<>
|
||||
<label>Storage</label>
|
||||
<select value={manageKind} onChange={(e) => setManageKind(e.target.value as "nfs" | "local")}>
|
||||
<option value="nfs">NFS share</option>
|
||||
<option value="local">Local / mounted path</option>
|
||||
</select>
|
||||
<label>{manageKind === "nfs" ? "NAS export path" : "Container path"}</label>
|
||||
<input className="mono" placeholder={manageHint} value={managePath} onChange={(e) => setManagePath(e.target.value)} />
|
||||
<div className="form-row">
|
||||
<button className="btn" disabled={busy || !managePath.trim()} onClick={saveSyncDir}>Save sync directory</button>
|
||||
<button className="btn primary" disabled={busy} onClick={syncProject}>{busy ? "Syncing…" : "Sync now"}</button>
|
||||
{isAdmin && <button className="btn danger" disabled={busy} onClick={deleteProject}>Delete project</button>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project members — admin picks who can see / be assigned this project */}
|
||||
{manageId !== "" && isAdmin && (
|
||||
<div className="members-box">
|
||||
<h4>Members <span className="dim">— who can see & be assigned this project</span></h4>
|
||||
<div className="form-row" style={{ marginBottom: 8 }}>
|
||||
<select value={memberPick} onChange={(e) => setMemberPick(e.target.value)}>
|
||||
<option value="">— pick a user to add —</option>
|
||||
{users
|
||||
.filter((u) => !members.some((m) => m.username === u.username))
|
||||
.map((u) => <option key={u.id} value={u.username}>{u.username} ({u.role})</option>)}
|
||||
</select>
|
||||
<button className="btn" disabled={busy || !memberPick} onClick={addMember}>Add member</button>
|
||||
</div>
|
||||
<div className="member-chips">
|
||||
{members.map((m) => (
|
||||
<span className="member-chip" key={m.username}>
|
||||
{m.username} <span className="dim">{m.role}</span>
|
||||
<button className="chip-x" title="Remove from project" onClick={() => removeMember(m.username)}>×</button>
|
||||
</span>
|
||||
))}
|
||||
{members.length === 0 && <span className="dim">No members yet — this project is hidden from all ml_support users.</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="dim" style={{ marginTop: 8 }}>
|
||||
Sync re-scans the folder (incl. subfolders) for new/changed videos + JSONs. For NFS, enter the
|
||||
export path (e.g. <code>/volume4/Saudi_Video_Sync/IRB_Master_VIdeos</code>) — a bare path uses
|
||||
the NAS address configured on the server; a full <code>nfs://host/path</code> also works.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Collaborators — admin only */}
|
||||
{isAdmin && (
|
||||
<section className="panel">
|
||||
<h3>Collaborators</h3>
|
||||
{createdToken && (
|
||||
<div className="token-box">
|
||||
<div>
|
||||
New token for <b>{createdToken.username}</b> — copy it now, it won't be shown again:
|
||||
</div>
|
||||
<div className="form-row" style={{ marginTop: 6 }}>
|
||||
<code className="token-val">{createdToken.token}</code>
|
||||
<button className="btn" onClick={() => navigator.clipboard?.writeText(createdToken.token)}>Copy</button>
|
||||
<button className="btn" onClick={() => setCreatedToken(null)}>Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-row" style={{ marginBottom: 10 }}>
|
||||
<input placeholder="username" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} />
|
||||
<input placeholder="display name (optional)" value={newDisplay} onChange={(e) => setNewDisplay(e.target.value)} />
|
||||
<select value={newRole} onChange={(e) => setNewRole(e.target.value as "ml_support" | "admin")}>
|
||||
<option value="ml_support">ml_support</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<button className="btn primary" disabled={busy || !newUsername.trim()} onClick={addUser}>Add collaborator</button>
|
||||
</div>
|
||||
<p className="dim" style={{ margin: "0 0 8px", fontSize: 12 }}>
|
||||
Tokens are stored hashed and can't be displayed. Use <b>Reset token</b> to issue a new one
|
||||
(shown once, here).
|
||||
</p>
|
||||
<table>
|
||||
<thead><tr><th>User</th><th>Role</th><th>Active</th><th>Since</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{users.map((u) => editing && editing.id === u.id ? (
|
||||
<tr key={u.id} className="row-editing">
|
||||
<td>
|
||||
<b>{u.username}</b>{" "}
|
||||
<input value={editing.display_name} placeholder="display name"
|
||||
onChange={(e) => setEditing({ ...editing, display_name: e.target.value })}
|
||||
style={{ width: 140 }} />
|
||||
</td>
|
||||
<td>
|
||||
<select value={editing.role} onChange={(e) => setEditing({ ...editing, role: e.target.value as "ml_support" | "admin" })}>
|
||||
<option value="ml_support">ml_support</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>{u.active ? "yes" : "no"}</td>
|
||||
<td className="dim">{u.created_at.slice(0, 10)}</td>
|
||||
<td className="row-actions">
|
||||
<button className="btn primary" disabled={busy} onClick={saveEdit}>Save</button>
|
||||
<button className="btn" disabled={busy} onClick={() => setEditing(null)}>Cancel</button>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr key={u.id}>
|
||||
<td>{u.username}{u.display_name && u.display_name !== u.username ? ` (${u.display_name})` : ""}</td>
|
||||
<td><span className={`badge ${u.role === "admin" ? "verified" : "annotated"}`}>{u.role}</span></td>
|
||||
<td>{u.active ? "yes" : "no"}</td>
|
||||
<td className="dim">{u.created_at.slice(0, 10)}</td>
|
||||
<td className="row-actions">
|
||||
<button className="btn" disabled={busy} title="Edit display name + role"
|
||||
onClick={() => setEditing({ id: u.id, display_name: u.display_name === u.username ? "" : u.display_name, role: u.role === "admin" ? "admin" : "ml_support" })}>Edit</button>
|
||||
<button className="btn" disabled={busy} title="Generate a new token (current one stops working)" onClick={() => resetToken(u)}>Reset token</button>
|
||||
<button className="btn danger" disabled={busy} title="Delete this user" onClick={() => deleteUser(u)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && <tr><td colSpan={5} className="dim">no collaborators yet</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Storage — admin only */}
|
||||
{isAdmin && storage && (
|
||||
<section className="panel">
|
||||
<h3>Storage</h3>
|
||||
<div className="storage-grid">
|
||||
<div><span className="dim">Database size</span><b>{fmtBytes(storage.db_bytes)}</b></div>
|
||||
<div><span className="dim">Videos</span><b>{storage.videos}</b></div>
|
||||
<div><span className="dim">Annotations</span><b>{storage.annotations}</b></div>
|
||||
<div><span className="dim">Event log rows</span><b>{storage.video_events}</b></div>
|
||||
<div><span className="dim">Audit log rows</span><b>{storage.audit_events}</b></div>
|
||||
</div>
|
||||
<p className="dim" style={{ marginTop: 8 }}>
|
||||
The activity + audit logs are trimmed to the most recent {storage.max_log_rows} rows nightly
|
||||
(~00:00 UTC) so the database stays bounded. Verification (push) history is kept.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Activity log — all */}
|
||||
<section className="panel">
|
||||
<h3>Activity log</h3>
|
||||
<table>
|
||||
<thead><tr><th>Time</th><th>User</th><th>Action</th><th>Details</th></tr></thead>
|
||||
<tbody>
|
||||
{audit.map((a, i) => (
|
||||
<tr key={i}>
|
||||
<td className="dim mono">{a.at.slice(0, 19).replace("T", " ")}</td>
|
||||
<td>{a.username || "—"}</td>
|
||||
<td><span className={`badge ${a.action === "signed_in" ? "verified" : "annotated"}`}>{a.action}</span></td>
|
||||
<td className="mono dim">{auditDetail(a)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{audit.length === 0 && <tr><td colSpan={4} className="dim">no activity yet</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function auditDetail(a: AuditRow): string {
|
||||
const d = a.detail || {};
|
||||
const parts: string[] = [];
|
||||
for (const [k, v] of Object.entries(d)) parts.push(`${k}=${v}`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
78
dashboard/src/FoldersPicker.tsx
Normal file
78
dashboard/src/FoldersPicker.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api, type Folder } from "./api";
|
||||
|
||||
/**
|
||||
* Dropdown checklist of the project's folders. Ticking folders restricts the videos
|
||||
* shown/counted to those folders (admins verify only some folders; the rest are
|
||||
* backup/misc). None ticked = consider all folders. Admin only.
|
||||
*/
|
||||
export function FoldersPicker({ projectId, onChange }: { projectId: number; onChange: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [folders, setFolders] = useState<Folder[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try { setFolders(await api.folders(projectId)); } catch (e) { setErr(String(e)); }
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => { if (open) void load(); }, [open, load]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||
document.addEventListener("mousedown", h);
|
||||
return () => document.removeEventListener("mousedown", h);
|
||||
}, [open]);
|
||||
|
||||
const includedCount = folders.filter((f) => f.included).length;
|
||||
|
||||
const commit = async (next: Folder[]) => {
|
||||
setFolders(next);
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await api.setFolders(projectId, next.filter((f) => f.included).map((f) => f.folder));
|
||||
onChange();
|
||||
} catch (e) {
|
||||
setErr(String(e));
|
||||
await load();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (folder: string) =>
|
||||
commit(folders.map((f) => (f.folder === folder ? { ...f, included: !f.included } : f)));
|
||||
const considerAll = () => commit(folders.map((f) => ({ ...f, included: false })));
|
||||
|
||||
const label = includedCount === 0 ? "Folders: all ▾" : `Folders: ${includedCount}/${folders.length} ▾`;
|
||||
|
||||
return (
|
||||
<div className="members-picker" ref={ref}>
|
||||
<button className="btn" onClick={() => setOpen((v) => !v)} title="Pick which folders to verify">{label}</button>
|
||||
{open && (
|
||||
<div className="members-dropdown">
|
||||
<div className="folders-hint dim">Tick folders to verify. None ticked = all folders shown.</div>
|
||||
<div className="members-list">
|
||||
{folders.map((f) => (
|
||||
<label key={f.folder} className={`members-item${f.included ? " on" : ""}`}>
|
||||
<input type="checkbox" checked={f.included} disabled={busy} onChange={() => toggle(f.folder)} />
|
||||
<span className="mi-name">{f.folder || "(root)"}</span>
|
||||
<span className="dim mi-disp">{f.video_count}</span>
|
||||
</label>
|
||||
))}
|
||||
{folders.length === 0 && <div className="dim mi-empty">no folders — sync the project first</div>}
|
||||
</div>
|
||||
{err && <div className="mi-err">⚠ {err}</div>}
|
||||
<div className="members-foot">
|
||||
<button className="btn" disabled={busy || includedCount === 0} onClick={considerAll}>Consider all</button>
|
||||
<span className="dim" style={{ marginLeft: 8 }}>
|
||||
{includedCount === 0 ? "all folders shown" : `${includedCount} selected`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
dashboard/src/MembersPicker.tsx
Normal file
88
dashboard/src/MembersPicker.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api, type Member, type UserRow } from "./api";
|
||||
|
||||
/**
|
||||
* Dropdown checklist to pick which ml_support users belong to a project. Searchable
|
||||
* + scrollable (built for ~30 users). Ticking a user adds them as a project member;
|
||||
* un-ticking removes them. Members are who can see the project and be assigned videos.
|
||||
* Admin only. By default no one is selected.
|
||||
*/
|
||||
export function MembersPicker({
|
||||
projectId,
|
||||
users,
|
||||
members,
|
||||
onChange,
|
||||
}: {
|
||||
projectId: number;
|
||||
users: UserRow[]; // all ml_support users
|
||||
members: Member[];
|
||||
onChange: () => void; // reload the project's members after a change
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const memberSet = new Set(members.map((m) => m.username));
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||
document.addEventListener("mousedown", h);
|
||||
return () => document.removeEventListener("mousedown", h);
|
||||
}, [open]);
|
||||
|
||||
const toggle = async (username: string) => {
|
||||
setBusy(username);
|
||||
setErr(null);
|
||||
try {
|
||||
if (memberSet.has(username)) await api.removeMember(projectId, username);
|
||||
else await api.addMember(projectId, username);
|
||||
onChange();
|
||||
} catch (e) {
|
||||
setErr(String(e));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = users.filter((u) => u.username.toLowerCase().includes(q.trim().toLowerCase()));
|
||||
|
||||
return (
|
||||
<div className="members-picker" ref={ref}>
|
||||
<button className="btn" onClick={() => setOpen((v) => !v)}>
|
||||
{members.length === 0 ? "+ Assign users" : `Members (${members.length}) ▾`}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="members-dropdown">
|
||||
<input
|
||||
className="members-search"
|
||||
placeholder="search ml_support…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="members-list">
|
||||
{filtered.map((u) => (
|
||||
<label key={u.id} className={`members-item${memberSet.has(u.username) ? " on" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={memberSet.has(u.username)}
|
||||
disabled={busy === u.username}
|
||||
onChange={() => toggle(u.username)}
|
||||
/>
|
||||
<span className="mi-name">{u.username}</span>
|
||||
{u.display_name && u.display_name !== u.username && <span className="dim mi-disp">{u.display_name}</span>}
|
||||
</label>
|
||||
))}
|
||||
{users.length === 0 && <div className="dim mi-empty">no ml_support users yet — create some in Admin</div>}
|
||||
{users.length > 0 && filtered.length === 0 && <div className="dim mi-empty">no matches</div>}
|
||||
</div>
|
||||
{err && <div className="mi-err">⚠ {err}</div>}
|
||||
<div className="members-foot dim">{members.length} selected · ticked users can be assigned videos</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
dashboard/src/RemarkCell.tsx
Normal file
110
dashboard/src/RemarkCell.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from "react";
|
||||
import { api, type Remark } from "./api";
|
||||
|
||||
/**
|
||||
* Remark + raise-hand cell for the dashboard video table. Collapsed shows just a
|
||||
* count (💬 N) so it never overflows the column; expand for the full thread + an add
|
||||
* box. The ✋ raise-hand toggle lets anyone start a conversation on a video; anyone
|
||||
* (user or admin) can lower it. Both use the signed-in token automatically.
|
||||
*/
|
||||
export function RemarkCell({
|
||||
videoId,
|
||||
initial,
|
||||
handRaised,
|
||||
handBy,
|
||||
}: {
|
||||
videoId: number;
|
||||
initial: Remark[];
|
||||
handRaised: boolean;
|
||||
handBy: string | null;
|
||||
}) {
|
||||
const [thread, setThread] = useState<Remark[]>(initial ?? []);
|
||||
const [raised, setRaised] = useState(handRaised);
|
||||
const [by, setBy] = useState(handBy);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const add = async () => {
|
||||
const body = draft.trim();
|
||||
if (!body) return;
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
setThread(await api.addRemark(videoId, body));
|
||||
setDraft("");
|
||||
} catch (e) {
|
||||
setErr(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleHand = async () => {
|
||||
const next = !raised;
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await api.setHand(videoId, next);
|
||||
setRaised(r.hand_raised);
|
||||
setBy(r.hand_raised ? r.by : null);
|
||||
} catch (e) {
|
||||
setErr(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handBtn = (
|
||||
<button
|
||||
className={`hand-btn${raised ? " raised" : ""}`}
|
||||
disabled={busy}
|
||||
onClick={toggleHand}
|
||||
title={raised ? `✋ raised by ${by ?? "?"} — click to lower` : "Raise hand to start a conversation"}
|
||||
>
|
||||
✋
|
||||
</button>
|
||||
);
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<div className="remark-cell">
|
||||
{handBtn}
|
||||
<button className="remark-collapsed" onClick={() => setOpen(true)} title="View / add remarks">
|
||||
{thread.length === 0
|
||||
? <span className="dim">+ add remark</span>
|
||||
: <span className="remark-badge">💬 {thread.length}</span>}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="remark-open">
|
||||
<div className="remark-open-head">
|
||||
{handBtn}
|
||||
{raised && <span className="hand-note">✋ {by}</span>}
|
||||
<span style={{ flex: 1 }} />
|
||||
<button className="btn" onClick={() => setOpen(false)}>Close</button>
|
||||
</div>
|
||||
<div className="remark-thread">
|
||||
{thread.length === 0 && <div className="dim">No remarks yet.</div>}
|
||||
{thread.map((r, i) => (
|
||||
<div key={i} className="remark-line"><b>{r.username}:</b> {r.body}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="remark-add">
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") add(); }}
|
||||
placeholder="Add a remark…"
|
||||
autoFocus
|
||||
/>
|
||||
<button className="btn primary" disabled={busy || !draft.trim()} onClick={add}>{busy ? "…" : "Add"}</button>
|
||||
</div>
|
||||
{err && <div className="dim" style={{ color: "#ff8b8b" }}>⚠ {err}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
dashboard/src/SignIn.tsx
Normal file
56
dashboard/src/SignIn.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "./api";
|
||||
|
||||
/**
|
||||
* Full-screen sign-in gate. No data is shown until a valid token is entered — this
|
||||
* applies to both admins and ml_support. `reason` shows why a previous session ended
|
||||
* (e.g. expired due to inactivity).
|
||||
*/
|
||||
export function SignIn({
|
||||
onSignedIn,
|
||||
reason,
|
||||
}: {
|
||||
onSignedIn: (token: string, auth: { username: string; role: string }) => void;
|
||||
reason?: string | null;
|
||||
}) {
|
||||
const [token, setToken] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const signIn = async () => {
|
||||
const t = token.trim();
|
||||
if (!t) return;
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const auth = await api.login(t); // validates + records a signed_in audit event
|
||||
onSignedIn(t, auth);
|
||||
} catch (e) {
|
||||
setErr(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="signin-screen">
|
||||
<div className="signin-card">
|
||||
<h1>Verification Dashboard</h1>
|
||||
<p className="dim">Sign in with your access token to continue.</p>
|
||||
{reason && <div className="signin-reason">⏱ {reason}</div>}
|
||||
<input
|
||||
type="password"
|
||||
placeholder="paste your access token"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") signIn(); }}
|
||||
autoFocus
|
||||
/>
|
||||
<button className="btn primary" disabled={busy || !token.trim()} onClick={signIn}>
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
{err && <div className="error">⚠ {err}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
308
dashboard/src/api.ts
Normal file
308
dashboard/src/api.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
const BASE = (import.meta.env.VITE_API_BASE as string) || "/api";
|
||||
|
||||
export interface ProjectSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
source_path: string;
|
||||
source_kind: string; // 'local' | 'nfs'
|
||||
client_id: number | null;
|
||||
client_name: string;
|
||||
total: number;
|
||||
pending: number;
|
||||
annotated: number;
|
||||
verified: number;
|
||||
}
|
||||
|
||||
export interface UserRow {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuditRow {
|
||||
at: string;
|
||||
username: string;
|
||||
action: string;
|
||||
detail: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: string;
|
||||
project_count: number;
|
||||
}
|
||||
|
||||
export interface Remark {
|
||||
username: string;
|
||||
body: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface VideoRow {
|
||||
id: number;
|
||||
file_name: string;
|
||||
rel_path: string;
|
||||
has_json: boolean;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
fps: number | null;
|
||||
frame_count: number | null;
|
||||
annotation_count: number;
|
||||
imported_count: number; // baseline at claim/ingest — vs annotation_count shows deletions
|
||||
annotation_time_ms: number;
|
||||
primary_annotator: string;
|
||||
status: string;
|
||||
annotated_at: string | null;
|
||||
// Phase 3 claim/verify dimension (orthogonal to status).
|
||||
claimed_by: string | null;
|
||||
claimed_at: string | null;
|
||||
lease_expires_at: string | null;
|
||||
completed_by: string | null;
|
||||
completed_at: string | null;
|
||||
verify_time_ms: number; // total across all passes
|
||||
verify_count: number; // 0 none, 1 verified, ≥2 re-verified
|
||||
verifiers: string; // distinct verifiers, e.g. "ravi, john"
|
||||
review_count: number; // annotations flagged for review still open
|
||||
assigned_to: string | null;
|
||||
workflow_status: string; // pending|annotated|assigned|in-progress|verified|re-verified
|
||||
hand_raised: boolean;
|
||||
hand_raised_by: string | null;
|
||||
hand_raised_at: string | null;
|
||||
ignored: boolean;
|
||||
ignored_by: string | null;
|
||||
remarks: Remark[];
|
||||
}
|
||||
|
||||
export interface LeaderRow {
|
||||
user: string;
|
||||
videos: number;
|
||||
annotations: number;
|
||||
total_time_ms: number;
|
||||
avg_time_ms: number;
|
||||
}
|
||||
|
||||
export interface VerifierRow {
|
||||
user: string;
|
||||
videos: number;
|
||||
total_time_ms: number;
|
||||
avg_time_ms: number;
|
||||
annotations: number; // total annotations this user authored
|
||||
rank: number; // 1 = fastest; 0 = no completions yet
|
||||
}
|
||||
|
||||
export interface ProjectTime {
|
||||
active_workers: number;
|
||||
avg_video_ms: number;
|
||||
total_spent_ms: number;
|
||||
elapsed_ms: number;
|
||||
all_verified: boolean;
|
||||
eta_ms: number | null;
|
||||
time_running: boolean;
|
||||
last_activity_at: string | null;
|
||||
recent_verified: number;
|
||||
recent_window_days: number;
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
folder: string; // '' = project root
|
||||
video_count: number;
|
||||
included: boolean;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
overview: {
|
||||
total: number;
|
||||
pending: number;
|
||||
annotated: number;
|
||||
verified: number;
|
||||
pct_done: number;
|
||||
};
|
||||
leaderboard: LeaderRow[];
|
||||
verifiers: VerifierRow[];
|
||||
timeline: ProjectTime;
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
username: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
added_at: string;
|
||||
}
|
||||
|
||||
export interface StorageInfo {
|
||||
db_bytes: number;
|
||||
videos: number;
|
||||
annotations: number;
|
||||
video_events: number;
|
||||
audit_events: number;
|
||||
max_log_rows: number;
|
||||
}
|
||||
|
||||
export interface ActiveClaim {
|
||||
video_id: number;
|
||||
file_name: string;
|
||||
claimed_by: string;
|
||||
claimed_at: string;
|
||||
lease_expires_at: string;
|
||||
}
|
||||
|
||||
export interface ActivityEvent {
|
||||
video_id: number;
|
||||
file_name: string;
|
||||
username: string;
|
||||
event: string; // claim | release | push | auto_release | reopen
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
active_claims: ActiveClaim[];
|
||||
recent_events: ActivityEvent[];
|
||||
}
|
||||
|
||||
// The signed-in bearer token. Set once after login (App gate); every request
|
||||
// attaches it automatically so the whole dashboard is behind auth.
|
||||
let authToken = "";
|
||||
export function setAuthToken(t: string) {
|
||||
authToken = t;
|
||||
}
|
||||
|
||||
/** Download URLs for annotation export. Served as <a download> attachments, which
|
||||
* can't set a header — so the token rides as a query param (validated server-side). */
|
||||
export const exportVideoUrl = (id: number) =>
|
||||
`${BASE}/videos/${id}/export?token=${encodeURIComponent(authToken)}`;
|
||||
export const exportProjectUrl = (id: number) =>
|
||||
`${BASE}/projects/${id}/export?token=${encodeURIComponent(authToken)}`;
|
||||
|
||||
function authHeaders(token?: string): Record<string, string> {
|
||||
const t = token ?? authToken;
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
async function get<T>(path: string, token?: string): Promise<T> {
|
||||
const r = await fetch(`${BASE}${path}`, { headers: authHeaders(token) });
|
||||
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
/** POST with optional JSON body; uses the explicit token or the signed-in one. */
|
||||
async function post<T>(path: string, body?: unknown, token?: string): Promise<T> {
|
||||
const headers: Record<string, string> = { ...authHeaders(token) };
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json";
|
||||
const r = await fetch(`${BASE}${path}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
/** DELETE with the signed-in (or explicit) token. */
|
||||
async function del<T>(path: string, token?: string): Promise<T> {
|
||||
const r = await fetch(`${BASE}${path}`, { method: "DELETE", headers: authHeaders(token) });
|
||||
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
projects: () => get<ProjectSummary[]>("/projects"),
|
||||
clients: () => get<Client[]>("/clients"),
|
||||
videos: (id: number) => get<VideoRow[]>(`/projects/${id}/videos`),
|
||||
stats: (id: number) => get<Stats>(`/projects/${id}/stats`),
|
||||
activity: (id: number) => get<Activity>(`/projects/${id}/activity`),
|
||||
audit: (limit = 100) => get<AuditRow[]>(`/audit?limit=${limit}`),
|
||||
// Sync re-scans the project's NAS folder; the token (if any) attributes the action.
|
||||
ingest: (id: number, token?: string) =>
|
||||
post<{ ingested: number; source_path: string }>(`/projects/${id}/ingest`, undefined, token),
|
||||
whoami: async (token: string): Promise<{ username: string; role: string }> => {
|
||||
const r = await fetch(`${BASE}/auth/whoami`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
|
||||
return r.json();
|
||||
},
|
||||
// Explicit sign-in: validates the token AND records a `signed_in` audit event.
|
||||
login: (token: string) => post<{ username: string; role: string }>("/auth/login", undefined, token),
|
||||
// Any authenticated user: add a project, change its sync directory.
|
||||
createProject: (
|
||||
p: { client_id: number; name: string; source_path: string; source_kind: string },
|
||||
token?: string,
|
||||
) => post<{ id: number }>("/projects", p, token),
|
||||
updateProjectSource: (
|
||||
id: number,
|
||||
p: { source_path: string; source_kind: string },
|
||||
token?: string,
|
||||
) => post<{ id: number }>(`/projects/${id}/source`, p, token),
|
||||
// Admin only.
|
||||
createClient: (name: string, token?: string) => post<Client>("/clients", { name }, token),
|
||||
deleteClient: (id: number, token?: string) => del<{ deleted: boolean; projects_removed: number }>(`/clients/${id}`, token),
|
||||
deleteProject: (id: number, token?: string) => del<{ deleted: boolean }>(`/projects/${id}`, token),
|
||||
deleteUser: (id: number, token?: string) => del<{ deleted: boolean; username: string }>(`/users/${id}`, token),
|
||||
listUsers: (token?: string) => get<UserRow[]>("/users", token),
|
||||
createUser: (
|
||||
u: { username: string; display_name?: string; role: string },
|
||||
token?: string,
|
||||
) => post<{ username: string; role: string; token: string }>("/users", u, token),
|
||||
// Admin only: regenerate a user's token (when they forget it). Returns the new token once.
|
||||
resetUserToken: (id: number, token?: string) =>
|
||||
post<{ username: string; role: string; token: string }>(`/users/${id}/token`, undefined, token),
|
||||
// Admin only: edit a user's display name + role.
|
||||
updateUser: (id: number, body: { display_name: string; role: string }, token?: string) =>
|
||||
post<{ updated: boolean; display_name: string; role: string }>(`/users/${id}/update`, body, token),
|
||||
// Append a remark (any authed user). Returns the updated thread.
|
||||
addRemark: (videoId: number, body: string, token?: string) =>
|
||||
post<Remark[]>(`/videos/${videoId}/remarks`, { body }, token),
|
||||
// Raise/lower the hand on a video (any authed user). Anyone can lower it.
|
||||
setHand: (videoId: number, raised: boolean, token?: string) =>
|
||||
post<{ hand_raised: boolean; by: string }>(`/videos/${videoId}/hand`, { raised }, token),
|
||||
// Ignore / un-ignore a video (admin only).
|
||||
setIgnore: (videoId: number, ignored: boolean, token?: string) =>
|
||||
post<{ ignored: boolean }>(`/videos/${videoId}/ignore`, { ignored }, token),
|
||||
// Assign (or unassign with assignee="") videos to a member. Any authed user.
|
||||
assign: (projectId: number, video_ids: number[], assignee: string, token?: string) =>
|
||||
post<{ assigned: number; assignee: string }>(`/projects/${projectId}/assign`, { video_ids, assignee }, token),
|
||||
// Project membership (admin picks who can see/be-assigned a project).
|
||||
members: (projectId: number) => get<Member[]>(`/projects/${projectId}/members`),
|
||||
addMember: (projectId: number, username: string, token?: string) =>
|
||||
post<{ added: boolean }>(`/projects/${projectId}/members`, { username }, token),
|
||||
removeMember: (projectId: number, username: string, token?: string) =>
|
||||
del<{ removed: boolean }>(`/projects/${projectId}/members/${encodeURIComponent(username)}`, token),
|
||||
// Admin: DB size + row counts (how much space the app uses).
|
||||
storage: () => get<StorageInfo>("/admin/storage"),
|
||||
// Project timer (admin): start | stop | reset.
|
||||
setTimer: (projectId: number, action: "start" | "stop" | "reset", token?: string) =>
|
||||
post<{ action: string }>(`/projects/${projectId}/timer`, { action }, token),
|
||||
// Folders to consider for verification.
|
||||
folders: (projectId: number) => get<Folder[]>(`/projects/${projectId}/folders`),
|
||||
setFolders: (projectId: number, folders: string[], token?: string) =>
|
||||
post<{ folders: number }>(`/projects/${projectId}/folders`, { folders }, token),
|
||||
};
|
||||
|
||||
/** Human-readable byte size. */
|
||||
export function fmtBytes(n: number): string {
|
||||
if (!n || n <= 0) return "0 B";
|
||||
const u = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
|
||||
return `${(n / 1024 ** i).toFixed(i ? 1 : 0)} ${u[i]}`;
|
||||
}
|
||||
|
||||
/** Readable local date+time for a server ISO timestamp; "" when missing/invalid. */
|
||||
export function fmtWhen(iso: string | null): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
return d.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function fmtDuration(ms: number): string {
|
||||
if (!ms || ms <= 0) return "—";
|
||||
const s = Math.round(ms / 1000);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
if (m > 0) return `${m}m ${sec}s`;
|
||||
return `${sec}s`;
|
||||
}
|
||||
10
dashboard/src/main.tsx
Normal file
10
dashboard/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
254
dashboard/src/styles.css
Normal file
254
dashboard/src/styles.css
Normal file
@@ -0,0 +1,254 @@
|
||||
:root {
|
||||
--bg0: #0e1222; --bg1: #151a30; --bg2: #1c223d; --border: #283154;
|
||||
--text: #e8e9f1; --dim: #8f95ae; --accent: #f5c838;
|
||||
--ok: #5ccf75; --info: #6ea8ff; --warn: #f5a623; --danger: #ff7a7a;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg0); color: var(--text);
|
||||
font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
|
||||
.page { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||
.dim { color: var(--dim); }
|
||||
.mono, .path { font-family: ui-monospace, Menlo, monospace; font-size: 12px; }
|
||||
|
||||
.topbar { display: flex; align-items: center; gap: 12px; padding: 10px 0 16px; }
|
||||
.brand { font-size: 18px; font-weight: 700; }
|
||||
.spacer { flex: 1; }
|
||||
.topbar select, .topbar button {
|
||||
background: var(--bg2); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.topbar button:hover:not(:disabled) { background: var(--border); }
|
||||
.topbar button:disabled { opacity: 0.5; cursor: default; }
|
||||
.path { max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.error { background: rgba(255,122,122,0.12); border: 1px solid var(--danger);
|
||||
color: #ffb3a8; padding: 8px 12px; border-radius: 6px; margin-bottom: 12px; }
|
||||
|
||||
.cards { display: grid; grid-template-columns: repeat(4, 1fr) 1.6fr; gap: 12px; margin-bottom: 16px; }
|
||||
.card { background: var(--bg1); border: 1px solid var(--border); border-radius: 10px; padding: 14px; }
|
||||
.card-label { color: var(--dim); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.card-value { font-size: 30px; font-weight: 700; margin-top: 4px; }
|
||||
.card-value.sm { font-size: 16px; }
|
||||
.card.ok .card-value { color: var(--ok); }
|
||||
.card.info .card-value { color: var(--info); }
|
||||
.card.warn .card-value { color: var(--warn); }
|
||||
.progress-card { display: flex; flex-direction: column; justify-content: center; }
|
||||
.bar { height: 10px; background: var(--bg2); border-radius: 6px; overflow: hidden; margin: 8px 0; }
|
||||
.bar-fill { height: 100%; background: var(--ok); transition: width 0.4s; }
|
||||
|
||||
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
|
||||
@media (max-width: 860px) { .grid2 { grid-template-columns: 1fr; } .cards { grid-template-columns: repeat(2, 1fr); } }
|
||||
.panel { background: var(--bg1); border: 1px solid var(--border); border-radius: 10px; padding: 14px; }
|
||||
.panel h3 { margin: 0 0 10px; font-size: 14px; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
th { color: var(--dim); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
tbody tr:hover { background: rgba(255,255,255,0.02); }
|
||||
|
||||
.badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
||||
.badge.pending { background: rgba(245,166,35,0.18); color: var(--warn); }
|
||||
.badge.annotated { background: rgba(110,168,255,0.18); color: var(--info); }
|
||||
.badge.verified { background: rgba(92,207,117,0.18); color: var(--ok); }
|
||||
|
||||
.throughput { display: flex; flex-direction: column; gap: 6px; }
|
||||
.tp-row { display: grid; grid-template-columns: 90px 1fr 34px; align-items: center; gap: 8px; }
|
||||
.tp-date { font-size: 11px; }
|
||||
.tp-bar { height: 12px; background: var(--bg2); border-radius: 6px; overflow: hidden; }
|
||||
.tp-fill { height: 100%; background: var(--accent); }
|
||||
.tp-count { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* View toggle (Dashboard | Admin) */
|
||||
.viewtabs { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
|
||||
.viewtabs button {
|
||||
background: var(--bg1); color: var(--dim); border: none; padding: 6px 14px;
|
||||
cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.viewtabs button.active { background: var(--bg2); color: var(--text); font-weight: 600; }
|
||||
.viewtabs button:not(.active):hover { color: var(--text); }
|
||||
|
||||
/* Admin forms */
|
||||
.admin { display: flex; flex-direction: column; gap: 16px; }
|
||||
.admin .grid2 { margin-bottom: 0; }
|
||||
.form-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.form-col { display: flex; flex-direction: column; gap: 6px; max-width: 480px; }
|
||||
.form-col label { color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.admin input, .admin select {
|
||||
background: var(--bg2); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 7px 10px; font-size: 13px; font-family: inherit;
|
||||
}
|
||||
.admin input:focus, .admin select:focus { outline: none; border-color: var(--info); }
|
||||
.btn {
|
||||
background: var(--bg2); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 7px 14px; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: var(--border); }
|
||||
.btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn.primary { background: var(--info); border-color: var(--info); color: #0a1020; font-weight: 600; align-self: flex-start; }
|
||||
.btn.primary:hover:not(:disabled) { filter: brightness(1.08); background: var(--info); }
|
||||
.ok-tag { color: var(--ok); font-size: 12px; }
|
||||
.err-tag { color: var(--danger); font-size: 12px; }
|
||||
.notice-ok { background: rgba(92,207,117,0.12); border: 1px solid var(--ok);
|
||||
color: #b6f0c3; padding: 8px 12px; border-radius: 6px; }
|
||||
|
||||
/* One-time token reveal */
|
||||
.token-box { background: rgba(245,200,56,0.08); border: 1px solid var(--accent);
|
||||
border-radius: 8px; padding: 10px 12px; margin-bottom: 10px; font-size: 13px; }
|
||||
.token-val { background: var(--bg0); border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 6px 8px; font-family: ui-monospace, Menlo, monospace; font-size: 12px;
|
||||
word-break: break-all; flex: 1; }
|
||||
|
||||
/* Remark thread cell (video table) */
|
||||
.remark-col { max-width: 360px; }
|
||||
.remark-collapsed { background: none; border: none; color: var(--fg, #dce3f5); cursor: pointer;
|
||||
text-align: left; padding: 2px 0; font-size: 12px; display: inline-flex; gap: 6px; align-items: center; max-width: 340px; }
|
||||
.remark-collapsed:hover { filter: brightness(1.2); }
|
||||
.remark-badge { background: var(--bg0); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 1px 7px; font-size: 11px; white-space: nowrap; }
|
||||
.remark-snip { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.remark-open { display: flex; flex-direction: column; gap: 6px; min-width: 280px; }
|
||||
.remark-thread { max-height: 120px; overflow: auto; display: flex; flex-direction: column; gap: 3px;
|
||||
background: var(--bg0); border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; }
|
||||
.remark-line { font-size: 12px; line-height: 1.35; }
|
||||
.remark-line b { color: var(--info); }
|
||||
.remark-add { display: flex; gap: 6px; align-items: center; }
|
||||
.remark-add input { flex: 1; background: var(--bg0); color: inherit; border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 6px 8px; font-size: 12px; }
|
||||
|
||||
/* Claim / completion subtitle under the status badge (video table) */
|
||||
.claim-line { margin-top: 3px; font-size: 11px; color: var(--dim); white-space: nowrap; }
|
||||
.claim-line b { color: var(--fg, #dce3f5); font-weight: 600; }
|
||||
|
||||
/* Flagged-for-review count (video table) */
|
||||
.review-flag { background: rgba(245,166,35,0.18); color: var(--warn); border-radius: 10px; padding: 2px 8px; font-size: 11px; font-weight: 600; white-space: nowrap; }
|
||||
|
||||
/* Panel header row (title + actions, e.g. Export all) */
|
||||
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.panel-head h3 { margin: 0; }
|
||||
|
||||
/* Recent activity feed + verification panel */
|
||||
.active-claims { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
|
||||
.active-claim { font-size: 12px; background: rgba(110,168,255,0.08); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; }
|
||||
.activity-feed { display: flex; flex-direction: column; gap: 3px; max-height: 260px; overflow: auto; }
|
||||
.activity-row { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 2px 0; }
|
||||
.evt { font-size: 10px; font-weight: 700; text-transform: uppercase; border-radius: 4px; padding: 1px 6px; min-width: 56px; text-align: center; }
|
||||
.evt-push { background: rgba(92,207,117,0.18); color: var(--ok); }
|
||||
.evt-claim { background: rgba(110,168,255,0.18); color: var(--info); }
|
||||
.evt-release { background: rgba(245,166,35,0.18); color: var(--warn); }
|
||||
.evt-auto_release { background: rgba(245,166,35,0.12); color: var(--warn); }
|
||||
.evt-reopen { background: rgba(160,160,160,0.18); color: var(--dim); }
|
||||
|
||||
/* Workflow-status badges (assigned / in-progress / re-verified) + assignment UI */
|
||||
.badge.assigned { background: rgba(180,140,255,0.18); color: #b48cff; }
|
||||
.badge.in-progress { background: rgba(110,168,255,0.20); color: var(--info); }
|
||||
.badge.re-verified { background: rgba(92,207,117,0.22); color: var(--ok); }
|
||||
.panel-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.assign-bar { display: flex; align-items: center; gap: 6px; font-size: 12px; }
|
||||
.assign-bar select { background: var(--bg0); color: inherit; border: 1px solid var(--border); border-radius: 6px; padding: 4px 6px; font-size: 12px; }
|
||||
tr.row-sel { background: rgba(110,168,255,0.08); }
|
||||
|
||||
/* ---- Sign-in gate ---- */
|
||||
.signin-screen { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 16px; }
|
||||
.signin-card { background: var(--bg1); border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 28px; width: 100%; max-width: 380px; box-shadow: 0 8px 32px rgba(0,0,0,0.4); }
|
||||
.signin-card h1 { margin: 0 0 4px; font-size: 20px; }
|
||||
.signin-card p { margin: 0 0 16px; }
|
||||
.signin-card input { width: 100%; background: var(--bg2); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 9px 11px; font-size: 14px; margin-bottom: 12px; }
|
||||
.signin-card input:focus { outline: none; border-color: var(--info); }
|
||||
.signin-card .btn.primary { width: 100%; justify-content: center; align-self: stretch; text-align: center; }
|
||||
.signin-card .error { margin-top: 12px; margin-bottom: 0; }
|
||||
.signin-reason { background: rgba(245,166,35,0.12); border: 1px solid var(--warn); color: #f3d3a0;
|
||||
border-radius: 6px; padding: 8px 10px; font-size: 13px; margin-bottom: 14px; }
|
||||
|
||||
/* Signed-in identity + sign out in the top bar */
|
||||
.whoami { color: var(--dim); font-size: 12px; white-space: nowrap; }
|
||||
.signout-btn { background: var(--bg2); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 13px; }
|
||||
.signout-btn:hover { background: var(--border); }
|
||||
|
||||
/* Danger / destructive buttons + row action group */
|
||||
.btn.danger { border-color: var(--danger); color: #ffb3a8; }
|
||||
.btn.danger:hover:not(:disabled) { background: rgba(255,122,122,0.15); }
|
||||
.row-actions { display: flex; gap: 6px; }
|
||||
|
||||
/* Imported-vs-current annotation count (deletions/additions) */
|
||||
.imported-line { font-size: 11px; margin-top: 2px; white-space: nowrap; }
|
||||
.ann-del { color: var(--danger); font-weight: 600; }
|
||||
.ann-add { color: var(--ok); font-weight: 600; }
|
||||
|
||||
/* Raise-hand toggle in the remark cell */
|
||||
.remark-cell { display: flex; align-items: center; gap: 6px; }
|
||||
.hand-btn { background: var(--bg2); border: 1px solid var(--border); border-radius: 6px;
|
||||
cursor: pointer; font-size: 13px; line-height: 1; padding: 3px 6px; filter: grayscale(1) opacity(0.55); }
|
||||
.hand-btn:hover:not(:disabled) { filter: none; }
|
||||
.hand-btn.raised { filter: none; background: rgba(245,166,35,0.18); border-color: var(--warn); }
|
||||
.hand-note { color: var(--warn); font-size: 11px; white-space: nowrap; }
|
||||
.remark-open-head { display: flex; align-items: center; gap: 6px; }
|
||||
|
||||
/* Ignored video row (admin "ignore") — crossed out + dimmed */
|
||||
tr.row-ignored td { text-decoration: line-through; opacity: 0.5; }
|
||||
tr.row-ignored .badge, tr.row-ignored .ignore-btn { text-decoration: none; }
|
||||
.badge.ignored-tag { background: rgba(160,160,160,0.18); color: var(--dim); margin-left: 6px; text-decoration: none; }
|
||||
.ignore-btn { background: none; border: none; color: var(--dim); cursor: pointer; font-size: 11px;
|
||||
margin-left: 8px; text-decoration: underline; padding: 0; }
|
||||
.ignore-btn:hover { color: var(--text); }
|
||||
|
||||
/* Project time & estimate panel */
|
||||
.time-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
|
||||
.tcell { display: flex; flex-direction: column; gap: 2px; background: var(--bg2);
|
||||
border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
|
||||
.tcell b { font-size: 18px; }
|
||||
.tcell .sub { font-size: 11px; }
|
||||
.tcell.big { background: rgba(110,168,255,0.08); border-color: rgba(110,168,255,0.35); }
|
||||
.tcell.big b { font-size: 20px; }
|
||||
|
||||
/* Members picker (dropdown checklist on the leaderboard panel) */
|
||||
.members-picker { position: relative; }
|
||||
.members-dropdown { position: absolute; right: 0; top: calc(100% + 6px); z-index: 30; width: 280px;
|
||||
background: var(--bg1); border: 1px solid var(--border); border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.45); padding: 8px; }
|
||||
.members-search { width: 100%; background: var(--bg2); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 6px 8px; font-size: 13px; margin-bottom: 6px; }
|
||||
.members-list { max-height: 260px; overflow: auto; display: flex; flex-direction: column; gap: 1px; }
|
||||
.members-item { display: flex; align-items: center; gap: 8px; padding: 5px 6px; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||
.members-item:hover { background: var(--bg2); }
|
||||
.members-item.on { background: rgba(92,207,117,0.10); }
|
||||
.members-item .mi-disp { font-size: 11px; margin-left: auto; }
|
||||
.mi-empty { padding: 8px; font-size: 12px; }
|
||||
.mi-err { color: var(--danger); font-size: 12px; padding: 4px 6px; }
|
||||
.members-foot { font-size: 11px; border-top: 1px solid var(--border); margin-top: 6px; padding-top: 6px; }
|
||||
.members-cta { background: rgba(245,166,35,0.10); border: 1px solid rgba(245,166,35,0.4);
|
||||
color: #f3d3a0; border-radius: 6px; padding: 7px 10px; font-size: 12px; margin-bottom: 10px; }
|
||||
.folders-hint { font-size: 11px; padding: 2px 4px 6px; }
|
||||
|
||||
/* Verification pace panel */
|
||||
.pace-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; }
|
||||
.pcell { display: flex; flex-direction: column; gap: 2px; background: var(--bg2);
|
||||
border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
|
||||
.pcell b { font-size: 18px; }
|
||||
.pcell .sub { font-size: 11px; }
|
||||
.pcell.big { background: rgba(110,168,255,0.08); border-color: rgba(110,168,255,0.35); }
|
||||
tr.rank-top { background: rgba(245,200,56,0.08); }
|
||||
|
||||
/* Project timer controls */
|
||||
.timer-controls { display: flex; align-items: center; gap: 8px; }
|
||||
.timer-state { font-size: 12px; font-weight: 600; }
|
||||
.timer-state.on { color: var(--ok); }
|
||||
.timer-state.off { color: var(--dim); }
|
||||
|
||||
/* Project members manager (Admin) */
|
||||
.members-box { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||
.members-box h4 { margin: 0 0 8px; font-size: 13px; }
|
||||
.member-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.member-chip { display: inline-flex; align-items: center; gap: 6px; background: var(--bg2);
|
||||
border: 1px solid var(--border); border-radius: 14px; padding: 3px 6px 3px 10px; font-size: 12px; }
|
||||
.chip-x { background: none; border: none; color: var(--danger); cursor: pointer; font-size: 14px;
|
||||
line-height: 1; padding: 0 2px; }
|
||||
.chip-x:hover { filter: brightness(1.2); }
|
||||
|
||||
/* Storage panel */
|
||||
.storage-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; }
|
||||
.storage-grid > div { display: flex; flex-direction: column; gap: 2px; background: var(--bg2);
|
||||
border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
|
||||
.storage-grid b { font-size: 18px; }
|
||||
1
dashboard/src/vite-env.d.ts
vendored
Normal file
1
dashboard/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
20
dashboard/tsconfig.json
Normal file
20
dashboard/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
dashboard/tsconfig.tsbuildinfo
Normal file
1
dashboard/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/AdminPanel.tsx","./src/App.tsx","./src/FoldersPicker.tsx","./src/MembersPicker.tsx","./src/RemarkCell.tsx","./src/SignIn.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts"],"version":"5.9.3"}
|
||||
12
dashboard/vite.config.ts
Normal file
12
dashboard/vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
// `npm run dev` proxies /api to the server so the app is same-origin in dev.
|
||||
proxy: {
|
||||
"/api": { target: "http://localhost:8080", changeOrigin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user