feat(web): maintenance window and patch policy editors

This commit is contained in:
2026-09-15 10:59:06 +00:00
parent 7809419202
commit 3a7618f82f
2 changed files with 338 additions and 0 deletions
+205
View File
@@ -0,0 +1,205 @@
"use client";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, PatchPolicy, PatchReboot, PatchScope } from "@/lib/api";
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";
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";
function Radio<T extends string>({ name, value, current, onChange, title, hint }: { name: string; value: T; current: T; onChange: (v: T) => void; title: string; hint: string }) {
return (
<label className="flex items-start gap-3">
<input type="radio" id={`${name}-${value}`} name={name} checked={current === value} onChange={() => onChange(value)} className="mt-0.5 h-4 w-4 accent-accent" />
<span>
<span className="block text-sm text-text-primary">{title}</span>
<span className="mt-0.5 block text-xs text-text-tertiary">{hint}</span>
</span>
</label>
);
}
export function PolicyModal({ initial, onClose }: { initial?: PatchPolicy; onClose: () => void }) {
const queryClient = useQueryClient();
const toast = useToast();
const [name, setName] = useState(initial?.name ?? "");
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
const [windowId, setWindowId] = useState(initial?.window_id ?? "");
const [targets, setTargets] = useState<string[]>(initial?.target_server_ids ?? []);
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);
const [channels, setChannels] = useState<string[]>(initial?.notify_channel_ids ?? []);
const [newWindow, setNewWindow] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data: windows } = useQuery({ queryKey: ["maintenance-windows"], queryFn: () => api.listMaintenanceWindows() });
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
const { data: knownTags } = useQuery({ queryKey: ["server-tags"], queryFn: () => api.listKnownTags(), staleTime: 60_000 });
const { data: allChannels } = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() });
const tags = useMemo(() => Object.fromEntries(tagRows.filter(([k, v]) => k && v)), [tagRows]);
const resolved = useMemo(() => resolveTargets(servers ?? [], targets, tags), [servers, targets, tags]);
const tooOld = resolved.filter((s) => !agentSupportsPatchResults(s.agent_version));
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 };
return initial ? api.updatePatchPolicy(initial.policy_id, input) : api.createPatchPolicy(input);
},
onSuccess: (p) => {
queryClient.invalidateQueries({ queryKey: ["patch-policies"] });
toast.success(initial ? `Saved ${p.name}.` : `Created ${p.name}.`);
onClose();
},
onError: (e) => setError(friendlyMessage(e)),
});
return (
<>
<Modal open title={initial ? "Edit patch policy" : "New patch policy"} onClose={onClose} wide>
<div className="space-y-5">
{error && (
<div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger" role="alert">
{error}
</div>
)}
<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">Name</span>
<input id="policy-name" className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="Sunday prod security" />
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-text-secondary">Maintenance window</span>
<div className="flex gap-2">
<select id="policy-window" className={inputClass} value={windowId} onChange={(e) => setWindowId(e.target.value)}>
<option value="">Choose a window</option>
{(windows ?? []).map((w) => (
<option key={w.window_id} value={w.window_id}>
{w.name}: {describeCron(w.cron)}, {formatDuration(w.duration_minutes)} ({w.tz})
</option>
))}
</select>
<Button variant="secondary" size="sm" onClick={() => setNewWindow(true)}>
New
</Button>
</div>
</label>
</div>
<div>
<span className="mb-1 block text-xs uppercase text-text-secondary">Target servers</span>
<DualListBox
items={(servers ?? []).map((s) => ({ id: s.server_id, label: s.hostname, hint: s.status === "active" ? undefined : s.status }))}
selected={targets}
onChange={setTargets}
selectedLabel="Targets"
emptyAvailable="Every server is a target."
emptySelected="No servers named."
/>
</div>
<div>
<span className="mb-1 block text-xs uppercase text-text-secondary">Target tags</span>
<p className="mb-2 text-[11px] text-text-tertiary">Servers carrying every tag below are patched too. Tags are read when the window opens, so a server tagged later is included.</p>
<datalist id="policy-tag-keys">
{Object.keys(knownTags ?? {}).map((k) => (
<option key={k} value={k} />
))}
</datalist>
<div className="flex flex-col gap-2">
{tagRows.map(([k, v], i) => (
<div key={i} className="flex gap-2">
<input id={`policy-tag-key-${i}`} list="policy-tag-keys" className={inputClass} value={k} placeholder="key" onChange={(e) => setTagRows(tagRows.map((r, j): [string, string] => (j === i ? [e.target.value, r[1]] : r)))} />
<input id={`policy-tag-value-${i}`} className={inputClass} value={v} placeholder="value" onChange={(e) => setTagRows(tagRows.map((r, j): [string, string] => (j === i ? [r[0], e.target.value] : r)))} />
<Button variant="ghost" size="sm" onClick={() => setTagRows(tagRows.filter((_, j) => j !== i))} aria-label={`Remove ${k || "tag"}`}>
Remove
</Button>
</div>
))}
<div>
<Button variant="secondary" size="sm" onClick={() => setTagRows([...tagRows, ["", ""]])}>
Add tag
</Button>
</div>
</div>
<p className="mt-2 text-xs text-text-secondary">
{resolved.length} server{resolved.length === 1 ? "" : "s"} targeted now.
{tooOld.length > 0 && (
<span className="text-warning">
{" "}
{tooOld.length} of {resolved.length} need an agent update to {MIN_AGENT_VERSION} or later and will be skipped until then.
</span>
)}
</p>
</div>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<fieldset className="space-y-3">
<legend className="mb-2 text-xs uppercase text-text-secondary">What to install</legend>
<Radio name="scope" value="security" current={scope} onChange={setScope} title="Security updates only" hint="Servers using apk or pacman have no security metadata and report unsupported." />
<Radio name="scope" value="all" current={scope} onChange={setScope} title="All pending updates" hint="Everything the package manager would upgrade." />
</fieldset>
<fieldset className="space-y-3">
<legend className="mb-2 text-xs uppercase text-text-secondary">Reboots</legend>
<Radio name="reboot" value="never" current={reboot} onChange={setReboot} title="Never reboot" hint="Servers that need one show reboot required." />
<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.
</p>
)}
</fieldset>
</div>
<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>
</label>
<div>
<span className="mb-1.5 block text-sm font-medium text-text-secondary">Alert when a run is not clean</span>
<div className="flex flex-col gap-1.5">
{(allChannels ?? []).length === 0 && <p className="text-xs text-text-tertiary">No notification channels yet.</p>}
{(allChannels ?? []).map((ch) => (
<label key={ch.channel_id} className="flex items-center gap-2 text-sm text-text-primary">
<input
type="checkbox"
id={`policy-channel-${ch.channel_id}`}
checked={channels.includes(ch.channel_id)}
onChange={(e) => setChannels(e.target.checked ? [...channels, ch.channel_id] : channels.filter((c) => c !== ch.channel_id))}
className="h-4 w-4 accent-accent"
/>
{ch.name}
</label>
))}
</div>
</div>
</div>
<label className="flex items-center gap-3">
<input type="checkbox" id="policy-enabled" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} className="h-4 w-4 accent-accent" />
<span className="text-sm text-text-primary">Enabled</span>
</label>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" loading={isPending} disabled={!name.trim() || !windowId} onClick={() => save()}>
{initial ? "Save policy" : "Create policy"}
</Button>
</div>
</div>
</Modal>
{newWindow && <WindowModal onClose={() => setNewWindow(false)} onSaved={(w) => setWindowId(w.window_id)} />}
</>
);
}
+133
View File
@@ -0,0 +1,133 @@
"use client";
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";
/*
* Presets write cron underneath, as the workflow schedule card does, and the
* next three windows come from the server, so the editor cannot disagree with
* the scheduler about when a window opens.
*/
const PRESETS: { label: string; cron: string }[] = [
{ label: "Nightly, 02:00", cron: "0 2 * * *" },
{ label: "Sunday, 02:00", cron: "0 2 * * 0" },
{ label: "Saturday, 22:00", cron: "0 22 * * 6" },
{ label: "Monthly, 1st 02:00", cron: "0 2 1 * *" },
];
const ZONES = ["UTC", "Europe/London", "Europe/Berlin", "America/New_York", "America/Los_Angeles", "Asia/Singapore", "Australia/Sydney"];
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";
export function WindowModal({ initial, onClose, onSaved }: { initial?: MaintenanceWindow; onClose: () => void; onSaved?: (w: MaintenanceWindow) => void }) {
const queryClient = useQueryClient();
const toast = useToast();
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);
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 }),
retry: false,
});
const { mutate: save, isPending } = useMutation({
mutationFn: () => {
const input = { name, cron, tz, duration_minutes: duration };
return initial ? api.updateMaintenanceWindow(initial.window_id, input) : api.createMaintenanceWindow(input);
},
onSuccess: (w) => {
queryClient.invalidateQueries({ queryKey: ["maintenance-windows"] });
queryClient.invalidateQueries({ queryKey: ["patch-policies"] });
toast.success(initial ? `Saved ${w.name}. Policies using it now follow the new times.` : `Created ${w.name}.`);
onSaved?.(w);
onClose();
},
onError: (e) => setError(friendlyMessage(e)),
});
return (
<Modal open title={initial ? "Edit maintenance window" : "New maintenance window"} onClose={onClose}>
<div className="space-y-4">
{error && (
<div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger" role="alert">
{error}
</div>
)}
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-text-secondary">Name</span>
<input id="window-name" className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="Sunday early morning" />
</label>
<div className="flex flex-wrap gap-2">
{PRESETS.map((p) => (
<button
key={p.cron}
type="button"
onClick={() => setCron(p.cron)}
className={`rounded-lg border px-3 py-1.5 text-xs transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
cron === p.cron ? "border-accent bg-accent/10 text-accent" : "border-border bg-surface-2 text-text-secondary hover:border-accent/40"
}`}
>
{p.label}
</button>
))}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-text-secondary">Starts (cron)</span>
<input id="window-cron" className={`${inputClass} font-mono`} value={cron} onChange={(e) => setCron(e.target.value)} />
<span className="mt-1.5 block font-mono text-[11px] text-text-tertiary">minute hour day month weekday</span>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-text-secondary">Timezone</span>
<select id="window-tz" className={inputClass} value={tz} onChange={(e) => setTz(e.target.value)}>
{[...new Set([tz, ...ZONES])].map((z) => (
<option key={z} value={z}>
{z}
</option>
))}
</select>
</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>
</label>
</div>
<div className="rounded-lg bg-well px-4 py-3">
<p className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">Next three windows</p>
{previewFailed ? (
<p className="mt-1.5 font-mono text-[11.5px] text-danger">{friendlyMessage(previewError)}</p>
) : preview ? (
<ul className="mt-1.5 flex flex-col gap-0.5 font-mono text-[11.5px] text-text-secondary">
{preview.map((s) => (
<li key={s.start}>
{new Date(s.start).toLocaleString()} to {new Date(s.end).toLocaleTimeString()}
</li>
))}
</ul>
) : (
<p className="mt-1.5 font-mono text-[11.5px] text-text-tertiary">Working it out...</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" loading={isPending} disabled={previewFailed || !name.trim()} onClick={() => save()}>
{initial ? "Save window" : "Create window"}
</Button>
</div>
</div>
</Modal>
);
}