11<script setup lang="ts">
2- import { ref , onUnmounted } from ' vue'
2+ import { ref , onMounted , onUnmounted } from ' vue'
33import { useI18n } from ' vue-i18n'
44import {
55 checkUpgrade ,
@@ -30,6 +30,26 @@ const upgradeSucceeded = ref(false)
3030
3131let pollTimer: ReturnType <typeof setTimeout > | null = null
3232
33+ // / On mount, check if an upgrade is already in progress (e.g. after page
34+ // / refresh). If so, resume the "waiting for restart" polling so the user sees
35+ // / the spinner and cannot trigger a second upgrade.
36+ onMounted (async () => {
37+ try {
38+ const health = await healthCheck ()
39+ if (health .upgradeInProgress ) {
40+ upgrading .value = true
41+ upgradeMessage .value = t (' settings.upgrading' )
42+ // Resume polling — the detached task is still running server-side.
43+ waitForRestart (health .version ).catch ((e ) => {
44+ upgradeError .value = e instanceof Error ? e .message : String (e )
45+ upgrading .value = false
46+ })
47+ }
48+ } catch {
49+ // Server unreachable on mount — not our concern, App.vue handles connection state.
50+ }
51+ })
52+
3353async function handleCheck() {
3454 checking .value = true
3555 checkError .value = ' '
@@ -48,16 +68,19 @@ async function handleCheck() {
4868
4969async function handleUpgrade() {
5070 if (! updateInfo .value ?.hasUpdate || ! updateInfo .value .supported ) return
71+ if (upgrading .value ) return // double-click guard
5172
5273 upgrading .value = true
5374 upgradeError .value = ' '
5475 upgradeMessage .value = t (' settings.upgrading' )
5576
77+ const currentVersion = updateInfo .value .currentVersion
78+
5679 try {
5780 await performUpgrade ()
58- upgradeMessage . value = t ( ' settings.restarting ' )
59- // Poll /api/ health until the server comes back after restart .
60- await pollUntilReady ( )
81+ // Detached mode: backend returns immediately with status:"started".
82+ // Don't reload — poll health until the upgrade completes + server restarts .
83+ await waitForRestart ( currentVersion )
6184 upgradeSucceeded .value = true
6285 // Reload to pick up the new frontend bundle baked into the new binary.
6386 window .location .reload ()
@@ -72,6 +95,67 @@ async function handleUpgrade() {
7295 }
7396}
7497
98+ // / Poll /api/health through the full upgrade lifecycle:
99+ // / Phase 1: upgradeInProgress=true → "downloading + installing" (spinner)
100+ // / Phase 2: requests fail → server restarting (spinner)
101+ // / Phase 3: requests succeed + version changed → upgrade done, caller reloads
102+ // / Timeout: 6 minutes (5min backend UPGRADE_TIMEOUT + 1min restart buffer)
103+ // /
104+ // / `preUpgradeVersion` is the version before the upgrade, used to detect when
105+ // / the server has restarted with the new binary.
106+ async function waitForRestart(preUpgradeVersion : string ): Promise <void > {
107+ const maxAttempts = 360 // 6 min @ 1s interval
108+ let sawUpgradeInProgress = false
109+ let sawServerDown = false
110+
111+ for (let i = 0 ; i < maxAttempts ; i ++ ) {
112+ try {
113+ const health = await healthCheck ()
114+
115+ if (health .upgradeInProgress ) {
116+ // Phase 1: detached task still running (download/replace).
117+ sawUpgradeInProgress = true
118+ upgradeMessage .value = t (' settings.upgrading' )
119+ await sleep (1000 )
120+ continue
121+ }
122+
123+ if (sawUpgradeInProgress || sawServerDown ) {
124+ // upgradeInProgress flipped to false (or server came back after restart).
125+ // If the version changed → upgrade succeeded. If version is the same
126+ // but we saw the flag → upgrade may have failed mid-way; treat as done
127+ // and let the caller reload to reflect reality.
128+ if (health .version !== preUpgradeVersion ) {
129+ upgradeMessage .value = t (' settings.restarting' )
130+ return // success — new version is live
131+ }
132+ // Flag cleared but version unchanged — upgrade task ended without
133+ // replacing the binary (e.g. ALREADY_UP_TO_DATE race). Stop waiting.
134+ throw new Error (t (' settings.upgradeNoChange' ))
135+ }
136+
137+ // No upgrade in progress and we never saw it — the request was very fast
138+ // or we missed the flag. Check version: if changed, done; else keep waiting.
139+ if (health .version !== preUpgradeVersion ) {
140+ return
141+ }
142+ // Still on old version, flag never seen — give it a few seconds in case
143+ // we raced ahead of the detached task setting the flag.
144+ await sleep (1000 )
145+ } catch {
146+ // Phase 2: server unreachable → restarting.
147+ sawServerDown = true
148+ upgradeMessage .value = t (' settings.restarting' )
149+ await sleep (1000 )
150+ }
151+ }
152+ throw new Error (t (' settings.restartTimeout' ))
153+ }
154+
155+ function sleep(ms : number ): Promise <void > {
156+ return new Promise ((r ) => setTimeout (r , ms ))
157+ }
158+
75159// / Map backend upgrade error codes to upgrade-specific localized messages.
76160// / These are richer than the generic errors.* keys (e.g. they mention sudo,
77161// / Token configuration) — tailored to the upgrade panel's context.
@@ -88,21 +172,6 @@ function mapUpgradeError(code: string): string {
88172 return key ? t (key ) : code
89173}
90174
91- // / Poll health every 1s for up to 30s. The server is unreachable during restart,
92- // / so HTTP polling is more reliable than WS (which would also be severed).
93- async function pollUntilReady(): Promise <void > {
94- const maxAttempts = 30
95- for (let i = 0 ; i < maxAttempts ; i ++ ) {
96- try {
97- await healthCheck ()
98- return // server is back
99- } catch {
100- await new Promise ((r ) => setTimeout (r , 1000 ))
101- }
102- }
103- throw new Error (t (' settings.restartTimeout' ))
104- }
105-
106175onUnmounted (() => {
107176 if (pollTimer ) clearTimeout (pollTimer )
108177})
0 commit comments