diff --git a/VERSION b/VERSION index 4ad595c..f683e66 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.42 +1.0.43 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index f2be0b5..767a555 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -45,7 +45,7 @@ import ( wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" ) -var version = "1.0.42" +var version = "1.0.43" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") @@ -176,6 +176,8 @@ func main() { handlers.NewNetworksHandler(ifsRepo, ipsRepo, fwZones, auditRepo, nodeID).Register(authed) handlers.NewIPAddressesHandler(ipsRepo, auditRepo, nodeID).Register(authed) handlers.NewClusterHandler(clusterStore, nodeID).Register(authed) + handlers.NewAuditHandler(auditRepo).Register(authed) + handlers.NewHAProxyStatsHandler().Register(authed) handlers.NewTLSCertsHandler(tlsRepo, auditRepo, nodeID, acmeService).Register(authed) // Firewall reload: nach jeder Mutation den Renderer neu fahren // (writes ruleset.nft + sudo nft -f). Errors loggen, nicht failen. diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index d91d0cd..ddaff29 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -9,7 +9,7 @@ import ( "os" ) -var version = "1.0.42" +var version = "1.0.43" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index 936226f..cbd089d 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -21,7 +21,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.0.42" +var version = "1.0.43" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/internal/handlers/audit.go b/internal/handlers/audit.go new file mode 100644 index 0000000..a8e14ec --- /dev/null +++ b/internal/handlers/audit.go @@ -0,0 +1,38 @@ +package handlers + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" +) + +type AuditHandler struct { + Repo *audit.Repo +} + +func NewAuditHandler(repo *audit.Repo) *AuditHandler { return &AuditHandler{Repo: repo} } + +func (h *AuditHandler) Register(rg *gin.RouterGroup) { + g := rg.Group("/audit") + g.GET("/recent", h.Recent) +} + +// Recent returns the most recent audit_log entries — used by the +// dashboard's recent-activity card. ?limit=N (1–100, default 10). +func (h *AuditHandler) Recent(c *gin.Context) { + limit := 10 + if v := c.Query("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + limit = n + } + } + rows, err := h.Repo.ListRecent(c.Request.Context(), limit) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"entries": rows}) +} diff --git a/internal/handlers/haproxy_stats.go b/internal/handlers/haproxy_stats.go new file mode 100644 index 0000000..1c1a3a1 --- /dev/null +++ b/internal/handlers/haproxy_stats.go @@ -0,0 +1,118 @@ +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"` + 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"])), + 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 +} diff --git a/internal/handlers/system.go b/internal/handlers/system.go index e017c09..7708ecc 100644 --- a/internal/handlers/system.go +++ b/internal/handlers/system.go @@ -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) { diff --git a/internal/services/audit/audit.go b/internal/services/audit/audit.go index e4787f3..28ab7a2 100644 --- a/internal/services/audit/audit.go +++ b/internal/services/audit/audit.go @@ -6,6 +6,7 @@ package audit import ( "context" "encoding/json" + "time" "github.com/jackc/pgx/v5/pgxpool" ) @@ -16,6 +17,47 @@ type Repo struct { func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} } +// Entry mirrors one audit_log row — ListRecent returns these for +// the dashboard's recent-activity card. +type Entry struct { + ID int64 `json:"id"` + Actor string `json:"actor"` + Action string `json:"action"` + Subject *string `json:"subject,omitempty"` + Detail json.RawMessage `json:"detail,omitempty"` + NodeID *string `json:"node_id,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// ListRecent returns the most recent audit entries, newest first. +// Pass 0 for a sensible default (10). +func (r *Repo) ListRecent(ctx context.Context, limit int) ([]Entry, error) { + if r == nil || r.Pool == nil { + return []Entry{}, nil + } + if limit <= 0 || limit > 100 { + limit = 10 + } + rows, err := r.Pool.Query(ctx, ` +SELECT id, actor, action, subject, detail, node_id, created_at +FROM audit_log +ORDER BY created_at DESC, id DESC +LIMIT $1`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]Entry, 0, limit) + for rows.Next() { + var e Entry + if err := rows.Scan(&e.ID, &e.Actor, &e.Action, &e.Subject, &e.Detail, &e.NodeID, &e.CreatedAt); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + // Log writes one audit_log row. detail is JSON-encodable (typically a // map[string]any) — empty map means "no payload". If pool is nil // (e.g. dev env without DB), Log silently no-ops so handlers don't diff --git a/management-ui/package.json b/management-ui/package.json index c762cef..addf953 100644 --- a/management-ui/package.json +++ b/management-ui/package.json @@ -1,7 +1,7 @@ { "name": "edgeguard-management-ui", "private": true, - "version": "1.0.42", + "version": "1.0.43", "type": "module", "scripts": { "dev": "vite", diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index da70af4..54082d1 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -75,7 +75,7 @@ const NAV: NavSection[] = [ }, ] -const VERSION = '1.0.42' +const VERSION = '1.0.43' export default function Sidebar({ isOpen, onClose }: SidebarProps) { const { t } = useTranslation() diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 7f2dabd..16634f0 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -399,6 +399,25 @@ "api": "API", "ifaces": "Interfaces", "wg": "WireGuard" + }, + "servicesCard": { + "title": "Service-Status (live, 10s)" + }, + "activityCard": { + "title": "Letzte Aktivität (Audit-Log)", + "empty": "Noch keine Aktivität — Mutationen werden hier protokolliert." + }, + "haproxyCard": { + "title": "HAProxy-Backends (live)", + "empty": "Keine Backend-Stats erreichbar (HAProxy down oder admin.sock-permission)." + }, + "resCard": { + "load": "Load", + "memory": "Memory", + "disk": "Disk /", + "free": "frei", + "conntrack": "Conntrack", + "uptime": "Uptime" } }, "ntp": { diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 0797c29..5878639 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -399,6 +399,25 @@ "api": "API", "ifaces": "Interfaces", "wg": "WireGuard" + }, + "servicesCard": { + "title": "Service status (live, 10s)" + }, + "activityCard": { + "title": "Recent activity (audit log)", + "empty": "No activity yet — mutations are logged here." + }, + "haproxyCard": { + "title": "HAProxy backends (live)", + "empty": "No backend stats reachable (HAProxy down or admin.sock permission)." + }, + "resCard": { + "load": "Load", + "memory": "Memory", + "disk": "Disk /", + "free": "free", + "conntrack": "Conntrack", + "uptime": "Uptime" } }, "ntp": { diff --git a/management-ui/src/pages/Dashboard/index.tsx b/management-ui/src/pages/Dashboard/index.tsx index e281cad..784363d 100644 --- a/management-ui/src/pages/Dashboard/index.tsx +++ b/management-ui/src/pages/Dashboard/index.tsx @@ -1,38 +1,431 @@ -import { Card, Col, Row, Statistic, Typography } from 'antd' +import { Alert, Card, Col, Progress, Row, Space, Statistic, Tag, Tooltip, Typography } from 'antd' +import { + ApartmentOutlined, ApiOutlined, BranchesOutlined, ClusterOutlined, + DashboardOutlined, DatabaseOutlined, FireOutlined, GlobalOutlined, + SafetyCertificateOutlined, ThunderboltOutlined, +} from '@ant-design/icons' import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import apiClient, { isEnvelope } from '../../api/client' +import PageHeader from '../../components/PageHeader' +import StatusDot from '../../components/StatusDot' + +const { Text } = Typography + +// ── Wire shapes ─────────────────────────────────────────────── + +interface Domain { id: number; active: boolean; primary_backend_id?: number | null } +interface Backend { id: number; active: boolean; name: string } +interface Iface { id: number; active: boolean; name: string; type: string } +interface FwRule { id: number; enabled: boolean; action: string } +interface FwNAT { id: number; enabled: boolean; kind: string } +interface FwZone { id: number; name: string; builtin: boolean } +interface TLSCert { id: number; common_name: string; not_after?: string } +interface ClusterNode { id: string; fqdn: string; role: string } +interface WGIface { id: number; name: string; mode: string; active: boolean } +interface WGStatusRow { + interface: string + peer_public_key: string + endpoint?: string + last_handshake_unix: number + transfer_rx: number + transfer_tx: number +} +interface ServiceStatus { + label: string + unit: string + active: boolean + state: string + since?: string +} +interface Resources { + load_avg_1: number; load_avg_5: number; load_avg_15: number + mem_total_kb: number; mem_avail_kb: number; mem_used_pct: number + disk_total_gb: number; disk_free_gb: number; disk_used_pct: number + conntrack_count: number; conntrack_max: number + uptime_sec: number; boot_time_unix: number +} +interface AuditEntry { + id: number; actor: string; action: string; subject?: string + detail?: unknown; created_at: string +} +interface HAProxyBackend { + backend: string; server: string; status: string + sessions: number; bytes_in: number; bytes_out: number + last_change_sec: number; health?: string +} + +// ── Fetchers ────────────────────────────────────────────────── + +async function fetchList(url: string, key: string): Promise { + try { + const r = await apiClient.get(url) + if (!isEnvelope(r.data)) return [] + return ((r.data.data as Record)[key]) ?? [] + } catch { return [] } +} +async function fetchOne(url: string): Promise { + try { + const r = await apiClient.get(url) + return isEnvelope(r.data) ? (r.data.data as T) : null + } catch { return null } +} + +// ── Helpers ─────────────────────────────────────────────────── + +function formatBytes(n: number): string { + if (!n) return '0 B' + if (n < 1024) return `${n} B` + const u = ['KiB', 'MiB', 'GiB', 'TiB'] + let v = n / 1024, i = 0 + while (v >= 1024 && i < u.length - 1) { v /= 1024; i++ } + return `${v.toFixed(1)} ${u[i]}` +} +function relativeTime(unix: number): string { + if (!unix) return 'nie' + const sec = Math.max(0, Math.floor(Date.now() / 1000) - unix) + if (sec < 60) return `vor ${sec}s` + if (sec < 3600) return `vor ${Math.floor(sec / 60)}m` + if (sec < 86400) return `vor ${Math.floor(sec / 3600)}h` + return `vor ${Math.floor(sec / 86400)}d` +} +function formatUptime(sec: number): string { + if (sec < 60) return `${sec}s` + const days = Math.floor(sec / 86400) + const h = Math.floor((sec % 86400) / 3600) + const m = Math.floor((sec % 3600) / 60) + if (days > 0) return `${days}d ${h}h` + if (h > 0) return `${h}h ${m}m` + return `${m}m` +} +function relativeFromIso(iso: string): string { + const t = new Date(iso).getTime() + if (!t) return '' + return relativeTime(Math.floor(t / 1000)) +} + +// ── Page ────────────────────────────────────────────────────── export default function DashboardPage() { const { t } = useTranslation() - const { data: health } = useQuery({ + const health = useQuery({ queryKey: ['system', 'health'], - queryFn: async () => { - const r = await apiClient.get('/system/health') - if (isEnvelope(r.data)) return r.data.data as { status: string; version: string } - return null - }, + queryFn: () => fetchOne<{ status: string; version: string }>('/system/health'), refetchInterval: 30_000, }) + const services = useQuery({ + queryKey: ['system', 'services'], + queryFn: () => fetchList('/system/services', 'services'), + refetchInterval: 10_000, + }) + const resources = useQuery({ + queryKey: ['system', 'resources'], + queryFn: () => fetchOne('/system/resources'), + refetchInterval: 10_000, + }) + const haproxyBackends = useQuery({ + queryKey: ['haproxy', 'stats'], + queryFn: () => fetchList('/haproxy/stats', 'backends'), + refetchInterval: 10_000, + }) + const auditEntries = useQuery({ + queryKey: ['audit', 'recent'], + queryFn: () => fetchList('/audit/recent?limit=10', 'entries'), + refetchInterval: 15_000, + }) + + const domains = useQuery({ queryKey: ['domains'], queryFn: () => fetchList('/domains', 'domains') }) + const backends = useQuery({ queryKey: ['backends'], queryFn: () => fetchList('/backends', 'backends') }) + const ifaces = useQuery({ queryKey: ['network-interfaces'], queryFn: () => fetchList('/network-interfaces', 'interfaces') }) + const fwRules = useQuery({ queryKey: ['fw', 'rules'], queryFn: () => fetchList('/firewall/rules', 'rules') }) + const fwNAT = useQuery({ queryKey: ['fw', 'nat'], queryFn: () => fetchList('/firewall/nat-rules', 'nat_rules') }) + const fwZones = useQuery({ queryKey: ['fw-zones'], queryFn: () => fetchList('/firewall/zones', 'zones') }) + const tlsCerts = useQuery({ queryKey: ['tls-certs'], queryFn: () => fetchList('/tls-certs', 'tls_certs') }) + const cluster = useQuery({ queryKey: ['cluster', 'nodes'], queryFn: () => fetchList('/cluster/nodes', 'nodes') }) + const wgIfaces = useQuery({ queryKey: ['wg', 'interfaces'], queryFn: () => fetchList('/wireguard/interfaces', 'interfaces') }) + const wgStatus = useQuery({ + queryKey: ['wg', 'status'], + queryFn: () => fetchList('/wireguard/status', 'status'), + refetchInterval: 10_000, + }) + + // Derived stats (same as before — KPI tiles) + const activeBackends = (backends.data ?? []).filter(b => b.active).length + const activeDomains = (domains.data ?? []).filter(d => d.active).length + const activeIfaces = (ifaces.data ?? []).filter(i => i.active).length + const activeFwRules = (fwRules.data ?? []).filter(r => r.enabled).length + const activeNAT = (fwNAT.data ?? []).filter(r => r.enabled).length + const wgServers = (wgIfaces.data ?? []).filter(i => i.mode === 'server' && i.active).length + const wgClients = (wgIfaces.data ?? []).filter(i => i.mode === 'client' && i.active).length + const wgConnected = (wgStatus.data ?? []).filter(s => s.last_handshake_unix > 0 + && Date.now() / 1000 - s.last_handshake_unix < 180).length + + const now = Date.now() + const certsSoon = (tlsCerts.data ?? []).filter(c => { + if (!c.not_after) return false + const exp = new Date(c.not_after).getTime() + return exp - now < 30 * 86_400_000 + }) return (
- {t('dashboard.title')} - {t('dashboard.welcomeHint')} - - - - + } + title={t('dashboard.title')} + subtitle={t('dashboard.welcomeHint')} + extra={ + + v{health.data?.version ?? '—'} + + + } + /> + + {/* ── KPI tiles (compact strip) ──────────────────── */} + + } label={t('dashboard.kpi.domains')} value={activeDomains} total={(domains.data ?? []).length} /> + } label={t('dashboard.kpi.backends')} value={activeBackends} total={(backends.data ?? []).length} /> + } label={t('dashboard.kpi.ifaces')} value={activeIfaces} total={(ifaces.data ?? []).length} /> + } label={t('dashboard.kpi.fwRules')} value={activeFwRules} total={(fwRules.data ?? []).length} /> + } label={t('dashboard.kpi.natRules')} value={activeNAT} total={(fwNAT.data ?? []).length} /> + } label={t('dashboard.kpi.wg')} value={wgConnected} total={wgServers + wgClients} /> + + + {/* ── Resources strip (load / mem / disk / conntrack / uptime) ── */} + + + + + {/* ── Service-health-grid ─────────────────────────── */} + {t('dashboard.servicesCard.title')}} className="mb-12"> + + {(services.data ?? []).map(s => ( + + +
+ + {s.label} + + +
+
+ + ))} +
+
+ + + {/* ── Recent activity (audit log) ─────────────────── */} + + {t('dashboard.activityCard.title')}} className="h-100"> + {(auditEntries.data ?? []).length === 0 ? ( + {t('dashboard.activityCard.empty')} + ) : ( + + {(auditEntries.data ?? []).map(e => ( +
+ + {e.action} + {e.actor} + {e.subject && {e.subject}} + {relativeFromIso(e.created_at)} + +
+ ))} +
+ )}
- - - + + {/* ── HAProxy backend live health ─────────────────── */} + + {t('dashboard.haproxyCard.title')}} className="h-100"> + {(haproxyBackends.data ?? []).length === 0 ? ( + {t('dashboard.haproxyCard.empty')} + ) : ( + + {(haproxyBackends.data ?? []).map((b, i) => ( +
+ + {b.backend}/{b.server} + {b.status} + + {b.sessions} sess · ↓{formatBytes(b.bytes_in)} ↑{formatBytes(b.bytes_out)} + + {formatUptime(b.last_change_sec)} + +
+ ))} +
+ )} +
+ + + {/* ── WireGuard live ─────────────────────────────── */} + + {t('dashboard.wgCard.title')}} className="h-100"> + {(wgIfaces.data ?? []).length === 0 ? ( + {t('dashboard.wgCard.empty')} + ) : ( + + {(wgIfaces.data ?? []).map(ifc => { + const status = (wgStatus.data ?? []).filter(s => s.interface === ifc.name) + return ( +
+ + {ifc.name} + {ifc.mode} + + + {status.map(s => ( +
+ {s.endpoint && {s.endpoint} · } + {relativeTime(s.last_handshake_unix)} + {' · ▼'}{formatBytes(s.transfer_rx)}{' · ▲'}{formatBytes(s.transfer_tx)} +
+ ))} +
+ ) + })} +
+ )} +
+ + + {/* ── Cluster ─────────────────────────────────────── */} + + {t('dashboard.clusterCard.title')}} className="h-100"> + + + {(cluster.data ?? []).map(n => ( +
+ {n.fqdn} {n.role} +
+ ))} +
+
+ + + {/* ── Firewall ────────────────────────────────────── */} + + {t('dashboard.firewallCard.title')}} className="h-100"> + + + {(fwZones.data ?? []).map(z => ( + {z.name.toUpperCase()} + ))} + +
+ {t('dashboard.firewallCard.activeRules', { rules: activeFwRules, nat: activeNAT })} +
+
+ + + {/* ── SSL ─────────────────────────────────────────── */} + + {t('dashboard.sslCard.title')}} className="h-100"> + + {certsSoon.length > 0 ? ( + + ) : ( +
+ {t('dashboard.sslCard.allFresh')} +
+ )} +
+ + + {/* ── Routing summary ─────────────────────────────── */} + + {t('dashboard.routingCard.title')}} className="h-100"> + + + + +
+ {t('dashboard.routingCard.attached', { + count: (domains.data ?? []).filter(d => d.primary_backend_id).length, + total: (domains.data ?? []).length, + })} +
) } + +// ── Sub-components ──────────────────────────────────────────── + +interface KPIProps { icon: React.ReactNode; label: string; value: number; total?: number } + +function KPI({ icon, label, value, total }: KPIProps) { + return ( + + + + + {icon}{label} + +
+ {value}{total !== undefined && total !== value && / {total}} +
+
+
+ + ) +} + +function ResourcesCard({ r }: { r?: Resources | null }) { + const { t } = useTranslation() + if (!r) return null + const memUsedGB = ((r.mem_total_kb - r.mem_avail_kb) / 1024 / 1024).toFixed(1) + const memTotalGB = (r.mem_total_kb / 1024 / 1024).toFixed(1) + const ctPct = r.conntrack_max > 0 ? (r.conntrack_count * 100 / r.conntrack_max) : 0 + return ( + + + + + {t('dashboard.resCard.load')} +
+ {r.load_avg_1.toFixed(2)} / {r.load_avg_5.toFixed(2)} / {r.load_avg_15.toFixed(2)} +
+ + + + {t('dashboard.resCard.memory')} ({memUsedGB} / {memTotalGB} GB) + + 90 ? 'exception' : 'normal'} /> + + + + {t('dashboard.resCard.disk')} ({r.disk_free_gb.toFixed(1)} GB {t('dashboard.resCard.free')} / {r.disk_total_gb.toFixed(1)} GB) + + 90 ? 'exception' : 'normal'} /> + + + + {t('dashboard.resCard.conntrack')} ({r.conntrack_count} / {r.conntrack_max}) + + 80 ? 'exception' : 'normal'} /> + + + {t('dashboard.resCard.uptime')} +
{formatUptime(r.uptime_sec)}
+ +
+
+ + ) +} diff --git a/packaging/debian/edgeguard-api/DEBIAN/postinst b/packaging/debian/edgeguard-api/DEBIAN/postinst index 327c83a..d72b605 100755 --- a/packaging/debian/edgeguard-api/DEBIAN/postinst +++ b/packaging/debian/edgeguard-api/DEBIAN/postinst @@ -18,6 +18,13 @@ case "$1" in --shell /usr/sbin/nologin --no-create-home \ --gecos "EdgeGuard daemon" "$EG_USER" fi + # haproxy admin.sock liegt mit mode 0660 root:haproxy. Damit + # edgeguard-api das Backend-Stat-CSV lesen kann (Dashboard), + # in die haproxy-group adden — best-effort, nicht failen wenn + # das Paket noch nicht installiert ist. + if getent group haproxy >/dev/null; then + usermod -a -G haproxy "$EG_USER" || true + fi # ── Directories ────────────────────────────────────────────── # /etc/edgeguard und Service-Subdirs müssen für die Service-User