dev_arc_aws/backend/src/integrations/uptimeKuma.ts
Claude bbb26dab0d
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.
2026-06-21 11:00:47 +00:00

118 lines
3.9 KiB
TypeScript

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<Socket> {
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<Resource[]> {
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<number, UptimeKumaMonitor>()
const lastHeartbeat = new Map<number, Heartbeat>()
socket.on('monitorList', (list: Record<string, UptimeKumaMonitor>) => {
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()
}
},
}