feat(dashboard): HAProxy Listener-Stats + i18n-Relativzeit
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -60,7 +60,7 @@ import (
|
|||||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.1.76"
|
var version = "1.1.77"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||||
|
|||||||
@@ -44,18 +44,31 @@ type backendStat struct {
|
|||||||
Health string `json:"health,omitempty"` // check_status (e.g. L7OK)
|
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) {
|
func (h *HAProxyStatsHandler) Stats(c *gin.Context) {
|
||||||
conn, err := net.DialTimeout("unix", haproxyAdminSock, 2*time.Second)
|
conn, err := net.DialTimeout("unix", haproxyAdminSock, 2*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Socket nicht erreichbar (haproxy down oder no perm) →
|
// Socket nicht erreichbar (haproxy down oder no perm) →
|
||||||
// leere Liste statt 500 damit das Dashboard nicht rot wird.
|
// 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
|
return
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
|
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
|
||||||
if _, err := conn.Write([]byte("show stat\n")); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +76,8 @@ func (h *HAProxyStatsHandler) Stats(c *gin.Context) {
|
|||||||
// "# pxname,svname,..." — die nutzen wir um Spalten-Indizes
|
// "# pxname,svname,..." — die nutzen wir um Spalten-Indizes
|
||||||
// zu finden, weil das Format zwischen Versionen wechseln kann.
|
// zu finden, weil das Format zwischen Versionen wechseln kann.
|
||||||
colIdx := map[string]int{}
|
colIdx := map[string]int{}
|
||||||
out := []backendStat{}
|
backends := []backendStat{}
|
||||||
|
frontends := []frontendStat{}
|
||||||
|
|
||||||
scanner := bufio.NewScanner(conn)
|
scanner := bufio.NewScanner(conn)
|
||||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
@@ -81,18 +95,30 @@ func (h *HAProxyStatsHandler) Stats(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Skip frontend rows + the "BACKEND" summary row — we want
|
|
||||||
// the per-server view ("L4OK", "L7OK", etc.).
|
|
||||||
svname := safeAt(fields, colIdx["svname"])
|
svname := safeAt(fields, colIdx["svname"])
|
||||||
pxname := safeAt(fields, colIdx["pxname"])
|
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
|
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
|
continue
|
||||||
}
|
}
|
||||||
st := backendStat{
|
|
||||||
|
// Per-server backend row.
|
||||||
|
backends = append(backends, backendStat{
|
||||||
Backend: pxname,
|
Backend: pxname,
|
||||||
Server: svname,
|
Server: svname,
|
||||||
Status: safeAt(fields, colIdx["status"]),
|
Status: safeAt(fields, colIdx["status"]),
|
||||||
@@ -103,10 +129,9 @@ func (h *HAProxyStatsHandler) Stats(c *gin.Context) {
|
|||||||
ReqRate: parseInt64(safeAt(fields, colIdx["req_rate"])),
|
ReqRate: parseInt64(safeAt(fields, colIdx["req_rate"])),
|
||||||
LastChg: parseInt64(safeAt(fields, colIdx["lastchg"])),
|
LastChg: parseInt64(safeAt(fields, colIdx["lastchg"])),
|
||||||
Health: safeAt(fields, colIdx["check_status"]),
|
Health: safeAt(fields, colIdx["check_status"]),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
out = append(out, st)
|
response.OK(c, gin.H{"backends": backends, "frontends": frontends})
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"backends": out})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func safeAt(fields []string, i int) string {
|
func safeAt(fields []string, i int) string {
|
||||||
|
|||||||
@@ -757,7 +757,8 @@
|
|||||||
},
|
},
|
||||||
"haproxyCard": {
|
"haproxyCard": {
|
||||||
"title": "HAProxy-Backends (live)",
|
"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": {
|
"resCard": {
|
||||||
"load": "Load",
|
"load": "Load",
|
||||||
|
|||||||
@@ -757,7 +757,8 @@
|
|||||||
},
|
},
|
||||||
"haproxyCard": {
|
"haproxyCard": {
|
||||||
"title": "HAProxy backends (live)",
|
"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": {
|
"resCard": {
|
||||||
"load": "Load",
|
"load": "Load",
|
||||||
|
|||||||
@@ -104,6 +104,11 @@ interface HAProxyBackend {
|
|||||||
req_tot: number; req_rate: number
|
req_tot: number; req_rate: number
|
||||||
last_change_sec: number; health?: string
|
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 ──────────────────────────────────────────────────
|
// ── Fetchers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -189,11 +194,19 @@ export default function DashboardPage() {
|
|||||||
queryFn: () => fetchOne<Resources>('/system/resources'),
|
queryFn: () => fetchOne<Resources>('/system/resources'),
|
||||||
refetchInterval: 10_000,
|
refetchInterval: 10_000,
|
||||||
})
|
})
|
||||||
const haproxyBackends = useQuery({
|
const haproxyStats = useQuery({
|
||||||
queryKey: ['haproxy', 'stats'],
|
queryKey: ['haproxy', 'stats'],
|
||||||
queryFn: () => fetchList<HAProxyBackend>('/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,
|
refetchInterval: 10_000,
|
||||||
})
|
})
|
||||||
|
const haproxyBackends = { data: haproxyStats.data?.backends }
|
||||||
const auditEntries = useAuditLive(15)
|
const auditEntries = useAuditLive(15)
|
||||||
|
|
||||||
const domains = useQuery({ queryKey: ['domains'], queryFn: () => fetchList<Domain>('/domains', 'domains') })
|
const domains = useQuery({ queryKey: ['domains'], queryFn: () => fetchList<Domain>('/domains', 'domains') })
|
||||||
@@ -450,6 +463,27 @@ export default function DashboardPage() {
|
|||||||
{/* ── HAProxy backend live health ─────────────────── */}
|
{/* ── HAProxy backend live health ─────────────────── */}
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={12}>
|
||||||
<Card size="small" title={<><DatabaseOutlined /> {t('dashboard.haproxyCard.title')}</>} className="h-100">
|
<Card size="small" title={<><DatabaseOutlined /> {t('dashboard.haproxyCard.title')}</>} className="h-100">
|
||||||
|
{(haproxyStats.data?.frontends ?? []).length > 0 && (
|
||||||
|
<div style={{ marginBottom: 8, paddingBottom: 8, borderBottom: '1px solid #E2E8F0' }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
{t('dashboard.haproxyCard.frontends')}
|
||||||
|
</Text>
|
||||||
|
<Space direction="vertical" size={2} style={{ width: '100%', marginTop: 4 }}>
|
||||||
|
{(haproxyStats.data?.frontends ?? []).map((f) => (
|
||||||
|
<div key={f.name} style={{ fontSize: 12, color: '#334155' }}>
|
||||||
|
<Space size={6} wrap>
|
||||||
|
<code style={{ fontSize: 11 }}>{f.name}</code>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{f.sessions} sess
|
||||||
|
{f.req_rate > 0 ? ` · ${f.req_rate}/s` : ''}
|
||||||
|
{` · ↓${formatBytes(f.bytes_in)} ↑${formatBytes(f.bytes_out)}`}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{(haproxyBackends.data ?? []).length === 0 ? (
|
{(haproxyBackends.data ?? []).length === 0 ? (
|
||||||
<Text type="secondary">{t('dashboard.haproxyCard.empty')}</Text>
|
<Text type="secondary">{t('dashboard.haproxyCard.empty')}</Text>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user