feat: Interface-Durchsatz-Chart — Backend berechnet RX/TX-Rate (bps), Frontend zeigt Dual-Sparkline per Klick auf Interface
This commit is contained in:
parent
eb5266f848
commit
9cb504fea1
@ -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
|
||||
|
||||
@ -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
|
||||
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
|
||||
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))
|
||||
// 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 {
|
||||
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)
|
||||
|
||||
|
||||
@ -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,11 +626,14 @@ 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)}
|
||||
>
|
||||
<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'}`} />
|
||||
@ -605,7 +660,8 @@ function InterfacesTab({ sys }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traffic */}
|
||||
{/* 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)}
|
||||
@ -614,6 +670,12 @@ function InterfacesTab({ sys }) {
|
||||
<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>
|
||||
|
||||
{/* Expandierter Chart */}
|
||||
{isOpen && <IfaceChart agentId={agentId} ifaceName={i.name} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user