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 (
| Name |
Gateway |
Status |
RTT |
Loss |
{gateways.map((gw) => (
| {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
| Name |
Gateway |
Status |
RTT |
Loss |
{sys.gateways.map((gw) => (
| {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
| Dienst |
Beschreibung |
Status |
{sorted.map((svc) => {
const up = isRunning(svc)
return (
| {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)
)}
| Interface |
Public Key |
Endpoint |
Allowed IPs |
Transfer |
Handshake |
Status |
{peers.map((p, i) => {
const isActive = p.status === 'active' || p.handshake_age
return (
| {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 && (
)}
{sshCopied && (
SSH-Befehl kopiert — in CMD/Terminal einfuegen und ausfuehren
)}
{error &&
{error}
}
{/* Active Tunnels */}
Aktive Tunnel
{loading ? (
Laden...
) : tunnels.length === 0 ? (
Keine aktiven Tunnel
) : (
| Ziel |
Lokaler Zugang |
Status |
Erstellt |
Aktionen |
{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 (
| {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
) : (
| Name |
Public Key |
Tunnel IP |
Enabled |
Aktionen |
{peers.map((p) => (
| {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 (
| Ziel |
Gateway |
Flags |
Interface |
{routes.map((r, i) => (
| {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"
/>
| IP |
MAC |
Hostname |
Status |
Ablauf |
{leases.map((l, i) => (
| {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 */}
{expiredCount > 0 && (
{expiredCount}
Abgelaufen
)}
{soonCount > 0 && (
)}
{/* 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 */}
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) => (
))}
{/* SSH Logins */}
SSH-Logins
{logins.length === 0 ? (
Keine SSH-Login-Events
) : (
| Zeitstempel |
User |
Quell-IP |
Port |
Methode |
Status |
{logins.map((e, i) => (
| {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
) : (
| User |
TTY |
Von |
Login |
Logout |
Dauer |
{sessions.map((s, i) => (
| {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} />
{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' && (
)}
{showWebhook && (
)}
{error &&
{error}
}
)}
{/* Table */}
{loading ? (
Laden...
) : tasks.length === 0 ? (
Keine Jobs vorhanden
) : (
| Status |
Name |
Aktion |
Intervall |
Nächster Lauf |
Letzter Lauf |
Webhook |
Aktionen |
{tasks.map(t => (
|
{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
) : (
| ID |
Erstellt |
Groesse |
Hash |
Aktionen |
{backups.map((b) => (
| {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 && (
| Paket |
Aktuell |
Neu |
{pendingPkgs.map((pkg, i) => (
| {pkg.package} |
{pkg.current_version} |
{pkg.new_version} |
))}
)}
)}
{/* Normales Update */}
{/* Major Update */}
{/* Reboot */}
Reboot
Geraet manuell neu starten.
{/* Update-Ergebnis */}
{updateResult && (
Ergebnis
{updateResult.error ? (
{updateResult.error}
) : (
{updateResult.message && (
{updateResult.message}
)}
{updateResult.steps?.map((step, i) => (
{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
| Proxy |
Server |
Typ |
Status |
Sessions |
Requests |
Traffic In |
Traffic Out |
Fehler |
{entries.map((e, i) => (
| {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
| Adresse |
Status |
Aktive Req. |
Fehler |
{upstreams.map((u, i) => (
| {u.address} |
{u.healthy ? 'Healthy' : 'Unhealthy'}
|
{u.num_requests} |
{u.fails} |
))}
>
)}
Konfigurierte Sites
| Domain |
Upstream |
TLS |
{(caddy.sites || []).map((s, i) => (
| {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`
}