From bbb26dab0da976df1989b7473e144447ec83a3d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 11:00:47 +0000 Subject: [PATCH] Implement real Uptime Kuma monitor reporting via Socket.IO Uptime Kuma has no REST API for monitor data; connect over the same Socket.IO session the web UI uses (login, then read monitorList and heartbeat events) so connected monitors now surface as Resources. Switches the integration's credentials from an API key to username/password, matching what Uptime Kuma's session login expects. --- backend/package-lock.json | 88 ++++++++++++++++++++ backend/package.json | 1 + backend/src/integrations/uptimeKuma.ts | 111 ++++++++++++++++++++++++- src/pages/Settings.tsx | 3 +- 4 files changed, 198 insertions(+), 5 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 013cd5f..9d2c05f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -21,6 +21,7 @@ "fastify": "^5.2.1", "guacamole-lite": "^1.2.0", "node-pty": "^1.1.0", + "socket.io-client": "^4.8.3", "ssh2": "^1.17.0", "undici": "^8.5.0", "zod": "^3.24.1" @@ -1234,6 +1235,12 @@ "node": ">=14.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@types/bcryptjs": { "version": "2.4.6", "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", @@ -1530,6 +1537,23 @@ "node": ">=10.0.0" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -1614,6 +1638,28 @@ "once": "^1.4.0" } }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -2074,6 +2120,12 @@ "obliterator": "^2.0.4" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nan": { "version": "2.27.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", @@ -2458,6 +2510,34 @@ "simple-concat": "^1.0.0" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -2721,6 +2801,14 @@ "node": ">=16.0.0" } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 291db55..480760b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,6 +22,7 @@ "fastify": "^5.2.1", "guacamole-lite": "^1.2.0", "node-pty": "^1.1.0", + "socket.io-client": "^4.8.3", "ssh2": "^1.17.0", "undici": "^8.5.0", "zod": "^3.24.1" diff --git a/backend/src/integrations/uptimeKuma.ts b/backend/src/integrations/uptimeKuma.ts index 71e628d..f50be26 100644 --- a/backend/src/integrations/uptimeKuma.ts +++ b/backend/src/integrations/uptimeKuma.ts @@ -1,15 +1,118 @@ -import type { IntegrationAdapter } from './types.js' +import { io, type Socket } from 'socket.io-client' +import type { IntegrationAdapter, Resource } from './types.js' + +/** + * Uptime Kuma has no plain REST API for monitor data — the web UI talks to it + * over a Socket.IO session (login, then the server pushes `monitorList` and + * per-monitor `importantHeartbeatList` events). This connects the same way, + * waits briefly for those events, then disconnects. + */ +interface UptimeKumaMonitor { + id: number + name: string + active: boolean +} + +interface Heartbeat { + status: number // 0 = down, 1 = up, 2 = pending, 3 = maintenance + msg: string + time: string +} + +function connectAndLogin( + baseUrl: string, + username: string, + password: string, +): Promise { + return new Promise((resolve, reject) => { + const socket = io(baseUrl, { transports: ['websocket', 'polling'], reconnection: false, timeout: 10000 }) + + const timer = setTimeout(() => { + socket.disconnect() + reject(new Error('Timed out connecting to Uptime Kuma')) + }, 10000) + + socket.on('connect_error', (err) => { + clearTimeout(timer) + socket.disconnect() + reject(new Error(err.message || 'Connection failed')) + }) + + socket.on('connect', () => { + socket.emit('login', { username, password, token: '' }, (res: { ok: boolean; msg?: string }) => { + clearTimeout(timer) + if (!res.ok) { + socket.disconnect() + reject(new Error(res.msg || 'Login failed')) + return + } + resolve(socket) + }) + }) + }) +} export const uptimeKuma: IntegrationAdapter = { - async testConnection(config) { + async testConnection(config, secrets) { const baseUrl = config.baseUrl?.replace(/\/$/, '') + const username = secrets.username + const password = secrets.password if (!baseUrl) return { ok: false, message: 'Missing baseUrl' } + if (!username || !password) return { ok: false, message: 'Missing username/password' } try { - const res = await fetch(`${baseUrl}/api/status-page/heartbeat/default`) - if (!res.ok) return { ok: false, message: `HTTP ${res.status}` } + const socket = await connectAndLogin(baseUrl, username, password) + socket.disconnect() return { ok: true, message: 'Connected' } } catch (err) { return { ok: false, message: err instanceof Error ? err.message : 'Connection failed' } } }, + + async listResources(config, secrets): Promise { + const baseUrl = config.baseUrl?.replace(/\/$/, '') + const username = secrets.username + const password = secrets.password + if (!baseUrl || !username || !password) return [] + + const socket = await connectAndLogin(baseUrl, username, password) + try { + const monitors = new Map() + const lastHeartbeat = new Map() + + socket.on('monitorList', (list: Record) => { + for (const m of Object.values(list)) monitors.set(m.id, m) + }) + socket.on('importantHeartbeatList', (monitorId: number, beats: Heartbeat[]) => { + const latest = beats[beats.length - 1] + if (latest) lastHeartbeat.set(monitorId, latest) + }) + socket.on('heartbeat', (beat: Heartbeat & { monitorID: number }) => { + lastHeartbeat.set(beat.monitorID, beat) + }) + + // Server pushes these events asynchronously right after login — there's no + // single "done" signal, so give it a short window to arrive. + await new Promise((resolve) => setTimeout(resolve, 2500)) + + const resources: Resource[] = [] + for (const monitor of monitors.values()) { + if (!monitor.active) continue + const beat = lastHeartbeat.get(monitor.id) + const status: Resource['status'] = + beat === undefined + ? 'unknown' + : beat.status === 1 + ? 'healthy' + : beat.status === 0 + ? 'critical' + : beat.status === 2 + ? 'warning' + : 'unknown' + resources.push({ name: monitor.name, status, detail: beat?.msg || undefined }) + } + return resources + } finally { + socket.disconnect() + } + }, } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index c77e0d7..61c7ca5 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -77,7 +77,8 @@ const integrationTypeDefs: { type: string; name: string; multiInstance?: boolean ] }, { type: 'uptime_kuma', name: 'Uptime Kuma', fields: [ { key: 'baseUrl', label: 'URL', placeholder: 'https://uptime.example.com' }, - { key: 'apiKey', label: 'API Key', secret: true }, + { key: 'username', label: 'Username', secret: true }, + { key: 'password', label: 'Password', secret: true }, ] }, { type: 'weather', name: 'Weather API', fields: [{ key: 'location', label: 'Location' }, { key: 'units', label: 'Units' }] }, { type: 'remote_desktop', name: 'Remote Desktop', multiInstance: true, fields: [