feat(web): maintenance window and patch policy editors
This commit is contained in:
@@ -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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user