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:
Dorian
2026-04-21 19:11:36 -04:00
co-authored by Claude Opus 4.7
parent 1709149ebd
commit 18f0929614
13 changed files with 221 additions and 209 deletions
@@ -247,6 +247,7 @@ impl DockerPackageScanner {
status: service_status,
}),
install_progress: None,
uninstall_stage: None,
};
packages.insert(app_id.clone(), package);
+11 -133
View File
@@ -57,6 +57,13 @@ impl Default for RegistryConfig {
enabled: true,
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)
}
/// 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.
@@ -155,80 +126,6 @@ pub async fn save_registries(data_dir: &Path, config: &RegistryConfig) -> Result
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)]
mod tests {
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]
fn test_active_registries_sorted() {
let config = RegistryConfig::default();
let active = config.active_registries();
assert_eq!(active.len(), 2);
assert_eq!(active.len(), 3);
assert!(active[0].priority <= active[1].priority);
assert!(active[1].priority <= active[2].priority);
}
#[tokio::test]
async fn test_load_default() {
let tmp = TempDir::new().unwrap();
let config = load_registries(tmp.path()).await.unwrap();
assert_eq!(config.registries.len(), 2);
assert_eq!(config.registries.len(), 3);
}
#[tokio::test]
@@ -307,6 +185,6 @@ mod tests {
});
save_registries(tmp.path(), &config).await.unwrap();
let loaded = load_registries(tmp.path()).await.unwrap();
assert_eq!(loaded.registries.len(), 3);
assert_eq!(loaded.registries.len(), 4);
}
}