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:
archipelago
2026-04-23 07:58:43 -04:00
parent 576ff1a6de
commit 7e62ea07f7
8 changed files with 224 additions and 29 deletions
+6 -21
View File
@@ -176,7 +176,7 @@ let discoverAnimationDone = false
</script>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { useServerStore } from '@/stores/server'
@@ -215,29 +215,14 @@ const categories = computed(() => [
{ id: 'other', name: 'Other' }
])
// Installation state — uses global store so it persists across navigation
// Installation state — uses global store so it persists across navigation.
// The store's watcher (stores/server.ts) handles install-progress updates
// globally, so this view doesn't need its own watcher. Previously had a
// local watcher that duplicated logic using byte counters only — it has
// been removed in favour of the store's phase-aware mapping.
const installingApps = serverStore.installingApps
const maxAttempts = ref(60)
watch(() => store.packages, (packages) => {
if (!packages) return
for (const [appId, pkg] of Object.entries(packages)) {
const progress = pkg['install-progress']
if (progress && pkg.state === 'installing' && installingApps.has(appId)) {
const current = installingApps.get(appId)!
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)
installingApps.set(appId, {
...current,
status: 'downloading',
progress: Math.min(pct, 95),
message: progress.size > 0 ? `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)` : 'Downloading...',
})
}
}
}, { deep: true })
function selectCategory(id: string) {
selectedCategory.value = id
if (id === 'nostr' && nostrApps.value.length === 0 && !nostrLoading.value) {
+30 -1
View File
@@ -93,7 +93,7 @@
</div>
<div class="w-full h-1.5 bg-white/10 rounded-full overflow-hidden">
<div
class="h-full bg-white/60 rounded-full transition-all duration-500"
class="install-progress-fill h-full bg-white/60 rounded-full transition-all duration-500"
:style="{ width: `${Math.max(installProgress?.progress || 2, 2)}%` }"
></div>
</div>
@@ -288,3 +288,32 @@ const isTransitioning = computed(() => {
return s === 'starting' || s === 'installing' || s === 'stopping' || s === 'restarting' || s === 'updating' || (s === 'running' && h === 'starting')
})
</script>
<style scoped>
/* Shimmer overlay on the install progress bar so users see motion even
* when the bar is parked at a fixed phase percentage (pulling-image can
* take minutes, and podman doesn't give us byte-level progress). */
.install-progress-fill {
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0.55) 0%,
rgba(255, 255, 255, 0.9) 50%,
rgba(255, 255, 255, 0.55) 100%
);
background-size: 200% 100%;
animation: install-shimmer 1.8s ease-in-out infinite;
}
@keyframes install-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Respect user motion preferences */
@media (prefers-reduced-motion: reduce) {
.install-progress-fill {
animation: none;
background-image: none;
}
}
</style>