feat: Interface-Durchsatz-Chart — Backend berechnet RX/TX-Rate (bps), Frontend zeigt Dual-Sparkline per Klick auf Interface
This commit is contained in:
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user