feat: CheckMK host link in inventory, system logs hidden by default in logbook

This commit is contained in:
Brückner
2026-06-05 09:16:08 +02:00
parent 20308b53d6
commit ea9e6c1d46
6 changed files with 53 additions and 6 deletions

View File

@ -88,6 +88,7 @@ function ensureColumn(table: string, column: string, ddl: string) {
} }
ensureColumn('devices', 'checkMkUrl', "checkMkUrl TEXT NOT NULL DEFAULT ''"); ensureColumn('devices', 'checkMkUrl', "checkMkUrl TEXT NOT NULL DEFAULT ''");
ensureColumn('devices', 'cmkHostname', "cmkHostname TEXT NOT NULL DEFAULT ''");
// Seed default settings (INSERT OR IGNORE = only if key absent) // Seed default settings (INSERT OR IGNORE = only if key absent)
const _insertDefault = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'); const _insertDefault = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');

View File

@ -155,10 +155,12 @@ async function startServer() {
const secret = getSetting('azure_client_secret'); const secret = getSetting('azure_client_secret');
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`; const appUrl = process.env.APP_URL || `http://localhost:${PORT}`;
const effectiveRedirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`; const effectiveRedirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`;
const cmkApiUrl = getSetting('checkmk_api_url') || process.env.CHECKMK_API_URL || '';
res.json({ res.json({
azureEnabled: enabled && Boolean(clientId) && Boolean(tenantId) && Boolean(secret), azureEnabled: enabled && Boolean(clientId) && Boolean(tenantId) && Boolean(secret),
effectiveRedirectUri, effectiveRedirectUri,
checkmkEnabled: getSetting('checkmk_enabled') === 'true', checkmkEnabled: getSetting('checkmk_enabled') === 'true',
checkmkBaseUrl: cmkApiUrl.replace(/\/api\/.*$/, ''),
}); });
}); });
@ -751,7 +753,7 @@ async function startServer() {
const cmkHost = ipToHostname.get(dev.ip); const cmkHost = ipToHostname.get(dev.ip);
if (!cmkHost) { if (!cmkHost) {
if (dev.status !== 'unknown') { if (dev.status !== 'unknown') {
db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ? WHERE id = ?').run('unknown', now, dev.id); db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ?, cmkHostname = ? WHERE id = ?').run('unknown', now, '', dev.id);
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId) VALUES (?, ?, ?, ?, ?)') 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); .run(uid('log'), now, 'status', `CheckMK: ${dev.hostname} (${dev.ip}) not found in monitoring — status set to unknown.`, dev.id);
} }
@ -768,7 +770,7 @@ async function startServer() {
const state: number = hostData?.extensions?.state ?? -1; const state: number = hostData?.extensions?.state ?? -1;
const newStatus = state === 0 ? 'online' : state === 1 || state === 2 ? 'offline' : 'unknown'; const newStatus = state === 0 ? 'online' : state === 1 || state === 2 ? 'offline' : 'unknown';
db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ? WHERE id = ?').run(newStatus, now, dev.id); db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ?, cmkHostname = ? WHERE id = ?').run(newStatus, now, cmkHost, dev.id);
if (dev.status !== newStatus) { if (dev.status !== newStatus) {
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId) VALUES (?, ?, ?, ?, ?)') 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); .run(uid('log'), now, 'status', `CheckMK: ${dev.hostname} (${dev.ip}) status changed to ${newStatus} (was: ${dev.status}).`, dev.id);

View File

@ -54,6 +54,7 @@ export default function App() {
const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState<Device | null>(null); const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState<Device | null>(null);
const [checkmkEnabled, setCheckmkEnabled] = useState(false); const [checkmkEnabled, setCheckmkEnabled] = useState(false);
const [checkmkBaseUrl, setCheckmkBaseUrl] = useState('');
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
@ -142,7 +143,7 @@ export default function App() {
if (bookingsRes.ok) setBookings(await bookingsRes.json()); if (bookingsRes.ok) setBookings(await bookingsRes.json());
if (logsRes.ok) setLogs(await logsRes.json()); if (logsRes.ok) setLogs(await logsRes.json());
if (linksRes.ok) setLinks(await linksRes.json()); if (linksRes.ok) setLinks(await linksRes.json());
if (configRes.ok) { const cfg = await configRes.json(); setCheckmkEnabled(!!cfg.checkmkEnabled); } if (configRes.ok) { const cfg = await configRes.json(); setCheckmkEnabled(!!cfg.checkmkEnabled); setCheckmkBaseUrl(cfg.checkmkBaseUrl || ''); }
} catch (err) { } catch (err) {
console.error('[App] Failed to load data:', err); console.error('[App] Failed to load data:', err);
} finally { } finally {
@ -584,6 +585,7 @@ export default function App() {
<DeviceInventory <DeviceInventory
devices={devices} devices={devices}
checkmkEnabled={checkmkEnabled} checkmkEnabled={checkmkEnabled}
checkmkBaseUrl={checkmkBaseUrl}
onAddDevice={handleAddDevice} onAddDevice={handleAddDevice}
onUpdateDevice={handleUpdateDevice} onUpdateDevice={handleUpdateDevice}
onDeleteDevice={handleDeleteDevice} onDeleteDevice={handleDeleteDevice}

View File

@ -7,7 +7,7 @@ import React, { useState, useMemo } from 'react';
import { Device, DeviceType } from '../types'; import { Device, DeviceType } from '../types';
import { import {
Server, Search, Plus, Trash, Edit2, MapPin, Gauge, Server, Search, Plus, Trash, Edit2, MapPin, Gauge,
BookOpen, Save, X, Info BookOpen, Save, X, Info, ExternalLink
} from 'lucide-react'; } from 'lucide-react';
// Built-in device class presets shown in the dropdown. // Built-in device class presets shown in the dropdown.
@ -16,6 +16,7 @@ const DEVICE_CLASS_PRESETS = ['Switch', 'Firewall', 'Access-Point', 'Controller'
interface DeviceInventoryProps { interface DeviceInventoryProps {
devices: Device[]; devices: Device[];
checkmkEnabled: boolean; checkmkEnabled: boolean;
checkmkBaseUrl: string;
onAddDevice: (device: Omit<Device, 'id'>) => void; onAddDevice: (device: Omit<Device, 'id'>) => void;
onUpdateDevice: (device: Device) => void; onUpdateDevice: (device: Device) => void;
onDeleteDevice: (id: string) => void; onDeleteDevice: (id: string) => void;
@ -24,6 +25,7 @@ interface DeviceInventoryProps {
export default function DeviceInventory({ export default function DeviceInventory({
devices, devices,
checkmkEnabled, checkmkEnabled,
checkmkBaseUrl,
onAddDevice, onAddDevice,
onUpdateDevice, onUpdateDevice,
onDeleteDevice, onDeleteDevice,
@ -62,6 +64,10 @@ export default function DeviceInventory({
}); });
const effectiveStatus = (d: Device): 'online' | 'offline' | 'unknown' => d.status; const effectiveStatus = (d: Device): 'online' | 'offline' | 'unknown' => d.status;
const cmkHostUrl = (d: Device) =>
checkmkEnabled && checkmkBaseUrl && d.cmkHostname
? `${checkmkBaseUrl}/index.py?host=${encodeURIComponent(d.cmkHostname)}`
: null;
const statusMeta = (s: 'online' | 'offline' | 'unknown') => { 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' }; if (s === 'online') return { label: 'online', badge: 'bg-emerald-950/60 border-emerald-900/80 text-emerald-400', dot: 'bg-emerald-400' };
@ -330,6 +336,17 @@ export default function DeviceInventory({
{/* Action Panel */} {/* Action Panel */}
<div className="flex items-center gap-1.5 border-l border-slate-800/80 pl-3"> <div className="flex items-center gap-1.5 border-l border-slate-800/80 pl-3">
{cmkHostUrl(device) && (
<a
href={cmkHostUrl(device)!}
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 host in CheckMK"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
<button <button
onClick={() => handleOpenEdit(device)} onClick={() => handleOpenEdit(device)}
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-indigo-400 transition-colors" className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-indigo-400 transition-colors"
@ -400,6 +417,17 @@ export default function DeviceInventory({
</span> </span>
); })()} ); })()}
</div> </div>
{cmkHostUrl(selectedDevice) && (
<a
href={cmkHostUrl(selectedDevice)!}
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>
)}
{selectedDevice.lastCheckedAt && ( {selectedDevice.lastCheckedAt && (
<p className="text-[10px] text-slate-500 font-mono"> <p className="text-[10px] text-slate-500 font-mono">
Last checked: {new Date(selectedDevice.lastCheckedAt).toLocaleString()} Last checked: {new Date(selectedDevice.lastCheckedAt).toLocaleString()}

View File

@ -21,6 +21,7 @@ interface LogbookProps {
export default function Logbook({ logs, devices, users, currentUser, onAddLog }: LogbookProps) { export default function Logbook({ logs, devices, users, currentUser, onAddLog }: LogbookProps) {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all'); const [typeFilter, setTypeFilter] = useState<string>('all');
const [showSystem, setShowSystem] = useState(false);
// Custom Maintenance Log state // Custom Maintenance Log state
const [showAddLog, setShowAddLog] = useState(false); const [showAddLog, setShowAddLog] = useState(false);
@ -32,6 +33,7 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
// Filter logs // Filter logs
const filteredLogs = sortedLogs.filter(log => { const filteredLogs = sortedLogs.filter(log => {
if (!showSystem && log.type === 'system') return false;
const matchesSearch = log.message.toLowerCase().includes(searchTerm.toLowerCase()); const matchesSearch = log.message.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = typeFilter === 'all' || log.type === typeFilter; const matchesType = typeFilter === 'all' || log.type === typeFilter;
return matchesSearch && matchesType; return matchesSearch && matchesType;
@ -121,8 +123,8 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
className="w-full bg-slate-950 text-slate-101 border border-slate-800 rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none" className="w-full bg-slate-950 text-slate-101 border border-slate-800 rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none"
/> />
</div> </div>
<div className="flex gap-1 shrink-0 text-xs font-medium"> <div className="flex gap-1 shrink-0 text-xs font-medium flex-wrap">
{['all', 'booking', 'maintenance'].map((type) => ( {['all', 'booking', 'maintenance', 'status'].map((type) => (
<button <button
key={type} key={type}
onClick={() => setTypeFilter(type)} onClick={() => setTypeFilter(type)}
@ -135,6 +137,17 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
{type === 'all' ? 'All' : getLogTypeLabel(type)} {type === 'all' ? 'All' : getLogTypeLabel(type)}
</button> </button>
))} ))}
<button
onClick={() => setShowSystem(v => !v)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
showSystem
? 'bg-slate-700/40 border border-slate-600 text-slate-300'
: 'bg-slate-950 text-slate-600 border border-slate-850 hover:text-slate-400'
}`}
title="Show/hide automated system & CheckMK entries"
>
System
</button>
</div> </div>
</div> </div>

View File

@ -16,6 +16,7 @@ export interface Device {
type: DeviceType; type: DeviceType;
status: 'online' | 'offline' | 'unknown'; status: 'online' | 'offline' | 'unknown';
emergencySheet: string; // Markdown text emergencySheet: string; // Markdown text
cmkHostname?: string;
lastCheckedAt?: string; lastCheckedAt?: string;
} }