Skip to content

Commit 48692d7

Browse files
author
wuguofeng
committed
fix(upgrade): show upgrade progress in WebUI with spinner
2 parents fdd700c + 618eab4 commit 48692d7

6 files changed

Lines changed: 112 additions & 21 deletions

File tree

crates/server/src/api.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ pub(crate) struct HealthData {
7777
status: String,
7878
version: String,
7979
uptime_seconds: u64,
80+
/// True while a detached upgrade task is running (download → replace →
81+
/// restart). Frontend polls this to show a spinner and disable the upgrade
82+
/// button — survives page refresh because the flag lives in the process.
83+
upgrade_in_progress: bool,
8084
}
8185

8286
/// Runtime metrics snapshot returned by `GET /api/runtime`.
@@ -502,6 +506,7 @@ async fn health(
502506
status: "ok".to_string(),
503507
version: env!("CARGO_PKG_VERSION").to_string(),
504508
uptime_seconds: state.start_time.elapsed().as_secs(),
509+
upgrade_in_progress: state.upgrade_service.is_upgrade_in_progress(),
505510
}))
506511
}
507512

crates/server/src/upgrade.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ impl UpgradeService {
7171
}
7272
}
7373

74+
/// Whether an upgrade is currently in progress (download/replace/restart).
75+
/// Exposed via `/api/health` so the frontend can show a spinner and disable
76+
/// the upgrade button — survives page refresh (the flag lives in the process,
77+
/// not the browser).
78+
pub fn is_upgrade_in_progress(&self) -> bool {
79+
self.upgrade_in_progress.load(Ordering::SeqCst)
80+
}
81+
7482
/// Serve from cache if fresh; otherwise fetch from GitHub (spawn_blocking).
7583
/// `force` bypasses the cache for an explicit user-triggered refresh.
7684
pub async fn check_update(&self, force: bool) -> Result<UpdateInfo, String> {

frontend/src/components/settings/AboutSection.vue

Lines changed: 88 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { ref, onUnmounted } from 'vue'
2+
import { ref, onMounted, onUnmounted } from 'vue'
33
import { useI18n } from 'vue-i18n'
44
import {
55
checkUpgrade,
@@ -30,6 +30,26 @@ const upgradeSucceeded = ref(false)
3030
3131
let 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+
3353
async function handleCheck() {
3454
checking.value = true
3555
checkError.value = ''
@@ -48,16 +68,19 @@ async function handleCheck() {
4868
4969
async 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-
106175
onUnmounted(() => {
107176
if (pollTimer) clearTimeout(pollTimer)
108177
})

frontend/src/locales/en-US.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
"permissionDenied": "Permission denied: ensure the binary is writable (try sudo, or grant write access to the systemd/launchd service)",
8888
"upgradeInProgress": "An upgrade is already in progress, please wait for it to finish",
8989
"restartTimeout": "Service restart timed out, please check status manually",
90+
"upgradeNoChange": "Upgrade did not complete (version unchanged), please retry",
9091
"upgradeUnsupported": "Automatic upgrade is not supported on this platform, please download manually",
9192
"manualDownload": "Download manually",
9293
"updateToastTitle": "New version v{version} available",

frontend/src/locales/zh-CN.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
"permissionDenied": "权限不足,请使用 sudo 运行或检查二进制可写性",
8888
"upgradeInProgress": "升级正在进行中,请等待完成",
8989
"restartTimeout": "服务重启超时,请手动检查状态",
90+
"upgradeNoChange": "升级未完成,版本未发生变化,请重试",
9091
"upgradeUnsupported": "当前平台不支持自动升级,请手动下载",
9192
"manualDownload": "手动下载",
9293
"updateToastTitle": "新版本 v{version} 可用",

frontend/src/services/api.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,15 @@ export async function getFileTail(
138138
)
139139
}
140140

141-
export async function healthCheck(): Promise<{ status: string; version: string; uptimeSeconds: number }> {
142-
return request<{ status: string; version: string; uptimeSeconds: number }>('/api/health')
141+
export interface HealthData {
142+
status: string
143+
version: string
144+
uptimeSeconds: number
145+
upgradeInProgress: boolean
146+
}
147+
148+
export async function healthCheck(): Promise<HealthData> {
149+
return request<HealthData>('/api/health')
143150
}
144151

145152
// ── 运行时指标 API ─────────────────────────────────────────

0 commit comments

Comments
 (0)