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

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