import { useEffect, useState } from "react"; import { api, type CurrentUser, type IntegrationSummary, type MaintenanceTargets, type MaintenanceTargetType, type MaintenanceWindow, type UptimeKumaImportResult, } 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); const [kumaIntegrations, setKumaIntegrations] = useState(null); const [kumaIntegrationId, setKumaIntegrationId] = useState(""); const [kumaMinutes, setKumaMinutes] = useState(60); const [kumaBusy, setKumaBusy] = useState(false); const [kumaError, setKumaError] = useState(null); const [kumaResult, setKumaResult] = useState(null); useEffect(() => { api.integrations .list() .then((res) => { const found = res.integrations.filter((i) => i.type === "uptimekuma"); setKumaIntegrations(found); const firstEnabled = found.find((i) => i.enabled); if (firstEnabled) setKumaIntegrationId(firstEnabled.id); }) .catch(() => setKumaIntegrations([])); }, []); 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)); } } async function importFromUptimeKuma(e: React.FormEvent) { e.preventDefault(); if (kumaIntegrationId === "") return; setKumaError(null); setKumaResult(null); setKumaBusy(true); try { const result = await api.maintenance.importUptimeKuma({ integrationId: kumaIntegrationId, minutes: kumaMinutes }); setKumaResult(result); if (result.imported.length > 0) { load(); announceChange(); } } catch (err) { setKumaError(readableError(err)); } finally { setKumaBusy(false); } } 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)} />
)} {canEdit && kumaIntegrations && kumaIntegrations.length > 0 && (

Import from Uptime Kuma

Uptime Kuma only ever tells us what's in maintenance right now, not for how long, so pick a duration below — it doesn't have to match Uptime Kuma's own schedule exactly, and importing again later just extends the window instead of stacking a second one.
{kumaIntegrations.length > 1 && (
)}
{kumaError && (
{kumaError}
)} {kumaResult && (
{kumaResult.monitorsInMaintenance === 0 ? (
Nothing is currently in maintenance in Uptime Kuma.
) : (
{kumaResult.imported.length > 0 ? ( <> {kumaResult.imported.length} window{kumaResult.imported.length === 1 ? "" : "s"}{" "} {kumaResult.imported.every((i) => i.extended) ? "extended" : kumaResult.imported.every((i) => !i.extended) ? "started" : "started or extended"} : {kumaResult.imported.map((i) => `${i.serverName} (${i.extended ? "extended" : "started"})`).join(", ")}. ) : ( "None of the monitors currently in maintenance matched a server." )} {kumaResult.unmatched.length > 0 && (
{kumaResult.unmatched.length} monitor{kumaResult.unmatched.length === 1 ? "" : "s"} in maintenance {" "}couldn't be matched to a server: {kumaResult.unmatched.join(", ")}.
)}
)}
)}
)}

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).
  • Backup-verification-failure alerts (for a Proxmox Backup Server integration).
  • Failed-run alerts for the Semaphore or Gitea integration you choose.
  • "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.
); }