feat(install): phase-based progress bar replaces unparseable pull bytes
Podman emits zero parseable progress when stderr is piped (no TTY), so the old byte-counter regex never matched in real installs. Users saw 0% for the whole pull, then a jump to 95%, then silence through create-container, health-check, and post-install hooks. Replace with 7 explicit lifecycle phases wired through install.rs and update.rs: Preparing (5%), PullingImage (20%), CreatingContainer (70%), StartingContainer (80%), WaitingHealthy (88%), PostInstall (95%), Done (100%). Each maps to a fixed UI progress and status message. Frontend PHASE_INFO mapper in stores/server.ts prioritizes phase when present, falls back to byte-counter for legacy. A Math.max forward-only guard ensures the bar never regresses. Deleted the duplicate watcher in Discover.vue that was fighting the store's watcher with stale byte logic. Added shimmer CSS on the fill (with prefers-reduced-motion opt-out) so the bar looks alive during long phases.
This commit is contained in:
@@ -5,6 +5,32 @@ import { computed, ref, watch } from 'vue'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
import { useSyncStore } from './sync'
|
||||
import type { InstallProgress } from '../views/marketplace/marketplaceData'
|
||||
import type { InstallPhase } from '../types/api'
|
||||
|
||||
/**
|
||||
* Phase-to-UI mapping. Each backend pipeline phase maps to a fixed
|
||||
* progress percentage (so the bar only ever advances forward) and a
|
||||
* descriptive label the user can actually understand. This is the
|
||||
* source of truth — byte counters from `install-progress.size/downloaded`
|
||||
* are a fallback for the rare cases where podman does emit parseable
|
||||
* progress on a piped stderr.
|
||||
*
|
||||
* Percentages chosen so:
|
||||
* - the bar is never fully empty (users panic)
|
||||
* - the bar visibly advances at every phase boundary
|
||||
* - the slowest phases (PullingImage, WaitingHealthy) get the widest
|
||||
* bands so shimmer/indeterminate treatment has room
|
||||
* - 100% is reserved for "Done" / terminal success
|
||||
*/
|
||||
const PHASE_INFO: Record<InstallPhase, { progress: number; message: string; status: InstallProgress['status'] }> = {
|
||||
'preparing': { progress: 5, message: 'Preparing…', status: 'downloading' },
|
||||
'pulling-image': { progress: 20, message: 'Downloading image…', status: 'downloading' },
|
||||
'creating-container': { progress: 70, message: 'Creating container…', status: 'installing' },
|
||||
'starting-container': { progress: 80, message: 'Starting container…', status: 'starting' },
|
||||
'waiting-healthy': { progress: 88, message: 'Waiting for container…', status: 'starting' },
|
||||
'post-install': { progress: 95, message: 'Finalizing…', status: 'installing' },
|
||||
'done': { progress: 100, message: 'Installed', status: 'complete' },
|
||||
}
|
||||
|
||||
export const useServerStore = defineStore('server', () => {
|
||||
const sync = useSyncStore()
|
||||
@@ -25,26 +51,45 @@ export const useServerStore = defineStore('server', () => {
|
||||
title: pkg.manifest?.title || appId,
|
||||
status: 'downloading',
|
||||
progress: 0,
|
||||
message: 'Installing...',
|
||||
message: 'Installing…',
|
||||
attempt: 0,
|
||||
})
|
||||
}
|
||||
const progress = pkg['install-progress']
|
||||
if (progress) {
|
||||
const current = installingApps.value.get(appId)!
|
||||
// Primary source: the pipeline phase. Each phase maps to a
|
||||
// fixed progress% and a user-facing label.
|
||||
if (progress.phase) {
|
||||
const info = PHASE_INFO[progress.phase]
|
||||
if (info) {
|
||||
// Only advance forward — never let the bar step backward
|
||||
// between patches (can happen briefly during scan merges).
|
||||
const nextProgress = Math.max(current.progress, info.progress)
|
||||
installingApps.value.set(appId, {
|
||||
...current,
|
||||
status: info.status,
|
||||
progress: nextProgress,
|
||||
message: info.message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Fallback: byte counters (rare — podman usually doesn't
|
||||
// emit parseable progress on a piped stderr).
|
||||
const pct = progress.size > 0 ? Math.round((progress.downloaded / progress.size) * 100) : 0
|
||||
const downloadedMB = (progress.downloaded / (1024 * 1024)).toFixed(1)
|
||||
const totalMB = (progress.size / (1024 * 1024)).toFixed(1)
|
||||
let message = 'Downloading...'
|
||||
let message = 'Downloading…'
|
||||
if (progress.size > 1024 && pct < 100) {
|
||||
message = `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)`
|
||||
} else if (pct >= 100 || (progress.size > 0 && progress.downloaded >= progress.size)) {
|
||||
message = 'Installing package...'
|
||||
message = 'Installing package…'
|
||||
}
|
||||
installingApps.value.set(appId, {
|
||||
...current,
|
||||
status: pct >= 100 ? 'installing' : 'downloading',
|
||||
progress: Math.min(pct, 95),
|
||||
progress: Math.max(current.progress, Math.min(pct, 95)),
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user