From 68c613fd40ee3f704f6d4b9020293d5edbcce2da Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 15 Sep 2026 12:24:17 +0000 Subject: [PATCH] feat(web): patch run detail page with per-server output --- web/app/(app)/patching/runs/[runId]/page.tsx | 142 +++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 web/app/(app)/patching/runs/[runId]/page.tsx 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} + + ))} +
+ + + + + + + + + + + + + {run.servers.map((s) => ( + + ))} + +
ServerStatusInstalledReboot +
+
+ + )} +
+
+ ); +}