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”.

-
-
-