Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

### 中文

## 0.2.47 - 2026-09-06

### English

- Show staged update progress and host-updater errors on the System page, instead of leaving the button stuck on Updating
- Keep the update card visible while checking GitHub, reload after a successful restart, and allow cancelling a hung image download or discarding a prepared image

### 中文

- 系统页展示分阶段更新的真实进度和宿主机更新器错误,避免按钮一直停在「更新中」
- 检查更新时不再拆掉更新卡片;重启成功后自动刷新;下载卡住可取消,已下载镜像可放弃

## 0.2.46 - 2026-09-06

### English
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/api/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,7 @@ export function startSystemUpdate() {
export function applyPreparedSystemUpdate() {
return api<StartUpdateResult>('/api/system/update/apply', { method: 'POST', body: '{}' })
}

export function cancelSystemUpdate() {
return api<{ ok: boolean }>('/api/system/update/cancel', { method: 'POST', body: '{}' })
}
20 changes: 20 additions & 0 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,17 @@ export const messages: Record<Lang, Dict> = {
updaterNeedRelease: 'The running build must be a published GitHub release, not a local or development image.',
updateNow: 'Prepare update',
applyUpdateNow: 'Restart with downloaded image',
cancelUpdate: 'Cancel download',
discardPreparedImage: 'Discard downloaded image',
updateReadyHint: 'The image is downloaded and ready. Confirm when you want to restart the service; new requests will pause only then.',
updateReadyTargetHint: 'Restart will switch this host to {version}. New requests pause only during that restart.',
updateNewerReleaseHint: 'A newer release is available ({latest}). Restart this downloaded image first, or discard it and prepare again.',
updateStagedUnavailableHint: 'Update the host updater first to use the staged update flow.',
updateReloadingIn: 'Updating… refresh in {seconds}s',
updateInProgress: 'Updating…',
updatePreparingImage: 'Downloading image…',
updatePreparingImageHint: 'The host updater is pulling the new image. The running service stays on the current version until you confirm the restart. Cancel if the download hangs.',
updateApplyingHint: 'The host updater is recreating the container. The console will refresh after the health check.',
updateInProgressHint: 'The service is updating and will refresh automatically when it is ready.',
updateUnavailableHint: 'Updates are temporarily unavailable. Please try again later.',
updateFailedHint: 'The update could not be completed. Please try again.',
Expand All @@ -96,11 +103,14 @@ export const messages: Record<Lang, Dict> = {
updateState_unavailable: 'Unavailable',
updateState_queued: 'Queued',
updateState_preparing: 'Preparing',
updateState_preparing_image: 'Downloading image',
updateState_checking: 'Checking release',
updateState_backing_up: 'Backing up SQLite',
updateState_submitting: 'Submitting update',
updateState_running: 'Updating service',
updateState_pulling: 'Pulling image',
updateState_image_ready: 'Image ready',
updateState_ready_to_apply: 'Ready to restart',
updateState_recreating: 'Recreating service',
updateState_health_checking: 'Health checking',
updateState_rolling_back: 'Rolling back',
Expand Down Expand Up @@ -633,10 +643,17 @@ export const messages: Record<Lang, Dict> = {
updaterNeedRelease: '当前运行的必须是已发布的 GitHub Release,不能是本地或开发镜像。',
updateNow: '准备更新',
applyUpdateNow: '使用已下载镜像更新',
cancelUpdate: '取消下载',
discardPreparedImage: '放弃已下载镜像',
updateReadyHint: '镜像已经拉取完成。确认后才会重启服务;在此之前不会暂停新请求。',
updateReadyTargetHint: '确认后会把本机切换到 {version}。只有重启时才会暂停新请求。',
updateNewerReleaseHint: '又有更新的版本({latest})。可以先重启这份已下载镜像,或放弃后重新准备。',
updateStagedUnavailableHint: '请先更新宿主机更新器,才能使用分阶段更新。',
updateReloadingIn: '更新中… {seconds}s 后刷新',
updateInProgress: '更新中…',
updatePreparingImage: '正在下载镜像…',
updatePreparingImageHint: '宿主机更新器正在拉取新镜像。当前服务仍运行旧版本,确认重启后才会切换。下载卡住时可以取消。',
updateApplyingHint: '宿主机更新器正在重建容器。健康检查通过后控制台会自动刷新。',
updateInProgressHint: '系统正在更新,准备就绪后会自动刷新页面。',
updateUnavailableHint: '暂时无法更新,请稍后重试。',
updateFailedHint: '更新未完成,请重试。',
Expand All @@ -658,11 +675,14 @@ export const messages: Record<Lang, Dict> = {
updateState_unavailable: '不可用',
updateState_queued: '等待执行',
updateState_preparing: '准备中',
updateState_preparing_image: '正在下载镜像',
updateState_checking: '检查版本中',
updateState_backing_up: '备份 SQLite 中',
updateState_submitting: '提交更新中',
updateState_running: '更新服务中',
updateState_pulling: '拉取镜像中',
updateState_image_ready: '镜像已就绪',
updateState_ready_to_apply: '待确认重启',
updateState_recreating: '重建服务中',
updateState_health_checking: '健康检查中',
updateState_rolling_back: '回滚中',
Expand Down
124 changes: 85 additions & 39 deletions frontend/src/pages/SystemPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@ import {
X,
} from '@phosphor-icons/react'
import { fetchConsoleKey, rotateConsoleKey, type ConsoleKeyView } from '@/api/keys'
import { applyPreparedSystemUpdate, fetchSystemSettings, fetchSystemUpdate, startSystemUpdate, updateSystemSettings, type StartUpdateResult, type SystemSettings, type SystemUpdateInfo } from '@/api/system'
import { applyPreparedSystemUpdate, cancelSystemUpdate, fetchSystemSettings, fetchSystemUpdate, startSystemUpdate, updateSystemSettings, type StartUpdateResult, type SystemSettings, type SystemUpdateInfo } from '@/api/system'
import { useApiKey } from '@/hooks/useApiKey'
import { PageAlert } from '@/components/ui/PageAlert'
import { SystemBodySkeleton, SystemPageSkeleton } from '@/components/ui/PageSkeletons'
import { SystemPageSkeleton } from '@/components/ui/PageSkeletons'
import { useI18n } from '@/hooks/useI18n'
import { CompactSwitch } from '@/components/ui/CompactSwitch'

const activeStates = new Set(['preparing', 'preparing_image', 'checking', 'backing_up', 'submitting', 'running', 'queued', 'pulling', 'recreating', 'rolling_back'])
const busyStates = new Set(['preparing', 'preparing_image', 'checking', 'backing_up', 'submitting', 'running', 'queued', 'pulling', 'image_ready', 'recreating', 'rolling_back'])
const applyJobStates = new Set(['backing_up', 'running'])
const applyAgentStates = new Set(['recreating', 'rolling_back'])
const progressAgentStates = new Set(['queued', 'pulling', 'image_ready', 'preparing', 'recreating', 'checking', 'rolling_back'])

export function SystemPage() {
const { t } = useI18n()
Expand All @@ -38,15 +41,23 @@ export function SystemPage() {
const [error, setError] = useState('')
const [started, setStarted] = useState<StartUpdateResult | null>(null)
const [reloadIn, setReloadIn] = useState<number | null>(null)
const [now, setNow] = useState(() => Date.now())
const [initialVersion, setInitialVersion] = useState('')

const load = useCallback(async (force = false, quiet = false) => {
if (force && !quiet) setChecking(true)
try {
const result = await fetchSystemUpdate(force)
setInfo(result)
setError(result.update?.state === 'failed' || result.update?.state === 'rolled_back' ? t('updateFailedHint') : '')
} catch {
if (!quiet) setError(t('updateCheckFailedHint'))
setInitialVersion((current) => current || result.current_version || '')
const failed = result.update?.state === 'failed' || result.update?.state === 'rolled_back' || result.agent?.state === 'failed' || result.agent?.state === 'rolled_back'
if (failed) {
setError(result.update?.error || result.agent?.error || t('updateFailedHint'))
} else if (!quiet) {
setError('')
}
} catch (err) {
if (!quiet) setError(err instanceof Error ? err.message : t('updateCheckFailedHint'))
} finally {
setLoading(false)
if (force && !quiet) setChecking(false)
Expand All @@ -64,25 +75,38 @@ export function SystemPage() {

const agentState = info?.agent?.state || 'unavailable'
const preparationState = info?.update?.state || ''
const active = Boolean(activeStates.has(preparationState) || activeStates.has(agentState)) || submitting || reloadIn != null
const applying = applyJobStates.has(preparationState) || applyAgentStates.has(agentState) || Boolean(info?.update?.backup_path && busyStates.has(preparationState))
const readyToApply = !applying && (preparationState === 'ready_to_apply' || agentState === 'ready_to_apply')
const waitingForJob = Boolean(started?.job_id) && info?.update?.job_id !== started?.job_id && !readyToApply && !applying
const preparing = !applying && !readyToApply && (busyStates.has(preparationState) || busyStates.has(agentState) || waitingForJob)
const busy = preparing || applying
const succeeded = preparationState === 'succeeded' || agentState === 'succeeded'
const justUpdated = Boolean(succeeded && initialVersion && info?.current_version && info.current_version !== initialVersion)
if (justUpdated && reloadIn == null) setReloadIn(3)
const targetVersion = info?.update?.target_version || info?.agent?.target_version || ''
const newerThanPrepared = Boolean(readyToApply && info?.next_version && targetVersion && info.next_version !== targetVersion)
const startedAt = info?.update?.started_at || info?.agent?.started_at
const elapsed = startedAt && busy ? Math.max(0, Math.round((now - Date.parse(startedAt)) / 1000)) : 0
const visibleState = justUpdated
? 'succeeded'
: readyToApply
? 'ready_to_apply'
: busy && progressAgentStates.has(agentState)
? agentState
: (preparationState || agentState)
const updateStateLabel = visibleState ? t(`updateState_${visibleState}`) : ''
const updateStateText = updateStateLabel.startsWith('updateState_') ? visibleState : updateStateLabel
useEffect(() => {
if (!active || reloadIn != null) return
if (reloadIn != null || (!busy && !justUpdated)) return
const timer = window.setInterval(() => void load(false, true), 2000)
return () => window.clearInterval(timer)
}, [active, load, reloadIn])
}, [busy, justUpdated, load, reloadIn])

useEffect(() => {
if (!submitting || !started || !info || info.update?.job_id !== started.job_id) return
const state = info.update.state
if (state === 'ready_to_apply') {
setSubmitting(false)
} else if (state === 'succeeded') {
setSubmitting(false)
setReloadIn((current) => current ?? 3)
} else if (state === 'failed' || state === 'rolled_back') {
setSubmitting(false)
}
}, [info, started, submitting])
if (!busy) return
const timer = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [busy])

useEffect(() => {
if (reloadIn == null) return
Expand All @@ -94,8 +118,9 @@ export function SystemPage() {
return () => window.clearTimeout(timer)
}, [reloadIn])

const canPrepare = Boolean(info?.managed && info?.has_update && info?.next_version && info?.agent?.available && info?.agent?.staged_update && preparationState !== 'ready_to_apply' && !active)
const canApply = Boolean(preparationState === 'ready_to_apply' && info?.agent?.available && !active)
const canPrepare = Boolean(info?.managed && info?.has_update && info?.next_version && info?.agent?.available && info?.agent?.staged_update && !readyToApply && !busy && !submitting && reloadIn == null)
const canApply = Boolean(readyToApply && info?.agent?.available && !applying && !submitting && reloadIn == null)
const canCancel = Boolean(info?.agent?.available && (preparing || readyToApply) && !applying && !submitting && reloadIn == null)

async function prepareUpdate() {
setSubmitting(true)
Expand All @@ -105,9 +130,11 @@ export function SystemPage() {
const result = await startSystemUpdate()
setStarted(result)
await load(false, true)
} catch {
} catch (err) {
setError(err instanceof Error ? err.message : t('updateFailedHint'))
await load(false, true)
} finally {
setSubmitting(false)
setError(t('updateFailedHint'))
}
}

Expand All @@ -118,9 +145,26 @@ export function SystemPage() {
const result = await applyPreparedSystemUpdate()
setStarted(result)
await load(false, true)
} catch {
} catch (err) {
setError(err instanceof Error ? err.message : t('updateFailedHint'))
await load(false, true)
} finally {
setSubmitting(false)
}
}

async function cancelPreparedUpdate() {
setSubmitting(true)
setError('')
try {
await cancelSystemUpdate()
setStarted(null)
await load(true, true)
} catch (err) {
setError(err instanceof Error ? err.message : t('updateFailedHint'))
await load(false, true)
} finally {
setSubmitting(false)
setError(t('updateFailedHint'))
}
}

Expand Down Expand Up @@ -170,7 +214,6 @@ export function SystemPage() {

{error ? <PageAlert title={error} /> : null}

{checking ? <SystemBodySkeleton /> : (
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.18fr)_minmax(360px,.82fr)]">
<Card data-gsap-reveal className="overflow-hidden p-0">
<div className="flex items-center justify-between gap-3 border-b border-separator px-5 py-4">
Expand Down Expand Up @@ -199,20 +242,24 @@ export function SystemPage() {
</div>

<div className="mt-5 flex flex-wrap items-center justify-end gap-3">
<Button isDisabled={(!canPrepare && !canApply) || reloadIn != null} isPending={submitting || active} onPress={() => void (canApply ? confirmUpdate() : prepareUpdate())}>
{canCancel ? (
<Button size="sm" variant="ghost" isDisabled={submitting} onPress={() => void cancelPreparedUpdate()}>
{readyToApply ? t('discardPreparedImage') : t('cancelUpdate')}
</Button>
) : null}
<Button isDisabled={(!canPrepare && !canApply) || reloadIn != null} isPending={submitting || busy || reloadIn != null} onPress={() => void (canApply ? confirmUpdate() : prepareUpdate())}>
<ArrowCircleUp size={16} />
{reloadIn != null ? t('updateReloadingIn', { seconds: reloadIn }) : canApply ? t('applyUpdateNow') : active || submitting ? t('updateInProgress') : t('updateNow')}
{reloadIn != null ? t('updateReloadingIn', { seconds: reloadIn }) : canApply ? t('applyUpdateNow') : applying ? t('updateInProgress') : preparing || submitting ? t('updatePreparingImage') : t('updateNow')}
</Button>
</div>
{!info?.agent?.available && !active ? <p className="mt-3 text-right text-xs text-muted">{t('updateUnavailableHint')}</p> : null}
{info?.agent?.available && !info.agent.staged_update && !active ? <p className="mt-3 text-right text-xs text-muted">{t('updateStagedUnavailableHint')}</p> : null}
{preparationState === 'ready_to_apply' ? (
<div className="mt-4 rounded-lg border border-success/25 bg-success/5 px-3 py-3" role="status" aria-live="polite">
<p className="text-xs leading-5 text-muted">{t('updateReadyHint')}</p>
</div>
) : active ? (
<div className="mt-4 rounded-lg border border-warning/25 bg-warning/5 px-3 py-3" role="status" aria-live="polite">
<p className="text-xs leading-5 text-muted">{reloadIn != null ? t('updateReloadingHint') : t('updateInProgressHint')}</p>
{!info?.agent?.available && !busy ? <p className="mt-3 text-right text-xs text-muted">{t('updateUnavailableHint')}</p> : null}
{info?.agent?.available && !info.agent.staged_update && !busy ? <p className="mt-3 text-right text-xs text-muted">{t('updateStagedUnavailableHint')}</p> : null}
{readyToApply || busy || reloadIn != null || justUpdated ? (
<div className={`mt-4 rounded-lg border px-3 py-3 ${readyToApply || justUpdated ? 'border-success/25 bg-success/5' : 'border-warning/25 bg-warning/5'}`} role="status" aria-live="polite">
{updateStateText ? <p className="text-xs font-medium text-foreground">{updateStateText}{targetVersion ? ` · ${targetVersion}` : ''}{elapsed ? ` · ${t('updateElapsed', { seconds: elapsed })}` : ''}</p> : null}
<p className="mt-1 text-xs leading-5 text-muted">{reloadIn != null ? t('updateReloadingHint') : readyToApply ? t('updateReadyTargetHint', { version: targetVersion || info?.next_version || '' }) : applying ? t('updateApplyingHint') : t('updatePreparingImageHint')}</p>
{newerThanPrepared ? <p className="mt-1 text-xs leading-5 text-warning">{t('updateNewerReleaseHint', { latest: info?.next_version || '' })}</p> : null}
{info?.update?.error || info?.agent?.error ? <p className="mt-1 text-xs leading-5 text-danger">{info?.update?.error || info?.agent?.error}</p> : null}
</div>
) : null}
</div>
Expand Down Expand Up @@ -319,7 +366,6 @@ export function SystemPage() {

</div>
</div>
)}

<Modal.Root isOpen={rotateOpen} onOpenChange={(open: boolean) => { if (!open && !consoleBusy) setRotateOpen(false) }}>
<Modal.Backdrop variant="blur" isDismissable={!consoleBusy}>
Expand Down
5 changes: 3 additions & 2 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,9 @@ func (s *Server) routes() {
s.mux.HandleFunc(endpoint.HealthPath, s.handleHealth)
s.mux.HandleFunc("/api/overview", s.withConsoleKey(s.handleOverview))
s.mux.HandleFunc("/api/system/update", s.withConsoleKey(s.handleSystemUpdate))
s.mux.HandleFunc("/api/system/update/prepare", s.withConsoleKey(s.handleSystemUpdatePrepare))
s.mux.HandleFunc("/api/system/update/apply", s.withConsoleKey(s.handleSystemUpdateConfirm))
s.mux.HandleFunc("/api/system/update/prepare", s.withConsoleKey(s.handleSystemUpdatePrepare))
s.mux.HandleFunc("/api/system/update/apply", s.withConsoleKey(s.handleSystemUpdateConfirm))
s.mux.HandleFunc("/api/system/update/cancel", s.withConsoleKey(s.handleSystemUpdateCancel))
s.mux.HandleFunc("/api/system/settings", s.withConsoleKey(s.handleSystemSettings))
s.mux.HandleFunc("/api/system/console-key", s.withConsoleKey(s.handleConsoleKey))
s.mux.HandleFunc("/api/keys", s.withConsoleKey(s.handleAPIKeys))
Expand Down
Loading
Loading