feat: Interface-Durchsatz-Chart — Backend berechnet RX/TX-Rate (bps), Frontend zeigt Dual-Sparkline per Klick auf Interface

This commit is contained in:
cynfo3000
2026-03-06 22:00:33 +01:00
parent eb5266f848
commit 9cb504fea1
3 changed files with 137 additions and 43 deletions
+96 -34
View File
@@ -108,7 +108,7 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) {
if (!sys) return <div className="text-gray-500">Keine Systemdaten</div>
const tab = subTab
if (tab === 'overview') return <OverviewTab sys={sys} agentId={agentId} />
if (tab === 'interfaces') return <InterfacesTab sys={sys} />
if (tab === 'interfaces') return <InterfacesTab sys={sys} agentId={agentId} />
if (tab === 'services') return <ServicesTab sys={sys} />
if (tab === 'tunnels') return <TunnelTab agentId={agentId} agent={agent} />
if (tab === 'vpn') return <><VPNTab sys={sys} /><div className="mt-6"><WireGuardTab agentId={agentId} sys={sys} /></div></>
@@ -565,7 +565,59 @@ function StatusCard({ icon: Icon, value, label }) {
)
}
function InterfacesTab({ sys }) {
function IfaceChart({ agentId, ifaceName }) {
const [rxData, setRxData] = useState([])
const [txData, setTxData] = useState([])
useEffect(() => {
if (!agentId || !ifaceName) return
const from = new Date(Date.now() - 8 * 60 * 60 * 1000).toISOString()
const params = `&from=${from}&limit=200&tags=${encodeURIComponent(JSON.stringify({ interface: ifaceName }))}`
api.getMetrics(agentId, 'interface_rx_bps', params).then(d => { if (Array.isArray(d)) setRxData(d) }).catch(() => {})
api.getMetrics(agentId, 'interface_tx_bps', params).then(d => { if (Array.isArray(d)) setTxData(d) }).catch(() => {})
}, [agentId, ifaceName])
if (rxData.length < 2 && txData.length < 2) {
return <div className="text-xs text-gray-600 py-2 text-center">Noch keine Verlaufsdaten (ab dem nächsten Heartbeat)</div>
}
// Gemeinsame SVG mit zwei Linien (RX blau, TX grün)
const allVals = [...rxData.map(d => d.value), ...txData.map(d => d.value)]
const maxVal = Math.max(...allVals, 1)
const w = 100, h = 50
const toPath = (data) => data.map((d, i) => {
const x = (i / (data.length - 1)) * w
const y = h - (d.value / maxVal) * (h - 4) - 2
return `${x.toFixed(1)},${y.toFixed(1)}`
}).join(' ')
const formatBps = (v) => {
if (v >= 1024 * 1024) return `${(v / 1024 / 1024).toFixed(1)} MB/s`
if (v >= 1024) return `${(v / 1024).toFixed(1)} KB/s`
return `${v.toFixed(0)} B/s`
}
const lastRx = rxData.at(-1)?.value || 0
const lastTx = txData.at(-1)?.value || 0
return (
<div className="mt-3 pt-3 border-t border-gray-700/50">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-gray-500">Durchsatz letzte 8h</span>
<div className="flex gap-3 text-xs">
<span className="text-blue-400">↓ {formatBps(lastRx)}</span>
<span className="text-green-400">↑ {formatBps(lastTx)}</span>
</div>
</div>
<svg width="100%" height={h} viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className="w-full">
{rxData.length >= 2 && <polyline points={toPath(rxData)} fill="none" stroke="#3b82f6" strokeWidth="1.5" strokeLinejoin="round" />}
{txData.length >= 2 && <polyline points={toPath(txData)} fill="none" stroke="#22c55e" strokeWidth="1.5" strokeLinejoin="round" />}
</svg>
</div>
)
}
function InterfacesTab({ sys, agentId }) {
const [expanded, setExpanded] = useState(null)
const ifaces = sys.network_interfaces || []
return (
<div className="space-y-3">
@@ -574,46 +626,56 @@ function InterfacesTab({ sys }) {
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 || ''
const isOpen = expanded === i.name
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"
className="bg-gray-800/50 rounded-lg border border-gray-700/50 px-5 py-4 cursor-pointer hover:border-gray-600/70 transition-colors"
onClick={() => setExpanded(isOpen ? null : i.name)}
>
{/* 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 className="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>
<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>}
{/* 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 + Chevron */}
<div className="flex items-center gap-3">
<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>
{isOpen ? <ChevronDown className="w-4 h-4 text-gray-500" /> : <ChevronRight className="w-4 h-4 text-gray-500" />}
</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>
{/* Expandierter Chart */}
{isOpen && <IfaceChart agentId={agentId} ifaceName={i.name} />}
</div>
)
})}