Compare commits

..
Author SHA1 Message Date
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
DorianandClaude Opus 4.7 2664074210 release(v1.7.28-alpha): reboot progress overlay + VPS default primary
- New reboot progress overlay: full-screen black with the screensaver's
  pulsing ring, rebooting → reconnecting → back-online → stalled stages,
  elapsed counter, auto-reload on health-check success, manual reload
  button at 3 min stall. Mirrors the existing update overlay.
- Ring extracted from Screensaver.vue into a reusable ScreensaverRing
  component so the reboot overlay reuses the same animation.
- default_mirrors() now puts the VPS as Server 1 (primary) and tx1138 as
  Server 2 — new nodes fetch manifests from VPS first; existing nodes
  keep whatever mirror order they've customized.
- What's New entry prepended for v1.7.28-alpha.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:06:37 -04:00
23 changed files with 921 additions and 265 deletions
+1 -1
View File
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.27-alpha"
version = "1.7.30-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.27-alpha"
version = "1.7.30-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
+146 -38
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>,
@@ -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);
@@ -247,6 +247,7 @@ impl DockerPackageScanner {
status: service_status,
}),
install_progress: None,
uninstall_stage: None,
};
packages.insert(app_id.clone(), package);
+21 -96
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.
@@ -133,69 +126,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 +147,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 +185,6 @@ mod tests {
});
save_registries(tmp.path(), &config).await.unwrap();
let loaded = load_registries(tmp.path()).await.unwrap();
assert_eq!(loaded.registries.len(), 3);
assert_eq!(loaded.registries.len(), 4);
}
}
+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>,
+4 -4
View File
@@ -90,12 +90,12 @@ fn mirrors_path(data_dir: &Path) -> std::path::PathBuf {
fn default_mirrors() -> Vec<UpdateMirror> {
vec![
UpdateMirror {
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
label: "Server 1 (tx1138)".to_string(),
url: DEFAULT_SECONDARY_MIRROR_URL.to_string(),
label: "Server 1 (VPS)".to_string(),
},
UpdateMirror {
url: DEFAULT_SECONDARY_MIRROR_URL.to_string(),
label: "Server 2 (VPS)".to_string(),
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
label: "Server 2 (tx1138)".to_string(),
},
]
}
+7 -111
View File
@@ -13,15 +13,7 @@
</div>
<!-- Normal logo with audio viz ring -->
<div v-else class="screensaver-content">
<!-- Radial audio visualization - bars around the logo -->
<div class="screensaver-viz-ring">
<div
v-for="(_, i) in segmentCount"
:key="i"
class="screensaver-viz-segment"
:style="getSegmentStyle(i)"
/>
</div>
<ScreensaverRing />
<!-- Logo in center -->
<div class="screensaver-logo-wrapper">
<ScreensaverLogo />
@@ -35,21 +27,12 @@
<script setup lang="ts">
import { onMounted, onBeforeUnmount } from 'vue'
import ScreensaverLogo from '@/components/ScreensaverLogo.vue'
import ScreensaverRing from '@/components/ScreensaverRing.vue'
import BitcoinFaceAscii from '@/views/discover/BitcoinFaceAscii.vue'
import { useScreensaverStore } from '@/stores/screensaver'
const store = useScreensaverStore()
const segmentCount = 48
function getSegmentStyle(i: number) {
const deg = (i / segmentCount) * 360
return {
'--segment-index': i,
'--segment-deg': `${deg}deg`,
}
}
// Dismiss on any key (except when typing)
function onKeyDown(e: KeyboardEvent) {
if (store.isActive) {
@@ -86,102 +69,15 @@ onBeforeUnmount(() => {
.screensaver-content {
position: relative;
width: 280px;
height: 280px;
flex-shrink: 0;
}
@media (min-width: 640px) {
.screensaver-content {
width: 360px;
height: 360px;
}
}
@media (min-width: 768px) {
.screensaver-content {
width: 400px;
height: 400px;
}
}
/* Ring of segments around the logo - audio viz style (behind logo) */
.screensaver-viz-ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
--viz-radius: 140px;
}
@media (min-width: 640px) {
.screensaver-viz-ring {
--viz-radius: 180px;
}
}
@media (min-width: 768px) {
.screensaver-viz-ring {
--viz-radius: 200px;
}
}
.screensaver-viz-segment {
position: absolute;
left: 50%;
top: 50%;
width: 4px;
height: 24px;
margin-left: -2px;
margin-top: -12px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.1));
border-radius: 2px;
/* Origin at segment center = ring center (segment is centered via left/top 50%) */
transform-origin: center center;
transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius)));
animation: segment-pulse 14s ease-in-out infinite;
animation-delay: calc(var(--segment-index) * 0.02s);
}
@media (min-width: 768px) {
.screensaver-viz-segment {
height: 28px;
margin-top: -14px;
}
}
/* 5 normal loops (10s) then stronger longer expression (4s) - total 14s */
@keyframes segment-pulse {
/* Loop 1 */
0% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
7.1% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
14.3% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
/* Loop 2 */
21.4% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
28.6% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
/* Loop 3 */
35.7% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
42.9% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
/* Loop 4 */
50% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
57.1% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
/* Loop 5 */
64.3% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
71.4% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
/* Strong expression: ramp up (1.5s), hold (2s), ease back (0.5s) */
78.6% { opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1.5); }
85.7% { opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1.5); }
92.9% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
100% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
display: grid;
place-items: center;
}
.screensaver-logo-wrapper {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
inset: 0;
display: grid;
place-items: center;
z-index: 10;
filter: drop-shadow(0 0 40px rgba(255, 255, 255, 0.15));
}
+114
View File
@@ -0,0 +1,114 @@
<template>
<div class="viz-ring" :class="sizeClass">
<div
v-for="(_, i) in segmentCount"
:key="i"
class="viz-segment"
:style="getSegmentStyle(i)"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
/** Visual size: 'default' matches the screensaver; 'compact' drops the
* min-width breakpoints (useful inside overlays on narrower canvases). */
size?: 'default' | 'compact'
/** Override segment count. Defaults to 48 (screensaver standard). */
segmentCount?: number
}>(), { size: 'default', segmentCount: 48 })
const sizeClass = computed(() => props.size === 'compact' ? 'viz-ring-compact' : 'viz-ring-default')
function getSegmentStyle(i: number) {
const deg = (i / props.segmentCount) * 360
return {
'--segment-index': i,
'--segment-deg': `${deg}deg`,
}
}
</script>
<style scoped>
.viz-ring {
position: relative;
pointer-events: none;
}
.viz-ring-default {
width: 280px;
height: 280px;
--viz-radius: 140px;
}
@media (min-width: 640px) {
.viz-ring-default {
width: 360px;
height: 360px;
--viz-radius: 180px;
}
}
@media (min-width: 768px) {
.viz-ring-default {
width: 400px;
height: 400px;
--viz-radius: 200px;
}
}
.viz-ring-compact {
width: 240px;
height: 240px;
--viz-radius: 120px;
}
@media (min-width: 768px) {
.viz-ring-compact {
width: 320px;
height: 320px;
--viz-radius: 160px;
}
}
.viz-segment {
position: absolute;
left: 50%;
top: 50%;
width: 4px;
height: 24px;
margin-left: -2px;
margin-top: -12px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.1));
border-radius: 2px;
transform-origin: center center;
transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius)));
animation: segment-pulse 14s ease-in-out infinite;
animation-delay: calc(var(--segment-index) * 0.02s);
}
@media (min-width: 768px) {
.viz-segment {
height: 28px;
margin-top: -14px;
}
}
/* 5 normal loops (10s) then stronger longer expression (4s) — total 14s */
@keyframes segment-pulse {
0% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
7.1% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
14.3%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
21.4%{ opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
28.6%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
35.7%{ opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
42.9%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
50% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
57.1%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
64.3%{ opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
71.4%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
78.6%{ opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1.5); }
85.7%{ opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1.5); }
92.9%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
100% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
}
</style>
+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',
+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)
}
+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,43 @@ 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">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.28-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>Reboot now shows a proper progress screen. Click Reboot and you'll see a full-screen overlay with the familiar pulsing ring animation, a rebooting / reconnecting / back-online status, and an elapsed counter no more black screen of mystery while you wait.</p>
<p>The overlay auto-reloads the page the moment your node is back up; if it takes longer than three minutes it surfaces a manual Reload button.</p>
<p>New nodes now 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.</p>
</div>
</div>
<!-- v1.7.27-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>
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed, onBeforeUnmount } from 'vue'
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()
@@ -13,6 +15,59 @@ const rebooting = ref(false)
const rebootPassword = ref('')
const rebootError = ref('')
// Reboot overlay full-screen progress shown once the reboot is committed.
// Mirrors the update overlay pattern in SystemUpdate.vue: poll /health,
// auto-reload when the backend returns, stall fallback at 3 min.
type RebootStage = 'rebooting' | 'reconnecting' | 'ready' | 'stalled'
const rebootOverlay = ref(false)
const rebootStage = ref<RebootStage>('rebooting')
const rebootStartedAt = ref(0)
const rebootElapsedSec = ref(0)
let rebootPollTimer: ReturnType<typeof setInterval> | null = null
let rebootElapsedTimer: ReturnType<typeof setInterval> | null = null
const rebootElapsedLabel = computed(() => {
const s = rebootElapsedSec.value
if (s < 60) return `Elapsed: ${s}s`
return `Elapsed: ${Math.floor(s / 60)}m${s % 60 < 10 ? '0' : ''}${s % 60}s`
})
function startRebootOverlay() {
rebootOverlay.value = true
rebootStage.value = 'rebooting'
rebootStartedAt.value = Date.now()
rebootElapsedSec.value = 0
rebootElapsedTimer = setInterval(() => {
rebootElapsedSec.value = Math.floor((Date.now() - rebootStartedAt.value) / 1000)
if (rebootElapsedSec.value >= 180 && rebootStage.value !== 'ready') {
rebootStage.value = 'stalled'
}
}, 1000)
// Start health polling after 2.5s the kernel has to go down before
// /health can disappear, and we don't want to see the pre-reboot health
// reply and mis-report "ready".
setTimeout(() => {
rebootStage.value = 'reconnecting'
rebootPollTimer = setInterval(pollRebootHealth, 1500)
}, 2500)
}
async function pollRebootHealth() {
if (rebootStage.value === 'ready' || rebootStage.value === 'stalled') return
try {
const res = await fetch('/health', { signal: AbortSignal.timeout(2000) })
if (!res.ok) throw new Error(`health ${res.status}`)
rebootStage.value = 'ready'
if (rebootPollTimer) { clearInterval(rebootPollTimer); rebootPollTimer = null }
setTimeout(() => { window.location.reload() }, 1200)
} catch {
// Fetch failing is the normal state while the host is down.
}
}
function rebootReloadNow() { window.location.reload() }
onBeforeUnmount(() => {
if (rebootPollTimer) clearInterval(rebootPollTimer)
if (rebootElapsedTimer) clearInterval(rebootElapsedTimer)
})
async function performReboot() {
if (!rebootPassword.value) return
rebooting.value = true
@@ -21,6 +76,7 @@ async function performReboot() {
await rpcClient.call({ method: 'system.reboot', params: { password: rebootPassword.value } })
showRebootConfirm.value = false
rebootPassword.value = ''
startRebootOverlay()
} catch (e) {
rebootError.value = e instanceof Error ? e.message : 'Reboot failed'
rebooting.value = false
@@ -108,6 +164,55 @@ async function performFactoryReset() {
</div>
</Teleport>
<!-- Reboot Progress Overlay -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="rebootOverlay"
class="fixed inset-0 z-[3000] bg-black flex flex-col items-center justify-center overflow-hidden"
>
<!-- 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">
<h2 class="text-xl font-semibold text-white mb-1">
{{ rebootStage === 'rebooting' ? 'Rebooting…'
: rebootStage === 'reconnecting' ? 'Reconnecting to your node…'
: rebootStage === 'ready' ? 'Back online'
: 'Reboot is taking longer than expected' }}
</h2>
<p class="text-sm text-white/60 mb-4">
Your node is restarting. This page will refresh automatically once it's back.
</p>
<!-- Animated progress bar: indeterminate stripe while working,
solid green when ready, paused at half while stalled. -->
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden mb-3 relative">
<div v-if="rebootStage === 'ready'" class="absolute inset-0 bg-green-400"></div>
<div v-else-if="rebootStage === 'stalled'" class="absolute inset-y-0 left-0 w-1/2 bg-orange-400/60"></div>
<div v-else class="absolute inset-y-0 w-1/3 bg-orange-400 rounded-full reboot-overlay-bar-anim"></div>
</div>
<p class="text-xs text-white/40">{{ rebootElapsedLabel }}</p>
<button
v-if="rebootStage === 'stalled'"
@click="rebootReloadNow"
class="mt-5 glass-button rounded-lg px-5 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30"
>
Reload now
</button>
</div>
</div>
</Transition>
</Teleport>
<!-- Factory Reset Section -->
<div class="glass-card px-6 py-6 mb-6 border border-red-500/30">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
@@ -148,3 +253,37 @@ async function performFactoryReset() {
</div>
</Teleport>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
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;
}
@keyframes rebootBarSlide {
0% { transform: translateX(-100%); }
50% { transform: translateX(120%); }
100% { transform: translateX(300%); }
}
</style>
@@ -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 />