- FinishRollingUpdateIfPending() auf API-Startup: transitiert updating-primary → done damit der UI-Flow nach Restart abschließt - RollingUpdateStatus: setzt done nach Auslieferung auf idle zurück (verhindert Stale-done bei Page-Reload) - wasRollingActiveRef: reagiert auf done nur wenn rolling in DIESER Session aktiv war — kein sofortiger Reload bei Stale-State - UI-Fallback für updating-primary: poll auf /system/health version-flip - Cluster-Erkennung via /cluster/status; Rolling-Update-Button nur im Cluster - Update-Banner-Button nicht mehr gequetscht (flex-shrink:0 + nowrap) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
414 lines
15 KiB
TypeScript
414 lines
15 KiB
TypeScript
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<ReturnType<typeof setInterval> | null>(null)
|
|
const upgradeTickRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
const installedRef = useRef<string>('')
|
|
const targetRef = useRef<string>('')
|
|
|
|
// Rolling update elapsed counter
|
|
const [rollingElapsed, setRollingElapsed] = useState(0)
|
|
const rollingTickRef = useRef<ReturnType<typeof setInterval> | 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}
|
|
<Tooltip title={t('update.checkNowHint')}>
|
|
<Button
|
|
type="default"
|
|
icon={<ReloadOutlined />}
|
|
loading={forceChecking}
|
|
onClick={forceCheck}
|
|
>
|
|
{t('update.checkNow')}
|
|
</Button>
|
|
</Tooltip>
|
|
</>
|
|
)
|
|
}
|
|
|
|
if (!updateAvailable && !upgrading && !rollingActive) {
|
|
return <>{msgCtx}</>
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{msgCtx}
|
|
|
|
{updateAvailable && !upgrading && !rollingActive && (
|
|
<Alert
|
|
type="warning"
|
|
banner
|
|
showIcon
|
|
icon={<CloudDownloadOutlined />}
|
|
message={
|
|
<div className="update-banner-row">
|
|
<span>{t('update.available', { version: targetVersion })}</span>
|
|
{isCluster ? (
|
|
<Popconfirm
|
|
title={t('update.rollingConfirmTitle')}
|
|
description={t('update.rollingConfirmDesc', {
|
|
secondary: clusterStatus.data?.peers?.[0]?.fqdn ?? 'secondary',
|
|
})}
|
|
okText={t('update.rollingUpdate')}
|
|
cancelText={t('common.cancel')}
|
|
onConfirm={startRollingUpdate}
|
|
>
|
|
<Button size="small" type="primary" icon={<ClusterOutlined />}>
|
|
Rolling Update
|
|
</Button>
|
|
</Popconfirm>
|
|
) : (
|
|
<Popconfirm
|
|
title={t('update.confirmTitle')}
|
|
description={t('update.confirmDesc', { version: targetVersion })}
|
|
okText={t('update.applyNow')}
|
|
cancelText={t('common.cancel')}
|
|
onConfirm={startUpgrade}
|
|
>
|
|
<Button size="small" type="primary" icon={<CloudDownloadOutlined />}>
|
|
{t('update.applyNow')}
|
|
</Button>
|
|
</Popconfirm>
|
|
)}
|
|
</div>
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{upgrading && (
|
|
<div className="update-modal-overlay">
|
|
<div className="update-modal">
|
|
<div className="update-modal__orbit">
|
|
<div className="update-modal__ring" />
|
|
<div className="update-modal__ring update-modal__ring--2" />
|
|
<div className="update-modal__dot" />
|
|
<div className="update-modal__dot update-modal__dot--2" />
|
|
<div className="update-modal__center">
|
|
<RocketOutlined className="update-modal__icon" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="update-modal__title">{t('update.running')}</div>
|
|
<div className="update-modal__version">
|
|
v{installedRef.current || '…'} → v{targetRef.current || '…'}
|
|
</div>
|
|
|
|
<div className="update-modal__steps">
|
|
<Step done={upgradeElapsed >= 5} active={upgradeElapsed < 5} label={t('update.stepDownload')} />
|
|
<Step done={upgradeElapsed >= 15} active={upgradeElapsed >= 5 && upgradeElapsed < 15} label={t('update.stepInstall')} />
|
|
<Step done={upgradeElapsed >= 25} active={upgradeElapsed >= 15 && upgradeElapsed < 25} label={t('update.stepRestart')} />
|
|
<Step done={false} active={upgradeElapsed >= 25} label={t('update.stepVerify')} />
|
|
</div>
|
|
|
|
<div className="update-modal__timer">{upgradeElapsed}s</div>
|
|
<div className="update-modal__hint">{t('update.waitHint')}</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{rollingActive && (
|
|
<div className="update-modal-overlay">
|
|
<div className="update-modal">
|
|
<div className="update-modal__orbit">
|
|
<div className="update-modal__ring" />
|
|
<div className="update-modal__ring update-modal__ring--2" />
|
|
<div className="update-modal__dot" />
|
|
<div className="update-modal__dot update-modal__dot--2" />
|
|
<div className="update-modal__center">
|
|
<ClusterOutlined className="update-modal__icon" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="update-modal__title">{t('update.rollingRunning')}</div>
|
|
<div className="update-modal__version">
|
|
v{installedRef.current || '…'} → v{targetRef.current || '…'}
|
|
</div>
|
|
|
|
<div className="update-modal__steps">
|
|
<Step
|
|
done={rollingPhase === 'waiting-secondary' || rollingPhase === 'updating-primary'}
|
|
active={rollingPhase === 'updating-secondary'}
|
|
label={t('update.rollingStepSecondary', { fqdn: secondaryFQDN })}
|
|
/>
|
|
<Step
|
|
done={rollingPhase === 'updating-primary'}
|
|
active={rollingPhase === 'waiting-secondary'}
|
|
label={t('update.rollingStepWaiting')}
|
|
/>
|
|
<Step
|
|
done={false}
|
|
active={rollingPhase === 'updating-primary'}
|
|
label={t('update.rollingStepPrimary')}
|
|
/>
|
|
</div>
|
|
|
|
<div className="update-modal__timer">{rollingElapsed}s</div>
|
|
<div className="update-modal__hint">{t('update.waitHint')}</div>
|
|
|
|
{rollingStatus.data?.error && (
|
|
<div className="update-modal__hint" style={{ color: '#ff4d4f' }}>
|
|
{rollingStatus.data.error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div className={cls}>
|
|
<span className="update-modal__step-dot" />
|
|
<span>{label}</span>
|
|
</div>
|
|
)
|
|
}
|