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: [