Files
IT-Nexus/frontend/src/pages/MonitoringPage.jsx

2008 lines
166 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom';
import networkMonitorService from '../services/networkMonitorService';
import monitoringService from '../services/monitoringService';
import unifiService from '../services/unifiService';
import externalAlertService from '../services/externalAlertService';
import LoadingSpinner from '../components/common/LoadingSpinner';
import { toast } from 'react-toastify';
// ─── Constants ────────────────────────────────────────────────────────────────
const TYPE_META = {
host: { label: 'Host', icon: '🖥' },
switch: { label: 'Switch', icon: '🔀' },
ap: { label: 'Access Point',icon: '📶' },
router: { label: 'Router', icon: '🌐' },
printer: { label: 'Drucker', icon: '🖨' },
nas: { label: 'NAS', icon: '💾' },
service: { label: 'Service', icon: '⚙' },
};
const CHECK_TYPES = ['icmp', 'http', 'https', 'tcp', 'snmp'];
const INTERVALS = [
{ value: 15, label: '15 Sek' }, { value: 30, label: '30 Sek' },
{ value: 60, label: '1 Min' }, { value: 300, label: '5 Min' },
{ value: 600, label: '10 Min' },
];
const REFRESH_INTERVAL = 30;
const MAX_TS_POINTS = 60;
// State colors IT Nexus palette (nicht Checkmk!)
const SC = { ok: '#34d399', warn: '#f59e0b', crit: '#ef4444', unknown: '#6b7280' };
// ─── Helpers ──────────────────────────────────────────────────────────────────
const timeAgo = (iso) => {
if (!iso) return '';
const m = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
if (m < 1) return 'gerade eben';
if (m < 60) return `vor ${m} Min`;
if (m < 1440) return `vor ${Math.floor(m / 60)} Std`;
return `vor ${Math.floor(m / 1440)} Tagen`;
};
const duration = (iso) => {
if (!iso) return '';
const m = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
if (m < 60) return `${m} Min`;
const h = Math.floor(m / 60), rm = m % 60;
if (h < 24) return `${h}h ${rm}min`;
return `${Math.floor(h / 24)}d ${h % 24}h`;
};
const pct = (val, total) => (total > 0 ? Math.min(100, Math.round((val / total) * 100)) : 0);
const uptimeStr = (h) => { if (!h) return ''; if (h < 24) return `${Math.floor(h)}h`; return `${Math.floor(h / 24)}d ${Math.floor(h % 24)}h`; };
const netColor = (s) => s === 'up' ? SC.ok : s === 'down' ? SC.crit : SC.unknown;
const netLabel = (s) => s === 'up' ? 'UP' : s === 'down' ? 'DOWN' : 'UNBEKANNT';
const fmtTime = (d) => d instanceof Date ? d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : '';
const warnings = (a) => {
const w = [];
if (a.cpu_usage_percent > 90) w.push({ type: 'error', msg: `CPU ${a.cpu_usage_percent?.toFixed(0)}%` });
else if (a.cpu_usage_percent > 80) w.push({ type: 'warn', msg: `CPU ${a.cpu_usage_percent?.toFixed(0)}%` });
if (a.disk_free_gb != null && a.disk_free_gb < 5) w.push({ type: 'error', msg: `Disk ${a.disk_free_gb?.toFixed(1)}GB` });
else if (a.disk_free_gb != null && a.disk_free_gb < 15) w.push({ type: 'warn', msg: `Disk ${a.disk_free_gb?.toFixed(0)}GB` });
if (a.ram_total_gb > 0 && pct(a.ram_used_gb, a.ram_total_gb) > 90) w.push({ type: 'warn', msg: 'RAM voll' });
if (a.windows_updates_pending > 20) w.push({ type: 'error', msg: `${a.windows_updates_pending} Updates` });
else if (a.windows_updates_pending > 5) w.push({ type: 'warn', msg: `${a.windows_updates_pending} Updates` });
if (a.bitlocker_status === 'off') w.push({ type: 'warn', msg: 'BitLocker aus' });
if (a.defender_enabled === 0) w.push({ type: 'error', msg: 'Defender inaktiv' });
else if (a.defender_signatures_age > 7) w.push({ type: 'warn', msg: `Defender ${a.defender_signatures_age}d alt` });
return w;
};
const agentServices = (a) => [
{ name: 'Agent Verbindung', status: a.status === 'online' ? 'ok' : 'crit',
value: a.status === 'online' ? 'Online' : 'Offline', detail: `Checkin: ${timeAgo(a.last_checkin)}`, perf: null },
{ name: 'CPU Auslastung', status: a.cpu_usage_percent > 90 ? 'crit' : a.cpu_usage_percent > 80 ? 'warn' : 'ok',
value: a.cpu_usage_percent != null ? `${a.cpu_usage_percent.toFixed(1)}%` : '',
detail: a.cpu_model?.replace(/\(R\)|\(TM\)/g, '') || '',
perf: a.cpu_usage_percent != null ? { val: a.cpu_usage_percent, max: 100, warn: 80, crit: 90, unit: '%' } : null },
{ name: 'RAM Auslastung', status: pct(a.ram_used_gb, a.ram_total_gb) > 95 ? 'crit' : pct(a.ram_used_gb, a.ram_total_gb) > 85 ? 'warn' : 'ok',
value: a.ram_used_gb != null ? `${a.ram_used_gb.toFixed(1)} / ${a.ram_total_gb?.toFixed(0)} GB` : '',
detail: `${pct(a.ram_used_gb, a.ram_total_gb)}% belegt`,
perf: a.ram_total_gb > 0 ? { val: a.ram_used_gb, max: a.ram_total_gb, warn: 85, crit: 95, unit: '%' } : null },
{ name: 'Festplatte C:', status: (a.disk_free_gb ?? 99) < 5 ? 'crit' : (a.disk_free_gb ?? 99) < 15 ? 'warn' : 'ok',
value: a.disk_free_gb != null ? `${a.disk_free_gb.toFixed(0)} GB frei` : '',
detail: `von ${a.disk_total_gb?.toFixed(0) ?? ''} GB`,
perf: a.disk_total_gb > 0 ? { val: a.disk_total_gb - a.disk_free_gb, max: a.disk_total_gb, warn: 75, crit: 90, unit: '%', labelVal: `${a.disk_free_gb?.toFixed(0)} GB frei` } : null },
{ name: 'Windows Updates', status: (a.windows_updates_pending ?? 0) > 20 ? 'crit' : (a.windows_updates_pending ?? 0) > 5 ? 'warn' : 'ok',
value: `${a.windows_updates_pending ?? 0} ausstehend`, detail: '', perf: null },
];
const netDeviceServices = (d, uptimeStat) => {
const rtt = d.last_rtt_ms;
const up = uptimeStat?.pct ?? null;
return [
{ name: 'ICMP / Ping', status: d.last_status === 'up' ? 'ok' : d.last_status === 'down' ? 'crit' : 'unknown',
value: d.last_status === 'up' ? 'UP' : d.last_status === 'down' ? 'DOWN' : 'UNBEKANNT',
detail: rtt != null ? `${Math.round(rtt)} ms` : '', perf: null },
{ name: 'Antwortzeit', status: rtt == null ? 'unknown' : rtt > 1000 ? 'crit' : rtt > 200 ? 'warn' : 'ok',
value: rtt != null ? `${Math.round(rtt)} ms` : '', detail: '',
perf: rtt != null ? { val: rtt, max: 1000, warn: 200, crit: 500, unit: 'ms', labelVal: `${Math.round(rtt)}ms` } : null },
...(up != null ? [{ name: 'Uptime (24h)', status: up >= 99 ? 'ok' : up >= 90 ? 'warn' : 'crit',
value: `${up}%`, detail: '', perf: { val: 100 - up, max: 100, warn: 10, crit: 20, unit: '%', labelVal: `${up}% up` } }] : []),
];
};
const proxmoxServices = (px) => {
if (!px?.node) return [];
const { node, vms = [], lxcs = [], storage = [] } = px;
const cpuPct = (node.cpu || 0) * 100;
const ramPct = node.memory?.total > 0 ? (node.memory.used / node.memory.total) * 100 : 0;
const fsPct = node.rootfs?.total > 0 ? (node.rootfs.used / node.rootfs.total) * 100 : 0;
const load = node.loadavg?.[0] || 0;
const cpus = node.cpuinfo?.cpus || 1;
const runVMs = vms.filter(v => v.status === 'running').length;
const runCTs = lxcs.filter(c => c.status === 'running').length;
const svcs = [
{ name: 'CPU Auslastung', status: cpuPct >= 90 ? 'crit' : cpuPct >= 70 ? 'warn' : 'ok',
value: `${cpuPct.toFixed(1)}%`, detail: `${node.cpuinfo?.cpus || '?'} CPUs`,
perf: { val: cpuPct, max: 100, warn: 70, crit: 90, unit: '%' } },
{ name: 'RAM Auslastung', status: ramPct >= 90 ? 'crit' : ramPct >= 80 ? 'warn' : 'ok',
value: `${ramPct.toFixed(1)}%`, detail: `${(node.memory?.used/1073741824).toFixed(1)} / ${(node.memory?.total/1073741824).toFixed(1)} GB`,
perf: { val: ramPct, max: 100, warn: 80, crit: 90, unit: '%' } },
{ name: 'Root Filesystem', status: fsPct >= 90 ? 'crit' : fsPct >= 80 ? 'warn' : 'ok',
value: `${fsPct.toFixed(1)}%`, detail: `${(node.rootfs?.avail/1073741824).toFixed(1)} GB frei`,
perf: { val: fsPct, max: 100, warn: 80, crit: 90, unit: '%' } },
{ name: 'Load Average', status: load > cpus * 0.9 ? 'crit' : load > cpus * 0.7 ? 'warn' : 'ok',
value: load.toFixed(2), detail: `${node.loadavg?.map(l => l.toFixed(2)).join(' / ') || ''}`,
perf: { val: load, max: cpus, warn: cpus * 0.7, crit: cpus * 0.9, unit: '', labelVal: load.toFixed(2) } },
{ name: 'VMs', status: 'ok', value: `${runVMs}/${vms.length} laufend`, detail: '', perf: null },
{ name: 'Container', status: 'ok', value: `${runCTs}/${lxcs.length} laufend`, detail: '', perf: null },
...storage.filter(s => s.active && s.total > 0).map(s => {
const p = (s.used / s.total) * 100;
return { name: `Storage: ${s.storage}`, status: p >= 90 ? 'crit' : p >= 80 ? 'warn' : 'ok',
value: `${p.toFixed(1)}%`, detail: `${(s.avail/1073741824).toFixed(1)} GB frei`,
perf: { val: p, max: 100, warn: 80, crit: 90, unit: '%' } };
}),
];
return svcs;
};
// ─── UI Primitives ─────────────────────────────────────────────────────────────
// Checkmk-style State-Square: kleines farbiges Label mit Zustandstext
const StateSquare = ({ state, text, size = 'sm' }) => {
const color = SC[state] || SC.unknown;
const label = text || state?.toUpperCase() || '?';
return (
<span style={{
display: 'inline-block', background: `${color}22`, color,
border: `1px solid ${color}60`, borderRadius: 3,
padding: size === 'lg' ? '3px 10px' : '1px 6px',
fontSize: size === 'lg' ? 12 : 10,
fontWeight: 700, letterSpacing: .3, whiteSpace: 'nowrap',
fontFamily: 'monospace',
}}>{label}</span>
);
};
// Checkmk-style Perf-O-Meter mit WARN/CRIT Schwelllinien
const PerfBar = ({ val, max, warn = 80, crit = 90, unit = '%', labelVal = null }) => {
if (val == null || max == null || max === 0) return <span style={{ color: 'var(--text-muted)', fontSize: 11 }}></span>;
const fillPct = Math.min((val / max) * 100, 100);
const warnPct = warn;
const critPct = crit;
const color = fillPct >= critPct ? SC.crit : fillPct >= warnPct ? SC.warn : SC.ok;
const display = labelVal ?? `${fillPct.toFixed(0)}${unit}`;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 140 }}>
<div style={{ flex: 1, position: 'relative', height: 8, background: 'rgba(255,255,255,0.07)', borderRadius: 2, overflow: 'visible' }}>
{/* Fill */}
<div style={{ position: 'absolute', left: 0, top: 0, height: '100%', width: `${fillPct}%`, background: color, borderRadius: 2, transition: 'width .5s', maxWidth: '100%' }} />
{/* WARN threshold line */}
{warnPct < 100 && (
<div style={{ position: 'absolute', left: `${warnPct}%`, top: -2, bottom: -2, width: 1.5, background: `${SC.warn}bb`, borderRadius: 1 }} />
)}
{/* CRIT threshold line */}
{critPct < 100 && (
<div style={{ position: 'absolute', left: `${critPct}%`, top: -2, bottom: -2, width: 1.5, background: `${SC.crit}bb`, borderRadius: 1 }} />
)}
</div>
<span style={{ fontSize: 11, fontWeight: 700, color, minWidth: 42, textAlign: 'right', fontFamily: 'monospace' }}>{display}</span>
</div>
);
};
// Uptime %-Anzeige (für Netzwerkgeräte)
const UptimePill = ({ pct: p }) => {
if (p == null) return <span style={{ color: 'var(--text-muted)', fontSize: 11 }}></span>;
const color = p >= 99 ? SC.ok : p >= 90 ? SC.warn : SC.crit;
return <span style={{ fontSize: 11, fontWeight: 700, color, fontFamily: 'monospace' }}>{p}%</span>;
};
const NetDot = ({ status, size = 10 }) => (
<span style={{ display: 'inline-block', width: size, height: size, borderRadius: '50%', background: netColor(status), boxShadow: status === 'up' ? `0 0 6px ${netColor(status)}` : 'none', animation: status === 'up' ? 'nmPulse 2s infinite' : 'none', flexShrink: 0 }} />
);
const UptimeSparkline = ({ checks }) => {
if (!checks || checks.length === 0) return <div style={{ color: 'var(--text-muted)', fontSize: 11 }}>Keine Daten</div>;
const seg = checks.slice(-80);
const w = 240, h = 28, sw = w / seg.length;
return (
<svg width={w} height={h} style={{ borderRadius: 4, overflow: 'hidden' }}>
{seg.map((c, i) => <rect key={i} x={i * sw} y={0} width={Math.max(sw - 0.5, 1)} height={h} fill={c.status === 'up' ? SC.ok : SC.crit} opacity={0.85} />)}
</svg>
);
};
const RttSparkline = ({ checks }) => {
const vals = checks.filter(c => c.rtt_ms != null).map(c => c.rtt_ms);
if (vals.length < 2) return <div style={{ color: 'var(--text-muted)', fontSize: 11 }}></div>;
const max = Math.max(...vals);
const w = 240, h = 40;
const pts = vals.slice(-60).map((v, i) => { const x = (i / (vals.slice(-60).length - 1)) * w; const y = h - (v / max) * (h - 4) - 2; return `${x},${y}`; }).join(' ');
return <svg width={w} height={h}><polyline points={pts} fill="none" stroke="var(--accent)" strokeWidth="1.5" /></svg>;
};
const WarnBadge = ({ w }) => (
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 6px', borderRadius: 4, background: w.type === 'error' ? `${SC.crit}22` : `${SC.warn}22`, color: w.type === 'error' ? SC.crit : SC.warn, border: `1px solid ${w.type === 'error' ? SC.crit : SC.warn}44` }}>{w.msg}</span>
);
const TimeSeriesChart = ({ data, key1, key2, height = 110 }) => {
const vbW = 400, vbH = height;
if (!data || data.length < 2) return <div style={{ height, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted)', fontSize: 12 }}>Daten werden gesammelt...</div>;
const v1 = data.map(d => d[key1] || 0), v2 = data.map(d => d[key2] || 0);
const maxV = Math.max(...v1, ...v2, 1);
const pad = { top: 8, right: 8, bottom: 24, left: 24 };
const cw = vbW - pad.left - pad.right, ch = vbH - pad.top - pad.bottom;
const toX = (i) => pad.left + (i / (data.length - 1)) * cw;
const toY = (v) => pad.top + ch - (v / maxV) * ch;
const path = (vals) => vals.map((v, i) => `${i === 0 ? 'M' : 'L'}${toX(i)},${toY(v)}`).join(' ');
const area = (vals) => { const l = vals.map((v, i) => `${i === 0 ? 'M' : 'L'}${toX(i)},${toY(v)}`).join(' '); const n = vals.length - 1; return `${l} L${toX(n)},${toY(0)} L${toX(0)},${toY(0)} Z`; };
const xl = data.length > 1 ? [0, Math.floor(data.length / 2), data.length - 1].map(i => ({ i, t: fmtTime(data[i].time) })) : [];
return (
<svg viewBox={`0 0 ${vbW} ${vbH}`} style={{ width: '100%', height }}>
{[0, Math.ceil(maxV / 2), maxV].map(v => <line key={v} x1={pad.left} y1={toY(v)} x2={pad.left + cw} y2={toY(v)} stroke="var(--border-color)" strokeWidth="0.7" />)}
<path d={area(v1)} fill={`${SC.crit}18`} />
<path d={area(v2)} fill={`${SC.warn}18`} />
<path d={path(v1)} fill="none" stroke={SC.crit} strokeWidth="1.8" />
<path d={path(v2)} fill="none" stroke={SC.warn} strokeWidth="1.8" />
{[0, Math.ceil(maxV / 2), maxV].map(v => <text key={v} x={pad.left - 4} y={toY(v) + 4} textAnchor="end" fontSize="9" fill={SC.unknown}>{v}</text>)}
{xl.map(({ i, t }) => <text key={i} x={toX(i)} y={vbH - 4} textAnchor="middle" fontSize="9" fill={SC.unknown}>{t}</text>)}
</svg>
);
};
// ─── Checkmk-style Tactical Overview ─────────────────────────────────────────
const TacticalOverview = ({ devices, agents, problems, acknowledged, countdown, lastUpdated, onTabChange, extAlerts = [] }) => {
const openP = problems.filter(p => !acknowledged.has(p.id));
const critN = openP.filter(p => p.severity <= 1).length;
const warnN = openP.filter(p => p.severity === 2).length;
const hostUp = devices.filter(d => d.last_status === 'up').length;
const hostDown = devices.filter(d => d.last_status === 'down').length;
const hostUnk = devices.filter(d => !d.last_status || d.last_status === 'unknown').length;
const agOnline = agents.filter(a => a.status === 'online').length;
const agOffline = agents.filter(a => a.status === 'offline').length;
const agWarn = agents.filter(a => warnings(a).some(w => w.type === 'warn') && a.status === 'online').length;
const pxAlerts = extAlerts.filter(a => a.source === 'proxmox' && !a.acknowledged);
const pxCrit = pxAlerts.filter(a => a.severity === 'CRIT').length;
const pxWarn = pxAlerts.filter(a => a.severity === 'WARN').length;
const pxOk = pxCrit === 0 && pxWarn === 0;
const CountTile = ({ label, count, color, onClick, sub }) => {
const active = count > 0;
const W = 82, H = 72, cx = W / 2, cy = H / 2, r = 33;
const pts = Array.from({ length: 6 }, (_, i) => {
const a = (Math.PI / 3) * i;
return `${(cx + r * Math.cos(a)).toFixed(1)},${(cy + r * Math.sin(a)).toFixed(1)}`;
}).join(' ');
return (
<svg width={W} height={H} onClick={onClick} style={{ cursor: onClick ? 'pointer' : 'default', flexShrink: 0, transition: 'filter .15s' }}
onMouseEnter={e => { if (active && onClick) e.currentTarget.style.filter = `drop-shadow(0 0 7px ${color}99)`; }}
onMouseLeave={e => { e.currentTarget.style.filter = ''; }}>
<polygon points={pts}
fill={active ? `${color}20` : 'rgba(255,255,255,0.03)'}
stroke={active ? color : 'rgba(255,255,255,0.1)'}
strokeWidth={active ? 1.5 : 1} />
<text x={cx} y={cy - (sub ? 8 : 3)} textAnchor="middle" dominantBaseline="middle"
fill={active ? color : 'rgba(255,255,255,0.2)'}
fontSize="21" fontWeight="800" fontFamily="monospace,'Courier New'">{count}</text>
<text x={cx} y={cy + (sub ? 9 : 12)} textAnchor="middle" dominantBaseline="middle"
fill={active ? color : 'rgba(255,255,255,0.2)'}
fontSize="7.5" fontWeight="700" letterSpacing="0.8">{label}</text>
{sub && <text x={cx} y={cy + 20} textAnchor="middle" dominantBaseline="middle"
fill="rgba(255,255,255,0.3)" fontSize="6.5">{sub}</text>}
</svg>
);
};
const Divider = () => <div style={{ width: 1, alignSelf: 'stretch', background: 'var(--border-color)', margin: '0 8px' }} />;
return (
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 10, padding: '14px 20px', marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 0, flexWrap: 'wrap', rowGap: 10 }}>
{/* Section: Zustand */}
<div style={{ display: 'flex', flexDirection: 'column', marginRight: 16 }}>
<span style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .5, color: 'var(--text-muted)', marginBottom: 6, paddingLeft: 2 }}>Gesamtzustand</span>
<div style={{ display: 'flex', gap: 6 }}>
<CountTile label="KRITISCH" count={critN} color={SC.crit} onClick={critN > 0 ? () => onTabChange('problems') : null} />
<CountTile label="WARNUNG" count={warnN} color={SC.warn} onClick={warnN > 0 ? () => onTabChange('problems') : null} />
<CountTile label="OK" count={hostUp + agOnline - agWarn} color={SC.ok} />
</div>
</div>
<Divider />
{/* Section: Netzwerk */}
<div style={{ display: 'flex', flexDirection: 'column', marginRight: 16 }}>
<span style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .5, color: 'var(--text-muted)', marginBottom: 6, paddingLeft: 2 }}>Netzwerk-Geräte</span>
<div style={{ display: 'flex', gap: 6 }}>
<CountTile label="UP" count={hostUp} color={SC.ok} sub={`von ${devices.length}`} />
<CountTile label="DOWN" count={hostDown} color={SC.crit} onClick={hostDown > 0 ? () => onTabChange('network') : null} />
<CountTile label="?" count={hostUnk} color={SC.unknown} />
</div>
</div>
<Divider />
{/* Section: Agents */}
<div style={{ display: 'flex', flexDirection: 'column', marginRight: 16 }}>
<span style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .5, color: 'var(--text-muted)', marginBottom: 6, paddingLeft: 2 }}>Windows Agents</span>
<div style={{ display: 'flex', gap: 6 }}>
<CountTile label="ONLINE" count={agOnline} color={SC.ok} sub={`von ${agents.length}`} />
<CountTile label="OFFLINE" count={agOffline} color={SC.crit} onClick={agOffline > 0 ? () => onTabChange('agents') : null} />
<CountTile label="WARNUNG" count={agWarn} color={SC.warn} onClick={agWarn > 0 ? () => onTabChange('agents') : null} />
</div>
</div>
<Divider />
{/* Section: Proxmox */}
<div style={{ display: 'flex', flexDirection: 'column', marginRight: 16 }}>
<span style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .5, color: 'var(--text-muted)', marginBottom: 6, paddingLeft: 2 }}>Proxmox</span>
<div style={{ display: 'flex', gap: 6 }}>
<CountTile label="OK" count={pxOk ? 1 : 0} color={SC.ok} sub="Infrastruktur" />
<CountTile label="KRITISCH" count={pxCrit} color={SC.crit} onClick={pxCrit > 0 ? () => onTabChange('overview') : null} />
<CountTile label="WARNUNG" count={pxWarn} color={SC.warn} onClick={pxWarn > 0 ? () => onTabChange('overview') : null} />
</div>
</div>
{/* Right: status + timer */}
<div style={{ marginLeft: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12 }}>
<span style={{ color: 'var(--text-muted)' }}>Nächster Refresh:</span>
<span style={{ fontWeight: 700, fontFamily: 'monospace', color: countdown <= 5 ? SC.warn : 'var(--text-primary)' }}>{countdown}s</span>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', fontFamily: 'monospace' }}>{lastUpdated}</div>
<div style={{ fontSize: 10, color: 'var(--text-muted)' }}>{devices.length + agents.length} Hosts gesamt</div>
</div>
</div>
</div>
);
};
// ─── Modals (unverändert) ─────────────────────────────────────────────────────
const DiscoverModal = ({ onClose, onAdd }) => {
const [subnet, setSubnet] = useState('192.168.0');
const [scanning, setScanning] = useState(false);
const [progress, setProgress] = useState(0);
const [results, setResults] = useState(null);
const [selected, setSelected] = useState({});
const [typeOverrides, setTypeOverrides] = useState({});
const [adding, setAdding] = useState(false);
const progressRef = useRef(null);
const startScan = async () => {
setScanning(true); setResults(null); setSelected({}); setProgress(0);
let p = 0;
progressRef.current = setInterval(() => { p = Math.min(p + 1.2, 90); setProgress(p); }, 500);
try {
const res = await networkMonitorService.discover(subnet);
clearInterval(progressRef.current); setProgress(100);
setResults(res.data || []);
const sel = {}; (res.data || []).forEach(d => sel[d.ip] = true); setSelected(sel);
} catch { clearInterval(progressRef.current); toast.error('Scan fehlgeschlagen'); }
finally { setScanning(false); }
};
const addSelected = async () => {
const toAdd = (results || []).filter(d => selected[d.ip]); if (!toAdd.length) return;
setAdding(true); let added = 0;
for (const d of toAdd) { try { const type = typeOverrides[d.ip] || d.type; const device = await networkMonitorService.create({ name: d.hostname || d.ip, type, host: d.ip, check_type: d.check_type, interval_sec: 60, timeout_sec: 5, enabled: 1 }); onAdd(device); added++; } catch {} }
toast.success(`${added} Gerät${added !== 1 ? 'e' : ''} hinzugefügt`); setAdding(false); onClose();
};
const toggleAll = (val) => { const sel = {}; (results || []).forEach(d => sel[d.ip] = val); setSelected(sel); };
const inp = { padding: '8px 12px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 13 };
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.65)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={e => e.target === e.currentTarget && !scanning && onClose()}>
<div style={{ background: 'var(--bg-primary)', borderRadius: 12, width: '100%', maxWidth: 600, maxHeight: '90vh', overflow: 'hidden', display: 'flex', flexDirection: 'column', border: '1px solid var(--border-color)' }}>
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border-color)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ fontSize: 16, fontWeight: 700 }}>📡 Netzwerk scannen</h2>
{!scanning && <button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 22, cursor: 'pointer' }}>×</button>}
</div>
<div style={{ padding: 20, overflowY: 'auto', flex: 1 }}>
<div style={{ display: 'flex', gap: 10, marginBottom: 16, alignItems: 'flex-end' }}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '.4px', display: 'block', marginBottom: 6 }}>Subnetz (erste 3 Oktette)</label>
<input style={{ ...inp, width: '100%', boxSizing: 'border-box' }} value={subnet} onChange={e => setSubnet(e.target.value)} disabled={scanning} />
</div>
<button onClick={startScan} disabled={scanning || !subnet} style={{ padding: '9px 20px', borderRadius: 7, border: 'none', background: 'var(--accent)', color: '#fff', cursor: scanning ? 'not-allowed' : 'pointer', fontWeight: 700, fontSize: 13 }}>{scanning ? 'Scanne...' : '▶ Scan starten'}</button>
</div>
{(scanning || results !== null) && (
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-muted)', marginBottom: 6 }}>
<span>{scanning ? `Scanne ${subnet}.1${subnet}.254...` : `${results?.length || 0} Geräte gefunden`}</span>
<span>{Math.round(progress)}%</span>
</div>
<div style={{ background: 'var(--border-color)', borderRadius: 4, height: 6, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${progress}%`, background: progress === 100 ? SC.ok : 'var(--accent)', borderRadius: 4, transition: 'width .4s' }} />
</div>
</div>
)}
{results !== null && results.length === 0 && <div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 30 }}>Keine neuen Geräte im Subnetz {subnet}.x</div>}
{results && results.length > 0 && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>{Object.values(selected).filter(Boolean).length} / {results.length} ausgewählt</span>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => toggleAll(true)} style={{ fontSize: 11, padding: '3px 10px', borderRadius: 5, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}>Alle</button>
<button onClick={() => toggleAll(false)} style={{ fontSize: 11, padding: '3px 10px', borderRadius: 5, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}>Keine</button>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{results.map(d => (
<div key={d.ip} onClick={() => setSelected(s => ({ ...s, [d.ip]: !s[d.ip] }))} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 12px', borderRadius: 8, background: selected[d.ip] ? `${SC.ok}10` : 'var(--bg-secondary)', border: `1px solid ${selected[d.ip] ? SC.ok + '50' : 'var(--border-color)'}`, cursor: 'pointer' }}>
<input type="checkbox" checked={!!selected[d.ip]} onChange={() => {}} style={{ accentColor: 'var(--accent)' }} />
<span style={{ fontSize: 18 }}>{TYPE_META[typeOverrides[d.ip] || d.type]?.icon || '🖥'}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{d.ip}</div>
{d.hostname && <div style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis' }}>{d.hostname}</div>}
{d.open_ports?.length > 0 && <div style={{ fontSize: 10, color: 'var(--text-muted)' }}>Ports: {d.open_ports.join(', ')}</div>}
</div>
<select value={typeOverrides[d.ip] || d.type} onChange={e => { e.stopPropagation(); setTypeOverrides(t => ({ ...t, [d.ip]: e.target.value })); }} onClick={e => e.stopPropagation()} style={{ fontSize: 11, padding: '3px 6px', borderRadius: 5, border: '1px solid var(--border-color)', background: 'var(--bg-primary)', color: 'var(--text-primary)', cursor: 'pointer' }}>
{Object.entries(TYPE_META).map(([k, v]) => <option key={k} value={k}>{v.icon} {v.label}</option>)}
</select>
</div>
))}
</div>
</>
)}
</div>
{results && results.length > 0 && (
<div style={{ padding: '14px 20px', borderTop: '1px solid var(--border-color)', display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<button onClick={onClose} disabled={adding} style={{ padding: '8px 16px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-primary)', cursor: 'pointer', fontSize: 13 }}>Abbrechen</button>
<button onClick={addSelected} disabled={adding || !Object.values(selected).some(Boolean)} style={{ padding: '8px 18px', borderRadius: 7, border: 'none', background: 'var(--accent)', color: '#fff', cursor: 'pointer', fontWeight: 700, fontSize: 13 }}>{adding ? 'Lädt...' : `${Object.values(selected).filter(Boolean).length} hinzufügen`}</button>
</div>
)}
</div>
</div>
);
};
const DeviceFormModal = ({ device, onSave, onClose }) => {
const [form, setForm] = useState({ name: '', type: 'host', host: '', check_type: 'icmp', port: '', http_path: '/', http_keyword: '', snmp_community: 'public', snmp_version: '2c', interval_sec: 60, timeout_sec: 5, enabled: 1, notify_email: '', location: '', ...(device || {}) });
const [saving, setSaving] = useState(false);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const save = async () => {
if (!form.name || !form.host) return toast.error('Name und Host sind Pflichtfelder');
setSaving(true);
try { const r = device ? await networkMonitorService.update(device.id, form) : await networkMonitorService.create(form); onSave(r); onClose(); toast.success(device ? 'Aktualisiert' : 'Hinzugefügt'); }
catch { toast.error('Fehler beim Speichern'); } finally { setSaving(false); }
};
const inp = { width: '100%', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--border-color)', background: 'var(--bg-primary)', color: 'var(--text-primary)', fontSize: 13, boxSizing: 'border-box' };
const lbl = { fontSize: 11, color: 'var(--text-muted)', fontWeight: 700, marginBottom: 4, display: 'block', textTransform: 'uppercase', letterSpacing: '.4px' };
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={e => e.target === e.currentTarget && onClose()}>
<div style={{ background: 'var(--bg-primary)', borderRadius: 12, width: '100%', maxWidth: 540, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-color)' }}>
<div style={{ padding: '18px 20px', borderBottom: '1px solid var(--border-color)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ fontSize: 16, fontWeight: 700 }}>{device ? 'Gerät bearbeiten' : 'Neues Gerät'}</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer' }}>×</button>
</div>
<div style={{ padding: 20, display: 'grid', gap: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div><label style={lbl}>Name *</label><input style={inp} value={form.name} onChange={e => set('name', e.target.value)} /></div>
<div><label style={lbl}>Typ</label><select style={inp} value={form.type} onChange={e => set('type', e.target.value)}>{Object.entries(TYPE_META).map(([k, v]) => <option key={k} value={k}>{v.icon} {v.label}</option>)}</select></div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div><label style={lbl}>Host / IP *</label><input style={inp} value={form.host} onChange={e => set('host', e.target.value)} /></div>
<div><label style={lbl}>Standort</label><input style={inp} value={form.location || ''} onChange={e => set('location', e.target.value)} /></div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<div><label style={lbl}>Check-Typ</label><select style={inp} value={form.check_type} onChange={e => set('check_type', e.target.value)}>{CHECK_TYPES.map(t => <option key={t} value={t}>{t.toUpperCase()}</option>)}</select></div>
{['tcp','http','https'].includes(form.check_type) && <div><label style={lbl}>Port</label><input style={inp} type="number" value={form.port || ''} onChange={e => set('port', e.target.value)} /></div>}
<div><label style={lbl}>Intervall</label><select style={inp} value={form.interval_sec} onChange={e => set('interval_sec', parseInt(e.target.value))}>{INTERVALS.map(i => <option key={i.value} value={i.value}>{i.label}</option>)}</select></div>
</div>
{['http','https'].includes(form.check_type) && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div><label style={lbl}>Pfad</label><input style={inp} value={form.http_path || '/'} onChange={e => set('http_path', e.target.value)} /></div>
<div><label style={lbl}>Keyword (opt.)</label><input style={inp} value={form.http_keyword || ''} onChange={e => set('http_keyword', e.target.value)} /></div>
</div>
)}
{form.check_type === 'snmp' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div><label style={lbl}>Community</label><input style={inp} value={form.snmp_community || 'public'} onChange={e => set('snmp_community', e.target.value)} placeholder="public" /></div>
<div><label style={lbl}>Version</label><select style={inp} value={form.snmp_version || '2c'} onChange={e => set('snmp_version', e.target.value)}><option value="2c">v2c</option><option value="1">v1</option></select></div>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div><label style={lbl}>Timeout (s)</label><input style={inp} type="number" min="1" max="30" value={form.timeout_sec} onChange={e => set('timeout_sec', parseInt(e.target.value))} /></div>
<div><label style={lbl}>Alert E-Mail</label><input style={inp} value={form.notify_email || ''} onChange={e => set('notify_email', e.target.value)} /></div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<input type="checkbox" id="devEnabled" checked={!!form.enabled} onChange={e => set('enabled', e.target.checked ? 1 : 0)} />
<label htmlFor="devEnabled" style={{ fontSize: 13, cursor: 'pointer' }}>Monitoring aktiv</label>
</div>
</div>
<div style={{ padding: '14px 20px', borderTop: '1px solid var(--border-color)', display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<button onClick={onClose} style={{ padding: '8px 18px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-primary)', cursor: 'pointer', fontSize: 13 }}>Abbrechen</button>
<button onClick={save} disabled={saving} style={{ padding: '8px 18px', borderRadius: 7, border: 'none', background: 'var(--accent)', color: '#fff', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>{saving ? '...' : 'Speichern'}</button>
</div>
</div>
</div>
);
};
const NetworkDetailModal = ({ device, onClose, onEdit, onDelete, onCheckNow }) => {
const [checksData, setChecksData] = useState(null);
const [timeRange, setTimeRange] = useState(24);
const [checking, setChecking] = useState(false);
useEffect(() => { networkMonitorService.getChecks(device.id, timeRange).then(setChecksData).catch(() => {}); }, [device.id, timeRange]);
const doCheckNow = async () => { setChecking(true); try { await onCheckNow(device.id); toast.success('Check ausgeführt'); const d = await networkMonitorService.getChecks(device.id, timeRange); setChecksData(d); } catch { toast.error('Fehlgeschlagen'); } finally { setChecking(false); } };
const meta = TYPE_META[device.type] || TYPE_META.host;
const uptime = checksData?.uptime, checks = checksData?.checks || [];
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={e => e.target === e.currentTarget && onClose()}>
<div style={{ background: 'var(--bg-primary)', borderRadius: 12, width: '100%', maxWidth: 660, maxHeight: '90vh', overflow: 'auto', border: `1px solid var(--border-color)`, borderTop: `3px solid ${netColor(device.last_status)}` }}>
<div style={{ padding: '16px 20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid var(--border-color)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 24 }}>{meta.icon}</span>
<div><div style={{ fontWeight: 700, fontSize: 16 }}>{device.name}</div><div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{device.host} · {meta.label}</div></div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<StateSquare state={device.last_status === 'up' ? 'ok' : device.last_status === 'down' ? 'crit' : 'unknown'} text={netLabel(device.last_status)} size="lg" />
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 22, cursor: 'pointer' }}>×</button>
</div>
</div>
<div style={{ padding: 20 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 10, marginBottom: 20 }}>
{[{ label: 'Letzter Check', value: timeAgo(device.last_checked) }, { label: 'Antwortzeit', value: device.last_rtt_ms != null ? `${Math.round(device.last_rtt_ms)} ms` : '' }, { label: `Uptime ${timeRange}h`, value: uptime?.total > 0 ? `${uptime.pct}%` : '', color: uptime?.pct >= 99 ? SC.ok : uptime?.pct >= 90 ? SC.warn : SC.crit }].map(m => (
<div key={m.label} style={{ background: 'var(--bg-secondary)', borderRadius: 8, padding: '10px 14px' }}>
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.4px', marginBottom: 4 }}>{m.label}</div>
<div style={{ fontSize: 18, fontWeight: 700, color: m.color || 'var(--text-primary)', fontFamily: 'monospace' }}>{m.value}</div>
</div>
))}
</div>
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.4px' }}>Uptime-Verlauf</div>
<div style={{ display: 'flex', gap: 4 }}>{[24, 48, 168].map(h => <button key={h} onClick={() => setTimeRange(h)} style={{ padding: '3px 10px', borderRadius: 5, fontSize: 11, fontWeight: 600, cursor: 'pointer', border: `1px solid ${timeRange === h ? 'var(--accent)' : 'var(--border-color)'}`, background: timeRange === h ? 'var(--accent)' : 'var(--bg-secondary)', color: timeRange === h ? '#fff' : 'var(--text-muted)' }}>{h === 168 ? '7d' : `${h}h`}</button>)}</div>
</div>
<div style={{ background: 'var(--bg-secondary)', borderRadius: 8, padding: 10 }}>
<UptimeSparkline checks={checks} />
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10, color: 'var(--text-muted)', marginTop: 4 }}>
<span>Grün = Online · Rot = Offline</span><span>{uptime?.up ?? 0}/{uptime?.total ?? 0} OK</span>
</div>
</div>
</div>
{checks.some(c => c.rtt_ms != null) && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.4px', marginBottom: 8 }}>Antwortzeit (ms)</div>
<div style={{ background: 'var(--bg-secondary)', borderRadius: 8, padding: 10 }}><RttSparkline checks={checks} /></div>
</div>
)}
<div style={{ background: 'var(--bg-secondary)', borderRadius: 8, padding: '12px 14px', marginBottom: 16 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.4px', marginBottom: 8 }}>Konfiguration</div>
<div style={{ display: 'grid', gridTemplateColumns: '130px 1fr', gap: '5px 12px', fontSize: 13, lineHeight: 1.7 }}>
{[['Check-Typ', device.check_type?.toUpperCase()], ['Intervall', `${device.interval_sec}s`], ['Timeout', `${device.timeout_sec}s`], device.port && ['Port', device.port], device.location && ['Standort', device.location], device.notify_email && ['Alert E-Mail', device.notify_email]].filter(Boolean).map(([k, v]) => (
<React.Fragment key={k}><span style={{ color: 'var(--text-muted)' }}>{k}</span><span style={{ fontWeight: 600 }}>{v}</span></React.Fragment>
))}
</div>
</div>
{[...checks].reverse().slice(0, 15).length > 0 && (
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.4px', marginBottom: 8 }}>Letzte Checks</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<tbody>{[...checks].reverse().slice(0, 15).map((c, i) => (
<tr key={i} style={{ borderBottom: '1px solid var(--border-color)' }}>
<td style={{ padding: '5px 8px' }}><StateSquare state={c.status === 'up' ? 'ok' : 'crit'} text={c.status === 'up' ? 'UP' : 'DOWN'} /></td>
<td style={{ padding: '5px 8px', color: 'var(--text-muted)', fontFamily: 'monospace' }}>{c.rtt_ms != null ? `${Math.round(c.rtt_ms)} ms` : ''}</td>
<td style={{ padding: '5px 8px', color: 'var(--text-muted)', textAlign: 'right', fontFamily: 'monospace', fontSize: 11 }}>{new Date(c.checked_at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}</td>
{c.error_msg && <td style={{ padding: '5px 8px', color: SC.crit, fontSize: 11 }}>{c.error_msg}</td>}
</tr>
))}</tbody>
</table>
</div>
)}
</div>
<div style={{ padding: '14px 20px', borderTop: '1px solid var(--border-color)', display: 'flex', gap: 8 }}>
<button onClick={doCheckNow} disabled={checking} style={{ padding: '7px 14px', borderRadius: 7, border: '1px solid var(--accent)', background: 'none', color: 'var(--accent)', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>{checking ? '...' : ' Jetzt prüfen'}</button>
<button onClick={() => { onEdit(device); onClose(); }} style={{ padding: '7px 14px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-primary)', cursor: 'pointer', fontSize: 13 }}>Bearbeiten</button>
<div style={{ flex: 1 }} />
<button onClick={() => onDelete(device)} style={{ padding: '7px 14px', borderRadius: 7, border: `1px solid ${SC.crit}`, background: 'none', color: SC.crit, cursor: 'pointer', fontSize: 13 }}>Löschen</button>
</div>
</div>
</div>
);
};
const AgentDetailModal = ({ agent, onClose, onDelete }) => {
const [swSearch, setSwSearch] = useState('');
const w = warnings(agent);
const sw = (agent.installed_software || []).filter(s => !swSearch || s.toLowerCase().includes(swSearch.toLowerCase()));
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={onClose}>
<div style={{ background: 'var(--bg-primary)', border: '1px solid var(--border-color)', borderRadius: 14, width: '100%', maxWidth: 720, maxHeight: '90vh', overflow: 'hidden', display: 'flex', flexDirection: 'column' }} onClick={e => e.stopPropagation()}>
<div style={{ padding: '20px 24px', borderBottom: '1px solid var(--border-color)', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
<span style={{ fontSize: 20, fontWeight: 700, fontFamily: 'monospace' }}>{agent.hostname}</span>
<StateSquare state={agent.status === 'online' ? 'ok' : 'crit'} text={agent.status === 'online' ? 'ONLINE' : 'OFFLINE'} size="lg" />
</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{agent.os_name} {agent.os_version}</div>
{w.length > 0 && <div style={{ display: 'flex', gap: 5, marginTop: 8, flexWrap: 'wrap' }}>{w.map((x, i) => <WarnBadge key={i} w={x} />)}</div>}
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer' }}>✕</button>
</div>
<div style={{ overflowY: 'auto', flex: 1 }}>
<div style={{ padding: '16px 24px', display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 12, borderBottom: '1px solid var(--border-color)' }}>
{[{ label: 'CPU', val: `${agent.cpu_usage_percent?.toFixed(0) ?? ''}%`, sub: agent.cpu_model?.replace(/\(R\)|\(TM\)/g, '') || '' }, { label: 'RAM', val: `${agent.ram_used_gb?.toFixed(1) ?? ''} / ${agent.ram_total_gb?.toFixed(0) ?? ''} GB`, sub: `${pct(agent.ram_used_gb, agent.ram_total_gb)}% belegt` }, { label: 'Disk C:', val: `${agent.disk_free_gb?.toFixed(0) ?? ''} GB frei`, sub: `von ${agent.disk_total_gb?.toFixed(0) ?? ''} GB` }].map(m => (
<div key={m.label} style={{ background: 'var(--bg-secondary)', borderRadius: 8, padding: '12px 14px' }}>
<div style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '.5px', marginBottom: 4 }}>{m.label}</div>
<div style={{ fontSize: 18, fontWeight: 700, fontFamily: 'monospace' }}>{m.val}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.sub}</div>
</div>
))}
</div>
<div style={{ padding: '16px 24px', borderBottom: '1px solid var(--border-color)' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.5px', marginBottom: 10 }}>Systeminfos</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4 }}>
{[['IP-Adresse', agent.ip_address || ''], ['MAC-Adresse', agent.mac_address || ''], ['Domain', agent.domain || ''], ['Letzter Benutzer', agent.last_user || ''], ['Uptime', uptimeStr(agent.uptime_hours)], ['CPU-Kerne', agent.cpu_cores || ''], ['Windows Updates', `${agent.windows_updates_pending ?? 0} ausstehend`], ['Agent Version', `v${agent.agent_version || '?'}`], ['Letzter Checkin', timeAgo(agent.last_checkin)], ['Registriert', new Date(agent.created_at).toLocaleDateString('de-DE')]].map(([k, v]) => (
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderBottom: '1px solid var(--border-color)', fontSize: 13 }}>
<span style={{ color: 'var(--text-muted)' }}>{k}</span><span style={{ fontWeight: 500 }}>{v}</span>
</div>
))}
</div>
</div>
<div style={{ padding: '16px 24px', borderBottom: '1px solid var(--border-color)' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.5px', marginBottom: 10 }}>Sicherheit</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4 }}>
{[
['BitLocker', (() => {
const s = agent.bitlocker_status;
if (s === 'encrypted') return { label: ' Verschlüsselt', color: '#22c55e' };
if (s === 'off') return { label: ' Nicht aktiv', color: '#ef4444' };
return { label: '? Unbekannt', color: 'var(--text-muted)' };
})()],
['Microsoft Defender', (() => {
if (agent.defender_enabled == null) return { label: '? Unbekannt', color: 'var(--text-muted)' };
if (!agent.defender_enabled) return { label: ' Inaktiv', color: '#ef4444' };
const age = agent.defender_signatures_age;
if (age == null || age < 0) return { label: ' Aktiv', color: '#22c55e' };
if (age <= 3) return { label: ' Aktiv · Signaturen aktuell', color: '#22c55e' };
return { label: `⚠ Aktiv · Signaturen ${age}d alt`, color: '#f59e0b' };
})()],
['Seriennummer', { label: agent.hardware_serial || '', color: agent.hardware_serial ? 'var(--text-primary)' : 'var(--text-muted)' }],
['TPM', { label: agent.tpm_v2 ? ' TPM 2.0' : agent.tpm_present ? `TPM ${agent.tpm_version || '?'}` : ' Nicht vorhanden', color: agent.tpm_v2 ? '#22c55e' : agent.tpm_present ? '#f59e0b' : '#ef4444' }],
['Secure Boot', { label: agent.secure_boot ? ' Aktiv' : ' Inaktiv', color: agent.secure_boot ? '#22c55e' : '#ef4444' }],
['Win 11 Ready', { label: agent.win11_ready ? ' Ja' : ' Nein', color: agent.win11_ready ? '#22c55e' : 'var(--text-muted)' }],
].map(([k, v]) => (
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderBottom: '1px solid var(--border-color)', fontSize: 13 }}>
<span style={{ color: 'var(--text-muted)' }}>{k}</span>
<span style={{ fontWeight: 600, color: v.color }}>{v.label}</span>
</div>
))}
</div>
</div>
{agent.installed_software?.length > 0 && (
<div style={{ padding: '16px 24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.5px' }}>Software ({agent.installed_software.length})</div>
<input placeholder="Suchen..." value={swSearch} onChange={e => setSwSearch(e.target.value)} style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 12, width: 160 }} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4, maxHeight: 180, overflowY: 'auto' }}>
{sw.map((s, i) => <div key={i} style={{ fontSize: 12, color: 'var(--text-muted)', padding: '4px 8px', background: 'var(--bg-secondary)', borderRadius: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={s}>{s}</div>)}
</div>
</div>
)}
</div>
<div style={{ padding: '14px 24px', borderTop: '1px solid var(--border-color)', display: 'flex', justifyContent: 'flex-end' }}>
<button onClick={() => onDelete(agent.id, agent.hostname)} style={{ padding: '7px 16px', background: `${SC.crit}18`, color: SC.crit, border: `1px solid ${SC.crit}44`, borderRadius: 7, cursor: 'pointer', fontSize: 13 }}>Agent entfernen</button>
</div>
</div>
</div>
);
};
// ─── Unifi Config Modal (top-level so React doesn't remount on every render)
const UnifiConfigModal = ({ unifiConfig, onSave, onClose }) => {
const [form, setForm] = useState({
controller_url: '', username: '', password: '', site: 'default', poll_interval_min: 5,
...(unifiConfig || {}), enabled: !!unifiConfig?.enabled,
});
const [saving, setSaving] = useState(false);
const setF = (k, v) => setForm(f => ({ ...f, [k]: v }));
const inp = { width: '100%', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--border-color)', background: 'var(--bg-primary)', color: 'var(--text-primary)', fontSize: 13, boxSizing: 'border-box' };
const lbl = { fontSize: 11, color: 'var(--text-muted)', fontWeight: 700, marginBottom: 4, display: 'block', textTransform: 'uppercase', letterSpacing: '.4px' };
const save = async () => {
setSaving(true);
try { const c = await unifiService.saveConfig(form); onSave(c); onClose(); }
catch (e) { alert(e.message || 'Fehler'); } finally { setSaving(false); }
};
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={e => e.target === e.currentTarget && onClose()}>
<div style={{ background: 'var(--bg-primary)', borderRadius: 12, width: '100%', maxWidth: 480, border: '1px solid var(--border-color)' }}>
<div style={{ padding: '18px 20px', borderBottom: '1px solid var(--border-color)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ fontSize: 16, fontWeight: 700 }}>📶 Unifi Controller</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer' }}>×</button>
</div>
<div style={{ padding: 20, display: 'grid', gap: 12 }}>
<div><label style={lbl}>Controller URL</label><input style={inp} value={form.controller_url} onChange={e => setF('controller_url', e.target.value)} placeholder="https://192.168.0.1:8443" /></div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div><label style={lbl}>Benutzername</label><input style={inp} value={form.username} onChange={e => setF('username', e.target.value)} /></div>
<div><label style={lbl}>Passwort</label><input style={inp} type="password" value={form.password} onChange={e => setF('password', e.target.value)} placeholder="leer = unverändert" /></div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div><label style={lbl}>Site</label><input style={inp} value={form.site} onChange={e => setF('site', e.target.value)} placeholder="default" /></div>
<div><label style={lbl}>Poll-Intervall (Min)</label><input style={inp} type="number" min="1" max="60" value={form.poll_interval_min} onChange={e => setF('poll_interval_min', parseInt(e.target.value))} /></div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<input type="checkbox" id="unifiEnabled" checked={!!form.enabled} onChange={e => setF('enabled', e.target.checked)} />
<label htmlFor="unifiEnabled" style={{ fontSize: 13, cursor: 'pointer' }}>Unifi-Polling aktiviert</label>
</div>
</div>
<div style={{ padding: '14px 20px', borderTop: '1px solid var(--border-color)', display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<button onClick={onClose} style={{ padding: '8px 18px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-primary)', cursor: 'pointer', fontSize: 13 }}>Abbrechen</button>
<button onClick={save} disabled={saving} style={{ padding: '8px 18px', borderRadius: 7, border: 'none', background: 'var(--accent)', color: '#fff', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>{saving ? '...' : 'Speichern'}</button>
</div>
</div>
</div>
);
};
// ─── Main Page ────────────────────────────────────────────────────────────────
const MonitoringPage = () => {
const [devices, setDevices] = useState([]);
const [agents, setAgents] = useState([]);
const [loading, setLoading] = useState(true);
const [timeSeriesData, setTimeSeriesData] = useState([]);
const [recentChecks, setRecentChecks] = useState([]);
const [selectedDevice, setSelectedDevice] = useState(null);
const [selectedAgent, setSelectedAgent] = useState(null);
const [showDeviceForm, setShowDeviceForm] = useState(false);
const [editDevice, setEditDevice] = useState(null);
const [showDiscover, setShowDiscover] = useState(false);
const [showDeploy, setShowDeploy] = useState(false);
const [countdown, setCountdown] = useState(REFRESH_INTERVAL);
const [activeTab, setActiveTab] = useState('overview');
const [netSearch, setNetSearch] = useState('');
const [netFilter, setNetFilter] = useState('all');
const [agentSearch, setAgentSearch] = useState('');
const [agentFilter, setAgentFilter] = useState('all');
const [expandedAgents, setExpandedAgents] = useState(new Set());
const [expandedHosts, setExpandedHosts] = useState(new Set());
const [agentSort, setAgentSort] = useState('cpu');
const [hostFilter, setHostFilter] = useState('all');
const [hostSearch, setHostSearch] = useState('');
const [proxmoxData, setProxmoxData] = useState(null);
const [acknowledged, setAcknowledged] = useState(new Set());
const [showAcked, setShowAcked] = useState(false);
const [uptimeStats, setUptimeStats] = useState({});
const [collapsedGroups,setCollapsedGroups]= useState(new Set());
const [unifiDevices, setUnifiDevices] = useState([]);
const [unifiConfig, setUnifiConfig] = useState(null);
const [showUnifiCfg, setShowUnifiCfg] = useState(false);
const [extAlerts, setExtAlerts] = useState([]);
const [expandedAlerts, setExpandedAlerts] = useState({});
const [hideAcknowledged, setHideAcknowledged] = useState(true);
const esRef = useRef(null), countRef = useRef(REFRESH_INTERVAL), agentsRef = useRef([]), devicesRef = useRef([]);
agentsRef.current = agents; devicesRef.current = devices;
const pushSnapshot = useCallback((devs, agts) => {
setTimeSeriesData(prev => [...prev.slice(-(MAX_TS_POINTS - 1)), { time: new Date(), devDown: devs.filter(d => d.last_status === 'down').length, devWarn: 0, agentOffline: agts.filter(a => a.status === 'offline').length, agentWarn: agts.filter(a => warnings(a).some(w => w.type === 'warn')).length }]);
}, []);
const loadRecentChecks = useCallback(async (devs) => {
if (!devs?.length) return;
const results = [];
await Promise.allSettled(devs.slice(0, 8).map(async d => {
try { const data = await networkMonitorService.getChecks(d.id, 1); (data?.checks || []).slice(-3).forEach(c => results.push({ deviceName: d.name, status: c.status, checked_at: c.checked_at, rtt_ms: c.rtt_ms })); } catch {}
}));
results.sort((a, b) => new Date(b.checked_at) - new Date(a.checked_at));
setRecentChecks(results.slice(0, 12));
}, []);
useEffect(() => {
let pollTimer = null, sseConnected = false;
const loadViaApi = () => networkMonitorService.getAll().then(d => { setDevices(d); setLoading(false); pushSnapshot(d, agentsRef.current); }).catch(() => setLoading(false));
const startPolling = () => { if (pollTimer) return; loadViaApi(); pollTimer = setInterval(loadViaApi, 15000); };
let es;
try {
es = networkMonitorService.createSSE(); esRef.current = es;
const to = setTimeout(() => { if (!sseConnected) { es.close(); startPolling(); } }, 4000);
es.addEventListener('initial_state', e => { sseConnected = true; clearTimeout(to); const d = JSON.parse(e.data); setDevices(d); setLoading(false); pushSnapshot(d, agentsRef.current); });
es.addEventListener('device_update', e => { const u = JSON.parse(e.data); setDevices(prev => { const ex = prev.find(d => d.id === u.id); const n = ex ? prev.map(d => d.id === u.id ? u : d) : [...prev, u]; pushSnapshot(n, agentsRef.current); return n; }); setSelectedDevice(prev => prev?.id === u.id ? u : prev); });
es.addEventListener('device_deleted', e => { const { id } = JSON.parse(e.data); setDevices(prev => prev.filter(d => d.id !== id)); });
es.onerror = () => { clearTimeout(to); if (!sseConnected) { es.close(); startPolling(); } };
} catch { startPolling(); }
return () => { es?.close(); if (pollTimer) clearInterval(pollTimer); };
}, [pushSnapshot]);
const loadAgents = useCallback(async (silent = false) => {
try { const d = await monitoringService.getAll(); setAgents(d); countRef.current = REFRESH_INTERVAL; setCountdown(REFRESH_INTERVAL); pushSnapshot(devicesRef.current, d); if (!silent) setLoading(false); }
catch { if (!silent) { toast.error('Fehler beim Laden'); setLoading(false); } }
}, [pushSnapshot]);
useEffect(() => {
loadAgents();
const r = setInterval(() => loadAgents(true), REFRESH_INTERVAL * 1000);
const t = setInterval(() => { countRef.current = Math.max(0, countRef.current - 1); setCountdown(countRef.current); }, 1000);
return () => { clearInterval(r); clearInterval(t); };
}, [loadAgents]);
useEffect(() => { if (devices.length > 0) loadRecentChecks(devices); }, [devices, loadRecentChecks]);
useEffect(() => { if (devices.length > 0) networkMonitorService.getUptimeStats().then(setUptimeStats).catch(() => {}); }, [devices.length]);
const loadUnifi = useCallback(async () => {
try {
const [devs, cfg] = await Promise.all([unifiService.getDevices(), unifiService.getConfig()]);
setUnifiDevices(devs);
setUnifiConfig(cfg);
} catch {}
}, []);
useEffect(() => { loadUnifi(); }, [loadUnifi]);
const loadExtAlerts = useCallback(async () => {
try { const d = await externalAlertService.getAll(); setExtAlerts(d); } catch {}
}, []);
useEffect(() => { loadExtAlerts(); const r = setInterval(loadExtAlerts, 60000); return () => clearInterval(r); }, [loadExtAlerts]);
const loadProxmox = useCallback(async () => {
try {
const { default: api } = await import('../services/api');
const r = await api.get('/proxmox/overview');
setProxmoxData(r.data);
} catch {}
}, []);
useEffect(() => { loadProxmox(); const r = setInterval(loadProxmox, 30000); return () => clearInterval(r); }, [loadProxmox]);
const handleExtAcknowledge = async (id) => {
try { await externalAlertService.acknowledge(id); loadExtAlerts(); toast.success('Quittiert'); } catch { toast.error('Fehler'); }
};
const handleExtCreateTicket = async (id) => {
try { await externalAlertService.createTicket(id); loadExtAlerts(); toast.success('Ticket erstellt'); } catch { toast.error('Fehler beim Erstellen'); }
};
const handleExtRemove = async (id) => {
if (!window.confirm('Alert wirklich löschen?')) return;
try { await externalAlertService.remove(id); loadExtAlerts(); toast.success('Gelöscht'); } catch { toast.error('Fehler'); }
};
const handleAgentDelete = async (id, hostname) => {
if (!window.confirm(`Agent "${hostname}" wirklich entfernen?`)) return;
try { await monitoringService.delete(id); toast.success('Entfernt'); setSelectedAgent(null); loadAgents(true); } catch { toast.error('Fehler'); }
};
const handleNetDelete = async (d) => {
if (!window.confirm(`${d.name} wirklich löschen?`)) return;
try { await networkMonitorService.delete(d.id); setSelectedDevice(null); toast.success('Gelöscht'); } catch { toast.error('Fehler'); }
};
const handleNetSave = (d) => setDevices(prev => { const ex = prev.find(x => x.id === d.id); return ex ? prev.map(x => x.id === d.id ? d : x) : [...prev, d]; });
const toggleAgent = id => setExpandedAgents(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
const toggleGroup = g => setCollapsedGroups(prev => { const n = new Set(prev); n.has(g) ? n.delete(g) : n.add(g); return n; });
// Derived
const problems = [
...devices.filter(d => d.last_status !== 'up').map(d => ({ id: `dev-${d.id}`, severity: d.last_status === 'down' ? 0 : 2, statusColor: netColor(d.last_status), statusLabel: netLabel(d.last_status), name: d.name, service: TYPE_META[d.type]?.label || d.type, typeIcon: TYPE_META[d.type]?.icon || '🖥', lastCheck: d.last_checked, isDevice: true, raw: d })),
...agents.filter(a => a.status !== 'online' || warnings(a).length > 0).map(a => { const w = warnings(a); const hasErr = w.some(x => x.type === 'error'); const isOffline = a.status === 'offline'; return { id: `agt-${a.id}`, severity: isOffline ? 3 : hasErr ? 1 : 2, statusColor: isOffline ? SC.unknown : hasErr ? SC.crit : SC.warn, statusLabel: isOffline ? 'OFFLINE' : hasErr ? 'CRIT' : 'WARN', name: a.hostname, service: 'Windows Agent', typeIcon: '🖥', lastCheck: a.last_checkin, isDevice: false, raw: a }; }),
].sort((a, b) => a.severity - b.severity);
const INACTIVE_DAYS = 7;
const isLongInactive = (p) => {
if (!p.lastCheck) return false;
const days = (Date.now() - new Date(p.lastCheck).getTime()) / 86400000;
return days >= INACTIVE_DAYS;
};
const openProblems = problems.filter(p => !acknowledged.has(p.id));
const activeProblems = openProblems.filter(p => !isLongInactive(p));
const inactiveProblems = openProblems.filter(p => isLongInactive(p));
const lastUpdated = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
if (loading) return <LoadingSpinner />;
// ── Row background helper (Checkmk-style) ──
const rowBg = (state) => state === 'crit' || state === 'down' ? `${SC.crit}0a` : state === 'warn' ? `${SC.warn}08` : 'transparent';
const rowBorder = (state) => state === 'crit' || state === 'down' ? SC.crit : state === 'warn' ? SC.warn : 'transparent';
// ── View toolbar helper ──
const pill = (active, onClick, label) => (
<button onClick={onClick} style={{ padding: '4px 12px', borderRadius: 20, border: `1px solid ${active ? 'var(--accent)' : 'var(--border-color)'}`, background: active ? 'var(--accent)' : 'none', color: active ? '#fff' : 'var(--text-muted)', fontSize: 12, cursor: 'pointer', fontWeight: active ? 600 : 400, transition: 'all .15s' }}>{label}</button>
);
const card = (ex = {}) => ({ background: 'var(--bg-secondary)', borderRadius: 10, border: '1px solid var(--border-color)', ...ex });
// ─── Tab: Übersicht ───────────────────────────────────────────────────────
const renderOverview = () => {
const toggleHost = (id) => setExpandedHosts(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
const pxState = proxmoxData?.node ? (() => {
const cpu = (proxmoxData.node.cpu || 0) * 100;
const ram = proxmoxData.node.memory?.total > 0 ? (proxmoxData.node.memory.used / proxmoxData.node.memory.total) * 100 : 0;
const load = proxmoxData.node.loadavg?.[0] || 0;
const cpus = proxmoxData.node.cpuinfo?.cpus || 1;
if (cpu >= 90 || ram >= 90 || load > cpus * 0.9) return 'crit';
if (cpu >= 70 || ram >= 80 || load > cpus * 0.7) return 'warn';
return 'ok';
})() : 'unknown';
const allHosts = [
...(proxmoxData ? [{ id: 'px-hve01', type: 'proxmox', name: proxmoxData.node_name || 'hve-01', ip: '192.168.0.184', icon: '🖥️', subLabel: 'Proxmox VE Node', state: pxState, services: proxmoxServices(proxmoxData), lastCheck: proxmoxData.fetched_at }] : []),
...devices.map(d => {
const ut = uptimeStats[d.id];
const state = d.last_status === 'up' ? 'ok' : d.last_status === 'down' ? 'crit' : 'unknown';
return { id: `net-${d.id}`, type: 'network', name: d.name, ip: d.host, icon: TYPE_META[d.type]?.icon || '🌐', subLabel: TYPE_META[d.type]?.label || 'Netzwerkgerät', state, services: netDeviceServices(d, ut), lastCheck: d.last_checked, raw: d };
}),
...agents.map(a => {
const w = warnings(a);
const hasErr = w.some(x => x.type === 'error');
const state = a.status === 'offline' ? 'crit' : hasErr ? 'crit' : w.length ? 'warn' : 'ok';
return { id: `ag-${a.id}`, type: 'agent', name: a.hostname, ip: a.ip_address, icon: '💻', subLabel: 'Windows Agent', state, services: agentServices(a), lastCheck: a.last_checkin, raw: a };
}),
].sort((a, b) => { const o = { crit: 0, warn: 1, unknown: 2, ok: 3 }; return (o[a.state] ?? 3) - (o[b.state] ?? 3) || a.name.localeCompare(b.name); });
const filtered = allHosts
.filter(h => hostFilter === 'all' || h.state !== 'ok')
.filter(h => !hostSearch || h.name.toLowerCase().includes(hostSearch.toLowerCase()) || (h.ip || '').includes(hostSearch));
const stateLabel = { ok: 'OK', warn: 'WARN', crit: 'CRIT', unknown: '?' };
const stateColor = { ok: SC.ok, warn: SC.warn, crit: SC.crit, unknown: SC.unknown };
return (
<>
{/* Filter + Search */}
<div style={{ display: 'flex', gap: 8, marginBottom: 14, alignItems: 'center', flexWrap: 'wrap' }}>
{[['all', `Alle (${allHosts.length})`], ['problems', `Probleme (${allHosts.filter(h => h.state !== 'ok').length})`]].map(([v, l]) => (
<button key={v} onClick={() => setHostFilter(v)} style={{ padding: '4px 14px', borderRadius: 20, border: `1px solid ${hostFilter === v ? 'var(--accent)' : 'var(--border-color)'}`, background: hostFilter === v ? 'var(--accent)' : 'none', color: hostFilter === v ? '#fff' : 'var(--text-muted)', fontSize: 12, cursor: 'pointer', fontWeight: hostFilter === v ? 700 : 400 }}>{l}</button>
))}
<button onClick={() => { setExpandedHosts(new Set(filtered.map(h => h.id))); }} style={{ padding: '4px 12px', borderRadius: 20, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 12, cursor: 'pointer' }}>Alle aufklappen</button>
<button onClick={() => setExpandedHosts(new Set())} style={{ padding: '4px 12px', borderRadius: 20, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 12, cursor: 'pointer' }}>Alle zuklappen</button>
<input value={hostSearch} onChange={e => setHostSearch(e.target.value)} placeholder="🔍 Host suchen..." style={{ marginLeft: 'auto', padding: '5px 12px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 13, minWidth: 200 }} />
</div>
{/* CheckMK Host Table */}
<div style={{ border: '1px solid var(--border-color)', borderRadius: 10, overflow: 'hidden' }}>
{/* Header */}
<div style={{ display: 'grid', gridTemplateColumns: '32px 1fr 90px 60px 60px 60px 130px', background: 'var(--bg-secondary)', borderBottom: '2px solid var(--border-color)', padding: '8px 14px', fontSize: 10, color: 'var(--text-muted)', fontWeight: 800, textTransform: 'uppercase', letterSpacing: .5 }}>
<div />
<div>Host / IP</div>
<div>Status</div>
<div style={{ color: SC.ok }}>OK</div>
<div style={{ color: SC.warn }}>WARN</div>
<div style={{ color: SC.crit }}>CRIT</div>
<div>Letzter Check</div>
</div>
{filtered.map(host => {
const expanded = expandedHosts.has(host.id);
const okC = host.services.filter(s => s.status === 'ok').length;
const warnC = host.services.filter(s => s.status === 'warn').length;
const critC = host.services.filter(s => s.status === 'crit').length;
const hColor = stateColor[host.state] || SC.unknown;
return (
<div key={host.id}>
{/* Host row */}
<div onClick={() => toggleHost(host.id)} style={{ display: 'grid', gridTemplateColumns: '32px 1fr 90px 60px 60px 60px 130px', padding: '10px 14px', borderBottom: '1px solid var(--border-color)', cursor: 'pointer', background: host.state === 'crit' ? `${SC.crit}12` : host.state === 'warn' ? `${SC.warn}09` : 'transparent', borderLeft: `3px solid ${hColor}`, transition: 'background .1s' }}
onMouseEnter={e => e.currentTarget.style.filter = 'brightness(1.08)'}
onMouseLeave={e => e.currentTarget.style.filter = ''}>
<div style={{ color: 'var(--text-muted)', fontSize: 11, paddingTop: 2 }}>{expanded ? '▼' : '▶'}</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
<span style={{ fontSize: 14 }}>{host.icon}</span>
<span style={{ fontWeight: 700, fontSize: 13, color: 'var(--text-primary)' }}>{host.name}</span>
{host.ip && <span style={{ fontSize: 11, color: 'var(--text-muted)', fontFamily: 'monospace' }}>{host.ip}</span>}
</div>
<div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 1 }}>{host.subLabel}</div>
</div>
<div><span style={{ padding: '2px 8px', borderRadius: 3, fontSize: 11, fontWeight: 800, background: `${hColor}22`, color: hColor, border: `1px solid ${hColor}55`, fontFamily: 'monospace' }}>{stateLabel[host.state] || '?'}</span></div>
<div style={{ fontWeight: 700, fontSize: 13, color: SC.ok }}>{okC}</div>
<div style={{ fontWeight: warnC > 0 ? 700 : 400, fontSize: 13, color: warnC > 0 ? SC.warn : 'var(--text-muted)' }}>{warnC}</div>
<div style={{ fontWeight: critC > 0 ? 700 : 400, fontSize: 13, color: critC > 0 ? SC.crit : 'var(--text-muted)' }}>{critC}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', fontFamily: 'monospace' }}>{timeAgo(host.lastCheck)}</div>
</div>
{/* Service rows */}
{expanded && host.services.map((svc, i) => {
const sc = stateColor[svc.status] || SC.unknown;
return (
<div key={i} style={{ display: 'grid', gridTemplateColumns: '32px 220px 80px 1fr 160px', gap: 8, padding: '6px 14px 6px 44px', borderBottom: '1px solid rgba(255,255,255,0.04)', background: svc.status === 'crit' ? `${SC.crit}09` : svc.status === 'warn' ? `${SC.warn}06` : i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent', borderLeft: `3px solid ${i % 2 === 0 ? 'transparent' : 'transparent'}` }}>
<div />
<div style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500, display: 'flex', alignItems: 'center' }}>{svc.name}</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ padding: '1px 7px', borderRadius: 3, fontSize: 10, fontWeight: 800, background: `${sc}22`, color: sc, border: `1px solid ${sc}44`, fontFamily: 'monospace' }}>{(svc.status || 'ok').toUpperCase()}</span>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 8, overflow: 'hidden' }}>
<span style={{ fontWeight: 700, color: svc.status === 'crit' ? SC.crit : svc.status === 'warn' ? SC.warn : 'var(--text-secondary)', whiteSpace: 'nowrap' }}>{svc.value}</span>
{svc.detail && <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{svc.detail}</span>}
</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
{svc.perf && <PerfBar {...svc.perf} />}
</div>
</div>
);
})}
</div>
);
})}
{filtered.length === 0 && (
<div style={{ padding: 48, textAlign: 'center', color: SC.ok, fontSize: 15 }}>
✅ {hostFilter === 'problems' ? 'Alle Services in Ordnung — keine Probleme' : 'Keine Hosts gefunden'}
</div>
)}
</div>
</>
);
};
// ─── Tab: Netzwerk ────────────────────────────────────────────────────────
const renderNetwork = () => {
const filtered = devices.filter(d => {
if (netSearch && !d.name.toLowerCase().includes(netSearch.toLowerCase()) && !d.host.includes(netSearch)) return false;
if (netFilter === 'up' && d.last_status !== 'up') return false;
if (netFilter === 'down' && d.last_status !== 'down') return false;
if (netFilter === 'unknown' && d.last_status === 'up') return false;
return true;
});
const grouped = {};
filtered.forEach(d => { const g = d.type || 'host'; if (!grouped[g]) grouped[g] = []; grouped[g].push(d); });
return (
<>
{/* Toolbar */}
<div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap', alignItems: 'center' }}>
<input value={netSearch} onChange={e => setNetSearch(e.target.value)} placeholder="Suchen..." style={{ padding: '7px 12px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 13, width: 200 }} />
<div style={{ display: 'flex', gap: 5 }}>
{pill(netFilter === 'all', () => setNetFilter('all'), 'Alle')}
{pill(netFilter === 'up', () => setNetFilter('up'), 'UP')}
{pill(netFilter === 'down', () => setNetFilter('down'), 'DOWN')}
{pill(netFilter === 'unknown', () => setNetFilter('unknown'), 'UNBEKANNT')}
</div>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{filtered.length} Einträge</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
<button onClick={() => setShowDiscover(true)} style={{ padding: '7px 14px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>📡 Discovery</button>
<button onClick={() => { setEditDevice(null); setShowDeviceForm(true); }} style={{ padding: '7px 14px', borderRadius: 7, border: 'none', background: 'var(--accent)', color: '#fff', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>+ Gerät</button>
<button onClick={() => setShowDeploy(v => !v)} style={{ padding: '7px 12px', borderRadius: 7, border: '1px solid var(--border-color)', background: showDeploy ? 'var(--bg-secondary)' : 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: 13 }}>Agent {showDeploy ? '▴' : '▾'}</button>
<button onClick={() => setShowUnifiCfg(true)} title="Unifi Controller konfigurieren" style={{ padding: '7px 12px', borderRadius: 7, border: `1px solid ${unifiConfig?.enabled ? SC.ok + '66' : 'var(--border-color)'}`, background: 'none', color: unifiConfig?.enabled ? SC.ok : 'var(--text-muted)', cursor: 'pointer', fontSize: 13 }}>📶 Unifi</button>
</div>
</div>
{/* Deploy panel */}
{showDeploy && (
<div style={card({ marginBottom: 16, padding: 16 })}>
<div style={{ fontSize: 12, fontWeight: 700, borderLeft: '3px solid var(--accent)', paddingLeft: 8, marginBottom: 12 }}>Agent bereitstellen</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 10 }}>
{[{ href: '/downloads/IT-Nexus-Agent-Setup-v1.0.0.exe', icon: '⬇', title: 'Installer (.exe)', sub: 'Manuelle Installation' }, { href: '/downloads/IT-Nexus-Agent-Setup-v1.0.0.intunewin', icon: '📦', title: 'Intune (.intunewin)', sub: 'Microsoft Intune' }].map(({ href, icon, title, sub }) => (
<a key={href} href={href} download style={{ textDecoration: 'none', color: 'inherit' }}>
<div style={{ background: 'var(--bg-primary)', border: '1px solid var(--border-color)', borderRadius: 8, padding: '12px 14px', display: 'flex', alignItems: 'center', gap: 12, transition: 'border-color .15s' }} onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--accent)'} onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-color)'}>
<span style={{ fontSize: 22 }}>{icon}</span>
<div><div style={{ fontWeight: 700, fontSize: 13 }}>{title}</div><div style={{ color: 'var(--text-muted)', fontSize: 11 }}>{sub}</div></div>
</div>
</a>
))}
</div>
<div style={{ background: 'var(--bg-primary)', borderRadius: 7, padding: '10px 12px', fontSize: 11 }}>
<div style={{ display: 'grid', gridTemplateColumns: '130px 1fr', gap: '4px 10px' }}>
{[['Install', 'IT-Nexus-Agent-Setup-v1.0.0.exe /VERYSILENT /NORESTART'], ['Uninstall', '{app}\\unins000.exe /VERYSILENT'], ['Detect', 'C:\\ProgramData\\IT Nexus Agent\\it-nexus-agent.ps1']].map(([k, v]) => (
<React.Fragment key={k}><span style={{ color: 'var(--text-muted)' }}>{k}</span><code style={{ background: 'rgba(0,0,0,.3)', padding: '1px 7px', borderRadius: 3, fontFamily: 'monospace' }}>{v}</code></React.Fragment>
))}
</div>
</div>
</div>
)}
{/* Grouped device tables Checkmk-style */}
{Object.keys(grouped).length === 0
? <div style={{ textAlign: 'center', padding: 40, color: 'var(--text-muted)' }}>Keine Geräte gefunden</div>
: Object.entries(grouped).map(([type, devList]) => {
const meta = TYPE_META[type] || TYPE_META.host;
const collapsed = collapsedGroups.has(type);
const downCount = devList.filter(d => d.last_status === 'down').length;
return (
<div key={type} style={card({ marginBottom: 10, padding: 0, overflow: 'hidden' })}>
{/* Group header */}
<div onClick={() => toggleGroup(type)} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 16px', cursor: 'pointer', borderBottom: collapsed ? 'none' : '1px solid var(--border-color)', background: 'var(--bg-primary)', userSelect: 'none' }}>
<span style={{ fontSize: 15 }}>{meta.icon}</span>
<span style={{ fontWeight: 700, fontSize: 13 }}>{meta.label}</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)', background: 'var(--bg-secondary)', padding: '1px 7px', borderRadius: 10, border: '1px solid var(--border-color)' }}>{devList.length}</span>
{downCount > 0 && <StateSquare state="crit" text={`${downCount} DOWN`} />}
<span style={{ marginLeft: 'auto', color: 'var(--text-muted)', fontSize: 12 }}>{collapsed ? '▸' : '▾'}</span>
</div>
{!collapsed && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border-color)', background: 'rgba(255,255,255,0.02)' }}>
{['Status', 'Host', 'IP-Adresse', 'Standort', '24h Uptime', 'RTT', 'Letzter Check', 'Aktionen'].map(h => (
<th key={h} style={{ padding: '6px 12px', textAlign: 'left', fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: .4, whiteSpace: 'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{devList.map(d => {
const state = d.last_status === 'up' ? 'ok' : d.last_status === 'down' ? 'crit' : 'unknown';
const ut = uptimeStats[d.id];
return (
<tr key={d.id} onClick={() => setSelectedDevice(d)} style={{ borderBottom: '1px solid var(--border-color)', background: rowBg(state), borderLeft: `3px solid ${rowBorder(state)}`, cursor: 'pointer', transition: 'filter .1s' }}
onMouseEnter={e => e.currentTarget.style.filter = 'brightness(1.1)'}
onMouseLeave={e => e.currentTarget.style.filter = ''}>
<td style={{ padding: '8px 12px', whiteSpace: 'nowrap' }}><StateSquare state={state} text={netLabel(d.last_status)} /></td>
<td style={{ padding: '8px 12px', fontWeight: 600 }}>{d.name}</td>
<td style={{ padding: '8px 12px', fontFamily: 'monospace', color: 'var(--text-muted)', fontSize: 11 }}>{d.host}</td>
<td style={{ padding: '8px 12px', color: 'var(--text-muted)' }}>{d.location || ''}</td>
<td style={{ padding: '8px 12px', minWidth: 100 }}>
{ut?.total > 0 ? <PerfBar val={ut.total - ut.up} max={ut.total} warn={10} crit={20} labelVal={`${ut.pct}%`} /> : <span style={{ color: 'var(--text-muted)' }}></span>}
</td>
<td style={{ padding: '8px 12px', fontFamily: 'monospace', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>{d.last_rtt_ms != null ? `${Math.round(d.last_rtt_ms)} ms` : ''}</td>
<td style={{ padding: '8px 12px', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>{timeAgo(d.last_checked)}</td>
<td style={{ padding: '8px 12px' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', gap: 5 }}>
<button onClick={() => networkMonitorService.checkNow(d.id).then(() => toast.success('OK')).catch(() => {})} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: 11 }}>▶</button>
<button onClick={() => { setEditDevice(d); setShowDeviceForm(true); }} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: 11 }}>✎</button>
<button onClick={() => handleNetDelete(d)} style={{ padding: '2px 8px', borderRadius: 4, border: `1px solid ${SC.crit}44`, background: 'none', color: SC.crit, cursor: 'pointer', fontSize: 11 }}>✕</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
);
})
}
{/* ── Unifi-Geräte ─────────────────────────────────────────── */}
{unifiConfig?.enabled && unifiDevices.length > 0 && (() => {
const upStr = (s) => { if (!s) return ''; const h = Math.floor(s / 3600); return h < 24 ? `${h}h` : `${Math.floor(h / 24)}d`; };
const groups = [
{ key: 'uap', label: 'Unifi Access Points', icon: '📶', items: unifiDevices.filter(d => d.type === 'uap') },
{ key: 'usw', label: 'Unifi Switches', icon: '🔀', items: unifiDevices.filter(d => d.type === 'usw') },
{ key: 'other', label: 'Unifi Sonstige', icon: '🔌', items: unifiDevices.filter(d => d.type !== 'uap' && d.type !== 'usw') },
].filter(g => g.items.length > 0);
return groups.map(({ key, label, icon, items }) => {
const collapsed = collapsedGroups.has('unifi_' + key);
const downCount = items.filter(d => d.state !== 1).length;
const hasClients = items.some(d => d.num_sta > 0);
return (
<div key={key} style={card({ marginBottom: 10, padding: 0, overflow: 'hidden' })}>
<div onClick={() => toggleGroup('unifi_' + key)} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 16px', cursor: 'pointer', borderBottom: collapsed ? 'none' : '1px solid var(--border-color)', background: 'var(--bg-primary)', userSelect: 'none' }}>
<span style={{ fontSize: 15 }}>{icon}</span>
<span style={{ fontWeight: 700, fontSize: 13 }}>{label}</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)', background: 'var(--bg-secondary)', padding: '1px 7px', borderRadius: 10, border: '1px solid var(--border-color)' }}>{items.length}</span>
{downCount > 0 && <StateSquare state="crit" text={`${downCount} OFFLINE`} />}
<span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--text-muted)', fontStyle: 'italic' }}>via Unifi Controller</span>
<span style={{ color: 'var(--text-muted)', fontSize: 12, marginLeft: 6 }}>{collapsed ? '▸' : '▾'}</span>
</div>
{!collapsed && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border-color)', background: 'rgba(255,255,255,0.02)' }}>
{['Status','Name','IP','Modell','Uptime', hasClients && 'Clients','CPU','RAM'].filter(Boolean).map(h => (
<th key={h} style={{ padding: '6px 12px', textAlign: 'left', fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: .4, whiteSpace: 'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{items.map(d => (
<tr key={d.unifi_id} style={{ borderBottom: '1px solid var(--border-color)', background: d.state === 1 ? 'transparent' : `${SC.crit}0a`, borderLeft: `3px solid ${d.state === 1 ? 'transparent' : SC.crit}` }}>
<td style={{ padding: '8px 12px' }}><StateSquare state={d.state === 1 ? 'ok' : 'crit'} text={d.state === 1 ? 'ONLINE' : 'OFFLINE'} /></td>
<td style={{ padding: '8px 12px', fontWeight: 600 }}>{d.name || d.mac}</td>
<td style={{ padding: '8px 12px', fontFamily: 'monospace', color: 'var(--text-muted)', fontSize: 11 }}>{d.ip || ''}</td>
<td style={{ padding: '8px 12px', color: 'var(--text-muted)' }}>{d.model || ''}</td>
<td style={{ padding: '8px 12px', fontFamily: 'monospace', fontSize: 11 }}>{upStr(d.uptime)}</td>
{hasClients && <td style={{ padding: '8px 12px', textAlign: 'center' }}>
{d.num_sta > 0 ? <span style={{ background: `${SC.ok}22`, color: SC.ok, border: `1px solid ${SC.ok}44`, borderRadius: 10, padding: '1px 8px', fontSize: 11, fontWeight: 700, fontFamily: 'monospace' }}>{d.num_sta}</span> : <span style={{ color: 'var(--text-muted)' }}></span>}
</td>}
<td style={{ padding: '8px 12px', minWidth: 120 }}>{d.cpu_pct != null ? <PerfBar val={d.cpu_pct} max={100} warn={70} crit={90} unit="%" /> : <span style={{ color: 'var(--text-muted)' }}></span>}</td>
<td style={{ padding: '8px 12px', minWidth: 120 }}>{d.ram_pct != null ? <PerfBar val={d.ram_pct} max={100} warn={75} crit={90} unit="%" /> : <span style={{ color: 'var(--text-muted)' }}></span>}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
});
})()}
</>
);
};
// ─── Tab: Agents ──────────────────────────────────────────────────────────
const renderAgents = () => {
const isAgentOffline = (a) => {
if (!a.last_checkin) return true;
return Date.now() - new Date(a.last_checkin).getTime() > 5 * 60 * 1000;
};
const filtered = agents.filter(a => {
if (agentSearch && !a.hostname.toLowerCase().includes(agentSearch.toLowerCase()) && !(a.ip_address || '').includes(agentSearch)) return false;
if (agentFilter === 'online' && a.status !== 'online') return false;
if (agentFilter === 'offline' && a.status !== 'offline') return false;
if (agentFilter === 'warn' && warnings(a).length === 0) return false;
return true;
});
const sorted = [...filtered].sort((a, b) => {
const aOffline = isAgentOffline(a);
const bOffline = isAgentOffline(b);
// Offline immer ans Ende
if (aOffline !== bOffline) return aOffline ? 1 : -1;
if (agentSort === 'cpu') {
const av = a.cpu_usage_percent ?? (a.ram_total_gb > 0 ? (a.ram_used_gb / a.ram_total_gb) * 100 : 0);
const bv = b.cpu_usage_percent ?? (b.ram_total_gb > 0 ? (b.ram_used_gb / b.ram_total_gb) * 100 : 0);
return bv - av;
}
if (agentSort === 'ram') {
const av = a.ram_total_gb > 0 ? (a.ram_used_gb / a.ram_total_gb) * 100 : 0;
const bv = b.ram_total_gb > 0 ? (b.ram_used_gb / b.ram_total_gb) * 100 : 0;
return bv - av;
}
if (agentSort === 'name') return (a.hostname || '').localeCompare(b.hostname || '');
if (agentSort === 'seen') return new Date(b.last_checkin || 0) - new Date(a.last_checkin || 0);
return 0;
});
return (
<>
<div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap', alignItems: 'center' }}>
<input value={agentSearch} onChange={e => setAgentSearch(e.target.value)} placeholder="Hostname / IP..." style={{ padding: '7px 12px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 13, width: 220 }} />
<div style={{ display: 'flex', gap: 5 }}>
{pill(agentFilter === 'all', () => setAgentFilter('all'), 'Alle')}
{pill(agentFilter === 'online', () => setAgentFilter('online'), 'ONLINE')}
{pill(agentFilter === 'offline', () => setAgentFilter('offline'), 'OFFLINE')}
{pill(agentFilter === 'warn', () => setAgentFilter('warn'), 'WARNUNG')}
</div>
<select value={agentSort} onChange={e => setAgentSort(e.target.value)} style={{ padding: '5px 10px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 12, cursor: 'pointer' }}>
<option value="cpu">Sortierung: CPU-Auslastung</option>
<option value="ram">Sortierung: RAM-Auslastung</option>
<option value="name">Sortierung: Name</option>
<option value="seen">Sortierung: Zuletzt gesehen</option>
</select>
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 'auto' }}>{sorted.length} Einträge</span>
</div>
{sorted.length === 0
? <div style={{ textAlign: 'center', padding: 40, color: 'var(--text-muted)' }}>Keine Agents gefunden</div>
: (
/* Checkmk-style service table */
<div style={card({ padding: 0, overflow: 'hidden' })}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border-color)', background: 'var(--bg-primary)' }}>
<th style={{ width: 28 }} />
{['Host', 'Status', 'CPU', 'RAM', 'Disk', 'Uptime', 'Letzter Checkin', ''].map(h => (
<th key={h} style={{ padding: '7px 12px', textAlign: 'left', fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: .4 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{sorted.map(a => {
const w = warnings(a), hasErr = w.some(x => x.type === 'error');
const state = a.status === 'offline' ? 'crit' : hasErr ? 'crit' : w.length ? 'warn' : 'ok';
const isExp = expandedAgents.has(a.id);
const services = agentServices(a);
return (
<React.Fragment key={a.id}>
{/* Agent row */}
<tr style={{ borderBottom: isExp ? 'none' : '1px solid var(--border-color)', background: rowBg(state), borderLeft: `3px solid ${rowBorder(state)}`, cursor: 'pointer' }}
onClick={() => toggleAgent(a.id)}>
<td style={{ padding: '8px 6px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 11 }}>{isExp ? '▼' : '▶'}</td>
<td style={{ padding: '8px 12px', fontWeight: 700, whiteSpace: 'nowrap', fontFamily: 'monospace' }}>
<Link to={`/monitoring/device/${a.id}`} onClick={e => e.stopPropagation()} style={{ color: 'inherit', textDecoration: 'none', borderBottom: '1px dotted var(--text-muted)' }} title="Geräte-Details öffnen">
{a.hostname}
</Link>
</td>
<td style={{ padding: '8px 12px' }}><StateSquare state={state} text={state.toUpperCase()} /></td>
<td style={{ padding: '8px 12px', minWidth: 120 }}>
{a.cpu_usage_percent != null
? <PerfBar val={a.cpu_usage_percent} max={100} warn={80} crit={90} />
: <span style={{ color: 'var(--text-muted)' }}></span>}
</td>
<td style={{ padding: '8px 12px', minWidth: 120 }}>
{a.ram_total_gb > 0
? <PerfBar val={a.ram_used_gb} max={a.ram_total_gb} warn={85} crit={95} />
: <span style={{ color: 'var(--text-muted)' }}></span>}
</td>
<td style={{ padding: '8px 12px', minWidth: 120 }}>
{a.disk_total_gb > 0
? <PerfBar val={a.disk_total_gb - a.disk_free_gb} max={a.disk_total_gb} warn={75} crit={90} labelVal={`${a.disk_free_gb?.toFixed(0)} GB`} />
: <span style={{ color: 'var(--text-muted)' }}></span>}
</td>
<td style={{ padding: '8px 12px', color: 'var(--text-muted)', fontFamily: 'monospace', fontSize: 11 }}>{uptimeStr(a.uptime_hours)}</td>
<td style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 11, whiteSpace: 'nowrap' }}>{timeAgo(a.last_checkin)}</td>
<td style={{ padding: '8px 12px' }} onClick={e => e.stopPropagation()}>
<button onClick={() => setSelectedAgent(a)} style={{ padding: '2px 9px', borderRadius: 4, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: 11 }}>Details</button>
</td>
</tr>
{/* Expanded: service checks (Checkmk-style sub-table) */}
{isExp && (
<tr style={{ borderBottom: '1px solid var(--border-color)' }}>
<td />
<td colSpan={8} style={{ padding: '0 0 0 16px', background: 'rgba(255,255,255,0.02)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, marginBottom: 4 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border-color)' }}>
{['Status', 'Service', 'Perf-O-Meter', 'Wert', 'Details'].map(h => (
<th key={h} style={{ padding: '5px 10px', textAlign: 'left', fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: .4 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{services.map((svc, si) => (
<tr key={si} style={{ borderBottom: '1px solid var(--border-color)', background: rowBg(svc.status) }}>
<td style={{ padding: '6px 10px', borderLeft: `3px solid ${rowBorder(svc.status)}` }}>
<StateSquare state={svc.status} />
</td>
<td style={{ padding: '6px 10px', fontWeight: 600 }}>{svc.name}</td>
<td style={{ padding: '6px 10px', minWidth: 160 }}>
{svc.perf
? <PerfBar val={svc.perf.val} max={svc.perf.max} warn={svc.perf.warn} crit={svc.perf.crit} unit={svc.perf.unit} labelVal={svc.perf.labelVal} />
: <span style={{ color: 'var(--text-muted)', fontSize: 11 }}></span>}
</td>
<td style={{ padding: '6px 10px', fontFamily: 'monospace', fontSize: 11, color: SC[svc.status], fontWeight: 700 }}>{svc.value}</td>
<td style={{ padding: '6px 10px', color: 'var(--text-muted)', fontSize: 11 }}>{svc.detail}</td>
</tr>
))}
</tbody>
</table>
<div style={{ display: 'flex', gap: 8, padding: '8px 0 10px' }}>
<button onClick={() => setSelectedAgent(a)} style={{ padding: '4px 12px', borderRadius: 5, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: 11 }}>Software &amp; Details</button>
<button onClick={() => handleAgentDelete(a.id, a.hostname)} style={{ padding: '4px 12px', borderRadius: 5, border: `1px solid ${SC.crit}44`, background: 'none', color: SC.crit, cursor: 'pointer', fontSize: 11 }}>Entfernen</button>
</div>
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
)
}
</>
);
};
// ─── Tab: Probleme ────────────────────────────────────────────────────────
const renderProblems = () => {
const visible = showAcked ? problems : activeProblems;
const renderTable = (rows) => (
<div style={card({ padding: 0, overflow: 'hidden' })}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border-color)', background: 'var(--bg-primary)' }}>
{['Schwere', 'Status', 'Host', 'Service', 'Seit', 'Bestätigen'].map(h => (
<th key={h} style={{ padding: '8px 14px', textAlign: 'left', fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: .4 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{rows.map(p => {
const isAck = acknowledged.has(p.id);
const state = p.severity <= 1 ? 'crit' : 'warn';
return (
<tr key={p.id} style={{ borderBottom: '1px solid var(--border-color)', background: isAck ? 'transparent' : rowBg(state), borderLeft: `3px solid ${isAck ? 'transparent' : rowBorder(state)}`, opacity: isAck ? 0.5 : 1 }}>
<td style={{ padding: '9px 14px' }}>
<StateSquare state={state} text={state.toUpperCase()} size="lg" />
</td>
<td style={{ padding: '9px 14px' }}>
<StateSquare state={p.severity <= 1 ? 'crit' : 'warn'} text={p.statusLabel} />
{isAck && <span style={{ marginLeft: 6, fontSize: 10, fontWeight: 700, color: SC.unknown, background: `${SC.unknown}20`, padding: '1px 6px', borderRadius: 3, border: `1px solid ${SC.unknown}44` }}>ACK</span>}
</td>
<td style={{ padding: '9px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
<span style={{ fontSize: 15 }}>{p.typeIcon}</span>
<div>
<div style={{ fontWeight: 700, fontFamily: 'monospace' }}>{p.name}</div>
{p.raw?.host && <div style={{ fontSize: 10, color: 'var(--text-muted)' }}>{p.raw.host}</div>}
</div>
</div>
</td>
<td style={{ padding: '9px 14px', color: 'var(--text-muted)' }}>{p.service}</td>
<td style={{ padding: '9px 14px', fontFamily: 'monospace', color: 'var(--text-muted)', whiteSpace: 'nowrap', fontSize: 11 }}>{duration(p.lastCheck)}</td>
<td style={{ padding: '9px 14px' }}>
{!isAck
? <button onClick={() => setAcknowledged(prev => new Set([...prev, p.id]))} style={{ padding: '3px 12px', borderRadius: 5, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: 11, fontWeight: 600 }}>✓ Bestätigen</button>
: <button onClick={() => p.isDevice ? setSelectedDevice(p.raw) : setSelectedAgent(p.raw)} style={{ padding: '3px 12px', borderRadius: 5, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: 11 }}>Details</button>
}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
return (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 14 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 13, cursor: 'pointer', color: 'var(--text-muted)' }}>
<input type="checkbox" checked={showAcked} onChange={e => setShowAcked(e.target.checked)} style={{ accentColor: 'var(--accent)', width: 14, height: 14 }} />
Bestätigte anzeigen
</label>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{activeProblems.length} aktiv · {inactiveProblems.length} langfristig offline · {acknowledged.size} bestätigt</span>
</div>
{visible.length === 0 && inactiveProblems.length === 0
? (
<div style={{ textAlign: 'center', padding: '60px 20px', background: 'var(--bg-secondary)', borderRadius: 10, border: `1px solid ${SC.ok}30` }}>
<div style={{ fontSize: 40, marginBottom: 10 }}></div>
<div style={{ fontSize: 16, fontWeight: 700, color: SC.ok }}>Keine offenen Probleme</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 6 }}>Alle {devices.length + agents.length} Hosts sind in Ordnung</div>
</div>
)
: (
<>
{visible.length > 0 && renderTable(visible)}
{inactiveProblems.length > 0 && (
<div style={{ marginTop: 16 }}>
<button onClick={() => setCollapsedGroups(prev => { const n = new Set(prev); n.has('inactive') ? n.delete('inactive') : n.add('inactive'); return n; })} style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 8, cursor: 'pointer', fontSize: 12, color: 'var(--text-muted)', textAlign: 'left' }}>
<span style={{ fontSize: 10 }}>{collapsedGroups.has('inactive') ? '▶' : '▼'}</span>
<span style={{ fontWeight: 600 }}>Langfristig offline (&gt; {INACTIVE_DAYS} Tage)</span>
<span style={{ fontSize: 10, padding: '1px 6px', borderRadius: 8, background: `${SC.unknown}22`, color: SC.unknown, border: `1px solid ${SC.unknown}44` }}>{inactiveProblems.length}</span>
<span style={{ marginLeft: 'auto', fontSize: 11 }}>Geräte die längere Zeit nicht gesehen wurden</span>
</button>
{!collapsedGroups.has('inactive') && (
<div style={{ marginTop: 6, opacity: 0.6 }}>
{renderTable(inactiveProblems)}
</div>
)}
</div>
)}
</>
)
}
</>
);
};
const extUnacked = extAlerts.filter(a => !a.acknowledged).length;
const netgoAlerts = extAlerts.filter(a => a.source === 'netgo');
const netgoUnacked = netgoAlerts.filter(a => !a.acknowledged).length;
const awAlerts = extAlerts.filter(a => a.source === 'arcticwolf');
const awUnacked = awAlerts.filter(a => !a.acknowledged).length;
const mdoAlerts = extAlerts.filter(a => a.source === 'mdo');
const mdoUnacked = mdoAlerts.filter(a => !a.acknowledged).length;
const toggleAlertExpand = (id) => setExpandedAlerts(p => ({ ...p, [id]: !p[id] }));
const renderExtAlerts = () => {
const sevColor = (s) => s === 'CRIT' ? SC.crit : s === 'WARN' ? SC.warn : s === 'OK' ? SC.ok : SC.unknown;
const renderAlertCard = (alert) => {
const color = sevColor(alert.severity);
const isAW = alert.source === 'arcticwolf';
const expanded = expandedAlerts[alert.id];
let aw = null;
if (isAW && alert.raw_body) {
try { aw = JSON.parse(alert.raw_body); } catch { /* ignore */ }
}
const actions = (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
{!alert.ticket_id && (
<button onClick={() => handleExtCreateTicket(alert.id)} style={{ padding: '5px 12px', borderRadius: 7, border: `1px solid var(--accent)`, background: 'var(--accent)', color: '#fff', fontSize: 12, cursor: 'pointer', fontWeight: 600, whiteSpace: 'nowrap' }}>🎫 Ticket</button>
)}
{!alert.acknowledged && (
<button onClick={() => handleExtAcknowledge(alert.id)} style={{ padding: '5px 12px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap' }}> Quittieren</button>
)}
<button onClick={() => handleExtRemove(alert.id)} style={{ padding: '5px 12px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 12, cursor: 'pointer' }}>🗑</button>
</div>
);
// ── Arctic Wolf Detailkarte ──────────────────────────────────────
if (isAW) {
const labelStyle = { fontSize: 11, color: 'var(--text-muted)', minWidth: 130, flexShrink: 0 };
const valStyle = { fontSize: 12, color: 'var(--text-primary)', wordBreak: 'break-word' };
const row = (label, val) => val ? (
<div style={{ display: 'flex', gap: 8, padding: '4px 0', borderBottom: '1px solid var(--border-color)' }}>
<span style={labelStyle}>{label}</span>
<span style={valStyle}>{val}</span>
</div>
) : null;
const section = (title, text) => text ? (
<div style={{ marginTop: 10 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 4 }}>{title}</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', lineHeight: 1.6 }}>{text}</div>
</div>
) : null;
return (
<div key={alert.id} style={{ background: 'var(--bg-secondary)', border: `1px solid ${alert.acknowledged ? 'var(--border-color)' : color + '44'}`, borderLeft: `4px solid ${color}`, borderRadius: 10, padding: '14px 16px', opacity: alert.acknowledged ? 0.65 : 1 }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 8 }}>
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 6, background: color + '22', color, border: `1px solid ${color}44`, letterSpacing: 1 }}>
{aw?.severity_label || alert.severity}
</span>
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', padding: '2px 8px', borderRadius: 6 }}>ARCTIC WOLF</span>
{alert.acknowledged && <span style={{ fontSize: 10, padding: '2px 7px', borderRadius: 6, background: 'var(--bg-tertiary)', color: 'var(--text-muted)', border: '1px solid var(--border-color)' }}> Quittiert</span>}
{alert.ticket_id && <span style={{ fontSize: 10, padding: '2px 7px', borderRadius: 6, background: SC.ok + '22', color: SC.ok, border: `1px solid ${SC.ok}44` }}>🎫 Ticket #{alert.ticket_id}</span>}
</div>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 4 }}>{alert.message || aw?.incident_name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
🖥 {alert.device}{aw?.ip ? ` · ${aw.ip}` : ''}
{alert.state_time && <span style={{ marginLeft: 12 }}>🕐 {alert.state_time}</span>}
<span style={{ marginLeft: 12 }}>Eingang: {timeAgo(alert.created_at)}</span>
</div>
</div>
{actions}
</div>
{/* Schnell-Info-Grid */}
{aw && (
<div style={{ marginTop: 12, padding: '10px 12px', background: 'var(--bg-primary)', borderRadius: 8 }}>
{row('Event-Typ', aw.event_type)}
{row('Application', aw.application)}
{row('User', aw.user)}
{row('Process', aw.process)}
</div>
)}
{/* Aufklapp-Button */}
{aw && (aw.what_is_it || aw.why_matters || aw.next_steps || aw.recommend) && (
<button onClick={() => toggleAlertExpand(alert.id)} style={{ marginTop: 10, padding: '4px 12px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 12, cursor: 'pointer' }}>
{expanded ? '▲ Weniger' : '▼ Mehr Details'}
</button>
)}
{/* Erweiterte Sektionen */}
{expanded && aw && (
<div style={{ marginTop: 12, padding: '12px', background: 'var(--bg-primary)', borderRadius: 8, display: 'flex', flexDirection: 'column', gap: 2 }}>
{section('Was ist passiert?', aw.what_is_it)}
{section('Warum ist das relevant?', aw.why_matters)}
{section('Wie wurde es erkannt?', aw.how_detected)}
{section('Nächste Schritte', aw.next_steps)}
{section('Empfehlungen Arctic Wolf', aw.recommend)}
</div>
)}
</div>
);
}
// ── Standard-Karte (Netgo & andere) ────────────────────────────
return (
<div key={alert.id} style={{ background: 'var(--bg-secondary)', border: `1px solid ${alert.acknowledged ? 'var(--border-color)' : color + '44'}`, borderLeft: `4px solid ${color}`, borderRadius: 10, padding: '12px 16px', opacity: alert.acknowledged ? 0.6 : 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 7px', borderRadius: 6, background: color + '22', color, border: `1px solid ${color}44`, letterSpacing: 1 }}>{alert.severity || 'UNKNOWN'}</span>
{alert.acknowledged && <span style={{ fontSize: 10, padding: '2px 7px', borderRadius: 6, background: 'var(--bg-tertiary)', color: 'var(--text-muted)', border: '1px solid var(--border-color)' }}> Quittiert</span>}
{alert.ticket_id && <span style={{ fontSize: 10, padding: '2px 7px', borderRadius: 6, background: SC.ok + '22', color: SC.ok, border: `1px solid ${SC.ok}44` }}>🎫 Ticket #{alert.ticket_id}</span>}
<span style={{ fontSize: 11, color: 'var(--text-muted)', marginLeft: 4 }}>{alert.source?.toUpperCase()}</span>
</div>
{alert.device && <div style={{ fontSize: 14, fontWeight: 600, marginBottom: 2 }}>🖥 {alert.device}{alert.service ? ` · ${alert.service}` : ''}</div>}
{alert.state_transition && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 2 }}>Status: {alert.state_transition}</div>}
{alert.message && <div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 4, wordBreak: 'break-word' }}>{alert.message}</div>}
<div style={{ display: 'flex', gap: 14, marginTop: 6, fontSize: 11, color: 'var(--text-muted)' }}>
{alert.customer && <span>Kunde: {alert.customer}</span>}
{alert.state_time && <span>🕐 {alert.state_time}</span>}
<span>Eingang: {timeAgo(alert.created_at)}</span>
</div>
</div>
{actions}
</div>
</div>
);
};
const otherAlerts = extAlerts.filter(a => a.source !== 'netgo' && a.source !== 'arcticwolf' && a.source !== 'mdo');
const otherUnacked = otherAlerts.filter(a => !a.acknowledged).length;
const hiddenCount = otherAlerts.filter(a => a.acknowledged).length;
const visibleAlerts = hideAcknowledged ? otherAlerts.filter(a => !a.acknowledged) : otherAlerts;
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>{otherAlerts.length} Alert{otherAlerts.length !== 1 ? 's' : ''} · {otherUnacked} nicht quittiert</span>
{hiddenCount > 0 && (
<button onClick={() => setHideAcknowledged(h => !h)} style={{ padding: '3px 10px', borderRadius: 7, border: '1px solid var(--border-color)', background: hideAcknowledged ? 'var(--accent)' : 'none', color: hideAcknowledged ? '#fff' : 'var(--text-muted)', fontSize: 11, cursor: 'pointer' }}>
{hideAcknowledged ? `✓ Quittierte ausgeblendet (${hiddenCount})` : `Quittierte ausblenden (${hiddenCount})`}
</button>
)}
</div>
<button onClick={loadExtAlerts} style={{ padding: '5px 14px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 12, cursor: 'pointer' }}> Aktualisieren</button>
</div>
{visibleAlerts.length === 0 && (
<div style={{ textAlign: 'center', padding: 60, color: 'var(--text-muted)', fontSize: 14 }}> Keine sonstigen Alerts vorhanden</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{visibleAlerts.map(renderAlertCard)}
</div>
</div>
);
};
const renderNetGoAlerts = () => {
const sevColor = (s) => s === 'CRIT' ? SC.crit : s === 'WARN' ? SC.warn : s === 'OK' ? SC.ok : SC.unknown;
const parseAi = (alert) => {
if (!alert.ai_analysis) return null;
try { return typeof alert.ai_analysis === 'string' ? JSON.parse(alert.ai_analysis) : alert.ai_analysis; } catch { return null; }
};
const parseTransition = (st) => {
if (!st) return { from: '', to: '' };
const m = st.match(/^(.+?)\s+(?:to|→)\s+(.+)$/i);
return m ? { from: m[1].trim(), to: m[2].trim() } : { from: '', to: st };
};
const hiddenCount = netgoAlerts.filter(a => a.acknowledged).length;
const visible = hideAcknowledged ? netgoAlerts.filter(a => !a.acknowledged) : netgoAlerts;
// Kanban-Spalten
const columns = [
{ key: 'CRIT', label: 'Kritisch', icon: '🔴', color: SC.crit, alerts: visible.filter(a => (parseAi(a)?.severity || a.severity) === 'CRIT') },
{ key: 'WARN', label: 'Warnung', icon: '🟡', color: SC.warn, alerts: visible.filter(a => (parseAi(a)?.severity || a.severity) === 'WARN') },
{ key: 'OK', label: 'Wiederhergestellt', icon: '🟢', color: SC.ok, alerts: visible.filter(a => (parseAi(a)?.severity || a.severity) === 'OK') },
{ key: 'UNKNOWN', label: 'Unbekannt', icon: '⚪', color: SC.unknown, alerts: visible.filter(a => !['CRIT','WARN','OK'].includes(parseAi(a)?.severity || a.severity)) },
].filter(col => col.alerts.length > 0);
const KanbanCard = ({ alert }) => {
const ai = parseAi(alert);
const sev = ai?.severity || alert.severity;
const col = sevColor(sev);
const trans = parseTransition(alert.state_transition);
const stateFrom = ai?.state_from || trans.from;
const stateTo = ai?.state_to || trans.to;
const device = ai?.device || (alert.device?.length < 40 ? alert.device : '');
const service = ai?.service || (alert.service?.length < 80 ? alert.service : '');
return (
<div style={{
background: 'var(--bg-secondary)',
border: `1px solid ${col}33`,
borderTop: `3px solid ${col}`,
borderRadius: 10,
overflow: 'hidden',
opacity: alert.acknowledged ? 0.55 : 1,
fontSize: 12,
}}>
{/* Card-Header */}
<div style={{ padding: '10px 12px 8px', borderBottom: '1px solid var(--border-color)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 6 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.3 }}>
🖥 {device}
</div>
<span style={{ fontSize: 10, color: 'var(--text-muted)', flexShrink: 0, marginTop: 2 }}>{timeAgo(alert.created_at)}</span>
</div>
{service && <div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 2 }}>{service}</div>}
</div>
{/* State Transition */}
{(stateFrom || stateTo) && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', background: col + '08' }}>
{stateFrom && <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 7px', borderRadius: 5, background: `${SC.crit}22`, color: SC.crit }}>{stateFrom}</span>}
{stateFrom && stateTo && <span style={{ color: 'var(--text-muted)' }}></span>}
{stateTo && <span style={{ fontSize: 11, fontWeight: 700, padding: '2px 7px', borderRadius: 5, background: `${col}22`, color: col }}>{stateTo}</span>}
</div>
)}
{/* KI-Block */}
<div style={{ padding: '8px 12px' }}>
{ai?.metrics && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 6, padding: '3px 7px', background: 'var(--bg-tertiary)', borderRadius: 5, display: 'inline-block' }}>
📊 {ai.metrics}
</div>
)}
{ai?.what ? (
<>
<div style={{ fontSize: 12, color: 'var(--text-primary)', lineHeight: 1.5, marginBottom: 6 }}>{ai.what}</div>
{ai.recommendation && (
<div style={{ display: 'flex', gap: 5, padding: '5px 8px', borderRadius: 6, background: ai.action_needed ? `${SC.warn}12` : `${SC.ok}12`, border: `1px solid ${ai.action_needed ? SC.warn : SC.ok}30` }}>
<span style={{ flexShrink: 0 }}>{ai.action_needed ? '⚠️' : '✅'}</span>
<span style={{ fontSize: 11, color: 'var(--text-secondary)', lineHeight: 1.4 }}>{ai.recommendation}</span>
</div>
)}
</>
) : (
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}> KI-Analyse ausstehend</div>
)}
</div>
{/* Footer */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 12px 8px', borderTop: '1px solid var(--border-color)' }}>
<div style={{ display: 'flex', gap: 6 }}>
{ai && <span style={{ fontSize: 10, padding: '1px 6px', borderRadius: 4, background: 'rgba(124,92,246,0.12)', color: '#a78bfa' }}>🤖 KI</span>}
{alert.acknowledged && <span style={{ fontSize: 10, padding: '1px 6px', borderRadius: 4, background: 'var(--bg-tertiary)', color: 'var(--text-muted)' }}> Quittiert</span>}
</div>
<div style={{ display: 'flex', gap: 4 }}>
{!alert.acknowledged && (
<button onClick={() => handleExtAcknowledge(alert.id)} style={{ padding: '3px 9px', borderRadius: 6, border: `1px solid ${col}55`, background: col + '15', color: col, fontSize: 11, cursor: 'pointer', fontWeight: 600 }}></button>
)}
<button onClick={() => handleExtRemove(alert.id)} style={{ padding: '3px 7px', borderRadius: 6, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 11, cursor: 'pointer' }}>🗑</button>
</div>
</div>
</div>
);
};
return (
<div>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>
{netgoAlerts.length} Alerts · {netgoUnacked} nicht quittiert
</span>
{hiddenCount > 0 && (
<button onClick={() => setHideAcknowledged(h => !h)} style={{ padding: '3px 10px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 11, cursor: 'pointer' }}>
{hideAcknowledged ? `Quittierte einblenden (${hiddenCount})` : 'Quittierte ausblenden'}
</button>
)}
</div>
<button onClick={loadExtAlerts} style={{ padding: '5px 14px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 12, cursor: 'pointer' }}> Aktualisieren</button>
</div>
{visible.length === 0 && (
<div style={{ textAlign: 'center', padding: 60, color: 'var(--text-muted)', fontSize: 14 }}> Keine aktiven NetGo-Alerts</div>
)}
{/* Kanban Board */}
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${columns.length}, 1fr)`, gap: 16, alignItems: 'start' }}>
{columns.map(col => (
<div key={col.key}>
{/* Spalten-Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10, padding: '8px 12px', borderRadius: 8, background: col.color + '12', border: `1px solid ${col.color}33` }}>
<span style={{ fontSize: 14 }}>{col.icon}</span>
<span style={{ fontWeight: 700, fontSize: 13, color: col.color }}>{col.label}</span>
<span style={{ marginLeft: 'auto', fontSize: 11, fontWeight: 700, padding: '1px 7px', borderRadius: 10, background: col.color + '22', color: col.color }}>{col.alerts.length}</span>
</div>
{/* Cards */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{col.alerts.map(alert => <KanbanCard key={alert.id} alert={alert} />)}
</div>
</div>
))}
</div>
</div>
);
};
const renderArcticWolfAlerts = () => {
const sevColor = (s) => s === 'CRIT' ? SC.crit : s === 'WARN' ? SC.warn : s === 'OK' ? SC.ok : SC.unknown;
const hiddenCount = awAlerts.filter(a => a.acknowledged).length;
const visible = hideAcknowledged ? awAlerts.filter(a => !a.acknowledged) : awAlerts;
const renderAWCard = (alert) => {
const color = sevColor(alert.severity);
const expanded = expandedAlerts[alert.id];
let aw = null;
if (alert.raw_body) { try { aw = JSON.parse(alert.raw_body); } catch {} }
const labelStyle = { fontSize: 11, color: 'var(--text-muted)', minWidth: 130, flexShrink: 0 };
const valStyle = { fontSize: 12, color: 'var(--text-primary)', wordBreak: 'break-word' };
const row = (label, val) => val ? (
<div style={{ display: 'flex', gap: 8, padding: '4px 0', borderBottom: '1px solid var(--border-color)' }}>
<span style={labelStyle}>{label}</span><span style={valStyle}>{val}</span>
</div>
) : null;
const section = (title, text) => text ? (
<div style={{ marginTop: 10 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 4 }}>{title}</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', lineHeight: 1.6 }}>{text}</div>
</div>
) : null;
return (
<div key={alert.id} style={{ background: 'var(--bg-secondary)', border: `1px solid ${alert.acknowledged ? 'var(--border-color)' : color + '44'}`, borderLeft: `4px solid ${color}`, borderRadius: 10, padding: '14px 16px', opacity: alert.acknowledged ? 0.65 : 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 8 }}>
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 6, background: color + '22', color, border: `1px solid ${color}44`, letterSpacing: 1 }}>{aw?.severity_label || alert.severity}</span>
{alert.acknowledged && <span style={{ fontSize: 10, padding: '2px 7px', borderRadius: 6, background: 'var(--bg-tertiary)', color: 'var(--text-muted)', border: '1px solid var(--border-color)' }}> Quittiert</span>}
{alert.ticket_id && <span style={{ fontSize: 10, padding: '2px 7px', borderRadius: 6, background: SC.ok + '22', color: SC.ok, border: `1px solid ${SC.ok}44` }}>🎫 Ticket #{alert.ticket_id}</span>}
</div>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 4 }}>{alert.message || aw?.incident_name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
🖥 {alert.device}{aw?.ip ? ` · ${aw.ip}` : ''}
{alert.state_time && <span style={{ marginLeft: 12 }}>🕐 {alert.state_time}</span>}
<span style={{ marginLeft: 12 }}>Eingang: {timeAgo(alert.created_at)}</span>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
{!alert.ticket_id && (
<button onClick={() => handleExtCreateTicket(alert.id)} style={{ padding: '5px 12px', borderRadius: 7, border: `1px solid var(--accent)`, background: 'var(--accent)', color: '#fff', fontSize: 12, cursor: 'pointer', fontWeight: 600, whiteSpace: 'nowrap' }}>🎫 Ticket</button>
)}
{!alert.acknowledged && (
<button onClick={() => handleExtAcknowledge(alert.id)} style={{ padding: '5px 12px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap' }}> Quittieren</button>
)}
<button onClick={() => handleExtRemove(alert.id)} style={{ padding: '5px 12px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 12, cursor: 'pointer' }}>🗑</button>
</div>
</div>
{aw && (
<div style={{ marginTop: 12, padding: '10px 12px', background: 'var(--bg-primary)', borderRadius: 8 }}>
{row('Event-Typ', aw.event_type)}
{row('Application', aw.application)}
{row('User', aw.user)}
{row('Process', aw.process)}
</div>
)}
{aw && (aw.what_is_it || aw.why_matters || aw.next_steps || aw.recommend) && (
<button onClick={() => toggleAlertExpand(alert.id)} style={{ marginTop: 10, padding: '4px 12px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 12, cursor: 'pointer' }}>
{expanded ? '▲ Weniger' : '▼ Mehr Details'}
</button>
)}
{expanded && aw && (
<div style={{ marginTop: 12, padding: '12px', background: 'var(--bg-primary)', borderRadius: 8, display: 'flex', flexDirection: 'column', gap: 2 }}>
{section('Was ist passiert?', aw.what_is_it)}
{section('Warum ist das relevant?', aw.why_matters)}
{section('Wie wurde es erkannt?', aw.how_detected)}
{section('Nächste Schritte', aw.next_steps)}
{section('Empfehlungen Arctic Wolf', aw.recommend)}
</div>
)}
</div>
);
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>{awAlerts.length} Alert{awAlerts.length !== 1 ? 's' : ''} · {awUnacked} nicht quittiert</span>
{hiddenCount > 0 && (
<button onClick={() => setHideAcknowledged(h => !h)} style={{ padding: '3px 10px', borderRadius: 7, border: '1px solid var(--border-color)', background: hideAcknowledged ? 'var(--accent)' : 'none', color: hideAcknowledged ? '#fff' : 'var(--text-muted)', fontSize: 11, cursor: 'pointer' }}>
{hideAcknowledged ? `✓ Quittierte ausgeblendet (${hiddenCount})` : `Quittierte ausblenden (${hiddenCount})`}
</button>
)}
</div>
<button onClick={loadExtAlerts} style={{ padding: '5px 14px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 12, cursor: 'pointer' }}> Aktualisieren</button>
</div>
{visible.length === 0 && (
<div style={{ textAlign: 'center', padding: 60, color: 'var(--text-muted)', fontSize: 14 }}> Keine Arctic Wolf Alerts vorhanden</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{visible.map(renderAWCard)}
</div>
</div>
);
};
const renderMdoAlerts = () => {
const sevColor = (s) => s === 'CRIT' ? SC.crit : s === 'WARN' ? SC.warn : s === 'OK' ? SC.ok : SC.unknown;
const parseAi = (alert) => { try { return alert.ai_analysis ? (typeof alert.ai_analysis === 'string' ? JSON.parse(alert.ai_analysis) : alert.ai_analysis) : null; } catch { return null; } };
const parseRaw = (alert) => { try { return alert.raw_body ? (typeof alert.raw_body === 'string' ? JSON.parse(alert.raw_body) : alert.raw_body) : null; } catch { return null; } };
const hiddenCount = mdoAlerts.filter(a => a.acknowledged).length;
const visible = hideAcknowledged ? mdoAlerts.filter(a => !a.acknowledged) : mdoAlerts;
const labelStyle = { fontSize: 11, color: 'var(--text-muted)', minWidth: 140, flexShrink: 0 };
const valStyle = { fontSize: 12, color: 'var(--text-primary)', wordBreak: 'break-word' };
const row = (label, val) => val ? (
<div style={{ display: 'flex', gap: 8, padding: '4px 0', borderBottom: '1px solid var(--border-color)' }}>
<span style={labelStyle}>{label}</span><span style={valStyle}>{val}</span>
</div>
) : null;
const renderMdoCard = (alert) => {
const color = sevColor(alert.severity);
const expanded = expandedAlerts[alert.id];
const ai = parseAi(alert);
const raw = parseRaw(alert);
return (
<div key={alert.id} style={{ background: 'var(--bg-secondary)', border: `1px solid ${alert.acknowledged ? 'var(--border-color)' : color + '44'}`, borderLeft: `4px solid ${color}`, borderRadius: 10, padding: '14px 16px', opacity: alert.acknowledged ? 0.65 : 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 8 }}>
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 6, background: color + '22', color, border: `1px solid ${color}44`, letterSpacing: 1 }}>{alert.severity}</span>
<span style={{ fontSize: 11, fontWeight: 600, color: '#60a5fa', background: 'rgba(96,165,250,0.12)', padding: '2px 8px', borderRadius: 6, border: '1px solid rgba(96,165,250,0.3)' }}>MS DEFENDER</span>
{alert.acknowledged && <span style={{ fontSize: 10, padding: '2px 7px', borderRadius: 6, background: 'var(--bg-tertiary)', color: 'var(--text-muted)', border: '1px solid var(--border-color)' }}> Quittiert</span>}
{alert.ticket_id && <span style={{ fontSize: 10, padding: '2px 7px', borderRadius: 6, background: SC.ok + '22', color: SC.ok, border: `1px solid ${SC.ok}44` }}>🎫 Ticket #{alert.ticket_id}</span>}
</div>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 4 }}>{alert.message}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
👤 {alert.device || '(kein Benutzer)'}
{alert.service && <span style={{ marginLeft: 12 }}>📂 {alert.service}</span>}
{alert.state_time && <span style={{ marginLeft: 12 }}>🕐 {new Date(alert.state_time).toLocaleString('de-DE')}</span>}
<span style={{ marginLeft: 12 }}>Eingang: {timeAgo(alert.created_at)}</span>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
{!alert.ticket_id && <button onClick={() => handleExtCreateTicket(alert.id)} style={{ padding: '5px 12px', borderRadius: 7, border: `1px solid var(--accent)`, background: 'var(--accent)', color: '#fff', fontSize: 12, cursor: 'pointer', fontWeight: 600, whiteSpace: 'nowrap' }}>🎫 Ticket</button>}
{!alert.acknowledged && <button onClick={() => handleExtAcknowledge(alert.id)} style={{ padding: '5px 12px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap' }}> Quittieren</button>}
<button onClick={() => handleExtRemove(alert.id)} style={{ padding: '5px 12px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 12, cursor: 'pointer' }}>🗑</button>
</div>
</div>
{raw && (
<div style={{ marginTop: 12, padding: '10px 12px', background: 'var(--bg-primary)', borderRadius: 8 }}>
{row('Kategorie', raw.category)}
{row('Bedrohungsfamilie', raw.threatFamilyName)}
{row('Angriffsvektor', ai?.attack_vector || (raw.attackTechniques || [])[0])}
{row('Bedrohungstyp', ai?.threat_type)}
{row('Status (Defender)', alert.state_transition)}
</div>
)}
{(ai?.what || ai?.recommendation) && (
<button onClick={() => toggleAlertExpand(alert.id)} style={{ marginTop: 10, padding: '4px 12px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 12, cursor: 'pointer' }}>
{expanded ? '▲ Weniger' : '▼ KI-Analyse anzeigen'}
</button>
)}
{!ai && <div style={{ marginTop: 8, fontSize: 11, color: 'var(--text-muted)' }}> KI-Analyse ausstehend</div>}
{expanded && ai && (
<div style={{ marginTop: 12, padding: '12px', background: 'var(--bg-primary)', borderRadius: 8 }}>
{ai.what && (
<div style={{ marginBottom: 10 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 4 }}>Was ist passiert?</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', lineHeight: 1.6 }}>{ai.what}</div>
</div>
)}
{ai.recommendation && (
<div style={{ display: 'flex', gap: 5, padding: '8px 10px', borderRadius: 7, background: ai.action_needed ? `${SC.warn}12` : `${SC.ok}12`, border: `1px solid ${ai.action_needed ? SC.warn : SC.ok}30` }}>
<span style={{ flexShrink: 0 }}>{ai.action_needed ? '⚠️' : '✅'}</span>
<span style={{ fontSize: 12, color: 'var(--text-secondary)', lineHeight: 1.5 }}>{ai.recommendation}</span>
</div>
)}
</div>
)}
</div>
);
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>{mdoAlerts.length} Alert{mdoAlerts.length !== 1 ? 's' : ''} · {mdoUnacked} nicht quittiert</span>
{hiddenCount > 0 && (
<button onClick={() => setHideAcknowledged(h => !h)} style={{ padding: '3px 10px', borderRadius: 7, border: '1px solid var(--border-color)', background: hideAcknowledged ? 'var(--accent)' : 'none', color: hideAcknowledged ? '#fff' : 'var(--text-muted)', fontSize: 11, cursor: 'pointer' }}>
{hideAcknowledged ? `✓ Quittierte ausgeblendet (${hiddenCount})` : `Quittierte ausblenden (${hiddenCount})`}
</button>
)}
</div>
<button onClick={loadExtAlerts} style={{ padding: '5px 14px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 12, cursor: 'pointer' }}> Aktualisieren</button>
</div>
{visible.length === 0 && <div style={{ textAlign: 'center', padding: 60, color: 'var(--text-muted)', fontSize: 14 }}> Keine aktiven Microsoft Defender Alerts</div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>{visible.map(renderMdoCard)}</div>
</div>
);
};
const tabs = [
{ id: 'overview', label: '🖥 Host-Übersicht', badge: null },
{ id: 'network', label: 'Netzwerk', badge: devices.length + unifiDevices.length },
{ id: 'agents', label: 'Agents', badge: agents.length },
{ id: 'problems', label: 'Probleme', badge: activeProblems.length, red: activeProblems.length > 0 },
{ id: 'netgo', label: 'NetGo', badge: netgoUnacked, red: netgoUnacked > 0 },
{ id: 'arcticwolf', label: 'Arctic Wolf', badge: awUnacked, red: awUnacked > 0 },
{ id: 'mdo', label: 'MS Defender', badge: mdoUnacked, red: mdoUnacked > 0 },
{ id: 'ext-alerts', label: 'Sonstige', badge: extUnacked - netgoUnacked - awUnacked - mdoUnacked > 0 ? extUnacked - netgoUnacked - awUnacked - mdoUnacked : null, red: false },
];
return (
<div style={{ padding: 24 }}>
<style>{`
@keyframes monPulse { 0%,100%{opacity:1} 50%{opacity:.3} }
@keyframes nmPulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.5;transform:scale(1.3)} }
::-webkit-scrollbar { width:6px; height:6px; }
::-webkit-scrollbar-track { background:transparent; }
::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.12); border-radius:10px; }
::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,0.22); }
`}</style>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18 }}>
<h1 style={{ fontSize: 22, fontWeight: 700, margin: 0 }}>📡 Monitoring</h1>
</div>
{/* Checkmk-style Tactical Overview */}
<TacticalOverview devices={devices} agents={agents} problems={problems} acknowledged={acknowledged} countdown={countdown} lastUpdated={lastUpdated} onTabChange={setActiveTab} extAlerts={extAlerts} />
{/* Tab bar */}
<div style={{ display: 'flex', gap: 0, marginBottom: 20, borderBottom: '1px solid var(--border-color)' }}>
{tabs.map(({ id, label, badge, red }) => (
<button key={id} onClick={() => setActiveTab(id)} style={{ padding: '10px 22px', border: 'none', background: 'none', cursor: 'pointer', fontSize: 13, fontWeight: activeTab === id ? 700 : 400, color: activeTab === id ? 'var(--accent)' : 'var(--text-muted)', borderBottom: activeTab === id ? '2px solid var(--accent)' : '2px solid transparent', marginBottom: -1, transition: 'all .15s', display: 'flex', alignItems: 'center', gap: 7 }}>
{label}
{badge != null && badge > 0 && (
<span style={{ fontSize: 10, fontWeight: 700, padding: '1px 6px', borderRadius: 10, background: red ? `${SC.crit}22` : 'var(--bg-secondary)', color: red ? SC.crit : 'var(--text-muted)', border: `1px solid ${red ? SC.crit + '44' : 'var(--border-color)'}` }}>{badge}</span>
)}
</button>
))}
</div>
{activeTab === 'overview' && renderOverview()}
{activeTab === 'network' && renderNetwork()}
{activeTab === 'agents' && renderAgents()}
{activeTab === 'problems' && renderProblems()}
{activeTab === 'netgo' && renderNetGoAlerts()}
{activeTab === 'arcticwolf' && renderArcticWolfAlerts()}
{activeTab === 'mdo' && renderMdoAlerts()}
{activeTab === 'ext-alerts' && renderExtAlerts()}
{selectedDevice && <NetworkDetailModal device={selectedDevice} onClose={() => setSelectedDevice(null)} onEdit={d => { setEditDevice(d); setShowDeviceForm(true); }} onDelete={d => { handleNetDelete(d); setSelectedDevice(null); }} onCheckNow={networkMonitorService.checkNow} />}
{showDeviceForm && <DeviceFormModal device={editDevice} onSave={handleNetSave} onClose={() => { setShowDeviceForm(false); setEditDevice(null); }} />}
{showDiscover && <DiscoverModal onClose={() => setShowDiscover(false)} onAdd={handleNetSave} />}
{selectedAgent && <AgentDetailModal agent={selectedAgent} onClose={() => setSelectedAgent(null)} onDelete={handleAgentDelete} />}
{showUnifiCfg && <UnifiConfigModal unifiConfig={unifiConfig} onSave={cfg => { setUnifiConfig(cfg); loadUnifi(); toast.success('Gespeichert'); }} onClose={() => setShowUnifiCfg(false)} />}
</div>
);
};
export default MonitoringPage;