From 70c239021d71028e5a47895a2fdbbc5e6cf69210 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 15 Sep 2026 11:19:01 +0000 Subject: [PATCH] feat(web): patching page with policies, windows and runs --- web/app/(app)/patching/page.tsx | 68 +++++++++++ web/components/patching/PolicyList.tsx | 161 +++++++++++++++++++++++++ web/components/patching/RunList.tsx | 75 ++++++++++++ web/components/patching/WindowList.tsx | 103 ++++++++++++++++ 4 files changed, 407 insertions(+) create mode 100644 web/app/(app)/patching/page.tsx create mode 100644 web/components/patching/PolicyList.tsx create mode 100644 web/components/patching/RunList.tsx create mode 100644 web/components/patching/WindowList.tsx diff --git a/web/app/(app)/patching/page.tsx b/web/app/(app)/patching/page.tsx new file mode 100644 index 0000000..60581ca --- /dev/null +++ b/web/app/(app)/patching/page.tsx @@ -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 ( + }> + + + ); +} + +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 ( +
+
+

Patching

+

+ Patch servers inside maintenance windows. Every patch Vantage performs, scheduled or clicked, is recorded as a run with a result for each server. +

+
+ +
+ {TABS.map((t) => ( + + ))} +
+ + + {tab === "policies" && } + {tab === "windows" && } + {tab === "runs" && } + +
+ ); +} diff --git a/web/components/patching/PolicyList.tsx b/web/components/patching/PolicyList.tsx new file mode 100644 index 0000000..43f007c --- /dev/null +++ b/web/components/patching/PolicyList.tsx @@ -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 = { + 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(null); + const [deleting, setDeleting] = useState(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 && ( +
+ +
+ )} + } + isEmpty={!policies || policies.length === 0} + empty={} + > + + + + + + + + + {canEdit && + + + {(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 ( + + + + + + + {canEdit && ( + + )} + + ); + })} + +
PolicyNext windowTargetsInstallsLast run} +
+
+ {p.name} + {!p.enabled && ( + + Disabled{p.disabled_reason ? `: ${p.disabled_reason}` : ""} + + )} + {p.last_skipped && ( + + Skipped {new Date(p.last_skipped.due).toLocaleString()}: {SKIP_REASON[p.last_skipped.reason] ?? p.last_skipped.reason} + + )} +
+
+ {p.enabled && p.next_run_at ? ( + + {new Date(p.next_run_at).toLocaleString(undefined, { timeZone: w?.tz, dateStyle: "medium", timeStyle: "short" })} + {w?.name} + + ) : ( + none + )} + + {count} + +
+ {p.scope === "security" ? "security only" : "all updates"} + {p.reboot === "if_required" && reboots} +
+
+ {last ? ( + + {RUN_STATUS[last.status].label} + + ) : ( + never + )} + +
+ + + +
+
+
+ {editing && setEditing(null)} />} + {deleting && ( + remove.mutate(deleting)} + onClose={() => setDeleting(null)} + /> + )} + + ); +} diff --git a/web/components/patching/RunList.tsx b/web/components/patching/RunList.tsx new file mode 100644 index 0000000..79683a8 --- /dev/null +++ b/web/components/patching/RunList.tsx @@ -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 = { + 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 ( + } + isEmpty={!runs || runs.length === 0} + empty={} + > + + + + + + + + + + + + {(runs ?? []).map((r) => { + const ok = r.servers.filter((s) => s.status === "succeeded").length; + return ( + + + + + + + + ); + })} + +
StartedPolicyStatusServersStarted by
+ + {new Date(r.started_at).toLocaleString()} + + {r.policy_name ?? manual} + {RUN_STATUS[r.status].label} + + + {ok}/{r.servers.length} + + + + {r.triggered_by} via {SOURCE_LABEL[r.source] ?? r.source} + +
+
+ ); +} diff --git a/web/components/patching/WindowList.tsx b/web/components/patching/WindowList.tsx new file mode 100644 index 0000000..76ae9c9 --- /dev/null +++ b/web/components/patching/WindowList.tsx @@ -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(null); + const [deleting, setDeleting] = useState(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 && ( +
+ +
+ )} + } + isEmpty={!windows || windows.length === 0} + empty={} + > + + + + + + + + {canEdit && + + + {(windows ?? []).map((w) => ( + + + + + + {canEdit && ( + + )} + + ))} + +
NameWhenLengthUsed by} +
{w.name} + {describeCron(w.cron)} ({w.tz}) + {formatDuration(w.duration_minutes)} + {usedBy(w.window_id)} {usedBy(w.window_id) === 1 ? "policy" : "policies"} + +
+ + +
+
+
+ {editing && setEditing(null)} />} + {deleting && ( + remove(deleting)} + onClose={() => setDeleting(null)} + /> + )} + + ); +}