diff --git a/web/app/(app)/patching/runs/[runId]/page.tsx b/web/app/(app)/patching/runs/[runId]/page.tsx
new file mode 100644
index 0000000..b8fa282
--- /dev/null
+++ b/web/app/(app)/patching/runs/[runId]/page.tsx
@@ -0,0 +1,142 @@
+"use client";
+
+import Link from "next/link";
+import { useState } from "react";
+import { useParams } from "next/navigation";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { api, PatchServerRun } from "@/lib/api";
+import { AsyncBoundary, Badge, Button, Card, CenteredSpinner, Table, Tbody, Td, Th, Thead, Tr, friendlyMessage, useToast } from "@/components/ui";
+import { RUN_STATUS, SERVER_STATUS } from "@/components/patching/status";
+
+function installed(s: PatchServerRun): string {
+ if (s.pending_after === undefined || s.pending_after === null) return "n/a";
+ return String(Math.max(0, s.pending_before - s.pending_after));
+}
+
+function ServerRow({ s }: { s: PatchServerRun }) {
+ const [open, setOpen] = useState(false);
+ const meta = SERVER_STATUS[s.status];
+ return (
+ <>
+
+ |
+
+ {s.hostname}
+
+ |
+
+ {meta.label}
+ |
+
+ {installed(s)}
+ |
+
+
+ {s.rebooted_at ? `rebooted ${new Date(s.rebooted_at).toLocaleTimeString()}` : "none"}
+ {s.verified_at && `, back ${new Date(s.verified_at).toLocaleTimeString()}`}
+
+ |
+
+
+ {s.error && {s.error}}
+ {s.output && (
+
+ )}
+
+ |
+
+ {open && s.output && (
+
+
+ {s.output}
+ |
+
+ )}
+ >
+ );
+}
+
+export default function PatchRunPage() {
+ const { runId } = useParams<{ runId: string }>();
+ const queryClient = useQueryClient();
+ const toast = useToast();
+ const { data: run, isLoading, error, refetch } = useQuery({
+ queryKey: ["patch-run", runId],
+ queryFn: () => api.getPatchRun(runId),
+ refetchInterval: (q) => (q.state.data?.status === "running" ? 5_000 : false),
+ });
+ const cancel = useMutation({
+ mutationFn: () => api.cancelPatchRun(runId),
+ onSuccess: () => {
+ toast.success("Cancelled. Servers already patching will finish; nothing further starts.");
+ queryClient.invalidateQueries({ queryKey: ["patch-run", runId] });
+ },
+ onError: (e) => toast.error(friendlyMessage(e)),
+ });
+
+ if (isLoading) return ;
+
+ const counts = new Map();
+ run?.servers.forEach((s) => counts.set(s.status, (counts.get(s.status) ?? 0) + 1));
+
+ return (
+
+
+ {run && (
+ <>
+
+ Back to patch runs
+
+
+
+
{run.policy_name ?? "Manual update"}
+
+ Started {new Date(run.started_at).toLocaleString()} by {run.triggered_by}.{" "}
+ {run.scope === "security" ? "Security updates only" : "All pending updates"}, {run.reboot === "if_required" ? "reboot if required" : "no reboot"}.
+ {run.window_end && ` Window ends ${new Date(run.window_end).toLocaleString()}.`}
+
+
+
+ {RUN_STATUS[run.status].label}
+ {run.status === "running" && !run.cancelled_at && (
+
+ )}
+
+
+
+
+ {[...counts.entries()].map(([status, n]) => (
+
+ {n} {SERVER_STATUS[status as PatchServerRun["status"]].label}
+
+ ))}
+
+
+
+
+
+
+ | Server |
+ Status |
+ Installed |
+ Reboot |
+ |
+
+
+
+ {run.servers.map((s) => (
+
+ ))}
+
+
+
+ >
+ )}
+
+
+ );
+}