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, 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 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() } }, }