diff --git a/INTEGRATIONS.md b/INTEGRATIONS.md index f9b4327..8d3575e 100644 --- a/INTEGRATIONS.md +++ b/INTEGRATIONS.md @@ -57,6 +57,24 @@ the dashboard views themselves load fine. - **Actions used:** list environments/containers, check for image updates, start/stop/restart a container - **Required access:** A Dockhand API token belonging to a user with access to every environment (Docker host) you want visible, with permission to **start/stop/restart containers and trigger update checks** in each — not just view them. +### Uptime Kuma + +- **Config fields:** Uptime Kuma URL, username (leave blank — see below), API key +- **Auth:** HTTP Basic, with the API key as the password and the username left empty. Uptime Kuma has no + conventional REST API — the dashboard talks to it over Socket.IO — so this integration reads its + Prometheus-metrics endpoint (`GET /metrics`) instead and parses that. On installs from before the API-key + feature existed (Uptime Kuma < 1.23), that endpoint instead checks your real dashboard login, so put your + Uptime Kuma username and password in those two fields rather than leaving the username blank. +- **Required access:** In Uptime Kuma, go to Settings → API Keys → **Add API Key**, and paste the value it + shows you (once — it isn't shown again) into this integration's "API key" field. No other permission is + needed; the metrics endpoint is read-only. +- **What you get:** every monitor's status (up/down/pending/maintenance), response time, uptime over 24 + hours/30 days, and certificate days remaining where applicable. A monitor is linked to one of your servers + when its target — an IP, or a TCP/HTTP hostname — matches that server's own address or hostname; monitors + 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. + ## DNS providers ### Cloudflare diff --git a/README.md b/README.md index 5c6ba05..c001079 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Repository: `git@10.200.5.13:bobban/Homelab-manager.git` ([gitea.labsconnect.se/bobban/Homelab-manager](https://gitea.labsconnect.se/bobban/Homelab-manager) externally). A single dashboard for a homelab: Proxmox, Synology DSM, Semaphore, Tailscale, -Gitea, and Dockhand/Docker status and basic actions, plus DNS record +Gitea, Dockhand/Docker, and Uptime Kuma status and basic actions, plus DNS record management, an IP address inventory (IPAM), and a secret-expiry tracker (ported from [Sloth Manager](../Sloth%20manager)) and scheduled-task tracking across Debian/Raspbian hosts (ported from @@ -35,9 +35,11 @@ All modules from the original plan are built: The Domains widget shows how many registrations are tracked, which are expired or expiring (soonest first), the next one to expire, and how many couldn't be refreshed. + The Uptime Kuma widget shows the monitor count, how many are down, and how + many are matched to one of your servers. - **Diagnostic Log** (admin-only) — every call this app makes to a DNS provider or integration (Tailscale, Proxmox, Synology, Semaphore, Gitea, - Dockhand), success or failure, with latency and the error message if it + Dockhand, Uptime Kuma), success or failure, with latency and the error message if it failed — the last 500 calls, filterable by source/result, for troubleshooting connectivity issues (ported from Sloth Manager's provider-diagnostics log, generalized to cover every integration this app @@ -77,8 +79,8 @@ All modules from the original plan are built: link options") — it stays visible regardless once a server actually is linked, so unlinking is always reachable. - **Tailscale**, **Proxmox**, **Synology**, **Semaphore**, **Gitea**, - and **Docker** each get their own top-level page (backed by the - matching integration) instead of living inside a shared Integrations + **Docker**, and **Uptime Kuma** each get their own top-level page (backed + by the matching integration) instead of living inside a shared Integrations browsing view: - **Tailscale** — device list with online/authorized status, and authorize/deauthorize/remove actions; a live device-count widget. @@ -102,11 +104,20 @@ All modules from the original plan are built: container (from Dockhand's own cached update check, plus a button to trigger a fresh one), and a live running/total widget (with an updates-available count). + - **Uptime Kuma** — every monitor's status (up/down/pending/maintenance), + response time, and 24h/30d uptime, with certificate days remaining where + applicable. Each monitor is matched to one of your servers when its + target (an IP, or a TCP/HTTP hostname) lines up with that server's own + address or hostname, linking straight to it — so you can see what's + actually being watched on each box, not just a flat monitor list. + Uptime Kuma has no conventional REST API (the dashboard talks to it over + Socket.IO), so this reads its Prometheus `/metrics` endpoint instead and + parses that itself; read-only, no actions. The Integrations page itself is now just a list of configured integrations (name/type/status, visible to every role) with an admin-only "Add integration" button and edit/enable/disable/delete - actions per row — the six dedicated pages above are where you + actions per row — the seven dedicated pages above are where you actually use each one. - Every table in the app is click-to-sort on any column (numbers, booleans, and dates/text sort correctly regardless of how the column formats them) @@ -142,7 +153,9 @@ assumed HTTPS-only (the NAS is reached over plain HTTP), and the Tailscale adapter read `online`/`isExitNode` fields that don't actually exist in the real API response (fixed to derive them from `connectedToControl` and `enabledRoutes`). See the git log for the full verification notes per -integration. +integration. (Uptime Kuma, added later, is not part of that "six" — see +its own git log entry for what was and wasn't verified against a real +instance.) Server and storage health is watched every 15 minutes: a server whose agent stops reporting, a server disk / Proxmox storage / Synology volume passing a diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index e07e95f..5b47282 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -286,6 +286,7 @@ export const integrationTypes = [ "tailscale", "gitea", "dockhand", + "uptimekuma", ] as const; export type IntegrationType = (typeof integrationTypes)[number]; diff --git a/server/src/integrations/fieldSchemas.ts b/server/src/integrations/fieldSchemas.ts index f166ea1..964cd4f 100644 --- a/server/src/integrations/fieldSchemas.ts +++ b/server/src/integrations/fieldSchemas.ts @@ -39,6 +39,17 @@ export const INTEGRATION_FIELDS: Partial f.type !== "checkbox") + .filter((f) => f.type !== "checkbox" && !f.optional) .filter((f) => merged[f.key] === undefined || merged[f.key] === "") .map((f) => f.key); } diff --git a/server/src/integrations/registry.ts b/server/src/integrations/registry.ts index 64b4527..a58586e 100644 --- a/server/src/integrations/registry.ts +++ b/server/src/integrations/registry.ts @@ -6,6 +6,7 @@ import { createDockhandAdapter } from "./dockhand/adapter.js"; import { createSemaphoreAdapter } from "./semaphore/adapter.js"; import { createProxmoxAdapter } from "./proxmox/adapter.js"; import { createSynologyAdapter } from "./synology/adapter.js"; +import { createUptimeKumaAdapter } from "./uptimekuma/adapter.js"; export interface PingableAdapter { ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; @@ -32,6 +33,8 @@ export function createIntegrationAdapter(type: IntegrationType, config: Integrat return createProxmoxAdapter(config as any); case "synology": return createSynologyAdapter(config as any); + case "uptimekuma": + return createUptimeKumaAdapter(config as any); default: throw new Error(`Integration type "${type}" is not implemented yet`); } diff --git a/server/src/integrations/types.ts b/server/src/integrations/types.ts index f4150d6..3cf8cf7 100644 --- a/server/src/integrations/types.ts +++ b/server/src/integrations/types.ts @@ -4,6 +4,8 @@ export interface IntegrationField { secret: boolean; type?: "text" | "password" | "checkbox"; placeholder?: string; + /** Not required to save the integration (e.g. a username that's normally left blank in favor of an API key). */ + optional?: boolean; } export type IntegrationConfig = Record; diff --git a/server/src/integrations/uptimekuma/adapter.ts b/server/src/integrations/uptimekuma/adapter.ts new file mode 100644 index 0000000..e1fa67c --- /dev/null +++ b/server/src/integrations/uptimekuma/adapter.ts @@ -0,0 +1,221 @@ +/** + * Uptime Kuma adapter. + * Requires config: url, password (an API key, or — on installs older than the API-key + * feature — the account password); username is optional and normally left blank. + * + * Uptime Kuma has no conventional REST API (the dashboard talks to it over Socket.IO). + * The one machine-readable endpoint that lists every monitor is its Prometheus exporter + * at GET /metrics, gated by HTTP Basic auth — empty username + an API key as the + * password once one exists, or the real login username/password on older installs + * (https://github.com/louislam/uptime-kuma/wiki/Prometheus-API-Keys). This adapter reads + * that endpoint and parses the Prometheus text-exposition format itself; there is no + * JSON alternative. + * + * Verified against the documented metric/label set (server/prometheus.js upstream): + * gauges monitor_status (1=up, 0=down, 2=pending, 3=maintenance), monitor_response_time + * (ms), monitor_cert_days_remaining, monitor_uptime_ratio{window="1d"|"30d"|"365d"}, each + * carrying labels monitor_id, monitor_name, monitor_type, monitor_url, monitor_hostname, + * monitor_port (plus the monitor's own tags, which this adapter doesn't try to separate + * out from the fixed labels, since tag label *names* are user-defined and not reliably + * distinguishable from any other label Uptime Kuma might add later). + */ +import { withDiagLogging } from "../../services/diagLog.js"; + +export interface UptimeKumaConfig { + url: string; + username?: string; + password: string; +} + +export type MonitorStatus = "up" | "down" | "pending" | "maintenance" | "unknown"; + +const STATUS_BY_CODE: Record = { 0: "down", 1: "up", 2: "pending", 3: "maintenance" }; + +export interface UptimeKumaMonitor { + id: string; + name: string; + type: string; + /** The host Uptime Kuma actually checks — a bare hostname/IP for TCP-style monitors, or the host part of the URL for HTTP/keyword ones. Null for types with no single network target (group, push, docker, ...). */ + target: string | null; + port: number | null; + status: MonitorStatus; + responseTimeMs: number | null; + certDaysRemaining: number | null; + /** Percent, 0–100. */ + uptime24h: number | null; + uptime30d: number | null; + uptime1y: number | null; +} + +export interface UptimeKumaAdapter { + ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; + listMonitors(): Promise; +} + +// ─── Prometheus text-exposition parsing (pure) ───────────────────────────── + +export interface PromSample { + metric: string; + labels: Record; + value: number; +} + +const SAMPLE_LINE = /^([a-zA-Z_:][a-zA-Z0-9_:]*)(\{(.*)\})?\s+(\S+)\s*$/; +// key="value" pairs; the value may contain an escaped quote (\") or backslash (\\), per the exposition format. +const LABEL_PAIR = /([a-zA-Z_][a-zA-Z0-9_]*)="((?:[^"\\]|\\.)*)"/g; + +function unescapeLabelValue(raw: string): string { + return raw.replace(/\\n/g, "\n").replace(/\\"/g, '"').replace(/\\\\/g, "\\"); +} + +/** Parses Prometheus's plain-text exposition format into flat samples. Comment (#) and blank lines are skipped; a line that doesn't parse as a sample is skipped rather than failing the whole scrape — one odd line from a future Uptime Kuma version shouldn't blank the page. */ +export function parsePrometheusText(text: string): PromSample[] { + const samples: PromSample[] = []; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const m = SAMPLE_LINE.exec(trimmed); + if (!m) continue; + const value = Number(m[4]); + if (!Number.isFinite(value)) continue; + const labels: Record = {}; + if (m[3]) { + LABEL_PAIR.lastIndex = 0; + let lm: RegExpExecArray | null; + while ((lm = LABEL_PAIR.exec(m[3]))) labels[lm[1]] = unescapeLabelValue(lm[2]); + } + samples.push({ metric: m[1], labels, value }); + } + return samples; +} + +/** Groups flat samples into one row per monitor_id, reading whichever of the known metrics are present for it. */ +export function monitorsFromSamples(samples: PromSample[]): UptimeKumaMonitor[] { + interface Acc { + name: string; + type: string; + url: string; + hostname: string; + port: string; + status: MonitorStatus; + responseTimeMs: number | null; + certDaysRemaining: number | null; + uptime: Partial>; + } + const byId = new Map(); + const get = (id: string, labels: Record) => { + let acc = byId.get(id); + if (!acc) { + acc = { + name: labels.monitor_name ?? id, + type: labels.monitor_type ?? "unknown", + url: labels.monitor_url ?? "", + hostname: labels.monitor_hostname ?? "", + port: labels.monitor_port ?? "", + status: "unknown", + responseTimeMs: null, + certDaysRemaining: null, + uptime: {}, + }; + byId.set(id, acc); + } + return acc; + }; + + for (const s of samples) { + const id = s.labels.monitor_id; + if (!id) continue; + const acc = get(id, s.labels); + switch (s.metric) { + case "monitor_status": + acc.status = STATUS_BY_CODE[s.value] ?? "unknown"; + break; + case "monitor_response_time": + acc.responseTimeMs = s.value; + break; + case "monitor_cert_days_remaining": + acc.certDaysRemaining = s.value; + break; + case "monitor_uptime_ratio": { + const window = s.labels.window; + if (window === "1d" || window === "30d" || window === "365d") acc.uptime[window] = s.value; + break; + } + default: + break; + } + } + + const target = (acc: Acc): { target: string | null; port: number | null } => { + if (acc.hostname) { + const port = Number(acc.port); + return { target: acc.hostname, port: Number.isFinite(port) && port > 0 ? port : null }; + } + if (acc.url) { + try { + const u = new URL(acc.url); + const port = u.port ? Number(u.port) : null; + return { target: u.hostname, port }; + } catch { + return { target: null, port: null }; + } + } + return { target: null, port: null }; + }; + const pct = (v: number | undefined): number | null => (v === undefined ? null : Math.round(v * 1000) / 10); + + return [...byId.entries()] + .map(([id, acc]) => { + const { target: t, port } = target(acc); + return { + id, + name: acc.name, + type: acc.type, + target: t, + port, + status: acc.status, + responseTimeMs: acc.responseTimeMs, + certDaysRemaining: acc.certDaysRemaining, + uptime24h: pct(acc.uptime["1d"]), + uptime30d: pct(acc.uptime["30d"]), + uptime1y: pct(acc.uptime["365d"]), + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +// ─── HTTP ─────────────────────────────────────────────────────────────────── + +export function createUptimeKumaAdapter(config: UptimeKumaConfig): UptimeKumaAdapter { + function base() { + return config.url.replace(/\/$/, ""); + } + + async function fetchMetrics(): Promise { + const auth = Buffer.from(`${config.username ?? ""}:${config.password}`).toString("base64"); + const res = await fetch(`${base()}/metrics`, { headers: { Authorization: `Basic ${auth}` } }); + if (res.status === 401) { + throw new Error("Uptime Kuma rejected the credentials — check the API key (or username/password) and try again."); + } + if (!res.ok) { + throw new Error(`Uptime Kuma API error: HTTP ${res.status}`); + } + return res.text(); + } + + async function listMonitors(): Promise { + return monitorsFromSamples(parsePrometheusText(await fetchMetrics())); + } + + async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> { + const start = Date.now(); + try { + await fetchMetrics(); + return { ok: true, latencyMs: Date.now() - start }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + + return withDiagLogging("uptimekuma", { ping, listMonitors }); +} diff --git a/server/src/routes/integrations.ts b/server/src/routes/integrations.ts index 2e2e086..5c13d29 100644 --- a/server/src/routes/integrations.ts +++ b/server/src/routes/integrations.ts @@ -2,7 +2,7 @@ import { Router, type Request, type Response } from "express"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { db } from "../db/client.js"; -import { integrations, integrationCredentials, integrationTypes } from "../db/schema.js"; +import { integrations, integrationCredentials, integrationTypes, servers } from "../db/schema.js"; import { requireAuth, requireRole } from "../auth/middleware.js"; import { recordAudit } from "../services/audit.js"; import { encryptSecret } from "../crypto.js"; @@ -20,6 +20,8 @@ import { createDockhandAdapter } from "../integrations/dockhand/adapter.js"; 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 { asyncHandler } from "../utils/asyncHandler.js"; export const integrationsRouter = Router(); @@ -769,3 +771,48 @@ integrationsRouter.get("/:id/synology/system", asyncHandler(async (req, res) => res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); } })); + +// ─── Uptime Kuma ───────────────────────────────────────────────────────────── + +async function requireUptimeKumaAdapter(req: Request, res: Response) { + const id = Number(req.params.id); + const loaded = await loadIntegrationConfig(id); + if (!loaded) { + res.status(404).json({ error: "not_found" }); + return null; + } + if (loaded.integration.type !== "uptimekuma") { + res.status(400).json({ error: "wrong_type" }); + return null; + } + if (!loaded.integration.enabled) { + res.status(400).json({ error: "integration_disabled" }); + return null; + } + 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; + + 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); + 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/services/uptimeKumaMatch.ts b/server/src/services/uptimeKumaMatch.ts new file mode 100644 index 0000000..9082c1a --- /dev/null +++ b/server/src/services/uptimeKumaMatch.ts @@ -0,0 +1,63 @@ +import * as net from "node:net"; +import type { UptimeKumaMonitor } from "../integrations/uptimekuma/adapter.js"; + +/** The columns of a server row this needs, so it can be tested without a live database. */ +export interface MatchableServer { + id: number; + name: string; + hostname: string | null; + ips: string[]; +} + +const clean = (s: string) => s.trim().toLowerCase().replace(/\.$/, ""); +const firstLabel = (s: string) => clean(s).split(".")[0]; + +/** + * Which of these servers a monitor's target host belongs to — an IP is matched against + * each server's reported addresses; anything else is matched as a hostname, against the + * server's own hostname or its short name (so "elsa" and "elsa.lab.example" both work). + * Returns null rather than guessing when nothing lines up. + */ +export function matchServerForTarget(target: string | null, servers: MatchableServer[]): MatchableServer | null { + if (!target) return null; + const t = target.trim(); + if (!t) return null; + + if (net.isIP(t) !== 0) { + const ip = t.toLowerCase(); + return servers.find((s) => s.ips.some((a) => a.toLowerCase() === ip)) ?? null; + } + const name = clean(t); + const short = firstLabel(t); + return ( + servers.find((s) => s.hostname && clean(s.hostname) === name) ?? + servers.find((s) => clean(s.name) === short) ?? + null + ); +} + +export interface MonitorWithServer extends UptimeKumaMonitor { + matchedServer: { id: number; name: string } | null; +} + +export function attachMatchedServers(monitors: UptimeKumaMonitor[], servers: MatchableServer[]): MonitorWithServer[] { + return monitors.map((m) => { + const match = matchServerForTarget(m.target, servers); + return { ...m, matchedServer: match ? { id: match.id, name: match.name } : null }; + }); +} + +export interface MonitorSummary { + total: number; + up: number; + down: number; + pending: number; + maintenance: number; + unknown: number; +} + +export function summarizeMonitors(monitors: UptimeKumaMonitor[]): MonitorSummary { + const summary: MonitorSummary = { total: monitors.length, up: 0, down: 0, pending: 0, maintenance: 0, unknown: 0 }; + for (const m of monitors) summary[m.status]++; + return summary; +} diff --git a/web/src/App.tsx b/web/src/App.tsx index de639dd..78830bd 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -19,6 +19,7 @@ import Semaphore from "./pages/Semaphore"; import Gitea from "./pages/Gitea"; import Proxmox from "./pages/Proxmox"; import Synology from "./pages/Synology"; +import UptimeKuma from "./pages/UptimeKuma"; import Generator from "./pages/Generator"; import Maintenance from "./pages/Maintenance"; import Domains from "./pages/Domains"; @@ -103,6 +104,7 @@ export default function App() { } /> } /> } /> + } /> + request(`/api/integrations/${integrationId}/uptimekuma/monitors`), + }, semaphore: { templates: (integrationId: number) => request(`/api/integrations/${integrationId}/semaphore/templates`), diff --git a/web/src/components/CommandPalette.tsx b/web/src/components/CommandPalette.tsx index 1bda77f..2579cd4 100644 --- a/web/src/components/CommandPalette.tsx +++ b/web/src/components/CommandPalette.tsx @@ -17,6 +17,7 @@ const INTEGRATION_TYPE_LABELS: Record = { semaphore: "Semaphore", gitea: "Gitea", dockhand: "Dockhand", + uptimekuma: "Uptime Kuma", }; const DNS_PROVIDER_LABELS: Record = { diff --git a/web/src/components/IntegrationEditForm.tsx b/web/src/components/IntegrationEditForm.tsx index 8b9c270..d2986b2 100644 --- a/web/src/components/IntegrationEditForm.tsx +++ b/web/src/components/IntegrationEditForm.tsx @@ -8,6 +8,7 @@ const TYPE_LABELS: Record = { semaphore: "Semaphore", gitea: "Gitea", dockhand: "Dockhand", + uptimekuma: "Uptime Kuma", }; export default function IntegrationEditForm({ @@ -132,7 +133,7 @@ export default function IntegrationEditForm({ type={field.type === "password" ? "password" : "text"} className="form-control" placeholder={field.secret ? "Leave blank to keep the current value" : field.placeholder} - required={!field.secret} + required={!field.secret && !field.optional} value={(values[field.key] as string) ?? ""} onChange={(e) => setField(field.key, e.target.value)} /> diff --git a/web/src/components/IntegrationForm.tsx b/web/src/components/IntegrationForm.tsx index a28c30d..227fe4d 100644 --- a/web/src/components/IntegrationForm.tsx +++ b/web/src/components/IntegrationForm.tsx @@ -8,6 +8,7 @@ const TYPE_LABELS: Record = { semaphore: "Semaphore", gitea: "Gitea", dockhand: "Dockhand", + uptimekuma: "Uptime Kuma", }; export default function IntegrationForm({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) { @@ -135,7 +136,7 @@ export default function IntegrationForm({ onCreated, onCancel }: { onCreated: () type={field.type === "password" ? "password" : "text"} className="form-control" placeholder={field.placeholder} - required + required={!field.optional} value={(values[field.key] as string) ?? ""} onChange={(e) => setField(field.key, e.target.value)} /> diff --git a/web/src/layout/AppShell.tsx b/web/src/layout/AppShell.tsx index 6bf2ee5..ada0504 100644 --- a/web/src/layout/AppShell.tsx +++ b/web/src/layout/AppShell.tsx @@ -27,6 +27,7 @@ import { IconListCheck, IconShieldLock, IconRefresh, + IconActivityHeartbeat, } from "@tabler/icons-react"; import { api, type CurrentUser, type MaintenanceWindow } from "../api/client"; import { formatRemaining } from "../utils/duration"; @@ -66,6 +67,7 @@ const NAV: NavEntry[] = [ { to: "/synology", label: "Synology", icon: }, { to: "/docker", label: "Docker", icon: }, { to: "/tailscale", label: "Tailscale", icon: }, + { to: "/uptime-kuma", label: "Uptime Kuma", icon: }, ], }, { diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 99f554e..f8d1644 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -51,6 +51,14 @@ const SERVER_STATE_COLORS: Record = { "No data": "#94a3b8", }; +const MONITOR_STATE_COLORS: Record = { + Up: "#4ade80", + Down: "#f87171", + Pending: "#fbbf24", + Maintenance: "#60a5fa", + Unknown: "#94a3b8", +}; + const CONTAINER_STATE_COLORS: Record = { running: "#4ade80", exited: "#f87171", @@ -193,8 +201,17 @@ const WIDGETS: { type: IntegrationType; label: string }[] = [ { type: "tailscale", label: "Tailscale" }, { type: "gitea", label: "Gitea" }, { type: "dockhand", label: "Dockhand / Docker" }, + { type: "uptimekuma", label: "Uptime Kuma" }, ]; +interface UptimeKumaSummary { + up: number; + down: number; + total: number; + matched: number; + statusBreakdown: { label: string; count: number }[]; +} + interface GiteaSummary { repoCount: number; privateCount: number; @@ -259,6 +276,7 @@ export default function Dashboard({ user }: { user: CurrentUser }) { const [semaphoreSummary, setSemaphoreSummary] = useState(null); const [proxmoxSummary, setProxmoxSummary] = useState(null); const [synologySummary, setSynologySummary] = useState(null); + const [uptimeKumaSummary, setUptimeKumaSummary] = useState(null); const [dnsStats, setDnsStats] = useState> | null>(null); const [dnsError, setDnsError] = useState(null); @@ -421,6 +439,27 @@ export default function Dashboard({ user }: { user: CurrentUser }) { .catch(() => setSynologySummary(null)); }, [integrations]); + useEffect(() => { + const uptimeKuma = integrations?.find((i) => i.type === "uptimekuma" && i.enabled); + if (!uptimeKuma) { + setUptimeKumaSummary(null); + return; + } + api.integrations.uptimekuma + .monitors(uptimeKuma.id) + .then((res) => { + const labelFor = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + setUptimeKumaSummary({ + up: res.summary.up, + down: res.summary.down, + total: res.summary.total, + matched: res.monitors.filter((m) => m.matchedServer).length, + statusBreakdown: breakdownFrom(res.monitors, (m) => labelFor(m.status)), + }); + }) + .catch(() => setUptimeKumaSummary(null)); + }, [integrations]); + const domains = domainList?.domains ?? []; const expiredDomains = domains.filter((d) => d.status === "expired"); const expiringDomains = domains.filter((d) => d.status === "expiring"); @@ -725,7 +764,7 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
{WIDGETS.map(({ type, label }) => { @@ -736,7 +775,9 @@ export default function Dashboard({ user }: { user: CurrentUser }) { const isLiveSemaphore = type === "semaphore" && integration && semaphoreSummary; const isLiveProxmox = type === "proxmox" && integration && proxmoxSummary; const isLiveSynology = type === "synology" && integration && synologySummary; - const isLive = isLiveTailscale || isLiveGitea || isLiveDockhand || isLiveSemaphore || isLiveProxmox || isLiveSynology; + const isLiveUptimeKuma = type === "uptimekuma" && integration && uptimeKumaSummary; + const isLive = + isLiveTailscale || isLiveGitea || isLiveDockhand || isLiveSemaphore || isLiveProxmox || isLiveSynology || isLiveUptimeKuma; return ( )} + ) : isLiveUptimeKuma ? ( + <> +
+
+ +
+
+ 0 ? "#ef4444" : undefined} /> +
+
+ +
+
+ {uptimeKumaSummary!.statusBreakdown.length > 0 && ( + + )} + ) : (
{integration ? ( diff --git a/web/src/pages/Integrations.tsx b/web/src/pages/Integrations.tsx index 1ced03f..23d6b23 100644 --- a/web/src/pages/Integrations.tsx +++ b/web/src/pages/Integrations.tsx @@ -14,6 +14,7 @@ const TYPE_LABELS: Record = { semaphore: "Semaphore", gitea: "Gitea", dockhand: "Dockhand", + uptimekuma: "Uptime Kuma", }; function typeBadgeStyle(colors: Record, type: IntegrationType): CSSProperties { @@ -101,8 +102,8 @@ export default function Integrations({ user }: { user: CurrentUser }) {

Integrations

- Tailscale, Proxmox, Synology, Semaphore, Gitea, and Dockhand each get their own page once - connected — add and manage credentials here. + Tailscale, Proxmox, Synology, Semaphore, Gitea, Dockhand, and Uptime Kuma each get their own page + once connected — add and manage credentials here.
{isAdmin && !adding && !editingIntegration && ( diff --git a/web/src/pages/UptimeKuma.tsx b/web/src/pages/UptimeKuma.tsx new file mode 100644 index 0000000..2583a71 --- /dev/null +++ b/web/src/pages/UptimeKuma.tsx @@ -0,0 +1,218 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { api, type CurrentUser, type IntegrationSummary, type UptimeKumaMonitor, type UptimeKumaMonitorsResponse } from "../api/client"; +import { useSortable } from "../hooks/useSortable"; +import SortableTh from "../components/SortableTh"; +import { usePagination } from "../hooks/usePagination"; +import Pagination from "../components/Pagination"; +import { downloadCsv } from "../utils/csv"; +import { readableError } from "../utils/errors"; + +function statusBadge(monitor: UptimeKumaMonitor) { + switch (monitor.status) { + case "up": + return Up; + case "down": + return Down; + case "pending": + return Pending; + case "maintenance": + return Maintenance; + default: + return Unknown; + } +} + +function formatPercent(v: number | null): string { + return v === null ? "—" : `${v}%`; +} + +function formatMs(v: number | null): string { + if (v === null || v < 0) return "—"; + return `${Math.round(v)} ms`; +} + +export default function UptimeKuma({ user: _user }: { user: CurrentUser }) { + const [integrations, setIntegrations] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [error, setError] = useState(null); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + api.integrations + .list() + .then((res) => { + const kumaIntegrations = res.integrations.filter((i) => i.type === "uptimekuma"); + setIntegrations(kumaIntegrations); + if (!selectedId && kumaIntegrations.length > 0) setSelectedId(kumaIntegrations[0].id); + }) + .catch((err) => setError(readableError(err))); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const selected = integrations?.find((i) => i.id === selectedId) ?? null; + + function loadMonitors(id: number) { + setLoading(true); + setError(null); + api.integrations.uptimekuma + .monitors(id) + .then(setData) + .catch((err) => setError(readableError(err))) + .finally(() => setLoading(false)); + } + + useEffect(() => { + if (selected?.enabled) { + loadMonitors(selected.id); + } else { + setData(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedId]); + + const { sorted, sortKey, sortDir, requestSort } = useSortable(data?.monitors, "name"); + const { pageItems, page, setPage, pageCount, totalCount } = usePagination(sorted); + + function exportCsv() { + if (!sorted) return; + downloadCsv( + "uptime-kuma-monitors.csv", + ["Monitor", "Type", "Target", "Status", "Response (ms)", "Uptime 24h", "Uptime 30d", "Cert days left", "Server"], + sorted.map((m) => [ + m.name, + m.type, + m.target ? `${m.target}${m.port ? `:${m.port}` : ""}` : "", + m.status, + m.responseTimeMs !== null && m.responseTimeMs >= 0 ? Math.round(m.responseTimeMs) : "", + m.uptime24h ?? "", + m.uptime30d ?? "", + m.certDaysRemaining ?? "", + m.matchedServer?.name ?? "", + ]), + ); + } + + return ( + <> +

Uptime Kuma

+ {error &&
{error}
} + + {integrations?.length === 0 ? ( +
+
+ No Uptime Kuma integration configured yet. Add one under Integrations. +
+
+ ) : ( + <> + {integrations && integrations.length > 1 && ( +
+ +
+ )} + + {selected && !selected.enabled ? ( +
+
+ "{selected.name}" is disabled. Enable it under Integrations → Manage integrations to see its monitors. +
+
+ ) : ( +
+
+

+ Monitors + {data && data.summary.down > 0 && {data.summary.down} down} + {data && data.summary.down === 0 && data.summary.pending > 0 && ( + {data.summary.pending} pending + )} +

+
+ + +
+
+
+ + + + label="Monitor" sortKeyName="name" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + label="Target" sortKeyName="target" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + + label="Status" sortKeyName="status" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + + label="Response" + sortKeyName="responseTimeMs" + activeKey={sortKey} + direction={sortDir} + onSort={requestSort} + /> + label="Uptime 24h" sortKeyName="uptime24h" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + label="Uptime 30d" sortKeyName="uptime30d" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + + + + {pageItems?.map((m) => ( + + + + + + + + + + ))} + {sorted?.length === 0 && ( + + + + )} + +
Server
+ {m.name} +
{m.type}
+
+ {m.target ? `${m.target}${m.port ? `:${m.port}` : ""}` : "—"} + {m.certDaysRemaining !== null && ( +
+ cert: {m.certDaysRemaining}d left +
+ )} +
+ {m.matchedServer ? ( + {m.matchedServer.name} + ) : ( + — + )} + {statusBadge(m)}{formatMs(m.responseTimeMs)}{formatPercent(m.uptime24h)}{formatPercent(m.uptime30d)}
+ No monitors reported by this Uptime Kuma instance. +
+
+ +
+ "Server" links a monitor to one of your servers when its target (an IP, or a TCP/HTTP hostname) matches a + server's own address or hostname — monitors of other kinds (groups, pushes, keyword/DNS checks without a + plain host) are left unmatched rather than guessed at. +
+
+ )} + + )} + + ); +} diff --git a/web/src/pages/settings/BadgeSettings.tsx b/web/src/pages/settings/BadgeSettings.tsx index 58dd917..003e762 100644 --- a/web/src/pages/settings/BadgeSettings.tsx +++ b/web/src/pages/settings/BadgeSettings.tsx @@ -17,6 +17,7 @@ const INTEGRATION_NAMES: Record = { semaphore: "Semaphore", gitea: "Gitea", dockhand: "Dockhand", + uptimekuma: "Uptime Kuma", }; function ColorList({