diff --git a/web/src/pages/Admin.tsx b/web/src/pages/Admin.tsx
new file mode 100644
index 000000000..7ccad4e3e
--- /dev/null
+++ b/web/src/pages/Admin.tsx
@@ -0,0 +1,685 @@
+import { useQueryClient } from "@tanstack/react-query";
+import { Link } from "@tanstack/react-router";
+import { Ban, FileX, FolderTree, KeyRound, ListOrdered, Mail, Plus, Tags, Trash2, UserX, Users, Wrench } from "lucide-react";
+import { motion } from "motion/react";
+import { useState } from "react";
+
+import { RunStatusBadge } from "@/components/StatusBadge";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { ConfirmDialog } from "@/components/ui/confirm";
+import {
+ allowExtension,
+ blockUser,
+ createCategory,
+ createTag,
+ deactivateUser,
+ deleteCategory,
+ forbidExtension,
+ renameCategory,
+ revokeToken,
+ sendUserReset,
+ setMaintenance,
+ unblockUser,
+ updateUserRole,
+ useBlockedUsers,
+ useCategories,
+ useForbiddenExtensions,
+ useMaintenance,
+ useQueue,
+ useTags,
+ useTokens,
+ useUsers,
+ type PlatformUser,
+} from "@/lib/api";
+import { getSession } from "@/lib/auth";
+import { cn } from "@/lib/utils";
+import type { RunStatus } from "@/lib/types";
+
+const ROLES: PlatformUser["role"][] = ["admin", "contributor", "tester", "user"];
+
+/**
+ * Administration. Platform mutations live here and nowhere else — browse
+ * pages stay read-only so a stray click can't change the suite.
+ */
+export function Admin() {
+ return (
+
+
Administration
+
+ Manage users, categories, tags, CI availability and API tokens.
+
+
+
+
+ Regression suite
+
+
+
+
+
+
New regression test
+
+ Pick a sample, set the command, verify the output, then activate.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Run one administration mutation and refresh its list. Every section here
+ * needs the same busy/error handling, and a failed write must leave the list
+ * showing what the server still has rather than an optimistic guess.
+ */
+function useMutate(queryKey: string) {
+ const qc = useQueryClient();
+ const [busy, setBusy] = useState(false);
+ const [err, setErr] = useState(null);
+
+ const run = async (fn: () => Promise) => {
+ setBusy(true);
+ setErr(null);
+ try {
+ await fn();
+ await qc.invalidateQueries({ queryKey: [queryKey] });
+ } catch (e) {
+ setErr(e instanceof Error ? e.message : "That didn't work.");
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return { run, busy, err };
+}
+
+function ErrorLine({ msg }: Readonly<{ msg: string | null }>) {
+ if (!msg) return null;
+ return {msg}
;
+}
+
+function MaintenanceSection() {
+ const { data } = useMaintenance();
+ const { run, busy, err } = useMutate("maintenance");
+ const platforms = data?.platforms ?? [];
+
+ return (
+
+
+ Maintenance mode
+
+
+
+ {platforms.map((m) => (
+
+
+
{m.platform}
+
+ {m.disabled ? "CI paused — new runs queue but won't start" : "Accepting runs"}
+
+
+
run(() => setMaintenance(m.platform, !m.disabled))}
+ className={cn(
+ "relative h-5 w-9 cursor-pointer rounded-full transition-colors disabled:opacity-50",
+ m.disabled ? "bg-warning" : "bg-border-strong",
+ )}
+ >
+
+
+
+ ))}
+
+
+ );
+}
+
+function BlockedUsersSection() {
+ const { data: blocked = [] } = useBlockedUsers();
+ const { run, busy, err } = useMutate("blocked-users");
+ const [id, setId] = useState("");
+ const [comment, setComment] = useState("");
+
+ const add = () =>
+ run(async () => {
+ await blockUser(Number(id), comment.trim() || "Blocked from the console");
+ setId("");
+ setComment("");
+ });
+
+ return (
+
+
+ Blocked CI users
+ {blocked.length}
+
+
+
+ {blocked.map((b) => (
+
+ {b.user_id}
+ {b.comment}
+ run(() => unblockUser(b.user_id))}
+ >
+ unblock
+
+
+ ))}
+
+
+
+ );
+}
+
+function ForbiddenSection() {
+ const { data: exts = [] } = useForbiddenExtensions();
+ const { run, busy, err } = useMutate("forbidden-extensions");
+ const [val, setVal] = useState("");
+
+ return (
+
+
+ Forbidden upload extensions
+
+
+
+
+ {exts.map((e) => (
+
+ .{e}
+ run(() => allowExtension(e))}
+ >
+ ×
+
+
+ ))}
+ {exts.length === 0 && (
+ Every extension is accepted.
+ )}
+
+
+ {/* Stored without the leading dot, alphanumeric only — the API
+ rejects anything else so a pattern can't be smuggled in. */}
+
setVal(e.target.value.replace(/[^a-z0-9]/gi, "").toLowerCase())}
+ placeholder="extension"
+ className="h-7 w-40 rounded-md border bg-card px-2 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ />
+
run(async () => { await forbidExtension(val); setVal(""); })}
+ >
+ Add
+
+
+
+
+ );
+}
+
+function CategorySection() {
+ const { data: cats = [] } = useCategories();
+ const { run, busy, err } = useMutate("categories");
+ const [editing, setEditing] = useState(null);
+ const [draft, setDraft] = useState("");
+ const [fresh, setFresh] = useState("");
+
+ // Enter commits and blurs, and Escape clears `editing` before the blur
+ // lands — both would otherwise fire this twice, the second time undoing
+ // the cancel.
+ const commit = (id: number) => {
+ if (editing !== id) return;
+ const name = draft.trim();
+ setEditing(null);
+ if (name && name !== cats.find((c) => c.id === id)?.name) {
+ run(() => renameCategory(id, name));
+ }
+ };
+
+ return (
+
+
+ Categories
+ {cats.length}
+
+
+
+ {cats.map((c) => (
+
+ {editing === c.id ? (
+ setDraft(e.target.value)}
+ onBlur={() => commit(c.id)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") commit(c.id);
+ if (e.key === "Escape") setEditing(null);
+ }}
+ className="h-7 flex-1 rounded-md border bg-card px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ />
+ ) : (
+ {c.name}
+ )}
+ {c.test_count} tests
+ { setEditing(c.id); setDraft(c.name); }}
+ >
+ Rename
+
+ {/* The API refuses with 409 while tests still reference it, so
+ don't offer a click that can only fail. */}
+ 0}
+ title={
+ c.test_count > 0
+ ? "Reassign its tests before deleting this category"
+ : "Delete category"
+ }
+ onClick={() => run(() => deleteCategory(c.id))}
+ >
+
+
+
+ ))}
+
+
setFresh(e.target.value)}
+ placeholder="new category name"
+ className="h-7 flex-1 rounded-md border bg-card px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ />
+
run(async () => { await createCategory(fresh.trim()); setFresh(""); })}
+ >
+ Add
+
+
+
+
+ );
+}
+
+function TagSection() {
+ const { data: tags = [] } = useTags();
+ const { run, busy, err } = useMutate("tags");
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+
+ const add = () =>
+ run(async () => {
+ await createTag(name.trim(), description.trim());
+ setName("");
+ setDescription("");
+ });
+
+ return (
+
+
+ Sample tags
+ {tags.length}
+
+
+
+ {tags.map((t) => (
+
+ {t.name}
+
+ {t.description || "no description"}
+
+
+ ))}
+ {/* Created here, applied to samples on the samples page. There is no
+ removal: the classic site has no way to drop a tag either. */}
+
+
+
+ );
+}
+
+function UserSection() {
+ const { data: users = [], isLoading } = useUsers();
+ const qc = useQueryClient();
+ const me = getSession();
+ const [busy, setBusy] = useState(null);
+ const [err, setErr] = useState(null);
+ const [sentTo, setSentTo] = useState(null);
+ const [closing, setClosing] = useState(null);
+
+ // One runner for all three row actions: they share the per-row busy flag
+ // and only the successful ones differ in what they leave behind.
+ const act = async (u: PlatformUser, what: string, fn: () => Promise) => {
+ setBusy(u.user_id);
+ setErr(null);
+ try {
+ await fn();
+ await qc.invalidateQueries({ queryKey: ["users"] });
+ } catch (e) {
+ setErr(e instanceof Error ? e.message : `${what} failed`);
+ return false;
+ } finally {
+ setBusy(null);
+ }
+ return true;
+ };
+
+ const changeRole = (u: PlatformUser, role: string) =>
+ act(u, "Role change", () => updateUserRole(u.user_id, role));
+
+ const sendReset = async (u: PlatformUser) => {
+ setSentTo(null);
+ if (await act(u, "Sending the reset link", () => sendUserReset(u.user_id))) {
+ setSentTo(u.user_id);
+ }
+ };
+
+ const deactivate = async () => {
+ if (!closing) return;
+ await act(closing, "Deactivation", () => deactivateUser(closing.user_id));
+ setClosing(null);
+ };
+
+ return (
+
+
+ User management
+ {users.length} users
+
+ {err && {err}
}
+
+ {isLoading &&
}
+ {users.map((u, i) => {
+ const self = u.email === me?.email;
+ return (
+
+
+
+ {u.name}
+ {self && (you) }
+
+
{u.email}
+
+ {sentTo === u.user_id && (
+ reset link sent
+ )}
+ {u.github_linked && github }
+ sendReset(u)}
+ >
+
+
+ {/* Deactivating scrubs the name, email and password, so it is
+ a confirm; your own account is closed from the account page
+ instead, which signs you out afterwards. */}
+ setClosing(u)}
+ >
+
+
+ changeRole(u, e.target.value)}
+ className={cn(
+ "h-7 cursor-pointer rounded-md border bg-card px-2 text-xs capitalize shadow-card transition-colors hover:border-border-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
+ self && "cursor-not-allowed opacity-60",
+ )}
+ title={self ? "You can't change your own role" : "Change role"}
+ >
+ {ROLES.map((r) => (
+ {r}
+ ))}
+
+
+ );
+ })}
+
+
+ !o && setClosing(null)}
+ title={`Deactivate ${closing?.name}?`}
+ body={
+ <>
+ The name and email are replaced with a placeholder and the password is
+ scrambled, so nobody can sign in as {closing?.email} again. The account row
+ stays behind so their samples and runs keep an author.{" "}
+ This cannot be undone.
+ >
+ }
+ confirmLabel={busy !== null ? "Deactivating…" : "Deactivate account"}
+ busy={busy !== null}
+ onConfirm={deactivate}
+ />
+
+ );
+}
+
+function QueueSection() {
+ const { data } = useQueue();
+ return (
+
+
+ CI queue
+ {data && (
+
+ {data.meta.running_count} running · {data.meta.queue_depth} queued
+
+ )}
+
+
+ {(data?.data ?? []).map((q) => (
+
+ run {q.run_id}
+
+ {q.platform}
+
+
+
+ ))}
+ {data?.data.length === 0 && (
+
Queue is empty.
+ )}
+
+
+ );
+}
+
+function TokenSection() {
+ const { data: tokens = [], isLoading } = useTokens();
+ const qc = useQueryClient();
+ const [revoking, setRevoking] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const current = getSession();
+
+ const doRevoke = async () => {
+ if (revoking === null) return;
+ setBusy(true);
+ try {
+ await revokeToken(revoking);
+ await qc.invalidateQueries({ queryKey: ["tokens"] });
+ } finally {
+ setBusy(false);
+ setRevoking(null);
+ }
+ };
+
+ const active = tokens.filter((t) => !t.is_revoked);
+
+ return (
+
+
+ API tokens
+ {active.length} active
+
+
+ {isLoading &&
}
+ {active.map((t, i) => (
+
+ {t.token_prefix}…
+
+ {t.token_name}
+
+
+ {t.scopes.slice(0, 3).map((s) => (
+ {s}
+ ))}
+ {t.scopes.length > 3 && +{t.scopes.length - 3} }
+
+
+ expires {new Date(t.expires_at).toLocaleDateString()}
+
+ setRevoking(t.id)}
+ >
+ revoke
+
+
+ ))}
+
+
+ !o && setRevoking(null)}
+ title="Revoke this API token?"
+ body={
+ <>
+ Anything using it loses access immediately.
+ {current && " Revoking the token this session uses signs you out."}
+ >
+ }
+ confirmLabel={busy ? "Revoking…" : "Revoke token"}
+ busy={busy}
+ onConfirm={doRevoke}
+ />
+
+ );
+}
+
+function SectionLabel({ children }: Readonly<{ children: React.ReactNode }>) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/web/src/pages/Status.tsx b/web/src/pages/Status.tsx
new file mode 100644
index 000000000..5c2930d82
--- /dev/null
+++ b/web/src/pages/Status.tsx
@@ -0,0 +1,176 @@
+import { Link } from "@tanstack/react-router";
+import { Activity, CircleCheck, CircleSlash, TriangleAlert } from "lucide-react";
+import { useMemo } from "react";
+
+import { RunStatusBadge } from "@/components/StatusBadge";
+import { useHealth, useQueue, useRuns } from "@/lib/api";
+import type { Platform, RunStatus } from "@/lib/types";
+import { cn } from "@/lib/utils";
+
+/**
+ * Platform status at a glance — health, CI queue depth, and the last master
+ * run per platform. Replaces "is the platform down? better ask on IRC" with a
+ * single page. Composes read-only endpoints only, so it works the moment the
+ * API is reachable.
+ */
+const PLATFORMS: Platform[] = ["linux", "windows"];
+
+/** The three states the platform banner can be in. */
+function HealthIcon({ ok, degraded }: Readonly<{ ok: boolean; degraded: boolean }>) {
+ if (ok) return ;
+ if (degraded) return ;
+ return ;
+}
+
+function headline(loaded: boolean, ok: boolean, degraded: boolean): string {
+ if (!loaded) return "Checking…";
+ if (ok) return "All systems operational";
+ return degraded ? "Degraded — some dependencies are unhealthy" : "Platform is down";
+}
+
+function dependencyDot(status: string): string {
+ if (status === "ok") return "bg-success";
+ return status === "degraded" ? "bg-warning" : "bg-destructive";
+}
+
+export function Status() {
+ const { data: health } = useHealth();
+ const { data: queue } = useQueue();
+ const { data: runs = [] } = useRuns();
+
+ // Most recent master run touching each platform.
+ const lastMaster = useMemo(() => {
+ const out: Record = {
+ linux: null,
+ windows: null,
+ };
+ for (const platform of PLATFORMS) {
+ for (const run of runs) {
+ if (run.test_type !== "commit" || run.branch !== "master") continue;
+ const p = run.platforms.find((x) => x.platform === platform);
+ if (p) {
+ out[platform] = { commit: run.commit, status: p.status, at: p.completed_at ?? p.started_at };
+ break; // runs are newest-first
+ }
+ }
+ }
+ return out;
+ }, [runs]);
+
+ const ok = health?.status === "ok";
+ const degraded = health?.status === "degraded";
+
+ return (
+
+
+
+
Platform status
+ health, queue and last run on master
+
+
+
+
+ {/* Headline banner */}
+
+
+
+
+ {headline(!!health, ok, degraded)}
+
+
+ {health
+ ? `${health.dependencies.filter((d) => d.status === "ok").length}/${health.dependencies.length} dependencies healthy`
+ : "Contacting the API…"}
+
+
+
+
+ {/* Dependencies */}
+ {health && (
+
+ {health.dependencies.map((d) => (
+
+
+ {d.name}
+ {d.status}
+
+ ))}
+
+ )}
+
+
+ {/* Queue */}
+
+
+
CI queue
+ {queue && (
+
+ {queue.meta.running_count} running · {queue.meta.queue_depth} queued
+
+ )}
+
+
+ {(queue?.data ?? []).map((j) => (
+
+ run {j.run_id}
+ {j.platform}
+
+
+ ))}
+ {queue?.data.length === 0 && (
+
Queue is empty.
+ )}
+
+
+
+ {/* Last master run per platform */}
+
+ Last master run
+
+ {PLATFORMS.map((platform) => {
+ const last = lastMaster[platform];
+ return (
+
+
+ {platform}
+
+ {last ? (
+ <>
+ {last.commit}
+
+ >
+ ) : (
+ no recent run
+ )}
+
+ );
+ })}
+
+
+ All test results →
+
+
+
+
+
+ );
+}
diff --git a/web/src/pages/Triage.tsx b/web/src/pages/Triage.tsx
new file mode 100644
index 000000000..edee41631
--- /dev/null
+++ b/web/src/pages/Triage.tsx
@@ -0,0 +1,474 @@
+import { Link } from "@tanstack/react-router";
+import { ExternalLink, MoreHorizontal, ShieldCheck } from "lucide-react";
+import { AnimatePresence, motion } from "motion/react";
+import { useMemo, useState } from "react";
+
+import { DiffDrawer, type DiffTarget } from "@/components/DiffDrawer";
+import { Sparkline } from "@/components/Sparkline";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { ConfirmDialog } from "@/components/ui/confirm";
+import {
+ promoteBaseline,
+ useHealth,
+ useQueue,
+ useRegressionTests,
+ useRunFailures,
+ useRuns,
+ useSamples,
+ useTestHistory,
+ type RunFailure,
+} from "@/lib/api";
+import { getSession } from "@/lib/auth";
+import { githubUrl } from "@/lib/validate";
+import type { LogicalRun } from "@/lib/types";
+import { cn } from "@/lib/utils";
+
+/**
+ * Home: an inbox of recent runs that finished with failures. Each card pulls
+ * that run's failing tests and groups them by category.
+ *
+ * Baseline promotion hides behind a per-row overflow menu and a warning
+ * dialog. There is intentionally no bulk accept — replacing baselines should
+ * take one deliberate click per test.
+ */
+export function Triage() {
+ const { data: runs = [], isLoading } = useRuns();
+ const { data: tests = [] } = useRegressionTests();
+ const { data: health } = useHealth();
+
+ // Latest platform runs that finished with failures — at most 3 cards.
+ const failedRuns = useMemo(() => {
+ const out: { run: LogicalRun; runId: number; platform: string }[] = [];
+ for (const run of runs) {
+ for (const p of run.platforms) {
+ if (p.status === "fail" || p.status === "incomplete") {
+ out.push({ run, runId: p.run_id, platform: p.platform });
+ }
+ }
+ if (out.length >= 3) break;
+ }
+ return out.slice(0, 3);
+ }, [runs]);
+
+ const failingNow = tests.filter(
+ (t) => t.active && t.recent_results.at(-1) === "fail",
+ ).length;
+
+ return (
+
+
+
+
Home
+
+ recent runs that finished with failures
+
+
+ {failingNow > 0 && (
+
+ {failingNow} tests red in recent runs
+
+ )}
+ {health && (
+ `${d.name}: ${d.status}`).join("\n")}
+ >
+
+ platform {health.status === "ok" ? "healthy" : "degraded"}
+
+ )}
+
+
+
+
+
+
+
+
Needs triage
+ {isLoading && (
+
+ {Array.from({ length: 2 }, (_, n) => `placeholder-${n}`).map((id) => (
+
+ ))}
+
+ )}
+
+ {!isLoading && failedRuns.length === 0 && (
+
+
+
Nothing to triage
+
+ No recent runs finished with failures. New ones land here automatically.
+
+
+ )}
+
+ {failedRuns.map((fr, idx) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+function SubHead({ children }: Readonly<{ children: React.ReactNode }>) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Scale-and-pressure counters across the top. Everything here comes from
+ * queries the page already needs, so the strip costs no extra requests.
+ */
+function StatStrip({ failingNow }: Readonly<{ failingNow: number }>) {
+ const { data: tests = [] } = useRegressionTests();
+ const { data: samples = [] } = useSamples();
+ const { data: queue } = useQueue();
+
+ const active = tests.filter((t) => t.active).length;
+
+ return (
+
+ 0 ? "bad" : "good"}
+ to="/tests"
+ />
+
+
+
+
+ );
+}
+
+function Stat({
+ label,
+ value,
+ hint,
+ tone,
+ to,
+}: Readonly<{
+ label: string;
+ value: number;
+ hint?: string;
+ tone?: "good" | "bad";
+ to: string;
+}>) {
+ return (
+
+
+ {label}
+
+
+
+ {value}
+
+ {hint && {hint} }
+
+
+ );
+}
+
+/** The last handful of runs regardless of outcome — the triage cards above
+ * only show failures, which leaves the page blank on a healthy week. */
+function RecentRuns({ runs, isLoading }: Readonly<{ runs: LogicalRun[]; isLoading: boolean }>) {
+ const recent = runs.slice(0, 8);
+ if (isLoading || recent.length === 0) return null;
+
+ return (
+ <>
+ Recent runs
+
+ {recent.map((run) => (
+
+
+ {run.pr_nr ? `PR #${run.pr_nr}` : run.commit}
+
+
+ {run.branch}
+
+
+ {run.platforms.map((p) => (
+
+ {p.platform === "linux" ? "lnx" : "win"}
+
+ ))}
+
+
+ {run.created_at && new Date(run.created_at).toLocaleDateString()}
+
+
+ ))}
+
+ >
+ );
+}
+
+function FailureCard({
+ run,
+ runId,
+ platform,
+ defaultOpen,
+ index,
+}: Readonly<{
+ run: LogicalRun;
+ runId: number;
+ platform: string;
+ defaultOpen: boolean;
+ index: number;
+}>) {
+ const [open, setOpen] = useState(defaultOpen);
+ const { data: failures = [], isLoading } = useRunFailures(open ? runId : null);
+ const gh = githubUrl(run.github_link);
+
+ // Group by first category, biggest group first.
+ const groups = useMemo(() => {
+ const byCat = new Map();
+ for (const f of failures) {
+ const cat = f.categories[0] ?? "Uncategorized";
+ byCat.set(cat, [...(byCat.get(cat) ?? []), f]);
+ }
+ return [...byCat.entries()].sort((a, b) => b[1].length - a[1].length);
+ }, [failures]);
+
+ return (
+
+
+
setOpen(!open)}
+ >
+
+ {platform === "linux" ? "lnx" : "win"}
+
+
+
+ {run.pr_nr ? `PR #${run.pr_nr}` : `commit ${run.commit}`}
+ {" "}
+
+ finished with failures on {platform}
+
+
+ {open && !isLoading && (
+ {failures.length} failing
+ )}
+
+
+
+ {open && (
+
+
+ {isLoading && (
+
+ {Array.from({ length: 3 }, (_, n) => `placeholder-${n}`).map((id) => (
+
+ ))}
+
+ )}
+ {groups.map(([cat, items]) => (
+
+
+ {cat} · {items.length}
+
+ {items.slice(0, 5).map((f) => (
+
+ ))}
+ {items.length > 5 && (
+
+ + {items.length - 5} more in {cat}
+
+ )}
+
+ ))}
+
+
+
+ )}
+
+
+
+ );
+}
+
+function FailureRow({ f, runId }: Readonly<{ f: RunFailure; runId: number }>) {
+ const admin = getSession()?.role === "admin";
+ const [menu, setMenu] = useState(false);
+ const [confirming, setConfirming] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [result, setResult] = useState(null);
+ const [diff, setDiff] = useState(null);
+ // Shares the query the tests list already made, so this costs no fetch.
+ const { data: history } = useTestHistory();
+ const past = history?.get(f.regression_test_id);
+ const output = f.outputs.find((o) => o.status === "fail") ?? f.outputs[0];
+
+ const doPromote = async () => {
+ if (!output) return;
+ setBusy(true);
+ const res = await promoteBaseline({
+ runId,
+ sampleId: f.sample_id,
+ regressionId: f.regression_test_id,
+ outputId: output.output_id,
+ });
+ setBusy(false);
+ setConfirming(false);
+ setResult(res.ok ? "Baseline promoted." : res.message);
+ };
+
+ return (
+
+
+
#{f.regression_test_id}
+
{f.command}
+
{f.sample_name}
+ {past && (
+
r !== "skip").slice(-10)} />
+ )}
+
+
+ {result && {result} }
+
+ {output && (
+
+ setDiff({
+ runId,
+ sampleId: f.sample_id,
+ regressionId: f.regression_test_id,
+ outputId: output.output_id,
+ command: f.command,
+ sampleName: f.sample_name,
+ })
+ }
+ >
+ diff
+
+ )}
+
+ {admin && output && !result && (
+
+
setMenu(!menu)}
+ >
+
+
+ {menu && (
+
+ {
+ setMenu(false);
+ setConfirming(true);
+ }}
+ >
+ Promote this output to baseline…
+
+
+ )}
+
+ )}
+
+
+ The output this run produced becomes the expected baseline{" "}
+ for every future run, on all platforms . The current baseline hash
+ is discarded, and the only way back is promoting another output.
+ >
+ }
+ confirmLabel={busy ? "Promoting…" : "Replace baseline"}
+ busy={busy}
+ onConfirm={doPromote}
+ />
+
+ setDiff(null)} />
+
+ );
+}