diff --git a/apps/bitcoin-core/Dockerfile b/apps/bitcoin-core/Dockerfile index 883f977f..04df9fdf 100644 --- a/apps/bitcoin-core/Dockerfile +++ b/apps/bitcoin-core/Dockerfile @@ -22,7 +22,12 @@ RUN set -eux; \ COPY bin/bitcoind /usr/local/bin/bitcoind COPY bin/bitcoin-cli /usr/local/bin/bitcoin-cli RUN chmod 0755 /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli -USER bitcoin +# Run as (container) root, like the legacy hand-built :latest image. Rootless +# Podman maps container-root to the unprivileged host service user; the manifest +# grants CAP_DAC_OVERRIDE so bitcoind can read its data dir, which the +# orchestrator chowns to the data_uid (host 100101 / container uid 102), not to +# this image's `bitcoin` user. A non-root USER can't read existing chain data and +# bitcoind crash-loops with "Error initializing block database". WORKDIR /home/bitcoin VOLUME ["/home/bitcoin/.bitcoin"] EXPOSE 8332 8333 diff --git a/apps/bitcoin-knots/Dockerfile b/apps/bitcoin-knots/Dockerfile index 5f2afb4d..74025b9c 100644 --- a/apps/bitcoin-knots/Dockerfile +++ b/apps/bitcoin-knots/Dockerfile @@ -23,7 +23,12 @@ RUN set -eux; \ COPY bin/bitcoind /usr/local/bin/bitcoind COPY bin/bitcoin-cli /usr/local/bin/bitcoin-cli RUN chmod 0755 /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli -USER bitcoin +# Run as (container) root, like the legacy hand-built :latest image. Rootless +# Podman maps container-root to the unprivileged host service user; the manifest +# grants CAP_DAC_OVERRIDE so bitcoind can read its data dir, which the +# orchestrator chowns to the data_uid (host 100101 / container uid 102), not to +# this image's `bitcoin` user. A non-root USER can't read existing chain data and +# bitcoind crash-loops with "Error initializing block database". WORKDIR /home/bitcoin VOLUME ["/home/bitcoin/.bitcoin"] EXPOSE 8332 8333 diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index cd4fe297..e21fd021 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -929,6 +929,51 @@ pub struct AdoptionReport { pub adopted: Vec, } +/// Decouple the app image from the shipped manifest: prefer the remote app +/// catalog when it covers this app with a same-repo image, and let a +/// runner-pinned version win over the catalog default so "install/switch to +/// the version I chose" is honored across every install + recreate. Falls +/// through to the catalog default when unpinned or the pin can't be resolved +/// (unknown/repo-mismatch), and to the manifest image when the catalog is +/// absent/uncovered. +/// +/// Applied in-place to a cloned manifest. MUST run anywhere a quadlet is +/// rendered — both install_fresh (pull + create) and sync_quadlet_unit (the +/// reconciler's drift re-render); otherwise the reconciler rewrites the unit +/// back to the manifest's shipped `:latest` tag and silently reverts a pinned +/// version on the next tick. +fn resolve_catalog_image(resolved_manifest: &mut AppManifest) { + let Some(current) = resolved_manifest.app.container.image.clone() else { + return; + }; + let app_id = resolved_manifest.app.id.clone(); + let resolved_image = crate::container::version_config::pinned_version(&app_id) + .and_then(|v| { + crate::container::app_catalog::catalog_image_for_version(&app_id, &v, ¤t).or_else( + || { + tracing::warn!( + app_id = %app_id, + pinned = %v, + "version_config: pinned version not resolvable in catalog — using default" + ); + None + }, + ) + }) + .or_else(|| crate::container::app_catalog::catalog_image_override(&app_id, ¤t)); + if let Some(catalog_image) = resolved_image { + if catalog_image != current { + tracing::info!( + app_id = %app_id, + from = %current, + to = %catalog_image, + "app-catalog: overriding manifest image" + ); + resolved_manifest.app.container.image = Some(catalog_image); + } + } +} + /// Internal: track a manifest together with the absolute directory it was loaded /// from, so Build sources can resolve relative `context:` paths. #[derive(Debug, Clone)] @@ -1809,45 +1854,7 @@ impl ProdContainerOrchestrator { self.ensure_app_secrets(&lm.manifest.app.id).await?; let mut resolved_manifest = lm.manifest.clone(); self.resolve_dynamic_env(&mut resolved_manifest)?; - - // Decouple the app image from the shipped manifest: prefer the remote - // app catalog when it covers this app with a same-repo image. This makes - // both the pull below and create_container() below use the catalog tag, - // so an app update no longer requires a binary/runtime release. Falls - // back to the manifest image when the catalog is absent/uncovered. - if let Some(current) = resolved_manifest.app.container.image.clone() { - let app_id = resolved_manifest.app.id.clone(); - // Multi-version support: a runner-pinned version wins over the catalog - // default so "install/switch to the version I chose" is honored across - // every install + recreate. Falls through to the catalog default when - // unpinned or the pin can't be resolved (unknown/repo-mismatch). - let resolved_image = crate::container::version_config::pinned_version(&app_id) - .and_then(|v| { - crate::container::app_catalog::catalog_image_for_version(&app_id, &v, ¤t) - .or_else(|| { - tracing::warn!( - app_id = %app_id, - pinned = %v, - "version_config: pinned version not resolvable in catalog — using default" - ); - None - }) - }) - .or_else(|| { - crate::container::app_catalog::catalog_image_override(&app_id, ¤t) - }); - if let Some(catalog_image) = resolved_image { - if catalog_image != current { - tracing::info!( - app_id = %app_id, - from = %current, - to = %catalog_image, - "app-catalog: overriding manifest image" - ); - resolved_manifest.app.container.image = Some(catalog_image); - } - } - } + resolve_catalog_image(&mut resolved_manifest); let resolved = resolved_manifest.app.container.resolve().ok_or_else(|| { anyhow::anyhow!( @@ -2206,6 +2213,10 @@ impl ProdContainerOrchestrator { let mut resolved = lm.manifest.clone(); self.resolve_dynamic_env(&mut resolved)?; + // Same catalog/pinned-version image resolution the installer applies, so + // the drift re-render doesn't revert a pinned version back to the + // manifest's shipped `:latest` tag on the next reconcile tick. + resolve_catalog_image(&mut resolved); let unit = quadlet::QuadletUnit::from_manifest(&resolved, name); let new_body = unit.render(); let restart_for_port_change = quadlet::publish_ports_changed(&old_body, &new_body); diff --git a/core/archipelago/src/container/quadlet.rs b/core/archipelago/src/container/quadlet.rs index 9250e841..25747558 100644 --- a/core/archipelago/src/container/quadlet.rs +++ b/core/archipelago/src/container/quadlet.rs @@ -268,14 +268,21 @@ impl QuadletUnit { let _ = writeln!(s, "HealthTimeout={}", h.timeout); let _ = writeln!(s, "HealthRetries={}", h.retries); } - if let Some(ep) = &self.entrypoint { - // Quadlet's Exec= replaces the image entrypoint+cmd. When - // the manifest provides both entrypoint and command we - // concatenate; if only command is set we'll emit that on - // its own below. - let mut parts: Vec = ep.clone(); + if let Some((first, rest)) = self.entrypoint.as_deref().and_then(<[String]>::split_first) { + // Quadlet's Exec= sets only the command (the args passed to the + // image's ENTRYPOINT) — it does NOT replace the entrypoint. So a + // manifest entrypoint like `sh -lc` must be emitted as a real + // Entrypoint= override; otherwise it gets appended to whatever + // ENTRYPOINT the image baked in (e.g. the versioned bitcoind + // images use `ENTRYPOINT ["bitcoind"]`, which turned the wrapper + // into `bitcoind sh -lc ...` and crash-looped). Emitting + // Entrypoint= makes the unit independent of the image's entrypoint. + let _ = writeln!(s, "Entrypoint={first}"); + let mut parts: Vec = rest.to_vec(); parts.extend(self.command.iter().cloned()); - let _ = writeln!(s, "Exec={}", shell_join(&parts)); + if !parts.is_empty() { + let _ = writeln!(s, "Exec={}", shell_join(&parts)); + } } else if !self.command.is_empty() { let _ = writeln!(s, "Exec={}", shell_join(&self.command)); } @@ -769,9 +776,11 @@ pub fn network_aliases_changed(old_body: &str, new_body: &str) -> bool { } pub fn exec_changed(old_body: &str, new_body: &str) -> bool { - let old_exec = directive_values(old_body, "Exec="); - let new_exec = directive_values(new_body, "Exec="); - old_exec != new_exec + // Entrypoint= and Exec= together define what the container runs, so a drift + // in either must recreate the container (e.g. when this renderer first + // splits a folded `Exec=sh -lc ...` into `Entrypoint=sh` + `Exec=-lc ...`). + directive_values(old_body, "Exec=") != directive_values(new_body, "Exec=") + || directive_values(old_body, "Entrypoint=") != directive_values(new_body, "Entrypoint=") } fn directive_values(unit_body: &str, prefix: &str) -> Vec { @@ -1063,7 +1072,10 @@ mod tests { assert!(s.contains("ReadOnly=true")); assert!(s.contains("NoNewPrivileges=true")); assert!(s.contains("PodmanArgs=--cpus=2")); - assert!(s.contains("Exec=/usr/local/bin/bitcoind -server=1 -rpcbind=0.0.0.0")); + // Manifest entrypoint becomes a real Entrypoint= override (not folded + // into Exec=), so the unit doesn't depend on the image's own ENTRYPOINT. + assert!(s.contains("Entrypoint=/usr/local/bin/bitcoind")); + assert!(s.contains("Exec=-server=1 -rpcbind=0.0.0.0")); assert!(s.contains("Restart=on-failure")); assert!(s.contains("Network=archy-net")); } diff --git a/docs/PRODUCTION-MASTER-PLAN.md b/docs/PRODUCTION-MASTER-PLAN.md index 4f81e4e1..6956271a 100644 --- a/docs/PRODUCTION-MASTER-PLAN.md +++ b/docs/PRODUCTION-MASTER-PLAN.md @@ -135,6 +135,16 @@ After the 2026-06-23 multinode test deploy (latest backend + UX frontend to .116 3. **§6c Lifecycle perfection** (workstream F) — the comprehensive uninstall/reinstall + progress-UI + all-apps gate expansion below. +## 6b-bis. Bitcoin multi-version bulletproofing (2026-06-29) — READY TO MERGE + DEPLOY + +Branch `bitcoin-version-bulletproof` (base `095a76cd`). Fixes the "switch version silently +fails / crash-loops" class + a data-access mismatch that can corrupt a node's index. All +code + images + catalog + frontend DONE; **.228** carries it (Knots chainstate mid-reindex +recovery). The **coordinated fleet rollout** (OTA binary+frontend, mirror catalog publish, +`:latest` repoint sequencing, full switch-matrix test) is the remaining work — fold it into +the next release. **Authoritative detail + exact remaining steps + test matrix → +`docs/bitcoin-version-bulletproof-rollout.md`.** Pairs with `docs/bitcoin-multi-version-design.md`. + ## 6c. Lifecycle perfection — what "green" MISSED (workstream F, the perfection bar) **Why this exists:** the 2026-06-23 single-node gate went 5×-green but is **NOT** the diff --git a/docs/bitcoin-version-bulletproof-rollout.md b/docs/bitcoin-version-bulletproof-rollout.md new file mode 100644 index 00000000..f8a6773f --- /dev/null +++ b/docs/bitcoin-version-bulletproof-rollout.md @@ -0,0 +1,131 @@ +# Bitcoin Multi-Version — Bulletproofing & Rollout (handoff) + +> **Status 2026-06-29:** code + images + catalog + frontend DONE on branch +> `bitcoin-version-bulletproof` (base commit `095a76cd`, plus the catalog-generator +> + handoff follow-ups). **.228 is the test node**: binary + frontend + catalog are +> live there; its Knots chainstate is mid-**reindex recovery** (see §5). The fleet +> rollout (OTA binary+frontend, mirror catalog publish, `:latest` repoint) is the +> **coordinated step the other agent owns** — see §4. Pairs with +> `docs/bitcoin-multi-version-design.md` (the original design). + +## 1. What was broken (root causes) + +User report: "switched Knots to `v29.3.knots20260508`, version didn't update in the UI." +Three **stacked** bugs, plus a data-corruption hazard: + +1. **Reconciler reverted the pin.** `prod_orchestrator::sync_quadlet_unit` re-rendered the + quadlet every reconcile tick using the manifest's `:latest`, ignoring the per-app + pinned version → any switch silently reverted within one tick. +2. **Entrypoint render bug.** The renderer folded the manifest `entrypoint: ["sh","-lc"]` + into `Exec=`. That only works when the image ENTRYPOINT is a passthrough shell wrapper. + The versioned images use `ENTRYPOINT ["bitcoind"]`, so `Exec=sh -lc …` became + `bitcoind sh -lc …` → `unexpected token 'sh'` → crash loop. +3. **Image USER divergence.** The versioned images were built `USER bitcoin` (uid 1000); + the legacy `:latest` ran as **root**. Chain data is owned by the `data_uid` + (host 100101 / container uid 102). Root reads it via `CAP_DAC_OVERRIDE` (granted in the + manifest); uid-1000 cannot → `Error initializing block database`. +4. **Data hazard (already hit on .228).** Repeated failed starts under mixed UIDs left + bitcoind's two LevelDBs (`blocks/index/` + `chainstate/`) truncated to KB stubs while + the raw `blocks/blk*.dat` (797 GB) stayed intact. Recovery = `bitcoind -reindex` from + local blocks (no re-download). The uniform-root image fix (below) removes the mixed-UID + cause going forward; the proper switch flow was already data-safe (600s stop grace, + clean stop→rm→recreate, conflict-stops the other impl — they share port 8332 + datadir + `/var/lib/archipelago/bitcoin`). + +## 2. What was fixed (all on the branch) + +- **Renderer** (`core/archipelago/src/container/`): + - `prod_orchestrator.rs`: factored `resolve_catalog_image()` (catalog/pinned-version → + image) and call it in BOTH `install_fresh` and `sync_quadlet_unit` — the pin now + survives reconcile. + - `quadlet.rs`: emit a real `Entrypoint=` + `Exec=` instead of folding; + `exec_changed` now also diffs `Entrypoint=` so the recreate fires. Validated against + the live podman 5.4.2 quadlet generator. +- **Images** (`scripts/build-bitcoin-image.sh`, `apps/bitcoin-{knots,core}/Dockerfile`): + removed `USER bitcoin` → run as **container-root** like legacy (still 100% rootless: + container-root maps to the unprivileged host service user; `CAP_DAC_OVERRIDE` from the + manifest lets bitcoind read the `data_uid`-owned datadir). **All** images rebuilt root + + pushed to the mirror (`146.59.87.168:3000/lfg2025`): + - Knots: `29.3.knots20260508`, `29.3.knots20260507`, `29.3.knots20260210`, `29.2.knots20251110` + - Core: `25.2 26.2 27.2 28.4 29.2 29.3 30.2 31.0` + `latest` (→31.0) +- **Catalog** (`scripts/generate-app-catalog.sh` VERSIONS map + regenerated + `releases/app-catalog.json`): Knots & Core `versions[]` populated; the generator now + forces top-level `version` == the `default` entry's version (the `169ff2e2` invariant) + regardless of the manifest version. Knots `latest` entry points at the newest **dated** + image (`29.3.knots20260508`) so "Always use latest" = newest on fixed-binary nodes. +- **Frontend** (`neode-ui/`): + - `AppSidebar.vue`: rename the latest option to **"Always use the latest version"** + (no `v` prefix), fix right padding, and `pickSelection()` guarantees the bound value is + a real option (fixes the blank dropdown). + - New `components/InstallVersionModal.vue`: full-screen version chooser shown from the + App Store / Discover **card** install button for multi-version apps — app icon + + "Install ", latest pre-selected. Wired in `Discover.vue handleInstall`. + - i18n keys: `appDetails.alwaysUseLatestVersion`, `marketplace.installModalTitle/Hint`. + +## 3. Current live state on .228 (test node) + +- Binary with both renderer fixes: **deployed** (`/usr/local/bin/archipelago`). +- New frontend bundle: **deployed** to `/opt/archipelago/web-ui` (hard-refresh to see it). +- Updated catalog: placed at `/var/lib/archipelago/app-catalog.json` (local override — + will refresh from the mirror's OLDER copy at the next hourly fetch until §4 publishes it). +- Knots: `bitcoin-knots` service held **stopped** (`package.stop`, user_stopped); + a detached `bitcoin-knots-reindex` container is rebuilding the index+UTXO (§5). + +## 4. Remaining — coordinated fleet rollout (OTHER AGENT) + +Do this together with the other workstream's release, AFTER both are ready: + +1. **Merge** branch `bitcoin-version-bulletproof` into the release line. +2. **Build + OTA** the binary + frontend (these carry the renderer fix + UI). The renderer + fix is a **hard prerequisite** for the new images everywhere — see fleet-safety below. +3. **Publish the catalog** to the mirror (push `releases/app-catalog.json` to gitea-vps2 + `main`, the raw URL nodes fetch hourly). The current catalog is **fleet-safe even before + the binary lands**: unpinned/auto-update nodes resolve via the manifest's floating + `:latest` (still the legacy image); only explicit version selection (needs the new UI) + uses the new root images. +4. **Only AFTER the binary is fleet-wide:** optionally repoint the `bitcoin-knots:latest` + tag → `29.3.knots20260508` (root) and simplify the catalog `latest` entry back to the + `:latest` tag. **Do NOT repoint `:latest` before then** — old-binary nodes fold + `Exec=sh -lc …` and would crash on an `ENTRYPOINT ["bitcoind"]` image. (Core never + worked on old binaries — it always shipped `ENTRYPOINT ["bitcoind"]` — so Core has no + such constraint.) +5. **Verify the full switch matrix** on a healthy node (§6). + +## 5. Finishing .228's reindex (OTHER AGENT owns this — not babysat by the original author) + +The detached `bitcoin-knots-reindex` container runs the new **root** `29.3.knots20260508` +image with `-reindex -server=0` against `/var/lib/archipelago/bitcoin`. It holds the datadir +lock, so the managed service (held stopped) can't collide. When it has connected blocks up +to ~the prior tip (height ≥ ~955800) it's done; then: + +```sh +# on .228 (SSH/sudo/UI pw all: ThisIsWeb54321@) +podman stop -t 600 bitcoin-knots-reindex && podman rm bitcoin-knots-reindex +# start the managed service via RPC (sets desired=running, clears user_stopped): +# package.start {id: bitcoin-knots} (POST https://127.0.0.1/rpc/v1, CSRF: echo csrf_token cookie as X-CSRF-Token) +# verify: +podman exec bitcoin-knots sh -lc '$(command -v bitcoind) --version | head -1' # → v29.3.knots20260508 +# RPC up → the Bitcoin UI populates; it syncs the gap to tip. +``` +The "Bitcoin RPC connection refused (127.0.0.1:8332)" the UI shows is EXPECTED until this +swap (reindex runs with RPC off). + +## 6. Switch-matrix test plan (what "bulletproof" must prove) + +On a healthy node, each step must end with bitcoind running + RPC answering + syncing, with +NO `Error initializing block database` and NO data loss: +- Knots: switch `latest` → `29.3.knots20260507` → `29.3.knots20260210` → back to `latest`. +- Core: install `latest`; switch `31.0` → `28.4.0`. +- **Knots ↔ Core** (shared datadir/port): Knots→Core upgrade path (Core ≥ data version) and + the reverse. **Cross-major DOWNGRADES** (e.g. 29.x data → Core 28.4) legitimately need a + reindex — the UI already surfaces a downgrade warning; confirm it does and that confirming + reindexes cleanly rather than crash-looping. +- Reboot survival after each switch. + +## 7. Notes / assumptions + +- **"29.2"** in the request doesn't exist as a Knots build (404 upstream); added as **Bitcoin + Core 29.2** (exists). Revisit if a Knots 29.2 was meant. +- Reindex is unavoidable ONLY because .228's index was already corrupted by the pre-fix + crash loop; a normal switch on the fixed binary does NOT reindex. +- Creds for .228: SSH/sudo + UI/RPC all `ThisIsWeb54321@`. diff --git a/neode-ui/src/components/InstallVersionModal.vue b/neode-ui/src/components/InstallVersionModal.vue new file mode 100644 index 00000000..47edbc73 --- /dev/null +++ b/neode-ui/src/components/InstallVersionModal.vue @@ -0,0 +1,120 @@ + + + diff --git a/neode-ui/src/locales/en.json b/neode-ui/src/locales/en.json index c8aa340a..c618d628 100644 --- a/neode-ui/src/locales/en.json +++ b/neode-ui/src/locales/en.json @@ -330,6 +330,8 @@ "marketplace": { "title": "App Store", "subtitle": "Discover and install apps for your new sovereign life", + "installModalTitle": "Install {name}", + "installModalHint": "Choose a version to install. The latest is recommended.", "curatedTab": "Curated", "communityTab": "Community", "nostrCommunityTab": "Nostr Community", @@ -572,6 +574,7 @@ "versionUpdates": "Version & Updates", "runningVersion": "Running version", "selectVersion": "Version", + "alwaysUseLatestVersion": "Always use the latest version", "autoUpdateLatest": "Auto-update to latest", "autoUpdatePinnedNote": "Auto-update is disabled while a version is pinned.", "confirmDowngrade": "Downgrade anyway", diff --git a/neode-ui/src/views/Discover.vue b/neode-ui/src/views/Discover.vue index 25dcbe84..7361d00c 100644 --- a/neode-ui/src/views/Discover.vue +++ b/neode-ui/src/views/Discover.vue @@ -207,6 +207,15 @@

// Cypherpunks write code. We run nodes.

+ + + @@ -228,6 +237,7 @@ import { APP_STORE_SECTIONS } from './appStoreCategories' import DiscoverHero from './discover/DiscoverHero.vue' import FeaturedApps from './discover/FeaturedApps.vue' import AppGrid from './discover/AppGrid.vue' +import InstallVersionModal from '@/components/InstallVersionModal.vue' import type { MarketplaceApp, FeaturedApp } from './discover/types' import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured } from './discover/curatedApps' @@ -262,6 +272,10 @@ const appStoreSections = computed(() => APP_STORE_SECTIONS) // been removed in favour of the store's phase-aware mapping. const installingApps = serverStore.installingApps +// First-install version-choice modal (multi-version apps: Bitcoin Knots / Core) +const showInstallModal = ref(false) +const installModalApp = ref(null) + function navigateToMarketplace(categoryId: string) { router.push({ name: 'marketplace', query: { category: categoryId } }) } @@ -427,19 +441,42 @@ function launchInstalledApp(app: MarketplaceApp) { appLauncher.openSession(app.id) } -function handleInstall(app: MarketplaceApp) { +async function handleInstall(app: MarketplaceApp) { const blocked = installBlockedReason(app.id) if (blocked) { toast.error(blocked) return } + if (installingApps.has(app.id) || isInstalled(app.id)) return + // Multi-version apps (Bitcoin Knots / Core): let the runner pick a version up + // front via a full-screen modal (latest pre-selected) instead of silently + // installing the default. Best-effort — if the lookup fails we install directly. + try { + const info = await rpcClient.getPackageVersions(app.id) + if (info.supportsVersions && info.versions.length > 1) { + installModalApp.value = app + showInstallModal.value = true + return + } + } catch { /* no catalog versions — fall through to direct install */ } + startInstall(app) +} + +function startInstall(app: MarketplaceApp, versionOverride?: string) { if (app.source === 'local') { - installApp(app) + installApp(app, versionOverride) } else { - installCommunityApp(app) + installCommunityApp(app, versionOverride) } } +function onInstallModalConfirm(version: string) { + const app = installModalApp.value + showInstallModal.value = false + installModalApp.value = null + if (app) startInstall(app, version) +} + function viewAppDetails(app: MarketplaceApp) { try { if (isInstalled(app.id)) { @@ -514,27 +551,27 @@ function failInstall(app: MarketplaceApp, err: unknown) { trackTimeout(() => { serverStore.clearInstallProgress(app.id) }, 5000) } -async function installApp(app: MarketplaceApp) { +async function installApp(app: MarketplaceApp, versionOverride?: string) { if (installingApps.has(app.id) || isInstalled(app.id)) return queueInstall(app) toast.info("Installing " + (app.title ?? app.id) + " - check My Apps") router.push('/dashboard/apps').catch(() => {}) try { const installUrl = app.url || app.manifestUrl || app.s9pkUrl - await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version }, timeout: 600000 }) + await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: versionOverride || app.version }, timeout: 600000 }) } catch (err) { if (import.meta.env.DEV) console.error('Installation failed:', err) failInstall(app, err) } } -async function installCommunityApp(app: MarketplaceApp) { +async function installCommunityApp(app: MarketplaceApp, versionOverride?: string) { if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return queueInstall(app) toast.info("Installing " + (app.title ?? app.id) + " - check My Apps") router.push('/dashboard/apps').catch(() => {}) try { - const installParams: Record = { id: app.id, dockerImage: app.dockerImage, version: app.version } + const installParams: Record = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version } if ((app as Record).containerConfig) { installParams.containerConfig = (app as Record).containerConfig } diff --git a/neode-ui/src/views/appDetails/AppSidebar.vue b/neode-ui/src/views/appDetails/AppSidebar.vue index 39dfb4ab..03eb2362 100644 --- a/neode-ui/src/views/appDetails/AppSidebar.vue +++ b/neode-ui/src/views/appDetails/AppSidebar.vue @@ -48,11 +48,9 @@ @@ -252,7 +250,8 @@ import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import type { AppCredentialsResponse } from '@/types/api' -import { rpcClient, type PackageVersionsResponse } from '../../api/rpc-client' +import { rpcClient, type PackageVersionsResponse, type CatalogVersionInfo } from '../../api/rpc-client' +import { displayVersion } from '@/utils/version' const { t } = useI18n() const copiedCredential = ref('') @@ -334,10 +333,33 @@ const isPinned = computed(() => !!versionInfo.value?.pinnedVersion) const versionDirty = computed(() => { const info = versionInfo.value if (!info) return false - const currentSelection = info.pinnedVersion || info.default || info.installedVersion || '' - return selectedVersion.value !== currentSelection || autoUpdate.value !== info.autoUpdate + return selectedVersion.value !== pickSelection(info) || autoUpdate.value !== info.autoUpdate }) +// Option label: the floating "latest" entry reads as a sentence (no "v" +// prefix); every concrete version is normalized via $ver + status suffixes. +function versionOptionLabel(v: CatalogVersionInfo): string { + if (v.version === 'latest') return t('appDetails.alwaysUseLatestVersion') + let label = displayVersion(v.version) + if (v.deprecated) label += ' (deprecated)' + if (v.eol) label += ` · EOL ${v.eol}` + return label +} + +// Resolve the dropdown's current value and GUARANTEE it matches a real option, +// otherwise the native