import { useEffect, useState } from "react"; import { api, type CurrentUser, type MaintenanceTargets, type MaintenanceTargetType, type MaintenanceWindow } from "../api/client"; import { formatDateTime } from "../utils/date"; import { formatRemaining } from "../utils/duration"; import { readableError } from "../utils/errors"; const DURATIONS: { minutes: number; label: string }[] = [ { minutes: 30, label: "30 minutes" }, { minutes: 60, label: "1 hour" }, { minutes: 120, label: "2 hours" }, { minutes: 240, label: "4 hours" }, { minutes: 480, label: "8 hours" }, { minutes: 1440, label: "24 hours" }, { minutes: 4320, label: "3 days" }, ]; // Tells the app shell's banner to refetch right away instead of waiting for its next poll. function announceChange() { window.dispatchEvent(new Event("maintenance-changed")); } export default function Maintenance({ user }: { user: CurrentUser }) { const canEdit = user.role === "admin" || user.role === "operator"; const [windows, setWindows] = useState(null); const [targets, setTargets] = useState(null); const [error, setError] = useState(null); const [now, setNow] = useState(() => Date.now()); const [target, setTarget] = useState(""); const [minutes, setMinutes] = useState(60); const [reason, setReason] = useState(""); const [busy, setBusy] = useState(false); function load() { api.maintenance .list() .then((res) => { setWindows(res.windows); setTargets(res.targets); }) .catch((err) => setError(readableError(err))); } useEffect(() => { load(); const tick = setInterval(() => setNow(Date.now()), 30_000); return () => clearInterval(tick); }, []); async function start(e: React.FormEvent) { e.preventDefault(); const [targetType, id] = target.split(":"); if (!targetType || !id) return; setError(null); setBusy(true); try { await api.maintenance.start({ targetType: targetType as MaintenanceTargetType, targetId: Number(id), minutes, reason: reason.trim() || undefined, }); setTarget(""); setReason(""); load(); announceChange(); } catch (err) { setError(readableError(err)); } finally { setBusy(false); } } async function end(w: MaintenanceWindow) { setError(null); try { await api.maintenance.end(w.id); load(); announceChange(); } catch (err) { setError(readableError(err)); } } return ( <>

Maintenance

Silence alerts about one server or integration while you work on it. Every window ends on its own — anything still wrong when it ends alerts then, so nothing stays hidden.
{error &&
{error}
} {canEdit && (

Start maintenance

setReason(e.target.value)} />
)}

Active

{canEdit && } {windows?.map((w) => ( {canEdit && ( )} ))} {windows?.length === 0 && ( )}
Target Reason Ends Started byActions
{w.targetName} · {w.targetKind} {w.reason ?? "—"} {formatRemaining(w.endsAt, now)} left {formatDateTime(new Date(w.endsAt))} {w.createdBy ?? "—"}
Nothing is in maintenance — all alerts are active.

What gets silenced

Silenced for the target
  • Server offline and disk-full alerts (for a server).
  • Storage, volume and disk-health alerts (for a Proxmox or Synology integration).
  • Backup-failure and uncovered-guest alerts (for a Proxmox integration).
  • "Integration down" alerts for that type of service — these are tracked per type (e.g. all Proxmox), not per instance, so with two Proxmox integrations a failure on the other one is silenced too while a window is open.
Not silenced
  • Secret and certificate expiry, Tailscale key expiry, Docker update reminders.
  • DNS record change notifications.
When a window ends
A problem that started during it and is still there alerts on the next check (within 15 minutes). One that was already alerted before it began and has since cleared is reported as cleared; one that started and cleared entirely inside the window is never reported.
); }