From 884f84d3f1c52d31271648af3f098f275b02329b Mon Sep 17 00:00:00 2001 From: Debian Date: Sat, 30 May 2026 12:56:20 +0200 Subject: [PATCH] fix(rolling-update): stable done-detection, mobile banner layout, stale-state protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- cmd/edgeguard-api/main.go | 6 +- internal/handlers/cluster_rollingupdate.go | 30 +++++-- management-ui/src/components/UpdateBanner.tsx | 81 +++++++++---------- management-ui/src/styles/enterprise.css | 45 +++++------ 4 files changed, 85 insertions(+), 77 deletions(-) diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 907bb1c..3e65f45 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -61,7 +61,7 @@ import ( usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" ) -var version = "1.2.3" +var version = "1.2.13" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") @@ -475,6 +475,10 @@ func main() { // schon erledigt. 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) srv := &http.Server{Addr: addr, Handler: r} if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { diff --git a/internal/handlers/cluster_rollingupdate.go b/internal/handlers/cluster_rollingupdate.go index 1cab3e4..64f63af 100644 --- a/internal/handlers/cluster_rollingupdate.go +++ b/internal/handlers/cluster_rollingupdate.go @@ -22,9 +22,24 @@ const ( phaseUpdatingSecondary = "updating-secondary" phaseWaitingSecondary = "waiting-secondary" phaseUpdatingPrimary = "updating-primary" + phaseDone = "done" 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. // Persistiert in rollingUpdateStateFile damit der Status über // einen kurzen API-Neustart hinaus lesbar bleibt. @@ -75,7 +90,7 @@ func (h *ClusterHandler) RollingUpdate(c *gin.Context) { } st := readRollingUpdateState() - if st.Phase != phaseIdle && st.Phase != phaseFailed { + if st.Phase != phaseIdle && st.Phase != phaseFailed && st.Phase != phaseDone { response.OK(c, st) return } @@ -112,11 +127,14 @@ func (h *ClusterHandler) RollingUpdate(c *gin.Context) { } // RollingUpdateStatus gibt den aktuellen Rolling-Update-State zurück. -// Wenn phase == "updating-primary" soll der Client auf /system/health -// umschalten (der Primary restartet gleich → State kann nicht mehr -// geschrieben werden). +// Bei phase == "done" wird nach Auslieferung sofort auf idle zurückgesetzt +// damit der nächste Pageload keinen Stale-done vorfindet. 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) { @@ -221,7 +239,7 @@ rm -f /var/lib/edgeguard/upgrade.sh _ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", unitName).Run() cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run", "--unit="+unitName, - "--description=EdgeGuard rolling-update (primary)", + "--description=EdgeGuard self-upgrade", "--collect", "bash", scriptPath) if err := cmd.Run(); err != nil { diff --git a/management-ui/src/components/UpdateBanner.tsx b/management-ui/src/components/UpdateBanner.tsx index 4c6eecf..427eb50 100644 --- a/management-ui/src/components/UpdateBanner.tsx +++ b/management-ui/src/components/UpdateBanner.tsx @@ -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 { useQuery } from '@tanstack/react-query' import { useEffect, useRef, useState } from 'react' @@ -97,9 +97,14 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {} const isCluster = clusterStatus.data?.mode === 'cluster' 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 ?? '' + // 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) @@ -112,57 +117,58 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {} // Rolling update elapsed counter const [rollingElapsed, setRollingElapsed] = useState(0) const rollingTickRef = useRef | null>(null) - const rollingPollRef = useRef | null>(null) useEffect(() => () => { if (upgradePollRef.current) clearInterval(upgradePollRef.current) if (upgradeTickRef.current) clearInterval(upgradeTickRef.current) if (rollingTickRef.current) clearInterval(rollingTickRef.current) - if (rollingPollRef.current) clearInterval(rollingPollRef.current) }, []) // Start rolling elapsed timer when rolling becomes active useEffect(() => { - if (rollingActive && !rollingTickRef.current) { - setRollingElapsed(0) - rollingTickRef.current = setInterval(() => setRollingElapsed(e => e + 1), 1000) + 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]) - // When phase reaches "updating-primary": switch to health polling - // (primary will restart, state file can't be updated after that) + // "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 === 'updating-primary' && !rollingPollRef.current) { - const primaryInstalled = installedRef.current + 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 - rollingPollRef.current = setInterval(async () => { + const poll = setInterval(async () => { try { const res = await apiClient.get('/system/health') const newV = isEnvelope(res.data) ? (res.data.data as SystemHealth).version : '' - const flipped = newV && primaryInstalled && newV !== primaryInstalled - if (flipped || sawDown) { - if (rollingPollRef.current) clearInterval(rollingPollRef.current) - 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) + if (sawDown && newV) { + clearInterval(poll) + void rollingStatus.refetch() } } catch { sawDown = true } }, 3000) - // Safety timeout - setTimeout(() => { - if (rollingPollRef.current) clearInterval(rollingPollRef.current) - if (rollingTickRef.current) clearInterval(rollingTickRef.current) - window.location.reload() - }, 120_000) + // Safety: nach 2 Min einfach reload + const safety = setTimeout(() => { clearInterval(poll); window.location.reload() }, 120_000) + return () => { clearInterval(poll); clearTimeout(safety) } } - }, [rollingPhase, msg, t]) + }, [rollingPhase, rollingStatus]) const data = pkgVersions.data ?? {} const updates = allUpdates(data) @@ -276,20 +282,9 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {} banner showIcon icon={} - message={t('update.available', { version: targetVersion })} - description={updates.length > 1 - ? t('update.multiPackageHint', { count: updates.length }) - : undefined} - action={ - - + message={ +
+ {t('update.available', { version: targetVersion })} {isCluster ? ( ) : ( @@ -317,7 +312,7 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {} )} - +
} /> )} diff --git a/management-ui/src/styles/enterprise.css b/management-ui/src/styles/enterprise.css index 0e582b5..89a7574 100644 --- a/management-ui/src/styles/enterprise.css +++ b/management-ui/src/styles/enterprise.css @@ -2168,33 +2168,24 @@ h1, h2, h3, h4, h5, h6 { * center icon, plus a four-step progress list and a large seconds * timer. Classes are namespaced with `update-modal` so they don't * clash with AntD Modal internals. */ -/* Update-Banner Mobile-Layout (1.6.92+). Auf engen Viewports kollidiert - der „Update verfügbar"-Text mit den beiden Aktion-Buttons (Check + - Update Now), weil AntD-Alert beide horizontal nebeneinander rendert. - Unter 640 px kippen wir das Layout in column-flex, action-Bereich - landet unter der Message + die Buttons stretchen auf 100% Breite. */ -@media (max-width: 640px) { - .update-banner-alert.ant-alert { - flex-direction: column; - align-items: stretch; - } - .update-banner-alert .ant-alert-content { - margin-right: 0; - } - .update-banner-alert .ant-alert-action { - margin-left: 0; - margin-top: 8px; - } - .update-banner-alert .ant-alert-action .ant-space { - 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%; - } +/* Update-Banner: Text links, Button rechts; auf Mobile umbrechen. */ +.update-banner-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.update-banner-row > span { + flex: 1 1 160px; + min-width: 0; +} + +.update-banner-row button, +.update-banner-row .ant-popover-open, +.update-banner-row > span + * { + flex-shrink: 0; + white-space: nowrap; } /* Popconfirm der hinter "Update jetzt installieren?" steckt. Default