2008 lines
166 KiB
JavaScript
2008 lines
166 KiB
JavaScript
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 & 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 (> {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;
|