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:
Brückner
2026-06-04 14:07:54 +02:00
parent e9fb79041e
commit f12f92aea8
8 changed files with 194 additions and 118 deletions

View File

@ -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'],
];

119
server.ts
View File

@ -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 }[];
for (const dev of rows) {
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}`);

View File

@ -53,6 +53,7 @@ export default function App() {
const [remindedBookings, setRemindedBookings] = useState<Set<string>>(new Set());
const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState<Device | null>(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() {
<div className="text-[11px] text-slate-400 leading-relaxed font-sans space-y-0.5">
<div>Active: <span className="text-emerald-400 font-semibold font-mono">{bookings.filter(b => b.status === 'active').length}</span></div>
<div>Upcoming: <span className="text-indigo-400 font-semibold font-mono">{bookings.filter(b => b.status === 'upcoming').length}</span></div>
<div>Online: <span className="text-white font-semibold font-mono">{devices.filter(d => d.checkMkUrl && d.status === 'online').length}<span className="text-slate-500">/{devices.length}</span></span> devices</div>
<div>Online: <span className="text-white font-semibold font-mono">{devices.filter(d => d.status === 'online').length}<span className="text-slate-500">/{devices.length}</span></span> devices</div>
<div>Labs: <span className="text-white font-semibold font-mono">{labs.length}</span> configured</div>
</div>
<div className="h-1 bg-gradient-to-r from-emerald-500 to-teal-600 rounded-full w-full animate-pulse mt-2" /></div>
@ -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' && (
<DeviceInventory
devices={devices}
checkmkEnabled={checkmkEnabled}
onAddDevice={handleAddDevice}
onUpdateDevice={handleUpdateDevice}
onDeleteDevice={handleDeleteDevice}

View File

@ -6,7 +6,7 @@ import {
} from 'lucide-react';
function effectiveStatus(d: Device): 'online' | 'offline' | 'unknown' {
return d.checkMkUrl ? (d.status === 'online' || d.status === 'offline' ? d.status : 'unknown') : 'unknown';
return d.status;
}
function isOnline(d: Device): boolean {
return effectiveStatus(d) === 'online';
@ -17,6 +17,7 @@ interface BookingCalendarProps {
labs: LabTemplate[];
devices: Device[];
currentUser: User;
checkmkEnabled: boolean;
onAddBooking: (booking: Omit<Booking, 'id' | 'notified' | 'emailSent'>) => 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({
</div>
);
}
if (offline.length > 0) {
if (checkmkEnabled && offline.length > 0) {
return (
<div className="bg-amber-950/40 p-2.5 rounded border border-amber-800/60 flex gap-2 text-amber-300 text-[11px] leading-normal">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />

View File

@ -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<Device, 'id'>) => 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 */}
<div className="flex items-center gap-3" onClick={(e) => 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 (
<div className="flex flex-col items-end gap-1 font-sans">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium border capitalize ${m.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${m.dot}`} />
{m.label}
</span>
<span className="text-[9px] font-mono text-slate-500">{device.checkMkUrl ? 'via CheckMK' : 'not linked'}</span>
</div>
); })()}
{/* Action Panel */}
<div className="flex items-center gap-1.5 border-l border-slate-800/80 pl-3">
{device.checkMkUrl && (
<a
href={device.checkMkUrl}
target="_blank"
rel="noopener noreferrer"
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-cyan-400 transition-colors"
title="Open in CheckMK"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
<button
onClick={() => handleOpenEdit(device)}
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-indigo-400 transition-colors"
@ -403,7 +385,8 @@ export default function DeviceInventory({
</div>
</div>
{/* CheckMK Monitoring Panel */}
{/* CheckMK Monitoring Panel only when CheckMK is enabled */}
{checkmkEnabled && (
<div className="mt-4 pt-4 border-t border-slate-800 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-400 font-sans font-medium flex items-center gap-1.5">
@ -417,22 +400,13 @@ export default function DeviceInventory({
</span>
); })()}
</div>
{selectedDevice.checkMkUrl ? (
<a
href={selectedDevice.checkMkUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 px-3 py-1.5 bg-slate-900 border border-slate-700 text-slate-200 hover:text-cyan-400 hover:border-cyan-500 rounded text-xs transition-colors font-mono"
>
<ExternalLink className="w-3.5 h-3.5" />
Open host in CheckMK
</a>
) : (
<p className="text-[11px] text-slate-500 italic bg-slate-900/50 border border-slate-800 rounded p-2 leading-relaxed">
No CheckMK host linked yet. Add the host URL in the modify window - live status will be pulled from the CheckMK API.
{selectedDevice.lastCheckedAt && (
<p className="text-[10px] text-slate-500 font-mono">
Last checked: {new Date(selectedDevice.lastCheckedAt).toLocaleString()}
</p>
)}
</div>
)}
</div>
{/* Emergency rescue guidelines sheet */}
@ -569,29 +543,6 @@ Pick a box from the list to see its specs and break-glass playbook.
/>
</div>
{/* CheckMK Monitoring integration */}
<div className="bg-slate-900/60 border border-cyan-900/40 rounded-lg p-3.5 space-y-3">
<div className="flex items-center gap-2 text-cyan-400 font-semibold">
<Gauge className="w-4 h-4" />
CheckMK Monitoring
</div>
<p className="text-[11px] text-slate-400 leading-relaxed flex gap-1.5">
<Info className="w-3.5 h-3.5 text-cyan-400 shrink-0 mt-0.5" />
Link this host to CheckMK. Connectivity is monitored there and pulled in via the CheckMK API - no in-app ping checks.
</p>
<div>
<label className="block text-slate-300 font-semibold mb-1">CheckMK Host URL</label>
<input
type="text"
value={formData.checkMkUrl}
onChange={(e) => 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"
/>
<p className="text-[10px] text-slate-500 mt-1.5 italic">Without a linked CheckMK host the status stays unknown.</p>
</div>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Disaster Recovery Guide (Markdown formatting supported)</label>
<textarea

View File

@ -3,7 +3,7 @@ import { authFetch } from '../lib/auth';
import { User } from '../types';
import {
Shield, Activity, Save, CheckCircle, AlertCircle, Eye, EyeOff,
Settings2, KeyRound, Globe, Clock, ChevronRight, Copy, Users,
Settings2, KeyRound, Globe, Clock, ChevronRight, Copy, Users, RefreshCw,
} from 'lucide-react';
const SECRET_SENTINEL = '__SET__';
@ -13,8 +13,11 @@ interface RawSettings {
azure_client_id: string;
azure_tenant_id: string;
azure_client_secret: string;
azure_redirect_uri: string;
azure_allowed_group: string;
checkmk_enabled: string;
checkmk_api_url: string;
checkmk_api_user: string;
checkmk_api_secret: string;
checkmk_sync_interval_ms: string;
}
@ -142,14 +145,18 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const [azureTenantId, setAzureTenantId] = useState('');
const [azureClientSecret, setAzureClientSecret] = useState('');
const [azureSecretSet, setAzureSecretSet] = useState(false);
const [azureRedirectUri, setAzureRedirectUri] = useState('');
const [azureAllowedGroup, setAzureAllowedGroup] = useState('');
const [showAzureSecret, setShowAzureSecret] = useState(false);
const [checkmkEnabled, setCheckmkEnabled] = useState(false);
const [checkmkApiUrl, setCheckmkApiUrl] = useState('');
const [checkmkApiUser, setCheckmkApiUser] = useState('automation');
const [checkmkApiSecret, setCheckmkApiSecret] = useState('');
const [checkmkSecretSet, setCheckmkSecretSet] = useState(false);
const [checkmkSyncInterval, setCheckmkSyncInterval] = useState('60000');
const [showCheckmkSecret, setShowCheckmkSecret] = useState(false);
const [syncing, setSyncing] = useState(false);
useEffect(() => {
loadSettings();
@ -176,8 +183,11 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
setAzureTenantId(data.azure_tenant_id || '');
setAzureSecretSet(data.azure_client_secret === SECRET_SENTINEL);
setAzureClientSecret('');
setAzureRedirectUri(data.azure_redirect_uri || '');
setAzureAllowedGroup(data.azure_allowed_group || '');
setCheckmkEnabled(data.checkmk_enabled === 'true');
setCheckmkApiUrl(data.checkmk_api_url || '');
setCheckmkApiUser(data.checkmk_api_user || 'automation');
setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL);
setCheckmkApiSecret('');
setCheckmkSyncInterval(data.checkmk_sync_interval_ms || '60000');
@ -196,8 +206,11 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
azure_enabled: azureEnabled ? 'true' : 'false',
azure_client_id: azureClientId,
azure_tenant_id: azureTenantId,
azure_redirect_uri: azureRedirectUri,
azure_allowed_group: azureAllowedGroup,
checkmk_enabled: checkmkEnabled ? 'true' : 'false',
checkmk_api_url: checkmkApiUrl,
checkmk_api_user: checkmkApiUser,
checkmk_sync_interval_ms: checkmkSyncInterval,
};
if (azureClientSecret) payload.azure_client_secret = azureClientSecret;
@ -223,6 +236,25 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
}
}
async function runSync() {
setSyncing(true);
setError('');
try {
const res = await authFetch('/api/checkmk/sync', { method: 'POST' });
if (!res.ok) {
const d = await res.json();
setError(d.error || 'Sync failed.');
return;
}
setSuccessMsg('CheckMK sync triggered successfully.');
setTimeout(() => setSuccessMsg(''), 4000);
} catch {
setError('Network error during sync.');
} finally {
setSyncing(false);
}
}
function copyRedirectUri() {
if (!effectiveRedirectUri) return;
navigator.clipboard.writeText(effectiveRedirectUri).then(() => {
@ -359,6 +391,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
{/* ── CheckMK ── */}
<SectionCard accentColor="bg-gradient-to-r from-emerald-600 to-teal-600">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-emerald-950/60 border border-emerald-900/40 rounded-xl">
<Activity className="w-4 h-4 text-emerald-400" />
@ -366,7 +399,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-white">CheckMK</h2>
{checkmkApiUrl && checkmkSecretSet && (
{checkmkEnabled && checkmkApiUrl && checkmkSecretSet && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-emerald-950/60 border border-emerald-900/50 text-emerald-400">
<span className="w-1 h-1 rounded-full bg-emerald-400 animate-pulse inline-block" />
ACTIVE
@ -376,10 +409,23 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<p className="text-[11px] text-slate-500 mt-0.5">Device status sync via CheckMK REST API</p>
</div>
</div>
<div className="flex items-center gap-2.5">
<span className={`text-[10px] font-semibold font-mono ${checkmkEnabled ? 'text-emerald-400' : 'text-slate-600'}`}>
{checkmkEnabled ? 'ENABLED' : 'DISABLED'}
</span>
<button
type="button"
onClick={() => setCheckmkEnabled(v => !v)}
className={`relative w-10 h-5 rounded-full transition-all duration-200 focus:outline-none ${checkmkEnabled ? 'bg-emerald-600 shadow-[0_0_10px_rgba(5,150,105,0.4)]' : 'bg-slate-800 border border-slate-700'}`}
>
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-all duration-200 ${checkmkEnabled ? 'left-5' : 'left-0.5'}`} />
</button>
</div>
</div>
<div className="h-px bg-slate-800/60" />
<div className="space-y-5">
<div className={`space-y-5 transition-opacity duration-200 ${!checkmkEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
<FieldRow label="API URL">
<Input
value={checkmkApiUrl}
@ -390,6 +436,15 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
/>
</FieldRow>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<FieldRow label="Automation User" hint="CheckMK automation user (default: automation)">
<Input
value={checkmkApiUser}
onChange={setCheckmkApiUser}
placeholder="automation"
monospace
icon={<KeyRound className="w-3.5 h-3.5" />}
/>
</FieldRow>
<FieldRow
label="Automation Secret"
badge={checkmkSecretSet ? <ConfiguredBadge /> : undefined}
@ -412,6 +467,17 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
/>
</FieldRow>
</div>
{checkmkApiUrl && checkmkSecretSet && (
<button
type="button"
onClick={runSync}
disabled={syncing}
className="flex items-center gap-2 bg-emerald-950/60 hover:bg-emerald-900/40 border border-emerald-900/50 text-emerald-400 text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Syncing…' : 'Run sync now'}
</button>
)}
</div>
</SectionCard>

View File

@ -271,7 +271,6 @@ export default function TopologyPanel({ devices, links, onSelectDevice }: Topolo
>
{/* Status Indicator (sourced from CheckMK; grey = unknown) */}
<span className={`absolute -top-1 -right-1 w-3 h-3 rounded-full border-2 border-slate-950 ${
!device.checkMkUrl ? 'bg-slate-500' :
device.status === 'online' ? 'bg-emerald-500' :
device.status === 'offline' ? 'bg-rose-500' : 'bg-slate-500'
}`} />

View File

@ -14,9 +14,8 @@ export interface Device {
location: string;
notes: string;
type: DeviceType;
status: 'online' | 'offline' | 'unknown'; // 'unknown' until CheckMK reports a state
status: 'online' | 'offline' | 'unknown';
emergencySheet: string; // Markdown text
checkMkUrl: string; // Link to this host in CheckMK; live status comes from the CheckMK API
lastCheckedAt?: string;
}