import { Alert, Button, Popconfirm, Tooltip, message } from 'antd' import { CloudDownloadOutlined, ReloadOutlined, RocketOutlined, ClusterOutlined } from '@ant-design/icons' import { useQuery } from '@tanstack/react-query' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import apiClient, { isEnvelope } from '../api/client' interface UpdateBannerProps { // compact: rendert nur einen kleinen „Jetzt prüfen"-Button (z. B. im // Header), unabhängig davon ob aktuell ein Update verfügbar ist. So // hat der Operator immer einen Weg den Server-seitigen Throttle zu // umgehen — sonst Catch-22 wenn der Banner mangels Update nicht // rendert. Pattern 1:1 aus mail-gateway/Dashboard/v2/UpdateBanner. compact?: boolean } interface PackageVersions { [key: string]: string } interface SystemHealth { status: string; version: string } interface PendingUpdate { pkg: string; installed: string; available: string } interface ClusterStatus { mode: string // "single-node" | "cluster" peers: Array<{ id: string; fqdn: string }> } interface RollingUpdateState { phase: string // idle | updating-secondary | waiting-secondary | updating-primary | failed secondary_fqdn: string secondary_id: string error?: string updated_at: string } // allUpdates parsed das flache map-Format ({pkg_installed,pkg_available}) // das /system/package-versions zurückliefert. Eines davon ist meist // das meta-Paket "edgeguard" → die "Ziel-Version". function allUpdates(v: PackageVersions): PendingUpdate[] { const out: PendingUpdate[] = [] for (const key of Object.keys(v)) { if (!key.endsWith('_installed')) continue const pkg = key.replace('_installed', '') const installed = v[key] const available = v[`${pkg}_available`] if (installed && available && installed !== available) { out.push({ pkg, installed, available }) } } return out } export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}) { const { t } = useTranslation() const [msg, msgCtx] = message.useMessage() const pkgVersions = useQuery({ queryKey: ['system', 'package-versions'], queryFn: async () => { const r = await apiClient.get('/system/package-versions') return isEnvelope(r.data) ? (r.data.data as PackageVersions) : ({} as PackageVersions) }, refetchInterval: 30_000, refetchOnMount: 'always', staleTime: 0, gcTime: 0, }) const clusterStatus = useQuery({ queryKey: ['cluster', 'status-update-banner'], queryFn: async () => { try { const r = await apiClient.get('/cluster/status') return isEnvelope(r.data) ? (r.data.data as ClusterStatus) : null } catch { return null } }, refetchInterval: 60_000, staleTime: 30_000, }) const rollingStatus = useQuery({ queryKey: ['cluster', 'rolling-update-status'], queryFn: async () => { try { const r = await apiClient.get('/cluster/rolling-update/status') return isEnvelope(r.data) ? (r.data.data as RollingUpdateState) : null } catch { return null } }, refetchInterval: 5_000, staleTime: 0, gcTime: 0, }) const isCluster = clusterStatus.data?.mode === 'cluster' const rollingPhase = rollingStatus.data?.phase ?? 'idle' const rollingActive = rollingPhase !== 'idle' && rollingPhase !== 'failed' && rollingPhase !== 'done' const secondaryFQDN = rollingStatus.data?.secondary_fqdn ?? '' // Verhindert dass ein stale "done" aus einer vorherigen Session sofort // einen Reload auslöst. Nur wenn rollingActive in DIESER Session true // war, reagieren wir auf "done". const wasRollingActiveRef = useRef(false) // Normal single-node upgrade state const [upgrading, setUpgrading] = useState(false) const [upgradeElapsed, setUpgradeElapsed] = useState(0) const [forceChecking, setForceChecking] = useState(false) const upgradePollRef = useRef | null>(null) const upgradeTickRef = useRef | null>(null) const installedRef = useRef('') const targetRef = useRef('') // Rolling update elapsed counter const [rollingElapsed, setRollingElapsed] = useState(0) const rollingTickRef = useRef | null>(null) useEffect(() => () => { if (upgradePollRef.current) clearInterval(upgradePollRef.current) if (upgradeTickRef.current) clearInterval(upgradeTickRef.current) if (rollingTickRef.current) clearInterval(rollingTickRef.current) }, []) // Start rolling elapsed timer when rolling becomes active useEffect(() => { if (rollingActive) { wasRollingActiveRef.current = true if (!rollingTickRef.current) { setRollingElapsed(0) rollingTickRef.current = setInterval(() => setRollingElapsed(e => e + 1), 1000) } } else if (!rollingActive && rollingTickRef.current) { clearInterval(rollingTickRef.current) rollingTickRef.current = null } }, [rollingActive]) // "done": nur reagieren wenn wir in DIESER Session rollingActive gesehen // haben — sonst würde ein stale "done" sofort einen Reload auslösen. useEffect(() => { if (rollingPhase === 'done' && wasRollingActiveRef.current) { msg.success(t('update.success', { version: targetRef.current || '…' })) setTimeout(() => window.location.reload(), 1500) } }, [rollingPhase, msg, t]) // Fallback: wenn "updating-primary" und die API noch antwortet (Primary // schon neu gestartet bevor das UI die Phase gesehen hat), poll auf "done". useEffect(() => { if (rollingPhase === 'updating-primary') { let sawDown = false const poll = setInterval(async () => { try { const res = await apiClient.get('/system/health') const newV = isEnvelope(res.data) ? (res.data.data as SystemHealth).version : '' if (sawDown && newV) { clearInterval(poll) void rollingStatus.refetch() } } catch { sawDown = true } }, 3000) // Safety: nach 2 Min einfach reload const safety = setTimeout(() => { clearInterval(poll); window.location.reload() }, 120_000) return () => { clearInterval(poll); clearTimeout(safety) } } }, [rollingPhase, rollingStatus]) const data = pkgVersions.data ?? {} const updates = allUpdates(data) const updateAvailable = updates.length > 0 const meta = updates.find(u => u.pkg === 'edgeguard') ?? updates[0] const installedVersion = meta?.installed ?? '' const targetVersion = meta?.available ?? '' const forceCheck = async () => { setForceChecking(true) try { const r = await apiClient.get('/system/package-versions?force=1') const fresh = (isEnvelope(r.data) ? (r.data.data as PackageVersions) : {}) void pkgVersions.refetch() const found = allUpdates(fresh).length > 0 msg[found ? 'success' : 'info']( found ? t('update.checkDone') : t('update.noUpdate'), ) } catch { msg.error(t('update.checkFailed')) } finally { setForceChecking(false) } } const startUpgrade = () => { installedRef.current = installedVersion targetRef.current = targetVersion setUpgrading(true) setUpgradeElapsed(0) upgradeTickRef.current = setInterval(() => setUpgradeElapsed((e) => e + 1), 1000) apiClient.post('/system/upgrade') .then(() => { let sawDown = false upgradePollRef.current = setInterval(async () => { try { const res = await apiClient.get('/system/health') const newV = (isEnvelope(res.data) ? (res.data.data as SystemHealth).version : '') const flipped = newV && installedRef.current && newV !== installedRef.current if (flipped || sawDown) { if (upgradePollRef.current) clearInterval(upgradePollRef.current) if (upgradeTickRef.current) clearInterval(upgradeTickRef.current) setUpgrading(false) msg.success(t('update.success', { version: targetRef.current })) setTimeout(() => window.location.reload(), 1500) } } catch { sawDown = true } }, 3000) setTimeout(() => { if (upgradePollRef.current) clearInterval(upgradePollRef.current) if (upgradeTickRef.current) clearInterval(upgradeTickRef.current) setUpgrading(false) window.location.reload() }, 120_000) }) .catch((e: Error) => { if (upgradeTickRef.current) clearInterval(upgradeTickRef.current) if (upgradePollRef.current) clearInterval(upgradePollRef.current) setUpgrading(false) msg.error(t('update.failed') + ': ' + e.message) }) } const startRollingUpdate = () => { installedRef.current = installedVersion targetRef.current = targetVersion apiClient.post('/cluster/rolling-update') .then(() => { void rollingStatus.refetch() }) .catch((e: Error) => { msg.error(t('update.failed') + ': ' + e.message) }) } if (compact) { if (updateAvailable) { return <>{msgCtx} } return ( <> {msgCtx} ) } if (!updateAvailable && !upgrading && !rollingActive) { return <>{msgCtx} } return ( <> {msgCtx} {updateAvailable && !upgrading && !rollingActive && ( } message={
{t('update.available', { version: targetVersion })} {isCluster ? ( ) : ( )}
} /> )} {upgrading && (
{t('update.running')}
v{installedRef.current || '…'} → v{targetRef.current || '…'}
= 5} active={upgradeElapsed < 5} label={t('update.stepDownload')} /> = 15} active={upgradeElapsed >= 5 && upgradeElapsed < 15} label={t('update.stepInstall')} /> = 25} active={upgradeElapsed >= 15 && upgradeElapsed < 25} label={t('update.stepRestart')} /> = 25} label={t('update.stepVerify')} />
{upgradeElapsed}s
{t('update.waitHint')}
)} {rollingActive && (
{t('update.rollingRunning')}
v{installedRef.current || '…'} → v{targetRef.current || '…'}
{rollingElapsed}s
{t('update.waitHint')}
{rollingStatus.data?.error && (
{rollingStatus.data.error}
)}
)} ) } function Step({ done, active, label }: { done: boolean; active: boolean; label: string }) { const cls = 'update-modal__step' + (active ? ' update-modal__step--active' : '') + (done ? ' update-modal__step--done' : '') return (
{label}
) }