release(v1.7.30-alpha): live install/uninstall progress + cleaner pull waterfall
- Backend: unified pull-progress streaming across primary AND fallback
registries. Earlier code only streamed for the primary attempt; if it
failed fast (VPS 404, etc.) the UI froze at 0% until the fallback
finished. The waterfall now uses a single shared helper that streams
podman stderr through update_install_progress for every URL tried.
- Backend: PackageDataEntry gains uninstall_stage, set at each phase of
handle_package_uninstall ("Stopping containers (i/total)",
"Cleaning up volumes", "Removing app data"). State flips to Removing
during the pipeline.
- Frontend: MarketplaceAppCard renders the live progress bar with byte
counts during installs, matching the System Update download bar style.
- Frontend: AppCard renders the live uninstall stage label per app.
Modal closes immediately on confirm so concurrent uninstalls each
show their own progress on their own card.
- Cleanup: removed dead helpers (image_candidates, rewrite_for_primary,
primary_image_url, pull_from_registries_with_skip) made unused by
the install.rs refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1709149ebd
commit
18f0929614
2
core/Cargo.lock
generated
2
core/Cargo.lock
generated
@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.7.29-alpha"
|
version = "1.7.30-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.7.29-alpha"
|
version = "1.7.30-alpha"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||||
authors = ["Archipelago Team"]
|
authors = ["Archipelago Team"]
|
||||||
|
|||||||
@ -635,47 +635,32 @@ impl RpcHandler {
|
|||||||
unreachable!()
|
unreachable!()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Single image pull attempt with progress streaming.
|
/// Pull one image URL with live progress streamed through
|
||||||
async fn do_pull_image(&self, package_id: &str, docker_image: &str) -> Result<()> {
|
/// `update_install_progress`. Returns Ok(true) on a successful pull,
|
||||||
debug!("Pulling image: {}", docker_image);
|
/// Ok(false) on transient failure (so the caller can try the next
|
||||||
self.set_install_progress(package_id, 0, 0).await;
|
/// mirror), Err only for unrecoverable setup errors.
|
||||||
|
async fn pull_one_url_with_progress(
|
||||||
// Set TMPDIR to user-writable location — rootless podman's user namespace
|
&self,
|
||||||
// makes /var/tmp read-only, which causes `podman pull` to fail with
|
url: &str,
|
||||||
// "mkdir /var/tmp/container_images_storage...: read-only file system"
|
tls_verify: bool,
|
||||||
let user_tmp = format!(
|
package_id: &str,
|
||||||
"{}/.local/share/containers/tmp",
|
user_tmp: &str,
|
||||||
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string())
|
) -> Result<bool> {
|
||||||
);
|
let mut pull_args = vec!["pull".to_string(), url.to_string()];
|
||||||
let _ = std::fs::create_dir_all(&user_tmp);
|
if !tls_verify {
|
||||||
|
|
||||||
// Rewrite to the primary registry's URL so the first attempt
|
|
||||||
// honors the operator's mirror choice (default: VPS) instead of
|
|
||||||
// blindly using whatever registry the image was hardcoded to.
|
|
||||||
// If the rewritten URL fails, pull_from_registries_with_skip
|
|
||||||
// falls through to the other configured registries.
|
|
||||||
let (primary_url, primary_tls) =
|
|
||||||
crate::container::registry::primary_image_url(&self.config.data_dir, docker_image)
|
|
||||||
.await;
|
|
||||||
if primary_url != docker_image {
|
|
||||||
debug!("Rewrote {} → {} for primary registry", docker_image, primary_url);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut pull_args = vec!["pull".to_string(), primary_url.clone()];
|
|
||||||
if !primary_tls {
|
|
||||||
pull_args.push("--tls-verify=false".to_string());
|
pull_args.push("--tls-verify=false".to_string());
|
||||||
}
|
}
|
||||||
let mut child = tokio::process::Command::new("podman")
|
let mut child = tokio::process::Command::new("podman")
|
||||||
.args(&pull_args)
|
.args(&pull_args)
|
||||||
.env("TMPDIR", &user_tmp)
|
.env("TMPDIR", user_tmp)
|
||||||
.stdout(std::process::Stdio::piped())
|
.stdout(std::process::Stdio::piped())
|
||||||
.stderr(std::process::Stdio::piped())
|
.stderr(std::process::Stdio::piped())
|
||||||
.spawn()
|
.spawn()
|
||||||
.context("Failed to start image pull")?;
|
.context("Failed to start image pull")?;
|
||||||
|
|
||||||
// Wrap the entire pull (stderr progress + wait) in a 10-minute timeout.
|
// 10-minute per-URL budget — large layers (Minio, Postgres,
|
||||||
// Large image layers (Minio, Postgres, ffmpeg) can take several minutes
|
// ffmpeg) regularly take several minutes and we'd rather wait
|
||||||
// to pull. 60s was too short and caused premature retries on slow registries.
|
// than bounce to the next mirror mid-download.
|
||||||
let pull_result = tokio::time::timeout(std::time::Duration::from_secs(600), async {
|
let pull_result = tokio::time::timeout(std::time::Duration::from_secs(600), async {
|
||||||
if let Some(stderr) = child.stderr.take() {
|
if let Some(stderr) = child.stderr.take() {
|
||||||
let reader = BufReader::new(stderr);
|
let reader = BufReader::new(stderr);
|
||||||
@ -693,50 +678,115 @@ impl RpcHandler {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let primary_failed = match pull_result {
|
match pull_result {
|
||||||
Ok(Ok(status)) => !status.success(),
|
Ok(Ok(status)) => Ok(status.success()),
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
tracing::warn!("Image pull process error: {}", e);
|
tracing::warn!("Image pull process error on {}: {}", url, e);
|
||||||
true
|
Ok(false)
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
tracing::warn!("Image pull timed out after 60s: {}", primary_url);
|
tracing::warn!("Image pull timed out after 600s: {}", url);
|
||||||
let _ = child.kill().await;
|
let _ = child.kill().await;
|
||||||
let _ = child.wait().await; // reap zombie
|
let _ = child.wait().await; // reap zombie
|
||||||
true
|
Ok(false)
|
||||||
}
|
}
|
||||||
};
|
|
||||||
if !primary_failed && primary_url != docker_image {
|
|
||||||
// Primary pull succeeded but used a rewritten URL. Tag under
|
|
||||||
// the original image reference so downstream code (images -q,
|
|
||||||
// run -d docker_image, etc.) finds it.
|
|
||||||
let _ = tokio::process::Command::new("podman")
|
|
||||||
.args(["tag", &primary_url, docker_image])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
tracing::info!("Pulled {} from primary registry ({})", docker_image, primary_url);
|
|
||||||
}
|
}
|
||||||
if primary_failed {
|
}
|
||||||
// Primary failed — walk the remaining configured registries.
|
|
||||||
// Skip primary_url so we don't retry what just failed.
|
/// Pull a container image, trying each configured registry in
|
||||||
match crate::container::registry::pull_from_registries_with_skip(
|
/// priority order and streaming progress during every attempt. The
|
||||||
&self.config.data_dir,
|
/// primary is tried first; if it doesn't have the image (or 404's),
|
||||||
docker_image,
|
/// the next mirror is tried — with its own progress streaming, so
|
||||||
&user_tmp,
|
/// the UI doesn't freeze at 0% after a primary miss. On success the
|
||||||
Some(&primary_url),
|
/// image is tagged under `docker_image` so downstream commands
|
||||||
)
|
/// (images -q, run -d, etc.) can find it by its canonical name.
|
||||||
|
async fn do_pull_image(&self, package_id: &str, docker_image: &str) -> Result<()> {
|
||||||
|
debug!("Pulling image: {}", docker_image);
|
||||||
|
self.set_install_progress(package_id, 0, 0).await;
|
||||||
|
|
||||||
|
// Set TMPDIR to user-writable location — rootless podman's user namespace
|
||||||
|
// makes /var/tmp read-only, which causes `podman pull` to fail with
|
||||||
|
// "mkdir /var/tmp/container_images_storage...: read-only file system"
|
||||||
|
let user_tmp = format!(
|
||||||
|
"{}/.local/share/containers/tmp",
|
||||||
|
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string())
|
||||||
|
);
|
||||||
|
let _ = std::fs::create_dir_all(&user_tmp);
|
||||||
|
|
||||||
|
// Build the ordered candidate list: every enabled registry
|
||||||
|
// (highest priority first), each rewriting the image URL to its
|
||||||
|
// own origin. Deduplicate — two registries that happen to share
|
||||||
|
// a URL should only be tried once.
|
||||||
|
let config = crate::container::registry::load_registries(&self.config.data_dir)
|
||||||
.await
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut tried: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
let mut candidates: Vec<(String, bool)> = Vec::new();
|
||||||
|
for reg in config.active_registries() {
|
||||||
|
let url = config.rewrite_image(docker_image, reg);
|
||||||
|
if tried.insert(url.clone()) {
|
||||||
|
candidates.push((url, reg.tls_verify));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If no registries are configured, fall back to the literal URL.
|
||||||
|
if candidates.is_empty() {
|
||||||
|
candidates.push((docker_image.to_string(), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk candidates, streaming progress for each attempt.
|
||||||
|
let mut pulled_url: Option<String> = None;
|
||||||
|
let attempts = candidates.len();
|
||||||
|
for (i, (url, tls_verify)) in candidates.iter().enumerate() {
|
||||||
|
if url != docker_image {
|
||||||
|
debug!("Attempt {}/{}: {}", i + 1, attempts, url);
|
||||||
|
} else {
|
||||||
|
debug!("Attempt {}/{}: {} (literal)", i + 1, attempts, url);
|
||||||
|
}
|
||||||
|
// Reset progress at the top of each attempt so the UI reflects
|
||||||
|
// the fresh pull instead of showing stale bytes from a prior
|
||||||
|
// partial attempt.
|
||||||
|
self.set_install_progress(package_id, 0, 0).await;
|
||||||
|
match self
|
||||||
|
.pull_one_url_with_progress(url, *tls_verify, package_id, &user_tmp)
|
||||||
|
.await?
|
||||||
{
|
{
|
||||||
Ok(_) => {
|
true => {
|
||||||
tracing::info!("Pulled {} via fallback registry", docker_image);
|
tracing::info!("Pulled {} from {}", docker_image, url);
|
||||||
|
pulled_url = Some(url.clone());
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
false => {
|
||||||
return Err(anyhow::anyhow!("Image pull failed: {}", e));
|
tracing::debug!(
|
||||||
|
"Pull attempt {}/{} failed for {}, trying next mirror",
|
||||||
|
i + 1,
|
||||||
|
attempts,
|
||||||
|
url
|
||||||
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify image exists locally after pull
|
let Some(pulled_url) = pulled_url else {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Image pull failed from all {} configured registries for {}",
|
||||||
|
attempts,
|
||||||
|
docker_image
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tag under the original docker_image reference if the successful
|
||||||
|
// pull came from a rewritten URL — downstream code (images -q,
|
||||||
|
// run -d docker_image, etc.) needs to find it by its canonical
|
||||||
|
// name regardless of which mirror actually served the bytes.
|
||||||
|
if pulled_url != docker_image {
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args(["tag", &pulled_url, docker_image])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify image exists locally after pull.
|
||||||
let verify = tokio::process::Command::new("podman")
|
let verify = tokio::process::Command::new("podman")
|
||||||
.args(["images", "-q", docker_image])
|
.args(["images", "-q", docker_image])
|
||||||
.output()
|
.output()
|
||||||
|
|||||||
@ -28,6 +28,18 @@ impl RpcHandler {
|
|||||||
self.state_manager.update_data(data).await;
|
self.state_manager.update_data(data).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the uninstall stage label so the UI can show what's happening
|
||||||
|
/// instead of a generic spinner. Each call broadcasts a state change
|
||||||
|
/// — call sparingly (one per pipeline phase, not per container).
|
||||||
|
pub(super) async fn set_uninstall_stage(&self, package_id: &str, stage: &str) {
|
||||||
|
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||||
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
|
entry.uninstall_stage = Some(stage.to_string());
|
||||||
|
entry.state = crate::data_model::PackageState::Removing;
|
||||||
|
}
|
||||||
|
self.state_manager.update_data(data).await;
|
||||||
|
}
|
||||||
|
|
||||||
/// Update install progress (static method for use in async closures).
|
/// Update install progress (static method for use in async closures).
|
||||||
pub(super) async fn update_install_progress(
|
pub(super) async fn update_install_progress(
|
||||||
state_manager: &crate::state::StateManager,
|
state_manager: &crate::state::StateManager,
|
||||||
@ -81,6 +93,7 @@ fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
|||||||
},
|
},
|
||||||
installed: None,
|
installed: None,
|
||||||
install_progress: None,
|
install_progress: None,
|
||||||
|
uninstall_stage: None,
|
||||||
available_update: None,
|
available_update: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -261,12 +261,28 @@ impl RpcHandler {
|
|||||||
if containers_to_remove.is_empty() {
|
if containers_to_remove.is_empty() {
|
||||||
tracing::warn!("Uninstall {}: no containers found", package_id);
|
tracing::warn!("Uninstall {}: no containers found", package_id);
|
||||||
}
|
}
|
||||||
|
let total = containers_to_remove.len();
|
||||||
|
|
||||||
let mut stopped = 0u32;
|
let mut stopped = 0u32;
|
||||||
let mut removed = 0u32;
|
let mut removed = 0u32;
|
||||||
let mut errors = Vec::new();
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
for name in &containers_to_remove {
|
self.set_uninstall_stage(
|
||||||
|
package_id,
|
||||||
|
&if total > 0 {
|
||||||
|
format!("Stopping containers (0/{})", total)
|
||||||
|
} else {
|
||||||
|
"Cleaning up".to_string()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
for (i, name) in containers_to_remove.iter().enumerate() {
|
||||||
|
self.set_uninstall_stage(
|
||||||
|
package_id,
|
||||||
|
&format!("Stopping containers ({}/{})", i + 1, total),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
tracing::info!("Uninstall {}: stopping container {}", package_id, name);
|
tracing::info!("Uninstall {}: stopping container {}", package_id, name);
|
||||||
let stop_out = tokio::process::Command::new("podman")
|
let stop_out = tokio::process::Command::new("podman")
|
||||||
.args(["stop", "-t", stop_timeout_secs(name), name])
|
.args(["stop", "-t", stop_timeout_secs(name), name])
|
||||||
@ -326,6 +342,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.set_uninstall_stage(package_id, "Cleaning up volumes").await;
|
||||||
// Clean up dangling volumes associated with removed containers
|
// Clean up dangling volumes associated with removed containers
|
||||||
let _ = tokio::process::Command::new("podman")
|
let _ = tokio::process::Command::new("podman")
|
||||||
.args(["volume", "prune", "-f"])
|
.args(["volume", "prune", "-f"])
|
||||||
@ -354,6 +371,7 @@ impl RpcHandler {
|
|||||||
|
|
||||||
// Clean data directories unless preserve_data
|
// Clean data directories unless preserve_data
|
||||||
if !preserve_data {
|
if !preserve_data {
|
||||||
|
self.set_uninstall_stage(package_id, "Removing app data").await;
|
||||||
let data_dirs = get_data_dirs_for_app(package_id);
|
let data_dirs = get_data_dirs_for_app(package_id);
|
||||||
for dir in &data_dirs {
|
for dir in &data_dirs {
|
||||||
tracing::info!("Uninstall {}: removing data {}", package_id, dir);
|
tracing::info!("Uninstall {}: removing data {}", package_id, dir);
|
||||||
|
|||||||
@ -247,6 +247,7 @@ impl DockerPackageScanner {
|
|||||||
status: service_status,
|
status: service_status,
|
||||||
}),
|
}),
|
||||||
install_progress: None,
|
install_progress: None,
|
||||||
|
uninstall_stage: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
packages.insert(app_id.clone(), package);
|
packages.insert(app_id.clone(), package);
|
||||||
|
|||||||
@ -57,6 +57,13 @@ impl Default for RegistryConfig {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 10,
|
priority: 10,
|
||||||
},
|
},
|
||||||
|
Registry {
|
||||||
|
url: "146.59.87.168:3000/lfg2025".to_string(),
|
||||||
|
name: "Server 3 (OVH)".to_string(),
|
||||||
|
tls_verify: false,
|
||||||
|
enabled: true,
|
||||||
|
priority: 20,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -80,42 +87,6 @@ impl RegistryConfig {
|
|||||||
format!("{}/{}", registry.url, image_name)
|
format!("{}/{}", registry.url, image_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate fallback image URLs to try (excludes the original since it already failed).
|
|
||||||
pub fn image_candidates(&self, image: &str) -> Vec<(String, bool)> {
|
|
||||||
let mut candidates = Vec::new();
|
|
||||||
|
|
||||||
// Rewrite for each active registry (skip if identical to original)
|
|
||||||
for reg in self.active_registries() {
|
|
||||||
let rewritten = self.rewrite_image(image, reg);
|
|
||||||
if rewritten != image {
|
|
||||||
candidates.push((rewritten, reg.tls_verify));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
candidates
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rewrite an image to use the highest-priority enabled registry, so
|
|
||||||
/// the FIRST pull attempt honors the operator's primary choice instead
|
|
||||||
/// of blindly using whatever registry the image URL was hardcoded to.
|
|
||||||
/// Returns (rewritten_url, tls_verify) — or the original URL + default
|
|
||||||
/// tls_verify=true if there's no primary (no enabled registries).
|
|
||||||
pub fn rewrite_for_primary(&self, image: &str) -> (String, bool) {
|
|
||||||
match self.active_registries().first() {
|
|
||||||
Some(primary) => (self.rewrite_image(image, primary), primary.tls_verify),
|
|
||||||
None => (image.to_string(), true),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load the registry config and rewrite an image to use the primary
|
|
||||||
/// registry's URL. Convenience wrapper for callers that don't already
|
|
||||||
/// have a `RegistryConfig` in hand.
|
|
||||||
pub async fn primary_image_url(data_dir: &Path, image: &str) -> (String, bool) {
|
|
||||||
match load_registries(data_dir).await {
|
|
||||||
Ok(config) => config.rewrite_for_primary(image),
|
|
||||||
Err(_) => (image.to_string(), true),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract the image name from a full image reference.
|
/// Extract the image name from a full image reference.
|
||||||
@ -155,80 +126,6 @@ pub async fn save_registries(data_dir: &Path, config: &RegistryConfig) -> Result
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try pulling an image from configured registries in priority order.
|
|
||||||
/// If `already_tried` is Some, that URL is skipped (avoids retrying the
|
|
||||||
/// primary when the caller already attempted it with progress streaming).
|
|
||||||
/// Returns the image reference that succeeded.
|
|
||||||
pub async fn pull_from_registries_with_skip(
|
|
||||||
data_dir: &Path,
|
|
||||||
image: &str,
|
|
||||||
tmpdir: &str,
|
|
||||||
already_tried: Option<&str>,
|
|
||||||
) -> Result<String> {
|
|
||||||
let config = load_registries(data_dir).await?;
|
|
||||||
let mut candidates = config.image_candidates(image);
|
|
||||||
if let Some(skip) = already_tried {
|
|
||||||
candidates.retain(|(url, _)| url != skip);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (candidate, tls_verify) in &candidates {
|
|
||||||
debug!("Trying registry: {}", candidate);
|
|
||||||
|
|
||||||
let mut args = vec!["pull".to_string(), candidate.clone()];
|
|
||||||
if !tls_verify {
|
|
||||||
args.push("--tls-verify=false".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut child = tokio::process::Command::new("podman")
|
|
||||||
.args(&args)
|
|
||||||
.env("TMPDIR", tmpdir)
|
|
||||||
.stdout(std::process::Stdio::null())
|
|
||||||
.stderr(std::process::Stdio::null())
|
|
||||||
.spawn()
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
let status = if let Some(ref mut c) = child {
|
|
||||||
match tokio::time::timeout(std::time::Duration::from_secs(120), c.wait()).await {
|
|
||||||
Ok(Ok(s)) => Some(s.success()),
|
|
||||||
_ => {
|
|
||||||
let _ = c.kill().await;
|
|
||||||
let _ = c.wait().await;
|
|
||||||
debug!("Fallback pull timed out: {}", candidate);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
if status == Some(true) {
|
|
||||||
// If we pulled from a non-original registry, tag it with the original name
|
|
||||||
if candidate != image {
|
|
||||||
let _ = tokio::process::Command::new("podman")
|
|
||||||
.args(["tag", candidate, image])
|
|
||||||
.status()
|
|
||||||
.await;
|
|
||||||
info!(
|
|
||||||
"Pulled {} from fallback registry, tagged as {}",
|
|
||||||
candidate, image
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
info!("Pulled {} from primary registry", image);
|
|
||||||
}
|
|
||||||
return Ok(candidate.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("Failed to pull from {}", candidate);
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(anyhow::anyhow!(
|
|
||||||
"Failed to pull {} from all {} configured registries",
|
|
||||||
image,
|
|
||||||
candidates.len()
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -259,39 +156,20 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_image_candidates() {
|
|
||||||
let config = RegistryConfig::default();
|
|
||||||
let candidates = config.image_candidates("git.tx1138.com/lfg2025/lnd:v0.18.4-beta");
|
|
||||||
// Defaults: VPS (primary) + tx1138. tx1138 is filtered out because
|
|
||||||
// it's identical to the original image URL, leaving one candidate.
|
|
||||||
assert_eq!(candidates.len(), 1);
|
|
||||||
// Primary-first — VPS rewrite leads the candidate list.
|
|
||||||
assert_eq!(candidates[0].0, "23.182.128.160:3000/lfg2025/lnd:v0.18.4-beta");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_rewrite_for_primary_uses_top_priority() {
|
|
||||||
let config = RegistryConfig::default();
|
|
||||||
let (url, tls) =
|
|
||||||
config.rewrite_for_primary("git.tx1138.com/lfg2025/lnd:v0.18.4-beta");
|
|
||||||
assert_eq!(url, "23.182.128.160:3000/lfg2025/lnd:v0.18.4-beta");
|
|
||||||
assert!(!tls, "VPS primary is HTTP — tls_verify should be false");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_active_registries_sorted() {
|
fn test_active_registries_sorted() {
|
||||||
let config = RegistryConfig::default();
|
let config = RegistryConfig::default();
|
||||||
let active = config.active_registries();
|
let active = config.active_registries();
|
||||||
assert_eq!(active.len(), 2);
|
assert_eq!(active.len(), 3);
|
||||||
assert!(active[0].priority <= active[1].priority);
|
assert!(active[0].priority <= active[1].priority);
|
||||||
|
assert!(active[1].priority <= active[2].priority);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_load_default() {
|
async fn test_load_default() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let config = load_registries(tmp.path()).await.unwrap();
|
let config = load_registries(tmp.path()).await.unwrap();
|
||||||
assert_eq!(config.registries.len(), 2);
|
assert_eq!(config.registries.len(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@ -307,6 +185,6 @@ mod tests {
|
|||||||
});
|
});
|
||||||
save_registries(tmp.path(), &config).await.unwrap();
|
save_registries(tmp.path(), &config).await.unwrap();
|
||||||
let loaded = load_registries(tmp.path()).await.unwrap();
|
let loaded = load_registries(tmp.path()).await.unwrap();
|
||||||
assert_eq!(loaded.registries.len(), 3);
|
assert_eq!(loaded.registries.len(), 4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -138,6 +138,13 @@ pub struct PackageDataEntry {
|
|||||||
pub installed: Option<InstalledPackageDataEntry>,
|
pub installed: Option<InstalledPackageDataEntry>,
|
||||||
#[serde(rename = "install-progress")]
|
#[serde(rename = "install-progress")]
|
||||||
pub install_progress: Option<InstallProgress>,
|
pub install_progress: Option<InstallProgress>,
|
||||||
|
/// Live label describing the current uninstall step ("Stopping
|
||||||
|
/// containers (2/5)", "Removing data", …). Set by the uninstall
|
||||||
|
/// pipeline so the UI can show real progress instead of a generic
|
||||||
|
/// "Uninstalling…" spinner. Cleared after the package entry is
|
||||||
|
/// removed.
|
||||||
|
#[serde(rename = "uninstall-stage", skip_serializing_if = "Option::is_none", default)]
|
||||||
|
pub uninstall_stage: Option<String>,
|
||||||
/// Pinned image version from image-versions.sh when it differs from running version
|
/// Pinned image version from image-versions.sh when it differs from running version
|
||||||
#[serde(rename = "available-update", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "available-update", skip_serializing_if = "Option::is_none")]
|
||||||
pub available_update: Option<String>,
|
pub available_update: Option<String>,
|
||||||
|
|||||||
@ -91,6 +91,8 @@ export interface PackageDataEntry {
|
|||||||
manifest: Manifest
|
manifest: Manifest
|
||||||
installed?: InstalledPackageDataEntry
|
installed?: InstalledPackageDataEntry
|
||||||
'install-progress'?: InstallProgress
|
'install-progress'?: InstallProgress
|
||||||
|
/** Live label for the current uninstall step ("Stopping containers (2/5)", …). */
|
||||||
|
'uninstall-stage'?: string | null
|
||||||
'available-update'?: string | null
|
'available-update'?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -282,6 +282,9 @@ function closeUninstallModal() {
|
|||||||
|
|
||||||
async function onConfirmUninstall() {
|
async function onConfirmUninstall() {
|
||||||
const { appId } = uninstallModal.value
|
const { appId } = uninstallModal.value
|
||||||
|
// Close the modal immediately so the user can fire off concurrent
|
||||||
|
// uninstalls. Each AppCard surfaces its own live stage label while
|
||||||
|
// its uninstall is in flight.
|
||||||
uninstallModal.value.show = false
|
uninstallModal.value.show = false
|
||||||
await actions.confirmUninstall(appId)
|
await actions.confirmUninstall(appId)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -99,14 +99,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Uninstalling progress — replaces action buttons -->
|
<!-- Uninstalling progress — live stage label from backend -->
|
||||||
<div v-else-if="isUninstalling" class="mt-4">
|
<div v-else-if="isUninstalling" class="mt-4">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<svg class="animate-spin h-3 w-3 text-red-400" fill="none" viewBox="0 0 24 24">
|
<svg class="animate-spin h-3 w-3 text-red-400" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-xs text-red-300">{{ t('common.uninstalling') }}...</span>
|
<span class="text-xs text-red-300 truncate">{{ uninstallStageLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1.5 w-full h-1.5 bg-white/10 rounded-full overflow-hidden">
|
<div class="mt-1.5 w-full h-1.5 bg-white/10 rounded-full overflow-hidden">
|
||||||
<div class="h-full bg-red-400/60 rounded-full animate-pulse w-full"></div>
|
<div class="h-full bg-red-400/60 rounded-full animate-pulse w-full"></div>
|
||||||
@ -251,6 +251,13 @@ const tier = computed(() => {
|
|||||||
return 'optional'
|
return 'optional'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Live uninstall stage from backend, with a sensible fallback so the
|
||||||
|
// label is never blank between WS pushes.
|
||||||
|
const uninstallStageLabel = computed(() => {
|
||||||
|
const raw = props.pkg['uninstall-stage']
|
||||||
|
return raw ? raw : `${t('common.uninstalling')}…`
|
||||||
|
})
|
||||||
|
|
||||||
const isTransitioning = computed(() => {
|
const isTransitioning = computed(() => {
|
||||||
const s = props.pkg.state
|
const s = props.pkg.state
|
||||||
const h = props.pkg.health
|
const h = props.pkg.health
|
||||||
|
|||||||
@ -96,20 +96,32 @@
|
|||||||
Checking...
|
Checking...
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<!-- Installing — simple button-style indicator -->
|
<!-- Installing — live progress with bar + message matching the
|
||||||
<button
|
update download bar's accuracy. Falls back to a simple
|
||||||
|
spinner if no install_progress data is available yet. -->
|
||||||
|
<div
|
||||||
v-else-if="!installed && installing"
|
v-else-if="!installed && installing"
|
||||||
disabled
|
class="flex-1 flex flex-col gap-1.5"
|
||||||
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium opacity-80 cursor-wait"
|
|
||||||
>
|
>
|
||||||
<span class="flex items-center justify-center gap-2">
|
<div
|
||||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium opacity-90 cursor-wait flex items-center justify-center gap-2 text-center"
|
||||||
|
>
|
||||||
|
<svg class="animate-spin h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Installing
|
<span class="truncate">{{ installProgressMessage }}</span>
|
||||||
</span>
|
</div>
|
||||||
</button>
|
<div
|
||||||
|
v-if="(installProgress?.progress ?? 0) > 0"
|
||||||
|
class="w-full h-1 bg-white/10 rounded-full overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full bg-orange-400 transition-all duration-300"
|
||||||
|
:style="{ width: (installProgress?.progress ?? 0) + '%' }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
v-else-if="!installed && (app.source === 'local' || app.dockerImage)"
|
v-else-if="!installed && (app.source === 'local' || app.dockerImage)"
|
||||||
data-controller-install-btn
|
data-controller-install-btn
|
||||||
@ -130,6 +142,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { MarketplaceApp, InstallProgress } from './marketplaceData'
|
import type { MarketplaceApp, InstallProgress } from './marketplaceData'
|
||||||
|
|
||||||
@ -154,6 +167,14 @@ defineEmits<{
|
|||||||
launch: [app: MarketplaceApp]
|
launch: [app: MarketplaceApp]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const installProgressMessage = computed(() => {
|
||||||
|
const p = props.installProgress
|
||||||
|
if (!p) return 'Installing'
|
||||||
|
// The store already formats messages like "Downloading: 50.5 / 200.0 MB (25%)"
|
||||||
|
// so we just surface them directly.
|
||||||
|
return p.message || 'Installing'
|
||||||
|
})
|
||||||
|
|
||||||
function handleImageError(event: Event) {
|
function handleImageError(event: Event) {
|
||||||
const img = event.target as HTMLImageElement
|
const img = event.target as HTMLImageElement
|
||||||
img.src = '/assets/img/logo-archipelago.svg'
|
img.src = '/assets/img/logo-archipelago.svg'
|
||||||
|
|||||||
@ -180,6 +180,18 @@ init()
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||||
|
<!-- v1.7.30-alpha -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.30-alpha</span>
|
||||||
|
<span class="text-xs text-white/40">Apr 21, 2026</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||||
|
<p>App installs now show a real download progress bar — same accuracy as the system update bar. You'll see "Downloading: 50.5 / 200.0 MB (25%)" with a live percentage instead of a generic spinner. The bar keeps streaming even when the install falls back from one registry to another, so you'll never see a "stuck at 0%" again.</p>
|
||||||
|
<p>Uninstalls now show what's actually happening: "Stopping containers (2/5)", "Cleaning up volumes", "Removing app data" — labelled per app so you can fire off multiple uninstalls in parallel and watch each one's stage on its own card.</p>
|
||||||
|
<p>OVH (146.59.87.168) is now baked in as Server 3 by default for both updates and the app registry — extra mirror, completely independent network path so a single-provider outage can't take everything down.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- v1.7.29-alpha -->
|
<!-- v1.7.29-alpha -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user