import { useEffect, useState } from 'react' import api from '../api/client' import StatusBadge from './StatusBadge' import { copyToClipboard } from '../utils/clipboard' import { useSettingsStore } from '../stores/settings' import { X, Cpu, MemoryStick, HardDrive, Clock, Network, Shield, Route, Globe, Key, Download, Terminal, Wifi, Search, Plus, Trash2, Copy, Check, ExternalLink, Unplug, Plug, MonitorSmartphone, Ban, FileKey, UserCheck, Database, CalendarClock, Cable, Settings, Server, GitBranch, Play, Power, ChevronDown, ChevronRight, Pencil, } from 'lucide-react' // Two-level navigation structure (DATAZONE style) const mainCategories = [ { id: 'uebersicht', label: 'Uebersicht' }, { id: 'netzwerk', label: 'Netzwerk' }, { id: 'sicherheit', label: 'Sicherheit' }, { id: 'daten', label: 'Daten' }, { id: 'system', label: 'System' }, ] const subItems = { netzwerk: [ { id: 'interfaces', label: 'Interfaces', icon: Network }, { id: 'vpn', label: 'VPN (WireGuard)', icon: Shield, platforms: ['freebsd'] }, { id: 'routes', label: 'Routen', icon: GitBranch }, { id: 'dhcp', label: 'DHCP', icon: Server, platforms: ['freebsd'] }, { id: 'gateways', label: 'Gateways', icon: Globe }, ], sicherheit: [ { id: 'certs', label: 'Zertifikate', icon: FileKey, platforms: ['freebsd'] }, { id: 'security', label: 'Logins', icon: UserCheck, platforms: ['freebsd'] }, ], daten: [ { id: 'backups', label: 'Backups', icon: Database }, { id: 'tasks', label: 'Aufgaben', icon: CalendarClock, platforms: ['freebsd', 'linux'] }, { id: 'tunnels', label: 'Tunnel', icon: Cable }, ], system: [ { id: 'services', label: 'Dienste', icon: Settings }, { id: 'updates', label: 'Updates', icon: Download, platforms: ['freebsd'] }, { id: 'agent', label: 'Agent', icon: Cpu }, ], } export default function AgentPanel({ agentId, customers, onClose, onReload }) { const [data, setData] = useState(null) const [mainTab, setMainTab] = useState('uebersicht') const [subTab, setSubTab] = useState('overview') const [loading, setLoading] = useState(true) useEffect(() => { setLoading(true) setMainTab('uebersicht') setSubTab('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 const platform = agent.platform || 'freebsd' // Filter sub-items by platform and determine visible main categories const getVisibleSubs = (catId) => { const items = subItems[catId] || [] let filtered = items.filter(s => !s.platforms || s.platforms.includes(platform)) // Dynamic sub-tabs based on data availability if (catId === 'netzwerk') { if (sys?.haproxy) { filtered = [...filtered, { id: 'haproxy', label: 'HAProxy', icon: Server }] } if (sys?.caddy) { filtered = [...filtered, { id: 'caddy', label: 'Caddy', icon: Globe }] } } return filtered } const visibleMainTabs = mainCategories.filter(cat => { if (cat.id === 'uebersicht') return true return getVisibleSubs(cat.id).length > 0 }) const handleMainTabChange = (catId) => { setMainTab(catId) if (catId === 'uebersicht') { setSubTab('overview') } else { const subs = getVisibleSubs(catId) if (subs.length > 0) setSubTab(subs[0].id) } } // For "gateways" sub-tab, we render the OverviewTab's gateway section standalone const currentSubs = mainTab !== 'uebersicht' ? getVisibleSubs(mainTab) : [] const renderContent = () => { if (!sys) return
Keine Systemdaten
const tab = subTab if (tab === 'overview') return if (tab === 'interfaces') return if (tab === 'services') return if (tab === 'tunnels') return if (tab === 'vpn') return <>
if (tab === 'routes') return if (tab === 'dhcp') return if (tab === 'gateways') return if (tab === 'certs') return if (tab === 'updates') return if (tab === 'security') return if (tab === 'tasks') return if (tab === 'backups') return platform === 'linux' ? : if (tab === 'agent') return if (tab === 'haproxy') return if (tab === 'caddy') return return null } return ( {/* Header */}

{agent.name}

{agent.hostname} · IP: {agent.ip} · {platform === 'linux' ? 'Linux' : 'OPNsense'} · {sys?.opnsense_version || agent.opnsense_version || '—'}
{/* Main Category Tabs */}
{visibleMainTabs.map((cat) => ( ))}
{/* Content area with optional sub-nav */}
{/* Sub-Navigation (left sidebar) — hidden for Uebersicht */} {mainTab !== 'uebersicht' && currentSubs.length > 0 && (
{mainCategories.find(c => c.id === mainTab)?.label}
{currentSubs.map((item) => { const Icon = item.icon const isActive = subTab === item.id return ( ) })}
)} {/* Main content */}
{renderContent()}
) } 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 GatewaysTab({ sys }) { const gateways = sys.gateways || [] if (gateways.length === 0) return
Keine Gateways
return (
{gateways.map((gw) => ( ))}
Name Gateway Status RTT Loss
{gw.name} {gw.gateway || gw.address} {gw.status || '—'} {gw.delay || '—'} {gw.loss || '—'}
) } function Sparkline({ data, height = 40, color = '#3b82f6' }) { if (!data || data.length < 2) return null const vals = data.map(d => d.value) const min = Math.min(...vals) const max = Math.max(...vals) const range = max - min || 1 const w = 200 const h = height const pts = vals.map((v, i) => { const x = (i / (vals.length - 1)) * w const y = h - ((v - min) / range) * (h - 4) - 2 return `${x.toFixed(1)},${y.toFixed(1)}` }).join(' ') return ( ) } function MetricCard({ icon: Icon, label, value, sub, bar, barColor, chart }) { return (
{Icon && } {label}
{value}
{sub &&
{sub}
} {bar !== undefined && bar !== null && (
)}
{chart && (
{chart}
)}
) } function useMetricChart(agentId, metric) { const [chartData, setChartData] = useState([]) useEffect(() => { if (!agentId) return const from = new Date(Date.now() - 8 * 60 * 60 * 1000).toISOString() api.getMetrics(agentId, metric, `&from=${from}&limit=200`) .then(data => { if (Array.isArray(data)) setChartData(data) }) .catch(() => {}) }, [agentId, metric]) return chartData } function PFStatesCard({ agentId, sys }) { const chartData = useMetricChart(agentId, 'pf_states_current') if (!sys?.pf_states) return null const pf = sys.pf_states const usedPct = pf.hard_limit > 0 ? (pf.current_entries / pf.hard_limit * 100) : 0 const barColor = usedPct > 90 ? 'bg-red-500' : usedPct > 70 ? 'bg-yellow-500' : 'bg-green-500' return ( 0 ? `${pf.current_entries.toLocaleString('de-DE')} / ${pf.hard_limit.toLocaleString('de-DE')}` : `${pf.current_entries.toLocaleString('de-DE')}`} sub={`Search: ${pf.search_rate?.toFixed(1)}/s · Insert: ${pf.insert_rate?.toFixed(1)}/s`} bar={pf.hard_limit > 0 ? usedPct : null} barColor={barColor} chart={} /> ) } function CpuCard({ agentId, sys }) { const chartData = useMetricChart(agentId, 'cpu_usage') const cpuPct = sys?.cpu?.usage_percent const barColor = cpuPct > 90 ? 'bg-red-500' : cpuPct > 70 ? 'bg-yellow-500' : cpuPct > 50 ? 'bg-orange-500' : 'bg-green-500' return ( } /> ) } function RamCard({ agentId, sys }) { const chartData = useMetricChart(agentId, 'memory_used_percent') const ramPct = sys?.memory?.total_bytes > 0 ? (sys.memory.used_bytes / sys.memory.total_bytes * 100) : null const barColor = ramPct > 90 ? 'bg-red-500' : ramPct > 70 ? 'bg-yellow-500' : ramPct > 50 ? 'bg-orange-500' : 'bg-green-500' return ( } /> ) } function OverviewTab({ sys, agentId }) { 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

System 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 [sshCopied, setSshCopied] = useState(null) const backendHost = useSettingsStore.getState().getBackendHost() 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() // Auto-open for known services if (data?.proxy_port) { setTimeout(() => { if (targetPort === 4444) { window.open(`https://${backendHost}:${data.proxy_port}`, '_blank') } else if (targetPort === 22) { const cmd = `ssh root@${backendHost} -p ${data.proxy_port}` copyToClipboard(cmd) setSshCopied(data.proxy_port) setTimeout(() => setSshCopied(null), 4000) } }, 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()} />
)} {sshCopied && (
SSH-Befehl kopiert — in CMD/Terminal einfuegen und ausfuehren
)} {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' const isSSH = t.target_port === 22 || t.target_port === '22' return ( ) })}
Ziel Lokaler Zugang Status Erstellt Aktionen
{target}
{proxyUrl} {isWebGUI && proxyPort && ( )} {isSSH && 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) => { copyToClipboard(text) setCopied(true) setTimeout(() => setCopied(false), 2000) } if (noInstance) { return
Kein ALWAYSON_VPN Tunnel auf diesem Geraet 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 PVEBackupsTab({ sys }) { const [monthOffset, setMonthOffset] = useState(0) const backups = sys?.backups || {} const jobs = backups.jobs || [] const tasks = backups.tasks || [] // Stats const totalJobs = jobs.filter(j => j.enabled === 1 || j.enabled === true).length const okTasks = tasks.filter(t => t.status === 'OK').length const failedTasks = tasks.filter(t => t.status && t.status !== 'OK').length const successRate = tasks.length > 0 ? Math.round((okTasks / tasks.length) * 100) : null // Naechster Lauf const nextRuns = jobs.map(j => j.next_run).filter(Boolean).sort() const nextRun = nextRuns[0] ? new Date(nextRuns[0] * 1000) : null // Kalender const now = new Date() const calMonth = new Date(now.getFullYear(), now.getMonth() + monthOffset, 1) const year = calMonth.getFullYear() const month = calMonth.getMonth() const daysInMonth = new Date(year, month + 1, 0).getDate() const firstDayOfWeek = (new Date(year, month, 1).getDay() + 6) % 7 // Mo=0 // Tasks nach Tag gruppieren const tasksByDay = {} tasks.forEach(t => { const d = new Date(t.starttime * 1000) if (d.getFullYear() === year && d.getMonth() === month) { const day = d.getDate() if (!tasksByDay[day]) tasksByDay[day] = [] tasksByDay[day].push(t) } }) // Geplante Tage (aus Schedule-Pattern) const plannedDays = new Set() jobs.forEach(j => { if (!j.enabled) return const nr = j.next_run if (nr) { const d = new Date(nr * 1000) if (d.getFullYear() === year && d.getMonth() === month) { plannedDays.add(d.getDate()) } } }) const today = new Date() const isCurrentMonth = today.getFullYear() === year && today.getMonth() === month const dayNames = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] const monthNames = ['Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'] return (
{/* Stats */}
Jobs
{totalJobs}
30 Tage
{tasks.length}
Erfolgsrate
= 90 ? 'text-yellow-400' : 'text-red-400'}`}> {successRate !== null ? `${successRate}%` : '--'}
Naechstes
{nextRun ? nextRun.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }) + ', ' + nextRun.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : '--'}
{/* Kalender */}

{monthNames[month]} {year}

{dayNames.map(d => (
{d}
))} {/* Leere Zellen vor dem 1. */} {Array.from({ length: firstDayOfWeek }).map((_, i) => (
))} {/* Tage */} {Array.from({ length: daysInMonth }).map((_, i) => { const day = i + 1 const dayTasks = tasksByDay[day] || [] const isToday = isCurrentMonth && day === today.getDate() const hasOk = dayTasks.some(t => t.status === 'OK') const hasFailed = dayTasks.some(t => t.status && t.status !== 'OK') const isPlanned = plannedDays.has(day) return (
{day} {/* Dot indicators */}
{hasOk && } {hasFailed && } {isPlanned && !hasOk && !hasFailed && }
) })}
{/* Legende */}
OK Fehler Geplant
{/* Konfigurierte Jobs */}

Konfigurierte Jobs

{jobs.length === 0 ? (
Keine Backup-Jobs konfiguriert
) : (
{jobs.map((job, i) => { const nr = job.next_run ? new Date(job.next_run * 1000) : null const vmids = job.vmid || (job.all ? 'Alle' + (job.exclude ? ` (ohne ${job.exclude})` : '') : '--') return (
{vmids}
{job.schedule} · {job.storage} · {job.mode} {job.node && · Node: {job.node}}
{nr && (
Naechstes: {nr.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}, {nr.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
)}
) })}
)}
{/* Letzte Backups */} {tasks.length > 0 && (

Letzte Backups ({tasks.length})

{tasks.slice(0, 30).map((t, i) => { const start = new Date(t.starttime * 1000) const duration = t.endtime && t.starttime ? Math.round((t.endtime - t.starttime) / 60) : null return (
{start.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })} {start.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
{duration !== null && {duration} min} {t.status}
) })}
)}
) } function SecurityTab({ sys }) { const sec = sys?.security if (!sec) return
Keine Sicherheitsdaten verfuegbar
const summary = sec.summary || {} const logins = sec.ssh_logins || [] const sessions = sec.sessions || [] const cards = [ { label: 'Akzeptiert', value: summary.total_accepted || 0, color: 'text-green-400' }, { label: 'Abgelehnt', value: summary.total_failed || 0, color: 'text-red-400' }, { label: 'Eindeutige IPs', value: summary.unique_ips || 0, color: 'text-blue-400' }, { label: 'Letzter Login', value: summary.last_login ? new Date(summary.last_login).toLocaleString('de-DE') : '-', color: 'text-gray-300', small: true }, ] return (
{/* Summary Cards */}
{cards.map((c, i) => (
{c.label}
{c.value}
))}
{/* SSH Logins */}

SSH-Logins

{logins.length === 0 ? (
Keine SSH-Login-Events
) : (
{logins.map((e, i) => ( ))}
Zeitstempel User Quell-IP Port Methode Status
{e.timestamp ? new Date(e.timestamp).toLocaleString('de-DE') : '-'} {e.user} {e.source} {e.port} {e.method} {e.status}
)}
{/* Sessions */}

Sessions

{sessions.length === 0 ? (
Keine Sessions
) : (
{sessions.map((s, i) => ( ))}
User TTY Von Login Logout Dauer
{s.user} {s.tty} {s.from || '-'} {s.login} {s.logout || '-'} {s.duration || '-'}
)}
) } function TasksTab({ agentId, agentName }) { const [tasks, setTasks] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) const [showDialog, setShowDialog] = useState(false) const [editTask, setEditTask] = useState(null) const [creating, setCreating] = useState(false) const [error, setError] = useState('') const [showWebhook, setShowWebhook] = useState(false) const defaultForm = { name: '', action: 'backup', schedule_type: 'once', schedule_time: '02:00', scheduled_at: '', schedule_weekday: 1, schedule_monthday: 1, reboot: false, health_check: false, health_check_timeout: 600, webhook_url: '', active: true, } const jobTemplates = [ { label: 'Wöchentliches Update', icon: '🔄', form: { name: 'Wöchentliches Update', action: 'update', schedule_type: 'weekly', schedule_time: '02:00', schedule_weekday: 0, reboot: true, health_check: true, health_check_timeout: 600, webhook_url: '', active: true }, }, { label: 'Monatliches Update', icon: '📅', form: { name: 'Monatliches Update', action: 'update', schedule_type: 'monthly', schedule_time: '02:00', schedule_monthday: 1, reboot: true, health_check: true, health_check_timeout: 600, webhook_url: '', active: true }, }, { label: 'Tägliches Backup', icon: '💾', form: { name: 'Tägliches Backup', action: 'backup', schedule_type: 'daily', schedule_time: '03:00', reboot: false, health_check: false, health_check_timeout: 600, webhook_url: '', active: true }, }, { label: 'Monatliches Backup', icon: '🗄️', form: { name: 'Monatliches Backup', action: 'backup', schedule_type: 'monthly', schedule_time: '03:00', schedule_monthday: 1, reboot: false, health_check: false, health_check_timeout: 600, webhook_url: '', active: true }, }, { label: 'Update + ERP-Webhook', icon: '🔗', form: { name: 'Monatliches Update + Servicebericht', action: 'update', schedule_type: 'monthly', schedule_time: '02:00', schedule_monthday: 1, reboot: true, health_check: true, health_check_timeout: 600, webhook_url: '', active: true }, showWebhook: true, }, ] const [form, setForm] = useState(defaultForm) const loadTasks = () => { api.getTasks({ agent_id: agentId, limit: 50 }) .then(resp => { setTasks(resp.data || []); setTotal(resp.total || 0) }) .catch(() => setTasks([])) .finally(() => setLoading(false)) } useEffect(() => { loadTasks() }, [agentId]) useEffect(() => { const iv = setInterval(loadTasks, 15000); return () => clearInterval(iv) }, [agentId]) const handleCreate = async () => { setCreating(true); setError('') try { const data = { agent_id: agentId, name: form.name, action: form.action, schedule_type: form.schedule_type, schedule_time: form.schedule_time, active: form.active } if (form.schedule_type === 'once' && form.scheduled_at) { data.scheduled_at = new Date(form.scheduled_at).toISOString() } if (form.schedule_type === 'weekly') data.schedule_weekday = parseInt(form.schedule_weekday) if (form.schedule_type === 'monthly') data.schedule_monthday = parseInt(form.schedule_monthday) if (form.action === 'update') { data.reboot = form.reboot; data.health_check = form.health_check if (form.health_check) data.health_check_timeout = parseInt(form.health_check_timeout) || 600 } if (form.webhook_url) data.webhook_url = form.webhook_url await api.createTask(data) setShowDialog(false); setForm(defaultForm); setShowWebhook(false); loadTasks() } catch (e) { setError(e.message) } finally { setCreating(false) } } const handleToggle = async (id) => { try { await api.toggleTask(id); loadTasks() } catch (e) { setError(e.message) } } const handleDelete = async (id) => { if (!confirm('Job wirklich loeschen?')) return try { await api.deleteTask(id); loadTasks() } catch (e) { setError(e.message) } } const handleEdit = (t) => { setEditTask(t) setForm({ name: t.name || '', action: t.action, schedule_type: t.schedule_type || 'once', schedule_time: t.schedule_time || '02:00', scheduled_at: '', schedule_weekday: t.schedule_weekday ?? 1, schedule_monthday: t.schedule_monthday ?? 1, reboot: t.reboot || false, health_check: t.health_check || false, health_check_timeout: t.health_check_timeout || 600, webhook_url: t.webhook_url || '', active: t.active !== false, }) setShowWebhook(!!t.webhook_url) setShowDialog(true) } const handleSave = async () => { setCreating(true); setError('') try { const data = { name: form.name, schedule_type: form.schedule_type, schedule_time: form.schedule_time, active: form.active, reboot: form.reboot, health_check: form.health_check, health_check_timeout: parseInt(form.health_check_timeout) || 600, webhook_url: form.webhook_url, } if (form.schedule_type === 'weekly') data.schedule_weekday = parseInt(form.schedule_weekday) if (form.schedule_type === 'monthly') data.schedule_monthday = parseInt(form.schedule_monthday) await api.updateTask(editTask.id, data) setShowDialog(false); setEditTask(null); setForm(defaultForm); setShowWebhook(false); loadTasks() } catch (e) { setError(e.message) } finally { setCreating(false) } } const handleRunNow = async (id) => { try { await api.runTaskNow(id) setTimeout(loadTasks, 1500) } catch (e) { setError(e.message) } } const weekdays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'] const fmtInterval = (t) => { if (t.schedule_type === 'daily') return `Täglich ${t.schedule_time}` if (t.schedule_type === 'weekly') return `Wöchentlich ${weekdays[t.schedule_weekday || 0]} ${t.schedule_time}` if (t.schedule_type === 'monthly') return `Monatlich ${t.schedule_monthday || 1}. ${t.schedule_time}` return 'Einmalig' } const fmtDate = (d) => d ? new Date(d).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—' const inputCls = 'w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500' const labelCls = 'block text-xs text-gray-400 mb-1' return (
{total} Job(s)
{/* Modal */} {showDialog && (
setShowDialog(false)}>
e.stopPropagation()}>

{editTask ? `Job bearbeiten — ${editTask.name || `#${editTask.id}`}` : `Neuer Job${agentName ? ` — ${agentName}` : ''}`}

{!editTask && (

Vorlage wählen

{jobTemplates.map((tpl, i) => ( ))}
)}
setForm({...form, name: e.target.value})} placeholder="z.B. Monatliches Update" className={inputCls} />
setForm({...form, schedule_time: e.target.value})} className={inputCls} />
{form.schedule_type === 'once' && (
setForm({...form, scheduled_at: e.target.value})} className={inputCls} />
)} {form.schedule_type === 'weekly' && (
)} {form.schedule_type === 'monthly' && (
setForm({...form, schedule_monthday: e.target.value})} className={inputCls} />
)} {!editTask && (
{['backup', 'update'].map(a => ( ))}
)} {form.action === 'update' && (
{form.health_check && (
setForm({...form, health_check_timeout: e.target.value})} className={inputCls + ' max-w-[200px]'} />
)}
)}
{showWebhook && (
setForm({...form, webhook_url: e.target.value})} placeholder="https://erp.example.com/api/service-reports" className={inputCls + ' mt-1'} />

Empfangt: customer_id, hostname, opnsense_version, reachable, update_status

)}
{error &&
{error}
}
)} {/* Table */} {loading ? (
Laden...
) : tasks.length === 0 ? (
Keine Jobs vorhanden
) : (
{tasks.map(t => ( ))}
Status Name Aktion Intervall Nächster Lauf Letzter Lauf Webhook Aktionen
{t.status === 'running' && lauft} {t.status === 'failed' && fehler} {t.status === 'completed_with_warning' && warnung} {t.status === 'cancelled' && abgebr.}
{t.name || `Job #${t.id}`} {t.action} {fmtInterval(t)} {fmtDate(t.next_run)} {fmtDate(t.last_run)} {t.webhook_url ? ( t.webhook_sent ? OK : t.last_run ? Fehler : ) : }
)}
) } 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 UpdatesTab({ agentId }) { const [checking, setChecking] = useState(false) const [updating, setUpdating] = useState(false) const [result, setResult] = useState(null) const [updateResult, setUpdateResult] = useState(null) const [reboot, setReboot] = useState(false) const [majorVer, setMajorVer] = useState('') const [majorPhase, setMajorPhase] = useState('1') const [majorReboot, setMajorReboot] = useState(true) const checkUpdates = async () => { setChecking(true) setResult(null) try { const res = await api.checkUpdates(agentId) setResult(res.data || res) } catch (err) { setResult({ error: err.message }) } finally { setChecking(false) } } const runUpdate = async () => { if (!confirm('Update jetzt ausfuehren?' + (reboot ? ' (mit Reboot)' : ''))) return setUpdating(true) setUpdateResult(null) try { const res = await api.runUpdate(agentId, reboot) setUpdateResult(res.data || res) } catch (err) { setUpdateResult({ error: err.message }) } finally { setUpdating(false) } } const runMajorUpdate = async () => { if (!majorVer) return const phaseText = majorPhase === '1' ? 'Phase 1: Base+Kernel installieren' + (majorReboot ? ' + Reboot' : '') : 'Phase 2: Packages aktualisieren' if (!confirm(`Major-Update auf ${majorVer}?\n${phaseText}`)) return setUpdating(true) setUpdateResult(null) try { const res = await api.post(`/api/v1/agents/${agentId}/major-update`, { version: majorVer, phase: majorPhase, reboot: majorReboot }) setUpdateResult(res.data || res) } catch (err) { setUpdateResult({ error: err.message }) } finally { setUpdating(false) } } const runReboot = async () => { if (!confirm('Geraet jetzt neu starten?')) return try { await api.post(`/api/v1/agents/${agentId}/reboot`, { delay: 5 }) setUpdateResult({ message: 'Reboot in 5 Sekunden...' }) } catch (err) { setUpdateResult({ error: err.message }) } } const pendingPkgs = result?.pending_packages || [] return (
{/* Update Check */}

Update-Check

{result?.error && (
{result.error}
)} {result && !result.error && (
{/* Core Update */}
{result.core_update_available ? 'Core-Update verfuegbar' : 'Core ist aktuell'}
{result.core_update_info && result.core_update_available && (
{result.core_update_info}
)} {/* Reboot Required */} {result.reboot_required && (
Reboot erforderlich
)} {/* Package Updates */}
0 ? 'bg-orange-400' : 'bg-emerald-400'}`} /> {pendingPkgs.length > 0 ? `${pendingPkgs.length} Paket-Updates verfuegbar` : 'Alle Pakete aktuell'}
{/* Package List */} {pendingPkgs.length > 0 && (
{pendingPkgs.map((pkg, i) => ( ))}
Paket Aktuell Neu
{pkg.package} {pkg.current_version} {pkg.new_version}
)}
)}
{/* Normales Update */}

Normales Update

OPNsense Core + Paket-Updates installieren.

{/* Major Update */}

Major-Update

Upgrade auf neue OPNsense-Version (2 Phasen: Phase 1 = Base+Kernel + Reboot, Phase 2 = Packages).

setMajorVer(e.target.value)} placeholder="z.B. 26.1" className="bg-gray-900 border border-gray-700 text-white text-sm rounded px-3 py-1.5 w-28 focus:border-orange-500 focus:outline-none" /> {majorPhase === '1' && ( )}
{/* Reboot */}

Reboot

Geraet manuell neu starten.

{/* Update-Ergebnis */} {updateResult && (

Ergebnis

{updateResult.error ? (
{updateResult.error}
) : (
{updateResult.message && (
{updateResult.message}
)} {updateResult.steps?.map((step, i) => (
{step.step}
{step.output && (
{step.output}
)} {step.error &&
{step.error}
}
))} {updateResult.reboot_required && (
Reboot erforderlich {!updateResult.reboot_requested && ( )}
)}
)}
)}
) } function AgentTab({ agent, status, platform }) { 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

{(platform === 'linux' ? [ { label: 'Service Status:', cmd: 'systemctl status rmm-agent' }, { label: 'Logs anzeigen:', cmd: 'journalctl -u rmm-agent -f' }, { label: 'Agent neustarten:', cmd: 'systemctl restart rmm-agent' }, ] : [ { label: 'Service Status:', 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

Plattform: {platform === 'linux' ? 'Linux' : platform === 'freebsd' ? 'FreeBSD / OPNsense' : platform}
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 HAProxyTab({ sys }) { const ha = sys?.haproxy if (!ha) return
Keine HAProxy-Daten
const info = ha.info || {} const entries = ha.entries || [] return (

HAProxy Info

Proxies

{entries.map((e, i) => ( ))}
Proxy Server Typ Status Sessions Requests Traffic In Traffic Out Fehler
{e.proxy_name} {e.server_name} {e.type} {e.status} {e.current_sessions} / {e.total_sessions?.toLocaleString('de-DE')} {e.requests_total?.toLocaleString('de-DE') || '—'} {formatBytes(e.bytes_in)} {formatBytes(e.bytes_out)} {(e.errors_req || 0) + (e.errors_conn || 0) + (e.errors_resp || 0)}
) } function CaddyTab({ sys }) { const caddy = sys?.caddy if (!caddy) return
Keine Caddy-Daten
const upstreams = caddy.upstreams || [] const healthyCount = upstreams.filter(u => u.healthy).length const activeRequests = upstreams.reduce((s, u) => s + (u.num_requests || 0), 0) const totalFails = upstreams.reduce((s, u) => s + (u.fails || 0), 0) return (

Caddy Reverse Proxy

{upstreams.length > 0 && ( <>

Upstreams

{upstreams.map((u, i) => ( ))}
Adresse Status Aktive Req. Fehler
{u.address} {u.healthy ? 'Healthy' : 'Unhealthy'} {u.num_requests} {u.fails}
)}

Konfigurierte Sites

{(caddy.sites || []).map((s, i) => ( ))}
Domain Upstream TLS
{s.domain} {s.upstream} {s.tls ? 'Aktiv' : 'Aus'}
) } 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` }