Compare commits
14 Commits
60358a6d47
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aeb4eaa6b6 | ||
|
|
4675c6062e | ||
|
|
25bc9c3673 | ||
|
|
a54d367c24 | ||
|
|
6149670375 | ||
|
|
1b13df4032 | ||
|
|
3ca37ee226 | ||
|
|
86aee33308 | ||
|
|
4be9f7f280 | ||
|
|
a34457f13f | ||
|
|
1b0320a5da | ||
|
|
9137c07c95 | ||
|
|
84112d399b | ||
|
|
0f2fba4a62 |
@@ -168,7 +168,8 @@ func main() {
|
|||||||
if st != nil && st.Completed {
|
if st != nil && st.Completed {
|
||||||
// Auto-create /etc/edgeguard/node.conf falls fehlt.
|
// Auto-create /etc/edgeguard/node.conf falls fehlt.
|
||||||
_, _ = cluster.EnsureLocalConfig("")
|
_, _ = cluster.EnsureLocalConfig("")
|
||||||
if _, err := cluster.EnsureSelfRegistered(ctx, clusterStore, st.FQDN, "primary", version); err != nil {
|
if _, err := cluster.EnsureSelfRegistered(ctx, clusterStore, st.FQDN,
|
||||||
|
localClusterRole(ctx, pool, st), version); err != nil {
|
||||||
slog.Warn("self-register in ha_nodes failed", "error", err)
|
slog.Warn("self-register in ha_nodes failed", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,12 +304,12 @@ func main() {
|
|||||||
systemHdl.WithAudit(auditRepo, nodeID)
|
systemHdl.WithAudit(auditRepo, nodeID)
|
||||||
systemHdl.WithDB(pool)
|
systemHdl.WithDB(pool)
|
||||||
systemHdl.WithConfigPreviewers(map[string]func(context.Context) (string, error){
|
systemHdl.WithConfigPreviewers(map[string]func(context.Context) (string, error){
|
||||||
"haproxy": haproxy.New(pool).RenderToString,
|
"haproxy": haproxy.New(pool).RenderToString,
|
||||||
"nftables": firewallrender.New(pool).RenderToString,
|
"nftables": firewallrender.New(pool).RenderToString,
|
||||||
"squid": squidrender.New(pool).RenderToString,
|
"squid": squidrender.New(pool).RenderToString,
|
||||||
"unbound": unboundrender.New(pool).RenderToString,
|
"unbound": unboundrender.New(pool).RenderToString,
|
||||||
"chrony": chronyrender.New(pool).RenderToString,
|
"chrony": chronyrender.New(pool).RenderToString,
|
||||||
"wireguard": wgrender.New(pool, secretsBox).RenderToString,
|
"wireguard": wgrender.New(pool, secretsBox).RenderToString,
|
||||||
"crowdsec-whitelist": crowdsec.NewWhitelistGenerator(pool).RenderToString,
|
"crowdsec-whitelist": crowdsec.NewWhitelistGenerator(pool).RenderToString,
|
||||||
})
|
})
|
||||||
setupHdl.WithAudit(auditRepo, nodeID)
|
setupHdl.WithAudit(auditRepo, nodeID)
|
||||||
@@ -927,3 +928,47 @@ func randomEphemeralSecret() []byte {
|
|||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// localClusterRole ermittelt die eigene Cluster-Rolle für die node-lokale
|
||||||
|
// ha_nodes-Zeile.
|
||||||
|
//
|
||||||
|
// Befund 2026-09-11: Hier stand fest "primary" — für JEDEN Node, bei jedem
|
||||||
|
// API-Start. ha_nodes ist node-lokal (nicht repliziert), also trug sich auch
|
||||||
|
// ein per Join dazugekommener Standby bei sich selbst als "primary" ein. In
|
||||||
|
// der Cluster-Ansicht DIESES Nodes erschienen dadurch beide Knoten als
|
||||||
|
// Primary, und eine Korrektur direkt in der DB hielt nur bis zum nächsten
|
||||||
|
// Neustart.
|
||||||
|
//
|
||||||
|
// Nicht kosmetisch: keepalived.go nutzt `role` als Fallback, wenn pg_role
|
||||||
|
// nicht 'standby' ist. Ein Standby, der sich selbst "primary" nennt, ist
|
||||||
|
// damit genau der Zustand, der 2026-05 schon einmal einen Split-Brain
|
||||||
|
// ausgelöst hat (beide Knoten Priorität 200, höhere IP gewinnt).
|
||||||
|
//
|
||||||
|
// Verlässlich ist — wie in cluster_repair.go dokumentiert — die
|
||||||
|
// Replikations-Topologie selbst: nur der Primary hat die PUBLICATION, nur
|
||||||
|
// der Standby die SUBSCRIPTION. Beide Kataloge darf der edgeguard-DB-User
|
||||||
|
// lesen. Das ist zugleich selbstheilend: nach `edgeguard-ctl promote` hat
|
||||||
|
// der neue Primary die Publication und meldet sich ab dem nächsten Start
|
||||||
|
// korrekt als "primary" — anders als eine Ableitung aus setup.json, die
|
||||||
|
// den Promote überschreiben würde.
|
||||||
|
func localClusterRole(ctx context.Context, pool *pgxpool.Pool, st *setup.State) string {
|
||||||
|
if pool != nil {
|
||||||
|
var hasPub, hasSub bool
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname = 'edgeguard_shared')`,
|
||||||
|
).Scan(&hasPub); err == nil && hasPub {
|
||||||
|
return "primary"
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM pg_subscription WHERE subname = 'edgeguard_sub')`,
|
||||||
|
).Scan(&hasSub); err == nil && hasSub {
|
||||||
|
return "peer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keine Replikation eingerichtet: ein per Join dazugekommener Node ist
|
||||||
|
// trotzdem kein Primary, alles andere (Founder/Single-Node) schon.
|
||||||
|
if st != nil && st.IsClusterNode {
|
||||||
|
return "peer"
|
||||||
|
}
|
||||||
|
return "primary"
|
||||||
|
}
|
||||||
|
|||||||
@@ -167,8 +167,10 @@ func main() {
|
|||||||
st, _ := setupStore.Load()
|
st, _ := setupStore.Load()
|
||||||
|
|
||||||
var renewer *certrenewer.Service
|
var renewer *certrenewer.Service
|
||||||
|
var acmeIssuer *acme.Service
|
||||||
if st != nil && st.ACMEEmail != "" {
|
if st != nil && st.ACMEEmail != "" {
|
||||||
issuer := acme.New(st.ACMEEmail)
|
issuer := acme.New(st.ACMEEmail)
|
||||||
|
acmeIssuer = issuer
|
||||||
renewer = certrenewer.New(tlsRepo, issuer, certDir, 30*24*time.Hour)
|
renewer = certrenewer.New(tlsRepo, issuer, certDir, 30*24*time.Hour)
|
||||||
slog.Info("scheduler: ACME renewer enabled",
|
slog.Info("scheduler: ACME renewer enabled",
|
||||||
"email", st.ACMEEmail, "tick", renewTickInterval, "threshold", "30d")
|
"email", st.ACMEEmail, "tick", renewTickInterval, "threshold", "30d")
|
||||||
@@ -195,6 +197,11 @@ func main() {
|
|||||||
if renewer != nil && nodeHoldsVIP(ctx, pool) {
|
if renewer != nil && nodeHoldsVIP(ctx, pool) {
|
||||||
runRenewer(ctx, renewer, alertSvc, alertDedupe)
|
runRenewer(ctx, renewer, alertSvc, alertDedupe)
|
||||||
}
|
}
|
||||||
|
// Das EIGENE Management-Zertifikat dagegen auf jedem Node — dessen FQDN
|
||||||
|
// zeigt auf die eigene IP, nicht auf die VIP (siehe mgmtcert.go).
|
||||||
|
if acmeIssuer != nil {
|
||||||
|
runManagementCertRenew(ctx, setupStore, tlsRepo, acmeIssuer, alertSvc, alertDedupe)
|
||||||
|
}
|
||||||
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
|
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
|
||||||
|
|
||||||
// Lokale Node-ID für Heartbeat. EnsureNodeID liefert dieselbe ID
|
// Lokale Node-ID für Heartbeat. EnsureNodeID liefert dieselbe ID
|
||||||
@@ -268,6 +275,10 @@ func main() {
|
|||||||
if renewer != nil && nodeHoldsVIP(ctx, pool) {
|
if renewer != nil && nodeHoldsVIP(ctx, pool) {
|
||||||
runRenewer(ctx, renewer, alertSvc, alertDedupe)
|
runRenewer(ctx, renewer, alertSvc, alertDedupe)
|
||||||
}
|
}
|
||||||
|
// Eigenes Management-Cert: unabhaengig von der VIP, siehe oben.
|
||||||
|
if acmeIssuer != nil {
|
||||||
|
runManagementCertRenew(ctx, setupStore, tlsRepo, acmeIssuer, alertSvc, alertDedupe)
|
||||||
|
}
|
||||||
runCertExpiryCheck(ctx, tlsRepo, alertSvc, alertDedupe)
|
runCertExpiryCheck(ctx, tlsRepo, alertSvc, alertDedupe)
|
||||||
case <-licTick.C:
|
case <-licTick.C:
|
||||||
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
|
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
|
||||||
|
|||||||
180
cmd/edgeguard-scheduler/mgmtcert.go
Normal file
180
cmd/edgeguard-scheduler/mgmtcert.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/certstore"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Node-lokale Erneuerung des eigenen Management-Zertifikats.
|
||||||
|
//
|
||||||
|
// Befund 2026-09-11: Auf utm-2 war das Zertifikat fuer die Management-UI
|
||||||
|
// seit zwei Wochen abgelaufen und haette sich nie erneuert. Zwei Gruende
|
||||||
|
// trafen zusammen:
|
||||||
|
//
|
||||||
|
// 1. Das FQDN eines per Join dazugekommenen Nodes landet in KEINER
|
||||||
|
// tls_certs-Zeile — es wird beim Setup einmalig ausgestellt und danach
|
||||||
|
// von niemandem mehr angefasst. certrenewer arbeitet ausschliesslich
|
||||||
|
// die Tabelle ab und sieht es deshalb nie.
|
||||||
|
// 2. Der Scheduler blockt auf einem Nicht-VIP-Master jede ACME-Erneuerung
|
||||||
|
// (v1.3.20). Das ist fuer geteilte Domains richtig — die zeigen per DNS
|
||||||
|
// auf die VIP, nur der Master kann die Challenge bestehen. Fuer das
|
||||||
|
// eigene Management-FQDN stimmt es NICHT: das zeigt auf die eigene IP
|
||||||
|
// des Nodes, der die HTTP-01-Challenge also selbst beantworten kann.
|
||||||
|
//
|
||||||
|
// Deshalb laeuft diese Pruefung auf JEDEM Node, unabhaengig von der VIP —
|
||||||
|
// aber ausschliesslich fuer das eigene FQDN aus setup.json.
|
||||||
|
//
|
||||||
|
// Bewusst NICHT ueber die tls_certs-Tabelle: die ist eine replizierte
|
||||||
|
// Shared-Table, und cluster-reconcile-replication TRUNCATEt solche Tabellen
|
||||||
|
// beim Refresh. Eine lokal auf dem Subscriber eingefuegte Zeile waere beim
|
||||||
|
// naechsten Paket-Upgrade wieder weg. Das Management-Zertifikat ist
|
||||||
|
// node-lokale Infrastruktur (wie die cluster-tls-Certs) und wird auch so
|
||||||
|
// behandelt: reine Datei unter certDir.
|
||||||
|
//
|
||||||
|
// Existiert dagegen eine tls_certs-Zeile fuer das eigene FQDN (so ist es
|
||||||
|
// auf dem Primary, dessen FQDN beim Setup regulaer als Domain angelegt
|
||||||
|
// wurde), bleibt alles beim Alten — dann macht certrenewer weiter seine
|
||||||
|
// Arbeit und wir fassen nichts an. Sonst haetten wir zwei Mechanismen auf
|
||||||
|
// derselben Datei.
|
||||||
|
|
||||||
|
// mgmtCertRenewThreshold: ab wann erneuert wird. Gleicher Wert wie der
|
||||||
|
// certrenewer fuer die Domain-Certs.
|
||||||
|
const mgmtCertRenewThreshold = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
// runManagementCertRenew prueft das eigene Management-Zertifikat und
|
||||||
|
// erneuert es bei Bedarf. Best-effort: Fehler werden geloggt/gemeldet,
|
||||||
|
// der Tick laeuft beim naechsten Zyklus erneut.
|
||||||
|
func runManagementCertRenew(
|
||||||
|
ctx context.Context,
|
||||||
|
setupStore *setup.Store,
|
||||||
|
tlsRepo *tlscerts.Repo,
|
||||||
|
issuer interface {
|
||||||
|
Issue(domain string) (string, string, string, error)
|
||||||
|
},
|
||||||
|
a *alerts.Service, d *dedupe,
|
||||||
|
) {
|
||||||
|
if setupStore == nil || issuer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := setupStore.Load()
|
||||||
|
if err != nil || st == nil || st.FQDN == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fqdn := strings.ToLower(strings.TrimSpace(st.FQDN))
|
||||||
|
|
||||||
|
// Wird das FQDN bereits als regulaere Domain verwaltet, ist der
|
||||||
|
// certrenewer zustaendig — nicht zusaetzlich hier anfassen.
|
||||||
|
if tlsRepo != nil {
|
||||||
|
if managed, err := mgmtCertIsManaged(ctx, tlsRepo, fqdn); err == nil && managed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(certDir, fqdn+".pem")
|
||||||
|
remaining, err := certRemainingValidity(path)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
slog.Info("scheduler: management cert missing/unreadable — issuing",
|
||||||
|
"fqdn", fqdn, "path", path, "error", err)
|
||||||
|
case remaining > mgmtCertRenewThreshold:
|
||||||
|
return // noch lange gueltig
|
||||||
|
default:
|
||||||
|
slog.Info("scheduler: management cert expiring — renewing",
|
||||||
|
"fqdn", fqdn, "remaining", remaining.Round(time.Hour).String())
|
||||||
|
}
|
||||||
|
|
||||||
|
certPEM, chainPEM, keyPEM, err := issuer.Issue(fqdn)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("scheduler: management cert issue failed", "fqdn", fqdn, "error", err)
|
||||||
|
if a != nil && d != nil && d.shouldFire("cert.mgmt_renew_failed:"+fqdn) {
|
||||||
|
_, _ = a.Fire(ctx, "cert.mgmt_renew_failed", alerts.SeverityError,
|
||||||
|
"Management-Zertifikat konnte nicht erneuert werden: "+fqdn,
|
||||||
|
"Die HTTP-01-Challenge fuer das eigene Management-FQDN ist fehlgeschlagen. "+
|
||||||
|
"Pruefe, ob "+fqdn+" auf die oeffentliche IP DIESES Nodes zeigt und Port 80 "+
|
||||||
|
"von aussen erreichbar ist. Fehler: "+err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := certstore.WriteCombined(certDir, fqdn, certPEM, chainPEM, keyPEM); err != nil {
|
||||||
|
slog.Error("scheduler: management cert write failed", "fqdn", fqdn, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := reloadHAProxyForMgmtCert(); err != nil {
|
||||||
|
slog.Warn("scheduler: haproxy reload after management cert renewal failed", "error", err)
|
||||||
|
}
|
||||||
|
slog.Info("scheduler: management cert renewed", "fqdn", fqdn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mgmtCertIsManaged sagt, ob fuer das FQDN bereits eine tls_certs-Zeile
|
||||||
|
// existiert (dann gehoert es dem certrenewer).
|
||||||
|
func mgmtCertIsManaged(ctx context.Context, repo *tlscerts.Repo, fqdn string) (bool, error) {
|
||||||
|
rows, err := repo.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(r.Domain), fqdn) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// certRemainingValidity liest die Restlaufzeit des ersten Zertifikats in
|
||||||
|
// einer kombinierten PEM-Datei. Fehler (Datei fehlt, unlesbar, kein
|
||||||
|
// Zertifikat drin) bedeuten "muss ausgestellt werden".
|
||||||
|
func certRemainingValidity(path string) (time.Duration, error) {
|
||||||
|
raw, err := os.ReadFile(path) //nolint:gosec // fester Pfad aus certDir + eigenem FQDN
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
rest := raw
|
||||||
|
for {
|
||||||
|
var block *pem.Block
|
||||||
|
block, rest = pem.Decode(rest)
|
||||||
|
if block == nil {
|
||||||
|
return 0, os.ErrNotExist
|
||||||
|
}
|
||||||
|
if block.Type != "CERTIFICATE" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
crt, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return time.Until(crt.NotAfter), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reloadHAProxyForMgmtCert: "haproxy.service" ausgeschrieben, weil die
|
||||||
|
// sudoers-Regel im postinst exakt darauf gepinnt ist — ohne Suffix wuerde
|
||||||
|
// sudo den Aufruf ablehnen. Gleicher Aufruf wie in certrenewer.
|
||||||
|
func reloadHAProxyForMgmtCert() error {
|
||||||
|
//nolint:noctx // System-Reload darf nicht am Tick-Context haengen
|
||||||
|
out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return &exitErr{msg: strings.TrimSpace(string(out)), err: err}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type exitErr struct {
|
||||||
|
msg string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *exitErr) Error() string { return e.err.Error() + ": " + e.msg }
|
||||||
|
func (e *exitErr) Unwrap() error { return e.err }
|
||||||
113
cmd/edgeguard-scheduler/mgmtcert_test.go
Normal file
113
cmd/edgeguard-scheduler/mgmtcert_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// certRemainingValidity entscheidet, ob ueberhaupt erneuert wird — ein
|
||||||
|
// falsches Ergebnis heisst entweder "Zertifikat laeuft unbemerkt ab"
|
||||||
|
// (genau der Befund auf utm-2) oder "wir erneuern bei jedem Tick".
|
||||||
|
func writeTestPEM(t *testing.T, dir, name string, notAfter time.Time, withKey bool) string {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("key: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: name},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: notAfter,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cert: %v", err)
|
||||||
|
}
|
||||||
|
var buf []byte
|
||||||
|
// Reihenfolge wie certstore.WriteCombined: erst Cert(-Kette), dann Key.
|
||||||
|
buf = append(buf, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})...)
|
||||||
|
if withKey {
|
||||||
|
kd, err := x509.MarshalECPrivateKey(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal key: %v", err)
|
||||||
|
}
|
||||||
|
buf = append(buf, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kd})...)
|
||||||
|
}
|
||||||
|
p := filepath.Join(dir, name+".pem")
|
||||||
|
if err := os.WriteFile(p, buf, 0o600); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCertRemainingValidity_LongLived(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := writeTestPEM(t, dir, "node.example.com", time.Now().Add(60*24*time.Hour), true)
|
||||||
|
got, err := certRemainingValidity(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unerwarteter Fehler: %v", err)
|
||||||
|
}
|
||||||
|
if got <= mgmtCertRenewThreshold {
|
||||||
|
t.Errorf("60d-Cert muss ueber dem 30d-Schwellwert liegen, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCertRemainingValidity_ExpiringSoon(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := writeTestPEM(t, dir, "node.example.com", time.Now().Add(5*24*time.Hour), true)
|
||||||
|
got, err := certRemainingValidity(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unerwarteter Fehler: %v", err)
|
||||||
|
}
|
||||||
|
if got > mgmtCertRenewThreshold {
|
||||||
|
t.Errorf("5d-Cert muss unter dem Schwellwert liegen, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der utm-2-Fall: bereits abgelaufen → negative Restlaufzeit, also
|
||||||
|
// eindeutig unter dem Schwellwert und damit erneuerungspflichtig.
|
||||||
|
func TestCertRemainingValidity_AlreadyExpired(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := writeTestPEM(t, dir, "node.example.com", time.Now().Add(-14*24*time.Hour), true)
|
||||||
|
got, err := certRemainingValidity(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unerwarteter Fehler: %v", err)
|
||||||
|
}
|
||||||
|
if got >= 0 {
|
||||||
|
t.Errorf("abgelaufenes Cert muss negative Restlaufzeit liefern, got %v", got)
|
||||||
|
}
|
||||||
|
if got > mgmtCertRenewThreshold {
|
||||||
|
t.Errorf("abgelaufenes Cert muss erneuert werden, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCertRemainingValidity_MissingFile(t *testing.T) {
|
||||||
|
if _, err := certRemainingValidity(filepath.Join(t.TempDir(), "nope.pem")); err == nil {
|
||||||
|
t.Error("fehlende Datei muss einen Fehler liefern (→ ausstellen)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur-Key-Datei: darf nicht als gueltiges Zertifikat durchgehen, sonst
|
||||||
|
// wuerde ein kaputter Zustand nie repariert.
|
||||||
|
func TestCertRemainingValidity_NoCertificateBlock(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "keyonly.pem")
|
||||||
|
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
kd, _ := x509.MarshalECPrivateKey(key)
|
||||||
|
if err := os.WriteFile(p, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kd}), 0o600); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := certRemainingValidity(p); err == nil {
|
||||||
|
t.Error("PEM ohne CERTIFICATE-Block muss einen Fehler liefern")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,8 +25,15 @@ type Store struct {
|
|||||||
|
|
||||||
func NewStore(pool *pgxpool.Pool) *Store { return &Store{Pool: pool} }
|
func NewStore(pool *pgxpool.Pool) *Store { return &Store{Pool: pool} }
|
||||||
|
|
||||||
|
// baseSelect MUSS spaltenweise zu scanNode passen. pg_role fehlte hier
|
||||||
|
// urspruenglich (Befund 2026-09-11): HANode.PGRole kam dadurch ueberall als
|
||||||
|
// leerer String an, wo Store.List/Get benutzt wird — /cluster/nodes,
|
||||||
|
// /cluster/status und damit auch die pg_role-Spalte der Cluster-Seite.
|
||||||
|
// UpsertSelf schreibt pg_role bewusst NICHT (ON CONFLICT laesst die Spalte
|
||||||
|
// unangetastet), liest sie aber im RETURNING mit — sonst passt die
|
||||||
|
// Scan-Reihenfolge nicht.
|
||||||
const baseSelect = `
|
const baseSelect = `
|
||||||
SELECT id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip, role,
|
SELECT id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip, role, pg_role,
|
||||||
version, config_hash, status,
|
version, config_hash, status,
|
||||||
last_seen, joined_at, created_at, updated_at
|
last_seen, joined_at, created_at, updated_at
|
||||||
FROM ha_nodes
|
FROM ha_nodes
|
||||||
@@ -90,7 +97,7 @@ ON CONFLICT (id) DO UPDATE SET
|
|||||||
last_seen = EXCLUDED.last_seen,
|
last_seen = EXCLUDED.last_seen,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
RETURNING id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip,
|
RETURNING id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip,
|
||||||
role, version, config_hash, status,
|
role, pg_role, version, config_hash, status,
|
||||||
last_seen, joined_at, created_at, updated_at`,
|
last_seen, joined_at, created_at, updated_at`,
|
||||||
n.ID, n.Name, n.FQDN, n.APIURL,
|
n.ID, n.Name, n.FQDN, n.APIURL,
|
||||||
n.PublicIP, n.InternalIP, n.MgmtIP,
|
n.PublicIP, n.InternalIP, n.MgmtIP,
|
||||||
@@ -181,7 +188,7 @@ func scanNode(row interface{ Scan(...any) error }) (*models.HANode, error) {
|
|||||||
if err := row.Scan(
|
if err := row.Scan(
|
||||||
&n.ID, &n.Name, &n.FQDN, &n.APIURL,
|
&n.ID, &n.Name, &n.FQDN, &n.APIURL,
|
||||||
&n.PublicIP, &n.InternalIP, &n.MgmtIP,
|
&n.PublicIP, &n.InternalIP, &n.MgmtIP,
|
||||||
&n.Role, &n.Version, &n.ConfigHash, &n.Status,
|
&n.Role, &n.PGRole, &n.Version, &n.ConfigHash, &n.Status,
|
||||||
&n.LastSeen, &n.JoinedAt,
|
&n.LastSeen, &n.JoinedAt,
|
||||||
&n.CreatedAt, &n.UpdatedAt,
|
&n.CreatedAt, &n.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
// POST /api/v1/alerts/events/acknowledge — Bulk-Quittieren {ids:[…]}
|
// POST /api/v1/alerts/events/acknowledge — Bulk-Quittieren {ids:[…]}
|
||||||
// POST /api/v1/alerts/events/acknowledge-all — alle offenen quittieren
|
// POST /api/v1/alerts/events/acknowledge-all — alle offenen quittieren
|
||||||
// POST /api/v1/alerts/events/delete — Bulk-Löschen {ids:[…]}
|
// POST /api/v1/alerts/events/delete — Bulk-Löschen {ids:[…]}
|
||||||
|
// POST /api/v1/alerts/events/delete-acknowledged — alle quittierten löschen
|
||||||
type AlertsHandler struct {
|
type AlertsHandler struct {
|
||||||
Service *alerts.Service
|
Service *alerts.Service
|
||||||
Audit *audit.Repo
|
Audit *audit.Repo
|
||||||
@@ -43,6 +44,7 @@ func (h *AlertsHandler) Register(rg *gin.RouterGroup) {
|
|||||||
g.POST("/events/acknowledge", h.AcknowledgeEvents)
|
g.POST("/events/acknowledge", h.AcknowledgeEvents)
|
||||||
g.POST("/events/acknowledge-all", h.AcknowledgeAllEvents)
|
g.POST("/events/acknowledge-all", h.AcknowledgeAllEvents)
|
||||||
g.POST("/events/delete", h.DeleteEvents)
|
g.POST("/events/delete", h.DeleteEvents)
|
||||||
|
g.POST("/events/delete-acknowledged", h.DeleteAcknowledgedEvents)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AlertsHandler) ListChannels(c *gin.Context) {
|
func (h *AlertsHandler) ListChannels(c *gin.Context) {
|
||||||
@@ -192,3 +194,18 @@ func (h *AlertsHandler) DeleteEvents(c *gin.Context) {
|
|||||||
strconv.Itoa(len(req.IDs)), gin.H{"ids": req.IDs, "deleted": n}, h.NodeID)
|
strconv.Itoa(len(req.IDs)), gin.H{"ids": req.IDs, "deleted": n}, h.NodeID)
|
||||||
response.OK(c, gin.H{"deleted": n})
|
response.OK(c, gin.H{"deleted": n})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteAcknowledgedEvents löscht alle quittierten Events auf einmal.
|
||||||
|
// Gegenstück zu AcknowledgeAllEvents — ohne das kam man an einen
|
||||||
|
// groesseren Backlog nur seitenweise heran (die Kopf-Checkbox der
|
||||||
|
// Tabelle markiert nur die aktuelle Seite).
|
||||||
|
func (h *AlertsHandler) DeleteAcknowledgedEvents(c *gin.Context) {
|
||||||
|
n, err := h.Service.DeleteAcknowledged(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "alert.events.delete_acknowledged",
|
||||||
|
"acknowledged", gin.H{"deleted": n}, h.NodeID)
|
||||||
|
response.OK(c, gin.H{"deleted": n})
|
||||||
|
}
|
||||||
|
|||||||
@@ -253,22 +253,46 @@ func (h *SetupHandler) preRegisterPrimary(primaryFQDN string) {
|
|||||||
}
|
}
|
||||||
ip := addrs[0]
|
ip := addrs[0]
|
||||||
|
|
||||||
// Stable ID so repeated calls (join + startup) don't accumulate rows.
|
// Sobald der Primary sich selbst gemeldet hat (Heartbeat-Push), steht
|
||||||
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(primaryFQDN, ".", "-"))
|
// hier bereits eine Zeile mit seiner ECHTEN Node-ID. Dann ist nur die
|
||||||
n := models.HANode{
|
// IP frisch zu halten — mehr will diese Funktion gar nicht.
|
||||||
ID: nodeID,
|
//
|
||||||
Name: primaryFQDN,
|
// Vorher lief in dem Fall unbedingt der Platzhalter-Upsert unten, der
|
||||||
FQDN: primaryFQDN,
|
// am fqdn-Unique-Index scheiterte: UpsertSelf nutzt ON CONFLICT (id),
|
||||||
APIURL: "https://" + primaryFQDN + ":3443",
|
// und die Synthetik-ID "prenode-<fqdn>" trifft die echte Zeile nicht.
|
||||||
Role: "primary",
|
// Ergebnis war ein "duplicate key value violates unique constraint
|
||||||
Status: "online",
|
// ha_nodes_fqdn_unique" bei JEDEM API-Start auf dem Standby — folgenlos
|
||||||
}
|
// (die echte Zeile stimmt ja), aber es sah nach einem Defekt aus und
|
||||||
n.PublicIP = &ip
|
// verdeckte echte Warnungen.
|
||||||
|
tag, err := h.ClusterStore.Pool.Exec(ctx,
|
||||||
if _, err := h.ClusterStore.UpsertSelf(ctx, n); err != nil {
|
`UPDATE ha_nodes SET public_ip = $1, updated_at = NOW() WHERE fqdn = $2`,
|
||||||
slog.Warn("setup: pre-register primary in ha_nodes failed", "fqdn", primaryFQDN, "error", err)
|
ip, primaryFQDN)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("setup: refreshing primary IP in ha_nodes failed",
|
||||||
|
"fqdn", primaryFQDN, "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
// Noch keine Zeile: Platzhalter anlegen, damit @peer_ipv4 den
|
||||||
|
// Primary schon kennt, bevor er sich das erste Mal meldet.
|
||||||
|
// Stabile ID, damit wiederholte Aufrufe (Join + Start) keine
|
||||||
|
// Zeilen anhaeufen.
|
||||||
|
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(primaryFQDN, ".", "-"))
|
||||||
|
n := models.HANode{
|
||||||
|
ID: nodeID,
|
||||||
|
Name: primaryFQDN,
|
||||||
|
FQDN: primaryFQDN,
|
||||||
|
APIURL: "https://" + primaryFQDN + ":3443",
|
||||||
|
Role: "primary",
|
||||||
|
Status: "online",
|
||||||
|
}
|
||||||
|
n.PublicIP = &ip
|
||||||
|
if _, err := h.ClusterStore.UpsertSelf(ctx, n); err != nil {
|
||||||
|
slog.Warn("setup: pre-register primary in ha_nodes failed",
|
||||||
|
"fqdn", primaryFQDN, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := h.PeerReloader(ctx); err != nil {
|
if err := h.PeerReloader(ctx); err != nil {
|
||||||
slog.Warn("setup: PeerReloader failed after primary pre-register", "error", err)
|
slog.Warn("setup: PeerReloader failed after primary pre-register", "error", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -240,6 +240,26 @@ func (s *Service) DeleteEvents(ctx context.Context, ids []int64) (int64, error)
|
|||||||
return tag.RowsAffected(), nil
|
return tag.RowsAffected(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteAcknowledged löscht alle bereits quittierten Events und liefert
|
||||||
|
// die Anzahl. Backing für "Alle quittierten löschen".
|
||||||
|
//
|
||||||
|
// Warum das gebraucht wird (Befund 2026-09-11): Bulk-Löschen ging nur über
|
||||||
|
// die Tabellen-Auswahl, und deren Kopf-Checkbox markiert nur die AKTUELLE
|
||||||
|
// Seite. Bei einem Backlog von ~950 Alt-Alarmen loescht ein Klick also 25
|
||||||
|
// Zeilen, woraufhin die naechsten 25 — optisch identischen — nachruecken.
|
||||||
|
// Es sah aus, als passiere nichts, obwohl korrekt geloescht wurde.
|
||||||
|
//
|
||||||
|
// Bewusst nur die quittierten: was noch niemand gesehen hat, soll nicht
|
||||||
|
// per Sammelaktion verschwinden.
|
||||||
|
func (s *Service) DeleteAcknowledged(ctx context.Context) (int64, error) {
|
||||||
|
tag, err := s.Pool.Exec(ctx,
|
||||||
|
`DELETE FROM alert_events WHERE acknowledged_at IS NOT NULL`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return tag.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Cleanup löscht alert_events älter als keepDays und liefert die Anzahl
|
// Cleanup löscht alert_events älter als keepDays und liefert die Anzahl
|
||||||
// gelöschter Rows. make_interval(days => $1) nimmt $1 sauber als int —
|
// gelöschter Rows. make_interval(days => $1) nimmt $1 sauber als int —
|
||||||
// der frühere ($1 || ' days')::interval-Ansatz erzwang text und scheiterte
|
// der frühere ($1 || ' days')::interval-Ansatz erzwang text und scheiterte
|
||||||
@@ -316,7 +336,7 @@ func (s *Service) sendWebhook(ctx context.Context, c Channel, kind string,
|
|||||||
"severity": string(sev),
|
"severity": string(sev),
|
||||||
"subject": subject,
|
"subject": subject,
|
||||||
"message": message,
|
"message": message,
|
||||||
"content": fmt.Sprintf("[%s] %s: %s\n%s",
|
"content": fmt.Sprintf("[%s] %s: %s\n%s",
|
||||||
strings.ToUpper(string(sev)), kind, subject, message),
|
strings.ToUpper(string(sev)), kind, subject, message),
|
||||||
"text": fmt.Sprintf("*[%s]* %s — %s\n%s",
|
"text": fmt.Sprintf("*[%s]* %s — %s\n%s",
|
||||||
strings.ToUpper(string(sev)), kind, subject, message),
|
strings.ToUpper(string(sev)), kind, subject, message),
|
||||||
|
|||||||
@@ -424,8 +424,9 @@
|
|||||||
"split-brain": "split-brain"
|
"split-brain": "split-brain"
|
||||||
},
|
},
|
||||||
"roleDbPrimary": "DB-Primary",
|
"roleDbPrimary": "DB-Primary",
|
||||||
"rolePeer": "Peer",
|
"roleHint": "Datenbank-Rolle aus der Replikation: DB-Primary nimmt Schreibzugriffe entgegen, DB-Standby repliziert von dort. Sie wandert NICHT mit der VIP — ein Knoten kann DB-Primary sein und trotzdem gerade keepalived-BACKUP (siehe VIP/VRRP-Karte). Sie aendert sich nur durch „edgeguard-ctl promote“.",
|
||||||
"roleHint": "DB-/Cluster-Rolle: bestimmt, wohin Schreibzugriffe gehen. Sie wandert NICHT mit der VIP — ein Node kann DB-Primary sein und trotzdem gerade keepalived-BACKUP (siehe VIP/VRRP-Karte). Sie aendert sich nur durch „edgeguard-ctl promote“."
|
"roleDbStandby": "DB-Standby",
|
||||||
|
"roleDbUnknown": "—"
|
||||||
},
|
},
|
||||||
"routingCard": {
|
"routingCard": {
|
||||||
"title": "Routing",
|
"title": "Routing",
|
||||||
@@ -1557,7 +1558,10 @@
|
|||||||
"subject": "Betreff",
|
"subject": "Betreff",
|
||||||
"delivered": "Gesendet",
|
"delivered": "Gesendet",
|
||||||
"status": "Status"
|
"status": "Status"
|
||||||
}
|
},
|
||||||
|
"deleteAcknowledged": "Quittierte löschen",
|
||||||
|
"confirmDeleteAcked": "{{n}} quittierte Alarme endgültig löschen? Offene Alarme bleiben erhalten.",
|
||||||
|
"eventsDeleted": "{{n}} Alarme gelöscht."
|
||||||
},
|
},
|
||||||
"remotes": {
|
"remotes": {
|
||||||
"scopeTitle": "Off-Site-Backup-Ziele",
|
"scopeTitle": "Off-Site-Backup-Ziele",
|
||||||
|
|||||||
@@ -424,8 +424,9 @@
|
|||||||
"split-brain": "split-brain"
|
"split-brain": "split-brain"
|
||||||
},
|
},
|
||||||
"roleDbPrimary": "DB primary",
|
"roleDbPrimary": "DB primary",
|
||||||
"rolePeer": "Peer",
|
"roleHint": "Database role from replication: the DB primary accepts writes, the DB standby replicates from it. It does NOT follow the VIP — a node can be DB primary while currently being keepalived BACKUP (see the VIP/VRRP card). It only changes via \"edgeguard-ctl promote\".",
|
||||||
"roleHint": "Database/cluster role: decides where writes go. It does NOT follow the VIP — a node can be DB primary while currently being keepalived BACKUP (see the VIP/VRRP card). It only changes via \"edgeguard-ctl promote\"."
|
"roleDbStandby": "DB standby",
|
||||||
|
"roleDbUnknown": "—"
|
||||||
},
|
},
|
||||||
"routingCard": {
|
"routingCard": {
|
||||||
"title": "Routing",
|
"title": "Routing",
|
||||||
@@ -1557,7 +1558,10 @@
|
|||||||
"subject": "Subject",
|
"subject": "Subject",
|
||||||
"delivered": "Delivered",
|
"delivered": "Delivered",
|
||||||
"status": "Status"
|
"status": "Status"
|
||||||
}
|
},
|
||||||
|
"deleteAcknowledged": "Delete acknowledged",
|
||||||
|
"confirmDeleteAcked": "Permanently delete {{n}} acknowledged alerts? Open alerts are kept.",
|
||||||
|
"eventsDeleted": "{{n}} alerts deleted."
|
||||||
},
|
},
|
||||||
"remotes": {
|
"remotes": {
|
||||||
"scopeTitle": "Off-site backup targets",
|
"scopeTitle": "Off-site backup targets",
|
||||||
|
|||||||
@@ -120,9 +120,34 @@ export default function AlertsPage() {
|
|||||||
})
|
})
|
||||||
const delEventsMut = useMutation({
|
const delEventsMut = useMutation({
|
||||||
mutationFn: (ids: number[]) => apiClient.post('/alerts/events/delete', { ids }),
|
mutationFn: (ids: number[]) => apiClient.post('/alerts/events/delete', { ids }),
|
||||||
onSuccess: () => { message.success(t('common.delete')); refreshEvents() },
|
onSuccess: (_d, ids) => {
|
||||||
|
// Anzahl mitsagen: die Kopf-Checkbox markiert nur die AKTUELLE Seite,
|
||||||
|
// und bei einem groesseren Backlog ruecken sofort optisch identische
|
||||||
|
// Eintraege nach. Ohne diese Rueckmeldung sieht es aus, als sei nichts
|
||||||
|
// passiert (Befund 2026-09-11).
|
||||||
|
message.success(t('alerts.eventsDeleted', { n: ids.length }))
|
||||||
|
setSelectedIds([])
|
||||||
|
refreshEvents()
|
||||||
|
},
|
||||||
onError: (e: Error) => message.error(e.message),
|
onError: (e: Error) => message.error(e.message),
|
||||||
})
|
})
|
||||||
|
const delAckedMut = useMutation({
|
||||||
|
mutationFn: () => apiClient.post('/alerts/events/delete-acknowledged'),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
const n = (r?.data as { data?: { deleted?: number } })?.data?.deleted ?? 0
|
||||||
|
message.success(t('alerts.eventsDeleted', { n }))
|
||||||
|
setSelectedIds([])
|
||||||
|
refreshEvents()
|
||||||
|
},
|
||||||
|
onError: (e: Error) => message.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Anzahl quittierter Events ueber den GESAMTEN Datensatz, nicht nur die
|
||||||
|
// sichtbare Seite — der Button soll zeigen, was er wirklich raeumt.
|
||||||
|
const ackedCount = useMemo(
|
||||||
|
() => (events.data ?? []).filter(e => e.acknowledged_at).length,
|
||||||
|
[events.data],
|
||||||
|
)
|
||||||
|
|
||||||
const kindOptions = useMemo(() => {
|
const kindOptions = useMemo(() => {
|
||||||
const kinds = [...new Set((events.data ?? []).map(e => e.kind))].sort()
|
const kinds = [...new Set((events.data ?? []).map(e => e.kind))].sort()
|
||||||
@@ -429,6 +454,18 @@ export default function AlertsPage() {
|
|||||||
{t('alerts.acknowledgeAll')}
|
{t('alerts.acknowledgeAll')}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
{/* Gegenstueck zu "Alle quittieren": ohne das kommt man an
|
||||||
|
einen groesseren Backlog nur seitenweise heran, weil die
|
||||||
|
Kopf-Checkbox der Tabelle nur die aktuelle Seite markiert. */}
|
||||||
|
<Popconfirm title={t('alerts.confirmDeleteAcked', { n: ackedCount })}
|
||||||
|
onConfirm={() => delAckedMut.mutate()}
|
||||||
|
disabled={isViewer || ackedCount === 0}>
|
||||||
|
<Button size="small" danger ghost
|
||||||
|
disabled={isViewer || ackedCount === 0}
|
||||||
|
loading={delAckedMut.isPending}>
|
||||||
|
{t('alerts.deleteAcknowledged')}{ackedCount > 0 ? ` (${ackedCount})` : ''}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<Table size="small" rowKey="id" loading={events.isFetching}
|
<Table size="small" rowKey="id" loading={events.isFetching}
|
||||||
|
|||||||
@@ -74,12 +74,28 @@ interface HAProxyStat {
|
|||||||
req_tot: number; req_rate: number
|
req_tot: number; req_rate: number
|
||||||
last_change_sec: number; health: string
|
last_change_sec: number; health: string
|
||||||
}
|
}
|
||||||
async function listHAProxyStats(): Promise<HAProxyStat[]> {
|
// Der Cache-Eintrag ['haproxy','stats'] wird mit dem Dashboard geteilt,
|
||||||
|
// das aus derselben Antwort zusaetzlich `frontends` liest. Deshalb hier
|
||||||
|
// IMMER die vollstaendige Antwort cachen und erst per `select` auf die
|
||||||
|
// Backends reduzieren, die diese Seite braucht.
|
||||||
|
//
|
||||||
|
// Befund 2026-09-11: Lieferte diese Funktion nur das Backend-Array, hing
|
||||||
|
// es vom zuletzt besuchten Screen ab, welche Form unter dem Key lag —
|
||||||
|
// nach einem Wechsel hierher und zurueck riss das Dashboard mit
|
||||||
|
// "Cannot read properties of undefined (reading 'length')" die ganze
|
||||||
|
// Oberflaeche in die ErrorBoundary.
|
||||||
|
interface HAProxyStatsPayload {
|
||||||
|
backends: HAProxyStat[]
|
||||||
|
frontends: unknown[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
async function fetchHAProxyStats(): Promise<HAProxyStatsPayload> {
|
||||||
try {
|
try {
|
||||||
const r = await apiClient.get('/haproxy/stats')
|
const r = await apiClient.get('/haproxy/stats')
|
||||||
if (!isEnvelope(r.data)) return []
|
if (!isEnvelope(r.data)) return { backends: [], frontends: [] }
|
||||||
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
|
const d = r.data.data as Partial<HAProxyStatsPayload>
|
||||||
} catch { return [] }
|
return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error }
|
||||||
|
} catch { return { backends: [], frontends: [] } }
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtBytes(n: number): string {
|
function fmtBytes(n: number): string {
|
||||||
@@ -112,7 +128,8 @@ export default function BackendDetailPage() {
|
|||||||
const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains })
|
const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains })
|
||||||
const { data: haproxyStats } = useQuery({
|
const { data: haproxyStats } = useQuery({
|
||||||
queryKey: ['haproxy', 'stats'],
|
queryKey: ['haproxy', 'stats'],
|
||||||
queryFn: listHAProxyStats,
|
queryFn: fetchHAProxyStats,
|
||||||
|
select: (d: HAProxyStatsPayload) => d.backends,
|
||||||
refetchInterval: 10_000,
|
refetchInterval: 10_000,
|
||||||
})
|
})
|
||||||
const [form] = Form.useForm<BackendFormValues>()
|
const [form] = Form.useForm<BackendFormValues>()
|
||||||
|
|||||||
@@ -118,12 +118,28 @@ function fmtBytes(n: number): string {
|
|||||||
if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB'
|
if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB'
|
||||||
return n + ' B'
|
return n + ' B'
|
||||||
}
|
}
|
||||||
async function listHAProxyStats(): Promise<HAProxyStat[]> {
|
// Der Cache-Eintrag ['haproxy','stats'] wird mit dem Dashboard geteilt,
|
||||||
|
// das aus derselben Antwort zusaetzlich `frontends` liest. Deshalb hier
|
||||||
|
// IMMER die vollstaendige Antwort cachen und erst per `select` auf die
|
||||||
|
// Backends reduzieren, die diese Seite braucht.
|
||||||
|
//
|
||||||
|
// Befund 2026-09-11: Lieferte diese Funktion nur das Backend-Array, hing
|
||||||
|
// es vom zuletzt besuchten Screen ab, welche Form unter dem Key lag —
|
||||||
|
// nach einem Wechsel hierher und zurueck riss das Dashboard mit
|
||||||
|
// "Cannot read properties of undefined (reading 'length')" die ganze
|
||||||
|
// Oberflaeche in die ErrorBoundary.
|
||||||
|
interface HAProxyStatsPayload {
|
||||||
|
backends: HAProxyStat[]
|
||||||
|
frontends: unknown[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
async function fetchHAProxyStats(): Promise<HAProxyStatsPayload> {
|
||||||
try {
|
try {
|
||||||
const r = await apiClient.get('/haproxy/stats')
|
const r = await apiClient.get('/haproxy/stats')
|
||||||
if (!isEnvelope(r.data)) return []
|
if (!isEnvelope(r.data)) return { backends: [], frontends: [] }
|
||||||
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
|
const d = r.data.data as Partial<HAProxyStatsPayload>
|
||||||
} catch { return [] }
|
return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error }
|
||||||
|
} catch { return { backends: [], frontends: [] } }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BackendsPage() {
|
export default function BackendsPage() {
|
||||||
@@ -146,7 +162,8 @@ export default function BackendsPage() {
|
|||||||
const haproxyService = services?.find(s => s.unit === 'haproxy.service' || s.unit === 'haproxy')
|
const haproxyService = services?.find(s => s.unit === 'haproxy.service' || s.unit === 'haproxy')
|
||||||
const { data: haproxyStats } = useQuery({
|
const { data: haproxyStats } = useQuery({
|
||||||
queryKey: ['haproxy', 'stats'],
|
queryKey: ['haproxy', 'stats'],
|
||||||
queryFn: listHAProxyStats,
|
queryFn: fetchHAProxyStats,
|
||||||
|
select: (d: HAProxyStatsPayload) => d.backends,
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ interface FwRule { id: number; enabled: boolean; action: string }
|
|||||||
interface FwNAT { id: number; enabled: boolean; kind: string }
|
interface FwNAT { id: number; enabled: boolean; kind: string }
|
||||||
interface FwZone { id: number; name: string; builtin: boolean }
|
interface FwZone { id: number; name: string; builtin: boolean }
|
||||||
interface TLSCert { id: number; common_name: string; not_after?: string }
|
interface TLSCert { id: number; common_name: string; not_after?: string }
|
||||||
interface ClusterNode { id: string; fqdn: string; role: string }
|
interface ClusterNode { id: string; fqdn: string; role: string; pg_role?: string }
|
||||||
interface WGIface { id: number; name: string; mode: string; active: boolean }
|
interface WGIface { id: number; name: string; mode: string; active: boolean }
|
||||||
interface WGStatusRow {
|
interface WGStatusRow {
|
||||||
interface: string
|
interface: string
|
||||||
@@ -613,11 +613,11 @@ function VIPCard({ data }: { data?: VIPStatus | null }) {
|
|||||||
>
|
>
|
||||||
{!data ? (
|
{!data ? (
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>—</Text>
|
<Text type="secondary" style={{ fontSize: 12 }}>—</Text>
|
||||||
) : data.vips.length === 0 ? (
|
) : (data.vips ?? []).length === 0 ? (
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('dashboard.vipCard.noVips')}</Text>
|
<Text type="secondary" style={{ fontSize: 12 }}>{t('dashboard.vipCard.noVips')}</Text>
|
||||||
) : (
|
) : (
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size={0}>
|
<Space direction="vertical" style={{ width: '100%' }} size={0}>
|
||||||
{data.vips.map((v) => (
|
{(data.vips ?? []).map((v) => (
|
||||||
<div key={v.address} style={{
|
<div key={v.address} style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
padding: '5px 0', borderBottom: '1px solid #F1F5F9',
|
padding: '5px 0', borderBottom: '1px solid #F1F5F9',
|
||||||
@@ -644,6 +644,24 @@ function VIPCard({ data }: { data?: VIPStatus | null }) {
|
|||||||
|
|
||||||
// ── Cluster card ──────────────────────────────────────────────
|
// ── Cluster card ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// dbRoleLabel/-Color bilden pg_role ab. 'standalone' bzw. leer heisst:
|
||||||
|
// keine Replikation eingerichtet — dann gibt es schlicht keine DB-Rolle
|
||||||
|
// zu zeigen, statt eine zu erfinden.
|
||||||
|
function dbRoleLabel(pgRole: string | undefined, t: (k: string) => string): string {
|
||||||
|
switch (pgRole) {
|
||||||
|
case 'primary': return t('dashboard.clusterCard.roleDbPrimary')
|
||||||
|
case 'standby': return t('dashboard.clusterCard.roleDbStandby')
|
||||||
|
default: return t('dashboard.clusterCard.roleDbUnknown')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function dbRoleColor(pgRole: string | undefined): string {
|
||||||
|
switch (pgRole) {
|
||||||
|
case 'primary': return 'green'
|
||||||
|
case 'standby': return 'blue'
|
||||||
|
default: return 'default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface ClusterStatusCardProps {
|
interface ClusterStatusCardProps {
|
||||||
nodes: ClusterNode[]
|
nodes: ClusterNode[]
|
||||||
status: { mode: string; health: string; drift_found: boolean } | null
|
status: { mode: string; health: string; drift_found: boolean } | null
|
||||||
@@ -677,16 +695,19 @@ function ClusterStatusCard({ nodes, status }: ClusterStatusCardProps) {
|
|||||||
padding: '4px 0', borderBottom: '1px solid #F1F5F9', fontSize: 12,
|
padding: '4px 0', borderBottom: '1px solid #F1F5F9', fontSize: 12,
|
||||||
}}>
|
}}>
|
||||||
<code style={{ color: '#334155' }}>{n.fqdn}</code>
|
<code style={{ color: '#334155' }}>{n.fqdn}</code>
|
||||||
{/* ha_nodes.role ist die DB-/Cluster-Rolle (wohin Schreibzugriffe
|
{/* Bewusst pg_role, NICHT role: ha_nodes ist node-lokal (nicht
|
||||||
gehen) und wandert bewusst NICHT mit der VIP — sie aendert
|
repliziert), und jeder Node traegt sich in `role` selbst ein —
|
||||||
sich nur durch `edgeguard-ctl promote`. Ein nacktes "primary"
|
ein per Join dazugekommener Node behaelt dort den Default
|
||||||
hier las sich neben der VIP-Karte ("BACKUP") wie ein
|
"primary" und behauptete deshalb in seiner eigenen Ansicht,
|
||||||
Widerspruch, deshalb explizit als DB-Rolle beschriftet. */}
|
beide Knoten seien DB-Primary. `pg_role` folgt der
|
||||||
|
tatsaechlichen Replikationsrolle und stimmt auf beiden Seiten
|
||||||
|
ueberein. Siehe auch cluster_repair.go: role/pg_role sind
|
||||||
|
node-lokal, verlaesslich ist letztlich die PUBLICATION.
|
||||||
|
Die DB-Rolle wandert NICHT mit der VIP — sie aendert sich nur
|
||||||
|
durch `edgeguard-ctl promote`. */}
|
||||||
<Tooltip title={t('dashboard.clusterCard.roleHint')}>
|
<Tooltip title={t('dashboard.clusterCard.roleHint')}>
|
||||||
<Tag color={n.role === 'primary' ? 'green' : 'default'} style={{ margin: 0 }}>
|
<Tag color={dbRoleColor(n.pg_role)} style={{ margin: 0 }}>
|
||||||
{n.role === 'primary'
|
{dbRoleLabel(n.pg_role, t)}
|
||||||
? t('dashboard.clusterCard.roleDbPrimary')
|
|
||||||
: t('dashboard.clusterCard.rolePeer')}
|
|
||||||
</Tag>
|
</Tag>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -715,7 +736,7 @@ function HAProxyFullCard({ stats, resolveHAName }: HAProxyFullCardProps) {
|
|||||||
className="h-100"
|
className="h-100"
|
||||||
title={<><DatabaseOutlined style={{ color: '#0EA5E9' }} /> {t('dashboard.haproxyCard.title')}</>}
|
title={<><DatabaseOutlined style={{ color: '#0EA5E9' }} /> {t('dashboard.haproxyCard.title')}</>}
|
||||||
extra={
|
extra={
|
||||||
stats.frontends.length > 0 && (
|
(stats.frontends ?? []).length > 0 && (
|
||||||
<Space size={8}>
|
<Space size={8}>
|
||||||
<Text type="secondary" style={{ fontSize: 11 }}>{totalSessions} sess</Text>
|
<Text type="secondary" style={{ fontSize: 11 }}>{totalSessions} sess</Text>
|
||||||
{totalReqRate > 0 && <Text type="secondary" style={{ fontSize: 11 }}>{totalReqRate}/s</Text>}
|
{totalReqRate > 0 && <Text type="secondary" style={{ fontSize: 11 }}>{totalReqRate}/s</Text>}
|
||||||
@@ -731,7 +752,7 @@ function HAProxyFullCard({ stats, resolveHAName }: HAProxyFullCardProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Listeners */}
|
{/* Listeners */}
|
||||||
{stats.frontends.length > 0 && (
|
{(stats.frontends ?? []).length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Text type="secondary" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
<Text type="secondary" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
{t('dashboard.haproxyCard.frontends')}
|
{t('dashboard.haproxyCard.frontends')}
|
||||||
@@ -752,7 +773,7 @@ function HAProxyFullCard({ stats, resolveHAName }: HAProxyFullCardProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Backends */}
|
{/* Backends */}
|
||||||
{stats.backends.length === 0 && !stats.error ? (
|
{(stats.backends ?? []).length === 0 && !stats.error ? (
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('dashboard.haproxyCard.empty')}</Text>
|
<Text type="secondary" style={{ fontSize: 12 }}>{t('dashboard.haproxyCard.empty')}</Text>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -101,12 +101,28 @@ async function listCerts(): Promise<TLSCertLite[]> {
|
|||||||
if (!isEnvelope(r.data)) return []
|
if (!isEnvelope(r.data)) return []
|
||||||
return (r.data.data as { tls_certs?: TLSCertLite[] }).tls_certs ?? []
|
return (r.data.data as { tls_certs?: TLSCertLite[] }).tls_certs ?? []
|
||||||
}
|
}
|
||||||
async function listHAProxyStats(): Promise<HAProxyStat[]> {
|
// Der Cache-Eintrag ['haproxy','stats'] wird mit dem Dashboard geteilt,
|
||||||
|
// das aus derselben Antwort zusaetzlich `frontends` liest. Deshalb hier
|
||||||
|
// IMMER die vollstaendige Antwort cachen und erst per `select` auf die
|
||||||
|
// Backends reduzieren, die diese Seite braucht.
|
||||||
|
//
|
||||||
|
// Befund 2026-09-11: Lieferte diese Funktion nur das Backend-Array, hing
|
||||||
|
// es vom zuletzt besuchten Screen ab, welche Form unter dem Key lag —
|
||||||
|
// nach einem Wechsel hierher und zurueck riss das Dashboard mit
|
||||||
|
// "Cannot read properties of undefined (reading 'length')" die ganze
|
||||||
|
// Oberflaeche in die ErrorBoundary.
|
||||||
|
interface HAProxyStatsPayload {
|
||||||
|
backends: HAProxyStat[]
|
||||||
|
frontends: unknown[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
async function fetchHAProxyStats(): Promise<HAProxyStatsPayload> {
|
||||||
try {
|
try {
|
||||||
const r = await apiClient.get('/haproxy/stats')
|
const r = await apiClient.get('/haproxy/stats')
|
||||||
if (!isEnvelope(r.data)) return []
|
if (!isEnvelope(r.data)) return { backends: [], frontends: [] }
|
||||||
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
|
const d = r.data.data as Partial<HAProxyStatsPayload>
|
||||||
} catch { return [] }
|
return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error }
|
||||||
|
} catch { return { backends: [], frontends: [] } }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DomainDetailPage() {
|
export default function DomainDetailPage() {
|
||||||
@@ -126,7 +142,8 @@ export default function DomainDetailPage() {
|
|||||||
const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
|
const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
|
||||||
const { data: haproxyStats } = useQuery({
|
const { data: haproxyStats } = useQuery({
|
||||||
queryKey: ['haproxy', 'stats'],
|
queryKey: ['haproxy', 'stats'],
|
||||||
queryFn: listHAProxyStats,
|
queryFn: fetchHAProxyStats,
|
||||||
|
select: (d: HAProxyStatsPayload) => d.backends,
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -83,12 +83,28 @@ async function listCerts(): Promise<TLSCertLite[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface HAProxyStat { backend: string; server: string; status: string }
|
interface HAProxyStat { backend: string; server: string; status: string }
|
||||||
async function listHAProxyStats(): Promise<HAProxyStat[]> {
|
// Der Cache-Eintrag ['haproxy','stats'] wird mit dem Dashboard geteilt,
|
||||||
|
// das aus derselben Antwort zusaetzlich `frontends` liest. Deshalb hier
|
||||||
|
// IMMER die vollstaendige Antwort cachen und erst per `select` auf die
|
||||||
|
// Backends reduzieren, die diese Seite braucht.
|
||||||
|
//
|
||||||
|
// Befund 2026-09-11: Lieferte diese Funktion nur das Backend-Array, hing
|
||||||
|
// es vom zuletzt besuchten Screen ab, welche Form unter dem Key lag —
|
||||||
|
// nach einem Wechsel hierher und zurueck riss das Dashboard mit
|
||||||
|
// "Cannot read properties of undefined (reading 'length')" die ganze
|
||||||
|
// Oberflaeche in die ErrorBoundary.
|
||||||
|
interface HAProxyStatsPayload {
|
||||||
|
backends: HAProxyStat[]
|
||||||
|
frontends: unknown[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
async function fetchHAProxyStats(): Promise<HAProxyStatsPayload> {
|
||||||
try {
|
try {
|
||||||
const r = await apiClient.get('/haproxy/stats')
|
const r = await apiClient.get('/haproxy/stats')
|
||||||
if (!isEnvelope(r.data)) return []
|
if (!isEnvelope(r.data)) return { backends: [], frontends: [] }
|
||||||
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
|
const d = r.data.data as Partial<HAProxyStatsPayload>
|
||||||
} catch { return [] }
|
return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error }
|
||||||
|
} catch { return { backends: [], frontends: [] } }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DomainsPage() {
|
export default function DomainsPage() {
|
||||||
@@ -109,7 +125,8 @@ export default function DomainsPage() {
|
|||||||
const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
|
const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
|
||||||
const { data: haproxyStats } = useQuery({
|
const { data: haproxyStats } = useQuery({
|
||||||
queryKey: ['haproxy', 'stats'],
|
queryKey: ['haproxy', 'stats'],
|
||||||
queryFn: listHAProxyStats,
|
queryFn: fetchHAProxyStats,
|
||||||
|
select: (d: HAProxyStatsPayload) => d.backends,
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
})
|
})
|
||||||
const certByDomain = new Map((certs ?? []).map(c => [c.domain, c]))
|
const certByDomain = new Map((certs ?? []).map(c => [c.domain, c]))
|
||||||
|
|||||||
@@ -58,12 +58,28 @@ function fmtBytes(n: number): string {
|
|||||||
if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB'
|
if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB'
|
||||||
return n + ' B'
|
return n + ' B'
|
||||||
}
|
}
|
||||||
async function listHAProxyStats(): Promise<HAProxyStat[]> {
|
// Der Cache-Eintrag ['haproxy','stats'] wird mit dem Dashboard geteilt,
|
||||||
|
// das aus derselben Antwort zusaetzlich `frontends` liest. Deshalb hier
|
||||||
|
// IMMER die vollstaendige Antwort cachen und erst per `select` auf die
|
||||||
|
// Backends reduzieren, die diese Seite braucht.
|
||||||
|
//
|
||||||
|
// Befund 2026-09-11: Lieferte diese Funktion nur das Backend-Array, hing
|
||||||
|
// es vom zuletzt besuchten Screen ab, welche Form unter dem Key lag —
|
||||||
|
// nach einem Wechsel hierher und zurueck riss das Dashboard mit
|
||||||
|
// "Cannot read properties of undefined (reading 'length')" die ganze
|
||||||
|
// Oberflaeche in die ErrorBoundary.
|
||||||
|
interface HAProxyStatsPayload {
|
||||||
|
backends: HAProxyStat[]
|
||||||
|
frontends: unknown[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
async function fetchHAProxyStats(): Promise<HAProxyStatsPayload> {
|
||||||
try {
|
try {
|
||||||
const r = await apiClient.get('/haproxy/stats')
|
const r = await apiClient.get('/haproxy/stats')
|
||||||
if (!isEnvelope(r.data)) return []
|
if (!isEnvelope(r.data)) return { backends: [], frontends: [] }
|
||||||
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
|
const d = r.data.data as Partial<HAProxyStatsPayload>
|
||||||
} catch { return [] }
|
return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error }
|
||||||
|
} catch { return { backends: [], frontends: [] } }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RoutingRulesPage() {
|
export default function RoutingRulesPage() {
|
||||||
@@ -76,7 +92,8 @@ export default function RoutingRulesPage() {
|
|||||||
const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
|
const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
|
||||||
const { data: haproxyStats } = useQuery({
|
const { data: haproxyStats } = useQuery({
|
||||||
queryKey: ['haproxy', 'stats'],
|
queryKey: ['haproxy', 'stats'],
|
||||||
queryFn: listHAProxyStats,
|
queryFn: fetchHAProxyStats,
|
||||||
|
select: (d: HAProxyStatsPayload) => d.backends,
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user