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.
This commit is contained in:
Claude 2026-06-21 11:00:47 +00:00
parent 4674f4e209
commit bbb26dab0d
No known key found for this signature in database
4 changed files with 198 additions and 5 deletions

View file

@ -21,6 +21,7 @@
"fastify": "^5.2.1", "fastify": "^5.2.1",
"guacamole-lite": "^1.2.0", "guacamole-lite": "^1.2.0",
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
"socket.io-client": "^4.8.3",
"ssh2": "^1.17.0", "ssh2": "^1.17.0",
"undici": "^8.5.0", "undici": "^8.5.0",
"zod": "^3.24.1" "zod": "^3.24.1"
@ -1234,6 +1235,12 @@
"node": ">=14.0.0" "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": { "node_modules/@types/bcryptjs": {
"version": "2.4.6", "version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
@ -1530,6 +1537,23 @@
"node": ">=10.0.0" "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": { "node_modules/decompress-response": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@ -1614,6 +1638,28 @@
"once": "^1.4.0" "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": { "node_modules/esbuild": {
"version": "0.28.1", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
@ -2074,6 +2120,12 @@
"obliterator": "^2.0.4" "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": { "node_modules/nan": {
"version": "2.27.0", "version": "2.27.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz",
@ -2458,6 +2510,34 @@
"simple-concat": "^1.0.0" "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": { "node_modules/sonic-boom": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
@ -2721,6 +2801,14 @@
"node": ">=16.0.0" "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": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View file

@ -22,6 +22,7 @@
"fastify": "^5.2.1", "fastify": "^5.2.1",
"guacamole-lite": "^1.2.0", "guacamole-lite": "^1.2.0",
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
"socket.io-client": "^4.8.3",
"ssh2": "^1.17.0", "ssh2": "^1.17.0",
"undici": "^8.5.0", "undici": "^8.5.0",
"zod": "^3.24.1" "zod": "^3.24.1"

View file

@ -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<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 = { export const uptimeKuma: IntegrationAdapter = {
async testConnection(config) { async testConnection(config, secrets) {
const baseUrl = config.baseUrl?.replace(/\/$/, '') const baseUrl = config.baseUrl?.replace(/\/$/, '')
const username = secrets.username
const password = secrets.password
if (!baseUrl) return { ok: false, message: 'Missing baseUrl' } if (!baseUrl) return { ok: false, message: 'Missing baseUrl' }
if (!username || !password) return { ok: false, message: 'Missing username/password' }
try { try {
const res = await fetch(`${baseUrl}/api/status-page/heartbeat/default`) const socket = await connectAndLogin(baseUrl, username, password)
if (!res.ok) return { ok: false, message: `HTTP ${res.status}` } socket.disconnect()
return { ok: true, message: 'Connected' } return { ok: true, message: 'Connected' }
} catch (err) { } catch (err) {
return { ok: false, message: err instanceof Error ? err.message : 'Connection failed' } 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()
}
},
} }

View file

@ -77,7 +77,8 @@ const integrationTypeDefs: { type: string; name: string; multiInstance?: boolean
] }, ] },
{ type: 'uptime_kuma', name: 'Uptime Kuma', fields: [ { type: 'uptime_kuma', name: 'Uptime Kuma', fields: [
{ key: 'baseUrl', label: 'URL', placeholder: 'https://uptime.example.com' }, { 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: 'weather', name: 'Weather API', fields: [{ key: 'location', label: 'Location' }, { key: 'units', label: 'Units' }] },
{ type: 'remote_desktop', name: 'Remote Desktop', multiInstance: true, fields: [ { type: 'remote_desktop', name: 'Remote Desktop', multiInstance: true, fields: [