From f12f92aea892f757c3fc7439ac52ad79f584f401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Br=C3=BCckner?= Date: Thu, 4 Jun 2026 14:07:54 +0200 Subject: [PATCH] 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 --- server-db.ts | 3 + server.ts | 117 +++++++++++++++++++++-------- src/App.tsx | 9 ++- src/components/BookingCalendar.tsx | 6 +- src/components/DeviceInventory.tsx | 75 ++++-------------- src/components/Settings.tsx | 98 ++++++++++++++++++++---- src/components/TopologyPanel.tsx | 1 - src/types.ts | 3 +- 8 files changed, 194 insertions(+), 118 deletions(-) diff --git a/server-db.ts b/server-db.ts index 80dcd7a..9fbb4a3 100644 --- a/server-db.ts +++ b/server-db.ts @@ -96,8 +96,11 @@ const _defaultSettings: [string, string][] = [ ['azure_client_id', ''], ['azure_tenant_id', ''], ['azure_client_secret', ''], + ['azure_redirect_uri', ''], ['azure_allowed_group', ''], + ['checkmk_enabled', 'false'], ['checkmk_api_url', ''], + ['checkmk_api_user', 'automation'], ['checkmk_api_secret', ''], ['checkmk_sync_interval_ms', '60000'], ]; diff --git a/server.ts b/server.ts index 422dc8d..8f8bdb6 100644 --- a/server.ts +++ b/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; 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; + 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(); + 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}`); diff --git a/src/App.tsx b/src/App.tsx index ce64c23..d5a927c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,6 +53,7 @@ export default function App() { const [remindedBookings, setRemindedBookings] = useState>(new Set()); const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState(null); + const [checkmkEnabled, setCheckmkEnabled] = useState(false); useEffect(() => { const root = document.documentElement; @@ -126,13 +127,14 @@ export default function App() { async function loadData() { setLoading(true); try { - const [usersRes, devicesRes, labsRes, bookingsRes, logsRes, linksRes] = await Promise.all([ + const [usersRes, devicesRes, labsRes, bookingsRes, logsRes, linksRes, configRes] = await Promise.all([ authFetch('/api/users'), authFetch('/api/devices'), authFetch('/api/labs'), authFetch('/api/bookings'), authFetch('/api/logs'), authFetch('/api/links'), + fetch('/api/auth/config'), ]); if (usersRes.ok) setUsers(await usersRes.json()); @@ -141,6 +143,7 @@ export default function App() { if (bookingsRes.ok) setBookings(await bookingsRes.json()); if (logsRes.ok) setLogs(await logsRes.json()); if (linksRes.ok) setLinks(await linksRes.json()); + if (configRes.ok) { const cfg = await configRes.json(); setCheckmkEnabled(!!cfg.checkmkEnabled); } } catch (err) { console.error('[App] Failed to load data:', err); } finally { @@ -542,7 +545,7 @@ export default function App() {
Active: {bookings.filter(b => b.status === 'active').length}
Upcoming: {bookings.filter(b => b.status === 'upcoming').length}
-
Online: {devices.filter(d => d.checkMkUrl && d.status === 'online').length}/{devices.length} devices
+
Online: {devices.filter(d => d.status === 'online').length}/{devices.length} devices
Labs: {labs.length} configured
@@ -571,6 +574,7 @@ export default function App() { labs={labs} devices={devices} currentUser={currentUser} + checkmkEnabled={checkmkEnabled} onAddBooking={handleAddBooking} onCancelBooking={handleCancelBooking} onDeleteBooking={handleDeleteBooking} @@ -580,6 +584,7 @@ export default function App() { {activeTab === 'devices' && ( ) => void; onCancelBooking: (id: string) => void; onDeleteBooking: (id: string) => void; @@ -72,6 +73,7 @@ export default function BookingCalendar({ labs, devices, currentUser, + checkmkEnabled, onAddBooking, onCancelBooking, onDeleteBooking, @@ -674,7 +676,7 @@ export default function BookingCalendar({ ); } - if (offline.length > 0) { + if (checkmkEnabled && offline.length > 0) { return (
diff --git a/src/components/DeviceInventory.tsx b/src/components/DeviceInventory.tsx index ddbc787..2845375 100644 --- a/src/components/DeviceInventory.tsx +++ b/src/components/DeviceInventory.tsx @@ -6,8 +6,8 @@ import React, { useState, useMemo } from 'react'; import { Device, DeviceType } from '../types'; import { - Server, Search, Plus, Trash, Edit2, MapPin, Info, - BookOpen, Save, X, ExternalLink, Gauge + Server, Search, Plus, Trash, Edit2, MapPin, Gauge, + BookOpen, Save, X, Info } from 'lucide-react'; // Built-in device class presets shown in the dropdown. @@ -15,6 +15,7 @@ const DEVICE_CLASS_PRESETS = ['Switch', 'Firewall', 'Access-Point', 'Controller' interface DeviceInventoryProps { devices: Device[]; + checkmkEnabled: boolean; onAddDevice: (device: Omit) => void; onUpdateDevice: (device: Device) => void; onDeleteDevice: (id: string) => void; @@ -22,6 +23,7 @@ interface DeviceInventoryProps { export default function DeviceInventory({ devices, + checkmkEnabled, onAddDevice, onUpdateDevice, onDeleteDevice, @@ -50,7 +52,6 @@ export default function DeviceInventory({ notes: string; type: DeviceType; emergencySheet: string; - checkMkUrl: string; }>({ hostname: '', ip: '', @@ -58,12 +59,9 @@ export default function DeviceInventory({ notes: '', type: 'Switch', emergencySheet: '', - checkMkUrl: '' }); - // Effective status: nothing is known until CheckMK is linked and reports a state. - const effectiveStatus = (d: Device): 'online' | 'offline' | 'unknown' => - d.checkMkUrl ? (d.status === 'online' || d.status === 'offline' ? d.status : 'unknown') : 'unknown'; + const effectiveStatus = (d: Device): 'online' | 'offline' | 'unknown' => d.status; const statusMeta = (s: 'online' | 'offline' | 'unknown') => { if (s === 'online') return { label: 'online', badge: 'bg-emerald-950/60 border-emerald-900/80 text-emerald-400', dot: 'bg-emerald-400' }; @@ -90,7 +88,6 @@ export default function DeviceInventory({ location: '', notes: '', type: 'Switch', - checkMkUrl: '', emergencySheet: `### EMERGENCY MANUAL [HOSTNAME] **Device Type:** [Enter Model] @@ -119,7 +116,6 @@ export default function DeviceInventory({ location: dev.location, notes: dev.notes, type: dev.type, - checkMkUrl: dev.checkMkUrl ?? '', emergencySheet: dev.emergencySheet }); setIsEditing(true); @@ -138,7 +134,6 @@ export default function DeviceInventory({ type: formData.type, status: 'unknown', emergencySheet: formData.emergencySheet, - checkMkUrl: formData.checkMkUrl }); } else if (formMode === 'edit' && formData.id) { const match = devices.find(d => d.id === formData.id); @@ -151,7 +146,6 @@ export default function DeviceInventory({ notes: formData.notes, type: formData.type, emergencySheet: formData.emergencySheet, - checkMkUrl: formData.checkMkUrl }); } } @@ -324,30 +318,18 @@ export default function DeviceInventory({ {/* Right: Actions and Status */}
e.stopPropagation()}> - {/* CheckMK Monitoring Badge */} - {(() => { const m = statusMeta(effectiveStatus(device)); return ( + {/* CheckMK Status Badge – only when CheckMK is enabled */} + {checkmkEnabled && (() => { const m = statusMeta(effectiveStatus(device)); return (
{m.label} - {device.checkMkUrl ? 'via CheckMK' : 'not linked'}
); })()} {/* Action Panel */}
- {device.checkMkUrl && ( - - - - )}
- {/* CheckMK Monitoring Panel */} + {/* CheckMK Monitoring Panel – only when CheckMK is enabled */} + {checkmkEnabled && (
@@ -417,22 +400,13 @@ export default function DeviceInventory({ ); })()}
- {selectedDevice.checkMkUrl ? ( - - - Open host in CheckMK - - ) : ( -

- No CheckMK host linked yet. Add the host URL in the modify window - live status will be pulled from the CheckMK API. + {selectedDevice.lastCheckedAt && ( +

+ Last checked: {new Date(selectedDevice.lastCheckedAt).toLocaleString()}

)}
+ )}
{/* Emergency rescue guidelines sheet */} @@ -569,29 +543,6 @@ Pick a box from the list to see its specs and break-glass playbook. /> - {/* CheckMK Monitoring integration */} -
-
- - CheckMK Monitoring -
-

- - Link this host to CheckMK. Connectivity is monitored there and pulled in via the CheckMK API - no in-app ping checks. -

-
- - setFormData({ ...formData, checkMkUrl: e.target.value })} - className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-cyan-500 font-mono" - placeholder="https://checkmk.internal/site/check_mk/index.py?host=SW-CORE-03" - /> -

Without a linked CheckMK host the status stays “unknown”.

-
-
-