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:
@@ -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.
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
var version = "1.0.42"
|
||||
var version = "1.0.43"
|
||||
|
||||
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
38
internal/handlers/audit.go
Normal file
38
internal/handlers/audit.go
Normal file
@@ -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})
|
||||
}
|
||||
118
internal/handlers/haproxy_stats.go
Normal file
118
internal/handlers/haproxy_stats.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "edgeguard-management-ui",
|
||||
"private": true,
|
||||
"version": "1.0.42",
|
||||
"version": "1.0.43",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<T>(url: string, key: string): Promise<T[]> {
|
||||
try {
|
||||
const r = await apiClient.get(url)
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return ((r.data.data as Record<string, T[]>)[key]) ?? []
|
||||
} catch { return [] }
|
||||
}
|
||||
async function fetchOne<T>(url: string): Promise<T | null> {
|
||||
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<ServiceStatus>('/system/services', 'services'),
|
||||
refetchInterval: 10_000,
|
||||
})
|
||||
const resources = useQuery({
|
||||
queryKey: ['system', 'resources'],
|
||||
queryFn: () => fetchOne<Resources>('/system/resources'),
|
||||
refetchInterval: 10_000,
|
||||
})
|
||||
const haproxyBackends = useQuery({
|
||||
queryKey: ['haproxy', 'stats'],
|
||||
queryFn: () => fetchList<HAProxyBackend>('/haproxy/stats', 'backends'),
|
||||
refetchInterval: 10_000,
|
||||
})
|
||||
const auditEntries = useQuery({
|
||||
queryKey: ['audit', 'recent'],
|
||||
queryFn: () => fetchList<AuditEntry>('/audit/recent?limit=10', 'entries'),
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
|
||||
const domains = useQuery({ queryKey: ['domains'], queryFn: () => fetchList<Domain>('/domains', 'domains') })
|
||||
const backends = useQuery({ queryKey: ['backends'], queryFn: () => fetchList<Backend>('/backends', 'backends') })
|
||||
const ifaces = useQuery({ queryKey: ['network-interfaces'], queryFn: () => fetchList<Iface>('/network-interfaces', 'interfaces') })
|
||||
const fwRules = useQuery({ queryKey: ['fw', 'rules'], queryFn: () => fetchList<FwRule>('/firewall/rules', 'rules') })
|
||||
const fwNAT = useQuery({ queryKey: ['fw', 'nat'], queryFn: () => fetchList<FwNAT>('/firewall/nat-rules', 'nat_rules') })
|
||||
const fwZones = useQuery({ queryKey: ['fw-zones'], queryFn: () => fetchList<FwZone>('/firewall/zones', 'zones') })
|
||||
const tlsCerts = useQuery({ queryKey: ['tls-certs'], queryFn: () => fetchList<TLSCert>('/tls-certs', 'tls_certs') })
|
||||
const cluster = useQuery({ queryKey: ['cluster', 'nodes'], queryFn: () => fetchList<ClusterNode>('/cluster/nodes', 'nodes') })
|
||||
const wgIfaces = useQuery({ queryKey: ['wg', 'interfaces'], queryFn: () => fetchList<WGIface>('/wireguard/interfaces', 'interfaces') })
|
||||
const wgStatus = useQuery({
|
||||
queryKey: ['wg', 'status'],
|
||||
queryFn: () => fetchList<WGStatusRow>('/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 (
|
||||
<div>
|
||||
<Typography.Title level={3}>{t('dashboard.title')}</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">{t('dashboard.welcomeHint')}</Typography.Paragraph>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic title="API status" value={health?.status ?? '—'} />
|
||||
<PageHeader
|
||||
icon={<DashboardOutlined />}
|
||||
title={t('dashboard.title')}
|
||||
subtitle={t('dashboard.welcomeHint')}
|
||||
extra={
|
||||
<Space>
|
||||
<Tag color="blue">v{health.data?.version ?? '—'}</Tag>
|
||||
<StatusDot active={health.data?.status === 'ok'} />
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ── KPI tiles (compact strip) ──────────────────── */}
|
||||
<Row gutter={[12, 12]} className="mb-12">
|
||||
<KPI icon={<GlobalOutlined />} label={t('dashboard.kpi.domains')} value={activeDomains} total={(domains.data ?? []).length} />
|
||||
<KPI icon={<DatabaseOutlined />} label={t('dashboard.kpi.backends')} value={activeBackends} total={(backends.data ?? []).length} />
|
||||
<KPI icon={<ClusterOutlined />} label={t('dashboard.kpi.ifaces')} value={activeIfaces} total={(ifaces.data ?? []).length} />
|
||||
<KPI icon={<FireOutlined />} label={t('dashboard.kpi.fwRules')} value={activeFwRules} total={(fwRules.data ?? []).length} />
|
||||
<KPI icon={<BranchesOutlined />} label={t('dashboard.kpi.natRules')} value={activeNAT} total={(fwNAT.data ?? []).length} />
|
||||
<KPI icon={<ThunderboltOutlined />} label={t('dashboard.kpi.wg')} value={wgConnected} total={wgServers + wgClients} />
|
||||
</Row>
|
||||
|
||||
{/* ── Resources strip (load / mem / disk / conntrack / uptime) ── */}
|
||||
<Row gutter={[12, 12]} className="mb-12">
|
||||
<ResourcesCard r={resources.data} />
|
||||
</Row>
|
||||
|
||||
{/* ── Service-health-grid ─────────────────────────── */}
|
||||
<Card size="small" title={<><DashboardOutlined /> {t('dashboard.servicesCard.title')}</>} className="mb-12">
|
||||
<Row gutter={[8, 8]}>
|
||||
{(services.data ?? []).map(s => (
|
||||
<Col key={s.unit} xs={12} sm={8} md={6} lg={3}>
|
||||
<Tooltip title={s.since ? `${s.state} since ${s.since}` : s.state}>
|
||||
<div style={{ padding: '8px 10px', border: '1px solid #E2E8F0', borderRadius: 6, background: '#FFF' }}>
|
||||
<Space size={4} direction="vertical" style={{ width: '100%' }}>
|
||||
<Text style={{ fontSize: 12, fontWeight: 500 }}>{s.label}</Text>
|
||||
<StatusDot active={s.active} activeLabel={s.state} inactiveLabel={s.state || 'unknown'} />
|
||||
</Space>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Row gutter={[12, 12]} className="mb-12">
|
||||
{/* ── Recent activity (audit log) ─────────────────── */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card size="small" title={<><ApiOutlined /> {t('dashboard.activityCard.title')}</>} className="h-100">
|
||||
{(auditEntries.data ?? []).length === 0 ? (
|
||||
<Text type="secondary">{t('dashboard.activityCard.empty')}</Text>
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={2}>
|
||||
{(auditEntries.data ?? []).map(e => (
|
||||
<div key={e.id} style={{ fontSize: 12, color: '#334155', borderBottom: '1px solid #F1F5F9', padding: '4px 0' }}>
|
||||
<Space size={6} wrap>
|
||||
<Tag color="blue" style={{ margin: 0, fontSize: 10 }}>{e.action}</Tag>
|
||||
<Text style={{ fontSize: 12 }}><b>{e.actor}</b></Text>
|
||||
{e.subject && <Text type="secondary" style={{ fontSize: 11 }}>{e.subject}</Text>}
|
||||
<Text type="secondary" style={{ fontSize: 11, marginLeft: 'auto' }}>{relativeFromIso(e.created_at)}</Text>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic title="Version" value={health?.version ?? '—'} />
|
||||
|
||||
{/* ── HAProxy backend live health ─────────────────── */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card size="small" title={<><DatabaseOutlined /> {t('dashboard.haproxyCard.title')}</>} className="h-100">
|
||||
{(haproxyBackends.data ?? []).length === 0 ? (
|
||||
<Text type="secondary">{t('dashboard.haproxyCard.empty')}</Text>
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={2}>
|
||||
{(haproxyBackends.data ?? []).map((b, i) => (
|
||||
<div key={i} style={{ fontSize: 12, color: '#334155', borderBottom: '1px solid #F1F5F9', padding: '4px 0' }}>
|
||||
<Space size={6} wrap>
|
||||
<code style={{ fontSize: 11 }}>{b.backend}/{b.server}</code>
|
||||
<Tag
|
||||
color={b.status === 'UP' ? 'green' : b.status === 'no check' ? 'default' : 'red'}
|
||||
style={{ margin: 0, fontSize: 10 }}
|
||||
>{b.status}</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{b.sessions} sess · ↓{formatBytes(b.bytes_in)} ↑{formatBytes(b.bytes_out)}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 11, marginLeft: 'auto' }}>{formatUptime(b.last_change_sec)}</Text>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* ── WireGuard live ─────────────────────────────── */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card size="small" title={<><ThunderboltOutlined /> {t('dashboard.wgCard.title')}</>} className="h-100">
|
||||
{(wgIfaces.data ?? []).length === 0 ? (
|
||||
<Text type="secondary">{t('dashboard.wgCard.empty')}</Text>
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={4}>
|
||||
{(wgIfaces.data ?? []).map(ifc => {
|
||||
const status = (wgStatus.data ?? []).filter(s => s.interface === ifc.name)
|
||||
return (
|
||||
<div key={ifc.id} style={{ borderBottom: '1px solid #F1F5F9', padding: '4px 0' }}>
|
||||
<Space>
|
||||
<code>{ifc.name}</code>
|
||||
<Tag color={ifc.mode === 'server' ? 'blue' : 'purple'}>{ifc.mode}</Tag>
|
||||
<StatusDot active={ifc.active} />
|
||||
</Space>
|
||||
{status.map(s => (
|
||||
<div key={s.peer_public_key} style={{ fontSize: 12, color: '#64748B', marginTop: 2 }}>
|
||||
{s.endpoint && <span>{s.endpoint} · </span>}
|
||||
<span>{relativeTime(s.last_handshake_unix)}</span>
|
||||
{' · ▼'}{formatBytes(s.transfer_rx)}{' · ▲'}{formatBytes(s.transfer_tx)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* ── Cluster ─────────────────────────────────────── */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card size="small" title={<><ApartmentOutlined /> {t('dashboard.clusterCard.title')}</>} className="h-100">
|
||||
<Statistic title={t('dashboard.clusterCard.nodes')} value={(cluster.data ?? []).length} />
|
||||
<Space direction="vertical" style={{ marginTop: 6, width: '100%' }} size={2}>
|
||||
{(cluster.data ?? []).map(n => (
|
||||
<div key={n.id} style={{ fontSize: 12, color: '#334155' }}>
|
||||
<code>{n.fqdn}</code> <Tag color={n.role === 'primary' ? 'green' : 'default'}>{n.role}</Tag>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* ── Firewall ────────────────────────────────────── */}
|
||||
<Col xs={24} md={12} xl={8}>
|
||||
<Card size="small" title={<><FireOutlined /> {t('dashboard.firewallCard.title')}</>} className="h-100">
|
||||
<Statistic title={t('dashboard.firewallCard.zones')} value={(fwZones.data ?? []).length} />
|
||||
<Space wrap style={{ marginTop: 6 }}>
|
||||
{(fwZones.data ?? []).map(z => (
|
||||
<Tag key={z.id} color={z.builtin ? 'blue' : 'gold'}>{z.name.toUpperCase()}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
<div style={{ marginTop: 12, fontSize: 12, color: '#64748B' }}>
|
||||
{t('dashboard.firewallCard.activeRules', { rules: activeFwRules, nat: activeNAT })}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* ── SSL ─────────────────────────────────────────── */}
|
||||
<Col xs={24} md={12} xl={8}>
|
||||
<Card size="small" title={<><SafetyCertificateOutlined /> {t('dashboard.sslCard.title')}</>} className="h-100">
|
||||
<Statistic title={t('dashboard.sslCard.total')} value={(tlsCerts.data ?? []).length} />
|
||||
{certsSoon.length > 0 ? (
|
||||
<Alert
|
||||
style={{ marginTop: 8 }}
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t('dashboard.sslCard.expiringSoon', { count: certsSoon.length })}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#64748B' }}>
|
||||
{t('dashboard.sslCard.allFresh')}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* ── Routing summary ─────────────────────────────── */}
|
||||
<Col xs={24} md={12} xl={8}>
|
||||
<Card size="small" title={<><GlobalOutlined /> {t('dashboard.routingCard.title')}</>} className="h-100">
|
||||
<Row gutter={8}>
|
||||
<Col span={12}><Statistic title={t('dashboard.routingCard.domains')} value={(domains.data ?? []).length} /></Col>
|
||||
<Col span={12}><Statistic title={t('dashboard.routingCard.backends')} value={(backends.data ?? []).length} /></Col>
|
||||
</Row>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#64748B' }}>
|
||||
{t('dashboard.routingCard.attached', {
|
||||
count: (domains.data ?? []).filter(d => d.primary_backend_id).length,
|
||||
total: (domains.data ?? []).length,
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────
|
||||
|
||||
interface KPIProps { icon: React.ReactNode; label: string; value: number; total?: number }
|
||||
|
||||
function KPI({ icon, label, value, total }: KPIProps) {
|
||||
return (
|
||||
<Col xs={12} sm={8} md={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||
<span style={{ marginRight: 6, color: '#0EA5E9' }}>{icon}</span>{label}
|
||||
</Text>
|
||||
<div style={{ fontSize: 22, fontWeight: 600, color: '#0F172A' }}>
|
||||
{value}{total !== undefined && total !== value && <span style={{ fontSize: 13, color: '#94A3B8', fontWeight: 400 }}> / {total}</span>}
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Col span={24}>
|
||||
<Card size="small">
|
||||
<Row gutter={[16, 8]}>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase' }}>{t('dashboard.resCard.load')}</Text>
|
||||
<div style={{ fontSize: 18, fontWeight: 600 }}>
|
||||
{r.load_avg_1.toFixed(2)} / {r.load_avg_5.toFixed(2)} / {r.load_avg_15.toFixed(2)}
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={6}>
|
||||
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase' }}>
|
||||
{t('dashboard.resCard.memory')} ({memUsedGB} / {memTotalGB} GB)
|
||||
</Text>
|
||||
<Progress percent={Math.round(r.mem_used_pct)} size="small" status={r.mem_used_pct > 90 ? 'exception' : 'normal'} />
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={6}>
|
||||
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase' }}>
|
||||
{t('dashboard.resCard.disk')} ({r.disk_free_gb.toFixed(1)} GB {t('dashboard.resCard.free')} / {r.disk_total_gb.toFixed(1)} GB)
|
||||
</Text>
|
||||
<Progress percent={Math.round(r.disk_used_pct)} size="small" status={r.disk_used_pct > 90 ? 'exception' : 'normal'} />
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase' }}>
|
||||
{t('dashboard.resCard.conntrack')} ({r.conntrack_count} / {r.conntrack_max})
|
||||
</Text>
|
||||
<Progress percent={Math.round(ctPct)} size="small" status={ctPct > 80 ? 'exception' : 'normal'} />
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase' }}>{t('dashboard.resCard.uptime')}</Text>
|
||||
<div style={{ fontSize: 18, fontWeight: 600 }}>{formatUptime(r.uptime_sec)}</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user