feat(web): patching page with policies, windows and runs

This commit is contained in:
2026-09-15 11:19:01 +00:00
parent 3a7618f82f
commit 70c239021d
4 changed files with 407 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { Card, CenteredSpinner } from "@/components/ui";
import { PolicyList } from "@/components/patching/PolicyList";
import { WindowList } from "@/components/patching/WindowList";
import { RunList } from "@/components/patching/RunList";
const TABS = [
{ id: "policies", label: "Policies" },
{ id: "windows", label: "Windows" },
{ id: "runs", label: "Runs" },
] as const;
type TabId = (typeof TABS)[number]["id"];
// useSearchParams is only allowed inside a Suspense boundary, as on the
// servers page.
export default function PatchingPage() {
return (
<Suspense fallback={<CenteredSpinner label="Loading patching" />}>
<PatchingInner />
</Suspense>
);
}
function PatchingInner() {
const router = useRouter();
const params = useSearchParams();
const { isAdmin } = useAuth();
const tab: TabId = (TABS.find((t) => t.id === params.get("tab"))?.id ?? "policies") as TabId;
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-text-primary">Patching</h1>
<p className="mt-1 max-w-2xl text-sm text-text-secondary">
Patch servers inside maintenance windows. Every patch Vantage performs, scheduled or clicked, is recorded as a run with a result for each server.
</p>
</div>
<div role="tablist" aria-label="Patching" className="mb-4 flex gap-1 border-b border-border">
{TABS.map((t) => (
<button
key={t.id}
role="tab"
id={`patching-tab-${t.id}`}
aria-selected={tab === t.id}
onClick={() => router.replace(`/patching?tab=${t.id}`)}
className={`-mb-px border-b-2 px-4 py-2 text-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
tab === t.id ? "border-accent text-text-primary" : "border-transparent text-text-secondary hover:text-text-primary"
}`}
>
{t.label}
</button>
))}
</div>
<Card padding={false} role="tabpanel" aria-labelledby={`patching-tab-${tab}`}>
{tab === "policies" && <PolicyList canEdit={isAdmin} />}
{tab === "windows" && <WindowList canEdit={isAdmin} />}
{tab === "runs" && <RunList />}
</Card>
</div>
);
}
+161
View File
@@ -0,0 +1,161 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { api, PatchPolicy } from "@/lib/api";
import { resolveTargets } from "@/lib/targets";
import { AsyncBoundary, Badge, Button, ConfirmDialog, EmptyState, Table, TableSkeleton, Tbody, Td, Th, Thead, Tr, friendlyMessage, useToast } from "@/components/ui";
import { PolicyModal } from "./PolicyModal";
import { RUN_STATUS } from "./status";
const SKIP_REASON: Record<string, string> = {
missed: "the control plane was not running when the window opened",
already_running: "the previous run was still going",
no_targets: "no servers matched",
};
export function PolicyList({ canEdit }: { canEdit: boolean }) {
const router = useRouter();
const queryClient = useQueryClient();
const toast = useToast();
const [editing, setEditing] = useState<PatchPolicy | "new" | null>(null);
const [deleting, setDeleting] = useState<PatchPolicy | null>(null);
const { data: policies, isLoading, error, refetch } = useQuery({ queryKey: ["patch-policies"], queryFn: () => api.listPatchPolicies() });
const { data: windows } = useQuery({ queryKey: ["maintenance-windows"], queryFn: () => api.listMaintenanceWindows() });
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
const { data: runs } = useQuery({ queryKey: ["patch-runs", "all"], queryFn: () => api.listPatchRuns({ limit: 50 }) });
const runNow = useMutation({
mutationFn: (p: PatchPolicy) => api.runPatchPolicyNow(p.policy_id),
onSuccess: (run) => router.push(`/patching/runs/${run.run_id}`),
onError: (e) => toast.error(friendlyMessage(e)),
});
const remove = useMutation({
mutationFn: (p: PatchPolicy) => api.deletePatchPolicy(p.policy_id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["patch-policies"] });
toast.success("Policy deleted.");
setDeleting(null);
},
onError: (e) => toast.error(friendlyMessage(e)),
});
const windowName = (id: string) => windows?.find((w) => w.window_id === id);
const lastRun = (id: string) => runs?.find((r) => r.policy_id === id);
return (
<>
{canEdit && (
<div className="flex justify-end border-b border-border px-6 py-3">
<Button variant="primary" size="sm" onClick={() => setEditing("new")} disabled={(windows ?? []).length === 0} title={(windows ?? []).length === 0 ? "Create a maintenance window first" : undefined}>
New policy
</Button>
</div>
)}
<AsyncBoundary
isLoading={isLoading}
error={error}
onRetry={refetch}
skeleton={<TableSkeleton columns={5} />}
isEmpty={!policies || policies.length === 0}
empty={<EmptyState title="No patch policies yet." description="A policy says which servers to patch, what to install and whether to reboot, inside a maintenance window." />}
>
<Table>
<Thead>
<Tr>
<Th>Policy</Th>
<Th>Next window</Th>
<Th>Targets</Th>
<Th>Installs</Th>
<Th>Last run</Th>
{canEdit && <Th />}
</Tr>
</Thead>
<Tbody>
{(policies ?? []).map((p) => {
const w = windowName(p.window_id);
const count = resolveTargets(servers ?? [], p.target_server_ids, p.target_tags ?? {}).length;
const last = lastRun(p.policy_id);
return (
<Tr key={p.policy_id}>
<Td label="Policy">
<div className="flex flex-col gap-1">
<span className="font-medium text-text-primary">{p.name}</span>
{!p.enabled && (
<span className="text-xs text-warning">
Disabled{p.disabled_reason ? `: ${p.disabled_reason}` : ""}
</span>
)}
{p.last_skipped && (
<span className="text-xs text-warning">
Skipped {new Date(p.last_skipped.due).toLocaleString()}: {SKIP_REASON[p.last_skipped.reason] ?? p.last_skipped.reason}
</span>
)}
</div>
</Td>
<Td label="Next window">
{p.enabled && p.next_run_at ? (
<span className="text-sm">
{new Date(p.next_run_at).toLocaleString(undefined, { timeZone: w?.tz, dateStyle: "medium", timeStyle: "short" })}
<span className="block text-xs text-text-tertiary">{w?.name}</span>
</span>
) : (
<span className="text-text-tertiary">none</span>
)}
</Td>
<Td label="Targets">
<span className="tabular-nums">{count}</span>
</Td>
<Td label="Installs">
<div className="flex flex-wrap gap-1.5">
<Badge variant="neutral">{p.scope === "security" ? "security only" : "all updates"}</Badge>
{p.reboot === "if_required" && <Badge variant="accent">reboots</Badge>}
</div>
</Td>
<Td label="Last run">
{last ? (
<Link href={`/patching/runs/${last.run_id}`}>
<Badge variant={RUN_STATUS[last.status].variant}>{RUN_STATUS[last.status].label}</Badge>
</Link>
) : (
<span className="text-text-tertiary">never</span>
)}
</Td>
{canEdit && (
<Td>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" loading={runNow.isPending && runNow.variables?.policy_id === p.policy_id} onClick={() => runNow.mutate(p)}>
Run now
</Button>
<Button variant="ghost" size="sm" onClick={() => setEditing(p)}>
Edit
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleting(p)}>
Delete
</Button>
</div>
</Td>
)}
</Tr>
);
})}
</Tbody>
</Table>
</AsyncBoundary>
{editing && <PolicyModal initial={editing === "new" ? undefined : editing} onClose={() => setEditing(null)} />}
{deleting && (
<ConfirmDialog
open
title={`Delete ${deleting.name}?`}
body="The policy stops running. Its past runs stay on the Runs tab."
confirmLabel="Delete policy"
loading={remove.isPending}
onConfirm={() => remove.mutate(deleting)}
onClose={() => setDeleting(null)}
/>
)}
</>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { AsyncBoundary, Badge, EmptyState, Table, TableSkeleton, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
import { RUN_STATUS } from "./status";
const SOURCE_LABEL: Record<string, string> = {
schedule: "window",
run_now: "run now",
server: "server page",
vulnerabilities: "vulnerabilities",
mcp: "agent (MCP)",
};
export function RunList({ policyId }: { policyId?: string }) {
const { data: runs, isLoading, error, refetch } = useQuery({
queryKey: ["patch-runs", policyId ?? "all"],
queryFn: () => api.listPatchRuns({ policy_id: policyId, limit: 50 }),
// A run in progress changes on every scheduler tick.
refetchInterval: (q) => (q.state.data?.some((r) => r.status === "running") ? 10_000 : false),
});
return (
<AsyncBoundary
isLoading={isLoading}
error={error}
onRetry={refetch}
skeleton={<TableSkeleton columns={5} />}
isEmpty={!runs || runs.length === 0}
empty={<EmptyState title="No patch runs yet." description="A run is recorded every time a policy's window opens and every time someone clicks Apply updates." />}
>
<Table>
<Thead>
<Tr>
<Th>Started</Th>
<Th>Policy</Th>
<Th>Status</Th>
<Th>Servers</Th>
<Th>Started by</Th>
</Tr>
</Thead>
<Tbody>
{(runs ?? []).map((r) => {
const ok = r.servers.filter((s) => s.status === "succeeded").length;
return (
<Tr key={r.run_id}>
<Td label="Started">
<Link href={`/patching/runs/${r.run_id}`} className="font-mono text-sm text-accent hover:underline">
{new Date(r.started_at).toLocaleString()}
</Link>
</Td>
<Td label="Policy">{r.policy_name ?? <span className="text-text-tertiary">manual</span>}</Td>
<Td label="Status">
<Badge variant={RUN_STATUS[r.status].variant}>{RUN_STATUS[r.status].label}</Badge>
</Td>
<Td label="Servers">
<span className="font-mono text-sm tabular-nums">
{ok}/{r.servers.length}
</span>
</Td>
<Td label="Started by">
<span className="text-sm text-text-secondary">
{r.triggered_by} <span className="text-text-tertiary">via {SOURCE_LABEL[r.source] ?? r.source}</span>
</span>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</AsyncBoundary>
);
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, MaintenanceWindow } from "@/lib/api";
import { AsyncBoundary, Button, ConfirmDialog, EmptyState, Table, TableSkeleton, Tbody, Td, Th, Thead, Tr, friendlyMessage, useToast } from "@/components/ui";
import { WindowModal } from "./WindowModal";
import { describeCron, formatDuration } from "./status";
export function WindowList({ canEdit }: { canEdit: boolean }) {
const queryClient = useQueryClient();
const toast = useToast();
const [editing, setEditing] = useState<MaintenanceWindow | "new" | null>(null);
const [deleting, setDeleting] = useState<MaintenanceWindow | null>(null);
const { data: windows, isLoading, error, refetch } = useQuery({ queryKey: ["maintenance-windows"], queryFn: () => api.listMaintenanceWindows() });
const { data: policies } = useQuery({ queryKey: ["patch-policies"], queryFn: () => api.listPatchPolicies() });
const { mutate: remove, isPending } = useMutation({
mutationFn: (w: MaintenanceWindow) => api.deleteMaintenanceWindow(w.window_id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["maintenance-windows"] });
toast.success("Window deleted.");
setDeleting(null);
},
// 409 window_in_use arrives here with the server's own sentence.
onError: (e) => {
toast.error(friendlyMessage(e));
setDeleting(null);
},
});
const usedBy = (id: string) => (policies ?? []).filter((p) => p.window_id === id).length;
return (
<>
{canEdit && (
<div className="flex justify-end border-b border-border px-6 py-3">
<Button variant="primary" size="sm" onClick={() => setEditing("new")}>
New window
</Button>
</div>
)}
<AsyncBoundary
isLoading={isLoading}
error={error}
onRetry={refetch}
skeleton={<TableSkeleton columns={4} />}
isEmpty={!windows || windows.length === 0}
empty={<EmptyState title="No maintenance windows yet." description="A window is a recurring time slot, such as Sundays 02:00 to 04:00. Patch policies run inside one." />}
>
<Table>
<Thead>
<Tr>
<Th>Name</Th>
<Th>When</Th>
<Th>Length</Th>
<Th>Used by</Th>
{canEdit && <Th />}
</Tr>
</Thead>
<Tbody>
{(windows ?? []).map((w) => (
<Tr key={w.window_id}>
<Td label="Name">{w.name}</Td>
<Td label="When">
{describeCron(w.cron)} <span className="text-text-tertiary">({w.tz})</span>
</Td>
<Td label="Length">{formatDuration(w.duration_minutes)}</Td>
<Td label="Used by">
<span className="tabular-nums">{usedBy(w.window_id)}</span> {usedBy(w.window_id) === 1 ? "policy" : "policies"}
</Td>
{canEdit && (
<Td>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setEditing(w)}>
Edit
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleting(w)} disabled={usedBy(w.window_id) > 0} title={usedBy(w.window_id) > 0 ? "Move its policies to another window first" : undefined}>
Delete
</Button>
</div>
</Td>
)}
</Tr>
))}
</Tbody>
</Table>
</AsyncBoundary>
{editing && <WindowModal initial={editing === "new" ? undefined : editing} onClose={() => setEditing(null)} />}
{deleting && (
<ConfirmDialog
open
title={`Delete ${deleting.name}?`}
body="The window is removed. No policy uses it, so nothing else changes."
confirmLabel="Delete window"
loading={isPending}
onConfirm={() => remove(deleting)}
onClose={() => setDeleting(null)}
/>
)}
</>
);
}