Files
edgeguard-native/internal/handlers/haproxy_stats.go
Debian 9a05e8bca8 feat(haproxy): req_tot + req_rate in Stats-API; Backend-Detail + Dashboard nutzen sie
GET /haproxy/stats liefert jetzt req_tot (kumulierte Requests seit
HAProxy-Start) und req_rate (Requests/s im letzten Messfenster).
Backend-Detail zeigt in der Live-Spalte 'N sess · X/s' wenn Traffic
fließt; Dashboard-HAProxy-Card zeigt req/s ebenfalls an.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 23:03:54 +02:00

123 lines
4.0 KiB
Go

package handlers
import (
"bufio"
"net"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
)
// HAProxyStatsHandler exposes /api/v1/haproxy/stats — a parsed view
// of the haproxy runtime API ('show stat'). Used by the dashboard's
// backend-live-health card. Reads from the unix socket at
// /run/haproxy/admin.sock; postinst adds the edgeguard user to the
// haproxy group so the socket (mode 0660 root:haproxy) is readable.
type HAProxyStatsHandler struct{}
func NewHAProxyStatsHandler() *HAProxyStatsHandler { return &HAProxyStatsHandler{} }
func (h *HAProxyStatsHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/haproxy")
g.GET("/stats", h.Stats)
}
const haproxyAdminSock = "/run/haproxy/admin.sock"
// Backend is one server inside one backend, parsed from haproxy's
// 'show stat' CSV. We only emit the fields the dashboard cares
// about — full CSV is ~80 columns of which 90% are noise here.
type backendStat struct {
Backend string `json:"backend"` // pxname
Server string `json:"server"` // svname
Status string `json:"status"` // UP|DOWN|MAINT|...
Sessions int64 `json:"sessions"` // current sessions (scur)
BIn int64 `json:"bytes_in"`
BOut int64 `json:"bytes_out"`
ReqTot int64 `json:"req_tot"` // total requests since start (req_tot)
ReqRate int64 `json:"req_rate"` // requests/s last second (req_rate, server-level)
LastChg int64 `json:"last_change_sec"` // seconds since last status change
Health string `json:"health,omitempty"` // check_status (e.g. L7OK)
}
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()})
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()})
return
}
// CSV-format der haproxy stats: erste Zeile beginnt mit
// "# pxname,svname,..." — die nutzen wir um Spalten-Indizes
// zu finden, weil das Format zwischen Versionen wechseln kann.
colIdx := map[string]int{}
out := []backendStat{}
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
fields := strings.Split(line, ",")
if strings.HasPrefix(line, "# ") {
// Header — strip "# " prefix.
fields[0] = strings.TrimPrefix(fields[0], "# ")
for i, name := range fields {
colIdx[name] = i
}
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" {
continue
}
// Skip our internal api_backend stats listener and frontends.
if pxname == "internal_stats" {
continue
}
st := backendStat{
Backend: pxname,
Server: svname,
Status: safeAt(fields, colIdx["status"]),
Sessions: parseInt64(safeAt(fields, colIdx["scur"])),
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"])),
LastChg: parseInt64(safeAt(fields, colIdx["lastchg"])),
Health: safeAt(fields, colIdx["check_status"]),
}
out = append(out, st)
}
response.OK(c, gin.H{"backends": out})
}
func safeAt(fields []string, i int) string {
if i <= 0 || i >= len(fields) {
return ""
}
return fields[i]
}
func parseInt64(s string) int64 {
n, _ := strconv.ParseInt(s, 10, 64)
return n
}