fix(patching): final review fixes
- 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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user