fix(patching): final review fixes
Chart Release / chart (push) Successful in 19s
Server Deploy / deploy (push) Successful in 6m12s

- no dispatch in the last 15 minutes of a window; no-result timeout from dispatch time
- per-server output moves to patch_run_outputs (16MB document limit)
- reboot proven by a changed boot time; RebootTimeout 45m, ResultGrace 20m
- window update and delete are server-scoped against the policies using them
- scheduler puts the claim back on an error after it, so the next tick retries
- cancelled runs with failures alert; MCP apply_updates audits per server
- apply-updates 503 body documented; openapi regenerated
- web: cleared numeric fields no longer save as 0; Run now asks for confirmation
This commit is contained in:
2026-09-15 13:49:28 +00:00
parent 3ecea7c39f
commit 3f2d20868e
22 changed files with 436 additions and 71 deletions
+30 -3
View File
@@ -16,12 +16,21 @@ const SKIP_REASON: Record<string, string> = {
no_targets: "no servers matched",
};
// runNowBody says what clicking Run now does to real machines, before it does.
function runNowBody(p: PatchPolicy, count: number): string {
const servers = `${count} server${count === 1 ? "" : "s"}`;
const installs = p.scope === "security" ? "security updates" : "all pending updates";
const reboot = p.reboot === "if_required" ? "Servers that need a reboot will restart." : "No server will be rebooted.";
return `This starts a window of the usual length now and installs ${installs} on ${servers}. ${reboot}`;
}
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 [running, setRunning] = 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() });
@@ -29,8 +38,14 @@ export function PolicyList({ canEdit }: { canEdit: boolean }) {
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)),
onSuccess: (run) => {
setRunning(null);
router.push(`/patching/runs/${run.run_id}`);
},
onError: (e) => {
setRunning(null);
toast.error(friendlyMessage(e));
},
});
const remove = useMutation({
mutationFn: (p: PatchPolicy) => api.deletePatchPolicy(p.policy_id),
@@ -126,7 +141,7 @@ export function PolicyList({ canEdit }: { canEdit: boolean }) {
{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)}>
<Button variant="ghost" size="sm" loading={runNow.isPending && runNow.variables?.policy_id === p.policy_id} onClick={() => setRunning(p)}>
Run now
</Button>
<Button variant="ghost" size="sm" onClick={() => setEditing(p)}>
@@ -145,6 +160,18 @@ export function PolicyList({ canEdit }: { canEdit: boolean }) {
</Table>
</AsyncBoundary>
{editing && <PolicyModal initial={editing === "new" ? undefined : editing} onClose={() => setEditing(null)} />}
{running && (
<ConfirmDialog
open
title={`Run ${running.name} now?`}
body={runNowBody(running, resolveTargets(servers ?? [], running.target_server_ids, running.target_tags ?? {}).length)}
confirmLabel="Run now"
destructive={false}
loading={runNow.isPending}
onConfirm={() => runNow.mutate(running)}
onClose={() => setRunning(null)}
/>
)}
{deleting && (
<ConfirmDialog
open
+13 -7
View File
@@ -7,7 +7,7 @@ import { resolveTargets } from "@/lib/targets";
import { Button, Modal, friendlyMessage, useToast } from "@/components/ui";
import { DualListBox } from "@/components/workflows/DualListBox";
import { WindowModal } from "./WindowModal";
import { agentSupportsPatchResults, describeCron, formatDuration, MIN_AGENT_VERSION } from "./status";
import { agentSupportsPatchResults, describeCron, formatDuration, MIN_AGENT_VERSION, parseIntInRange } from "./status";
const inputClass = "w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
@@ -33,7 +33,9 @@ export function PolicyModal({ initial, onClose }: { initial?: PatchPolicy; onClo
const [tagRows, setTagRows] = useState<[string, string][]>(Object.entries(initial?.target_tags ?? {}));
const [scope, setScope] = useState<PatchScope>(initial?.scope ?? "security");
const [reboot, setReboot] = useState<PatchReboot>(initial?.reboot ?? "never");
const [maxConcurrent, setMaxConcurrent] = useState(initial?.max_concurrent ?? 0);
// Kept as the raw input so a cleared field is not silently read as 0 (no cap).
const [maxConcurrentRaw, setMaxConcurrentRaw] = useState(String(initial?.max_concurrent ?? 0));
const maxConcurrent = parseIntInRange(maxConcurrentRaw, 0, 1000);
const [channels, setChannels] = useState<string[]>(initial?.notify_channel_ids ?? []);
const [newWindow, setNewWindow] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -49,7 +51,7 @@ export function PolicyModal({ initial, onClose }: { initial?: PatchPolicy; onClo
const { mutate: save, isPending } = useMutation({
mutationFn: () => {
const input = { name, enabled, window_id: windowId, target_server_ids: targets, target_tags: tags, scope, reboot, max_concurrent: maxConcurrent, notify_channel_ids: channels };
const input = { name, enabled, window_id: windowId, target_server_ids: targets, target_tags: tags, scope, reboot, max_concurrent: maxConcurrent ?? 0, notify_channel_ids: channels };
return initial ? api.updatePatchPolicy(initial.policy_id, input) : api.createPatchPolicy(input);
},
onSuccess: (p) => {
@@ -152,7 +154,7 @@ export function PolicyModal({ initial, onClose }: { initial?: PatchPolicy; onClo
<Radio name="reboot" value="if_required" current={reboot} onChange={setReboot} title="Reboot if required" hint="Only when the OS says so, and only with 5 minutes or more left in the window." />
{reboot === "if_required" && resolved.length > 0 && (
<p className="rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning">
Up to {maxConcurrent > 0 ? Math.min(maxConcurrent, resolved.length) : resolved.length} server{resolved.length === 1 ? "" : "s"} may be rebooting at the same time during this window.
Up to {maxConcurrent ? Math.min(maxConcurrent, resolved.length) : resolved.length} server{resolved.length === 1 ? "" : "s"} may be rebooting at the same time during this window.
</p>
)}
</fieldset>
@@ -161,8 +163,12 @@ export function PolicyModal({ initial, onClose }: { initial?: PatchPolicy; onClo
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-text-secondary">At most this many at once</span>
<input id="policy-max-concurrent" type="number" min={0} max={1000} className={inputClass} value={maxConcurrent} onChange={(e) => setMaxConcurrent(Number(e.target.value))} />
<span className="mt-1.5 block text-[11px] text-text-tertiary">0 means no limit</span>
<input id="policy-max-concurrent" type="number" min={0} max={1000} className={inputClass} value={maxConcurrentRaw} aria-invalid={maxConcurrent === null} onChange={(e) => setMaxConcurrentRaw(e.target.value)} />
{maxConcurrent === null ? (
<span className="mt-1.5 block text-[11px] text-danger">Enter a whole number from 0 to 1000.</span>
) : (
<span className="mt-1.5 block text-[11px] text-text-tertiary">0 means no limit</span>
)}
</label>
<div>
<span className="mb-1.5 block text-sm font-medium text-text-secondary">Alert when a run is not clean</span>
@@ -193,7 +199,7 @@ export function PolicyModal({ initial, onClose }: { initial?: PatchPolicy; onClo
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" loading={isPending} disabled={!name.trim() || !windowId} onClick={() => save()}>
<Button variant="primary" loading={isPending} disabled={!name.trim() || !windowId || maxConcurrent === null} onClick={() => save()}>
{initial ? "Save policy" : "Create policy"}
</Button>
</div>
+14 -6
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, MaintenanceWindow } from "@/lib/api";
import { Button, Modal, friendlyMessage, useToast } from "@/components/ui";
import { parseIntInRange } from "./status";
/*
* Presets write cron underneath, as the workflow schedule card does, and the
@@ -27,18 +28,21 @@ export function WindowModal({ initial, onClose, onSaved }: { initial?: Maintenan
const [name, setName] = useState(initial?.name ?? "");
const [cron, setCron] = useState(initial?.cron ?? "0 2 * * 0");
const [tz, setTz] = useState(initial?.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC");
const [duration, setDuration] = useState(initial?.duration_minutes ?? 120);
// Kept as the raw input so a cleared field never reaches the API as NaN.
const [durationRaw, setDurationRaw] = useState(String(initial?.duration_minutes ?? 120));
const duration = parseIntInRange(durationRaw, 15, 720);
const [error, setError] = useState<string | null>(null);
const { data: preview, isError: previewFailed, error: previewError } = useQuery({
queryKey: ["window-preview", cron, tz, duration],
queryFn: () => api.previewMaintenanceWindow({ cron, tz, duration_minutes: duration }),
queryFn: () => api.previewMaintenanceWindow({ cron, tz, duration_minutes: duration ?? 0 }),
enabled: duration !== null,
retry: false,
});
const { mutate: save, isPending } = useMutation({
mutationFn: () => {
const input = { name, cron, tz, duration_minutes: duration };
const input = { name, cron, tz, duration_minutes: duration ?? 0 };
return initial ? api.updateMaintenanceWindow(initial.window_id, input) : api.createMaintenanceWindow(input);
},
onSuccess: (w) => {
@@ -97,8 +101,12 @@ export function WindowModal({ initial, onClose, onSaved }: { initial?: Maintenan
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-text-secondary">Length (minutes)</span>
<input id="window-duration" type="number" min={15} max={720} step={15} className={inputClass} value={duration} onChange={(e) => setDuration(Number(e.target.value))} />
<span className="mt-1.5 block text-[11px] text-text-tertiary">15 to 720</span>
<input id="window-duration" type="number" min={15} max={720} step={15} className={inputClass} value={durationRaw} aria-invalid={duration === null} onChange={(e) => setDurationRaw(e.target.value)} />
{duration === null ? (
<span className="mt-1.5 block text-[11px] text-danger">Enter a whole number from 15 to 720.</span>
) : (
<span className="mt-1.5 block text-[11px] text-text-tertiary">15 to 720</span>
)}
</label>
</div>
@@ -123,7 +131,7 @@ export function WindowModal({ initial, onClose, onSaved }: { initial?: Maintenan
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" loading={isPending} disabled={previewFailed || !name.trim()} onClick={() => save()}>
<Button variant="primary" loading={isPending} disabled={previewFailed || !name.trim() || duration === null} onClick={() => save()}>
{initial ? "Save window" : "Create window"}
</Button>
</div>
+10
View File
@@ -68,3 +68,13 @@ export function formatDuration(minutes: number): string {
if (h === 0) return `${m} min`;
return m === 0 ? `${h} h` : `${h} h ${m} min`;
}
// parseIntInRange reads a form field kept as its raw string. It returns null
// for an empty, fractional or out-of-range value, so a cleared field is never
// sent as 0 or NaN.
export function parseIntInRange(raw: string, min: number, max: number): number | null {
const t = raw.trim();
if (!/^\d+$/.test(t)) return null;
const n = Number(t);
return n >= min && n <= max ? n : null;
}