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:
Debian
2026-06-04 12:10:41 +02:00
parent 025854150d
commit f7dd7a3a4b
11 changed files with 582 additions and 19 deletions

View File

@@ -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={{

View File

@@ -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",

View File

@@ -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",

View 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()
})
}

View File

@@ -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'

View File

@@ -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
}
/>
)}