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

View File

@ -34,7 +34,8 @@ func (h *Handler) getMetrics() http.HandlerFunc {
} }
} }
results, err := h.db.GetMetrics(agentID, metric, from, to, limit) tagsFilter := r.URL.Query().Get("tags")
results, err := h.db.GetMetrics(agentID, metric, tagsFilter, from, to, limit)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Metriken laden fehlgeschlagen") writeError(w, http.StatusInternalServerError, "Metriken laden fehlgeschlagen")
return return

View File

@ -15,8 +15,15 @@ import (
_ "github.com/jackc/pgx/v5/stdlib" _ "github.com/jackc/pgx/v5/stdlib"
) )
type ifaceSnapshot struct {
rxBytes float64
txBytes float64
ts time.Time
}
type Database struct { type Database struct {
db *sql.DB db *sql.DB
lastIfaceSnap map[string]map[string]ifaceSnapshot // agentID -> ifaceName -> snapshot
} }
type DBConfig struct { type DBConfig struct {
@ -49,7 +56,7 @@ func New(cfg DBConfig) (*Database, error) {
return nil, fmt.Errorf("Datenbank-Verbindung fehlgeschlagen: %w", err) return nil, fmt.Errorf("Datenbank-Verbindung fehlgeschlagen: %w", err)
} }
d := &Database{db: db} d := &Database{db: db, lastIfaceSnap: make(map[string]map[string]ifaceSnapshot)}
if err := d.migrate(); err != nil { if err := d.migrate(); err != nil {
return nil, fmt.Errorf("Migration fehlgeschlagen: %w", err) return nil, fmt.Errorf("Migration fehlgeschlagen: %w", err)
} }
@ -415,13 +422,32 @@ func (d *Database) writeMetrics(tx *sql.Tx, agentID string, ts time.Time, data *
} }
} }
// Network Interfaces // Network Interfaces — kumulierte Bytes + abgeleitete Rate (bytes/s)
for _, iface := range data.NetworkInterfaces { if _, ok := d.lastIfaceSnap[agentID]; !ok {
if iface.RxBytes > 0 || iface.TxBytes > 0 { d.lastIfaceSnap[agentID] = make(map[string]ifaceSnapshot)
tags, _ := json.Marshal(map[string]string{"interface": iface.Name})
stmt.Exec(ts, agentID, "interface_rx_bytes", float64(iface.RxBytes), string(tags))
stmt.Exec(ts, agentID, "interface_tx_bytes", float64(iface.TxBytes), string(tags))
} }
for _, iface := range data.NetworkInterfaces {
rx := float64(iface.RxBytes)
tx := float64(iface.TxBytes)
tags, _ := json.Marshal(map[string]string{"interface": iface.Name})
tagsStr := string(tags)
if rx > 0 || tx > 0 {
stmt.Exec(ts, agentID, "interface_rx_bytes", rx, tagsStr)
stmt.Exec(ts, agentID, "interface_tx_bytes", tx, tagsStr)
}
// Rate berechnen wenn vorheriger Snapshot vorhanden
if prev, ok := d.lastIfaceSnap[agentID][iface.Name]; ok {
elapsed := ts.Sub(prev.ts).Seconds()
if elapsed > 0 && elapsed < 300 { // max 5 min Lücke ignorieren
rxRate := (rx - prev.rxBytes) / elapsed
txRate := (tx - prev.txBytes) / elapsed
if rxRate >= 0 && txRate >= 0 { // negative Werte ignorieren (Reboot)
stmt.Exec(ts, agentID, "interface_rx_bps", rxRate, tagsStr)
stmt.Exec(ts, agentID, "interface_tx_bps", txRate, tagsStr)
}
}
}
d.lastIfaceSnap[agentID][iface.Name] = ifaceSnapshot{rxBytes: rx, txBytes: tx, ts: ts}
} }
// PF States // PF States
@ -858,7 +884,7 @@ func (d *Database) DeleteConfigBackup(agentID string, backupID int64) (bool, err
// --- Metrics Abfragen --- // --- Metrics Abfragen ---
// GetMetrics holt Metriken fuer einen Agent in einem Zeitraum // GetMetrics holt Metriken fuer einen Agent in einem Zeitraum
func (d *Database) GetMetrics(agentID, metric string, from, to time.Time, limit int) ([]map[string]interface{}, error) { func (d *Database) GetMetrics(agentID, metric, tagsFilter string, from, to time.Time, limit int) ([]map[string]interface{}, error) {
if limit <= 0 { if limit <= 0 {
limit = 1000 limit = 1000
} }
@ -872,6 +898,11 @@ func (d *Database) GetMetrics(agentID, metric string, from, to time.Time, limit
args = append(args, metric) args = append(args, metric)
} }
if tagsFilter != "" {
query += fmt.Sprintf(" AND tags @> $%d::jsonb", len(args)+1)
args = append(args, tagsFilter)
}
query += " ORDER BY time DESC LIMIT $" + fmt.Sprintf("%d", len(args)+1) query += " ORDER BY time DESC LIMIT $" + fmt.Sprintf("%d", len(args)+1)
args = append(args, limit) args = append(args, limit)

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> if (!sys) return <div className="text-gray-500">Keine Systemdaten</div>
const tab = subTab const tab = subTab
if (tab === 'overview') return <OverviewTab sys={sys} agentId={agentId} /> 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 === 'services') return <ServicesTab sys={sys} />
if (tab === 'tunnels') return <TunnelTab agentId={agentId} agent={agent} /> 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></> 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 || [] const ifaces = sys.network_interfaces || []
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@ -574,11 +626,14 @@ function InterfacesTab({ sys }) {
const isUp = i.status === 'active' || i.status === 'up' const isUp = i.status === 'active' || i.status === 'up'
const ip = i.ip || i.addresses?.filter((a) => !a.includes(':')).join(', ') || '' const ip = i.ip || i.addresses?.filter((a) => !a.includes(':')).join(', ') || ''
const role = i.role || i.description || '' const role = i.role || i.description || ''
const isOpen = expanded === i.name
return ( return (
<div <div
key={i.name} 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)}
> >
<div className="flex items-center gap-4">
{/* Icon */} {/* Icon */}
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<Network className={`w-7 h-7 ${isUp ? 'text-blue-400' : 'text-gray-600'}`} /> <Network className={`w-7 h-7 ${isUp ? 'text-blue-400' : 'text-gray-600'}`} />
@ -605,7 +660,8 @@ function InterfacesTab({ sys }) {
</div> </div>
</div> </div>
{/* Traffic */} {/* Traffic + Chevron */}
<div className="flex items-center gap-3">
<div className="flex-shrink-0 text-right text-xs"> <div className="flex-shrink-0 text-right text-xs">
<div className="text-gray-400"> <div className="text-gray-400">
<span className="text-blue-400"></span> {formatBytes(i.rx_bytes)} <span className="text-blue-400"></span> {formatBytes(i.rx_bytes)}
@ -614,6 +670,12 @@ function InterfacesTab({ sys }) {
<span className="text-green-400"></span> {formatBytes(i.tx_bytes)} <span className="text-green-400"></span> {formatBytes(i.tx_bytes)}
</div> </div>
</div> </div>
{isOpen ? <ChevronDown className="w-4 h-4 text-gray-500" /> : <ChevronRight className="w-4 h-4 text-gray-500" />}
</div>
</div>
{/* Expandierter Chart */}
{isOpen && <IfaceChart agentId={agentId} ifaceName={i.name} />}
</div> </div>
) )
})} })}