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>
This commit is contained in:
Dorian 2026-04-21 15:54:07 -04:00
parent 79ae14a127
commit 7432d84545
15 changed files with 541 additions and 36 deletions

2
core/Cargo.lock generated
View File

@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.7.28-alpha" version = "1.7.29-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "archipelago" name = "archipelago"
version = "1.7.28-alpha" version = "1.7.29-alpha"
edition = "2021" edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend" description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"] authors = ["Archipelago Team"]

View File

@ -120,8 +120,8 @@ impl ApiHandler {
/// first 2xx response. 15s total timeout. /// first 2xx response. 15s total timeout.
async fn handle_app_catalog_proxy() -> Result<Response<hyper::Body>> { async fn handle_app_catalog_proxy() -> Result<Response<hyper::Body>> {
const UPSTREAMS: &[&str] = &[ 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", "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() let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15)) .timeout(std::time::Duration::from_secs(15))

View File

@ -220,6 +220,7 @@ impl RpcHandler {
"registry.list" => self.handle_registry_list().await, "registry.list" => self.handle_registry_list().await,
"registry.add" => self.handle_registry_add(params).await, "registry.add" => self.handle_registry_add(params).await,
"registry.remove" => self.handle_registry_remove(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, "registry.test" => self.handle_registry_test(params).await,
// Streaming ecash payments // Streaming ecash payments

View File

@ -649,8 +649,24 @@ impl RpcHandler {
); );
let _ = std::fs::create_dir_all(&user_tmp); let _ = std::fs::create_dir_all(&user_tmp);
// Rewrite to the primary registry's URL so the first attempt
// honors the operator's mirror choice (default: VPS) instead of
// blindly using whatever registry the image was hardcoded to.
// If the rewritten URL fails, pull_from_registries_with_skip
// falls through to the other configured registries.
let (primary_url, primary_tls) =
crate::container::registry::primary_image_url(&self.config.data_dir, docker_image)
.await;
if primary_url != docker_image {
debug!("Rewrote {} → {} for primary registry", docker_image, primary_url);
}
let mut pull_args = vec!["pull".to_string(), primary_url.clone()];
if !primary_tls {
pull_args.push("--tls-verify=false".to_string());
}
let mut child = tokio::process::Command::new("podman") let mut child = tokio::process::Command::new("podman")
.args(["pull", docker_image]) .args(&pull_args)
.env("TMPDIR", &user_tmp) .env("TMPDIR", &user_tmp)
.stdout(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped())
@ -684,23 +700,35 @@ impl RpcHandler {
true true
} }
Err(_) => { Err(_) => {
tracing::warn!("Image pull timed out after 60s: {}", docker_image); tracing::warn!("Image pull timed out after 60s: {}", primary_url);
let _ = child.kill().await; let _ = child.kill().await;
let _ = child.wait().await; // reap zombie let _ = child.wait().await; // reap zombie
true true
} }
}; };
if !primary_failed && primary_url != docker_image {
// Primary pull succeeded but used a rewritten URL. Tag under
// the original image reference so downstream code (images -q,
// run -d docker_image, etc.) finds it.
let _ = tokio::process::Command::new("podman")
.args(["tag", &primary_url, docker_image])
.output()
.await;
tracing::info!("Pulled {} from primary registry ({})", docker_image, primary_url);
}
if primary_failed { if primary_failed {
// Try all configured fallback registries dynamically // Primary failed — walk the remaining configured registries.
match crate::container::registry::pull_from_registries( // Skip primary_url so we don't retry what just failed.
match crate::container::registry::pull_from_registries_with_skip(
&self.config.data_dir, &self.config.data_dir,
docker_image, docker_image,
&user_tmp, &user_tmp,
Some(&primary_url),
) )
.await .await
{ {
Ok(_) => { Ok(_) => {
tracing::info!("Pulled {} via dynamic registry fallback", docker_image); tracing::info!("Pulled {} via fallback registry", docker_image);
} }
Err(e) => { Err(e) => {
return Err(anyhow::anyhow!("Image pull failed: {}", e)); return Err(anyhow::anyhow!("Image pull failed: {}", e));
@ -1467,6 +1495,36 @@ server {
Ok(serde_json::json!({ "registries": config.registries, "removed": url })) 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( pub(in crate::api::rpc) async fn handle_registry_test(
&self, &self,
params: Option<serde_json::Value>, params: Option<serde_json::Value>,

View File

@ -44,16 +44,16 @@ impl Default for RegistryConfig {
Self { Self {
registries: vec![ registries: vec![
Registry { Registry {
url: "git.tx1138.com/lfg2025".to_string(), url: "23.182.128.160:3000/lfg2025".to_string(),
name: "Archipelago Primary".to_string(), name: "Server 1 (VPS)".to_string(),
tls_verify: true, tls_verify: false,
enabled: true, enabled: true,
priority: 0, priority: 0,
}, },
Registry { Registry {
url: "23.182.128.160:3000/lfg2025".to_string(), url: "git.tx1138.com/lfg2025".to_string(),
name: "Archipelago Fallback".to_string(), name: "Server 2 (tx1138)".to_string(),
tls_verify: false, tls_verify: true,
enabled: true, enabled: true,
priority: 10, priority: 10,
}, },
@ -94,6 +94,28 @@ impl RegistryConfig {
candidates candidates
} }
/// Rewrite an image to use the highest-priority enabled registry, so
/// the FIRST pull attempt honors the operator's primary choice instead
/// of blindly using whatever registry the image URL was hardcoded to.
/// Returns (rewritten_url, tls_verify) — or the original URL + default
/// tls_verify=true if there's no primary (no enabled registries).
pub fn rewrite_for_primary(&self, image: &str) -> (String, bool) {
match self.active_registries().first() {
Some(primary) => (self.rewrite_image(image, primary), primary.tls_verify),
None => (image.to_string(), true),
}
}
}
/// Load the registry config and rewrite an image to use the primary
/// registry's URL. Convenience wrapper for callers that don't already
/// have a `RegistryConfig` in hand.
pub async fn primary_image_url(data_dir: &Path, image: &str) -> (String, bool) {
match load_registries(data_dir).await {
Ok(config) => config.rewrite_for_primary(image),
Err(_) => (image.to_string(), true),
}
} }
/// Extract the image name from a full image reference. /// Extract the image name from a full image reference.
@ -134,10 +156,20 @@ pub async fn save_registries(data_dir: &Path, config: &RegistryConfig) -> Result
} }
/// Try pulling an image from configured registries in priority order. /// 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. /// Returns the image reference that succeeded.
pub async fn pull_from_registries(data_dir: &Path, image: &str, tmpdir: &str) -> Result<String> { 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 config = load_registries(data_dir).await?;
let candidates = config.image_candidates(image); let mut candidates = config.image_candidates(image);
if let Some(skip) = already_tried {
candidates.retain(|(url, _)| url != skip);
}
for (candidate, tls_verify) in &candidates { for (candidate, tls_verify) in &candidates {
debug!("Trying registry: {}", candidate); debug!("Trying registry: {}", candidate);
@ -196,6 +228,7 @@ pub async fn pull_from_registries(data_dir: &Path, image: &str, tmpdir: &str) ->
)) ))
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -217,9 +250,11 @@ mod tests {
#[test] #[test]
fn test_rewrite_image() { fn test_rewrite_image() {
let config = RegistryConfig::default(); 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!( 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" "23.182.128.160:3000/lfg2025/bitcoin-knots:latest"
); );
} }
@ -228,8 +263,20 @@ mod tests {
fn test_image_candidates() { fn test_image_candidates() {
let config = RegistryConfig::default(); let config = RegistryConfig::default();
let candidates = config.image_candidates("git.tx1138.com/lfg2025/lnd:v0.18.4-beta"); let candidates = config.image_candidates("git.tx1138.com/lfg2025/lnd:v0.18.4-beta");
assert!(candidates.len() >= 2); // Defaults: VPS (primary) + tx1138. tx1138 is filtered out because
assert_eq!(candidates[0].0, "git.tx1138.com/lfg2025/lnd:v0.18.4-beta"); // it's identical to the original image URL, leaving one candidate.
assert_eq!(candidates.len(), 1);
// Primary-first — VPS rewrite leads the candidate list.
assert_eq!(candidates[0].0, "23.182.128.160:3000/lfg2025/lnd:v0.18.4-beta");
}
#[test]
fn test_rewrite_for_primary_uses_top_priority() {
let config = RegistryConfig::default();
let (url, tls) =
config.rewrite_for_primary("git.tx1138.com/lfg2025/lnd:v0.18.4-beta");
assert_eq!(url, "23.182.128.160:3000/lfg2025/lnd:v0.18.4-beta");
assert!(!tls, "VPS primary is HTTP — tls_verify should be false");
} }
#[test] #[test]

View File

@ -193,6 +193,11 @@ const router = createRouter({
name: 'system-update', name: 'system-update',
component: () => import('../views/SystemUpdate.vue'), component: () => import('../views/SystemUpdate.vue'),
}, },
{
path: 'settings/registries',
name: 'app-registries',
component: () => import('../views/AppRegistries.vue'),
},
{ {
path: 'goals/:goalId', path: 'goals/:goalId',
name: 'goal-detail', name: 'goal-detail',

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>

View File

@ -180,6 +180,19 @@ init()
</button> </button>
</div> </div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1"> <div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.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 --> <!-- v1.7.28-alpha -->
<div> <div>
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">

View File

@ -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>

View File

@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import ScreensaverRing from '@/components/ScreensaverRing.vue' import ScreensaverRing from '@/components/ScreensaverRing.vue'
import ScreensaverLogo from '@/components/ScreensaverLogo.vue'
const router = useRouter() const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
@ -170,8 +171,13 @@ async function performFactoryReset() {
v-if="rebootOverlay" v-if="rebootOverlay"
class="fixed inset-0 z-[3000] bg-black flex flex-col items-center justify-center overflow-hidden" 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 --> <!-- Centered animated ring + logo same composition as the screensaver -->
<ScreensaverRing /> <div class="reboot-ring-content">
<ScreensaverRing />
<div class="reboot-logo-wrapper">
<ScreensaverLogo />
</div>
</div>
<!-- Stage text + progress bar underneath --> <!-- Stage text + progress bar underneath -->
<div class="mt-8 w-[min(520px,80vw)] text-center"> <div class="mt-8 w-[min(520px,80vw)] text-center">
@ -258,6 +264,20 @@ async function performFactoryReset() {
opacity: 0; 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 { .reboot-overlay-bar-anim {
animation: rebootBarSlide 1.8s ease-in-out infinite; animation: rebootBarSlide 1.8s ease-in-out infinite;
} }

View File

@ -3,6 +3,7 @@ import InterfaceModeSection from '@/views/settings/InterfaceModeSection.vue'
import ClaudeAuthSection from '@/views/settings/ClaudeAuthSection.vue' import ClaudeAuthSection from '@/views/settings/ClaudeAuthSection.vue'
import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue' import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue'
import SystemUpdatesSection from '@/views/settings/SystemUpdatesSection.vue' import SystemUpdatesSection from '@/views/settings/SystemUpdatesSection.vue'
import AppRegistriesSection from '@/views/settings/AppRegistriesSection.vue'
import WebhookSection from '@/views/settings/WebhookSection.vue' import WebhookSection from '@/views/settings/WebhookSection.vue'
import TelemetrySection from '@/views/settings/TelemetrySection.vue' import TelemetrySection from '@/views/settings/TelemetrySection.vue'
import BackupSection from '@/views/settings/BackupSection.vue' import BackupSection from '@/views/settings/BackupSection.vue'
@ -14,6 +15,7 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
<ClaudeAuthSection /> <ClaudeAuthSection />
<AIDataAccessSection /> <AIDataAccessSection />
<SystemUpdatesSection /> <SystemUpdatesSection />
<AppRegistriesSection />
<WebhookSection /> <WebhookSection />
<TelemetrySection /> <TelemetrySection />
<BackupSection /> <BackupSection />

View File

@ -1,26 +1,28 @@
{ {
"version": "1.7.28-alpha", "version": "1.7.29-alpha",
"release_date": "2026-04-21", "release_date": "2026-04-21",
"changelog": [ "changelog": [
"Reboot now shows a proper progress screen. Click Reboot and you'll see a full-screen overlay with the familiar pulsing ring, a rebooting / reconnecting / back-online status, and an elapsed counter. The page auto-reloads the moment your node is back up; if it takes longer than three minutes, a manual Reload button appears.", "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.",
"New nodes default to the VPS mirror as Server 1 (primary) and tx1138 as Server 2 (fallback). Existing nodes keep whatever mirror order they've already set — use Set Primary on the System Update page to change it any time." "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.",
"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.",
"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."
], ],
"components": [ "components": [
{ {
"name": "archipelago", "name": "archipelago",
"current_version": "1.7.27-alpha", "current_version": "1.7.28-alpha",
"new_version": "1.7.28-alpha", "new_version": "1.7.29-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.28-alpha/archipelago", "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.29-alpha/archipelago",
"sha256": "eba06b1c10a90a796e14b9e810900f77a6774ba7dfc782bb26b41d7f7800a605", "sha256": "38cb4f99c2af896de2f10db358b68824e07744c34c89d0e8d0e8b41c78c0cf33",
"size_bytes": 40888296 "size_bytes": 40753856
}, },
{ {
"name": "archipelago-frontend-1.7.28-alpha.tar.gz", "name": "archipelago-frontend-1.7.29-alpha.tar.gz",
"current_version": "1.7.27-alpha", "current_version": "1.7.28-alpha",
"new_version": "1.7.28-alpha", "new_version": "1.7.29-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.28-alpha/archipelago-frontend-1.7.28-alpha.tar.gz", "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.29-alpha/archipelago-frontend-1.7.29-alpha.tar.gz",
"sha256": "c7eec98be09b0f7f9f04fcbfea594bea4f2a738943cdaa3427251b402ac62620", "sha256": "0b2033d029324966d9ad7dcd2de745b1037964365596b8b9fb55a84c9396050b",
"size_bytes": 77000104 "size_bytes": 77004776
} }
] ]
} }

Binary file not shown.