1354 lines
55 KiB
JavaScript
1354 lines
55 KiB
JavaScript
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 <Panel onClose={onClose}><div className="p-6 text-gray-500">Laden...</div></Panel>
|
|
if (!data) return <Panel onClose={onClose}><div className="p-6 text-red-400">Nicht gefunden</div></Panel>
|
|
|
|
const { agent, system_data: sys, status } = data
|
|
const cust = agent.customer_id ? customers.find((c) => c.id === agent.customer_id) : null
|
|
|
|
return (
|
|
<Panel onClose={onClose}>
|
|
{/* Header */}
|
|
<div className="px-6 py-4 bg-gradient-to-r from-gray-800 to-gray-900 border-b border-gray-700">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-3">
|
|
<h2 className="text-lg font-bold text-white">{agent.name}</h2>
|
|
<StatusBadge status={status} />
|
|
</div>
|
|
<CustomerAssign agent={agent} customers={customers} current={cust} onReload={onReload} />
|
|
<div className="text-xs text-gray-500 mt-1">
|
|
{agent.hostname} · IP: {agent.ip} · {sys?.opnsense_version || agent.opnsense_version || '—'}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={async () => {
|
|
if (!confirm(`Firewall "${agent.name}" wirklich loeschen?`)) return
|
|
await api.deleteAgent(agent.id)
|
|
if (onReload) onReload()
|
|
onClose()
|
|
}}
|
|
className="text-gray-500 hover:text-red-400 transition-colors"
|
|
title="Firewall loeschen"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-white">
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-1 mt-4 overflow-x-auto text-xs">
|
|
{tabs.map((t) => (
|
|
<button
|
|
key={t.id}
|
|
onClick={() => setTab(t.id)}
|
|
className={`px-3 py-1.5 rounded-t whitespace-nowrap transition-colors ${
|
|
tab === t.id
|
|
? 'bg-gray-900 text-orange-400 border-t border-x border-gray-700'
|
|
: 'text-gray-400 hover:text-gray-200'
|
|
}`}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
|
{!sys ? (
|
|
<div className="text-gray-500">Keine Systemdaten</div>
|
|
) : tab === 'overview' ? (
|
|
<OverviewTab sys={sys} />
|
|
) : tab === 'interfaces' ? (
|
|
<InterfacesTab sys={sys} />
|
|
) : tab === 'services' ? (
|
|
<ServicesTab sys={sys} />
|
|
) : tab === 'tunnels' ? (
|
|
<TunnelTab agentId={agentId} agent={agent} />
|
|
) : tab === 'vpn' ? (
|
|
<VPNTab sys={sys} />
|
|
) : tab === 'wireguard' ? (
|
|
<WireGuardTab agentId={agentId} sys={sys} />
|
|
) : tab === 'routes' ? (
|
|
<RoutesTab sys={sys} />
|
|
) : tab === 'dhcp' ? (
|
|
<DHCPTab sys={sys} />
|
|
) : tab === 'certs' ? (
|
|
<CertsTab sys={sys} />
|
|
) : tab === 'backups' ? (
|
|
<BackupsTab agentId={agentId} />
|
|
) : tab === 'agent' ? (
|
|
<AgentTab agent={data?.agent} status={data?.status} />
|
|
) : null}
|
|
</div>
|
|
</Panel>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
<select
|
|
value={agent.customer_id || ''}
|
|
onChange={handleChange}
|
|
disabled={saving}
|
|
className="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-orange-500"
|
|
autoFocus
|
|
onBlur={() => !saving && setEditing(false)}
|
|
>
|
|
<option value="">— Nicht zugewiesen —</option>
|
|
{customers.sort((a, b) => a.number.localeCompare(b.number)).map((c) => (
|
|
<option key={c.id} value={c.id}>{c.number} — {c.name}</option>
|
|
))}
|
|
</select>
|
|
{saving && <span className="text-xs text-gray-500">Speichere...</span>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="text-sm mt-0.5 cursor-pointer hover:underline"
|
|
onClick={() => setEditing(true)}
|
|
title="Kunde aendern"
|
|
>
|
|
{current ? (
|
|
<span className="text-orange-400">{current.number} — {current.name}</span>
|
|
) : (
|
|
<span className="text-gray-500 italic">Kein Kunde zugewiesen (klicken zum Zuweisen)</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Panel({ onClose, children }) {
|
|
return (
|
|
<>
|
|
<div className="fixed inset-0 z-40 bg-black/50" onClick={onClose} />
|
|
<div className="fixed inset-y-0 right-0 z-50 w-full max-w-5xl bg-gray-900 border-l border-gray-800 flex flex-col shadow-2xl animate-slide-in">
|
|
{children}
|
|
</div>
|
|
<style>{`
|
|
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
|
.animate-slide-in { animation: slideIn 0.2s ease-out; }
|
|
`}</style>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function MetricCard({ icon: Icon, label, value, sub, bar, barColor }) {
|
|
return (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
<div className="flex items-center gap-2 text-gray-500 mb-2">
|
|
{Icon && <Icon className="w-4 h-4" />}
|
|
<span className="text-xs uppercase">{label}</span>
|
|
</div>
|
|
<div className="text-xl font-bold text-white">{value}</div>
|
|
{sub && <div className="text-xs text-gray-500 mt-0.5">{sub}</div>}
|
|
{bar !== undefined && bar !== null && (
|
|
<div className="mt-2 h-1.5 bg-gray-700 rounded overflow-hidden">
|
|
<div className={`h-full rounded ${barColor || 'bg-blue-500'}`} style={{ width: `${Math.min(bar, 100)}%` }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
<h3 className="text-sm font-medium text-gray-300">System-Metriken</h3>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<MetricCard icon={Cpu} label="CPU Auslastung" value={cpuPct != null ? `${cpuPct.toFixed(1)}%` : '—'}
|
|
sub={sys.cpu?.model} bar={cpuPct} barColor={barColor(cpuPct || 0)} />
|
|
<MetricCard icon={MemoryStick} label="RAM Auslastung" value={ramPct != null ? `${ramPct.toFixed(1)}%` : '—'}
|
|
sub={sys.memory ? `${formatBytes(sys.memory.used_bytes)} / ${formatBytes(sys.memory.total_bytes)}` : ''}
|
|
bar={ramPct} barColor={barColor(ramPct || 0)} />
|
|
<MetricCard icon={HardDrive} label="Festplatte" value={diskPct != null ? `${diskPct.toFixed(1)}%` : '—'}
|
|
sub={rootDisk ? `${formatBytes(rootDisk.used_bytes)} / ${formatBytes(rootDisk.total_bytes)}` : ''}
|
|
bar={diskPct} barColor={barColor(diskPct || 0)} />
|
|
<MetricCard icon={Clock} label="Uptime" value={sys.uptime_seconds ? formatUptime(sys.uptime_seconds) : '—'} />
|
|
</div>
|
|
|
|
<h3 className="text-sm font-medium text-gray-300 mt-4">Firewall Status</h3>
|
|
<div className="grid grid-cols-4 gap-3">
|
|
<StatusCard icon={Shield} value={sys.opnsense_version || '—'} label="OPNsense" />
|
|
<StatusCard icon={Network} value={ifaceCount} label="Interfaces" />
|
|
<StatusCard icon={Globe} value={gwCount} label="Gateways" />
|
|
<StatusCard icon={Wifi} value={wgCount} label="VPN Tunnel" />
|
|
<StatusCard icon={Route} value={routeCount} label="Routen" />
|
|
<StatusCard icon={Key} value={certCount} label="Zertifikate" />
|
|
<StatusCard value={`${svcRunning}/${svcTotal}`} label="Dienste" />
|
|
</div>
|
|
|
|
{/* Lizenz */}
|
|
{sys.license && (
|
|
<>
|
|
<h3 className="text-sm font-medium text-gray-300 mt-4">Lizenz</h3>
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-white font-medium">{sys.license.product_name || 'OPNsense'}</div>
|
|
{sys.license.subscription && (
|
|
<div className="text-xs text-gray-500 mt-1 font-mono">{sys.license.subscription}</div>
|
|
)}
|
|
</div>
|
|
{sys.license.valid_to && (
|
|
<div className="text-right">
|
|
<div className={`text-lg font-bold ${
|
|
sys.license.days_remaining < 30 ? 'text-red-400' :
|
|
sys.license.days_remaining < 90 ? 'text-yellow-400' :
|
|
'text-green-400'
|
|
}`}>
|
|
{sys.license.days_remaining > 0 ? `${sys.license.days_remaining} Tage` : 'Abgelaufen'}
|
|
</div>
|
|
<div className="text-xs text-gray-500">gueltig bis {sys.license.valid_to}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{sys.license.valid_to && sys.license.days_remaining != null && (
|
|
<div className="mt-3 w-full h-2 bg-gray-700 rounded overflow-hidden">
|
|
<div
|
|
className={`h-full rounded ${
|
|
sys.license.days_remaining < 30 ? 'bg-red-500' :
|
|
sys.license.days_remaining < 90 ? 'bg-yellow-500' :
|
|
'bg-green-500'
|
|
}`}
|
|
style={{ width: `${Math.min(100, Math.max(2, sys.license.days_remaining / 365 * 100))}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Gateways */}
|
|
{sys.gateways && sys.gateways.length > 0 && (
|
|
<>
|
|
<h3 className="text-sm font-medium text-gray-300 mt-4">Gateways</h3>
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
|
<th className="px-3 py-2">Name</th>
|
|
<th className="px-3 py-2">Gateway</th>
|
|
<th className="px-3 py-2">Status</th>
|
|
<th className="px-3 py-2">RTT</th>
|
|
<th className="px-3 py-2">Loss</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{sys.gateways.map((gw) => (
|
|
<tr key={gw.name}>
|
|
<td className="px-3 py-2 text-white">{gw.name}</td>
|
|
<td className="px-3 py-2 text-gray-400">{gw.gateway || gw.address}</td>
|
|
<td className="px-3 py-2">
|
|
<span className={gwColor(gw.status)}>
|
|
{gw.status || '—'}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-400">{gw.delay || '—'}</td>
|
|
<td className="px-3 py-2 text-gray-400">{gw.loss || '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function StatusCard({ icon: Icon, value, label }) {
|
|
return (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
|
{Icon && <Icon className="w-5 h-5 mx-auto text-gray-500 mb-1" />}
|
|
<div className="text-lg font-bold text-white">{value}</div>
|
|
<div className="text-xs text-gray-500">{label}</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function InterfacesTab({ sys }) {
|
|
const ifaces = sys.network_interfaces || []
|
|
return (
|
|
<div className="space-y-3">
|
|
<h3 className="text-sm font-medium text-gray-300">Netzwerk-Interfaces</h3>
|
|
{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 (
|
|
<div
|
|
key={i.name}
|
|
className="bg-gray-800/50 rounded-lg border border-gray-700/50 px-5 py-4 flex items-center gap-4"
|
|
>
|
|
{/* Icon */}
|
|
<div className="flex-shrink-0">
|
|
<Network className={`w-7 h-7 ${isUp ? 'text-blue-400' : 'text-gray-600'}`} />
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-white font-bold text-sm">{i.name}</span>
|
|
{role && (
|
|
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-900/50 text-blue-300 border border-blue-800/50">
|
|
{role}
|
|
</span>
|
|
)}
|
|
{!isUp && (
|
|
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-700 text-gray-400">
|
|
{i.status || 'down'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-gray-400 mt-1 flex items-center gap-3">
|
|
{ip && <span>{ip}</span>}
|
|
{i.mac && <span className="text-gray-500 font-mono">{i.mac}</span>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Traffic */}
|
|
<div className="flex-shrink-0 text-right text-xs">
|
|
<div className="text-gray-400">
|
|
<span className="text-blue-400">↓</span> {formatBytes(i.rx_bytes)}
|
|
</div>
|
|
<div className="text-gray-400 mt-0.5">
|
|
<span className="text-green-400">↑</span> {formatBytes(i.tx_bytes)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div className="space-y-3">
|
|
<div className="text-sm text-gray-400">{running.length} aktiv / {stopped.length} inaktiv</div>
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
|
<th className="px-4 py-2 font-medium">Dienst</th>
|
|
<th className="px-4 py-2 font-medium">Beschreibung</th>
|
|
<th className="px-4 py-2 font-medium text-right">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{sorted.map((svc) => {
|
|
const up = isRunning(svc)
|
|
return (
|
|
<tr key={svc.name}>
|
|
<td className="px-4 py-2.5 text-white font-medium">{svc.name}</td>
|
|
<td className="px-4 py-2.5 text-gray-400">{svc.description || SERVICE_DESCRIPTIONS[svc.name] || '—'}</td>
|
|
<td className="px-4 py-2.5 text-right">
|
|
<span
|
|
className={`inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1 rounded ${
|
|
up
|
|
? 'bg-green-900/40 text-green-400 border border-green-700/50'
|
|
: 'bg-gray-800 text-gray-500 border border-gray-700'
|
|
}`}
|
|
>
|
|
<span className={`w-1.5 h-1.5 rounded-full ${up ? 'bg-green-400' : 'bg-gray-500'}`} />
|
|
{up ? 'running' : 'stopped'}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 <div className="text-gray-500">Keine WireGuard-Peers</div>
|
|
return (
|
|
<div className="space-y-3">
|
|
{wgInterfaces.length > 0 && (
|
|
<div className="text-sm text-gray-400">{wgInterfaces.length} Interface(s), {peers.length} Peer(s)</div>
|
|
)}
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
|
<th className="px-3 py-2">Interface</th>
|
|
<th className="px-3 py-2">Public Key</th>
|
|
<th className="px-3 py-2">Endpoint</th>
|
|
<th className="px-3 py-2">Allowed IPs</th>
|
|
<th className="px-3 py-2">Transfer</th>
|
|
<th className="px-3 py-2">Handshake</th>
|
|
<th className="px-3 py-2">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{peers.map((p, i) => {
|
|
const isActive = p.status === 'active' || p.handshake_age
|
|
return (
|
|
<tr key={i}>
|
|
<td className="px-3 py-2 text-white font-mono text-xs">{p.iface || p.interface || '—'}</td>
|
|
<td className="px-3 py-2 text-gray-400 font-mono text-xs">{(p.public_key || '—').substring(0, 16)}...</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{p.endpoint || '—'}</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{(p.allowed_ips || []).join(', ') || '—'}</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">
|
|
{p.transfer_rx_bytes || p.transfer_rx
|
|
? `↓ ${formatBytes(p.transfer_rx_bytes || p.transfer_rx)} / ↑ ${formatBytes(p.transfer_tx_bytes || p.transfer_tx)}`
|
|
: '—'}
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{p.handshake_age || p.latest_handshake || '—'}</td>
|
|
<td className="px-3 py-2">
|
|
<span className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded ${
|
|
isActive
|
|
? 'bg-green-900/40 text-green-400 border border-green-700/50'
|
|
: 'bg-gray-800 text-gray-500 border border-gray-700'
|
|
}`}>
|
|
<span className={`w-1.5 h-1.5 rounded-full ${isActive ? 'bg-green-400' : 'bg-gray-500'}`} />
|
|
{isActive ? 'aktiv' : 'inaktiv'}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div className="space-y-4">
|
|
{/* Quick Buttons */}
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm text-gray-400">Schnellzugriff:</span>
|
|
<button
|
|
onClick={() => openTunnel('127.0.0.1', 4444, 'webgui')}
|
|
disabled={creating === 'webgui'}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-600 hover:bg-orange-700 disabled:bg-orange-800 disabled:opacity-50 rounded text-sm text-white transition-colors"
|
|
>
|
|
<MonitorSmartphone className="w-4 h-4" />
|
|
{creating === 'webgui' ? 'Verbinde...' : 'WebGUI (4444)'}
|
|
</button>
|
|
<button
|
|
onClick={() => openTunnel('127.0.0.1', 22, 'ssh')}
|
|
disabled={creating === 'ssh'}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:opacity-50 rounded text-sm text-white transition-colors"
|
|
>
|
|
<Terminal className="w-4 h-4" />
|
|
{creating === 'ssh' ? 'Verbinde...' : 'SSH (22)'}
|
|
</button>
|
|
<button
|
|
onClick={() => setCustomOpen(!customOpen)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm text-gray-300 transition-colors"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Benutzerdefiniert
|
|
</button>
|
|
</div>
|
|
|
|
{/* Custom Tunnel Form */}
|
|
{customOpen && (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4 flex items-end gap-3">
|
|
<div>
|
|
<label className="block text-xs text-gray-400 mb-1">Ziel-Host</label>
|
|
<input
|
|
type="text"
|
|
value={customHost}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-400 mb-1">Port</label>
|
|
<input
|
|
type="number"
|
|
value={customPort}
|
|
onChange={(e) => 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()}
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={handleCustom}
|
|
className="px-4 py-2 bg-green-600 hover:bg-green-700 rounded text-sm text-white transition-colors"
|
|
>
|
|
Verbinden
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{error && <div className="text-sm text-red-400 bg-red-900/20 rounded px-3 py-2 border border-red-800/50">{error}</div>}
|
|
|
|
{/* Active Tunnels */}
|
|
<div>
|
|
<h3 className="text-sm font-medium text-gray-300 mb-2">Aktive Tunnel</h3>
|
|
{loading ? (
|
|
<div className="text-gray-500">Laden...</div>
|
|
) : tunnels.length === 0 ? (
|
|
<div className="text-gray-500 bg-gray-800/30 rounded-lg border border-gray-800 px-4 py-6 text-center text-sm">
|
|
Keine aktiven Tunnel
|
|
</div>
|
|
) : (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
|
<th className="px-3 py-2">Ziel</th>
|
|
<th className="px-3 py-2">Lokaler Zugang</th>
|
|
<th className="px-3 py-2">Status</th>
|
|
<th className="px-3 py-2">Erstellt</th>
|
|
<th className="px-3 py-2 text-right">Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{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 (
|
|
<tr key={tid}>
|
|
<td className="px-3 py-2.5 text-white font-mono text-xs">{target}</td>
|
|
<td className="px-3 py-2.5">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-gray-400 font-mono text-xs">{proxyUrl}</span>
|
|
{isWebGUI && proxyPort && (
|
|
<a
|
|
href={`https://${backendHost}:${proxyPort}`}
|
|
target="_blank"
|
|
rel="noopener"
|
|
className="text-orange-400 hover:text-orange-300"
|
|
title="WebGUI oeffnen"
|
|
>
|
|
<ExternalLink className="w-3.5 h-3.5" />
|
|
</a>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-3 py-2.5">
|
|
<span className="inline-flex items-center gap-1.5 text-xs text-green-400">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" />
|
|
aktiv
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2.5 text-gray-500 text-xs">
|
|
{t.created_at ? new Date(t.created_at).toLocaleString('de-DE') : '—'}
|
|
</td>
|
|
<td className="px-3 py-2.5 text-right">
|
|
<button
|
|
onClick={() => closeTunnel(tid)}
|
|
className="text-red-400 hover:text-red-300 p-1 rounded hover:bg-red-900/30 transition-colors"
|
|
title="Tunnel schliessen"
|
|
>
|
|
<Unplug className="w-4 h-4" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Hint */}
|
|
<div className="text-xs text-gray-600 mt-2">
|
|
Tunnel werden ueber den RMM-Backend-Server ({backendHost}) geroutet. WebGUI-Tunnel oeffnen automatisch ein neues Browserfenster.
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 <div className="text-gray-500">Kein ALWAYSON_VPN Tunnel auf dieser Firewall gefunden.</div>
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm text-gray-400">
|
|
ALWAYSON_VPN Peer-Verwaltung
|
|
</div>
|
|
<button
|
|
onClick={() => { setAdding(!adding); setResult(null); setError('') }}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-600 hover:bg-orange-700 rounded text-sm text-white transition-colors"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Neuer Peer
|
|
</button>
|
|
</div>
|
|
|
|
{/* Add Peer Form */}
|
|
{adding && (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4 space-y-3">
|
|
<div>
|
|
<label className="block text-xs text-gray-400 mb-1">Name / Beschreibung</label>
|
|
<input
|
|
type="text"
|
|
value={newPeer.name}
|
|
onChange={(e) => 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
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-400 mb-1">DNS Server</label>
|
|
<input
|
|
type="text"
|
|
value={newPeer.dns}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
{error && <div className="text-sm text-red-400">{error}</div>}
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handleAdd}
|
|
className="px-4 py-1.5 bg-green-600 hover:bg-green-700 rounded text-sm text-white transition-colors"
|
|
>
|
|
Anlegen
|
|
</button>
|
|
<button
|
|
onClick={() => { setAdding(false); setError('') }}
|
|
className="px-4 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm text-gray-300 transition-colors"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* New Peer Result / Config */}
|
|
{result && result.client_config && (
|
|
<div className="bg-gray-800/50 rounded-lg border border-green-700/50 p-4 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-green-400 font-medium">Peer erstellt — Client-Konfiguration:</span>
|
|
<button
|
|
onClick={() => copyConfig(result.client_config)}
|
|
className="flex items-center gap-1.5 px-2 py-1 bg-gray-700 hover:bg-gray-600 rounded text-xs text-gray-300 transition-colors"
|
|
>
|
|
{copied ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
|
|
{copied ? 'Kopiert' : 'Kopieren'}
|
|
</button>
|
|
</div>
|
|
<pre className="bg-gray-900 rounded p-3 text-xs text-gray-300 font-mono overflow-x-auto whitespace-pre-wrap">
|
|
{result.client_config}
|
|
</pre>
|
|
{result.assigned_ip && (
|
|
<div className="text-xs text-gray-400">Zugewiesene IP: <span className="text-white">{result.assigned_ip}</span></div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Peer List */}
|
|
{loading ? (
|
|
<div className="text-gray-500">Laden...</div>
|
|
) : peers.length === 0 ? (
|
|
<div className="text-gray-500">Keine Peers konfiguriert</div>
|
|
) : (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
|
<th className="px-3 py-2">Name</th>
|
|
<th className="px-3 py-2">Public Key</th>
|
|
<th className="px-3 py-2">Tunnel IP</th>
|
|
<th className="px-3 py-2">Enabled</th>
|
|
<th className="px-3 py-2 text-right">Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{peers.map((p) => (
|
|
<tr key={p.uuid || p.public_key}>
|
|
<td className="px-3 py-2.5 text-white">{p.name || p.description || '—'}</td>
|
|
<td className="px-3 py-2.5 text-gray-400 font-mono text-xs">{(p.public_key || p.pubkey || '—').substring(0, 20)}...</td>
|
|
<td className="px-3 py-2.5 text-gray-400 font-mono text-xs">{p.tunnel_address || p.allowed_ips || '—'}</td>
|
|
<td className="px-3 py-2.5">
|
|
<span className={`inline-flex items-center gap-1.5 text-xs ${p.enabled !== false ? 'text-green-400' : 'text-gray-500'}`}>
|
|
<span className={`w-1.5 h-1.5 rounded-full ${p.enabled !== false ? 'bg-green-400' : 'bg-gray-500'}`} />
|
|
{p.enabled !== false ? 'Ja' : 'Nein'}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2.5 text-right">
|
|
<button
|
|
onClick={() => handleDelete(p.uuid, p.name || p.description)}
|
|
className="text-red-400 hover:text-red-300 p-1 rounded hover:bg-red-900/30 transition-colors"
|
|
title="Peer loeschen"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RoutesTab({ sys }) {
|
|
const routes = sys.routes || []
|
|
if (routes.length === 0) return <div className="text-gray-500">Keine Routen</div>
|
|
return (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
|
<th className="px-3 py-2">Ziel</th>
|
|
<th className="px-3 py-2">Gateway</th>
|
|
<th className="px-3 py-2">Flags</th>
|
|
<th className="px-3 py-2">Interface</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{routes.map((r, i) => (
|
|
<tr key={i}>
|
|
<td className="px-3 py-2 text-white font-mono text-xs">{r.destination}</td>
|
|
<td className="px-3 py-2 text-gray-400 font-mono text-xs">{r.gateway}</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{r.flags}</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{r.interface_name || r.iface}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 <div className="text-gray-500">Keine DHCP Leases</div>
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
{server && <div className="text-sm text-gray-400">DHCP Server: <span className="text-white">{server.toUpperCase()}</span> — {allLeases.length} Lease(s)</div>}
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-2 w-4 h-4 text-gray-500" />
|
|
<input
|
|
type="text"
|
|
placeholder="IP, MAC oder Hostname..."
|
|
value={dhcpSearch}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
|
<th className="px-3 py-2">IP</th>
|
|
<th className="px-3 py-2">MAC</th>
|
|
<th className="px-3 py-2">Hostname</th>
|
|
<th className="px-3 py-2">Status</th>
|
|
<th className="px-3 py-2">Ablauf</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{leases.map((l, i) => (
|
|
<tr key={i}>
|
|
<td className="px-3 py-2 text-white font-mono text-xs">{l.ip || l.address}</td>
|
|
<td className="px-3 py-2 text-gray-400 font-mono text-xs">{l.mac || l.hwaddr}</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{l.hostname || '—'}</td>
|
|
<td className="px-3 py-2">
|
|
<span className={`text-xs ${l.status === 'active' ? 'text-green-400' : 'text-gray-500'}`}>
|
|
{l.status || '—'}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{l.end || l.expires || l.expire || '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CertsTab({ sys }) {
|
|
const certs = sys.certificates || []
|
|
if (certs.length === 0) return <div className="text-gray-500">Keine Zertifikate</div>
|
|
|
|
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 (
|
|
<div className="space-y-4">
|
|
{/* Summary */}
|
|
<div className="flex gap-3">
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 px-4 py-2 flex-1 text-center">
|
|
<div className="text-2xl font-bold text-white">{sorted.length}</div>
|
|
<div className="text-xs text-gray-500">Gesamt</div>
|
|
</div>
|
|
{expiredCount > 0 && (
|
|
<div className="bg-red-500/10 rounded-lg border border-red-500/30 px-4 py-2 flex-1 text-center">
|
|
<div className="text-2xl font-bold text-red-400">{expiredCount}</div>
|
|
<div className="text-xs text-red-400/70">Abgelaufen</div>
|
|
</div>
|
|
)}
|
|
{soonCount > 0 && (
|
|
<div className="bg-yellow-500/10 rounded-lg border border-yellow-500/30 px-4 py-2 flex-1 text-center">
|
|
<div className="text-2xl font-bold text-yellow-400">{soonCount}</div>
|
|
<div className="text-xs text-yellow-400/70"><7 Tage</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Certificate Cards */}
|
|
<div className="space-y-2">
|
|
{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 (
|
|
<div key={i} className={`rounded border px-3 py-2 ${r ? r.bg : 'bg-gray-800/50 border-gray-700/50'}`}>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="min-w-0 flex-1 flex items-center gap-2">
|
|
<span className="text-white text-xs font-medium truncate">{c.name || c.subject || '--'}</span>
|
|
<span className="text-[9px] px-1 py-0.5 rounded bg-gray-700/50 text-gray-500 shrink-0">{type}</span>
|
|
</div>
|
|
<div className="text-right shrink-0">
|
|
{r ? (
|
|
<span className={`text-xs font-medium ${r.color}`}>{r.text}</span>
|
|
) : (
|
|
<span className="text-xs text-gray-600">--</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{r && r.pct !== undefined && (
|
|
<div className="mt-1 h-0.5 bg-gray-700/50 rounded-full overflow-hidden">
|
|
<div className={`h-full rounded-full ${r.barColor}`} style={{ width: `${r.pct}%` }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 <div className="text-gray-500">Laden...</div>
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={triggerBackup}
|
|
disabled={creating}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-600 hover:bg-orange-700 disabled:bg-orange-800 disabled:opacity-50 rounded text-sm text-white transition-colors"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
{creating ? 'Erstelle...' : 'Backup jetzt erstellen'}
|
|
</button>
|
|
{msg && <span className={`text-sm ${msg.startsWith('Fehler') ? 'text-red-400' : 'text-gray-400'}`}>{msg}</span>}
|
|
</div>
|
|
{backups.length === 0 ? (
|
|
<div className="text-gray-500">Keine Backups vorhanden</div>
|
|
) : (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
|
<th className="px-3 py-2">ID</th>
|
|
<th className="px-3 py-2">Erstellt</th>
|
|
<th className="px-3 py-2">Groesse</th>
|
|
<th className="px-3 py-2">Hash</th>
|
|
<th className="px-3 py-2 text-right">Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{backups.map((b) => (
|
|
<tr key={b.id}>
|
|
<td className="px-3 py-2 text-white">{b.id}</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{new Date(b.created_at).toLocaleString('de-DE')}</td>
|
|
<td className="px-3 py-2 text-gray-400">{formatBytes(b.size)}</td>
|
|
<td className="px-3 py-2 text-gray-500 font-mono text-xs">{(b.hash || '').substring(0, 16)}...</td>
|
|
<td className="px-3 py-2 text-right">
|
|
<button
|
|
onClick={() => api.downloadBackup(agentId, b.id)}
|
|
className="text-orange-400 hover:text-orange-300 p-1 rounded hover:bg-orange-900/30 transition-colors"
|
|
title="config.xml herunterladen"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div className="space-y-4">
|
|
{/* Agent Update */}
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
<h3 className="text-white font-semibold mb-1">Agent Update</h3>
|
|
<p className="text-gray-500 text-xs mb-3">Aktualisiert die Agent-Dateien remote ueber den bestehenden Agent-Kanal.</p>
|
|
<button
|
|
onClick={requestUpdate}
|
|
disabled={updating || agent?.update_requested}
|
|
className="flex items-center gap-2 bg-orange-600 hover:bg-orange-500 disabled:bg-gray-700 disabled:text-gray-500 text-white px-4 py-2 rounded text-sm transition-colors"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
{agent?.update_requested ? 'Update ausstehend...' : updating ? 'Wird angefordert...' : 'Agent aktualisieren'}
|
|
</button>
|
|
{msg && <div className="text-xs text-emerald-400 mt-2">{msg}</div>}
|
|
</div>
|
|
|
|
{/* Nuetzliche Befehle */}
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
<h3 className="text-white font-semibold mb-3">Nuetzliche Befehle</h3>
|
|
<div className="space-y-2">
|
|
{[
|
|
{ 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) => (
|
|
<div key={i} className="flex items-center gap-3">
|
|
<span className="text-gray-500 text-xs w-40 shrink-0">{item.label}</span>
|
|
<code className="bg-gray-900 text-gray-300 text-xs px-2 py-1 rounded font-mono">{item.cmd}</code>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Agent Info */}
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
<h3 className="text-white font-semibold mb-3">Agent Info</h3>
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-500 text-sm">Agent Version:</span>
|
|
<span className="text-white text-sm font-medium">{agent?.agent_version || '--'}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-500 text-sm">Status:</span>
|
|
<span className={`text-sm font-medium ${
|
|
status === 'online' ? 'text-emerald-400' :
|
|
status === 'stale' ? 'text-yellow-400' : 'text-gray-500'
|
|
}`}>
|
|
{status === 'online' ? 'Online' : status === 'stale' ? 'Verzoegert' : 'Offline'}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-500 text-sm">Letzter Heartbeat:</span>
|
|
<span className="text-white text-sm">
|
|
{lastHB ? lastHB.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '--'}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-500 text-sm">Agent ID:</span>
|
|
<span className="text-gray-400 text-xs font-mono">{agent?.id?.substring(0, 16) || '--'}...</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-500 text-sm">Registriert:</span>
|
|
<span className="text-gray-400 text-sm">
|
|
{agent?.registered_at ? new Date(agent.registered_at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '--'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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`
|
|
}
|