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