diff --git a/web/src/pages/Samples.tsx b/web/src/pages/Samples.tsx new file mode 100644 index 000000000..e84f46e5d --- /dev/null +++ b/web/src/pages/Samples.tsx @@ -0,0 +1,681 @@ +import { Link } from "@tanstack/react-router"; +import { useQueryClient } from "@tanstack/react-query"; +import { Copy, Download, FileVideo, Loader2, Search, Trash2 } from "lucide-react"; +import { motion } from "motion/react"; +import { useMemo, useState } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm"; +import { Input } from "@/components/ui/input"; +import { AnimatedSheet, SheetDescription, SheetTitle } from "@/components/ui/sheet"; +import { getSession } from "@/lib/auth"; +import { + deleteExtraFile, + deleteSample, + extraFile, + mediaInfoFile, + sampleFile, + updateSample, + useTags, + type StoredFile, + useRegressionTests, + useSampleDetails, + useSampleHistory, + useSamples, + type MediaInfoNode, +} from "@/lib/api"; +import type { Platform, Sample } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +/** "Samples" — live from GET /api/v1/samples. */ +export function Samples() { + const { data: samples = [], isLoading } = useSamples(); + const { data: tests = [] } = useRegressionTests(); + const [q, setQ] = useState(""); + const [ext, setExt] = useState(null); + const [selected, setSelected] = useState(null); + const [limit, setLimit] = useState(60); + + const extensions = useMemo(() => { + const counts = new Map(); + for (const s of samples) counts.set(s.extension, (counts.get(s.extension) ?? 0) + 1); + return [...counts.entries()].sort((a, b) => b[1] - a[1]); + }, [samples]); + + const filtered = useMemo( + () => + samples.filter((s) => { + if (ext && s.extension !== ext) return false; + if ( + q && + !`${s.original_name} ${s.sha} ${s.tags.join(" ")}`.toLowerCase().includes(q.toLowerCase()) + ) + return false; + return true; + }), + [samples, q, ext], + ); + + return ( +
+
+
+

Samples

+ {samples.length} media samples +
+ + setQ(e.target.value)} + /> +
+
+
+ +
+
+ {extensions.slice(0, 18).map(([e, count]) => ( + + ))} +
+ + {isLoading && ( +
+ {Array.from({ length: 9 }, (_, n) => `placeholder-${n}`).map((id) => ( +
+ ))} +
+ )} + +
+ {filtered.slice(0, limit).map((s, i) => { + const sampleTests = tests.filter((t) => t.sample_id === s.id); + return ( + setSelected(s)} + className="card-hover cursor-pointer rounded-xl border bg-card p-3.5 shadow-card" + > +
+ + + +
+
+ {s.original_name} +
+ {s.sha.slice(0, 20)}… +
+ {s.extension} +
+
+ {s.tags.slice(0, 3).map((t) => ( + {t} + ))} + + {sampleTests.length > 0 ? ( + e.stopPropagation()} + className="font-medium text-primary hover:underline" + > + {sampleTests.length} test{sampleTests.length > 1 ? "s" : ""} → + + ) : ( + no tests + )} + +
+
+ ); + })} +
+ {filtered.length > limit && ( +
+ +
+ )} +
+ + !o && setSelected(null)} + resizeKey="sample-detail" + > + {selected && } + +
+ ); +} + +/** + * Full sample-info drawer: basic details, + * upload metadata, tags, per-platform test status, tests, media-info tree, + * and cross-run result history. Everything live. + */ +function SampleDetail({ sample }: Readonly<{ sample: Sample }>) { + const { data: details } = useSampleDetails(sample.id); + const { data: history = [], isLoading } = useSampleHistory(sample.id); + const { data: tests = [] } = useRegressionTests(); + const sampleTests = tests.filter((t) => t.sample_id === sample.id); + const upload = details?.upload; + const admin = getSession()?.role === "admin"; + + // Latest result per platform → the "test status" summary the old page had. + const perPlatform = useMemo(() => { + const out: Record = { + linux: null, + windows: null, + }; + for (const h of history) { + const p = h.platform as Platform; + out[p] ??= { status: h.status, run_id: h.run_id }; + } + return out; + }, [history]); + + return ( +
+
+ + {sample.original_name} + +
+ + Sample #{sample.id} · {sample.extension} + + +
+
+ +
+
+ Basic details +
+ +
+ {sample.sha} + +
+
+ {sample.extension} + {upload && ( + <> + + {upload.version ?? "unknown"} + {upload.version_released && ` (released ${upload.version_released})`} + + {upload.platform ?? "—"} + + {upload.parameters || "—"} + + {upload.notes || "—"} + + )} +
+ {admin ? ( + + ) : ( +
+ Tags: + {sample.tags.length ? ( + sample.tags.map((t) => {t}) + ) : ( + no tags yet + )} +
+ )} + +
+ mediaInfoFile(sample.id)} /> + {(details?.extra_files ?? []).map((x) => ( + + extraFile(sample.id, x.id)} + /> + {admin && } + + ))} + {admin && } +
+
+ +
+ Test status +
+ {(["linux", "windows"] as Platform[]).map((p) => { + const r = perPlatform[p]; + return ( +
+
+ {p} +
+ {r ? ( + + + + {r.status} + + · run {r.run_id} + + ) : ( + no runs + )} +
+ ); + })} +
+
+ +
+ Regression tests using this sample +
+ {sampleTests.map((t) => ( + + #{t.id} + {t.command} + + ))} + {sampleTests.length === 0 && ( +
+ No regression tests use this sample yet. +
+ )} +
+
+ + {details?.media_info && ( +
+ Media info +
+ {details.media_info.map((node) => ( + + ))} +
+
+ )} + +
+ Result history — live + {isLoading &&
} +
+ {history.map((h) => ( +
+ + + {h.platform} + + test #{h.regression_test_id} + + {h.commit_sha.slice(0, 9)} + + + {h.tested_at && new Date(h.tested_at).toLocaleDateString()} + +
+ ))} + {!isLoading && history.length === 0 && ( +
No recorded results.
+ )} +
+
+
+
+ ); +} + +/** + * Resolves the sample's signed URL on click and follows it. + * + * Samples are gigabytes, so the API answers with a location instead of the + * bytes. Where the only copy is on the platform's own disk there is no URL + * to follow and the button says so. + */ +function SampleDownload({ sampleId }: Readonly<{ sampleId: number }>) { + const [state, setState] = useState<"idle" | "busy" | "unavailable">("idle"); + + const go = async () => { + setState("busy"); + try { + const file = await sampleFile(sampleId); + if (!file.download_url) { + setState("unavailable"); + return; + } + window.open(file.download_url, "_blank", "noopener"); + setState("idle"); + } catch { + setState("unavailable"); + } + }; + + return ( + + ); +} + +function SectionLabel({ children }: Readonly<{ children: React.ReactNode }>) { + return ( +
+ {children} +
+ ); +} + +function Row({ + label, children, last, +}: Readonly<{ + label: string; + children: React.ReactNode; + last?: boolean; +}>) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +/** Renders one media-info node: a scalar, a flat dict, or a list of tracks. */ +function MediaNode({ node, depth = 0 }: Readonly<{ node: MediaInfoNode; depth?: number }>) { + const pad = { paddingLeft: depth * 12 }; + if (typeof node.value === "string") { + return ( +
+ {node.name}: {node.value} +
+ ); + } + if (Array.isArray(node.value)) { + return ( +
+
{node.name}:
+ {node.value.map((track) => ( +
+
{track.name}:
+ {Object.entries(track.value).map(([k, v]) => ( +
+ {k}: {v} +
+ ))} +
+ ))} +
+ ); + } + return ( +
+
{node.name}:
+ {Object.entries(node.value).map(([k, v]) => ( +
+ {k}: {v} +
+ ))} +
+ ); +} + +/** + * Tag editing for one sample. + * + * Tags are picked from the platform's list rather than typed, because the + * API matches them by name and a typo would just be rejected. Creating a + * new tag is an admin action of its own, on the administration page. + */ +function TagEditor({ sample }: Readonly<{ sample: Sample }>) { + const qc = useQueryClient(); + const { data: tags = [] } = useTags(); + const [chosen, setChosen] = useState(sample.tags); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const dirty = + chosen.length !== sample.tags.length || + chosen.some((t) => !sample.tags.includes(t)); + + const save = async () => { + setBusy(true); + setError(null); + try { + await updateSample(sample.id, { tags: chosen }); + await qc.invalidateQueries({ queryKey: ["samples"] }); + } catch (e) { + setError(e instanceof Error ? e.message : "That did not work."); + } finally { + setBusy(false); + } + }; + + return ( +
+ Tags: + {tags.map((t) => { + const on = chosen.includes(t.name); + return ( + + ); + })} + {tags.length === 0 && ( + no tags defined yet + )} + {dirty && ( + + )} + {error && {error}} +
+ ); +} + +/** Deletes a sample, refused by the API while any test still uses it. */ +function DeleteSample({ sample }: Readonly<{ sample: Sample }>) { + const qc = useQueryClient(); + const [confirming, setConfirming] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const go = async () => { + setBusy(true); + setError(null); + try { + await deleteSample(sample.id); + await qc.invalidateQueries({ queryKey: ["samples"] }); + setConfirming(false); + } catch (e) { + setError(e instanceof Error ? e.message : "That did not work."); + setConfirming(false); + } finally { + setBusy(false); + } + }; + + return ( + <> + + {error &&
{error}
} + + The media file, its media info and any accompanying files are + removed from the repository. This cannot be undone. The + platform refuses while a regression test still uses the sample. + + } + confirmLabel={busy ? "Deleting…" : "Delete sample"} + busy={busy} + onConfirm={go} + /> + + ); +} + +/** + * Drops one of the files uploaded alongside a sample. + * + * No confirm dialog: unlike deleting the sample this loses a single + * accompanying file, and the classic page removes it on one click too. + */ +function RemoveExtraFile({ sampleId, extraId }: Readonly<{ sampleId: number; extraId: number }>) { + const qc = useQueryClient(); + const [busy, setBusy] = useState(false); + + const go = async () => { + setBusy(true); + try { + await deleteExtraFile(sampleId, extraId); + await qc.invalidateQueries({ queryKey: ["sample-details", sampleId] }); + } finally { + setBusy(false); + } + }; + + return ( + + ); +} + +/** Resolves a signed URL on click, the same way the baseline buttons do. */ +function FileLink({ + label, + locate, +}: Readonly<{ + label: string; + locate: () => Promise; +}>) { + const [state, setState] = useState<"idle" | "busy" | "unavailable">("idle"); + + const go = async () => { + setState("busy"); + try { + const file = await locate(); + if (!file.download_url) { + setState("unavailable"); + return; + } + window.open(file.download_url, "_blank", "noopener"); + setState("idle"); + } catch { + setState("unavailable"); + } + }; + + return ( + + ); +} diff --git a/web/src/pages/Upload.tsx b/web/src/pages/Upload.tsx new file mode 100644 index 000000000..bc10e117a --- /dev/null +++ b/web/src/pages/Upload.tsx @@ -0,0 +1,483 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { CheckCircle2, FileVideo, Loader2, UploadCloud, XCircle } from "lucide-react"; +import { motion } from "motion/react"; +import { useRef, useState } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + discardUpload, + finalizeUpload, + linkUpload, + uploadSample, + useAbout, + useFtpCredentials, + useQueuedSamples, + useSamples, + type FtpCredentials, + type QueuedSample, +} from "@/lib/api"; +import type { Platform } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +interface Picked { + file: File; + sha: string | null; // null while hashing + duplicateOf: string | null; + tooLarge?: boolean; +} + +// crypto.subtle has no streaming API, so hashing means loading the whole +// file into memory. Past this size that risks killing the tab — skip the +// browser-side duplicate check and let the server catch it instead. +const HASH_LIMIT = 1024 ** 3; // 1 GiB + +/** + * Sample upload, in the two steps the platform actually works in: the bytes + * go into a queue first, and the description that turns a queued file into + * a sample follows separately. + * + * Files are hashed in the browser and checked against the library before + * any transfer, so a duplicate is caught up front rather than after a + * multi-gigabyte upload. The server checks again on arrival, since another + * upload can land in between. + */ +export function Upload() { + const { data: samples = [] } = useSamples(); + const qc = useQueryClient(); + const [items, setItems] = useState([]); + const [dragging, setDragging] = useState(false); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + const inputRef = useRef(null); + + // Too big to hash in the browser still uploads: the server hashes it and + // rejects a duplicate there, which is the check that actually counts. + const ready = items.filter( + (it) => !it.duplicateOf && (it.sha !== null || it.tooLarge), + ); + + const sendAll = async () => { + setUploading(true); + setUploadError(null); + try { + for (const item of ready) { + await uploadSample(item.file); + setItems((prev) => prev.filter((it) => it.file !== item.file)); + } + await qc.invalidateQueries({ queryKey: ["queued-samples"] }); + } catch (e) { + setUploadError(e instanceof Error ? e.message : "Upload failed."); + } finally { + setUploading(false); + } + }; + + const addFiles = async (files: FileList | File[]) => { + for (const file of Array.from(files)) { + if (file.size > HASH_LIMIT) { + setItems((prev) => [...prev, { file, sha: null, duplicateOf: null, tooLarge: true }]); + continue; + } + const entry: Picked = { file, sha: null, duplicateOf: null }; + setItems((prev) => [...prev, entry]); + const buf = await file.arrayBuffer(); + const digest = await crypto.subtle.digest("SHA-256", buf); + const sha = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); + const dup = samples.find((s) => s.sha === sha); + setItems((prev) => + prev.map((it) => + it.file === file ? { ...it, sha, duplicateOf: dup?.original_name ?? null } : it, + ), + ); + } + }; + + return ( +
+
+
+

Sample upload

+ + files are checked against the library before uploading + +
+
+ +
+ {/* A button rather than a div: dropping is a convenience, but opening + the picker has to work from the keyboard too, and a button gets + that plus a focus ring for free. */} + + {/* Outside the button: a form control nested in one is invalid, and + browsers disagree about whether the click even reaches it. */} + e.target.files && addFiles(e.target.files)} + /> + +
+ {items.map((it) => ( + + +
+
{it.file.name}
+
+ {(it.file.size / 1_048_576).toFixed(1)} MB + {it.sha && ` · ${it.sha.slice(0, 20)}…`} +
+
+ +
+ ))} +
+ + {ready.length > 0 && ( +
+
+ {ready.length} file{ready.length > 1 ? "s" : ""} ready. + {uploadError && ( + {uploadError} + )} +
+ +
+ )} + + + + +
+
+ ); +} + +/** + * The queue between an upload and a sample. + * + * Each row can go three ways: describe it into a new sample, attach it to + * one that already exists, or throw it away. Describing needs a CCExtractor + * version, which is why the platform splits this from the transfer at all. + */ +function UploadQueue() { + const { data: queued = [], isLoading } = useQueuedSamples(); + const [active, setActive] = useState(null); + + if (isLoading || queued.length === 0) return null; + + return ( +
+ In the queue · {queued.length} +
+ {queued.map((q) => ( + setActive(active === q.id ? null : q.id)} + /> + ))} +
+
+ ); +} + +function QueueRow({ + item, + open, + onToggle, +}: Readonly<{ + item: QueuedSample; + open: boolean; + onToggle: () => void; +}>) { + const qc = useQueryClient(); + const { data: about } = useAbout(); + const { data: samples = [] } = useSamples(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [version, setVersion] = useState(""); + const [platform, setPlatform] = useState("linux"); + const [notes, setNotes] = useState(""); + const [linkTo, setLinkTo] = useState(""); + + // The platform reports the release under test, which is the version an + // uploader almost always means. + const versionValue = version || about?.ccextractor_version || ""; + + const act = async (fn: () => Promise) => { + setBusy(true); + setError(null); + try { + await fn(); + await qc.invalidateQueries({ queryKey: ["queued-samples"] }); + await qc.invalidateQueries({ queryKey: ["samples"] }); + } catch (e) { + setError(e instanceof Error ? e.message : "That did not work."); + } finally { + setBusy(false); + } + }; + + return ( +
+ + + {open && ( +
+
+ + + +
+ +
+ + + or attach to + + + + +
+ + {error && ( +
{error}
+ )} +
+ )} +
+ ); +} + +/** + * FTP details for the ingest server. + * + * Behind a click because it shows a working password, and because asking + * for it is what creates the account the first time. + */ +function FtpPanel() { + const [shown, setShown] = useState(false); + const { data, isLoading } = useFtpCredentials(shown); + + return ( +
+ Upload over FTP +
+

+ For files too large to push through the browser. The platform picks + up anything dropped into your FTP folder. +

+ setShown(true)} isLoading={isLoading} data={data} /> +
+
+ ); +} + +/** Where a picked file stands: too big to hash, hashing, duplicate, or new. */ +function PickedStatus({ item }: Readonly<{ item: Picked }>) { + if (item.tooLarge) { + return ( + + dup check on server + + ); + } + if (item.sha === null) { + return ( + + hashing + + ); + } + if (item.duplicateOf) { + return ( + + already in library: {item.duplicateOf} + + ); + } + return ( + + new sample + + ); +} + +/** + * FTP credentials, hidden until asked for. + * + * They are only fetched on reveal, so an unopened page never pulls a password + * it is not going to show. + */ +function FtpDetails({ + shown, + reveal, + isLoading, + data, +}: Readonly<{ + shown: boolean; + reveal: () => void; + isLoading: boolean; + data: FtpCredentials | undefined; +}>) { + if (!shown) { + return ( + + ); + } + if (isLoading) { + return ( +
+ fetching +
+ ); + } + if (!data) return null; + return ( +
+
Host
+
{data.host}
+
Port
+
{data.port}
+
Username
+
{data.username}
+
Password
+
{data.password}
+
+ ); +} + +function SectionLabel({ children }: Readonly<{ children: React.ReactNode }>) { + return ( +
+ {children} +
+ ); +}