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 (<integration>): <monitor>"), 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 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-29 19:11:35 +02:00
1 parent 26de6cb243
commit bf7f73f6b6
8 files changed
+290 -37

No files matched your search

+2 -13
View File
@@ -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) });
+79 -21
View File
@@ -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<number>();
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) => {
+38 -1
View File
@@ -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<StartWindowResult | null> {
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") {
+22
View File
@@ -47,6 +47,28 @@ export function attachMatchedServers(monitors: UptimeKumaMonitor[], servers: Mat
});
}
/** A server row as selected with {id, name, hostname, ips: <the JSON ip_addresses column>} — 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;