feat(dashboard): Operations-Dashboard mit Live-Health/Resources/Audit/HAProxy

Vorher: Dashboard war Counts + statische Cards. Jetzt operativer
Überblick — was läuft, was klemmt, was wurde gerade geändert.

Backend (4 neue Endpoints):
* GET /api/v1/system/services — systemctl is-active für 8 services
  (edgeguard-api, scheduler, haproxy, nftables, unbound, chrony,
  squid, postgresql). Inklusive ActiveEnterTimestamp.
* GET /api/v1/system/resources — /proc/loadavg, meminfo, statfs(/),
  nf_conntrack count+max, uptime.
* GET /api/v1/audit/recent?limit=N — letzte audit_log entries.
  audit-Repo bekommt ListRecent + Entry struct.
* GET /api/v1/haproxy/stats — parsed haproxy 'show stat' CSV vom
  /run/haproxy/admin.sock (postinst addet edgeguard zu haproxy-
  group für socket-read; haproxy-group exists nach apt install).

Frontend Dashboard rewrite:
* PageHeader + KPI-Strip (6 tiles, wie zuvor) — bleibt.
* Resources-Strip: Load (1/5/15) + Mem-Progress + Disk-Progress +
  Conntrack-Progress + Uptime.
* Service-Health-Grid: 8 Karten mit StatusDot + state.
* Recent-Activity-Card (audit-log): action-Tag + actor + subject +
  relative time.
* HAProxy-Backends-Card: backend/server + UP/DOWN-Tag + sessions +
  bytes_in/out + last_change_age.
* WireGuard live (handshake-age, traffic) — bleibt aus früherem
  Stand.
* Cluster + Firewall + SSL + Routing Cards — bleiben.
* Polling 10s für services/resources/haproxy, 15s für audit.

Plus: postinst usermod -a -G haproxy edgeguard für admin.sock
read-permission.

Version 1.0.43.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-11 07:46:39 +02:00
parent cc500139fc
commit c7b98f196e
14 changed files with 792 additions and 22 deletions

View File

@@ -1,14 +1,17 @@
package handlers
import (
"bufio"
"log/slog"
"net"
"net/http"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"syscall"
"time"
"github.com/gin-gonic/gin"
@@ -32,6 +35,135 @@ func (h *SystemHandler) Register(rg *gin.RouterGroup) {
g.GET("/package-versions", h.PackageVersions)
g.POST("/upgrade", h.Upgrade)
g.GET("/interfaces", h.Interfaces)
g.GET("/services", h.Services)
g.GET("/resources", h.Resources)
}
// servicesToCheck is the curated list shown on the dashboard
// service-health-grid. Order matters (UI renders in this sequence).
// Each entry is a (label, systemd-unit) pair — label is what the
// UI shows, unit is what `systemctl is-active` queries.
var servicesToCheck = []struct{ Label, Unit string }{
{"edgeguard-api", "edgeguard-api"},
{"edgeguard-scheduler", "edgeguard-scheduler"},
{"haproxy", "haproxy"},
{"nftables", "nftables"},
{"unbound", "unbound"},
{"chrony", "chrony"},
{"squid", "squid"},
{"postgresql", "postgresql"},
}
type serviceStatus struct {
Label string `json:"label"`
Unit string `json:"unit"`
Active bool `json:"active"`
State string `json:"state"` // active|inactive|failed|activating|...
Since string `json:"since,omitempty"` // ActiveEnterTimestamp
}
// Services returns systemd-unit status for the curated stack.
func (h *SystemHandler) Services(c *gin.Context) {
out := make([]serviceStatus, 0, len(servicesToCheck))
for _, s := range servicesToCheck {
st := serviceStatus{Label: s.Label, Unit: s.Unit}
// systemctl show -p SubState,ActiveEnterTimestamp gives us
// state + since in one shot, faster than two calls.
raw, err := exec.CommandContext(c.Request.Context(),
"systemctl", "show", "-p", "ActiveState,ActiveEnterTimestamp",
s.Unit).Output()
if err == nil {
for _, line := range strings.Split(string(raw), "\n") {
if k, v, ok := strings.Cut(line, "="); ok {
switch k {
case "ActiveState":
st.State = v
st.Active = v == "active"
case "ActiveEnterTimestamp":
st.Since = v
}
}
}
}
out = append(out, st)
}
response.OK(c, gin.H{"services": out})
}
type resources struct {
LoadAvg1 float64 `json:"load_avg_1"`
LoadAvg5 float64 `json:"load_avg_5"`
LoadAvg15 float64 `json:"load_avg_15"`
MemTotalKB int64 `json:"mem_total_kb"`
MemAvailKB int64 `json:"mem_avail_kb"`
MemUsedPct float64 `json:"mem_used_pct"`
DiskTotalGB float64 `json:"disk_total_gb"`
DiskFreeGB float64 `json:"disk_free_gb"`
DiskUsedPct float64 `json:"disk_used_pct"`
ConntrackCnt int64 `json:"conntrack_count"`
ConntrackMax int64 `json:"conntrack_max"`
UptimeSec int64 `json:"uptime_sec"`
BootTimeUnix int64 `json:"boot_time_unix"`
}
// Resources reads /proc + statfs for the box-level metrics card.
// All best-effort — missing files just leave the field at zero.
func (h *SystemHandler) Resources(c *gin.Context) {
r := resources{}
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
f := strings.Fields(string(data))
if len(f) >= 3 {
r.LoadAvg1, _ = strconv.ParseFloat(f[0], 64)
r.LoadAvg5, _ = strconv.ParseFloat(f[1], 64)
r.LoadAvg15, _ = strconv.ParseFloat(f[2], 64)
}
}
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
s := bufio.NewScanner(strings.NewReader(string(data)))
for s.Scan() {
line := s.Text()
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
val, _ := strconv.ParseInt(fields[1], 10, 64)
switch strings.TrimSuffix(fields[0], ":") {
case "MemTotal":
r.MemTotalKB = val
case "MemAvailable":
r.MemAvailKB = val
}
}
if r.MemTotalKB > 0 {
r.MemUsedPct = float64(r.MemTotalKB-r.MemAvailKB) * 100 / float64(r.MemTotalKB)
}
}
var fs syscall.Statfs_t
if err := syscall.Statfs("/", &fs); err == nil {
total := float64(fs.Blocks) * float64(fs.Bsize)
free := float64(fs.Bavail) * float64(fs.Bsize)
r.DiskTotalGB = total / 1024 / 1024 / 1024
r.DiskFreeGB = free / 1024 / 1024 / 1024
if total > 0 {
r.DiskUsedPct = (total - free) * 100 / total
}
}
if data, err := os.ReadFile("/proc/sys/net/netfilter/nf_conntrack_count"); err == nil {
r.ConntrackCnt, _ = strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
}
if data, err := os.ReadFile("/proc/sys/net/netfilter/nf_conntrack_max"); err == nil {
r.ConntrackMax, _ = strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
}
if data, err := os.ReadFile("/proc/uptime"); err == nil {
f := strings.Fields(string(data))
if len(f) >= 1 {
if up, err := strconv.ParseFloat(f[0], 64); err == nil {
r.UptimeSec = int64(up)
r.BootTimeUnix = time.Now().Unix() - r.UptimeSec
}
}
}
response.OK(c, r)
}
func (h *SystemHandler) Health(c *gin.Context) {