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:
parent
79ae14a127
commit
7432d84545
2
core/Cargo.lock
generated
2
core/Cargo.lock
generated
@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.28-alpha"
|
||||
version = "1.7.29-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.28-alpha"
|
||||
version = "1.7.29-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -649,8 +649,24 @@ impl RpcHandler {
|
||||
);
|
||||
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")
|
||||
.args(["pull", docker_image])
|
||||
.args(&pull_args)
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
@ -684,23 +700,35 @@ impl RpcHandler {
|
||||
true
|
||||
}
|
||||
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.wait().await; // reap zombie
|
||||
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 {
|
||||
// Try all configured fallback registries dynamically
|
||||
match crate::container::registry::pull_from_registries(
|
||||
// Primary failed — walk the remaining configured 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,
|
||||
docker_image,
|
||||
&user_tmp,
|
||||
Some(&primary_url),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
tracing::info!("Pulled {} via dynamic registry fallback", docker_image);
|
||||
tracing::info!("Pulled {} via fallback registry", docker_image);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(anyhow::anyhow!("Image pull failed: {}", e));
|
||||
@ -1467,6 +1495,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>,
|
||||
|
||||
@ -44,16 +44,16 @@ 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,
|
||||
},
|
||||
@ -94,6 +94,28 @@ impl RegistryConfig {
|
||||
|
||||
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.
|
||||
@ -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.
|
||||
/// If `already_tried` is Some, that URL is skipped (avoids retrying the
|
||||
/// primary when the caller already attempted it with progress streaming).
|
||||
/// Returns the image reference that succeeded.
|
||||
pub async fn pull_from_registries(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 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 {
|
||||
debug!("Trying registry: {}", candidate);
|
||||
@ -196,6 +228,7 @@ pub async fn pull_from_registries(data_dir: &Path, image: &str, tmpdir: &str) ->
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -217,9 +250,11 @@ 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"
|
||||
);
|
||||
}
|
||||
@ -228,8 +263,20 @@ mod tests {
|
||||
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");
|
||||
// Defaults: VPS (primary) + tx1138. tx1138 is filtered out because
|
||||
// it's identical to the original image URL, leaving one candidate.
|
||||
assert_eq!(candidates.len(), 1);
|
||||
// Primary-first — VPS rewrite leads the candidate list.
|
||||
assert_eq!(candidates[0].0, "23.182.128.160:3000/lfg2025/lnd:v0.18.4-beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rewrite_for_primary_uses_top_priority() {
|
||||
let config = RegistryConfig::default();
|
||||
let (url, tls) =
|
||||
config.rewrite_for_primary("git.tx1138.com/lfg2025/lnd:v0.18.4-beta");
|
||||
assert_eq!(url, "23.182.128.160:3000/lfg2025/lnd:v0.18.4-beta");
|
||||
assert!(!tls, "VPS primary is HTTP — tls_verify should be false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -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',
|
||||
|
||||
330
neode-ui/src/views/AppRegistries.vue
Normal file
330
neode-ui/src/views/AppRegistries.vue
Normal 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>
|
||||
@ -180,6 +180,19 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<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 -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
27
neode-ui/src/views/settings/AppRegistriesSection.vue
Normal file
27
neode-ui/src/views/settings/AppRegistriesSection.vue
Normal 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>
|
||||
@ -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,26 +1,28 @@
|
||||
{
|
||||
"version": "1.7.28-alpha",
|
||||
"version": "1.7.29-alpha",
|
||||
"release_date": "2026-04-21",
|
||||
"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 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 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 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": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.27-alpha",
|
||||
"new_version": "1.7.28-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.28-alpha/archipelago",
|
||||
"sha256": "eba06b1c10a90a796e14b9e810900f77a6774ba7dfc782bb26b41d7f7800a605",
|
||||
"size_bytes": 40888296
|
||||
"current_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.29-alpha/archipelago",
|
||||
"sha256": "38cb4f99c2af896de2f10db358b68824e07744c34c89d0e8d0e8b41c78c0cf33",
|
||||
"size_bytes": 40753856
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.28-alpha.tar.gz",
|
||||
"current_version": "1.7.27-alpha",
|
||||
"new_version": "1.7.28-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",
|
||||
"sha256": "c7eec98be09b0f7f9f04fcbfea594bea4f2a738943cdaa3427251b402ac62620",
|
||||
"size_bytes": 77000104
|
||||
"name": "archipelago-frontend-1.7.29-alpha.tar.gz",
|
||||
"current_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.29-alpha/archipelago-frontend-1.7.29-alpha.tar.gz",
|
||||
"sha256": "0b2033d029324966d9ad7dcd2de745b1037964365596b8b9fb55a84c9396050b",
|
||||
"size_bytes": 77004776
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
BIN
releases/v1.7.29-alpha/archipelago
Executable file
BIN
releases/v1.7.29-alpha/archipelago
Executable file
Binary file not shown.
BIN
releases/v1.7.29-alpha/archipelago-frontend-1.7.29-alpha.tar.gz
Normal file
BIN
releases/v1.7.29-alpha/archipelago-frontend-1.7.29-alpha.tar.gz
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user