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:
@@ -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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user