From eda200a8e96fea975f3386a105196c22fd262426 Mon Sep 17 00:00:00 2001 From: pulk17 Date: Mon, 14 Sep 2026 12:59:45 +0530 Subject: [PATCH] Web-Admin Home dashboard of recent failing runs, platform status and queue, and the administration page for users, blocked users, maintenance and tokens. --- web/src/pages/Admin.tsx | 685 +++++++++++++++++++++++++++++++++++++++ web/src/pages/Status.tsx | 176 ++++++++++ web/src/pages/Triage.tsx | 474 +++++++++++++++++++++++++++ 3 files changed, 1335 insertions(+) create mode 100644 web/src/pages/Admin.tsx create mode 100644 web/src/pages/Status.tsx create mode 100644 web/src/pages/Triage.tsx 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"} +
+
+ +
+ ))} +
+
+ ); +} + +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} + +
+ ))} +
+ {/* Numeric account id, not the login: GitHub logins can be changed + and reused, which would silently unblock somebody. */} + setId(e.target.value.replace(/\D/g, ""))} + placeholder="GitHub user id" + className="h-7 w-32 rounded-md border bg-card px-2 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + setComment(e.target.value)} + placeholder="reason" + className="h-7 flex-1 rounded-md border bg-card px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + +
+
+
+ ); +} + +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} + + + ))} + {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" + /> + +
+
+
+ ); +} + +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 + + {/* The API refuses with 409 while tests still reference it, so + don't offer a click that can only fail. */} + +
+ ))} +
+ 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" + /> + +
+
+
+ ); +} + +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. */} +
+ setName(e.target.value)} + placeholder="tag name" + className="h-7 w-40 rounded-md border bg-card px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + setDescription(e.target.value)} + placeholder="what it means" + className="h-7 flex-1 rounded-md border bg-card px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + +
+
+
+ ); +} + +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} + + {/* 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. */} + + +
+ ); + })} +
+ + !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()} + + + + ))} +
+ + !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 ( + +
+ + + + {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} +
+ )} +
+ ))} +
+ {gh && ( + + + + )} + + run {runId} + +
+
+ + )} + +
+
+ ); +} + +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 && ( + + )} + + {admin && output && !result && ( +
+ + {menu && ( +
+ +
+ )} +
+ )} + + + 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)} /> +
+ ); +}