feat(web): show patch policy coverage on servers and open the run after Apply updates

This commit is contained in:
2026-09-15 13:23:19 +00:00
parent 68c613fd40
commit 3ecea7c39f
3 changed files with 47 additions and 6 deletions
+7 -1
View File
@@ -130,7 +130,13 @@ export default function ServerDetailPage() {
const { mutate: applyUpdates, isPending: isApplying } = useMutation({
mutationFn: () => api.applyUpdates(serverId),
onSuccess: () => toast.success("Update command sent. Patching runs in the background and may take several minutes."),
onSuccess: (res) => {
if (res.run_id) {
router.push(`/patching/runs/${res.run_id}`);
} else {
toast.success("Update command sent.");
}
},
onError: toast.error,
});
+6 -4
View File
@@ -2,6 +2,7 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { api, vulnerabilities, type FindingState, type Severity, type VulnFinding } from "@/lib/api";
import { useAuth } from "@/components/AuthProvider";
import { Button, Card, Pagination, usePagination, useToast } from "@/components/ui";
@@ -42,6 +43,7 @@ const FIX_FILTERS: { key: string; label: string; hasFix: boolean | undefined }[]
export default function VulnerabilitiesPage() {
const { isAdmin } = useAuth();
const qc = useQueryClient();
const router = useRouter();
const [state, setState] = useState<FindingState>("open");
const [severity, setSeverity] = useState<Severity | "">("");
@@ -112,10 +114,10 @@ export default function VulnerabilitiesPage() {
});
const applyUpdates = useMutation({
mutationFn: (serverId: string) => api.applyUpdates(serverId),
// This one had no feedback of any kind: the button dispatched a patch
// run to a whole server and the page did not change in any way.
onSuccess: (_data, serverId) => toast.success(`Update command sent to ${serverName(serverId)}.`),
mutationFn: (serverId: string) => api.applyUpdates(serverId, "vulnerabilities"),
onSuccess: (res) => {
if (res.run_id) router.push(`/patching/runs/${res.run_id}`);
},
onError: toast.error,
});
+34 -1
View File
@@ -1,8 +1,12 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { api, ServerWithKeys } from "@/lib/api";
import { Badge, Button, Card, ConfirmDialog, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
import { matchesTags } from "@/lib/targets";
import { RUN_STATUS } from "@/components/patching/status";
/*
* Everything that changes what is installed on the machine: its OS packages,
@@ -42,6 +46,17 @@ export function MaintenanceTab({
const isWindows = server.os_info?.toLowerCase().includes("windows");
const agentCurrent = !!latestVersion && !!server.agent_version && server.agent_version === latestVersion;
const { data: policies } = useQuery({ queryKey: ["patch-policies"], queryFn: () => api.listPatchPolicies() });
const { data: windows } = useQuery({ queryKey: ["maintenance-windows"], queryFn: () => api.listMaintenanceWindows() });
const { data: lastRuns } = useQuery({ queryKey: ["patch-runs", "server", server.server_id], queryFn: () => api.listPatchRuns({ server_id: server.server_id, limit: 1 }) });
// Same selector rule as the scheduler: named, or carrying every tag.
const covering = (policies ?? []).filter((p) => p.enabled && (p.target_server_ids.includes(server.server_id) || matchesTags(server, p.target_tags ?? {})));
const next = covering
.filter((p) => p.next_run_at)
.sort((a, b) => (a.next_run_at! < b.next_run_at! ? -1 : 1))[0];
const nextWindow = next && windows?.find((w) => w.window_id === next.window_id);
const lastRun = lastRuns?.[0];
return (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<Card padding={false}>
@@ -55,6 +70,24 @@ export function MaintenanceTab({
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-6 py-3 text-xs text-text-secondary">
{next && nextWindow ? (
<span>
Covered by <span className="text-text-primary">{next.name}</span>, next window{" "}
{new Date(next.next_run_at!).toLocaleString(undefined, { timeZone: nextWindow.tz, dateStyle: "medium", timeStyle: "short" })} ({nextWindow.tz})
</span>
) : (
<span>
Not covered by any patch policy. <Link href="/patching" className="text-accent hover:underline">Set one up</Link>
</span>
)}
{lastRun && (
<Link href={`/patching/runs/${lastRun.run_id}`} className="flex items-center gap-2 hover:text-accent">
Last run <Badge variant={RUN_STATUS[lastRun.status].variant}>{RUN_STATUS[lastRun.status].label}</Badge>
</Link>
)}
</div>
{updates.length === 0 ? (
<p className="px-6 py-10 text-center text-sm text-text-secondary">
{isWindows ? "No pending Windows updates. The agent checks hourly." : "No pending package updates. The agent checks hourly."}
@@ -94,7 +127,7 @@ export function MaintenanceTab({
<Button variant="primary" loading={isApplying} onClick={onApplyUpdates} disabled={server.status !== "active"} title={server.status !== "active" ? "Agent must be online to apply updates" : undefined}>
Apply updates
</Button>
<p className="text-xs text-text-tertiary">Upgrade runs in the background and may take several minutes.</p>
<p className="text-xs text-text-tertiary">Installs all pending updates now, without rebooting, and records a run.</p>
</div>
</>
)}