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