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:
@@ -9,6 +9,7 @@ use super::dependencies::{
|
||||
use super::progress::parse_pull_progress;
|
||||
use super::validation::validate_app_id;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::data_model::InstallPhase;
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tracing::{debug, info, warn};
|
||||
@@ -96,6 +97,9 @@ impl RpcHandler {
|
||||
return self.install_indeedhub_stack().await;
|
||||
}
|
||||
|
||||
// Phase: Preparing — validating deps and configs before any slow I/O.
|
||||
self.set_install_phase(package_id, InstallPhase::Preparing).await;
|
||||
|
||||
// Dependency checks
|
||||
let deps = detect_running_deps().await?;
|
||||
check_install_deps(package_id, &deps)?;
|
||||
@@ -181,12 +185,21 @@ impl RpcHandler {
|
||||
package_id, docker_image
|
||||
))
|
||||
.await;
|
||||
// Phase: PullingImage — the longest phase. Podman doesn't emit
|
||||
// parseable progress on a piped stderr, so the UI shows an
|
||||
// indeterminate "Downloading image…" at this fixed percentage
|
||||
// until pull completes.
|
||||
self.set_install_phase(package_id, InstallPhase::PullingImage).await;
|
||||
let has_local_fallback = self.pull_or_verify_image(package_id, docker_image).await?;
|
||||
install_log(&format!(
|
||||
"INSTALL PULL OK: {} — image ready (local_fallback={})",
|
||||
package_id, has_local_fallback
|
||||
))
|
||||
.await;
|
||||
// Phase: CreatingContainer — image is local, now writing configs,
|
||||
// data directories, chowning to container UID, building the run
|
||||
// argv. Fast (sub-second to a few seconds).
|
||||
self.set_install_phase(package_id, InstallPhase::CreatingContainer).await;
|
||||
|
||||
// Normalize container name for legacy aliases
|
||||
let container_name = match package_id {
|
||||
@@ -436,9 +449,19 @@ impl RpcHandler {
|
||||
))
|
||||
.await;
|
||||
|
||||
// Phase: StartingContainer — podman run accepted. Next we poll
|
||||
// inspect until State.Status == running (up to 60s).
|
||||
self.set_install_phase(package_id, InstallPhase::StartingContainer).await;
|
||||
|
||||
// Post-start health verification: wait up to 60s for container to be running
|
||||
let mut container_running = false;
|
||||
for i in 0..12u32 {
|
||||
// After the first poll, flip the UI to WaitingHealthy — the
|
||||
// container hasn't come up yet, so the phase label changes
|
||||
// from "Starting container" to "Waiting for healthy".
|
||||
if i == 1 {
|
||||
self.set_install_phase(package_id, InstallPhase::WaitingHealthy).await;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
let status = tokio::process::Command::new("podman")
|
||||
.args(["inspect", container_name, "--format", "{{.State.Status}}"])
|
||||
@@ -498,6 +521,11 @@ impl RpcHandler {
|
||||
));
|
||||
}
|
||||
|
||||
// Phase: PostInstall — container is up and running. Now any
|
||||
// app-specific post-install (chain init, wallet setup, waiting
|
||||
// for a first block). Varies by app; some are no-ops.
|
||||
self.set_install_phase(package_id, InstallPhase::PostInstall).await;
|
||||
|
||||
// Post-install hooks — await completion before returning success
|
||||
self.run_post_install_hooks(package_id).await;
|
||||
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::data_model::{
|
||||
Description, InstallProgress, Manifest, PackageDataEntry, PackageState, StaticFiles,
|
||||
Description, InstallPhase, InstallProgress, Manifest, PackageDataEntry, PackageState,
|
||||
StaticFiles,
|
||||
};
|
||||
|
||||
impl RpcHandler {
|
||||
/// Set install progress for a package and broadcast the update.
|
||||
/// Creates a minimal package entry if one doesn't exist yet.
|
||||
///
|
||||
/// Prefer `set_install_phase` — this byte-counter API is kept for
|
||||
/// the rare case where the pull stream actually parses, but podman
|
||||
/// almost never emits parseable progress on a piped stderr.
|
||||
pub(super) async fn set_install_progress(&self, package_id: &str, downloaded: u64, size: u64) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
@@ -15,7 +20,45 @@ impl RpcHandler {
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
entry.state = PackageState::Installing;
|
||||
entry.install_progress = Some(InstallProgress { size, downloaded });
|
||||
let existing_phase = entry
|
||||
.install_progress
|
||||
.as_ref()
|
||||
.and_then(|p| p.phase);
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
downloaded,
|
||||
phase: existing_phase,
|
||||
});
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Set the install pipeline phase and broadcast. This is the
|
||||
/// primary progress signal — the UI maps each phase to a
|
||||
/// percentage and a user-facing label. Byte counters are retained
|
||||
/// for the rare case podman emits parseable progress.
|
||||
pub(super) async fn set_install_phase(&self, package_id: &str, phase: InstallPhase) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
// Preparing / PullingImage / CreatingContainer / StartingContainer /
|
||||
// WaitingHealthy / PostInstall all map to the Installing state.
|
||||
// Updates use Updating state — the wrapper has already flipped
|
||||
// state to Updating, so don't clobber it.
|
||||
if entry.state != PackageState::Updating {
|
||||
entry.state = PackageState::Installing;
|
||||
}
|
||||
let (size, downloaded) = entry
|
||||
.install_progress
|
||||
.as_ref()
|
||||
.map(|p| (p.size, p.downloaded))
|
||||
.unwrap_or((0, 0));
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
downloaded,
|
||||
phase: Some(phase),
|
||||
});
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
@@ -52,9 +95,14 @@ impl RpcHandler {
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
let existing_phase = entry
|
||||
.install_progress
|
||||
.as_ref()
|
||||
.and_then(|p| p.phase);
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size: total,
|
||||
downloaded,
|
||||
phase: existing_phase,
|
||||
});
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use super::runtime::stop_timeout_secs;
|
||||
use super::validation::validate_app_id;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::container::image_versions;
|
||||
use crate::data_model::PackageState;
|
||||
use crate::data_model::{InstallPhase, PackageState};
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tracing::{error, info, warn};
|
||||
@@ -96,6 +96,10 @@ impl RpcHandler {
|
||||
containers: &[String],
|
||||
images_to_pull: &[(String, String)],
|
||||
) -> Result<()> {
|
||||
// Phase: Preparing — about to stop the running container(s) so
|
||||
// we can swap images. Fast.
|
||||
self.set_install_phase(package_id, InstallPhase::Preparing).await;
|
||||
|
||||
// 1. Graceful stop all containers (reverse order for dependencies)
|
||||
info!(
|
||||
"Update {}: stopping {} containers",
|
||||
@@ -125,6 +129,9 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase: PullingImage — about to fetch each pinned image in turn.
|
||||
self.set_install_phase(package_id, InstallPhase::PullingImage).await;
|
||||
|
||||
// 2. Pull new images with progress
|
||||
info!(
|
||||
"Update {}: pulling {} images",
|
||||
@@ -168,6 +175,10 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase: CreatingContainer — about to recreate each container
|
||||
// via reconcile-containers.sh with the new image.
|
||||
self.set_install_phase(package_id, InstallPhase::CreatingContainer).await;
|
||||
|
||||
// 4. Recreate via reconcile script (single source of truth for container specs)
|
||||
info!("Update {}: recreating containers via reconcile", package_id);
|
||||
for name in containers {
|
||||
@@ -200,6 +211,10 @@ impl RpcHandler {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
|
||||
// Phase: WaitingHealthy — reconcile has started every container,
|
||||
// now verifying each reached running state.
|
||||
self.set_install_phase(package_id, InstallPhase::WaitingHealthy).await;
|
||||
|
||||
// 5. Verify containers reached running state
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
for name in containers {
|
||||
|
||||
@@ -245,6 +245,38 @@ pub enum ServiceStatus {
|
||||
pub struct InstallProgress {
|
||||
pub size: u64,
|
||||
pub downloaded: u64,
|
||||
/// High-level pipeline phase. Preferred by the UI over the byte
|
||||
/// counters (podman pull doesn't emit parseable progress on a piped
|
||||
/// stderr, so `size`/`downloaded` are often 0). Each phase maps to
|
||||
/// a fixed UI percentage and a descriptive label.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub phase: Option<InstallPhase>,
|
||||
}
|
||||
|
||||
/// Phases of the install / update pipeline, surfaced to the UI so users
|
||||
/// see where the pipeline is rather than a stuck 0% bar.
|
||||
///
|
||||
/// Ordered so each variant's index roughly corresponds to pipeline time.
|
||||
/// Serialized as kebab-case: "preparing", "pulling-image", …
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum InstallPhase {
|
||||
/// Validating params, checking deps, writing dynamic configs.
|
||||
Preparing,
|
||||
/// `podman pull` in progress (the longest phase — up to several
|
||||
/// minutes for large images on slow networks).
|
||||
PullingImage,
|
||||
/// Creating data directories, writing app-specific configs
|
||||
/// (bitcoin.conf, lnd.conf, searxng settings.yml, chown).
|
||||
CreatingContainer,
|
||||
/// `podman run` has returned; container is transitioning to running.
|
||||
StartingContainer,
|
||||
/// Post-start loop waiting up to 60s for `State.Status == running`.
|
||||
WaitingHealthy,
|
||||
/// Running post-install hooks (chain init, wallet setup, …).
|
||||
PostInstall,
|
||||
/// Pipeline finished successfully. Terminal phase, UI clears entry.
|
||||
Done,
|
||||
}
|
||||
|
||||
/// WebSocket message sent to clients
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -150,9 +150,22 @@ export const ServiceStatus = {
|
||||
|
||||
export type ServiceStatus = typeof ServiceStatus[keyof typeof ServiceStatus]
|
||||
|
||||
export type InstallPhase =
|
||||
| 'preparing'
|
||||
| 'pulling-image'
|
||||
| 'creating-container'
|
||||
| 'starting-container'
|
||||
| 'waiting-healthy'
|
||||
| 'post-install'
|
||||
| 'done'
|
||||
|
||||
export interface InstallProgress {
|
||||
size: number
|
||||
downloaded: number
|
||||
/** High-level pipeline phase. Preferred by the UI over the byte
|
||||
* counters — podman pull doesn't emit parseable progress when
|
||||
* stderr is piped, so byte counters are usually (0,0). */
|
||||
phase?: InstallPhase
|
||||
}
|
||||
|
||||
// RPC Request/Response types
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user