feat: CheckMK global IP-based integration with enable toggle
Replace per-device CheckMK URL field with a global, IP-based lookup. The sync job fetches all host configs from CheckMK once per cycle, matches each device by IP address, and updates its status accordingly. Devices not found in CheckMK are reset to 'unknown'. - Add checkmk_enabled / checkmk_api_user settings; toggle in Settings mirrors the Entra ID pattern (fields dim when disabled) - Sync job uses self-scheduling setTimeout so interval changes apply without a server restart; POST /api/checkmk/sync for manual triggers - Status changes and a per-cycle summary are written to the Logbook - Remove checkMkUrl from Device type, form, list view, and detail panel; status badge and CheckMK panel only render when CheckMK is enabled - Booking offline warning suppressed when CheckMK is disabled - Topology status dot color driven purely by device.status
This commit is contained in:
117
server.ts
117
server.ts
@ -154,6 +154,7 @@ async function startServer() {
|
||||
res.json({
|
||||
azureEnabled: enabled && Boolean(clientId) && Boolean(tenantId) && Boolean(secret),
|
||||
effectiveRedirectUri,
|
||||
checkmkEnabled: getSetting('checkmk_enabled') === 'true',
|
||||
});
|
||||
});
|
||||
|
||||
@ -164,7 +165,7 @@ async function startServer() {
|
||||
return res.redirect('/?auth_error=Azure+login+not+configured');
|
||||
}
|
||||
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`;
|
||||
const redirectUri = `${appUrl}/api/auth/azure/callback`;
|
||||
const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`;
|
||||
try {
|
||||
const authCodeUrl = await msalClient.getAuthCodeUrl({
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
@ -192,7 +193,7 @@ async function startServer() {
|
||||
return res.redirect('/?auth_error=Azure+login+not+configured');
|
||||
}
|
||||
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`;
|
||||
const redirectUri = `${appUrl}/api/auth/azure/callback`;
|
||||
const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`;
|
||||
try {
|
||||
const result = await msalClient.acquireTokenByCode({
|
||||
code: String(code),
|
||||
@ -240,7 +241,7 @@ async function startServer() {
|
||||
app.put('/api/settings', requireAuth, (req, res) => {
|
||||
try {
|
||||
const allowed = ['azure_enabled', 'azure_client_id', 'azure_tenant_id', 'azure_client_secret',
|
||||
'azure_redirect_uri', 'checkmk_api_url', 'checkmk_api_secret', 'checkmk_sync_interval_ms'];
|
||||
'azure_redirect_uri', 'checkmk_enabled', 'checkmk_api_url', 'checkmk_api_user', 'checkmk_api_secret', 'checkmk_sync_interval_ms'];
|
||||
const updates = req.body as Record<string, string>;
|
||||
for (const key of allowed) {
|
||||
if (key in updates && updates[key] !== '__SET__') {
|
||||
@ -314,16 +315,16 @@ async function startServer() {
|
||||
|
||||
app.post('/api/devices', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { hostname, ip, location, notes, type, status, emergencySheet, checkMkUrl, lastCheckedAt } = req.body;
|
||||
const { hostname, ip, location, notes, type, status, emergencySheet, lastCheckedAt } = req.body;
|
||||
if (!hostname || !ip || !type) {
|
||||
return res.status(400).json({ error: 'Missing required device specifications.' });
|
||||
}
|
||||
|
||||
const id = uid("dev");
|
||||
db.prepare(`
|
||||
INSERT INTO devices (id, hostname, ip, location, notes, type, status, emergencySheet, checkMkUrl, lastCheckedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, hostname, ip, location || '', notes || '', type, status || 'unknown', emergencySheet || '', checkMkUrl || '', lastCheckedAt || null);
|
||||
INSERT INTO devices (id, hostname, ip, location, notes, type, status, emergencySheet, lastCheckedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, hostname, ip, location || '', notes || '', type, status || 'unknown', emergencySheet || '', lastCheckedAt || null);
|
||||
|
||||
const logId = uid("log");
|
||||
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`)
|
||||
@ -341,12 +342,12 @@ async function startServer() {
|
||||
app.put('/api/devices/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
const id = req.params.id;
|
||||
const { hostname, ip, location, notes, type, status, emergencySheet, checkMkUrl, lastCheckedAt, operatorName } = req.body;
|
||||
const { hostname, ip, location, notes, type, status, emergencySheet, lastCheckedAt, operatorName } = req.body;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE devices SET hostname = ?, ip = ?, location = ?, notes = ?, type = ?, status = ?, emergencySheet = ?, checkMkUrl = ?, lastCheckedAt = ?
|
||||
UPDATE devices SET hostname = ?, ip = ?, location = ?, notes = ?, type = ?, status = ?, emergencySheet = ?, lastCheckedAt = ?
|
||||
WHERE id = ?
|
||||
`).run(hostname, ip, location, notes, type, status, emergencySheet, checkMkUrl ?? '', lastCheckedAt ?? null, id);
|
||||
`).run(hostname, ip, location, notes, type, status, emergencySheet, lastCheckedAt ?? null, id);
|
||||
|
||||
const logId = uid("log");
|
||||
const operatorText = operatorName ? `${operatorName} finished ` : 'Updated ';
|
||||
@ -679,46 +680,96 @@ async function startServer() {
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// CYCLIC CHECKMK STATUS SYNC
|
||||
// The device status shown in the UI is owned by CheckMK, not the app.
|
||||
// This job runs on an interval and reconciles each *linked* device's status
|
||||
// from the CheckMK REST API. The frontend additionally polls /api/devices,
|
||||
// so anything written here surfaces in the inventory & booking screens.
|
||||
// Until CHECKMK_API_URL/SECRET are configured, devices stay 'unknown' (and
|
||||
// therefore not bookable) - which is the intended safe default.
|
||||
// Looks up each device by IP address in CheckMK's host_config collection,
|
||||
// then fetches the monitoring state. Devices not found in CheckMK are reset
|
||||
// to 'unknown'. Runs on a self-scheduling setTimeout so that interval changes
|
||||
// in Settings take effect on the next cycle without a server restart.
|
||||
// -------------------------------------------------------------
|
||||
// Sync interval: DB setting takes precedence over env var
|
||||
const CHECKMK_SYNC_INTERVAL_MS = Number(getSetting('checkmk_sync_interval_ms') || process.env.CHECKMK_SYNC_INTERVAL_MS) || 60_000;
|
||||
|
||||
async function syncCheckMkStatuses() {
|
||||
// DB settings take precedence; fall back to env vars for backwards compatibility
|
||||
if (getSetting('checkmk_enabled') !== 'true') return;
|
||||
const CHECKMK_API_URL = getSetting('checkmk_api_url') || process.env.CHECKMK_API_URL;
|
||||
const CHECKMK_API_USER = getSetting('checkmk_api_user') || process.env.CHECKMK_API_USER || 'automation';
|
||||
const CHECKMK_API_SECRET = getSetting('checkmk_api_secret') || process.env.CHECKMK_API_SECRET;
|
||||
if (!CHECKMK_API_URL || !CHECKMK_API_SECRET) return; // not configured - leave statuses as 'unknown'
|
||||
const rows = db.prepare("SELECT id, hostname, checkMkUrl FROM devices WHERE checkMkUrl != ''")
|
||||
.all() as { id: string; hostname: string; checkMkUrl: string }[];
|
||||
if (!CHECKMK_API_URL || !CHECKMK_API_SECRET) return;
|
||||
|
||||
// Step 1: build IP → hostname map from CheckMK host configurations
|
||||
let ipToHostname: Map<string, string>;
|
||||
try {
|
||||
const cfgRes = await fetch(
|
||||
`${CHECKMK_API_URL}/domain-types/host_config/collections/all`,
|
||||
{ headers: { Authorization: `Bearer ${CHECKMK_API_USER} ${CHECKMK_API_SECRET}`, Accept: 'application/json' } }
|
||||
);
|
||||
if (!cfgRes.ok) throw new Error(`HTTP ${cfgRes.status}`);
|
||||
const cfgData = await cfgRes.json();
|
||||
ipToHostname = new Map<string, string>();
|
||||
for (const host of cfgData?.value ?? []) {
|
||||
const ip: string | undefined = host?.extensions?.attributes?.ipaddress;
|
||||
const name: string | undefined = host?.id;
|
||||
if (ip && name) ipToHostname.set(ip, name);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CheckMK] Failed to fetch host configs:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: update each device based on IP lookup and log status changes
|
||||
const rows = db.prepare('SELECT id, hostname, ip, status FROM devices').all() as { id: string; hostname: string; ip: string; status: string }[];
|
||||
const counts = { online: 0, offline: 0, unknown: 0 };
|
||||
const now = new Date().toISOString();
|
||||
|
||||
for (const dev of rows) {
|
||||
const cmkHost = ipToHostname.get(dev.ip);
|
||||
if (!cmkHost) {
|
||||
if (dev.status !== 'unknown') {
|
||||
db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ? WHERE id = ?').run('unknown', now, dev.id);
|
||||
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(uid('log'), now, 'status', `CheckMK: ${dev.hostname} (${dev.ip}) not found in monitoring — status set to unknown.`, dev.id);
|
||||
}
|
||||
counts.unknown++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const cmkHost = (() => {
|
||||
try { return new URL(dev.checkMkUrl).searchParams.get('host') ?? dev.hostname; }
|
||||
catch { return dev.hostname; }
|
||||
})();
|
||||
const res = await fetch(
|
||||
`${CHECKMK_API_URL}/objects/host/${encodeURIComponent(cmkHost)}`,
|
||||
{ headers: { Authorization: `Bearer automation ${CHECKMK_API_SECRET}`, Accept: 'application/json' } }
|
||||
{ headers: { Authorization: `Bearer ${CHECKMK_API_USER} ${CHECKMK_API_SECRET}`, Accept: 'application/json' } }
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const hardState: number = data?.extensions?.state ?? -1;
|
||||
const newStatus = hardState === 0 ? 'online' : 'offline';
|
||||
db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ? WHERE id = ?')
|
||||
.run(newStatus, new Date().toISOString(), dev.id);
|
||||
db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ? WHERE id = ?').run(newStatus, now, dev.id);
|
||||
if (dev.status !== newStatus) {
|
||||
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(uid('log'), now, 'status', `CheckMK: ${dev.hostname} (${dev.ip}) status changed to ${newStatus} (was: ${dev.status}).`, dev.id);
|
||||
}
|
||||
counts[newStatus as 'online' | 'offline']++;
|
||||
} catch (err) {
|
||||
console.error(`[CheckMK] Status sync failed for ${dev.hostname}:`, err);
|
||||
console.error(`[CheckMK] Status sync failed for ${dev.hostname} (${dev.ip}):`, err);
|
||||
counts.unknown++;
|
||||
}
|
||||
}
|
||||
|
||||
// Summary log entry for every sync run
|
||||
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
|
||||
.run(uid('log'), now, 'system',
|
||||
`CheckMK sync completed — ${counts.online} online, ${counts.offline} offline, ${counts.unknown} unknown (${rows.length} devices total).`);
|
||||
}
|
||||
setInterval(syncCheckMkStatuses, CHECKMK_SYNC_INTERVAL_MS);
|
||||
syncCheckMkStatuses();
|
||||
|
||||
async function scheduleSync() {
|
||||
await syncCheckMkStatuses();
|
||||
const ms = Number(getSetting('checkmk_sync_interval_ms')) || 60_000;
|
||||
setTimeout(scheduleSync, ms);
|
||||
}
|
||||
scheduleSync();
|
||||
|
||||
app.post('/api/checkmk/sync', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
await syncCheckMkStatuses();
|
||||
res.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`[Server] Core Server running at http://0.0.0.0:${PORT}`);
|
||||
|
||||
Reference in New Issue
Block a user