feat(web): patch run detail page with per-server output
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user