feat(cluster): GUI-Repair-Button für Config-Drift + Stale-Chunk-Auto-Reload — v1.2.86
Cluster/Replication:
- Drift-Banner: Button 'Resync erzwingen' baut die PG-Logical-Replication-
Subscription neu auf (via edgeguard-ctl cluster-setup-standby).
- Primary-Dispatch: Button auf dem Primary delegiert per mTLS an den
Standby (POST /agent/cluster/repair-replication); auf dem Standby lokal.
- Status-Proxy Primary->Standby via Aggregator.FanOut; Erfolg = Job-success
ODER drift_found wird false (--collect-Unit verschwindet nach Erfolg).
- Job als transiente systemd-Unit edgeguard-repair-replication.service
(sudoers exact-match + festes Script wie upgrade.sh).
- Banner-Text korrigiert (keine 'Outbox').
Frontend-Stabilität:
- Stale-Chunk-Auto-Reload: Lazy-Import-Fehler nach Deploy ('Failed to fetch
dynamically imported module') lösen einen einmaligen Reload aus (Loop-
Schutz via sessionStorage) statt einer Fehlerseite. Globaler
vite:preloadError-Listener + ErrorBoundary-Integration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -347,6 +347,7 @@ func main() {
|
||||
WithAggregator(clusterAggregator).
|
||||
WithJoinFlow(clusterTLSStore, joinTokens).
|
||||
WithPeerReloader(peerReloader).
|
||||
WithAudit(auditRepo, nodeID).
|
||||
WithVersion(version)
|
||||
clusterHdl.Register(authed)
|
||||
// /cluster/issue-cert läuft PUBLIC — joining Peer hat noch
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
)
|
||||
|
||||
// ClusterHandler exposes cluster-state endpoints. /status ist die
|
||||
@@ -44,6 +45,11 @@ type ClusterHandler struct {
|
||||
// PeerReloader: optional, gesetzt bei Phase 3.5. Nach Auto-Register
|
||||
// triggert das den firewall-Render damit peer_ipv4 frisch ist.
|
||||
PeerReloader PeerReloader
|
||||
|
||||
// Audit + NodeID: optional, gesetzt via WithAudit. Nötig für
|
||||
// protokollierte, mutierende Aktionen wie den Replication-Repair.
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
}
|
||||
|
||||
func NewClusterHandler(store *cluster.Store, localID string) *ClusterHandler {
|
||||
@@ -65,6 +71,13 @@ func (h *ClusterHandler) WithJoinFlow(store *clustertls.Store, tokens *jointoken
|
||||
return h
|
||||
}
|
||||
|
||||
// WithAudit setzt den Audit-Repo + NodeID für protokollierte Aktionen.
|
||||
func (h *ClusterHandler) WithAudit(a *audit.Repo, nodeID string) *ClusterHandler {
|
||||
h.Audit = a
|
||||
h.NodeID = nodeID
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *ClusterHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/cluster")
|
||||
g.GET("/nodes", h.ListNodes)
|
||||
@@ -75,6 +88,8 @@ func (h *ClusterHandler) Register(rg *gin.RouterGroup) {
|
||||
g.PUT("/vip-settings", h.UpdateVIPSettings)
|
||||
g.POST("/rolling-update", h.RollingUpdate)
|
||||
g.GET("/rolling-update/status", h.RollingUpdateStatus)
|
||||
g.POST("/repair-replication", h.RepairReplication)
|
||||
g.GET("/repair-replication/status", h.RepairReplicationStatus)
|
||||
g.GET("/vip-status", h.VIPStatus)
|
||||
g.POST("/vip-test", h.VIPTest)
|
||||
if h.TLSStore != nil {
|
||||
@@ -235,6 +250,8 @@ func (h *ClusterHandler) RegisterAgent(rg *gin.RouterGroup) {
|
||||
g.GET("/active-ips", h.AgentActiveIPs)
|
||||
g.POST("/vip-cmd", h.AgentVIPCmd)
|
||||
g.GET("/tls-certs", h.AgentTLSCerts)
|
||||
g.POST("/repair-replication", h.AgentRepairReplication)
|
||||
g.GET("/repair-replication/status", h.AgentRepairReplicationStatus)
|
||||
}
|
||||
|
||||
// AgentIdentity gibt die eigene ha_nodes-Row zurück. Wird vom Primary
|
||||
|
||||
344
internal/handlers/cluster_repair.go
Normal file
344
internal/handlers/cluster_repair.go
Normal file
@@ -0,0 +1,344 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
// Replication-Repair ("Resync erzwingen") für das Config-Drift-Banner.
|
||||
//
|
||||
// Drift entsteht, wenn ein Peer einen anderen config_hash hat als dieser
|
||||
// Node — entweder weil die Logical-Replication-Subscription gestört ist
|
||||
// oder weil direkt in die DB des Subscribers geschrieben wurde. Die
|
||||
// Reparatur baut die Subscription neu auf und kopiert alle geteilten
|
||||
// Tabellen frisch vom Primary (einseitig: Primary = Source of Truth).
|
||||
//
|
||||
// Der Resync MUSS auf dem Standby/Subscriber laufen (nur der hat eine
|
||||
// Subscription). Operatoren erreichen die UI aber über die VIP, die immer
|
||||
// auf den Primary zeigt. Deshalb:
|
||||
//
|
||||
// - Auf dem Primary geklickt → Dispatch via mTLS an den Standby
|
||||
// (POST /agent/cluster/repair-replication), der dort lokal läuft.
|
||||
// - Auf dem Standby direkt geklickt → läuft lokal.
|
||||
//
|
||||
// Die eigentliche Arbeit läuft — analog zum Rolling-Update — in einer
|
||||
// transienten systemd-Unit, die das bereits getestete
|
||||
// `edgeguard-ctl cluster-setup-standby <primary>` ausführt.
|
||||
|
||||
const (
|
||||
repairUnitName = "edgeguard-repair-replication.service"
|
||||
repairScriptPath = "/var/lib/edgeguard/repair-replication.sh"
|
||||
repairAgentPath = "/agent/cluster/repair-replication"
|
||||
)
|
||||
|
||||
// validRepairHost erlaubt nur IPv4/IPv6/Hostnamen — der Wert landet in
|
||||
// einem Bash-Script das als root läuft, also strikt validieren (defense
|
||||
// in depth, auch wenn er aus ha_nodes stammt).
|
||||
var validRepairHost = regexp.MustCompile(`^[A-Za-z0-9._:-]{1,253}$`)
|
||||
|
||||
// RepairReplication ist der UI-Endpoint. Läuft der lokale Node als
|
||||
// Primary, wird der Resync an den Standby-Peer delegiert; auf dem Standby
|
||||
// selbst läuft er lokal.
|
||||
func (h *ClusterHandler) RepairReplication(c *gin.Context) {
|
||||
if h.Store == nil {
|
||||
response.Internal(c, errors.New("cluster store unavailable"))
|
||||
return
|
||||
}
|
||||
all, err := h.Store.List(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
local := findNode(all, h.LocalID)
|
||||
standby := findByPGRole(all, "standby")
|
||||
|
||||
// Primary → an den Standby delegieren.
|
||||
if local != nil && local.PGRole == "primary" {
|
||||
if h.Aggregator == nil {
|
||||
response.BadRequest(c, errors.New("kein mTLS-Aggregator verfügbar — Resync nicht delegierbar"))
|
||||
return
|
||||
}
|
||||
if standby == nil {
|
||||
response.BadRequest(c, errors.New("kein Standby-Node gefunden, an den der Resync delegiert werden könnte"))
|
||||
return
|
||||
}
|
||||
res := h.Aggregator.PostPeer(c.Request.Context(), *standby, repairAgentPath)
|
||||
if !res.OK {
|
||||
response.Internal(c, fmt.Errorf("Resync auf %s anstoßen: %s", standby.FQDN, res.Err))
|
||||
return
|
||||
}
|
||||
slog.Info("cluster: replication repair delegated to standby", "standby", standby.FQDN)
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "cluster.repair-replication",
|
||||
standby.FQDN, gin.H{"target": "standby", "standby": standby.FQDN}, h.NodeID)
|
||||
}
|
||||
response.Accepted(c, gin.H{"dispatched": true, "target": "standby", "standby_fqdn": standby.FQDN})
|
||||
return
|
||||
}
|
||||
|
||||
// Standby (oder Direktzugriff) → lokal ausführen.
|
||||
host, err := h.runLocalRepair(c.Request.Context(), all)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "cluster.repair-replication",
|
||||
host, gin.H{"target": "local", "primary": host}, h.NodeID)
|
||||
}
|
||||
response.Accepted(c, gin.H{"dispatched": true, "target": "local", "primary": host})
|
||||
}
|
||||
|
||||
// AgentRepairReplication wird vom Primary via mTLS auf dem Standby
|
||||
// aufgerufen und startet dort den lokalen Resync.
|
||||
func (h *ClusterHandler) AgentRepairReplication(c *gin.Context) {
|
||||
if h.Store == nil {
|
||||
response.Internal(c, errors.New("cluster store unavailable"))
|
||||
return
|
||||
}
|
||||
all, err := h.Store.List(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
host, err := h.runLocalRepair(c.Request.Context(), all)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
slog.Info("cluster: replication repair triggered by peer", "primary", host, "node", h.LocalID)
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), "cluster-peer", "cluster.repair-replication",
|
||||
host, gin.H{"target": "local", "primary": host, "via": "agent"}, h.NodeID)
|
||||
}
|
||||
response.Accepted(c, gin.H{"dispatched": true, "primary": host})
|
||||
}
|
||||
|
||||
// runLocalRepair startet den Resync auf DIESEM Node. Verweigert auf dem
|
||||
// Primary (kein Subscriber). Gibt den ermittelten Primary-Host zurück.
|
||||
func (h *ClusterHandler) runLocalRepair(_ context.Context, all []models.HANode) (string, error) {
|
||||
local := findNode(all, h.LocalID)
|
||||
primary := findByPGRole(all, "primary")
|
||||
|
||||
if local != nil && local.PGRole == "primary" {
|
||||
return "", errors.New("dieser Node ist der PostgreSQL-Primary — Resync läuft nur auf einem Standby/Subscriber")
|
||||
}
|
||||
if primary == nil {
|
||||
return "", errors.New("kein PostgreSQL-Primary im Cluster gefunden — Resync-Quelle unbekannt")
|
||||
}
|
||||
if primary.ID == h.LocalID {
|
||||
return "", errors.New("der lokale Node ist als Primary markiert — Resync nicht möglich")
|
||||
}
|
||||
|
||||
host := pickPrimaryHost(primary)
|
||||
if host == "" {
|
||||
return "", errors.New("Primary hat keine erreichbare IP/FQDN in ha_nodes")
|
||||
}
|
||||
if !validRepairHost.MatchString(host) {
|
||||
return "", fmt.Errorf("ungültige Primary-Adresse: %q", host)
|
||||
}
|
||||
if st := repairUnitState(); st == "activating" || st == "active" {
|
||||
return "", errors.New("Resync läuft bereits")
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`#!/bin/bash
|
||||
set -uo pipefail
|
||||
echo "[repair] resync der Logical-Replication-Subscription von Primary %[1]s"
|
||||
/usr/bin/edgeguard-ctl cluster-setup-standby %[1]s
|
||||
rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "[repair] cluster-setup-standby fehlgeschlagen (rc=$rc)"
|
||||
exit "$rc"
|
||||
fi
|
||||
echo "[repair] abgeschlossen — config_hash wird beim nächsten Cluster-Status neu berechnet"
|
||||
rm -f %[2]s
|
||||
`, host, repairScriptPath)
|
||||
|
||||
if err := os.WriteFile(repairScriptPath, []byte(script), 0o755); err != nil {
|
||||
return "", fmt.Errorf("write repair script: %w", err)
|
||||
}
|
||||
_ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", repairUnitName).Run()
|
||||
cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run",
|
||||
"--unit="+repairUnitName,
|
||||
"--description=EdgeGuard replication repair",
|
||||
"--collect",
|
||||
"bash", repairScriptPath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("systemd-run failed: %w", err)
|
||||
}
|
||||
slog.Info("cluster: replication repair dispatched (local)", "primary", host, "node", h.LocalID)
|
||||
return host, nil
|
||||
}
|
||||
|
||||
// repairStatusResponse spiegelt den Zustand der transienten Repair-Unit.
|
||||
type repairStatusResponse struct {
|
||||
Phase string `json:"phase"` // idle | running | success | failed
|
||||
State string `json:"state"`
|
||||
Result string `json:"result"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
FinishedAt string `json:"finished_at,omitempty"`
|
||||
Log []string `json:"log"`
|
||||
}
|
||||
|
||||
// RepairReplicationStatus liest den Job-Zustand. Auf dem Primary wird der
|
||||
// Status vom Standby-Peer geholt (dort läuft der Job); sonst lokal.
|
||||
func (h *ClusterHandler) RepairReplicationStatus(c *gin.Context) {
|
||||
if h.Store != nil {
|
||||
if all, err := h.Store.List(c.Request.Context()); err == nil {
|
||||
local := findNode(all, h.LocalID)
|
||||
standby := findByPGRole(all, "standby")
|
||||
if local != nil && local.PGRole == "primary" && h.Aggregator != nil && standby != nil {
|
||||
results := h.Aggregator.FanOut(c.Request.Context(),
|
||||
[]models.HANode{*standby}, repairAgentPath+"/status", h.LocalID)
|
||||
if len(results) == 1 && results[0].OK && len(results[0].Data) > 0 {
|
||||
c.Data(200, "application/json", wrapEnvelope(results[0].Data))
|
||||
return
|
||||
}
|
||||
// Peer nicht erreichbar → idle zurückgeben statt Fehler,
|
||||
// damit das UI-Polling nicht hart abbricht.
|
||||
response.OK(c, repairStatusResponse{Phase: "idle", Log: []string{}})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
response.OK(c, localRepairStatus())
|
||||
}
|
||||
|
||||
// AgentRepairReplicationStatus liefert den lokalen Job-Zustand an den
|
||||
// abfragenden Primary.
|
||||
func (h *ClusterHandler) AgentRepairReplicationStatus(c *gin.Context) {
|
||||
response.OK(c, localRepairStatus())
|
||||
}
|
||||
|
||||
// wrapEnvelope verpackt eine bereits entpackte data-Payload wieder in die
|
||||
// Standard-Envelope, damit das UI (isEnvelope) sie konsistent liest.
|
||||
func wrapEnvelope(data []byte) []byte {
|
||||
out := []byte(`{"data":`)
|
||||
out = append(out, data...)
|
||||
out = append(out, []byte(`,"error":null,"message":"ok"}`)...)
|
||||
return out
|
||||
}
|
||||
|
||||
// localRepairStatus liest den Zustand der lokalen Repair-Unit aus systemd
|
||||
// (analog UpgradeStatus). Quelle der Wahrheit für Job-Ende ist die Unit.
|
||||
func localRepairStatus() repairStatusResponse {
|
||||
out := repairStatusResponse{Phase: "idle", Log: []string{}}
|
||||
|
||||
if data, err := exec.Command("systemctl", "show", repairUnitName,
|
||||
"--no-page",
|
||||
"-p", "ActiveState",
|
||||
"-p", "Result",
|
||||
"-p", "ExecMainStatus",
|
||||
"-p", "ExecMainStartTimestamp",
|
||||
"-p", "ExecMainExitTimestamp",
|
||||
).CombinedOutput(); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
kv := strings.SplitN(strings.TrimSpace(line), "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
switch kv[0] {
|
||||
case "ActiveState":
|
||||
out.State = kv[1]
|
||||
case "Result":
|
||||
out.Result = kv[1]
|
||||
case "ExecMainStatus":
|
||||
out.ExitCode, _ = strconv.Atoi(kv[1])
|
||||
case "ExecMainStartTimestamp":
|
||||
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", kv[1]); err == nil {
|
||||
out.StartedAt = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
case "ExecMainExitTimestamp":
|
||||
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", kv[1]); err == nil {
|
||||
out.FinishedAt = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch out.State {
|
||||
case "activating", "active", "deactivating":
|
||||
out.Phase = "running"
|
||||
case "failed":
|
||||
out.Phase = "failed"
|
||||
case "inactive":
|
||||
if out.Result == "success" && out.ExitCode == 0 && out.FinishedAt != "" {
|
||||
out.Phase = "success"
|
||||
} else if out.Result != "" && out.Result != "success" {
|
||||
out.Phase = "failed"
|
||||
}
|
||||
}
|
||||
|
||||
if data, err := exec.Command("journalctl",
|
||||
"-u", repairUnitName,
|
||||
"--no-pager", "-n", "100", "-o", "cat",
|
||||
).CombinedOutput(); err == nil {
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
if !(len(lines) == 1 && (lines[0] == "" || strings.HasPrefix(lines[0], "-- No entries"))) {
|
||||
out.Log = lines
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// findNode / findByPGRole: kleine Helfer über die ha_nodes-Liste.
|
||||
func findNode(nodes []models.HANode, id string) *models.HANode {
|
||||
for i := range nodes {
|
||||
if nodes[i].ID == id {
|
||||
return &nodes[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findByPGRole(nodes []models.HANode, role string) *models.HANode {
|
||||
for i := range nodes {
|
||||
if nodes[i].PGRole == role {
|
||||
return &nodes[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pickPrimaryHost wählt die beste erreichbare Adresse des Primary:
|
||||
// Mgmt-IP → Internal-IP → Public-IP → FQDN. Strippt eine etwaige
|
||||
// CIDR-Maske (inet-Spalten können "10.0.0.5/32" liefern).
|
||||
func pickPrimaryHost(n *models.HANode) string {
|
||||
for _, cand := range []*string{n.MgmtIP, n.InternalIP, n.PublicIP} {
|
||||
if cand != nil {
|
||||
if h := strings.TrimSpace(strings.SplitN(*cand, "/", 2)[0]); h != "" {
|
||||
return h
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(n.FQDN)
|
||||
}
|
||||
|
||||
// repairUnitState gibt den ActiveState der Repair-Unit zurück ("" wenn
|
||||
// unbekannt). Für den Doppelstart-Schutz.
|
||||
func repairUnitState() string {
|
||||
out, err := exec.Command("systemctl", "show", repairUnitName, "--no-page", "-p", "ActiveState").CombinedOutput()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if kv := strings.SplitN(strings.TrimSpace(line), "=", 2); len(kv) == 2 && kv[0] == "ActiveState" {
|
||||
return kv[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,35 +1,65 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
// Top-level ErrorBoundary. Catches throws aus dem React-Tree (inkl.
|
||||
// Lazy-Chunk-Loadfehler, die auf flakigem Mobilfunk häufig sind) und
|
||||
// rendert eine sichtbare Fehlerseite statt #root leer zu lassen.
|
||||
// Ohne diese Boundary endet jeder Render-Throw als „blank page".
|
||||
//
|
||||
// Wir loggen den Fehler in die Browser-Console (für Remote-Debug via
|
||||
// Safari-Inspector/Chrome-Remote) und zeigen dem Operator die
|
||||
// Fehlermeldung wörtlich — kein Translation-Layer, weil i18n selbst
|
||||
// schon kaputt sein kann.
|
||||
import { isStaleChunkError, reloadForStaleChunkOnce } from '../lib/staleChunkReload'
|
||||
|
||||
interface State { error: Error | null }
|
||||
// Top-level ErrorBoundary. Catches throws aus dem React-Tree (inkl.
|
||||
// Lazy-Chunk-Loadfehler nach einem Deploy) und rendert eine sichtbare
|
||||
// Fehlerseite statt #root leer zu lassen. Ohne diese Boundary endet
|
||||
// jeder Render-Throw als „blank page".
|
||||
//
|
||||
// Stale-Chunk-Fehler (alter Tab referenziert nicht mehr existierende
|
||||
// gehashte Chunks nach einem Deploy) werden automatisch per einmaligem
|
||||
// Reload behoben — der Operator sieht dann nur kurz „Aktualisiere…".
|
||||
// Erst wenn auch der Reload nicht hilft (giveUp) zeigen wir die manuelle
|
||||
// Fehlerkarte. Andere Fehler werden wörtlich angezeigt — kein
|
||||
// Translation-Layer, weil i18n selbst kaputt sein kann.
|
||||
|
||||
interface State { error: Error | null; giveUp: boolean }
|
||||
|
||||
export default class ErrorBoundary extends Component<{ children: ReactNode }, State> {
|
||||
state: State = { error: null }
|
||||
state: State = { error: null, giveUp: false }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[ErrorBoundary]', error, info.componentStack)
|
||||
// Stale-Chunk → einmalig neu laden. Schlägt der Loop-Schutz an
|
||||
// (Reload half nicht), auf die manuelle Karte zurückfallen.
|
||||
if (isStaleChunkError(error) && !reloadForStaleChunkOnce()) {
|
||||
this.setState({ giveUp: true })
|
||||
}
|
||||
}
|
||||
|
||||
reset = () => { this.setState({ error: null }) }
|
||||
reset = () => { this.setState({ error: null, giveUp: false }) }
|
||||
|
||||
render() {
|
||||
const err = this.state.error
|
||||
if (!err) return this.props.children
|
||||
const isChunkErr = /Loading chunk|Failed to fetch dynamically imported module|Importing a module script failed/i.test(err.message)
|
||||
const isChunkErr = isStaleChunkError(err)
|
||||
|
||||
// Auto-Reload läuft (Chunk-Fehler, Loop-Schutz noch nicht erreicht):
|
||||
// neutralen Lade-Hinweis zeigen statt der Fehlerkarte.
|
||||
if (isChunkErr && !this.state.giveUp) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
background: '#F8FAFC',
|
||||
color: '#64748B',
|
||||
fontSize: 14,
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||
}}>
|
||||
Aktualisiere EdgeGuard…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
@@ -54,7 +84,7 @@ export default class ErrorBoundary extends Component<{ children: ReactNode }, St
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#64748B', marginBottom: 16 }}>
|
||||
{isChunkErr
|
||||
? 'Ein Teil der App konnte nicht aus dem Netz geladen werden. Das passiert häufig bei wechselndem Mobilfunk-Empfang. Versuche es mit einem Reload.'
|
||||
? 'Ein Teil der App konnte nicht geladen werden — auch ein automatischer Reload hat nicht geholfen. Bitte lade die Seite manuell neu (ggf. mit Strg+F5), oder prüfe die Verbindung zum Server.'
|
||||
: 'Beim Initialisieren der Oberfläche ist ein Fehler aufgetreten.'}
|
||||
</div>
|
||||
<pre style={{
|
||||
|
||||
@@ -654,7 +654,17 @@
|
||||
"tokenLabel": "Token",
|
||||
"caFingerprintLabel": "CA-Fingerabdruck",
|
||||
"driftBanner": "Config-Drift erkannt",
|
||||
"driftBannerDesc": "Ein oder mehrere Peers haben einen anderen Config-Hash als dieser Node. Entweder stehen noch Änderungen in der Outbox, oder auf einem Peer wurde direkt in der DB editiert. Warte bis die Outbox leer ist oder starte Diagnostics.",
|
||||
"driftBannerDesc": "Ein oder mehrere Peers haben einen anderen Config-Hash als dieser Node. Kurz nach einer Änderung ist das normal (die Replikation hinkt nach) und verschwindet von selbst. Bleibt der Drift bestehen, ist die Replikation gestört oder es wurde direkt in die DB eines Peers geschrieben.",
|
||||
"repair": {
|
||||
"button": "Resync erzwingen",
|
||||
"noStandbyHint": "Kein Standby-Node gefunden, an den der Resync delegiert werden könnte.",
|
||||
"confirmTitle": "Replikation reparieren?",
|
||||
"confirmDesc": "Baut die Replikations-Subscription auf dem Standby-Node neu auf und kopiert alle geteilten Config-Tabellen frisch vom Primary. Vom Primary aus geklickt wird der Resync per mTLS an den Standby delegiert. Lokale Direkt-Edits am Standby werden dabei überschrieben (Primary = Source of Truth).",
|
||||
"confirmOk": "Resync starten",
|
||||
"started": "Resync angestoßen — läuft im Hintergrund auf dem Standby.",
|
||||
"ok": "Replikation repariert — Config wieder synchron.",
|
||||
"failed": "Resync fehlgeschlagen"
|
||||
},
|
||||
"col": {
|
||||
"node": "Knoten",
|
||||
"status": "Status",
|
||||
|
||||
@@ -654,7 +654,17 @@
|
||||
"tokenLabel": "Token",
|
||||
"caFingerprintLabel": "CA fingerprint",
|
||||
"driftBanner": "Config drift detected",
|
||||
"driftBannerDesc": "One or more peers have a different config hash than this node. Either changes are still in the outbox or a peer was edited directly in the DB. Wait for the outbox to drain or run diagnostics.",
|
||||
"driftBannerDesc": "One or more peers have a different config hash than this node. Right after a change this is normal (replication is catching up) and clears on its own. If the drift persists, replication is broken or a peer's DB was edited directly.",
|
||||
"repair": {
|
||||
"button": "Force resync",
|
||||
"noStandbyHint": "No standby node found to delegate the resync to.",
|
||||
"confirmTitle": "Repair replication?",
|
||||
"confirmDesc": "Rebuilds the replication subscription on the standby node and re-copies all shared config tables from the primary. When clicked on the primary, the resync is delegated to the standby via mTLS. Local direct edits on the standby will be overwritten (primary = source of truth).",
|
||||
"confirmOk": "Start resync",
|
||||
"started": "Resync dispatched — running in the background on the standby.",
|
||||
"ok": "Replication repaired — config in sync again.",
|
||||
"failed": "Resync failed"
|
||||
},
|
||||
"col": {
|
||||
"node": "Node",
|
||||
"status": "Status",
|
||||
|
||||
44
management-ui/src/lib/staleChunkReload.ts
Normal file
44
management-ui/src/lib/staleChunkReload.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Behandelt "stale chunk"-Fehler: Ein Tab, der noch den Build von gestern
|
||||
// fährt, referenziert gehashte Chunk-Dateien (z.B. Cluster-BOSNsJEn.js).
|
||||
// Nach einem Deploy existieren diese Hashes nicht mehr → der Lazy-Import
|
||||
// läuft auf 404 ("Failed to fetch dynamically imported module"). Ein
|
||||
// voller Reload holt frisches index.html mit den neuen Hashes und behebt
|
||||
// das. Wir machen diesen Reload automatisch — aber nur einmal pro
|
||||
// Cooldown-Fenster, damit es keine Endlosschleife gibt wenn der Server
|
||||
// wirklich nicht erreichbar ist.
|
||||
|
||||
const RELOAD_FLAG = 'eg:stale-chunk-reload-at'
|
||||
const COOLDOWN_MS = 15_000
|
||||
|
||||
// isStaleChunkError erkennt die Lazy-Import-/Preload-Fehler quer über
|
||||
// Browser (Chrome/Safari/Firefox formulieren sie unterschiedlich).
|
||||
export function isStaleChunkError(err: unknown): boolean {
|
||||
const msg = err instanceof Error ? err.message : String(err ?? '')
|
||||
return /Loading chunk|Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module|'text\/html' is not a valid JavaScript MIME type/i.test(msg)
|
||||
}
|
||||
|
||||
// reloadForStaleChunkOnce lädt die Seite genau einmal neu. Gibt false
|
||||
// zurück, wenn innerhalb des Cooldowns bereits neu geladen wurde — dann
|
||||
// soll der Aufrufer auf eine manuelle Fehler-UI zurückfallen (der Reload
|
||||
// hat das Problem offensichtlich nicht gelöst, z.B. Server down).
|
||||
export function reloadForStaleChunkOnce(): boolean {
|
||||
let last = 0
|
||||
try { last = Number(sessionStorage.getItem(RELOAD_FLAG) || 0) } catch { /* private mode */ }
|
||||
const now = Date.now()
|
||||
if (last && now - last < COOLDOWN_MS) return false
|
||||
try { sessionStorage.setItem(RELOAD_FLAG, String(now)) } catch { /* ignore */ }
|
||||
window.location.reload()
|
||||
return true
|
||||
}
|
||||
|
||||
// installStaleChunkReload registriert einen globalen Listener für Vites
|
||||
// preloadError-Event (gefeuert wenn ein dynamisch importiertes Modul
|
||||
// nicht geladen werden kann). preventDefault verhindert das erneute
|
||||
// Werfen durch Vite; danach laden wir einmalig neu. Fängt Fälle ab, die
|
||||
// nicht im React-Render-Pfad landen (z.B. Modul-Preload).
|
||||
export function installStaleChunkReload(): void {
|
||||
window.addEventListener('vite:preloadError', (e) => {
|
||||
e.preventDefault()
|
||||
reloadForStaleChunkOnce()
|
||||
})
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ensureStorageSchema } from './lib/storageSchema'
|
||||
import { installStaleChunkReload } from './lib/staleChunkReload'
|
||||
|
||||
// Vor allen anderen Imports die Storage prüfen — i18n und auth-store
|
||||
// lesen beim Modul-Init aus Storage, also muss der Cleanup davor
|
||||
// passieren wenn die Schema-Version nicht stimmt.
|
||||
ensureStorageSchema()
|
||||
|
||||
// Stale-Chunk-Reload global registrieren: fängt Lazy-Import-Fehler nach
|
||||
// einem Deploy ab und lädt einmalig neu, statt eine Fehlerseite zu zeigen.
|
||||
installStaleChunkReload()
|
||||
|
||||
import './styles/enterprise.css'
|
||||
import './i18n'
|
||||
import App from './App.tsx'
|
||||
|
||||
@@ -40,6 +40,16 @@ interface ClusterStatus {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface RepairStatus {
|
||||
phase: 'idle' | 'running' | 'success' | 'failed'
|
||||
state: string
|
||||
result: string
|
||||
exit_code: number
|
||||
started_at?: string
|
||||
finished_at?: string
|
||||
log: string[]
|
||||
}
|
||||
|
||||
interface NodeResources {
|
||||
load_avg_1: number
|
||||
load_avg_5: number
|
||||
@@ -330,8 +340,67 @@ export default function ClusterPage() {
|
||||
onError: (e: Error) => void message.error(e.message),
|
||||
})
|
||||
|
||||
// ── Replication-Repair ("Resync erzwingen") ──────────────────
|
||||
const [repairing, setRepairing] = useState(false)
|
||||
|
||||
const repairStatusQuery = useQuery({
|
||||
queryKey: ['cluster', 'repair-status'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/cluster/repair-replication/status')
|
||||
return isEnvelope(r.data) ? (r.data.data as RepairStatus) : null
|
||||
},
|
||||
enabled: repairing,
|
||||
refetchInterval: 3_000,
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!repairing) return
|
||||
const st = repairStatusQuery.data
|
||||
// Job meldet Fehler → abbrechen mit letzter Log-Zeile.
|
||||
if (st?.phase === 'failed') {
|
||||
setRepairing(false)
|
||||
const tail = st.log?.slice(-1)[0] ?? ''
|
||||
void message.error(t('cluster.repair.failed') + (tail ? ': ' + tail : ''))
|
||||
return
|
||||
}
|
||||
// Erfolg = Job meldet success ODER der Drift ist verschwunden. Letzteres
|
||||
// ist das verlässliche Signal, da die transiente systemd-Unit (--collect)
|
||||
// nach Erfolg verschwindet und "success" so verpasst werden kann.
|
||||
if (st?.phase === 'success' || data?.drift_found === false) {
|
||||
setRepairing(false)
|
||||
void message.success(t('cluster.repair.ok'))
|
||||
void qc.invalidateQueries({ queryKey: ['cluster'] })
|
||||
return
|
||||
}
|
||||
// Cluster-Status frisch halten, damit drift_found zeitnah umspringt.
|
||||
void qc.invalidateQueries({ queryKey: ['cluster', 'status'] })
|
||||
}, [repairing, repairStatusQuery.data, data?.drift_found, qc, t])
|
||||
|
||||
const repairReplication = useMutation({
|
||||
mutationFn: async () => {
|
||||
const r = await apiClient.post('/cluster/repair-replication')
|
||||
return r.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
setRepairing(true)
|
||||
void message.info(t('cluster.repair.started'))
|
||||
void repairStatusQuery.refetch()
|
||||
},
|
||||
onError: (e: Error) => void message.error(t('cluster.repair.failed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
const primaryFqdn = data?.local_node?.fqdn ?? window.location.hostname
|
||||
|
||||
// Repair-Button: sichtbar bei Drift, für Admins, wenn ein Resync-Ziel
|
||||
// existiert — auf dem Standby (lokal) oder auf dem Primary (delegiert
|
||||
// an den Standby-Peer).
|
||||
const localRole = data?.local_node?.pg_role
|
||||
const canRepair = !isViewer
|
||||
&& !!data?.drift_found
|
||||
&& (localRole === 'standby'
|
||||
|| (localRole === 'primary' && (data?.peers?.some(p => p.pg_role === 'standby') ?? false)))
|
||||
|
||||
const peerColumns: ColumnsType<HANode> = [
|
||||
{
|
||||
title: t('cluster.col.node'), key: 'node',
|
||||
@@ -468,7 +537,35 @@ export default function ClusterPage() {
|
||||
banner
|
||||
className="mb-16"
|
||||
message={t('cluster.driftBanner')}
|
||||
description={t('cluster.driftBannerDesc')}
|
||||
description={
|
||||
<>
|
||||
<Paragraph style={{ marginBottom: 8 }}>{t('cluster.driftBannerDesc')}</Paragraph>
|
||||
{data.local_node?.pg_role === 'primary'
|
||||
&& !(data.peers?.some(p => p.pg_role === 'standby'))
|
||||
&& <Text type="secondary">{t('cluster.repair.noStandbyHint')}</Text>}
|
||||
</>
|
||||
}
|
||||
action={
|
||||
canRepair ? (
|
||||
<Popconfirm
|
||||
title={t('cluster.repair.confirmTitle')}
|
||||
description={t('cluster.repair.confirmDesc')}
|
||||
okText={t('cluster.repair.confirmOk')}
|
||||
cancelText={t('common.cancel')}
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => repairReplication.mutate()}
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={repairing || repairReplication.isPending}
|
||||
>
|
||||
{t('cluster.repair.button')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -146,6 +146,11 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/systemd-run --unit=edgeguard-upgrade.ser
|
||||
# unter /var/lib/edgeguard/restore.sh, Unit-Form ist fix.
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reset-failed edgeguard-restore.service
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemd-run --unit=edgeguard-restore.service --description=EdgeGuard self-restore --collect bash /var/lib/edgeguard/restore.sh
|
||||
# Replication-Repair: Resync der Logical-Replication-Subscription vom
|
||||
# Primary (Config-Drift-Banner → "Resync erzwingen"). Skript landet immer
|
||||
# unter /var/lib/edgeguard/repair-replication.sh, Unit-Form ist fix.
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reset-failed edgeguard-repair-replication.service
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemd-run --unit=edgeguard-repair-replication.service --description=EdgeGuard replication repair --collect bash /var/lib/edgeguard/repair-replication.sh
|
||||
# Keepalived reload: VIP-Settings-Änderung triggert keepalived-Reload.
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload-or-restart keepalived.service
|
||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload-or-restart keepalived.service
|
||||
|
||||
Reference in New Issue
Block a user