fix(rolling-update): stable done-detection, mobile banner layout, stale-state protection

- 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>
This commit is contained in:
Debian
2026-05-30 12:56:20 +02:00
parent bc6db1fc2b
commit 884f84d3f1
4 changed files with 85 additions and 77 deletions

View File

@@ -61,7 +61,7 @@ import (
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
) )
var version = "1.2.3" var version = "1.2.13"
func main() { func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR") addr := os.Getenv("EDGEGUARD_API_ADDR")
@@ -475,6 +475,10 @@ func main() {
// schon erledigt. // schon erledigt.
startAgentListener(version, agentHdl, systemHdl) startAgentListener(version, agentHdl, systemHdl)
// Nach einem Upgrade-Neustart: wenn die State-Datei "updating-primary"
// enthält, sind wir gerade neu gestartet → Update abgeschlossen → "done".
handlers.FinishRollingUpdateIfPending()
log.Printf("edgeguard-api %s listening on %s", version, addr) log.Printf("edgeguard-api %s listening on %s", version, addr)
srv := &http.Server{Addr: addr, Handler: r} srv := &http.Server{Addr: addr, Handler: r}
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {

View File

@@ -22,9 +22,24 @@ const (
phaseUpdatingSecondary = "updating-secondary" phaseUpdatingSecondary = "updating-secondary"
phaseWaitingSecondary = "waiting-secondary" phaseWaitingSecondary = "waiting-secondary"
phaseUpdatingPrimary = "updating-primary" phaseUpdatingPrimary = "updating-primary"
phaseDone = "done"
phaseFailed = "failed" phaseFailed = "failed"
) )
// FinishRollingUpdateIfPending wird beim API-Start aufgerufen. Wenn die
// State-Datei "updating-primary" enthält, bedeutet das dass der Primary
// gerade erfolgreich neugestartet ist → Update abgeschlossen → "done" schreiben.
func FinishRollingUpdateIfPending() {
st := readRollingUpdateState()
if st.Phase == phaseUpdatingPrimary {
writeRollingUpdateState(RollingUpdateState{
Phase: phaseDone,
SecondaryID: st.SecondaryID,
SecondaryFQDN: st.SecondaryFQDN,
})
}
}
// RollingUpdateState hält den Fortschritt des Rolling-Updates. // RollingUpdateState hält den Fortschritt des Rolling-Updates.
// Persistiert in rollingUpdateStateFile damit der Status über // Persistiert in rollingUpdateStateFile damit der Status über
// einen kurzen API-Neustart hinaus lesbar bleibt. // einen kurzen API-Neustart hinaus lesbar bleibt.
@@ -75,7 +90,7 @@ func (h *ClusterHandler) RollingUpdate(c *gin.Context) {
} }
st := readRollingUpdateState() st := readRollingUpdateState()
if st.Phase != phaseIdle && st.Phase != phaseFailed { if st.Phase != phaseIdle && st.Phase != phaseFailed && st.Phase != phaseDone {
response.OK(c, st) response.OK(c, st)
return return
} }
@@ -112,11 +127,14 @@ func (h *ClusterHandler) RollingUpdate(c *gin.Context) {
} }
// RollingUpdateStatus gibt den aktuellen Rolling-Update-State zurück. // RollingUpdateStatus gibt den aktuellen Rolling-Update-State zurück.
// Wenn phase == "updating-primary" soll der Client auf /system/health // Bei phase == "done" wird nach Auslieferung sofort auf idle zurückgesetzt
// umschalten (der Primary restartet gleich → State kann nicht mehr // damit der nächste Pageload keinen Stale-done vorfindet.
// geschrieben werden).
func (h *ClusterHandler) RollingUpdateStatus(c *gin.Context) { func (h *ClusterHandler) RollingUpdateStatus(c *gin.Context) {
response.OK(c, readRollingUpdateState()) st := readRollingUpdateState()
response.OK(c, st)
if st.Phase == phaseDone {
writeRollingUpdateState(RollingUpdateState{Phase: phaseIdle})
}
} }
func (h *ClusterHandler) runRollingUpdate(secondary *models.HANode) { func (h *ClusterHandler) runRollingUpdate(secondary *models.HANode) {
@@ -221,7 +239,7 @@ rm -f /var/lib/edgeguard/upgrade.sh
_ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", unitName).Run() _ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", unitName).Run()
cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run", cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run",
"--unit="+unitName, "--unit="+unitName,
"--description=EdgeGuard rolling-update (primary)", "--description=EdgeGuard self-upgrade",
"--collect", "--collect",
"bash", scriptPath) "bash", scriptPath)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {

View File

@@ -1,4 +1,4 @@
import { Alert, Button, Popconfirm, Space, Tooltip, message } from 'antd' import { Alert, Button, Popconfirm, Tooltip, message } from 'antd'
import { CloudDownloadOutlined, ReloadOutlined, RocketOutlined, ClusterOutlined } from '@ant-design/icons' import { CloudDownloadOutlined, ReloadOutlined, RocketOutlined, ClusterOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
@@ -97,9 +97,14 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
const isCluster = clusterStatus.data?.mode === 'cluster' const isCluster = clusterStatus.data?.mode === 'cluster'
const rollingPhase = rollingStatus.data?.phase ?? 'idle' const rollingPhase = rollingStatus.data?.phase ?? 'idle'
const rollingActive = rollingPhase !== 'idle' && rollingPhase !== 'failed' const rollingActive = rollingPhase !== 'idle' && rollingPhase !== 'failed' && rollingPhase !== 'done'
const secondaryFQDN = rollingStatus.data?.secondary_fqdn ?? '' 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 // Normal single-node upgrade state
const [upgrading, setUpgrading] = useState(false) const [upgrading, setUpgrading] = useState(false)
const [upgradeElapsed, setUpgradeElapsed] = useState(0) const [upgradeElapsed, setUpgradeElapsed] = useState(0)
@@ -112,57 +117,58 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
// Rolling update elapsed counter // Rolling update elapsed counter
const [rollingElapsed, setRollingElapsed] = useState(0) const [rollingElapsed, setRollingElapsed] = useState(0)
const rollingTickRef = useRef<ReturnType<typeof setInterval> | null>(null) const rollingTickRef = useRef<ReturnType<typeof setInterval> | null>(null)
const rollingPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => () => { useEffect(() => () => {
if (upgradePollRef.current) clearInterval(upgradePollRef.current) if (upgradePollRef.current) clearInterval(upgradePollRef.current)
if (upgradeTickRef.current) clearInterval(upgradeTickRef.current) if (upgradeTickRef.current) clearInterval(upgradeTickRef.current)
if (rollingTickRef.current) clearInterval(rollingTickRef.current) if (rollingTickRef.current) clearInterval(rollingTickRef.current)
if (rollingPollRef.current) clearInterval(rollingPollRef.current)
}, []) }, [])
// Start rolling elapsed timer when rolling becomes active // Start rolling elapsed timer when rolling becomes active
useEffect(() => { useEffect(() => {
if (rollingActive && !rollingTickRef.current) { if (rollingActive) {
setRollingElapsed(0) wasRollingActiveRef.current = true
rollingTickRef.current = setInterval(() => setRollingElapsed(e => e + 1), 1000) if (!rollingTickRef.current) {
setRollingElapsed(0)
rollingTickRef.current = setInterval(() => setRollingElapsed(e => e + 1), 1000)
}
} else if (!rollingActive && rollingTickRef.current) { } else if (!rollingActive && rollingTickRef.current) {
clearInterval(rollingTickRef.current) clearInterval(rollingTickRef.current)
rollingTickRef.current = null rollingTickRef.current = null
} }
}, [rollingActive]) }, [rollingActive])
// When phase reaches "updating-primary": switch to health polling // "done": nur reagieren wenn wir in DIESER Session rollingActive gesehen
// (primary will restart, state file can't be updated after that) // haben — sonst würde ein stale "done" sofort einen Reload auslösen.
useEffect(() => { useEffect(() => {
if (rollingPhase === 'updating-primary' && !rollingPollRef.current) { if (rollingPhase === 'done' && wasRollingActiveRef.current) {
const primaryInstalled = installedRef.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 let sawDown = false
rollingPollRef.current = setInterval(async () => { const poll = setInterval(async () => {
try { try {
const res = await apiClient.get('/system/health') const res = await apiClient.get('/system/health')
const newV = isEnvelope(res.data) ? (res.data.data as SystemHealth).version : '' const newV = isEnvelope(res.data) ? (res.data.data as SystemHealth).version : ''
const flipped = newV && primaryInstalled && newV !== primaryInstalled if (sawDown && newV) {
if (flipped || sawDown) { clearInterval(poll)
if (rollingPollRef.current) clearInterval(rollingPollRef.current) void rollingStatus.refetch()
if (rollingTickRef.current) clearInterval(rollingTickRef.current)
rollingPollRef.current = null
rollingTickRef.current = null
msg.success(t('update.success', { version: targetRef.current }))
setTimeout(() => window.location.reload(), 1500)
} }
} catch { } catch {
sawDown = true sawDown = true
} }
}, 3000) }, 3000)
// Safety timeout // Safety: nach 2 Min einfach reload
setTimeout(() => { const safety = setTimeout(() => { clearInterval(poll); window.location.reload() }, 120_000)
if (rollingPollRef.current) clearInterval(rollingPollRef.current) return () => { clearInterval(poll); clearTimeout(safety) }
if (rollingTickRef.current) clearInterval(rollingTickRef.current)
window.location.reload()
}, 120_000)
} }
}, [rollingPhase, msg, t]) }, [rollingPhase, rollingStatus])
const data = pkgVersions.data ?? {} const data = pkgVersions.data ?? {}
const updates = allUpdates(data) const updates = allUpdates(data)
@@ -276,20 +282,9 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
banner banner
showIcon showIcon
icon={<CloudDownloadOutlined />} icon={<CloudDownloadOutlined />}
message={t('update.available', { version: targetVersion })} message={
description={updates.length > 1 <div className="update-banner-row">
? t('update.multiPackageHint', { count: updates.length }) <span>{t('update.available', { version: targetVersion })}</span>
: undefined}
action={
<Space>
<Button
size="small"
icon={<ReloadOutlined />}
loading={forceChecking}
onClick={forceCheck}
>
{t('update.checkNow')}
</Button>
{isCluster ? ( {isCluster ? (
<Popconfirm <Popconfirm
title={t('update.rollingConfirmTitle')} title={t('update.rollingConfirmTitle')}
@@ -301,7 +296,7 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
onConfirm={startRollingUpdate} onConfirm={startRollingUpdate}
> >
<Button size="small" type="primary" icon={<ClusterOutlined />}> <Button size="small" type="primary" icon={<ClusterOutlined />}>
{t('update.rollingUpdate')} Rolling Update
</Button> </Button>
</Popconfirm> </Popconfirm>
) : ( ) : (
@@ -317,7 +312,7 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
</Button> </Button>
</Popconfirm> </Popconfirm>
)} )}
</Space> </div>
} }
/> />
)} )}

View File

@@ -2168,33 +2168,24 @@ h1, h2, h3, h4, h5, h6 {
* center icon, plus a four-step progress list and a large seconds * center icon, plus a four-step progress list and a large seconds
* timer. Classes are namespaced with `update-modal` so they don't * timer. Classes are namespaced with `update-modal` so they don't
* clash with AntD Modal internals. */ * clash with AntD Modal internals. */
/* Update-Banner Mobile-Layout (1.6.92+). Auf engen Viewports kollidiert /* Update-Banner: Text links, Button rechts; auf Mobile umbrechen. */
der „Update verfügbar"-Text mit den beiden Aktion-Buttons (Check + .update-banner-row {
Update Now), weil AntD-Alert beide horizontal nebeneinander rendert. display: flex;
Unter 640 px kippen wir das Layout in column-flex, action-Bereich flex-wrap: wrap;
landet unter der Message + die Buttons stretchen auf 100% Breite. */ align-items: center;
@media (max-width: 640px) { gap: 8px;
.update-banner-alert.ant-alert { }
flex-direction: column;
align-items: stretch; .update-banner-row > span {
} flex: 1 1 160px;
.update-banner-alert .ant-alert-content { min-width: 0;
margin-right: 0; }
}
.update-banner-alert .ant-alert-action { .update-banner-row button,
margin-left: 0; .update-banner-row .ant-popover-open,
margin-top: 8px; .update-banner-row > span + * {
} flex-shrink: 0;
.update-banner-alert .ant-alert-action .ant-space { white-space: nowrap;
width: 100%;
display: flex;
}
.update-banner-alert .ant-alert-action .ant-space > .ant-space-item {
flex: 1;
}
.update-banner-alert .ant-alert-action button {
width: 100%;
}
} }
/* Popconfirm der hinter "Update jetzt installieren?" steckt. Default /* Popconfirm der hinter "Update jetzt installieren?" steckt. Default