Compare commits

...
Author SHA1 Message Date
DorianandClaude Opus 4.7 06feb85aa5 release(v1.7.34-alpha): re-seed onboarding cache + rotating login bg + drop re-login zoom
- useOnboarding.ts: when the backend gives a definitive answer
  (true/false, not a null retry failure), re-seed the
  neode_onboarding_complete localStorage flag accordingly. Fixes the
  case where a user clears site data on an already-onboarded node —
  OnboardingWrapper's useVideoBackground computed reads localStorage
  synchronously, so without this re-seed the intro video would fire
  again on /login even though RootRedirect correctly sent them
  straight to /login.
- OnboardingWrapper.vue: login background now rotates through
  bg-intro-1..6 on each /login mount, with the current index
  persisted to localStorage (neode_login_bg_idx) so subsequent
  logouts advance rather than repeat the same image.
- Dashboard.vue: subsequent-login branch drops the 1.2s showZoomIn
  entirely. Only the first dashboard entry after onboarding plays
  the full zoom + glitch reveal; every re-login now just fades in
  with the welcome typing (~300ms).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 05:42:52 -04:00
DorianandClaude Opus 4.7 aa0677be57 release(v1.7.33-alpha): onboarding/login UX fixes + PWA cache bust
- useOnboarding.ts: prefer the backend over localStorage when checking
  onboarding completion. The old order (localStorage first) meant any
  browser that had ever onboarded a node would treat every new fresh
  node as already-onboarded and skip the wizard, dumping the user
  straight at the inline set-password form. Backend is now authoritative;
  localStorage stays as the offline fallback.
- OnboardingWrapper.vue: skip the intro video on `/login` once
  `neode_onboarding_complete` is set. Returning logged-out users now
  get the static lock-screen background + glitch overlay instead of
  replaying the full intro on every logout.
- RootRedirect.vue: when the health check fails, only show the full
  BootScreen if the node was never onboarded. For already-onboarded
  nodes (i.e. an OTA-update blip), keep the spinner and poll the
  health endpoint every 2s for up to 60s before falling back to the
  boot screen. Fixes the "fake boot loader" / "server starting up"
  screens flashing on every successful update.
- loginTransition store: new `justCompletedOnboarding` flag distinct
  from `justLoggedIn`. Set true only by the inline setup-password
  flow (handleSetup). Dashboard.vue branches on it: full glitch+zoom
  reveal for the post-onboarding entry, quick zoom + welcome typing
  on every other login (no triple glitch flashes, ~1.2s vs 8s).
- vite.config.ts: bump assets cache from `assets-cache-v2` to
  `assets-cache-v3` so service workers running the previous bundle
  invalidate their cache and pick up the new UI cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 04:45:33 -04:00
DorianandClaude Opus 4.7 974fce5870 release(v1.7.32-alpha): fix frontend tarball layout + mDNS shutdown hang
- HOTFIX: v1.7.31-alpha's frontend tarball was packaged with a
  `neode-ui/` top-level directory instead of the flat layout v1.7.30
  and earlier used. Nodes that applied v1.7.31 ended up with
  `/opt/archipelago/web-ui/neode-ui/index.html` instead of
  `/opt/archipelago/web-ui/index.html`, and nginx returned 403/500.
  v1.7.32's tarball is built with `tar -C web/dist/neode-ui .` so
  files land directly at web-ui root. Broken nodes auto-heal on this
  update (web-ui dir is replaced).
- transport/lan.rs: add Drop impl that calls ServiceDaemon::shutdown()
  on the mdns_sd daemon. Without this the OS thread it spawns, plus
  the blocking `receiver.recv()` task, keep the tokio runtime alive
  past SIGTERM — long enough for systemd's TimeoutStopSec to SIGKILL
  the service and mark it Failed. Was visible on every update:
  "shut down cleanly" logged, then 15s later systemd forcibly kills.
- main.rs: after logging "Archipelago shut down cleanly", call
  `std::process::exit(0)` explicitly. Belt-and-suspenders against
  any future non-daemon thread creeping in (reqwest resolver pool,
  etc.) and causing the same SIGKILL regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 03:52:22 -04:00
DorianandClaude Opus 4.7 682b93f2d6 release(v1.7.31-alpha): idempotent IndeedHub install + auto-merge default mirrors/registries + 3rd OVH update mirror
- Backend: install.rs registry reachability probe now strips the
  `host[:port]/namespace` suffix before appending `/v2/` (the Docker
  V2 API lives at the host root, not under the namespace) and accepts
  HTTP 405 in addition to 200/401 as "registry daemon alive". This
  fixes false "unreachable" reports on the Test button for Gitea and
  other registries that protect their /v2/ endpoint.
- Backend: stacks.rs install_indeedhub_stack now force-removes any
  leftover indeedhub-* containers and indeedhub-net before creating
  the stack. A partial install (or the old first-boot stub racing the
  installer) used to leave containers around that blocked re-install
  with "name already in use". Re-running the App Store install now
  self-heals.
- Backend: registry.rs load_registries auto-merges any default
  registry URLs missing from the saved config (appended with priority
  max+10+i, persisted). Lets new default mirrors (e.g. Server 3 OVH)
  roll out to existing nodes without manual config edits. Explicit
  removals still stick — URLs absent from disk AND absent from
  defaults stay gone.
- Backend: update.rs adds DEFAULT_TERTIARY_MIRROR_URL at
  http://146.59.87.168:3000/ (Server 3 OVH) to default_mirrors, with
  the same auto-merge-on-load behavior as registries. Test updated
  for 3-mirror default (.160, tx1138, .168).
- Scripts: dropped the first-boot IndeedHub stub (~38 lines in
  first-boot-containers.sh §8b). It predated the proper stack
  installer, raced it, and was the main source of the name-conflict
  mess the stacks.rs cleanup above now also guards against.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 03:26:09 -04:00
DorianandClaude Opus 4.7 18f0929614 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>
2026-04-21 19:11:36 -04:00
DorianandClaude Opus 4.7 1709149ebd release(v1.7.29-alpha): VPS as default app registry + settings UI
- New Settings → App registries page (/dashboard/settings/registries)
  that mirrors the update-mirrors experience: list of configured
  registries, test reachability, set primary, add/remove. New
  registry.set-primary RPC; existing registry.{list,add,remove,test}
  reused.
- Default RegistryConfig flipped: VPS (23.182.128.160:3000/lfg2025) is
  now Server 1 (primary), tx1138 is Server 2 (fallback).
- Install pipeline now rewrites the first pull to the primary registry
  URL before attempting it. Before this, installs always hit whichever
  registry the image was hardcoded to, so changing the primary didn't
  actually affect where images came from. On failure, the existing
  fallback walk skips the primary (already tried) and walks the rest.
- App catalog proxy UPSTREAMS order flipped so the catalog follows the
  same VPS-first rule.
- Reboot overlay: animated "a" logo now sits in the center of the ring
  (matches the screensaver composition). Extracted the logo-wrapper
  pattern inline.

7/7 registry tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:54:07 -04:00
33 changed files with 919 additions and 214 deletions
+1 -1
View File
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.28-alpha"
version = "1.7.34-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.28-alpha"
version = "1.7.34-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
+1 -1
View File
@@ -120,8 +120,8 @@ impl ApiHandler {
/// first 2xx response. 15s total timeout.
async fn handle_app_catalog_proxy() -> Result<Response<hyper::Body>> {
const UPSTREAMS: &[&str] = &[
"https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json",
"http://23.182.128.160:3000/lfg2025/app-catalog/raw/branch/main/catalog.json",
"https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json",
];
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
@@ -220,6 +220,7 @@ impl RpcHandler {
"registry.list" => self.handle_registry_list().await,
"registry.add" => self.handle_registry_add(params).await,
"registry.remove" => self.handle_registry_remove(params).await,
"registry.set-primary" => self.handle_registry_set_primary(params).await,
"registry.test" => self.handle_registry_test(params).await,
// Streaming ecash payments
+159 -42
View File
@@ -635,31 +635,32 @@ impl RpcHandler {
unreachable!()
}
/// Single image pull attempt with progress streaming.
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);
/// Pull one image URL with live progress streamed through
/// `update_install_progress`. Returns Ok(true) on a successful pull,
/// Ok(false) on transient failure (so the caller can try the next
/// mirror), Err only for unrecoverable setup errors.
async fn pull_one_url_with_progress(
&self,
url: &str,
tls_verify: bool,
package_id: &str,
user_tmp: &str,
) -> Result<bool> {
let mut pull_args = vec!["pull".to_string(), url.to_string()];
if !tls_verify {
pull_args.push("--tls-verify=false".to_string());
}
let mut child = tokio::process::Command::new("podman")
.args(["pull", docker_image])
.env("TMPDIR", &user_tmp)
.args(&pull_args)
.env("TMPDIR", user_tmp)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("Failed to start image pull")?;
// Wrap the entire pull (stderr progress + wait) in a 10-minute timeout.
// Large image layers (Minio, Postgres, ffmpeg) can take several minutes
// to pull. 60s was too short and caused premature retries on slow registries.
// 10-minute per-URL budget — large layers (Minio, Postgres,
// ffmpeg) regularly take several minutes and we'd rather wait
// than bounce to the next mirror mid-download.
let pull_result = tokio::time::timeout(std::time::Duration::from_secs(600), async {
if let Some(stderr) = child.stderr.take() {
let reader = BufReader::new(stderr);
@@ -677,38 +678,115 @@ impl RpcHandler {
})
.await;
let primary_failed = match pull_result {
Ok(Ok(status)) => !status.success(),
match pull_result {
Ok(Ok(status)) => Ok(status.success()),
Ok(Err(e)) => {
tracing::warn!("Image pull process error: {}", e);
true
tracing::warn!("Image pull process error on {}: {}", url, e);
Ok(false)
}
Err(_) => {
tracing::warn!("Image pull timed out after 60s: {}", docker_image);
tracing::warn!("Image pull timed out after 600s: {}", url);
let _ = child.kill().await;
let _ = child.wait().await; // reap zombie
true
Ok(false)
}
};
if primary_failed {
// Try all configured fallback registries dynamically
match crate::container::registry::pull_from_registries(
&self.config.data_dir,
docker_image,
&user_tmp,
)
}
}
/// Pull a container image, trying each configured registry in
/// priority order and streaming progress during every attempt. The
/// primary is tried first; if it doesn't have the image (or 404's),
/// the next mirror is tried — with its own progress streaming, so
/// the UI doesn't freeze at 0% after a primary miss. On success the
/// 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
.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(_) => {
tracing::info!("Pulled {} via dynamic registry fallback", docker_image);
true => {
tracing::info!("Pulled {} from {}", docker_image, url);
pulled_url = Some(url.clone());
break;
}
Err(e) => {
return Err(anyhow::anyhow!("Image pull failed: {}", e));
false => {
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")
.args(["images", "-q", docker_image])
.output()
@@ -1467,6 +1545,36 @@ server {
Ok(serde_json::json!({ "registries": config.registries, "removed": url }))
}
/// Promote a registry to primary by resetting priorities — the named
/// URL becomes priority 0, every other enabled registry is bumped up
/// by 10. Order is stable (ties broken by original priority).
pub(in crate::api::rpc) async fn handle_registry_set_primary(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let url = params
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing url"))?;
let mut config = crate::container::registry::load_registries(&self.config.data_dir).await?;
if !config.registries.iter().any(|r| r.url == url) {
return Err(anyhow::anyhow!("Registry '{}' not found", url));
}
// Reassign priorities: target = 0, everyone else = 10, 20, 30…
// in their existing priority order.
let target_url = url.to_string();
config.registries.sort_by_key(|r| (r.url != target_url, r.priority));
for (i, r) in config.registries.iter_mut().enumerate() {
r.priority = if r.url == target_url { 0 } else { (i as u32) * 10 };
}
crate::container::registry::save_registries(&self.config.data_dir, &config).await?;
Ok(serde_json::json!({ "registries": config.registries, "primary": url }))
}
pub(in crate::api::rpc) async fn handle_registry_test(
&self,
params: Option<serde_json::Value>,
@@ -1481,10 +1589,16 @@ server {
.and_then(|v| v.as_bool())
.unwrap_or(true);
// Registries are configured as `host[:port]/namespace` (for
// example `23.182.128.160:3000/lfg2025`), but the Docker V2
// registry API lives at `/v2/` on the ROOT of the host — NOT
// under the namespace. Strip the namespace before appending
// `/v2/` so the reachability probe hits the correct URL.
let host = url.split('/').next().unwrap_or(url);
let test_url = if tls_verify {
format!("https://{}/v2/", url)
format!("https://{}/v2/", host)
} else {
format!("http://{}/v2/", url)
format!("http://{}/v2/", host)
};
let client = reqwest::Client::builder()
@@ -1496,8 +1610,11 @@ server {
match client.get(&test_url).send().await {
Ok(resp) => {
let status = resp.status().as_u16();
// 200 = open registry, 401 = auth required (both mean it exists)
let reachable = status == 200 || status == 401;
// Accept any "the server responded to HTTP" code as
// reachable — 200 (open registry), 401 (auth required),
// or 405 (server rejects GET but the endpoint exists)
// all mean the registry daemon is alive on the host.
let reachable = status == 200 || status == 401 || status == 405;
Ok(serde_json::json!({
"url": url,
"reachable": reachable,
@@ -28,6 +28,18 @@ impl RpcHandler {
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).
pub(super) async fn update_install_progress(
state_manager: &crate::state::StateManager,
@@ -81,6 +93,7 @@ fn create_installing_entry(package_id: &str) -> PackageDataEntry {
},
installed: None,
install_progress: None,
uninstall_stage: None,
available_update: None,
}
}
@@ -261,12 +261,28 @@ impl RpcHandler {
if containers_to_remove.is_empty() {
tracing::warn!("Uninstall {}: no containers found", package_id);
}
let total = containers_to_remove.len();
let mut stopped = 0u32;
let mut removed = 0u32;
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);
let stop_out = tokio::process::Command::new("podman")
.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
let _ = tokio::process::Command::new("podman")
.args(["volume", "prune", "-f"])
@@ -354,6 +371,7 @@ impl RpcHandler {
// Clean data directories unless 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);
for dir in &data_dirs {
tracing::info!("Uninstall {}: removing data {}", package_id, dir);
@@ -937,6 +937,31 @@ impl RpcHandler {
.with_context(|| format!("Failed to pull IndeedHub image: {}", img))?;
}
// Remove any leftover containers from a previous partial install (or
// from the first-boot frontend stub that used to race the installer).
// Without this, `podman run --name indeedhub` fails on name conflict
// and the whole stack install errors out — leaving a half-broken node.
for name in [
"indeedhub",
"indeedhub-postgres",
"indeedhub-redis",
"indeedhub-minio",
"indeedhub-relay",
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub-build_api_1",
"indeedhub-build_ffmpeg-worker_1",
] {
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", name])
.status()
.await;
}
let _ = tokio::process::Command::new("podman")
.args(["network", "rm", "-f", "indeedhub-net"])
.status()
.await;
// Create indeedhub-net
let _ = tokio::process::Command::new("podman")
.args(["network", "create", "indeedhub-net"])
@@ -247,6 +247,7 @@ impl DockerPackageScanner {
status: service_status,
}),
install_progress: None,
uninstall_stage: None,
};
packages.insert(app_id.clone(), package);
+54 -98
View File
@@ -44,19 +44,26 @@ impl Default for RegistryConfig {
Self {
registries: vec![
Registry {
url: "git.tx1138.com/lfg2025".to_string(),
name: "Archipelago Primary".to_string(),
tls_verify: true,
url: "23.182.128.160:3000/lfg2025".to_string(),
name: "Server 1 (VPS)".to_string(),
tls_verify: false,
enabled: true,
priority: 0,
},
Registry {
url: "23.182.128.160:3000/lfg2025".to_string(),
name: "Archipelago Fallback".to_string(),
tls_verify: false,
url: "git.tx1138.com/lfg2025".to_string(),
name: "Server 2 (tx1138)".to_string(),
tls_verify: true,
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,20 +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
}
}
/// Extract the image name from a full image reference.
@@ -104,7 +97,12 @@ fn extract_image_name(image: &str) -> &str {
image.rsplit('/').next().unwrap_or(image)
}
/// Load registry config from disk.
/// Load registry config from disk, merging in any default registries
/// that the operator hasn't explicitly removed. This lets us roll out
/// new default mirrors (e.g. a new Server 3) to existing nodes without
/// them having to edit their saved config. Explicit removals stick —
/// if the URL is absent from disk AND absent from current defaults, it
/// stays gone.
pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
let path = data_dir.join(REGISTRY_FILE);
if !path.exists() {
@@ -113,8 +111,34 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
let content = fs::read_to_string(&path)
.await
.context("Failed to read registry config")?;
let config: RegistryConfig =
let mut config: RegistryConfig =
serde_json::from_str(&content).unwrap_or_else(|_| RegistryConfig::default());
// Migrate: any default registry URL that isn't already in the
// saved list gets appended at the end (so existing priority order
// is preserved for anything the operator already configured).
let defaults = RegistryConfig::default();
let known: std::collections::HashSet<String> =
config.registries.iter().map(|r| r.url.clone()).collect();
let max_priority = config
.registries
.iter()
.map(|r| r.priority)
.max()
.unwrap_or(0);
let mut added = false;
for (i, def) in defaults.registries.iter().enumerate() {
if !known.contains(&def.url) {
let mut cloned = def.clone();
cloned.priority = max_priority.saturating_add(10 + i as u32);
config.registries.push(cloned);
added = true;
}
}
if added {
// Persist so the next load doesn't have to re-merge.
let _ = save_registries(data_dir, &config).await;
}
Ok(config)
}
@@ -133,69 +157,6 @@ pub async fn save_registries(data_dir: &Path, config: &RegistryConfig) -> Result
Ok(())
}
/// Try pulling an image from configured registries in priority order.
/// Returns the image reference that succeeded.
pub async fn pull_from_registries(data_dir: &Path, image: &str, tmpdir: &str) -> Result<String> {
let config = load_registries(data_dir).await?;
let candidates = config.image_candidates(image);
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::*;
@@ -217,34 +178,29 @@ mod tests {
#[test]
fn test_rewrite_image() {
let config = RegistryConfig::default();
let fallback = &config.registries[1];
// Default primary is now VPS (index 0). A tx1138-hardcoded image
// rewrites to VPS when asked for the primary mirror.
let primary = &config.registries[0];
assert_eq!(
config.rewrite_image("git.tx1138.com/lfg2025/bitcoin-knots:latest", fallback),
config.rewrite_image("git.tx1138.com/lfg2025/bitcoin-knots:latest", primary),
"23.182.128.160:3000/lfg2025/bitcoin-knots:latest"
);
}
#[test]
fn test_image_candidates() {
let config = RegistryConfig::default();
let candidates = config.image_candidates("git.tx1138.com/lfg2025/lnd:v0.18.4-beta");
assert!(candidates.len() >= 2);
assert_eq!(candidates[0].0, "git.tx1138.com/lfg2025/lnd:v0.18.4-beta");
}
#[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]
@@ -260,6 +216,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);
}
}
+7
View File
@@ -138,6 +138,13 @@ pub struct PackageDataEntry {
pub installed: Option<InstalledPackageDataEntry>,
#[serde(rename = "install-progress")]
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
#[serde(rename = "available-update", skip_serializing_if = "Option::is_none")]
pub available_update: Option<String>,
+10 -1
View File
@@ -229,5 +229,14 @@ async fn main() -> Result<()> {
crash_recovery::remove_pid_marker(&config.data_dir).await;
info!("Archipelago shut down cleanly");
Ok(())
// Hard-exit after logging. All business state is persisted by now
// (connections drained, PID marker removed, disk flushes done via
// tokio::fs awaits). Letting tokio try to drop the runtime instead
// can stall for 15s+ on non-daemon OS threads we don't directly
// own (mdns_sd daemon, reqwest resolver pool, etc.) — long enough
// for systemd's TimeoutStopSec to SIGKILL us and mark the service
// Failed, which makes an otherwise-successful update look like a
// crash in `systemctl status`.
std::process::exit(0);
}
+12
View File
@@ -160,6 +160,18 @@ impl LanTransport {
}
}
impl Drop for LanTransport {
// The mdns_sd daemon runs on its own OS thread and the browse
// listener task blocks on a sync channel. Without this call both
// keep the process alive past SIGTERM, long enough for systemd to
// SIGKILL us — which makes a normal update look like a crash.
fn drop(&mut self) {
if let Some(daemon) = self.daemon.take() {
let _ = daemon.shutdown();
}
}
}
impl NodeTransport for LanTransport {
fn kind(&self) -> TransportKind {
TransportKind::Lan
+39 -9
View File
@@ -68,6 +68,10 @@ const DEFAULT_UPDATE_MANIFEST_URL: &str =
/// is slow or unreachable.
const DEFAULT_SECONDARY_MIRROR_URL: &str =
"http://23.182.128.160:3000/lfg2025/archy/raw/branch/main/releases/manifest.json";
/// Tertiary mirror on a separate OVH VPS — independent network path so
/// a single-provider outage doesn't knock out all three mirrors.
const DEFAULT_TERTIARY_MIRROR_URL: &str =
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json";
const UPDATE_STATE_FILE: &str = "update_state.json";
const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json";
@@ -97,13 +101,24 @@ fn default_mirrors() -> Vec<UpdateMirror> {
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
label: "Server 2 (tx1138)".to_string(),
},
UpdateMirror {
url: DEFAULT_TERTIARY_MIRROR_URL.to_string(),
label: "Server 3 (OVH)".to_string(),
},
]
}
/// Load the operator-configured mirror list. Returns defaults if the
/// file doesn't exist yet, so a node OTA'd from a pre-mirrors release
/// starts with both Server 1 and Server 2 available without any manual
/// config.
/// starts with the current default mirrors available without any
/// manual config.
///
/// Migration: any default mirror URL that isn't already in the saved
/// list gets appended at the end. This lets us add new default mirrors
/// (e.g. a new Server 3) and have them appear on existing nodes after
/// an update, without requiring manual config edits. Explicit removals
/// stick — once an operator removes a URL it stays gone unless it's
/// later re-added to defaults.
pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
let path = mirrors_path(data_dir);
if !path.exists() {
@@ -112,13 +127,27 @@ pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
let bytes = fs::read(&path)
.await
.with_context(|| format!("read {}", path.display()))?;
let list: Vec<UpdateMirror> =
let mut list: Vec<UpdateMirror> =
serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?;
if list.is_empty() {
Ok(default_mirrors())
} else {
Ok(list)
return Ok(default_mirrors());
}
// Merge in any default URLs the saved config is missing.
let known: std::collections::HashSet<String> =
list.iter().map(|m| m.url.clone()).collect();
let defaults = default_mirrors();
let mut added = false;
for def in &defaults {
if !known.contains(&def.url) {
list.push(def.clone());
added = true;
}
}
if added {
let _ = save_mirrors(data_dir, &list).await;
}
Ok(list)
}
pub async fn save_mirrors(data_dir: &Path, mirrors: &[UpdateMirror]) -> Result<()> {
@@ -1187,9 +1216,10 @@ mod tests {
async fn test_load_mirrors_returns_defaults_when_absent() {
let dir = tempfile::tempdir().unwrap();
let list = load_mirrors(dir.path()).await.unwrap();
assert_eq!(list.len(), 2);
assert!(list[0].url.contains("git.tx1138.com"));
assert!(list[1].url.contains("23.182.128.160"));
assert_eq!(list.len(), 3);
assert!(list[0].url.contains("23.182.128.160"));
assert!(list[1].url.contains("git.tx1138.com"));
assert!(list[2].url.contains("146.59.87.168"));
}
#[tokio::test]
+17 -4
View File
@@ -19,11 +19,24 @@ async function callWithRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T
}
export async function isOnboardingComplete(): Promise<boolean> {
// localStorage is set on completion and survives backend restarts/resets
if (localStorage.getItem('neode_onboarding_complete') === '1') return true
// Prefer the backend — localStorage gets stale across nodes (a
// browser that onboarded node A would otherwise treat fresh node B
// as already-onboarded and skip the wizard entirely). Only fall
// back to localStorage if the backend is unreachable.
const result = await callWithRetry(() => rpcClient.isOnboardingComplete(), 2)
if (result !== null) return result
return false
if (result !== null) {
// Re-seed the localStorage cache so non-async consumers
// (OnboardingWrapper's useVideoBackground computed, etc.) see the
// right answer after the user clears site data on an already-
// onboarded node.
if (result) {
try { localStorage.setItem('neode_onboarding_complete', '1') } catch {}
} else {
try { localStorage.removeItem('neode_onboarding_complete') } catch {}
}
return result
}
return localStorage.getItem('neode_onboarding_complete') === '1'
}
export async function completeOnboarding(): Promise<void> {
+5
View File
@@ -193,6 +193,11 @@ const router = createRouter({
name: 'system-update',
component: () => import('../views/SystemUpdate.vue'),
},
{
path: 'settings/registries',
name: 'app-registries',
component: () => import('../views/AppRegistries.vue'),
},
{
path: 'goals/:goalId',
name: 'goal-detail',
@@ -10,10 +10,19 @@ describe('useLoginTransitionStore', () => {
it('starts with all flags false', () => {
const store = useLoginTransitionStore()
expect(store.justLoggedIn).toBe(false)
expect(store.justCompletedOnboarding).toBe(false)
expect(store.pendingWelcomeTyping).toBe(false)
expect(store.startWelcomeTyping).toBe(false)
})
it('setJustCompletedOnboarding updates justCompletedOnboarding', () => {
const store = useLoginTransitionStore()
store.setJustCompletedOnboarding(true)
expect(store.justCompletedOnboarding).toBe(true)
store.setJustCompletedOnboarding(false)
expect(store.justCompletedOnboarding).toBe(false)
})
it('setJustLoggedIn updates justLoggedIn', () => {
const store = useLoginTransitionStore()
store.setJustLoggedIn(true)
+13
View File
@@ -4,6 +4,13 @@ import { ref } from 'vue'
/** Signals that we just logged in - Dashboard uses this for zoom + oomph */
export const useLoginTransitionStore = defineStore('loginTransition', () => {
const justLoggedIn = ref(false)
/**
* True only when the user just finished the onboarding wizard
* (first password setup), as distinct from a regular re-login.
* Dashboard uses this to decide whether to play the full glitchy
* reveal vs just a quick interface-draw.
*/
const justCompletedOnboarding = ref(false)
/** Show empty welcome block until typing starts (hide static text) */
const pendingWelcomeTyping = ref(false)
/** Trigger welcome typing on Home - set true after dashboard animation finishes */
@@ -13,6 +20,10 @@ export const useLoginTransitionStore = defineStore('loginTransition', () => {
justLoggedIn.value = value
}
function setJustCompletedOnboarding(value: boolean) {
justCompletedOnboarding.value = value
}
function setPendingWelcomeTyping(value: boolean) {
pendingWelcomeTyping.value = value
}
@@ -24,6 +35,8 @@ export const useLoginTransitionStore = defineStore('loginTransition', () => {
return {
justLoggedIn,
setJustLoggedIn,
justCompletedOnboarding,
setJustCompletedOnboarding,
pendingWelcomeTyping,
setPendingWelcomeTyping,
startWelcomeTyping,
+2
View File
@@ -91,6 +91,8 @@ export interface PackageDataEntry {
manifest: Manifest
installed?: InstalledPackageDataEntry
'install-progress'?: InstallProgress
/** Live label for the current uninstall step ("Stopping containers (2/5)", …). */
'uninstall-stage'?: string | null
'available-update'?: string | null
}
+330
View File
@@ -0,0 +1,330 @@
<template>
<div class="pb-6">
<div class="mb-6">
<h1 class="text-3xl font-bold text-white mb-2">App registries</h1>
<p class="text-white/70">
Container registries this node pulls app images from. The primary is tried first; if it's
slow or unreachable, the next one in the list is tried automatically.
</p>
</div>
<!-- Status message -->
<div
v-if="statusMessage"
class="mb-4 p-3 rounded-lg text-sm"
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
>
{{ statusMessage }}
</div>
<!-- Registry list -->
<div class="glass-card p-6 mb-6">
<div class="flex items-start justify-between gap-4 mb-2">
<h2 class="text-lg font-semibold text-white">Registries</h2>
<button
type="button"
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors"
@click="openAddRegistry"
>+ Add registry</button>
</div>
<p class="text-sm text-white/60 mb-4">
Registries are tried in priority order on every app install. Changing the primary takes
effect on the next install existing containers keep running on whatever image they
already pulled.
</p>
<ul v-if="registries.length" class="space-y-2">
<li
v-for="r in sortedRegistries"
:key="r.url"
class="p-3 bg-white/5 rounded-lg"
>
<div class="flex items-start gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5 flex-wrap">
<p class="text-sm font-medium text-white truncate">{{ r.name || r.url }}</p>
<span
v-if="r.priority === 0"
class="text-[10px] font-mono px-2 py-0.5 rounded bg-green-500/20 text-green-300"
>PRIMARY</span>
<span
v-if="!r.tls_verify"
class="text-[10px] font-mono px-2 py-0.5 rounded bg-amber-500/20 text-amber-300"
title="TLS verification disabled — HTTP or self-signed registry"
>HTTP</span>
</div>
<p class="text-xs text-white/50 font-mono break-all">{{ r.url }}</p>
</div>
<div class="shrink-0 flex items-center gap-1">
<button
type="button"
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-white hover:bg-white/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="registryTests[r.url]?.testing"
title="Test reachability"
@click="testRegistry(r)"
>
<svg v-if="registryTests[r.url]?.testing" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-opacity="0.25"></circle>
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</button>
<button
v-if="r.priority !== 0"
type="button"
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-yellow-300 hover:bg-white/10 transition-colors"
title="Make primary"
@click="setPrimary(r.url)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
<button
v-if="registries.length > 1"
type="button"
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-red-300 hover:bg-red-400/10 transition-colors"
title="Remove registry"
@click="removeRegistry(r.url)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<div
v-if="registryTests[r.url] && !registryTests[r.url]?.testing"
class="mt-2 pt-2 border-t border-white/5 text-xs"
>
<span v-if="registryTests[r.url]?.reachable" class="inline-flex items-center gap-1.5 text-green-300">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
Reachable (HTTP {{ registryTests[r.url]?.status }})
</span>
<span v-else class="inline-flex items-center gap-1.5 text-red-300">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12" />
</svg>
<span class="truncate">{{ registryTests[r.url]?.error || 'Unreachable' }}</span>
</span>
</div>
</li>
</ul>
</div>
<!-- Back link -->
<RouterLink
to="/dashboard/settings"
class="glass-button rounded-lg px-5 py-2 text-sm font-medium inline-flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back to Settings
</RouterLink>
<!-- Add-registry modal -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="addingRegistry"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md"
@click.self="cancelAddRegistry"
>
<div class="glass-card p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold text-white mb-1">Add app registry</h3>
<p class="text-sm text-white/60 mb-5">
The URL should be of the form <span class="font-mono text-white/80">host[:port]/namespace</span>
for example <span class="font-mono text-white/80">ghcr.io/myorg</span> or
<span class="font-mono text-white/80">192.168.1.50:3000/apps</span>. Registries are
added to the end of the list; use "Make primary" to reorder.
</p>
<form class="space-y-3" @submit.prevent="submitRegistry">
<div>
<label class="block text-xs text-white/60 mb-1">Name</label>
<input
v-model="registryDraft.name"
type="text"
placeholder="My private registry"
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
/>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Registry URL</label>
<input
v-model="registryDraft.url"
type="text"
autofocus
placeholder="host:port/namespace"
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none font-mono"
/>
</div>
<label class="flex items-center gap-2 cursor-pointer text-sm text-white/80">
<input
v-model="registryDraft.tls_verify"
type="checkbox"
class="accent-orange-400"
/>
Verify TLS certificate (uncheck for HTTP or self-signed)
</label>
<div class="flex gap-3 justify-end pt-2">
<button
type="button"
@click="cancelAddRegistry"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
>Cancel</button>
<button
type="submit"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="registrySaving || !registryDraft.url.trim()"
>{{ registrySaving ? 'Adding…' : 'Add registry' }}</button>
</div>
</form>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, reactive } from 'vue'
import { RouterLink } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
interface Registry {
url: string
name: string
tls_verify: boolean
enabled: boolean
priority: number
}
interface RegistryTestState {
testing?: boolean
reachable?: boolean
status?: number | null
error?: string | null
}
const registries = ref<Registry[]>([])
const sortedRegistries = computed(() =>
[...registries.value].sort((a, b) => a.priority - b.priority)
)
const registryTests = ref<Record<string, RegistryTestState>>({})
const statusMessage = ref('')
const statusIsError = ref(false)
const addingRegistry = ref(false)
const registrySaving = ref(false)
const registryDraft = reactive({ url: '', name: '', tls_verify: true })
function showStatus(msg: string, isError = false) {
statusMessage.value = msg
statusIsError.value = isError
setTimeout(() => { statusMessage.value = '' }, 8000)
}
async function loadRegistries() {
try {
const res = await rpcClient.call<{ registries: Registry[] }>({ method: 'registry.list' })
registries.value = res.registries
} catch (e) {
if (import.meta.env.DEV) console.warn('registry.list failed', e)
}
}
function openAddRegistry() {
registryDraft.url = ''
registryDraft.name = ''
registryDraft.tls_verify = true
addingRegistry.value = true
}
function cancelAddRegistry() {
addingRegistry.value = false
}
async function submitRegistry() {
const url = registryDraft.url.trim()
if (!url) return
registrySaving.value = true
try {
const res = await rpcClient.call<{ registries: Registry[] }>({
method: 'registry.add',
params: {
url,
name: registryDraft.name.trim() || url,
tls_verify: registryDraft.tls_verify,
},
})
registries.value = res.registries
addingRegistry.value = false
showStatus('Registry added.')
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
showStatus(`Add registry failed: ${msg}`, true)
} finally {
registrySaving.value = false
}
}
async function removeRegistry(url: string) {
try {
const res = await rpcClient.call<{ registries: Registry[] }>({
method: 'registry.remove',
params: { url },
})
registries.value = res.registries
showStatus('Registry removed.')
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
showStatus(`Remove failed: ${msg}`, true)
}
}
async function setPrimary(url: string) {
try {
const res = await rpcClient.call<{ registries: Registry[] }>({
method: 'registry.set-primary',
params: { url },
})
registries.value = res.registries
showStatus('Primary registry updated. Next install will try it first.')
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
showStatus(`Set primary failed: ${msg}`, true)
}
}
async function testRegistry(r: Registry) {
registryTests.value = { ...registryTests.value, [r.url]: { testing: true } }
try {
const res = await rpcClient.call<{
url: string
reachable: boolean
status: number | null
error?: string | null
}>({ method: 'registry.test', params: { url: r.url, tls_verify: r.tls_verify } })
registryTests.value = {
...registryTests.value,
[r.url]: {
testing: false,
reachable: res.reachable,
status: res.status,
error: res.error ?? null,
},
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
registryTests.value = {
...registryTests.value,
[r.url]: { testing: false, reachable: false, error: msg },
}
}
}
onMounted(() => { void loadRegistries() })
</script>
+3
View File
@@ -282,6 +282,9 @@ function closeUninstallModal() {
async function onConfirmUninstall() {
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
await actions.confirmUninstall(appId)
}
+14 -1
View File
@@ -264,10 +264,13 @@ watch(() => route.path, (newPath) => {
onMounted(() => {
previousRoutePath = route.path
document.body.classList.add('dashboard-active')
if (loginTransition.justLoggedIn) {
if (loginTransition.justCompletedOnboarding) {
// Full glitchy reveal only on the very first dashboard entry
// right after onboarding (one-time event, persists in feel).
playDashboardLoadOomph()
showZoomIn.value = true
loginTransition.setPendingWelcomeTyping(true)
loginTransition.setJustCompletedOnboarding(false)
loginTransition.setJustLoggedIn(false)
const triggerRevealGlitch = () => {
isGlitching.value = true
@@ -281,6 +284,16 @@ onMounted(() => {
loginTransition.setStartWelcomeTyping(true)
loginTransition.setPendingWelcomeTyping(false)
}, 4000)
} else if (loginTransition.justLoggedIn) {
// Regular re-login no zoom, no glitch. Just land on the
// dashboard and kick off the welcome typing quickly.
playDashboardLoadOomph()
loginTransition.setPendingWelcomeTyping(true)
loginTransition.setJustLoggedIn(false)
scheduledTimeout(() => {
loginTransition.setStartWelcomeTyping(true)
loginTransition.setPendingWelcomeTyping(false)
}, 300)
}
window.addEventListener('keydown', handleKioskShortcuts)
+1
View File
@@ -408,6 +408,7 @@ async function handleSetup() {
stopSynthwave()
whooshAway.value = true
playLoginSuccessWhoosh()
loginTransition.setJustCompletedOnboarding(true)
loginTransition.setJustLoggedIn(true)
await new Promise(r => setTimeout(r, 520))
await router.replace(loginRedirectTo.value).catch(() => {
+41 -2
View File
@@ -86,9 +86,23 @@ const videoBackgroundRoutes = ['/onboarding/intro', '/login']
// Login uses video when coming from splash, or static + glitch when direct
const isLoginRoute = computed(() => route.path === '/login')
// True once onboarding is complete. Used to skip the intro video on
// the /login route so that returning (logged-out) users go straight
// to the screensaver-style static + glitch background instead of
// replaying the full intro every time.
const onboardingDone = computed(() => {
try {
return localStorage.getItem('neode_onboarding_complete') === '1'
} catch {
return false
}
})
// Check if current route should use video background
const useVideoBackground = computed(() => {
return videoBackgroundRoutes.includes(route.path)
if (!videoBackgroundRoutes.includes(route.path)) return false
if (route.path === '/login' && onboardingDone.value) return false
return true
})
// Map each route to a specific background image
@@ -108,7 +122,32 @@ const routeBackgrounds: Record<string, string> = {
'/login': 'bg-intro.jpg' // Video loops from splash (same as intro)
}
const loginBackground = 'bg-intro-1.jpg'
// Rotate the login background so the lock screen doesn't look
// identical on every logout. Cycles through bg-intro-1..6 using a
// counter persisted to localStorage so subsequent visits advance.
const LOGIN_BACKGROUNDS = [
'bg-intro-1.jpg',
'bg-intro-2.jpg',
'bg-intro-3.jpg',
'bg-intro-4.jpg',
'bg-intro-5.jpg',
'bg-intro-6.jpg',
]
function pickNextLoginBackground(): string {
try {
const raw = localStorage.getItem('neode_login_bg_idx')
const prev = raw !== null ? parseInt(raw, 10) : -1
const next = (Number.isFinite(prev) ? prev + 1 : 0) % LOGIN_BACKGROUNDS.length
localStorage.setItem('neode_login_bg_idx', String(next))
return LOGIN_BACKGROUNDS[next]!
} catch {
return LOGIN_BACKGROUNDS[Math.floor(Math.random() * LOGIN_BACKGROUNDS.length)]!
}
}
const loginBackground = ref(pickNextLoginBackground())
watch(() => route.path, (p) => {
if (p === '/login') loginBackground.value = pickNextLoginBackground()
})
// Restore video time from splash screen for seamless transition
function restoreVideoTime() {
+25 -1
View File
@@ -129,7 +129,31 @@ onMounted(async () => {
return
}
// Server not ready show boot screen (waiting for backend)
// Server not ready. The full BootScreen is meant for a genuine
// cold-start (fresh install), not for the brief blip during an
// OTA update where the backend restarts. If onboarding has already
// completed we just keep the spinner and retry until the server
// responds again.
const wasOnboardedBefore = localStorage.getItem('neode_onboarding_complete') === '1'
if (wasOnboardedBefore) {
log('server down + onboarded → polling without boot screen')
let retries = 0
const maxRetries = 30 // 30 * 2s = 60s before giving up and showing boot screen
const poll = setInterval(async () => {
retries++
if (await quickHealthCheck()) {
clearInterval(poll)
proceedToApp()
return
}
if (retries >= maxRetries) {
clearInterval(poll)
log('server still down after retries → falling back to boot screen')
showBootScreen.value = true
}
}, 2000)
return
}
showBootScreen.value = true
})
</script>
+9 -2
View File
@@ -99,14 +99,14 @@
</div>
</div>
<!-- Uninstalling progress replaces action buttons -->
<!-- Uninstalling progress live stage label from backend -->
<div v-else-if="isUninstalling" class="mt-4">
<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">
<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>
</svg>
<span class="text-xs text-red-300">{{ t('common.uninstalling') }}...</span>
<span class="text-xs text-red-300 truncate">{{ uninstallStageLabel }}</span>
</div>
<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>
@@ -251,6 +251,13 @@ const tier = computed(() => {
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 s = props.pkg.state
const h = props.pkg.health
@@ -96,20 +96,32 @@
Checking...
</span>
</span>
<!-- Installing simple button-style indicator -->
<button
<!-- Installing live progress with bar + message matching the
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"
disabled
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium opacity-80 cursor-wait"
class="flex-1 flex flex-col gap-1.5"
>
<span class="flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<div
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>
<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>
Installing
</span>
</button>
<span class="truncate">{{ installProgressMessage }}</span>
</div>
<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
v-else-if="!installed && (app.source === 'local' || app.dockerImage)"
data-controller-install-btn
@@ -130,6 +142,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MarketplaceApp, InstallProgress } from './marketplaceData'
@@ -154,6 +167,14 @@ defineEmits<{
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) {
const img = event.target as HTMLImageElement
img.src = '/assets/img/logo-archipelago.svg'
@@ -180,6 +180,31 @@ init()
</button>
</div>
<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 -->
<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.29-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>New App registries page in Settings same experience as Update mirrors, but for the container registries your node pulls app images from. Add a mirror, test reachability with one click, pick the primary.</p>
<p>New nodes default to the VPS registry as the primary for both app installs and the app catalog, with tx1138 as the automatic fallback if the VPS is slow or unreachable. Existing nodes keep whatever registry order they've already set.</p>
<p>App installs now genuinely honor the primary registry: the first pull attempt rewrites the image URL to use your primary, and only falls through to the secondary if that fails. Before, installs always hit whichever registry the image was hardcoded to.</p>
<p>Reboot screen now shows the animated "a" logo in the center of the ring matching the screensaver's look so you get something nice to watch while the node comes back up.</p>
</div>
</div>
<!-- v1.7.28-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
</script>
<template>
<!-- App Registries Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold text-white/96">App registries</h2>
<p class="text-sm text-white/60 mt-1">
Choose the primary registry for app installs and add mirrors for fallback.
</p>
</div>
<RouterLink
to="/dashboard/settings/registries"
class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1M12 12V3m0 0l-4 4m4-4l4 4" />
</svg>
Manage registries
</RouterLink>
</div>
</div>
</template>
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import ScreensaverRing from '@/components/ScreensaverRing.vue'
import ScreensaverLogo from '@/components/ScreensaverLogo.vue'
const router = useRouter()
const { t } = useI18n()
@@ -170,8 +171,13 @@ async function performFactoryReset() {
v-if="rebootOverlay"
class="fixed inset-0 z-[3000] bg-black flex flex-col items-center justify-center overflow-hidden"
>
<!-- Centered animated ring same segments as the screensaver -->
<ScreensaverRing />
<!-- Centered animated ring + logo same composition as the screensaver -->
<div class="reboot-ring-content">
<ScreensaverRing />
<div class="reboot-logo-wrapper">
<ScreensaverLogo />
</div>
</div>
<!-- Stage text + progress bar underneath -->
<div class="mt-8 w-[min(520px,80vw)] text-center">
@@ -258,6 +264,20 @@ async function performFactoryReset() {
opacity: 0;
}
.reboot-ring-content {
position: relative;
display: grid;
place-items: center;
}
.reboot-logo-wrapper {
position: absolute;
inset: 0;
display: grid;
place-items: center;
z-index: 10;
filter: drop-shadow(0 0 40px rgba(255, 255, 255, 0.15));
}
.reboot-overlay-bar-anim {
animation: rebootBarSlide 1.8s ease-in-out infinite;
}
@@ -3,6 +3,7 @@ import InterfaceModeSection from '@/views/settings/InterfaceModeSection.vue'
import ClaudeAuthSection from '@/views/settings/ClaudeAuthSection.vue'
import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue'
import SystemUpdatesSection from '@/views/settings/SystemUpdatesSection.vue'
import AppRegistriesSection from '@/views/settings/AppRegistriesSection.vue'
import WebhookSection from '@/views/settings/WebhookSection.vue'
import TelemetrySection from '@/views/settings/TelemetrySection.vue'
import BackupSection from '@/views/settings/BackupSection.vue'
@@ -14,6 +15,7 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
<ClaudeAuthSection />
<AIDataAccessSection />
<SystemUpdatesSection />
<AppRegistriesSection />
<WebhookSection />
<TelemetrySection />
<BackupSection />
+1 -1
View File
@@ -94,7 +94,7 @@ export default defineConfig({
urlPattern: /\/assets\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'assets-cache-v2',
cacheName: 'assets-cache-v3',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
-38
View File
@@ -1216,44 +1216,6 @@ if $DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q 'str
fi
fi
# 8b. Indeehub (pull from registry, or use local build)
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q indeedhub; then
# Use image-versions.sh variable if sourced, otherwise detect
if [ -z "${INDEEDHUB_IMAGE:-}" ]; then
INDEEDHUB_IMAGE=""
# Try local image first (pre-built or loaded from ISO)
if $DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q 'localhost/indeedhub'; then
INDEEDHUB_IMAGE="localhost/indeedhub:local"
# Try pinned registry image
elif $DOCKER pull "$ARCHY_REGISTRY/indeedhub:1.0.0" --tls-verify=false 2>>"$LOG"; then
INDEEDHUB_IMAGE="$ARCHY_REGISTRY/indeedhub:1.0.0"
fi
fi
if [ -n "$INDEEDHUB_IMAGE" ]; then
log "Creating Indeehub from $INDEEDHUB_IMAGE..."
$DOCKER run -d --name indeedhub --restart unless-stopped \
--network archy-net --network-alias indeedhub \
--health-cmd="curl -sf http://localhost:7777/health || exit 1" --health-interval=120s --health-timeout=5s --health-retries=3 \
--memory=$(mem_limit indeedhub) \
--cap-drop ALL --security-opt no-new-privileges:true \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
-p 7778:7777 \
"$INDEEDHUB_IMAGE" 2>>"$LOG" || true
# Fix IndeedHub for iframe: remove X-Frame-Options so it loads in Archipelago panel
sleep 2
if $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q "^indeedhub$"; then
$DOCKER exec indeedhub sed -i "/X-Frame-Options/d" /etc/nginx/conf.d/default.conf 2>/dev/null || true
# Fix Host header for NIP-98 auth — $host strips port, $http_host preserves it
$DOCKER exec indeedhub sed -i 's|proxy_set_header Host $host;|proxy_set_header Host $http_host;|g' /etc/nginx/conf.d/default.conf 2>/dev/null || true
if [ -f /opt/archipelago/web-ui/nostr-provider.js ]; then
$DOCKER cp /opt/archipelago/web-ui/nostr-provider.js indeedhub:/usr/share/nginx/html/nostr-provider.js 2>/dev/null || true
fi
$DOCKER exec indeedhub nginx -s reload 2>/dev/null || true
log "Applied IndeedHub iframe fix (X-Frame-Options, Host header, nostr-provider)"
fi
fi
fi
# 9. Custom UI containers (bitcoin-ui, lnd-ui)
# These are built from Dockerfiles in /opt/archipelago/docker/ or loaded from pre-built images.