diff --git a/web/components/patching/PolicyModal.tsx b/web/components/patching/PolicyModal.tsx new file mode 100644 index 0000000..cbb8934 --- /dev/null +++ b/web/components/patching/PolicyModal.tsx @@ -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({ name, value, current, onChange, title, hint }: { name: string; value: T; current: T; onChange: (v: T) => void; title: string; hint: string }) { + return ( + + ); +} + +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(initial?.target_server_ids ?? []); + const [tagRows, setTagRows] = useState<[string, string][]>(Object.entries(initial?.target_tags ?? {})); + const [scope, setScope] = useState(initial?.scope ?? "security"); + const [reboot, setReboot] = useState(initial?.reboot ?? "never"); + const [maxConcurrent, setMaxConcurrent] = useState(initial?.max_concurrent ?? 0); + const [channels, setChannels] = useState(initial?.notify_channel_ids ?? []); + const [newWindow, setNewWindow] = useState(false); + const [error, setError] = useState(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 ( + <> + +
+ {error && ( +
+ {error} +
+ )} + +
+ + +
+ +
+ Target servers + ({ 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." + /> +
+ +
+ Target tags +

Servers carrying every tag below are patched too. Tags are read when the window opens, so a server tagged later is included.

+ + {Object.keys(knownTags ?? {}).map((k) => ( + +
+ {tagRows.map(([k, v], i) => ( +
+ setTagRows(tagRows.map((r, j): [string, string] => (j === i ? [e.target.value, r[1]] : r)))} /> + setTagRows(tagRows.map((r, j): [string, string] => (j === i ? [r[0], e.target.value] : r)))} /> + +
+ ))} +
+ +
+
+

+ {resolved.length} server{resolved.length === 1 ? "" : "s"} targeted now. + {tooOld.length > 0 && ( + + {" "} + {tooOld.length} of {resolved.length} need an agent update to {MIN_AGENT_VERSION} or later and will be skipped until then. + + )} +

+
+ +
+
+ What to install + + +
+
+ Reboots + + + {reboot === "if_required" && resolved.length > 0 && ( +

+ 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. +

+ )} +
+
+ +
+ +
+ Alert when a run is not clean +
+ {(allChannels ?? []).length === 0 &&

No notification channels yet.

} + {(allChannels ?? []).map((ch) => ( + + ))} +
+
+
+ + + +
+ + +
+
+
+ {newWindow && setNewWindow(false)} onSaved={(w) => setWindowId(w.window_id)} />} + + ); +} diff --git a/web/components/patching/WindowModal.tsx b/web/components/patching/WindowModal.tsx new file mode 100644 index 0000000..2ee6b22 --- /dev/null +++ b/web/components/patching/WindowModal.tsx @@ -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(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 ( + +
+ {error && ( +
+ {error} +
+ )} + + +
+ {PRESETS.map((p) => ( + + ))} +
+ +
+ + + +
+ +
+

Next three windows

+ {previewFailed ? ( +

{friendlyMessage(previewError)}

+ ) : preview ? ( +
    + {preview.map((s) => ( +
  • + {new Date(s.start).toLocaleString()} to {new Date(s.end).toLocaleTimeString()} +
  • + ))} +
+ ) : ( +

Working it out...

+ )} +
+ +
+ + +
+
+
+ ); +}