From 9cb504fea15d42c1678897bf998379b78a652201 Mon Sep 17 00:00:00 2001 From: cynfo3000 Date: Fri, 6 Mar 2026 22:00:33 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Interface-Durchsatz-Chart=20=E2=80=94?= =?UTF-8?q?=20Backend=20berechnet=20RX/TX-Rate=20(bps),=20Frontend=20zeigt?= =?UTF-8?q?=20Dual-Sparkline=20per=20Klick=20auf=20Interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/metrics.go | 3 +- backend/db/postgres.go | 47 +++++++-- frontend/src/components/AgentPanel.jsx | 130 ++++++++++++++++++------- 3 files changed, 137 insertions(+), 43 deletions(-) diff --git a/backend/api/metrics.go b/backend/api/metrics.go index 4536497..be7cddd 100644 --- a/backend/api/metrics.go +++ b/backend/api/metrics.go @@ -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 { writeError(w, http.StatusInternalServerError, "Metriken laden fehlgeschlagen") return diff --git a/backend/db/postgres.go b/backend/db/postgres.go index 90be336..741eaff 100644 --- a/backend/db/postgres.go +++ b/backend/db/postgres.go @@ -15,8 +15,15 @@ import ( _ "github.com/jackc/pgx/v5/stdlib" ) +type ifaceSnapshot struct { + rxBytes float64 + txBytes float64 + ts time.Time +} + type Database struct { - db *sql.DB + db *sql.DB + lastIfaceSnap map[string]map[string]ifaceSnapshot // agentID -> ifaceName -> snapshot } type DBConfig struct { @@ -49,7 +56,7 @@ func New(cfg DBConfig) (*Database, error) { 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 { 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) + if _, ok := d.lastIfaceSnap[agentID]; !ok { + d.lastIfaceSnap[agentID] = make(map[string]ifaceSnapshot) + } for _, iface := range data.NetworkInterfaces { - if iface.RxBytes > 0 || iface.TxBytes > 0 { - 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)) + 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 @@ -858,7 +884,7 @@ func (d *Database) DeleteConfigBackup(agentID string, backupID int64) (bool, err // --- Metrics Abfragen --- // 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 { limit = 1000 } @@ -872,6 +898,11 @@ func (d *Database) GetMetrics(agentID, metric string, from, to time.Time, limit 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) args = append(args, limit) diff --git a/frontend/src/components/AgentPanel.jsx b/frontend/src/components/AgentPanel.jsx index 492c50b..b9e000b 100644 --- a/frontend/src/components/AgentPanel.jsx +++ b/frontend/src/components/AgentPanel.jsx @@ -108,7 +108,7 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) { if (!sys) return
Keine Systemdaten
const tab = subTab if (tab === 'overview') return - if (tab === 'interfaces') return + if (tab === 'interfaces') return if (tab === 'services') return if (tab === 'tunnels') return if (tab === 'vpn') return <>
@@ -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
Noch keine Verlaufsdaten (ab dem nächsten Heartbeat)
+ } + + // 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 ( +
+
+ Durchsatz letzte 8h +
+ ↓ {formatBps(lastRx)} + ↑ {formatBps(lastTx)} +
+
+ + {rxData.length >= 2 && } + {txData.length >= 2 && } + +
+ ) +} + +function InterfacesTab({ sys, agentId }) { + const [expanded, setExpanded] = useState(null) const ifaces = sys.network_interfaces || [] return (
@@ -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 (
setExpanded(isOpen ? null : i.name)} > - {/* Icon */} -
- -
- - {/* Info */} -
-
- {i.name} - {role && ( - - {role} - - )} - {!isUp && ( - - {i.status || 'down'} - - )} +
+ {/* Icon */} +
+
-
- {ip && {ip}} - {i.mac && {i.mac}} + + {/* Info */} +
+
+ {i.name} + {role && ( + + {role} + + )} + {!isUp && ( + + {i.status || 'down'} + + )} +
+
+ {ip && {ip}} + {i.mac && {i.mac}} +
+
+ + {/* Traffic + Chevron */} +
+
+
+ {formatBytes(i.rx_bytes)} +
+
+ {formatBytes(i.tx_bytes)} +
+
+ {isOpen ? : }
- {/* Traffic */} -
-
- {formatBytes(i.rx_bytes)} -
-
- {formatBytes(i.tx_bytes)} -
-
+ {/* Expandierter Chart */} + {isOpen && }
) })}