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:
1 parent
26de6cb243
commit
bf7f73f6b6
8 files changed
+290
-37
No files matched your search
@@ -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
|
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
|
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.
|
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
|
## DNS providers
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
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
|
**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
|
and remembering what each one is for. "Scan…" runs a TCP scan of a port range
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { createSemaphoreAdapter } from "../integrations/semaphore/adapter.js";
|
|||||||
import { createProxmoxAdapter, guestsWithoutBackupCoverage } from "../integrations/proxmox/adapter.js";
|
import { createProxmoxAdapter, guestsWithoutBackupCoverage } from "../integrations/proxmox/adapter.js";
|
||||||
import { createSynologyAdapter } from "../integrations/synology/adapter.js";
|
import { createSynologyAdapter } from "../integrations/synology/adapter.js";
|
||||||
import { createUptimeKumaAdapter } from "../integrations/uptimekuma/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";
|
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||||
|
|
||||||
export const integrationsRouter = Router();
|
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) };
|
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) => {
|
integrationsRouter.get("/:id/uptimekuma/monitors", asyncHandler(async (req, res) => {
|
||||||
const found = await requireUptimeKumaAdapter(req, res);
|
const found = await requireUptimeKumaAdapter(req, res);
|
||||||
if (!found) return;
|
if (!found) return;
|
||||||
@@ -809,8 +799,7 @@ integrationsRouter.get("/:id/uptimekuma/monitors", asyncHandler(async (req, res)
|
|||||||
try {
|
try {
|
||||||
const monitors = await found.adapter.listMonitors();
|
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 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, toMatchableServers(serverRows));
|
||||||
const withServers = attachMatchedServers(monitors, matchable);
|
|
||||||
res.json({ monitors: withServers, summary: summarizeMonitors(monitors) });
|
res.json({ monitors: withServers, summary: summarizeMonitors(monitors) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
|
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { eq, lt } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { dnsProviders, integrations, maintenanceTargetTypes, maintenanceWindows, servers } from "../db/schema.js";
|
import { dnsProviders, integrations, maintenanceTargetTypes, maintenanceWindows, servers } from "../db/schema.js";
|
||||||
import { requireAuth, requireRole } from "../auth/middleware.js";
|
import { requireAuth, requireRole } from "../auth/middleware.js";
|
||||||
import { recordAudit } from "../services/audit.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";
|
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||||
|
|
||||||
export const maintenanceRouter = Router();
|
export const maintenanceRouter = Router();
|
||||||
@@ -46,37 +49,92 @@ maintenanceRouter.post("/", requireRole("operator"), asyncHandler(async (req, re
|
|||||||
}
|
}
|
||||||
const { targetType, targetId, minutes, reason } = parsed.data;
|
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 actor = req.currentUser!;
|
||||||
const createdBy = actor.name ?? actor.email ?? actor.oidcSub;
|
const createdBy = actor.name ?? actor.email ?? actor.oidcSub;
|
||||||
|
|
||||||
// Starting maintenance on something already in maintenance restarts its clock rather than stacking windows.
|
// 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);
|
const result = await startOrExtendWindow(targetType, targetId, minutes, reason ?? null, createdBy);
|
||||||
let window;
|
if (!result) {
|
||||||
if (existing) {
|
return res.status(404).json({ error: "not_found", message: "That target doesn't exist." });
|
||||||
[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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await recordAudit({
|
await recordAudit({
|
||||||
actor,
|
actor,
|
||||||
category: "maintenance",
|
category: "maintenance",
|
||||||
action: existing ? "extend" : "start",
|
action: result.extended ? "extend" : "start",
|
||||||
targetType,
|
targetType,
|
||||||
targetId,
|
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) => {
|
maintenanceRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq, gt } from "drizzle-orm";
|
import { eq, gt, lt } from "drizzle-orm";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { dnsProviders, integrations, maintenanceWindows, servers } from "../db/schema.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;
|
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. */
|
/** 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> {
|
export async function describeTarget(targetType: ActiveWindow["targetType"], targetId: number): Promise<{ name: string; kind: string } | null> {
|
||||||
if (targetType === "server") {
|
if (targetType === "server") {
|
||||||
|
|||||||
@@ -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 {
|
export interface MonitorSummary {
|
||||||
total: number;
|
total: number;
|
||||||
up: number;
|
up: number;
|
||||||
|
|||||||
@@ -629,6 +629,12 @@ export interface UptimeKumaMonitorsResponse {
|
|||||||
summary: { total: number; up: number; down: number; pending: number; maintenance: number; unknown: number };
|
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 =
|
export type SemaphoreTaskStatus =
|
||||||
| "waiting"
|
| "waiting"
|
||||||
| "starting"
|
| "starting"
|
||||||
@@ -835,6 +841,8 @@ export const api = {
|
|||||||
start: (data: { targetType: MaintenanceTargetType; targetId: number; minutes: number; reason?: string }) =>
|
start: (data: { targetType: MaintenanceTargetType; targetId: number; minutes: number; reason?: string }) =>
|
||||||
request<{ window: MaintenanceWindow }>("/api/maintenance", { method: "POST", body: JSON.stringify(data) }),
|
request<{ window: MaintenanceWindow }>("/api/maintenance", { method: "POST", body: JSON.stringify(data) }),
|
||||||
end: (id: number) => request<void>(`/api/maintenance/${id}`, { method: "DELETE" }),
|
end: (id: number) => request<void>(`/api/maintenance/${id}`, { method: "DELETE" }),
|
||||||
|
importUptimeKuma: (data: { integrationId: number; minutes: number }) =>
|
||||||
|
request<UptimeKumaImportResult>("/api/maintenance/import/uptimekuma", { method: "POST", body: JSON.stringify(data) }),
|
||||||
},
|
},
|
||||||
sessions: {
|
sessions: {
|
||||||
list: () => request<{ sessions: SessionSummary[]; currentSessionId: string }>("/api/sessions"),
|
list: () => request<{ sessions: SessionSummary[]; currentSessionId: string }>("/api/sessions"),
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { useEffect, useState } from "react";
|
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 { formatDateTime } from "../utils/date";
|
||||||
import { formatRemaining } from "../utils/duration";
|
import { formatRemaining } from "../utils/duration";
|
||||||
import { readableError } from "../utils/errors";
|
import { readableError } from "../utils/errors";
|
||||||
@@ -31,6 +39,25 @@ export default function Maintenance({ user }: { user: CurrentUser }) {
|
|||||||
const [reason, setReason] = useState("");
|
const [reason, setReason] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const [kumaIntegrations, setKumaIntegrations] = useState<IntegrationSummary[] | null>(null);
|
||||||
|
const [kumaIntegrationId, setKumaIntegrationId] = useState<number | "">("");
|
||||||
|
const [kumaMinutes, setKumaMinutes] = useState(60);
|
||||||
|
const [kumaBusy, setKumaBusy] = useState(false);
|
||||||
|
const [kumaError, setKumaError] = useState<string | null>(null);
|
||||||
|
const [kumaResult, setKumaResult] = useState<UptimeKumaImportResult | null>(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() {
|
function load() {
|
||||||
api.maintenance
|
api.maintenance
|
||||||
.list()
|
.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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<h2 className="page-title mb-3">Maintenance</h2>
|
<h2 className="page-title mb-3">Maintenance</h2>
|
||||||
@@ -161,6 +208,89 @@ export default function Maintenance({ user }: { user: CurrentUser }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canEdit && kumaIntegrations && kumaIntegrations.length > 0 && (
|
||||||
|
<div className="card mb-3">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Import from Uptime Kuma</h3>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={importFromUptimeKuma}>
|
||||||
|
<div className="card-body row g-3">
|
||||||
|
<div className="text-secondary">
|
||||||
|
Uptime Kuma only ever tells us what's in maintenance <em>right now</em>, 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.
|
||||||
|
</div>
|
||||||
|
{kumaIntegrations.length > 1 && (
|
||||||
|
<div className="col-md-5">
|
||||||
|
<label className="form-label">Uptime Kuma integration</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={kumaIntegrationId}
|
||||||
|
onChange={(e) => setKumaIntegrationId(e.target.value ? Number(e.target.value) : "")}
|
||||||
|
>
|
||||||
|
{kumaIntegrations.map((i) => (
|
||||||
|
<option key={i.id} value={i.id} disabled={!i.enabled}>
|
||||||
|
{i.name}
|
||||||
|
{!i.enabled ? " — disabled" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="col-md-3">
|
||||||
|
<label className="form-label">For</label>
|
||||||
|
<select className="form-select" value={kumaMinutes} onChange={(e) => setKumaMinutes(Number(e.target.value))}>
|
||||||
|
{DURATIONS.map((d) => (
|
||||||
|
<option key={d.minutes} value={d.minutes}>
|
||||||
|
{d.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{kumaError && (
|
||||||
|
<div className="card-body pt-0">
|
||||||
|
<div className="alert alert-danger mb-0">{kumaError}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{kumaResult && (
|
||||||
|
<div className="card-body pt-0">
|
||||||
|
{kumaResult.monitorsInMaintenance === 0 ? (
|
||||||
|
<div className="text-secondary">Nothing is currently in maintenance in Uptime Kuma.</div>
|
||||||
|
) : (
|
||||||
|
<div className="alert alert-success mb-0">
|
||||||
|
{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 && (
|
||||||
|
<div className="mt-1">
|
||||||
|
{kumaResult.unmatched.length} monitor{kumaResult.unmatched.length === 1 ? "" : "s"} in maintenance
|
||||||
|
{" "}couldn't be matched to a server: {kumaResult.unmatched.join(", ")}.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="card-footer">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={kumaBusy || kumaIntegrationId === ""}>
|
||||||
|
{kumaBusy ? "Importing…" : "Import"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="card mb-3">
|
<div className="card mb-3">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h3 className="card-title">Active</h3>
|
<h3 className="card-title">Active</h3>
|
||||||
|
|||||||
Reference in new issue
Block a user