From bf7f73f6b624d2f259302524b54f143399bc36b8 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Tue, 29 Sep 2026 19:11:35 +0200 Subject: [PATCH] Import maintenance windows from Uptime Kuma New "Import from Uptime Kuma" action on the Maintenance page (operator): pick an Uptime Kuma integration and a duration, and it starts (or extends) a maintenance window here for every server whose address matches a monitor Uptime Kuma currently reports as being in maintenance, reusing the existing monitor-to-server matching from the Uptime Kuma integration itself. Uptime Kuma's metrics endpoint only exposes a monitor's *current* status, not its scheduled start/end time (there's no API for that), so this deliberately doesn't try to mirror Uptime Kuma's own schedule -- it starts a window for the duration you choose, the same bounded/required-end window this feature has always used. Running it again while Kuma is still in maintenance extends the same window rather than stacking a second one; when Kuma later shows nothing in maintenance, already-active windows are left alone rather than force-ended, since ending them isn't something only Uptime Kuma's state should decide. Two Kuma monitors that match the same server are deduped to one window. Monitors with no matching server are reported back by name so nothing is silently missed, and monitors that aren't in maintenance are ignored entirely. The manual "Start maintenance" endpoint's start-or-extend logic (dedupe, pruning old rows, the response shape) is now a shared services/maintenance.ts function instead of living only in that route handler, so the import path can't drift from how a manual window behaves. Likewise the server-matching helper gained a small toMatchableServers() so the existing Uptime Kuma monitors route and this new one build the same match input the same way instead of each parsing server rows on their own. No schema change -- imported windows are ordinary maintenance windows; their Uptime-Kuma origin is only in the reason text ("Imported from Uptime Kuma (): "), visible in the Active table and the audit log like any other window. Verified with 28 backend checks (the refactored manual start/extend flow as a regression check; roles; unknown/wrong-type/disabled integration; validation; nothing-in-maintenance; matching including same-server dedup and unmatched monitors; re-running extends rather than duplicating; windows left alone once Kuma exits maintenance; upstream failure; audit entries) and by driving the real Maintenance page against the real routers in a browser: import, re-import (extends), the nothing-in-maintenance state, and the viewer view (no edit card, active windows still visible). Real dev database mtime untouched. Co-Authored-By: Claude Sonnet 5 --- INTEGRATIONS.md | 4 + README.md | 7 +- server/src/routes/integrations.ts | 15 +-- server/src/routes/maintenance.ts | 100 +++++++++++++++---- server/src/services/maintenance.ts | 39 +++++++- server/src/services/uptimeKumaMatch.ts | 22 +++++ web/src/api/client.ts | 8 ++ web/src/pages/Maintenance.tsx | 132 ++++++++++++++++++++++++- 8 files changed, 290 insertions(+), 37 deletions(-) diff --git a/INTEGRATIONS.md b/INTEGRATIONS.md index 8d3575e..d078032 100644 --- a/INTEGRATIONS.md +++ b/INTEGRATIONS.md @@ -74,6 +74,10 @@ the dashboard views themselves load fine. with no single network target (groups, push monitors, keyword checks with a complex URL) are shown unmatched rather than guessed at. Uptime Kuma's tags aren't read, since the metrics endpoint doesn't reliably distinguish a tag from any other label. +- **Maintenance import:** the Maintenance page can read which monitors are currently in maintenance in Uptime + Kuma and start (or extend) a maintenance window here for the matching server, for a duration you pick — the + metrics endpoint only exposes current status, not a monitor's scheduled start/end time, so this reflects + what's in maintenance right now rather than mirroring Uptime Kuma's own schedule. ## DNS providers diff --git a/README.md b/README.md index c001079..a6a2f20 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,12 @@ health, Proxmox backup alerts, and "integration down" for that service type). Every window has a fixed end (5 minutes to 7 days) and expires on its own, and a problem that began during a window and is still present when it ends alerts then — a forgotten window can't hide an outage. A banner shows what's currently -silenced to every signed-in user. +silenced to every signed-in user. If you also run Uptime Kuma, "Import from +Uptime Kuma" on the Maintenance page reads which of its monitors are +currently in maintenance and starts (or extends) a window here for whichever +server each one's target matches, for a duration you choose — Uptime Kuma's +metrics only say what's in maintenance right now, not for how long, so this +doesn't try to mirror its schedule, only its current state. **Ports** — each server's detail page has a Ports card for finding free ports and remembering what each one is for. "Scan…" runs a TCP scan of a port range diff --git a/server/src/routes/integrations.ts b/server/src/routes/integrations.ts index 5c13d29..7a2feec 100644 --- a/server/src/routes/integrations.ts +++ b/server/src/routes/integrations.ts @@ -21,7 +21,7 @@ import { createSemaphoreAdapter } from "../integrations/semaphore/adapter.js"; import { createProxmoxAdapter, guestsWithoutBackupCoverage } from "../integrations/proxmox/adapter.js"; import { createSynologyAdapter } from "../integrations/synology/adapter.js"; import { createUptimeKumaAdapter } from "../integrations/uptimekuma/adapter.js"; -import { attachMatchedServers, summarizeMonitors, type MatchableServer } from "../services/uptimeKumaMatch.js"; +import { attachMatchedServers, summarizeMonitors, toMatchableServers } from "../services/uptimeKumaMatch.js"; import { asyncHandler } from "../utils/asyncHandler.js"; export const integrationsRouter = Router(); @@ -792,16 +792,6 @@ async function requireUptimeKumaAdapter(req: Request, res: Response) { return { integration: loaded.integration, adapter: createUptimeKumaAdapter(loaded.config as any) }; } -function parseServerIps(stored: string | null): string[] { - if (!stored) return []; - try { - const value = JSON.parse(stored); - return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : []; - } catch { - return []; - } -} - integrationsRouter.get("/:id/uptimekuma/monitors", asyncHandler(async (req, res) => { const found = await requireUptimeKumaAdapter(req, res); if (!found) return; @@ -809,8 +799,7 @@ integrationsRouter.get("/:id/uptimekuma/monitors", asyncHandler(async (req, res) try { const monitors = await found.adapter.listMonitors(); const serverRows = await db.select({ id: servers.id, name: servers.name, hostname: servers.hostname, ips: servers.ipAddresses }).from(servers); - const matchable: MatchableServer[] = serverRows.map((s) => ({ id: s.id, name: s.name, hostname: s.hostname, ips: parseServerIps(s.ips) })); - const withServers = attachMatchedServers(monitors, matchable); + const withServers = attachMatchedServers(monitors, toMatchableServers(serverRows)); res.json({ monitors: withServers, summary: summarizeMonitors(monitors) }); } catch (err) { res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); diff --git a/server/src/routes/maintenance.ts b/server/src/routes/maintenance.ts index 63bfbcb..35ece3b 100644 --- a/server/src/routes/maintenance.ts +++ b/server/src/routes/maintenance.ts @@ -1,11 +1,14 @@ import { Router } from "express"; -import { eq, lt } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { z } from "zod"; import { db } from "../db/client.js"; import { dnsProviders, integrations, maintenanceTargetTypes, maintenanceWindows, servers } from "../db/schema.js"; import { requireAuth, requireRole } from "../auth/middleware.js"; import { recordAudit } from "../services/audit.js"; -import { describeTarget, listActiveWindows } from "../services/maintenance.js"; +import { describeTarget, listActiveWindows, startOrExtendWindow } from "../services/maintenance.js"; +import { loadIntegrationConfig } from "../integrations/loadIntegration.js"; +import { createUptimeKumaAdapter } from "../integrations/uptimekuma/adapter.js"; +import { attachMatchedServers, toMatchableServers } from "../services/uptimeKumaMatch.js"; import { asyncHandler } from "../utils/asyncHandler.js"; export const maintenanceRouter = Router(); @@ -46,37 +49,92 @@ maintenanceRouter.post("/", requireRole("operator"), asyncHandler(async (req, re } const { targetType, targetId, minutes, reason } = parsed.data; - const target = await describeTarget(targetType, targetId); - if (!target) { - return res.status(404).json({ error: "not_found", message: "That target doesn't exist." }); - } - - const now = new Date(); - const endsAt = new Date(now.getTime() + minutes * 60_000).toISOString(); const actor = req.currentUser!; const createdBy = actor.name ?? actor.email ?? actor.oidcSub; - // Starting maintenance on something already in maintenance restarts its clock rather than stacking windows. - const existing = (await listActiveWindows(now)).find((w) => w.targetType === targetType && w.targetId === targetId); - let window; - if (existing) { - [window] = await db.update(maintenanceWindows).set({ endsAt, reason: reason ?? null, createdBy }).where(eq(maintenanceWindows.id, existing.id)).returning(); - } else { - // Long-expired rows are just clutter; clear them out whenever a new one is added. - await db.delete(maintenanceWindows).where(lt(maintenanceWindows.endsAt, new Date(now.getTime() - 24 * 3600_000).toISOString())); - [window] = await db.insert(maintenanceWindows).values({ targetType, targetId, reason: reason ?? null, startedAt: now.toISOString(), endsAt, createdBy }).returning(); + const result = await startOrExtendWindow(targetType, targetId, minutes, reason ?? null, createdBy); + if (!result) { + return res.status(404).json({ error: "not_found", message: "That target doesn't exist." }); } await recordAudit({ actor, category: "maintenance", - action: existing ? "extend" : "start", + action: result.extended ? "extend" : "start", targetType, targetId, - detail: { name: target.name, minutes, reason: reason ?? null }, + detail: { name: result.target.name, minutes, reason: reason ?? null }, }); - res.status(201).json({ window: { ...window, targetName: target.name, targetKind: target.kind } }); + res.status(201).json({ window: { ...result.window, targetName: result.target.name, targetKind: result.target.kind } }); +})); + +const importUptimeKumaSchema = z.object({ + integrationId: z.number().int().positive(), + // Same bounds as a manual window: required and capped, so an import can't quietly create an open-ended one. + minutes: z.number().int().min(5).max(7 * 24 * 60), +}); + +/** + * Uptime Kuma has no scheduled-maintenance API to read (see the integration's own notes) — only each monitor's + * *current* status, which is "maintenance" for exactly as long as a window is active there. So rather than + * mirroring Kuma's schedule, this reads what's in maintenance right now and starts (or extends) a matching + * window here for that long, on whichever server the monitor's target matches. Re-running it while Kuma is + * still in maintenance just extends the same window rather than stacking a new one. + */ +maintenanceRouter.post("/import/uptimekuma", requireRole("operator"), asyncHandler(async (req, res) => { + const parsed = importUptimeKumaSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "invalid_body", message: "Choose an integration and a duration.", details: parsed.error.flatten() }); + } + const { integrationId, minutes } = parsed.data; + + const loaded = await loadIntegrationConfig(integrationId); + if (!loaded) return res.status(404).json({ error: "not_found" }); + if (loaded.integration.type !== "uptimekuma") return res.status(400).json({ error: "wrong_type" }); + if (!loaded.integration.enabled) return res.status(400).json({ error: "integration_disabled" }); + + let monitors; + try { + monitors = await createUptimeKumaAdapter(loaded.config as any).listMonitors(); + } catch (err) { + return res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); + } + + const serverRows = await db.select({ id: servers.id, name: servers.name, hostname: servers.hostname, ips: servers.ipAddresses }).from(servers); + const inMaintenance = attachMatchedServers(monitors, toMatchableServers(serverRows)).filter((m) => m.status === "maintenance"); + + const actor = req.currentUser!; + const createdBy = actor.name ?? actor.email ?? actor.oidcSub; + const imported: { serverId: number; serverName: string; monitorName: string; extended: boolean }[] = []; + const unmatched: string[] = []; + + // Two monitors on the same server would otherwise start, then immediately re-extend, the same window — + // harmless, but the response would misleadingly list the server twice. + const seenServers = new Set(); + for (const m of inMaintenance) { + if (!m.matchedServer) { + unmatched.push(m.name); + continue; + } + if (seenServers.has(m.matchedServer.id)) continue; + seenServers.add(m.matchedServer.id); + + const reason = `Imported from Uptime Kuma (${loaded.integration.name}): ${m.name}`; + const result = await startOrExtendWindow("server", m.matchedServer.id, minutes, reason, createdBy); + if (!result) continue; // the server was removed between the query above and now + await recordAudit({ + actor, + category: "maintenance", + action: result.extended ? "extend" : "start", + targetType: "server", + targetId: m.matchedServer.id, + detail: { name: result.target.name, minutes, reason }, + }); + imported.push({ serverId: m.matchedServer.id, serverName: result.target.name, monitorName: m.name, extended: result.extended }); + } + + res.json({ imported, unmatched, monitorsInMaintenance: inMaintenance.length }); })); maintenanceRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) => { diff --git a/server/src/services/maintenance.ts b/server/src/services/maintenance.ts index c037722..5f2d819 100644 --- a/server/src/services/maintenance.ts +++ b/server/src/services/maintenance.ts @@ -1,4 +1,4 @@ -import { eq, gt } from "drizzle-orm"; +import { eq, gt, lt } from "drizzle-orm"; import { db } from "../db/client.js"; import { dnsProviders, integrations, maintenanceWindows, servers } from "../db/schema.js"; @@ -58,6 +58,43 @@ export async function isSourceInMaintenance(source: string, now: Date = new Date return false; } +export interface StartWindowResult { + window: ActiveWindow; + target: { name: string; kind: string }; + /** True if this extended an already-active window rather than starting a new one. */ + extended: boolean; +} + +/** + * Starts a maintenance window for a target, or — if one is already active for it — extends it instead of + * stacking a second one, pruning long-expired rows along the way. Returns null if the target doesn't exist. + * Shared by the manual "Start maintenance" form and the Uptime Kuma import, so both silence alerts identically; + * callers still record their own audit entry, since only they know the acting user. + */ +export async function startOrExtendWindow( + targetType: ActiveWindow["targetType"], + targetId: number, + minutes: number, + reason: string | null, + createdBy: string | null, + now: Date = new Date(), +): Promise { + const target = await describeTarget(targetType, targetId); + if (!target) return null; + + const endsAt = new Date(now.getTime() + minutes * 60_000).toISOString(); + const existing = (await listActiveWindows(now)).find((w) => w.targetType === targetType && w.targetId === targetId); + let window: ActiveWindow; + if (existing) { + [window] = await db.update(maintenanceWindows).set({ endsAt, reason, createdBy }).where(eq(maintenanceWindows.id, existing.id)).returning(); + } else { + // Long-expired rows are just clutter; clear them out whenever a new one is added. + await db.delete(maintenanceWindows).where(lt(maintenanceWindows.endsAt, new Date(now.getTime() - 24 * 3600_000).toISOString())); + [window] = await db.insert(maintenanceWindows).values({ targetType, targetId, reason, startedAt: now.toISOString(), endsAt, createdBy }).returning(); + } + return { window, target, extended: !!existing }; +} + /** Display name for a window's target, or null if the target no longer exists. */ export async function describeTarget(targetType: ActiveWindow["targetType"], targetId: number): Promise<{ name: string; kind: string } | null> { if (targetType === "server") { diff --git a/server/src/services/uptimeKumaMatch.ts b/server/src/services/uptimeKumaMatch.ts index 9082c1a..5eb6743 100644 --- a/server/src/services/uptimeKumaMatch.ts +++ b/server/src/services/uptimeKumaMatch.ts @@ -47,6 +47,28 @@ export function attachMatchedServers(monitors: UptimeKumaMonitor[], servers: Mat }); } +/** A server row as selected with {id, name, hostname, ips: } — turns it into what matchServerForTarget needs. */ +export interface ServerRow { + id: number; + name: string; + hostname: string | null; + ips: string | null; +} + +function parseServerIps(stored: string | null): string[] { + if (!stored) return []; + try { + const value = JSON.parse(stored); + return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : []; + } catch { + return []; + } +} + +export function toMatchableServers(rows: ServerRow[]): MatchableServer[] { + return rows.map((r) => ({ id: r.id, name: r.name, hostname: r.hostname, ips: parseServerIps(r.ips) })); +} + export interface MonitorSummary { total: number; up: number; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f57bae9..332a977 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -629,6 +629,12 @@ export interface UptimeKumaMonitorsResponse { summary: { total: number; up: number; down: number; pending: number; maintenance: number; unknown: number }; } +export interface UptimeKumaImportResult { + imported: { serverId: number; serverName: string; monitorName: string; extended: boolean }[]; + unmatched: string[]; + monitorsInMaintenance: number; +} + export type SemaphoreTaskStatus = | "waiting" | "starting" @@ -835,6 +841,8 @@ export const api = { start: (data: { targetType: MaintenanceTargetType; targetId: number; minutes: number; reason?: string }) => request<{ window: MaintenanceWindow }>("/api/maintenance", { method: "POST", body: JSON.stringify(data) }), end: (id: number) => request(`/api/maintenance/${id}`, { method: "DELETE" }), + importUptimeKuma: (data: { integrationId: number; minutes: number }) => + request("/api/maintenance/import/uptimekuma", { method: "POST", body: JSON.stringify(data) }), }, sessions: { list: () => request<{ sessions: SessionSummary[]; currentSessionId: string }>("/api/sessions"), diff --git a/web/src/pages/Maintenance.tsx b/web/src/pages/Maintenance.tsx index eda6a5c..f86b52a 100644 --- a/web/src/pages/Maintenance.tsx +++ b/web/src/pages/Maintenance.tsx @@ -1,5 +1,13 @@ import { useEffect, useState } from "react"; -import { api, type CurrentUser, type MaintenanceTargets, type MaintenanceTargetType, type MaintenanceWindow } from "../api/client"; +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"; @@ -31,6 +39,25 @@ export default function Maintenance({ user }: { user: CurrentUser }) { 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() @@ -82,6 +109,26 @@ export default function Maintenance({ user }: { user: CurrentUser }) { } } + 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

@@ -161,6 +208,89 @@ export default function Maintenance({ user }: { user: CurrentUser }) { )} + {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