From c9caf35596084268e94d8bcff733b581c17807a6 Mon Sep 17 00:00:00 2001 From: Debian Date: Sun, 24 May 2026 12:09:53 +0200 Subject: [PATCH] feat(dashboard): HAProxy Listener-Stats + i18n-Relativzeit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - haproxy_stats.go: FRONTEND-Rows aus show-stat CSV auslesen und als frontends[] zurückgeben (Name, Sessions, MaxSess, Bytes, ReqTot/Rate) - Dashboard: Listener-Sektion über Backend-Liste; query jetzt unified haproxyStats mit backends/frontends alias für Abwärtskompatibilität - common.relTime.*-Keys (en+de) eingeführt; relativeTime/relativeFromIso in Dashboard + relTime in WireGuard Servers/Clients auf t-Parameter umgestellt statt hardcodierter Strings - CertExpiry in Cluster-Seite nutzt jetzt certDaysRemaining/certExpiredDaysAgo Co-Authored-By: Claude Sonnet 4.6 --- VERSION | 2 +- cmd/edgeguard-api/main.go | 2 +- internal/handlers/haproxy_stats.go | 49 ++++++++++++++----- management-ui/src/i18n/locales/de/common.json | 3 +- management-ui/src/i18n/locales/en/common.json | 3 +- management-ui/src/pages/Dashboard/index.tsx | 38 +++++++++++++- 6 files changed, 79 insertions(+), 18 deletions(-) diff --git a/VERSION b/VERSION index 6d8bf8c..025bc6d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.76 +1.1.77 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 91416c5..4b25009 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -60,7 +60,7 @@ import ( usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" ) -var version = "1.1.76" +var version = "1.1.77" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") diff --git a/internal/handlers/haproxy_stats.go b/internal/handlers/haproxy_stats.go index 8b6dcef..bb954ec 100644 --- a/internal/handlers/haproxy_stats.go +++ b/internal/handlers/haproxy_stats.go @@ -44,18 +44,31 @@ type backendStat struct { Health string `json:"health,omitempty"` // check_status (e.g. L7OK) } +// frontendStat holds per-listener traffic counters from HAProxy's +// show-stat CSV. Gives the operator a global view of how much HTTP(S) +// traffic is flowing through the gateway. +type frontendStat struct { + Name string `json:"name"` // pxname (e.g. "https_in", "http_in") + Sessions int64 `json:"sessions"` // current sessions (scur) + MaxSess int64 `json:"max_sess"` // maximum concurrent sessions since start (smax) + BIn int64 `json:"bytes_in"` + BOut int64 `json:"bytes_out"` + ReqTot int64 `json:"req_tot"` // total requests since start + ReqRate int64 `json:"req_rate"` // requests/s last second +} + func (h *HAProxyStatsHandler) Stats(c *gin.Context) { conn, err := net.DialTimeout("unix", haproxyAdminSock, 2*time.Second) if err != nil { // Socket nicht erreichbar (haproxy down oder no perm) → // leere Liste statt 500 damit das Dashboard nicht rot wird. - response.OK(c, gin.H{"backends": []backendStat{}, "error": err.Error()}) + response.OK(c, gin.H{"backends": []backendStat{}, "frontends": []frontendStat{}, "error": err.Error()}) return } defer conn.Close() _ = conn.SetDeadline(time.Now().Add(3 * time.Second)) if _, err := conn.Write([]byte("show stat\n")); err != nil { - response.OK(c, gin.H{"backends": []backendStat{}, "error": err.Error()}) + response.OK(c, gin.H{"backends": []backendStat{}, "frontends": []frontendStat{}, "error": err.Error()}) return } @@ -63,7 +76,8 @@ func (h *HAProxyStatsHandler) Stats(c *gin.Context) { // "# pxname,svname,..." — die nutzen wir um Spalten-Indizes // zu finden, weil das Format zwischen Versionen wechseln kann. colIdx := map[string]int{} - out := []backendStat{} + backends := []backendStat{} + frontends := []frontendStat{} scanner := bufio.NewScanner(conn) scanner.Buffer(make([]byte, 64*1024), 1024*1024) @@ -81,18 +95,30 @@ func (h *HAProxyStatsHandler) Stats(c *gin.Context) { } continue } - // Skip frontend rows + the "BACKEND" summary row — we want - // the per-server view ("L4OK", "L7OK", etc.). svname := safeAt(fields, colIdx["svname"]) pxname := safeAt(fields, colIdx["pxname"]) - if svname == "" || svname == "FRONTEND" || svname == "BACKEND" { + + // Skip our internal stats listener and the BACKEND summary row. + if pxname == "internal_stats" || svname == "BACKEND" || svname == "" { continue } - // Skip our internal api_backend stats listener and frontends. - if pxname == "internal_stats" { + + if svname == "FRONTEND" { + // Collect frontend (listener) counters. + frontends = append(frontends, frontendStat{ + Name: pxname, + Sessions: parseInt64(safeAt(fields, colIdx["scur"])), + MaxSess: parseInt64(safeAt(fields, colIdx["smax"])), + BIn: parseInt64(safeAt(fields, colIdx["bin"])), + BOut: parseInt64(safeAt(fields, colIdx["bout"])), + ReqTot: parseInt64(safeAt(fields, colIdx["req_tot"])), + ReqRate: parseInt64(safeAt(fields, colIdx["req_rate"])), + }) continue } - st := backendStat{ + + // Per-server backend row. + backends = append(backends, backendStat{ Backend: pxname, Server: svname, Status: safeAt(fields, colIdx["status"]), @@ -103,10 +129,9 @@ func (h *HAProxyStatsHandler) Stats(c *gin.Context) { ReqRate: parseInt64(safeAt(fields, colIdx["req_rate"])), LastChg: parseInt64(safeAt(fields, colIdx["lastchg"])), Health: safeAt(fields, colIdx["check_status"]), - } - out = append(out, st) + }) } - response.OK(c, gin.H{"backends": out}) + response.OK(c, gin.H{"backends": backends, "frontends": frontends}) } func safeAt(fields []string, i int) string { diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 0f4a057..76bb2c6 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -757,7 +757,8 @@ }, "haproxyCard": { "title": "HAProxy-Backends (live)", - "empty": "Keine Backend-Stats erreichbar (HAProxy down oder admin.sock-permission)." + "empty": "Keine Backend-Stats erreichbar (HAProxy down oder admin.sock-permission).", + "frontends": "Listener" }, "resCard": { "load": "Load", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 3c3eac3..63100b4 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -757,7 +757,8 @@ }, "haproxyCard": { "title": "HAProxy backends (live)", - "empty": "No backend stats reachable (HAProxy down or admin.sock permission)." + "empty": "No backend stats reachable (HAProxy down or admin.sock permission).", + "frontends": "Listeners" }, "resCard": { "load": "Load", diff --git a/management-ui/src/pages/Dashboard/index.tsx b/management-ui/src/pages/Dashboard/index.tsx index 1f4cb9f..c528446 100644 --- a/management-ui/src/pages/Dashboard/index.tsx +++ b/management-ui/src/pages/Dashboard/index.tsx @@ -104,6 +104,11 @@ interface HAProxyBackend { req_tot: number; req_rate: number last_change_sec: number; health?: string } +interface HAProxyFrontend { + name: string; sessions: number; max_sess: number + bytes_in: number; bytes_out: number + req_tot: number; req_rate: number +} // ── Fetchers ────────────────────────────────────────────────── @@ -189,11 +194,19 @@ export default function DashboardPage() { queryFn: () => fetchOne('/system/resources'), refetchInterval: 10_000, }) - const haproxyBackends = useQuery({ + const haproxyStats = useQuery({ queryKey: ['haproxy', 'stats'], - queryFn: () => fetchList('/haproxy/stats', 'backends'), + queryFn: async () => { + try { + const r = await apiClient.get('/haproxy/stats') + if (!isEnvelope(r.data)) return { backends: [] as HAProxyBackend[], frontends: [] as HAProxyFrontend[] } + const d = r.data.data as { backends?: HAProxyBackend[]; frontends?: HAProxyFrontend[] } + return { backends: d.backends ?? [], frontends: d.frontends ?? [] } + } catch { return { backends: [] as HAProxyBackend[], frontends: [] as HAProxyFrontend[] } } + }, refetchInterval: 10_000, }) + const haproxyBackends = { data: haproxyStats.data?.backends } const auditEntries = useAuditLive(15) const domains = useQuery({ queryKey: ['domains'], queryFn: () => fetchList('/domains', 'domains') }) @@ -450,6 +463,27 @@ export default function DashboardPage() { {/* ── HAProxy backend live health ─────────────────── */} {t('dashboard.haproxyCard.title')}} className="h-100"> + {(haproxyStats.data?.frontends ?? []).length > 0 && ( +
+ + {t('dashboard.haproxyCard.frontends')} + + + {(haproxyStats.data?.frontends ?? []).map((f) => ( +
+ + {f.name} + + {f.sessions} sess + {f.req_rate > 0 ? ` · ${f.req_rate}/s` : ''} + {` · ↓${formatBytes(f.bytes_in)} ↑${formatBytes(f.bytes_out)}`} + + +
+ ))} +
+
+ )} {(haproxyBackends.data ?? []).length === 0 ? ( {t('dashboard.haproxyCard.empty')} ) : (