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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Metriken laden fehlgeschlagen")
|
writeError(w, http.StatusInternalServerError, "Metriken laden fehlgeschlagen")
|
||||||
return
|
return
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user