feat(web): patching page with policies, windows and runs
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user