Add an Uptime Kuma integration: monitor status and which server each one watches
New integration, following the existing pattern: config in-app (URL + API key, credentials encrypted at rest), its own Uptime Kuma page, an Integrations list entry, a Dashboard widget, and diagnostic-log/ integration-down-alert coverage for free via the shared withDiagLogging wrapper. Read-only -- no start/stop equivalent exists for a monitor. Uptime Kuma has no conventional REST API (the dashboard talks to it over Socket.IO); researched before writing any code, since guessing wrong here would have cost real time. The one machine-readable, authenticated endpoint that lists every monitor is its Prometheus exporter at GET /metrics, gated by HTTP Basic auth -- an API key as the password with the username left blank on current installs, or the real dashboard login on installs from before the API-key feature existed. This adapter authenticates the same way and parses that endpoint's text-exposition format itself (metrics: monitor_status, monitor_response_time, monitor_cert_days_remaining, monitor_uptime_ratio; labels: monitor_id, monitor_name, monitor_type, monitor_url, monitor_hostname, monitor_port), verified against the documented metric/label set and the actual upstream source (server/prometheus.js). A malformed line is skipped rather than failing the whole scrape. "What server is being monitored for what": each monitor's target (an IP for TCP checks, or the hostname out of the URL for HTTP/keyword checks) is matched against your servers' own IPs and hostnames -- reusing the same kind of match already used in the consistency report -- and linked to that server's page. Monitors with no single network target (groups, push monitors, DNS/keyword checks with a complex URL) are left unmatched rather than guessed at. Uptime Kuma's tags aren't read, since the Prometheus endpoint doesn't reliably distinguish a tag label from any other label it might add later. The username field is the first genuinely optional integration config field this app has had; IntegrationField gained an `optional` flag (server validation and both the add/edit web forms honor it) rather than special-casing Uptime Kuma. Verified with 48 backend checks (Prometheus text parsing including escaped quotes, decimals, negative numbers, and malformed lines; TCP vs. HTTP target/port extraction; every documented status code; server matching by IP, hostname, and short name, including no-match cases; the route's real HTTP round trip against a fake Uptime Kuma server, wrong credentials, upstream failures, roles, wrong/disabled/missing integration, diagnostic-log entries; the optional-field validation rule) and by driving the real page and the real Dashboard widget in a browser against the real routers, including CSV export and column sorting. Real dev database mtime untouched. Not verified: a real Uptime Kuma instance. Everything here was checked against Uptime Kuma's documented metric format, its actual upstream source, and a fake server built to match both -- not against a live installation. If your instance's /metrics output differs from what's documented (older version, unusual monitor types), the parser should degrade to an empty or partial monitor list rather than error, but that degradation itself hasn't been observed against the real thing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ada2e648e9
commit
1a2dd19736
19 files changed
+706
-15
No files matched your search
@@ -57,6 +57,24 @@ the dashboard views themselves load fine.
|
|||||||
- **Actions used:** list environments/containers, check for image updates, start/stop/restart a container
|
- **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.
|
- **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
|
## DNS providers
|
||||||
|
|
||||||
### Cloudflare
|
### Cloudflare
|
||||||
|
|||||||
@@ -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).
|
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,
|
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
|
management, an IP address inventory (IPAM), and a secret-expiry tracker
|
||||||
(ported from [Sloth Manager](../Sloth%20manager)) and scheduled-task tracking
|
(ported from [Sloth Manager](../Sloth%20manager)) and scheduled-task tracking
|
||||||
across Debian/Raspbian hosts (ported from
|
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
|
The Domains widget shows how many registrations are tracked, which are
|
||||||
expired or expiring (soonest first), the next one to expire, and how many
|
expired or expiring (soonest first), the next one to expire, and how many
|
||||||
couldn't be refreshed.
|
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
|
- **Diagnostic Log** (admin-only) — every call this app makes to a DNS
|
||||||
provider or integration (Tailscale, Proxmox, Synology, Semaphore, Gitea,
|
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
|
failed — the last 500 calls, filterable by source/result, for
|
||||||
troubleshooting connectivity issues (ported from Sloth Manager's
|
troubleshooting connectivity issues (ported from Sloth Manager's
|
||||||
provider-diagnostics log, generalized to cover every integration this app
|
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
|
link options") — it stays visible regardless once a server actually is
|
||||||
linked, so unlinking is always reachable.
|
linked, so unlinking is always reachable.
|
||||||
- **Tailscale**, **Proxmox**, **Synology**, **Semaphore**, **Gitea**,
|
- **Tailscale**, **Proxmox**, **Synology**, **Semaphore**, **Gitea**,
|
||||||
and **Docker** each get their own top-level page (backed by the
|
**Docker**, and **Uptime Kuma** each get their own top-level page (backed
|
||||||
matching integration) instead of living inside a shared Integrations
|
by the matching integration) instead of living inside a shared Integrations
|
||||||
browsing view:
|
browsing view:
|
||||||
- **Tailscale** — device list with online/authorized status, and
|
- **Tailscale** — device list with online/authorized status, and
|
||||||
authorize/deauthorize/remove actions; a live device-count widget.
|
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
|
container (from Dockhand's own cached update check, plus a button
|
||||||
to trigger a fresh one), and a live running/total widget (with an
|
to trigger a fresh one), and a live running/total widget (with an
|
||||||
updates-available count).
|
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
|
The Integrations page itself is now just a list of configured
|
||||||
integrations (name/type/status, visible to every role) with an
|
integrations (name/type/status, visible to every role) with an
|
||||||
admin-only "Add integration" button and edit/enable/disable/delete
|
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.
|
actually use each one.
|
||||||
- Every table in the app is click-to-sort on any column (numbers, booleans,
|
- 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)
|
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
|
adapter read `online`/`isExitNode` fields that don't actually exist in the
|
||||||
real API response (fixed to derive them from `connectedToControl` and
|
real API response (fixed to derive them from `connectedToControl` and
|
||||||
`enabledRoutes`). See the git log for the full verification notes per
|
`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
|
Server and storage health is watched every 15 minutes: a server whose agent
|
||||||
stops reporting, a server disk / Proxmox storage / Synology volume passing a
|
stops reporting, a server disk / Proxmox storage / Synology volume passing a
|
||||||
|
|||||||
@@ -286,6 +286,7 @@ export const integrationTypes = [
|
|||||||
"tailscale",
|
"tailscale",
|
||||||
"gitea",
|
"gitea",
|
||||||
"dockhand",
|
"dockhand",
|
||||||
|
"uptimekuma",
|
||||||
] as const;
|
] as const;
|
||||||
export type IntegrationType = (typeof integrationTypes)[number];
|
export type IntegrationType = (typeof integrationTypes)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,17 @@ export const INTEGRATION_FIELDS: Partial<Record<IntegrationType, IntegrationFiel
|
|||||||
{ key: "password", label: "Password", secret: true, type: "password" },
|
{ key: "password", label: "Password", secret: true, type: "password" },
|
||||||
{ key: "insecure", label: "Allow self-signed certificate", secret: false, type: "checkbox" },
|
{ key: "insecure", label: "Allow self-signed certificate", secret: false, type: "checkbox" },
|
||||||
],
|
],
|
||||||
|
uptimekuma: [
|
||||||
|
{ key: "url", label: "Uptime Kuma URL", secret: false, placeholder: "https://kuma.example.lan" },
|
||||||
|
{
|
||||||
|
key: "username",
|
||||||
|
label: "Username (leave blank when using an API key)",
|
||||||
|
secret: false,
|
||||||
|
optional: true,
|
||||||
|
placeholder: "only needed on very old installs without API keys",
|
||||||
|
},
|
||||||
|
{ key: "password", label: "API key (or password, on old installs)", secret: true, type: "password" },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Fixed base URL per integration type, stored on the row for display/reference. */
|
/** Fixed base URL per integration type, stored on the row for display/reference. */
|
||||||
@@ -81,7 +92,7 @@ export function validateIntegrationConfig(
|
|||||||
): string[] {
|
): string[] {
|
||||||
const fields = INTEGRATION_FIELDS[type] ?? [];
|
const fields = INTEGRATION_FIELDS[type] ?? [];
|
||||||
return fields
|
return fields
|
||||||
.filter((f) => f.type !== "checkbox")
|
.filter((f) => f.type !== "checkbox" && !f.optional)
|
||||||
.filter((f) => merged[f.key] === undefined || merged[f.key] === "")
|
.filter((f) => merged[f.key] === undefined || merged[f.key] === "")
|
||||||
.map((f) => f.key);
|
.map((f) => f.key);
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import { createDockhandAdapter } from "./dockhand/adapter.js";
|
|||||||
import { createSemaphoreAdapter } from "./semaphore/adapter.js";
|
import { createSemaphoreAdapter } from "./semaphore/adapter.js";
|
||||||
import { createProxmoxAdapter } from "./proxmox/adapter.js";
|
import { createProxmoxAdapter } from "./proxmox/adapter.js";
|
||||||
import { createSynologyAdapter } from "./synology/adapter.js";
|
import { createSynologyAdapter } from "./synology/adapter.js";
|
||||||
|
import { createUptimeKumaAdapter } from "./uptimekuma/adapter.js";
|
||||||
|
|
||||||
export interface PingableAdapter {
|
export interface PingableAdapter {
|
||||||
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
|
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
|
||||||
@@ -32,6 +33,8 @@ export function createIntegrationAdapter(type: IntegrationType, config: Integrat
|
|||||||
return createProxmoxAdapter(config as any);
|
return createProxmoxAdapter(config as any);
|
||||||
case "synology":
|
case "synology":
|
||||||
return createSynologyAdapter(config as any);
|
return createSynologyAdapter(config as any);
|
||||||
|
case "uptimekuma":
|
||||||
|
return createUptimeKumaAdapter(config as any);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Integration type "${type}" is not implemented yet`);
|
throw new Error(`Integration type "${type}" is not implemented yet`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export interface IntegrationField {
|
|||||||
secret: boolean;
|
secret: boolean;
|
||||||
type?: "text" | "password" | "checkbox";
|
type?: "text" | "password" | "checkbox";
|
||||||
placeholder?: string;
|
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<string, string | boolean | undefined>;
|
export type IntegrationConfig = Record<string, string | boolean | undefined>;
|
||||||
@@ -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<number, MonitorStatus> = { 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<UptimeKumaMonitor[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Prometheus text-exposition parsing (pure) ─────────────────────────────
|
||||||
|
|
||||||
|
export interface PromSample {
|
||||||
|
metric: string;
|
||||||
|
labels: Record<string, string>;
|
||||||
|
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<string, string> = {};
|
||||||
|
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<Record<"1d" | "30d" | "365d", number>>;
|
||||||
|
}
|
||||||
|
const byId = new Map<string, Acc>();
|
||||||
|
const get = (id: string, labels: Record<string, string>) => {
|
||||||
|
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<string> {
|
||||||
|
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<UptimeKumaMonitor[]> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { Router, type Request, type Response } from "express";
|
|||||||
import { eq } 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 { integrations, integrationCredentials, integrationTypes } from "../db/schema.js";
|
import { integrations, integrationCredentials, integrationTypes, 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 { encryptSecret } from "../crypto.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 { 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 { attachMatchedServers, summarizeMonitors, type MatchableServer } from "../services/uptimeKumaMatch.js";
|
||||||
import { asyncHandler } from "../utils/asyncHandler.js";
|
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||||
|
|
||||||
export const integrationsRouter = Router();
|
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) });
|
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) });
|
||||||
|
}
|
||||||
|
}));
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import Semaphore from "./pages/Semaphore";
|
|||||||
import Gitea from "./pages/Gitea";
|
import Gitea from "./pages/Gitea";
|
||||||
import Proxmox from "./pages/Proxmox";
|
import Proxmox from "./pages/Proxmox";
|
||||||
import Synology from "./pages/Synology";
|
import Synology from "./pages/Synology";
|
||||||
|
import UptimeKuma from "./pages/UptimeKuma";
|
||||||
import Generator from "./pages/Generator";
|
import Generator from "./pages/Generator";
|
||||||
import Maintenance from "./pages/Maintenance";
|
import Maintenance from "./pages/Maintenance";
|
||||||
import Domains from "./pages/Domains";
|
import Domains from "./pages/Domains";
|
||||||
@@ -103,6 +104,7 @@ export default function App() {
|
|||||||
<Route path="/gitea" element={<Gitea user={user} />} />
|
<Route path="/gitea" element={<Gitea user={user} />} />
|
||||||
<Route path="/proxmox" element={<Proxmox user={user} />} />
|
<Route path="/proxmox" element={<Proxmox user={user} />} />
|
||||||
<Route path="/synology" element={<Synology user={user} />} />
|
<Route path="/synology" element={<Synology user={user} />} />
|
||||||
|
<Route path="/uptime-kuma" element={<UptimeKuma user={user} />} />
|
||||||
<Route
|
<Route
|
||||||
path="/users"
|
path="/users"
|
||||||
element={
|
element={
|
||||||
|
|||||||
+28
-1
@@ -428,7 +428,7 @@ export interface ManualTaskInput {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IntegrationType = "proxmox" | "synology" | "semaphore" | "tailscale" | "gitea" | "dockhand";
|
export type IntegrationType = "proxmox" | "synology" | "semaphore" | "tailscale" | "gitea" | "dockhand" | "uptimekuma";
|
||||||
|
|
||||||
export interface IntegrationField {
|
export interface IntegrationField {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -436,6 +436,7 @@ export interface IntegrationField {
|
|||||||
secret: boolean;
|
secret: boolean;
|
||||||
type?: "text" | "password" | "checkbox";
|
type?: "text" | "password" | "checkbox";
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
optional?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IntegrationSummary {
|
export interface IntegrationSummary {
|
||||||
@@ -606,6 +607,28 @@ export interface DockhandContainersResponse {
|
|||||||
summary: { total: number; running: number };
|
summary: { total: number; running: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UptimeKumaMonitorStatus = "up" | "down" | "pending" | "maintenance" | "unknown";
|
||||||
|
|
||||||
|
export interface UptimeKumaMonitor {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
target: string | null;
|
||||||
|
port: number | null;
|
||||||
|
status: UptimeKumaMonitorStatus;
|
||||||
|
responseTimeMs: number | null;
|
||||||
|
certDaysRemaining: number | null;
|
||||||
|
uptime24h: number | null;
|
||||||
|
uptime30d: number | null;
|
||||||
|
uptime1y: number | null;
|
||||||
|
matchedServer: { id: number; name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UptimeKumaMonitorsResponse {
|
||||||
|
monitors: UptimeKumaMonitor[];
|
||||||
|
summary: { total: number; up: number; down: number; pending: number; maintenance: number; unknown: number };
|
||||||
|
}
|
||||||
|
|
||||||
export type SemaphoreTaskStatus =
|
export type SemaphoreTaskStatus =
|
||||||
| "waiting"
|
| "waiting"
|
||||||
| "starting"
|
| "starting"
|
||||||
@@ -1079,6 +1102,10 @@ export const api = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
uptimekuma: {
|
||||||
|
monitors: (integrationId: number) =>
|
||||||
|
request<UptimeKumaMonitorsResponse>(`/api/integrations/${integrationId}/uptimekuma/monitors`),
|
||||||
|
},
|
||||||
semaphore: {
|
semaphore: {
|
||||||
templates: (integrationId: number) =>
|
templates: (integrationId: number) =>
|
||||||
request<SemaphoreTemplatesResponse>(`/api/integrations/${integrationId}/semaphore/templates`),
|
request<SemaphoreTemplatesResponse>(`/api/integrations/${integrationId}/semaphore/templates`),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const INTEGRATION_TYPE_LABELS: Record<IntegrationType, string> = {
|
|||||||
semaphore: "Semaphore",
|
semaphore: "Semaphore",
|
||||||
gitea: "Gitea",
|
gitea: "Gitea",
|
||||||
dockhand: "Dockhand",
|
dockhand: "Dockhand",
|
||||||
|
uptimekuma: "Uptime Kuma",
|
||||||
};
|
};
|
||||||
|
|
||||||
const DNS_PROVIDER_LABELS: Record<DnsProviderType, string> = {
|
const DNS_PROVIDER_LABELS: Record<DnsProviderType, string> = {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const TYPE_LABELS: Record<IntegrationType, string> = {
|
|||||||
semaphore: "Semaphore",
|
semaphore: "Semaphore",
|
||||||
gitea: "Gitea",
|
gitea: "Gitea",
|
||||||
dockhand: "Dockhand",
|
dockhand: "Dockhand",
|
||||||
|
uptimekuma: "Uptime Kuma",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function IntegrationEditForm({
|
export default function IntegrationEditForm({
|
||||||
@@ -132,7 +133,7 @@ export default function IntegrationEditForm({
|
|||||||
type={field.type === "password" ? "password" : "text"}
|
type={field.type === "password" ? "password" : "text"}
|
||||||
className="form-control"
|
className="form-control"
|
||||||
placeholder={field.secret ? "Leave blank to keep the current value" : field.placeholder}
|
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) ?? ""}
|
value={(values[field.key] as string) ?? ""}
|
||||||
onChange={(e) => setField(field.key, e.target.value)}
|
onChange={(e) => setField(field.key, e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const TYPE_LABELS: Record<IntegrationType, string> = {
|
|||||||
semaphore: "Semaphore",
|
semaphore: "Semaphore",
|
||||||
gitea: "Gitea",
|
gitea: "Gitea",
|
||||||
dockhand: "Dockhand",
|
dockhand: "Dockhand",
|
||||||
|
uptimekuma: "Uptime Kuma",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function IntegrationForm({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) {
|
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"}
|
type={field.type === "password" ? "password" : "text"}
|
||||||
className="form-control"
|
className="form-control"
|
||||||
placeholder={field.placeholder}
|
placeholder={field.placeholder}
|
||||||
required
|
required={!field.optional}
|
||||||
value={(values[field.key] as string) ?? ""}
|
value={(values[field.key] as string) ?? ""}
|
||||||
onChange={(e) => setField(field.key, e.target.value)}
|
onChange={(e) => setField(field.key, e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
IconListCheck,
|
IconListCheck,
|
||||||
IconShieldLock,
|
IconShieldLock,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
|
IconActivityHeartbeat,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { api, type CurrentUser, type MaintenanceWindow } from "../api/client";
|
import { api, type CurrentUser, type MaintenanceWindow } from "../api/client";
|
||||||
import { formatRemaining } from "../utils/duration";
|
import { formatRemaining } from "../utils/duration";
|
||||||
@@ -66,6 +67,7 @@ const NAV: NavEntry[] = [
|
|||||||
{ to: "/synology", label: "Synology", icon: <IconDatabase size={20} /> },
|
{ to: "/synology", label: "Synology", icon: <IconDatabase size={20} /> },
|
||||||
{ to: "/docker", label: "Docker", icon: <IconBrandDocker size={20} /> },
|
{ to: "/docker", label: "Docker", icon: <IconBrandDocker size={20} /> },
|
||||||
{ to: "/tailscale", label: "Tailscale", icon: <IconAffiliate size={20} /> },
|
{ to: "/tailscale", label: "Tailscale", icon: <IconAffiliate size={20} /> },
|
||||||
|
{ to: "/uptime-kuma", label: "Uptime Kuma", icon: <IconActivityHeartbeat size={20} /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ const SERVER_STATE_COLORS: Record<string, string> = {
|
|||||||
"No data": "#94a3b8",
|
"No data": "#94a3b8",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MONITOR_STATE_COLORS: Record<string, string> = {
|
||||||
|
Up: "#4ade80",
|
||||||
|
Down: "#f87171",
|
||||||
|
Pending: "#fbbf24",
|
||||||
|
Maintenance: "#60a5fa",
|
||||||
|
Unknown: "#94a3b8",
|
||||||
|
};
|
||||||
|
|
||||||
const CONTAINER_STATE_COLORS: Record<string, string> = {
|
const CONTAINER_STATE_COLORS: Record<string, string> = {
|
||||||
running: "#4ade80",
|
running: "#4ade80",
|
||||||
exited: "#f87171",
|
exited: "#f87171",
|
||||||
@@ -193,8 +201,17 @@ const WIDGETS: { type: IntegrationType; label: string }[] = [
|
|||||||
{ type: "tailscale", label: "Tailscale" },
|
{ type: "tailscale", label: "Tailscale" },
|
||||||
{ type: "gitea", label: "Gitea" },
|
{ type: "gitea", label: "Gitea" },
|
||||||
{ type: "dockhand", label: "Dockhand / Docker" },
|
{ 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 {
|
interface GiteaSummary {
|
||||||
repoCount: number;
|
repoCount: number;
|
||||||
privateCount: number;
|
privateCount: number;
|
||||||
@@ -259,6 +276,7 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
|||||||
const [semaphoreSummary, setSemaphoreSummary] = useState<SemaphoreSummary | null>(null);
|
const [semaphoreSummary, setSemaphoreSummary] = useState<SemaphoreSummary | null>(null);
|
||||||
const [proxmoxSummary, setProxmoxSummary] = useState<ProxmoxSummary | null>(null);
|
const [proxmoxSummary, setProxmoxSummary] = useState<ProxmoxSummary | null>(null);
|
||||||
const [synologySummary, setSynologySummary] = useState<SynologySummary | null>(null);
|
const [synologySummary, setSynologySummary] = useState<SynologySummary | null>(null);
|
||||||
|
const [uptimeKumaSummary, setUptimeKumaSummary] = useState<UptimeKumaSummary | null>(null);
|
||||||
|
|
||||||
const [dnsStats, setDnsStats] = useState<Awaited<ReturnType<typeof api.dns.stats>> | null>(null);
|
const [dnsStats, setDnsStats] = useState<Awaited<ReturnType<typeof api.dns.stats>> | null>(null);
|
||||||
const [dnsError, setDnsError] = useState<string | null>(null);
|
const [dnsError, setDnsError] = useState<string | null>(null);
|
||||||
@@ -421,6 +439,27 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
|||||||
.catch(() => setSynologySummary(null));
|
.catch(() => setSynologySummary(null));
|
||||||
}, [integrations]);
|
}, [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 domains = domainList?.domains ?? [];
|
||||||
const expiredDomains = domains.filter((d) => d.status === "expired");
|
const expiredDomains = domains.filter((d) => d.status === "expired");
|
||||||
const expiringDomains = domains.filter((d) => d.status === "expiring");
|
const expiringDomains = domains.filter((d) => d.status === "expiring");
|
||||||
@@ -725,7 +764,7 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
|||||||
|
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Integrations"
|
title="Integrations"
|
||||||
hint="Live status widgets for Proxmox, Synology, Semaphore, Tailscale, Gitea, and Dockhand will appear here as each integration is connected."
|
hint="Live status widgets for Proxmox, Synology, Semaphore, Tailscale, Gitea, Dockhand, and Uptime Kuma will appear here as each integration is connected."
|
||||||
/>
|
/>
|
||||||
<div className="row row-cards">
|
<div className="row row-cards">
|
||||||
{WIDGETS.map(({ type, label }) => {
|
{WIDGETS.map(({ type, label }) => {
|
||||||
@@ -736,7 +775,9 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
|||||||
const isLiveSemaphore = type === "semaphore" && integration && semaphoreSummary;
|
const isLiveSemaphore = type === "semaphore" && integration && semaphoreSummary;
|
||||||
const isLiveProxmox = type === "proxmox" && integration && proxmoxSummary;
|
const isLiveProxmox = type === "proxmox" && integration && proxmoxSummary;
|
||||||
const isLiveSynology = type === "synology" && integration && synologySummary;
|
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 (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
@@ -895,6 +936,23 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
) : isLiveUptimeKuma ? (
|
||||||
|
<>
|
||||||
|
<div className="row g-2 mb-3">
|
||||||
|
<div className="col-4">
|
||||||
|
<MiniStat label="Monitors" value={uptimeKumaSummary!.total} />
|
||||||
|
</div>
|
||||||
|
<div className="col-4">
|
||||||
|
<MiniStat label="Down" value={uptimeKumaSummary!.down} accent={uptimeKumaSummary!.down > 0 ? "#ef4444" : undefined} />
|
||||||
|
</div>
|
||||||
|
<div className="col-4">
|
||||||
|
<MiniStat label="Matched to a server" value={`${uptimeKumaSummary!.matched}/${uptimeKumaSummary!.total}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{uptimeKumaSummary!.statusBreakdown.length > 0 && (
|
||||||
|
<BreakdownBar data={uptimeKumaSummary!.statusBreakdown} colors={MONITOR_STATE_COLORS} compact />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-secondary mt-2">
|
<div className="text-secondary mt-2">
|
||||||
{integration ? (
|
{integration ? (
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const TYPE_LABELS: Record<IntegrationType, string> = {
|
|||||||
semaphore: "Semaphore",
|
semaphore: "Semaphore",
|
||||||
gitea: "Gitea",
|
gitea: "Gitea",
|
||||||
dockhand: "Dockhand",
|
dockhand: "Dockhand",
|
||||||
|
uptimekuma: "Uptime Kuma",
|
||||||
};
|
};
|
||||||
|
|
||||||
function typeBadgeStyle(colors: Record<string, string>, type: IntegrationType): CSSProperties {
|
function typeBadgeStyle(colors: Record<string, string>, type: IntegrationType): CSSProperties {
|
||||||
@@ -101,8 +102,8 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="page-title mb-0">Integrations</h2>
|
<h2 className="page-title mb-0">Integrations</h2>
|
||||||
<div className="text-secondary small mt-1">
|
<div className="text-secondary small mt-1">
|
||||||
Tailscale, Proxmox, Synology, Semaphore, Gitea, and Dockhand each get their own page once
|
Tailscale, Proxmox, Synology, Semaphore, Gitea, Dockhand, and Uptime Kuma each get their own page
|
||||||
connected — add and manage credentials here.
|
once connected — add and manage credentials here.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && !adding && !editingIntegration && (
|
{isAdmin && !adding && !editingIntegration && (
|
||||||
|
|||||||
@@ -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 <span className="badge bg-green-lt text-green">Up</span>;
|
||||||
|
case "down":
|
||||||
|
return <span className="badge bg-red-lt text-red">Down</span>;
|
||||||
|
case "pending":
|
||||||
|
return <span className="badge bg-yellow-lt text-yellow">Pending</span>;
|
||||||
|
case "maintenance":
|
||||||
|
return <span className="badge bg-blue-lt text-blue">Maintenance</span>;
|
||||||
|
default:
|
||||||
|
return <span className="badge bg-secondary-lt text-secondary">Unknown</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<IntegrationSummary[] | null>(null);
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [data, setData] = useState<UptimeKumaMonitorsResponse | null>(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 (
|
||||||
|
<>
|
||||||
|
<h2 className="page-title mb-3">Uptime Kuma</h2>
|
||||||
|
{error && <div className="alert alert-danger">{error}</div>}
|
||||||
|
|
||||||
|
{integrations?.length === 0 ? (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body text-secondary">
|
||||||
|
No Uptime Kuma integration configured yet. <Link to="/integrations">Add one under Integrations</Link>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{integrations && integrations.length > 1 && (
|
||||||
|
<div className="mb-3" style={{ maxWidth: 320 }}>
|
||||||
|
<select className="form-select" value={selectedId ?? ""} onChange={(e) => setSelectedId(Number(e.target.value))}>
|
||||||
|
{integrations.map((i) => (
|
||||||
|
<option key={i.id} value={i.id} disabled={!i.enabled}>
|
||||||
|
{i.name}
|
||||||
|
{!i.enabled ? " — disabled" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected && !selected.enabled ? (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body text-secondary">
|
||||||
|
"{selected.name}" is disabled. Enable it under Integrations → Manage integrations to see its monitors.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">
|
||||||
|
Monitors
|
||||||
|
{data && data.summary.down > 0 && <span className="text-red fw-normal ms-2">{data.summary.down} down</span>}
|
||||||
|
{data && data.summary.down === 0 && data.summary.pending > 0 && (
|
||||||
|
<span className="text-yellow fw-normal ms-2">{data.summary.pending} pending</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div className="card-actions">
|
||||||
|
<button className="btn btn-sm btn-outline-secondary" onClick={exportCsv} disabled={!sorted || sorted.length === 0}>
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-sm btn-outline-secondary" onClick={() => selected && loadMonitors(selected.id)} disabled={loading}>
|
||||||
|
{loading ? "Refreshing…" : "Refresh"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-vcenter card-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortableTh<UptimeKumaMonitor> label="Monitor" sortKeyName="name" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||||
|
<SortableTh<UptimeKumaMonitor> label="Target" sortKeyName="target" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||||
|
<th>Server</th>
|
||||||
|
<SortableTh<UptimeKumaMonitor> label="Status" sortKeyName="status" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||||
|
<SortableTh<UptimeKumaMonitor>
|
||||||
|
label="Response"
|
||||||
|
sortKeyName="responseTimeMs"
|
||||||
|
activeKey={sortKey}
|
||||||
|
direction={sortDir}
|
||||||
|
onSort={requestSort}
|
||||||
|
/>
|
||||||
|
<SortableTh<UptimeKumaMonitor> label="Uptime 24h" sortKeyName="uptime24h" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||||
|
<SortableTh<UptimeKumaMonitor> label="Uptime 30d" sortKeyName="uptime30d" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{pageItems?.map((m) => (
|
||||||
|
<tr key={m.id}>
|
||||||
|
<td>
|
||||||
|
{m.name}
|
||||||
|
<div className="text-secondary small">{m.type}</div>
|
||||||
|
</td>
|
||||||
|
<td className="text-secondary">
|
||||||
|
{m.target ? `${m.target}${m.port ? `:${m.port}` : ""}` : "—"}
|
||||||
|
{m.certDaysRemaining !== null && (
|
||||||
|
<div className={`small ${m.certDaysRemaining <= 14 ? "text-red" : "text-secondary"}`}>
|
||||||
|
cert: {m.certDaysRemaining}d left
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{m.matchedServer ? (
|
||||||
|
<Link to={`/servers/${m.matchedServer.id}`}>{m.matchedServer.name}</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{statusBadge(m)}</td>
|
||||||
|
<td className="text-secondary">{formatMs(m.responseTimeMs)}</td>
|
||||||
|
<td className="text-secondary">{formatPercent(m.uptime24h)}</td>
|
||||||
|
<td className="text-secondary">{formatPercent(m.uptime30d)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{sorted?.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="text-secondary text-center">
|
||||||
|
No monitors reported by this Uptime Kuma instance.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<Pagination page={page} pageCount={pageCount} totalCount={totalCount} onPageChange={setPage} />
|
||||||
|
<div className="card-footer text-secondary small">
|
||||||
|
"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.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ const INTEGRATION_NAMES: Record<string, string> = {
|
|||||||
semaphore: "Semaphore",
|
semaphore: "Semaphore",
|
||||||
gitea: "Gitea",
|
gitea: "Gitea",
|
||||||
dockhand: "Dockhand",
|
dockhand: "Dockhand",
|
||||||
|
uptimekuma: "Uptime Kuma",
|
||||||
};
|
};
|
||||||
|
|
||||||
function ColorList({
|
function ColorList({
|
||||||
|
|||||||
Reference in new issue
Block a user