feat(web): patch run detail page with per-server output

This commit is contained in:
2026-09-15 12:24:17 +00:00
parent 70c239021d
commit 68c613fd40
@@ -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 (
<>
<Tr>
<Td label="Server">
<Link href={`/servers/${s.server_id}`} className="font-mono text-sm text-accent hover:underline">
{s.hostname}
</Link>
</Td>
<Td label="Status">
<Badge variant={meta.variant}>{meta.label}</Badge>
</Td>
<Td label="Installed">
<span className="font-mono text-sm tabular-nums">{installed(s)}</span>
</Td>
<Td label="Reboot">
<span className="text-xs text-text-secondary">
{s.rebooted_at ? `rebooted ${new Date(s.rebooted_at).toLocaleTimeString()}` : "none"}
{s.verified_at && `, back ${new Date(s.verified_at).toLocaleTimeString()}`}
</span>
</Td>
<Td label="Detail">
<div className="flex items-center justify-end gap-3">
{s.error && <span className="max-w-xs truncate text-xs text-danger" title={s.error}>{s.error}</span>}
{s.output && (
<Button variant="ghost" size="sm" onClick={() => setOpen(!open)} aria-expanded={open}>
{open ? "Hide output" : "Output"}
</Button>
)}
</div>
</Td>
</Tr>
{open && s.output && (
<Tr>
<Td colSpan={5}>
<pre className="max-h-96 overflow-auto rounded bg-well px-4 py-3 font-mono text-[11.5px] leading-relaxed text-text-secondary">{s.output}</pre>
</Td>
</Tr>
)}
</>
);
}
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 <CenteredSpinner />;
const counts = new Map<string, number>();
run?.servers.forEach((s) => counts.set(s.status, (counts.get(s.status) ?? 0) + 1));
return (
<div className="p-4 sm:p-6 lg:p-8">
<AsyncBoundary isLoading={false} error={error} onRetry={refetch}>
{run && (
<>
<Link href="/patching?tab=runs" className="text-sm text-text-secondary hover:text-accent">
Back to patch runs
</Link>
<div className="mt-3 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-text-primary">{run.policy_name ?? "Manual update"}</h1>
<p className="mt-1 text-sm text-text-secondary">
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()}.`}
</p>
</div>
<div className="flex items-center gap-3">
<Badge variant={RUN_STATUS[run.status].variant}>{RUN_STATUS[run.status].label}</Badge>
{run.status === "running" && !run.cancelled_at && (
<Button variant="secondary" size="sm" loading={cancel.isPending} onClick={() => cancel.mutate()}>
Cancel run
</Button>
)}
</div>
</div>
<div className="mt-4 flex flex-wrap gap-x-6 gap-y-2">
{[...counts.entries()].map(([status, n]) => (
<span key={status} className="text-sm text-text-secondary">
<span className="font-mono tabular-nums text-text-primary">{n}</span> {SERVER_STATUS[status as PatchServerRun["status"]].label}
</span>
))}
</div>
<Card padding={false} className="mt-6">
<Table>
<Thead>
<Tr>
<Th>Server</Th>
<Th>Status</Th>
<Th>Installed</Th>
<Th>Reboot</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{run.servers.map((s) => (
<ServerRow key={s.server_id} s={s} />
))}
</Tbody>
</Table>
</Card>
</>
)}
</AsyncBoundary>
</div>
);
}