release(v1.7.17-alpha): cancel download + stall detection

Add Cancel Download button + stall detection so a wedged download can
be recovered instead of leaving the UI stuck on a frozen progress bar.

Backend:
- update.rs: DOWNLOAD_CANCEL AtomicBool + DOWNLOAD_PROGRESS_AT AtomicU64
- download loop checks cancel between chunks and during retry backoff
  (500ms slices instead of one exponential sleep, so Cancel wakes fast)
- cancel_download() wipes staging + clears update_in_progress
- update.status exposes download_progress.stalled (30s no-progress)
- RPC: update.cancel-download + dispatcher entry

Frontend:
- SystemUpdate.vue: Cancel Download button, amber stall styling,
  stalled copy, cancel-download confirm branch in modal
- i18n keys (en + es) for cancel/stall flow
- v1.7.17-alpha What's New block in AccountInfoSection

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-20 19:10:34 -04:00
parent f2360d570f
commit f853d14421
9 changed files with 209 additions and 27 deletions

View File

@@ -109,17 +109,30 @@
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.downloading') }}</h2>
<div class="w-full h-3 bg-white/10 rounded-full overflow-hidden mb-2">
<div
class="h-full bg-orange-400 rounded-full transition-all duration-500"
class="h-full rounded-full transition-all duration-500"
:class="downloadStalled ? 'bg-amber-400' : 'bg-orange-400'"
:style="{ width: downloadPercentFormatted + '%' }"
></div>
</div>
<div class="flex items-center gap-2">
<div v-if="downloadFinishing" class="w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full animate-spin shrink-0"></div>
<p class="text-xs text-white/60">
{{ downloadFinishing
? t('systemUpdate.finishingDownload')
: t('systemUpdate.percentComplete', { percent: downloadPercentFormatted }) }}
</p>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-2 min-w-0">
<div v-if="downloadFinishing && !downloadStalled" class="w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full animate-spin shrink-0"></div>
<p class="text-xs" :class="downloadStalled ? 'text-amber-300' : 'text-white/60'">
{{ downloadStalled
? t('systemUpdate.downloadStalled')
: downloadFinishing
? t('systemUpdate.finishingDownload')
: t('systemUpdate.percentComplete', { percent: downloadPercentFormatted }) }}
</p>
</div>
<button
@click="requestCancelDownload"
:disabled="cancelingDownload"
class="glass-button rounded-lg px-4 py-1.5 text-xs font-medium disabled:opacity-40 shrink-0"
:class="downloadStalled ? 'bg-amber-500/20 border-amber-400/40 text-amber-200' : ''"
>
{{ cancelingDownload ? t('systemUpdate.cancelingDownload') : t('systemUpdate.cancelDownload') }}
</button>
</div>
</div>
@@ -253,14 +266,18 @@
? t('systemUpdate.rollbackTitle')
: confirmAction === 'git-apply'
? t('systemUpdate.gitApplyTitle')
: t('systemUpdate.applyTitle') }}
: confirmAction === 'cancel-download'
? t('systemUpdate.cancelDownloadTitle')
: t('systemUpdate.applyTitle') }}
</h3>
<p class="text-sm text-white/70 mb-6">
{{ confirmAction === 'rollback'
? t('systemUpdate.rollbackMessage')
: confirmAction === 'git-apply'
? t('systemUpdate.gitApplyMessage')
: t('systemUpdate.applyMessage') }}
: confirmAction === 'cancel-download'
? t('systemUpdate.cancelDownloadConfirm')
: t('systemUpdate.applyMessage') }}
</p>
<div class="flex gap-3 justify-end">
<button @click="cancelConfirm" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">
@@ -269,13 +286,15 @@
<button
@click="executeConfirm"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
:class="confirmAction === 'rollback' ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
:class="(confirmAction === 'rollback' || confirmAction === 'cancel-download') ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
>
{{ confirmAction === 'rollback'
? t('systemUpdate.rollbackButton')
: confirmAction === 'git-apply'
? t('systemUpdate.pullAndRebuild')
: t('systemUpdate.applyNow') }}
: confirmAction === 'cancel-download'
? t('systemUpdate.cancelDownloadButton')
: t('systemUpdate.applyNow') }}
</button>
</div>
</div>
@@ -313,7 +332,9 @@ const loading = ref(false)
const downloading = ref(false)
const downloaded = ref(false)
const applying = ref(false)
const confirmAction = ref<'apply' | 'git-apply' | 'rollback' | null>(null)
const cancelingDownload = ref(false)
const downloadStalled = ref(false)
const confirmAction = ref<'apply' | 'git-apply' | 'rollback' | 'cancel-download' | null>(null)
const currentVersion = ref('0.0.0')
const lastCheck = ref<string | null>(null)
const updateInfo = ref<UpdateDetail | null>(null)
@@ -335,13 +356,16 @@ async function pollDownloadProgress(): Promise<boolean> {
bytes_downloaded: number
total_bytes: number
active: boolean
stalled?: boolean
} | null
}>({ method: 'update.status' })
const p = res.download_progress
if (p && p.total_bytes > 0) {
downloadPercent.value = Math.min(100, (p.bytes_downloaded / p.total_bytes) * 100)
downloadStalled.value = !!p.stalled
return p.active
}
downloadStalled.value = false
return false
} catch {
return false
@@ -547,6 +571,10 @@ function requestRollback() {
confirmAction.value = 'rollback'
}
function requestCancelDownload() {
confirmAction.value = 'cancel-download'
}
function cancelConfirm() {
confirmAction.value = null
}
@@ -560,6 +588,25 @@ async function executeConfirm() {
await applyUpdateGitWithOverlay()
} else if (action === 'rollback') {
await rollbackUpdate()
} else if (action === 'cancel-download') {
await cancelDownload()
}
}
async function cancelDownload() {
cancelingDownload.value = true
try {
await rpcClient.call({ method: 'update.cancel-download' })
downloading.value = false
downloaded.value = false
downloadPercent.value = 0
downloadStalled.value = false
showStatus(t('systemUpdate.cancelDownloadSuccess'))
} catch (e) {
showStatus(t('systemUpdate.cancelDownloadFailed'), true)
if (import.meta.env.DEV) console.warn('Cancel download failed', e)
} finally {
cancelingDownload.value = false
}
}