import { useEffect, useState } from 'react' import api from '../api/client' import StatusBadge from './StatusBadge' import { X, Cpu, MemoryStick, HardDrive, Clock, Network, Shield, Route, Globe, Key, Download, Terminal, Wifi, Search, Plus, Trash2, Copy, Check, ExternalLink, Unplug, Plug, MonitorSmartphone, } from 'lucide-react' const tabs = [ { id: 'overview', label: 'Uebersicht' }, { id: 'interfaces', label: 'Interfaces' }, { id: 'services', label: 'Dienste' }, { id: 'tunnels', label: 'Tunnel' }, { id: 'vpn', label: 'VPN' }, { id: 'wireguard', label: 'WireGuard' }, { id: 'routes', label: 'Routen' }, { id: 'dhcp', label: 'DHCP' }, { id: 'certs', label: 'Zertifikate' }, { id: 'backups', label: 'Backups' }, { id: 'agent', label: 'Agent' }, ] export default function AgentPanel({ agentId, customers, onClose, onReload }) { const [data, setData] = useState(null) const [tab, setTab] = useState('overview') const [loading, setLoading] = useState(true) useEffect(() => { setLoading(true) setTab('overview') api.getAgent(agentId).then(setData).finally(() => setLoading(false)) }, [agentId]) useEffect(() => { if (!data) return const iv = setInterval(() => { api.getAgent(agentId).then(setData) }, 30000) return () => clearInterval(iv) }, [agentId, data]) if (loading) return
Laden...
if (!data) return
Nicht gefunden
const { agent, system_data: sys, status } = data const cust = agent.customer_id ? customers.find((c) => c.id === agent.customer_id) : null return ( {/* Header */}

{agent.name}

{agent.hostname} · IP: {agent.ip} · {sys?.opnsense_version || agent.opnsense_version || '—'}
{/* Tabs */}
{tabs.map((t) => ( ))}
{/* Content */}
{!sys ? (
Keine Systemdaten
) : tab === 'overview' ? ( ) : tab === 'interfaces' ? ( ) : tab === 'services' ? ( ) : tab === 'tunnels' ? ( ) : tab === 'vpn' ? ( ) : tab === 'wireguard' ? ( ) : tab === 'routes' ? ( ) : tab === 'dhcp' ? ( ) : tab === 'certs' ? ( ) : tab === 'backups' ? ( ) : tab === 'agent' ? ( ) : null}
) } function CustomerAssign({ agent, customers, current, onReload }) { const [editing, setEditing] = useState(false) const [saving, setSaving] = useState(false) const handleChange = async (e) => { const val = e.target.value setSaving(true) try { if (val === '') { await api.unassignCustomer(agent.id) } else { await api.assignCustomer(agent.id, parseInt(val)) } if (onReload) onReload() } catch (err) { console.error(err) } finally { setSaving(false) setEditing(false) } } if (editing) { return (
{saving && Speichere...}
) } return (
setEditing(true)} title="Kunde aendern" > {current ? ( {current.number} — {current.name} ) : ( Kein Kunde zugewiesen (klicken zum Zuweisen) )}
) } function Panel({ onClose, children }) { return ( <>
{children}
) } function MetricCard({ icon: Icon, label, value, sub, bar, barColor }) { return (
{Icon && } {label}
{value}
{sub &&
{sub}
} {bar !== undefined && bar !== null && (
)}
) } function OverviewTab({ sys }) { const rootDisk = sys.disks?.find((d) => d.mount_point === '/') const diskPct = rootDisk && rootDisk.total_bytes > 0 ? (rootDisk.used_bytes / rootDisk.total_bytes * 100) : null const ramPct = sys.memory?.total_bytes > 0 ? (sys.memory.used_bytes / sys.memory.total_bytes * 100) : null const cpuPct = sys.cpu?.usage_percent const barColor = (v) => v > 90 ? 'bg-red-500' : v > 70 ? 'bg-yellow-500' : v > 50 ? 'bg-orange-500' : 'bg-green-500' const ifaceCount = sys.network_interfaces?.filter((i) => ['active', 'up'].includes(i.status)).length || 0 const gwCount = sys.gateways?.length || 0 const wgCount = (sys.wireguard || []).reduce((n, wg) => n + (wg.peers?.length || 0), 0) || sys.wireguard_peers?.length || 0 const routeCount = sys.routes?.length || 0 const certCount = sys.certificates?.length || 0 const svcRunning = sys.services?.filter((s) => s.running || s.status === 'running').length || 0 const svcTotal = sys.services?.length || 0 return ( <>

System-Metriken

Firewall Status

{/* Lizenz */} {sys.license && ( <>

Lizenz

{sys.license.product_name || 'OPNsense'}
{sys.license.subscription && (
{sys.license.subscription}
)}
{sys.license.valid_to && (
{sys.license.days_remaining > 0 ? `${sys.license.days_remaining} Tage` : 'Abgelaufen'}
gueltig bis {sys.license.valid_to}
)}
{sys.license.valid_to && sys.license.days_remaining != null && (
)}
)} {/* Gateways */} {sys.gateways && sys.gateways.length > 0 && ( <>

Gateways

{sys.gateways.map((gw) => ( ))}
Name Gateway Status RTT Loss
{gw.name} {gw.gateway || gw.address} {gw.status || '—'} {gw.delay || '—'} {gw.loss || '—'}
)} ) } function StatusCard({ icon: Icon, value, label }) { return (
{Icon && }
{value}
{label}
) } function InterfacesTab({ sys }) { const ifaces = sys.network_interfaces || [] return (

Netzwerk-Interfaces

{ifaces.map((i) => { const isUp = i.status === 'active' || i.status === 'up' const ip = i.ip || i.addresses?.filter((a) => !a.includes(':')).join(', ') || '' const role = i.role || i.description || '' return (
{/* Icon */}
{/* Info */}
{i.name} {role && ( {role} )} {!isUp && ( {i.status || 'down'} )}
{ip && {ip}} {i.mac && {i.mac}}
{/* Traffic */}
{formatBytes(i.rx_bytes)}
{formatBytes(i.tx_bytes)}
) })}
) } const SERVICE_DESCRIPTIONS = { configd: 'Configuration Daemon', dhcpd: 'DHCP Server', dpinger: 'Gateway Monitor', openssh: 'Secure Shell', syslogd: 'System Logger', unbound: 'DNS Resolver', openvpn: 'OpenVPN', cron: 'Cron Daemon', ntpd: 'NTP Daemon', 'syslog-ng': 'Syslog-NG', haproxy: 'HAProxy Load Balancer', nginx: 'Nginx Webserver', squid: 'Squid Proxy', suricata: 'Suricata IDS/IPS', wireguard: 'WireGuard VPN', pf: 'Packet Filter', routing: 'Routing Daemon', radvd: 'Router Advertisement', dnsmasq: 'DNS Forwarder', monit: 'Process Monitor', collectd: 'System Statistics', rmm_agent: 'RMM Agent', } function isRunning(svc) { return svc.running || svc.status === 'running' } function ServicesTab({ sys }) { const services = sys.services || [] const running = services.filter(isRunning) const stopped = services.filter((s) => !isRunning(s)) const sorted = [...services].sort((a, b) => isRunning(a) === isRunning(b) ? a.name.localeCompare(b.name) : isRunning(a) ? -1 : 1 ) return (
{running.length} aktiv / {stopped.length} inaktiv
{sorted.map((svc) => { const up = isRunning(svc) return ( ) })}
Dienst Beschreibung Status
{svc.name} {svc.description || SERVICE_DESCRIPTIONS[svc.name] || '—'} {up ? 'running' : 'stopped'}
) } function VPNTab({ sys }) { // Flatten: wireguard is array of interfaces, each with peers const wgInterfaces = sys.wireguard || [] const allPeers = [] wgInterfaces.forEach((wg) => { (wg.peers || []).forEach((p) => { allPeers.push({ ...p, iface: wg.interface, listen_port: wg.listen_port, iface_pubkey: wg.public_key }) }) }) // Fallback for flat wireguard_peers const peers = allPeers.length > 0 ? allPeers : (sys.wireguard_peers || []) if (peers.length === 0) return
Keine WireGuard-Peers
return (
{wgInterfaces.length > 0 && (
{wgInterfaces.length} Interface(s), {peers.length} Peer(s)
)}
{peers.map((p, i) => { const isActive = p.status === 'active' || p.handshake_age return ( ) })}
Interface Public Key Endpoint Allowed IPs Transfer Handshake Status
{p.iface || p.interface || '—'} {(p.public_key || '—').substring(0, 16)}... {p.endpoint || '—'} {(p.allowed_ips || []).join(', ') || '—'} {p.transfer_rx_bytes || p.transfer_rx ? `↓ ${formatBytes(p.transfer_rx_bytes || p.transfer_rx)} / ↑ ${formatBytes(p.transfer_tx_bytes || p.transfer_tx)}` : '—'} {p.handshake_age || p.latest_handshake || '—'} {isActive ? 'aktiv' : 'inaktiv'}
) } function TunnelTab({ agentId, agent }) { const [tunnels, setTunnels] = useState([]) const [loading, setLoading] = useState(true) const [creating, setCreating] = useState(null) // track which quick-button is active const [customOpen, setCustomOpen] = useState(false) const [customHost, setCustomHost] = useState('127.0.0.1') const [customPort, setCustomPort] = useState('') const [error, setError] = useState('') const backendHost = '192.168.85.13' const loadTunnels = () => { api.getTunnels(agentId) .then((resp) => { const list = resp?.tunnels || resp?.data?.tunnels || resp?.data || resp || [] setTunnels(Array.isArray(list) ? list : []) }) .catch(() => setTunnels([])) .finally(() => setLoading(false)) } useEffect(() => { loadTunnels() }, [agentId]) useEffect(() => { if (tunnels.length > 0) { const iv = setInterval(loadTunnels, 10000) return () => clearInterval(iv) } }, [tunnels.length, agentId]) const openTunnel = async (targetHost, targetPort, label) => { setError('') setCreating(label) try { const resp = await api.openTunnel(agentId, targetHost, targetPort) const data = resp?.data || resp // Reload tunnel list loadTunnels() // If WebGUI, open in new tab if (targetPort === 4444 && data?.proxy_port) { setTimeout(() => { window.open(`https://${backendHost}:${data.proxy_port}`, '_blank') }, 1000) } return data } catch (e) { setError(e.message) } finally { setCreating(null) } } const closeTunnel = async (tunnelId) => { try { await api.closeTunnel(agentId, tunnelId) setTunnels((prev) => prev.filter((t) => t.id !== tunnelId && t.tunnel_id !== tunnelId)) } catch (e) { setError(e.message) } } const handleCustom = () => { const port = parseInt(customPort) if (!port || port < 1 || port > 65535) return openTunnel(customHost, port, 'custom') setCustomOpen(false) setCustomPort('') } return (
{/* Quick Buttons */}
Schnellzugriff:
{/* Custom Tunnel Form */} {customOpen && (
setCustomHost(e.target.value)} className="bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-white w-40 focus:outline-none focus:border-orange-500" />
setCustomPort(e.target.value)} placeholder="z.B. 8080" className="bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-white w-28 focus:outline-none focus:border-orange-500" onKeyDown={(e) => e.key === 'Enter' && handleCustom()} />
)} {error &&
{error}
} {/* Active Tunnels */}

Aktive Tunnel

{loading ? (
Laden...
) : tunnels.length === 0 ? (
Keine aktiven Tunnel
) : (
{tunnels.map((t) => { const tid = t.id || t.tunnel_id const proxyPort = t.proxy_port || t.local_port const target = t.target_host && t.target_port ? `${t.target_host}:${t.target_port}` : t.target || '—' const proxyUrl = proxyPort ? `${backendHost}:${proxyPort}` : '—' const isWebGUI = t.target_port === 4444 || t.target_port === '4444' return ( ) })}
Ziel Lokaler Zugang Status Erstellt Aktionen
{target}
{proxyUrl} {isWebGUI && proxyPort && ( )}
aktiv {t.created_at ? new Date(t.created_at).toLocaleString('de-DE') : '—'}
)}
{/* Hint */}
Tunnel werden ueber den RMM-Backend-Server ({backendHost}) geroutet. WebGUI-Tunnel oeffnen automatisch ein neues Browserfenster.
) } function WireGuardTab({ agentId, sys }) { const [peers, setPeers] = useState([]) const [loading, setLoading] = useState(true) const [adding, setAdding] = useState(false) const [newPeer, setNewPeer] = useState({ name: '', dns: '10.172.100.210, 10.172.100.220' }) const [result, setResult] = useState(null) const [copied, setCopied] = useState(false) const [error, setError] = useState('') const [noInstance, setNoInstance] = useState(false) const loadPeers = () => { setLoading(true) setNoInstance(false) api.getWgPeers(agentId) .then((resp) => { const d = resp?.data || resp || {} setPeers(Array.isArray(d.peers) ? d.peers : Array.isArray(d) ? d : []) }) .catch((e) => { setPeers([]) if (e.message && e.message.includes('nicht gefunden')) setNoInstance(true) }) .finally(() => setLoading(false)) } useEffect(() => { loadPeers() }, [agentId]) const handleAdd = async () => { if (!newPeer.name.trim()) return setError('') try { const resp = await api.addWgPeer(agentId, { instance: 'ALWAYSON_VPN', name: newPeer.name.trim(), dns: newPeer.dns.trim(), }) setResult(resp?.data || resp) setAdding(false) setNewPeer({ name: '', dns: '10.172.100.210, 10.172.100.220' }) loadPeers() } catch (e) { setError(e.message) } } const handleDelete = async (uuid, name) => { if (!confirm(`Peer "${name || uuid}" wirklich loeschen?`)) return try { await api.deleteWgPeer(agentId, uuid) setResult(null) loadPeers() } catch (e) { setError(e.message) } } const copyConfig = (text) => { navigator.clipboard.writeText(text) setCopied(true) setTimeout(() => setCopied(false), 2000) } if (noInstance) { return
Kein ALWAYSON_VPN Tunnel auf dieser Firewall gefunden.
} return (
ALWAYSON_VPN Peer-Verwaltung
{/* Add Peer Form */} {adding && (
setNewPeer({ ...newPeer, name: e.target.value })} placeholder="z.B. Laptop-Christian" className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-orange-500" autoFocus />
setNewPeer({ ...newPeer, dns: e.target.value })} className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-orange-500" />
{error &&
{error}
}
)} {/* New Peer Result / Config */} {result && result.client_config && (
Peer erstellt — Client-Konfiguration:
            {result.client_config}
          
{result.assigned_ip && (
Zugewiesene IP: {result.assigned_ip}
)}
)} {/* Peer List */} {loading ? (
Laden...
) : peers.length === 0 ? (
Keine Peers konfiguriert
) : (
{peers.map((p) => ( ))}
Name Public Key Tunnel IP Enabled Aktionen
{p.name || p.description || '—'} {(p.public_key || p.pubkey || '—').substring(0, 20)}... {p.tunnel_address || p.allowed_ips || '—'} {p.enabled !== false ? 'Ja' : 'Nein'}
)}
) } function RoutesTab({ sys }) { const routes = sys.routes || [] if (routes.length === 0) return
Keine Routen
return (
{routes.map((r, i) => ( ))}
Ziel Gateway Flags Interface
{r.destination} {r.gateway} {r.flags} {r.interface_name || r.iface}
) } function DHCPTab({ sys }) { const [dhcpSearch, setDhcpSearch] = useState('') // dhcp can be { server, leases: [...] } or flat dhcp_leases const dhcp = sys.dhcp || {} const allLeases = dhcp.leases || sys.dhcp_leases || [] const server = dhcp.server || '' const leases = dhcpSearch ? allLeases.filter((l) => { const q = dhcpSearch.toLowerCase() return (l.ip || l.address || '').toLowerCase().includes(q) || (l.mac || l.hwaddr || '').toLowerCase().includes(q) || (l.hostname || '').toLowerCase().includes(q) }) : allLeases if (allLeases.length === 0) return
Keine DHCP Leases
return (
{server &&
DHCP Server: {server.toUpperCase()} — {allLeases.length} Lease(s)
}
setDhcpSearch(e.target.value)} className="w-full bg-gray-800 border border-gray-700 rounded pl-10 pr-3 py-1.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-orange-500" />
{leases.map((l, i) => ( ))}
IP MAC Hostname Status Ablauf
{l.ip || l.address} {l.mac || l.hwaddr} {l.hostname || '—'} {l.status || '—'} {l.end || l.expires || l.expire || '—'}
) } function CertsTab({ sys }) { const certs = sys.certificates || [] if (certs.length === 0) return
Keine Zertifikate
const now = new Date() const remaining = (expires) => { if (!expires) return null const diff = expires - now if (diff < 0) return { text: 'Abgelaufen', color: 'text-red-400', bg: 'bg-red-500/10 border-red-500/20', barColor: 'bg-red-500', pct: 0 } const days = Math.floor(diff / 86400000) if (days > 365) { const y = Math.floor(days / 365) const m = Math.floor((days % 365) / 30) return { text: `${y}J ${m}M`, color: 'text-gray-400', bg: 'bg-gray-800/50 border-gray-700/50', barColor: 'bg-emerald-500', pct: 100 } } if (days > 7) return { text: `${days} Tage`, color: 'text-gray-400', bg: 'bg-gray-800/50 border-gray-700/50', barColor: 'bg-emerald-500', pct: Math.min(100, days / 3.65) } if (days > 0) return { text: `${days} Tage`, color: 'text-yellow-400', bg: 'bg-yellow-500/10 border-yellow-500/20', barColor: 'bg-yellow-500', pct: Math.min(100, days / 3.65) } return { text: 'Heute', color: 'text-red-400', bg: 'bg-red-500/10 border-red-500/20', barColor: 'bg-red-500', pct: 0 } } const sorted = [...certs].sort((a, b) => { const ea = a.not_after ? new Date(a.not_after) : new Date('2099-01-01') const eb = b.not_after ? new Date(b.not_after) : new Date('2099-01-01') return ea - eb }) const expiredCount = sorted.filter(c => c.not_after && new Date(c.not_after) < now).length const soonCount = sorted.filter(c => { if (!c.not_after) return false const e = new Date(c.not_after) return e >= now && (e - now) < 7 * 86400000 }).length return (
{/* Summary */}
{sorted.length}
Gesamt
{expiredCount > 0 && (
{expiredCount}
Abgelaufen
)} {soonCount > 0 && (
{soonCount}
<7 Tage
)}
{/* Certificate Cards */}
{sorted.map((c, i) => { const expires = c.not_after ? new Date(c.not_after) : null const r = remaining(expires) const type = c.type || (c.is_ca ? 'CA' : 'Cert') return (
{c.name || c.subject || '--'} {type}
{r ? ( {r.text} ) : ( -- )}
{r && r.pct !== undefined && (
)}
) })}
) } function BackupsTab({ agentId }) { const [backups, setBackups] = useState([]) const [loading, setLoading] = useState(true) const [creating, setCreating] = useState(false) const [msg, setMsg] = useState('') const loadBackups = () => { api.getBackups(agentId) .then((resp) => { const list = Array.isArray(resp) ? resp : (resp?.data || resp?.backups || []) setBackups(Array.isArray(list) ? list : []) }) .catch(() => setBackups([])) .finally(() => setLoading(false)) } useEffect(() => { loadBackups() }, [agentId]) const triggerBackup = async () => { setCreating(true) setMsg('') try { const resp = await api.triggerBackup(agentId) const data = resp?.data || resp if (data?.new === false) { setMsg('Config unveraendert (Hash identisch)') } else { setMsg('Backup erstellt') } setTimeout(loadBackups, 1000) } catch (e) { setMsg('Fehler: ' + e.message) } finally { setCreating(false) } } if (loading) return
Laden...
return (
{msg && {msg}}
{backups.length === 0 ? (
Keine Backups vorhanden
) : (
{backups.map((b) => ( ))}
ID Erstellt Groesse Hash Aktionen
{b.id} {new Date(b.created_at).toLocaleString('de-DE')} {formatBytes(b.size)} {(b.hash || '').substring(0, 16)}...
)}
) } function AgentTab({ agent, status }) { const [updating, setUpdating] = useState(false) const [msg, setMsg] = useState('') const requestUpdate = async () => { setUpdating(true) try { await api.requestAgentUpdate(agent.id) setMsg('Update angefordert — wird beim naechsten Updater-Zyklus ausgefuehrt') } catch (err) { setMsg(`Fehler: ${err.message}`) } finally { setUpdating(false) } } const lastHB = agent?.last_heartbeat ? new Date(agent.last_heartbeat) : null return (
{/* Agent Update */}

Agent Update

Aktualisiert die Agent-Dateien remote ueber den bestehenden Agent-Kanal.

{msg &&
{msg}
}
{/* Nuetzliche Befehle */}

Nuetzliche Befehle

{[ { label: 'Service Status pruefen:', cmd: 'service rmm_agent status' }, { label: 'Logs anzeigen:', cmd: 'tail -f /var/log/rmm-agent.log' }, { label: 'Agent neustarten:', cmd: 'service rmm_agent restart' }, { label: 'Updater Status:', cmd: 'service rmm_updater status' }, { label: 'Updater Logs:', cmd: 'tail -f /var/log/rmm-updater.log' }, ].map((item, i) => (
{item.label} {item.cmd}
))}
{/* Agent Info */}

Agent Info

Agent Version: {agent?.agent_version || '--'}
Status: {status === 'online' ? 'Online' : status === 'stale' ? 'Verzoegert' : 'Offline'}
Letzter Heartbeat: {lastHB ? lastHB.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '--'}
Agent ID: {agent?.id?.substring(0, 16) || '--'}...
Registriert: {agent?.registered_at ? new Date(agent.registered_at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '--'}
) } function gwColor(status) { const s = (status || '').toLowerCase() if (s === 'online' || s === 'none') return 'text-green-400' if (s === 'offline' || s === 'down') return 'text-red-400' return 'text-yellow-400' } function formatBytes(bytes) { if (!bytes || bytes === 0) return '0 B' const k = 1024 const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}` } function formatUptime(seconds) { const d = Math.floor(seconds / 86400) const h = Math.floor((seconds % 86400) / 3600) const m = Math.floor((seconds % 3600) / 60) if (d > 0) return `${d}d ${h}h ${m}m` return `${h}h ${m}m` }