diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index bfa6782b..316a5900 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -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; diff --git a/core/archipelago/src/api/rpc/package/progress.rs b/core/archipelago/src/api/rpc/package/progress.rs index 96d3a72c..dfbaa2cd 100644 --- a/core/archipelago/src/api/rpc/package/progress.rs +++ b/core/archipelago/src/api/rpc/package/progress.rs @@ -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; } diff --git a/core/archipelago/src/api/rpc/package/update.rs b/core/archipelago/src/api/rpc/package/update.rs index 159730bd..72b0c391 100644 --- a/core/archipelago/src/api/rpc/package/update.rs +++ b/core/archipelago/src/api/rpc/package/update.rs @@ -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 { diff --git a/core/archipelago/src/data_model.rs b/core/archipelago/src/data_model.rs index e71da23a..6158b025 100644 --- a/core/archipelago/src/data_model.rs +++ b/core/archipelago/src/data_model.rs @@ -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, +} + +/// 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 diff --git a/neode-ui/src/stores/server.ts b/neode-ui/src/stores/server.ts index c5c0d6e7..78a9c29f 100644 --- a/neode-ui/src/stores/server.ts +++ b/neode-ui/src/stores/server.ts @@ -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 = { + '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, }) } diff --git a/neode-ui/src/types/api.ts b/neode-ui/src/types/api.ts index cc92b49e..8f787164 100644 --- a/neode-ui/src/types/api.ts +++ b/neode-ui/src/types/api.ts @@ -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 diff --git a/neode-ui/src/views/Discover.vue b/neode-ui/src/views/Discover.vue index e5fbabe7..a9a63f86 100644 --- a/neode-ui/src/views/Discover.vue +++ b/neode-ui/src/views/Discover.vue @@ -176,7 +176,7 @@ let discoverAnimationDone = false + +