Compare commits

...
Author SHA1 Message Date
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
DorianandClaude Opus 4.7 9868991900 release(v1.7.27-alpha): mirror transparency — served-by line + one-click test button
- New "Served by {mirror}" line on the System Update page so operators can see
  which mirror actually served the available manifest (vs. which is configured
  primary). Backend threads the served URL through UpdateState.manifest_mirror.
- New update.test-mirror RPC + per-row lightning-bolt button that pings a
  mirror and renders reachable/latency or error inline under the URL.
- UI polish on the mirrors section: Set Primary, Remove, and the new Test
  action are compact icon buttons; add-mirror form moved into a dialog.
- "What's New" block prepended for v1.7.27-alpha.

21/21 update module tests pass. vue-tsc + vite build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:05:42 -04:00
10 changed files with 537 additions and 160 deletions
+1 -1
View File
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.26-alpha"
version = "1.7.28-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.26-alpha"
version = "1.7.28-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
@@ -444,6 +444,10 @@ impl RpcHandler {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_update_set_primary_mirror(&p).await
}
"update.test-mirror" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_update_test_mirror(&p).await
}
"update.apply" => self.handle_update_apply().await,
"update.git-apply" => self.handle_update_git_apply().await,
"update.rollback" => self.handle_update_rollback().await,
+16
View File
@@ -40,6 +40,7 @@ impl RpcHandler {
"last_check": state.last_check,
"update_available": update_info.is_some(),
"update": update_info,
"manifest_mirror": state.manifest_mirror,
}))
}
@@ -195,6 +196,7 @@ impl RpcHandler {
"update_available": state.available_update.is_some(),
"update_in_progress": state.update_in_progress,
"rollback_available": state.rollback_available,
"manifest_mirror": state.manifest_mirror,
"download_progress": if active || completed {
Some(serde_json::json!({
"bytes_downloaded": downloaded,
@@ -290,6 +292,20 @@ impl RpcHandler {
Ok(serde_json::json!({ "mirrors": list }))
}
/// Ping a mirror's manifest URL. Returns reachability, wall-clock
/// latency, and HTTP status. Params: `{ url }`.
pub(super) async fn handle_update_test_mirror(
&self,
params: &serde_json::Value,
) -> Result<serde_json::Value> {
let url = params
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing url"))?;
let result = update::test_mirror(url).await;
Ok(serde_json::to_value(result)?)
}
/// Move a mirror to the top of the list so it's tried first.
/// Params: `{ url }`.
pub(super) async fn handle_update_set_primary_mirror(
+78 -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(),
},
]
}
@@ -218,6 +218,11 @@ pub struct UpdateState {
pub rollback_available: bool,
#[serde(default)]
pub schedule: UpdateSchedule,
/// URL of the mirror whose manifest populated `available_update`.
/// Surfaces in the UI so operators can tell at a glance which mirror
/// their node actually hit (vs. just which is configured primary).
#[serde(default)]
pub manifest_mirror: Option<String>,
}
impl Default for UpdateState {
@@ -229,6 +234,7 @@ impl Default for UpdateState {
update_in_progress: false,
rollback_available: false,
schedule: UpdateSchedule::DailyCheck,
manifest_mirror: None,
}
}
}
@@ -260,6 +266,7 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
// it unconditionally; the next check_for_updates will repopulate
// if there's genuinely something newer.
state.available_update = None;
state.manifest_mirror = None;
save_state(data_dir, &state).await?;
}
Ok(state)
@@ -328,6 +335,7 @@ pub async fn check_for_updates(data_dir: &Path) -> Result<UpdateState> {
"Update available"
);
state.available_update = Some(manifest);
state.manifest_mirror = Some(manifest_url.clone());
} else {
// Manifest version matches us or is behind
// us — either we're current, or this mirror
@@ -347,6 +355,7 @@ pub async fn check_for_updates(data_dir: &Path) -> Result<UpdateState> {
// break: another mirror could be ahead.
continue 'mirrors;
}
state.manifest_mirror = None;
state.available_update = None;
}
handled = true;
@@ -375,6 +384,66 @@ pub async fn check_for_updates(data_dir: &Path) -> Result<UpdateState> {
Ok(state)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MirrorTestResult {
pub reachable: bool,
pub latency_ms: u64,
pub http_status: Option<u16>,
pub error: Option<String>,
}
/// Ping a mirror's manifest URL and return reachability + wall-clock
/// latency. Used by the "Test mirror" button so operators can sanity-
/// check a newly added mirror without running a full update check.
pub async fn test_mirror(url: &str) -> MirrorTestResult {
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.connect_timeout(std::time::Duration::from_secs(5))
.build()
{
Ok(c) => c,
Err(e) => {
return MirrorTestResult {
reachable: false,
latency_ms: 0,
http_status: None,
error: Some(format!("client build failed: {}", e)),
}
}
};
let start = std::time::Instant::now();
match client.get(url).send().await {
Ok(resp) => {
let latency_ms = start.elapsed().as_millis() as u64;
let status = resp.status();
if status.is_success() {
MirrorTestResult {
reachable: true,
latency_ms,
http_status: Some(status.as_u16()),
error: None,
}
} else {
MirrorTestResult {
reachable: false,
latency_ms,
http_status: Some(status.as_u16()),
error: Some(format!("HTTP {}", status.as_u16())),
}
}
}
Err(e) => {
let latency_ms = start.elapsed().as_millis() as u64;
MirrorTestResult {
reachable: false,
latency_ms,
http_status: None,
error: Some(e.to_string()),
}
}
}
}
/// Get current update status without checking remote.
pub async fn get_status(data_dir: &Path) -> Result<UpdateState> {
load_state(data_dir).await
@@ -1155,6 +1224,7 @@ mod tests {
update_in_progress: false,
rollback_available: true,
schedule: UpdateSchedule::AutoApply,
manifest_mirror: None,
};
let json = serde_json::to_string(&state).unwrap();
let deserialized: UpdateState = serde_json::from_str(&json).unwrap();
@@ -1265,6 +1335,10 @@ mod tests {
update_in_progress: true,
rollback_available: false,
schedule: UpdateSchedule::Manual,
manifest_mirror: Some(
"https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json"
.to_string(),
),
};
save_state(dir.path(), &state).await.unwrap();
let loaded = load_state(dir.path()).await.unwrap();
+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>
+172 -42
View File
@@ -42,6 +42,12 @@
<div>
<h2 class="text-lg font-semibold text-white">{{ t('systemUpdate.updateAvailable') }}</h2>
<p class="text-sm text-white/60">Version {{ updateInfo.version }} &mdash; {{ updateInfo.release_date }}</p>
<p v-if="manifestMirrorLabel" class="text-xs text-white/40 mt-1 flex items-center gap-1.5">
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7" />
</svg>
<span>Served by <span class="text-white/70">{{ manifestMirrorLabel }}</span></span>
</p>
</div>
<span class="px-3 py-1 bg-orange-500/20 text-orange-400 text-xs font-medium rounded-full">{{ t('systemUpdate.new') }}</span>
</div>
@@ -179,57 +185,78 @@
<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="addingMirror = !addingMirror"
>{{ addingMirror ? 'Cancel' : '+ Add mirror' }}</button>
@click="openAddMirror"
>+ Add mirror</button>
</div>
<p class="text-sm text-white/60 mb-4">
Servers this node checks for updates. The primary is tried first; if it's slow or unreachable, the next one in the list is tried automatically. Downloads always come from the mirror that served the manifest — switching primary switches where files come from.
</p>
<ul v-if="mirrors.length" class="space-y-2 mb-3">
<li v-for="(m, i) in mirrors" :key="m.url" class="p-3 bg-white/5 rounded-lg flex items-start gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5">
<p class="text-sm font-medium text-white truncate">{{ m.label || `Mirror ${i + 1}` }}</p>
<span v-if="i === 0" class="text-[10px] font-mono px-2 py-0.5 rounded bg-green-500/20 text-green-300">PRIMARY</span>
<ul v-if="mirrors.length" class="space-y-2">
<li v-for="(m, i) in mirrors" :key="m.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">{{ m.label || `Mirror ${i + 1}` }}</p>
<span v-if="i === 0" class="text-[10px] font-mono px-2 py-0.5 rounded bg-green-500/20 text-green-300">PRIMARY</span>
</div>
<p class="text-xs text-white/50 font-mono break-all">{{ m.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="mirrorTests[m.url]?.testing"
title="Test reachability"
@click="testMirror(m.url)"
>
<svg v-if="mirrorTests[m.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="i !== 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="setPrimaryMirror(m.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="mirrors.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 mirror"
@click="removeMirror(m.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>
<p class="text-xs text-white/50 font-mono break-all">{{ m.url }}</p>
</div>
<div class="shrink-0 flex flex-col gap-1">
<button
v-if="i !== 0"
type="button"
class="text-xs px-2 py-1 rounded-md text-white/70 hover:bg-white/10 hover:text-white transition-colors"
title="Make this the primary mirror"
@click="setPrimaryMirror(m.url)"
>Set primary</button>
<button
v-if="mirrors.length > 1"
type="button"
class="text-xs px-2 py-1 rounded-md text-red-300 hover:bg-red-400/10 transition-colors"
@click="removeMirror(m.url)"
>Remove</button>
<div v-if="mirrorTests[m.url] && !mirrorTests[m.url]?.testing" class="mt-2 pt-2 border-t border-white/5 text-xs">
<span v-if="mirrorTests[m.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 &middot; {{ mirrorTests[m.url]?.latency_ms }}ms
</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">{{ mirrorTests[m.url]?.error || 'Unreachable' }}</span>
</span>
</div>
</li>
</ul>
<form v-if="addingMirror" class="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-3" @submit.prevent="submitMirror">
<input
v-model="mirrorDraft.url"
type="text"
placeholder="https://host/.../manifest.json"
class="sm:col-span-2 px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
/>
<input
v-model="mirrorDraft.label"
type="text"
placeholder="Label (optional)"
class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
/>
<button
type="submit"
class="sm:col-span-3 min-h-[40px] glass-button rounded-lg text-sm font-medium disabled:opacity-60"
:disabled="mirrorSaving || !mirrorDraft.url.trim()"
>{{ mirrorSaving ? 'Adding' : 'Add mirror' }}</button>
</form>
</div>
<!-- Actions row -->
@@ -317,6 +344,51 @@
</Transition>
</Teleport>
<!-- Add-mirror modal -->
<Transition name="fade">
<div v-if="addingMirror" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelAddMirror">
<div class="glass-card p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold text-white mb-1">Add update mirror</h3>
<p class="text-sm text-white/60 mb-5">
The URL should point directly at a <span class="font-mono text-white/80">manifest.json</span> served by a Gitea mirror or equivalent. It's added to the end of the list; use "Make primary" to change order.
</p>
<form class="space-y-3" @submit.prevent="submitMirror">
<div>
<label class="block text-xs text-white/60 mb-1">Manifest URL</label>
<input
v-model="mirrorDraft.url"
type="text"
autofocus
placeholder="https://host/.../manifest.json"
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>
<div>
<label class="block text-xs text-white/60 mb-1">Label (optional)</label>
<input
v-model="mirrorDraft.label"
type="text"
placeholder="Home VPS"
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 class="flex gap-3 justify-end pt-2">
<button
type="button"
@click="cancelAddMirror"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
>{{ t('common.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="mirrorSaving || !mirrorDraft.url.trim()"
>{{ mirrorSaving ? 'Adding…' : 'Add mirror' }}</button>
</div>
</form>
</div>
</div>
</Transition>
<!-- Confirmation modal -->
<Transition name="fade">
<div v-if="confirmAction" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelConfirm">
@@ -416,6 +488,60 @@ const addingMirror = ref(false)
const mirrorSaving = ref(false)
const mirrorDraft = reactive({ url: '', label: '' })
// URL of the mirror that served the currently-available-update manifest.
// Backend reports it in update.status and update.check responses; the UI
// resolves it to a friendly label by matching against the mirrors list.
const manifestMirror = ref<string | null>(null)
const manifestMirrorLabel = computed(() => {
const url = manifestMirror.value
if (!url) return null
const match = mirrors.value.find(m => m.url === url)
if (match && match.label) return match.label
try {
const u = new URL(url)
return u.host
} catch {
return url
}
})
// Per-mirror test state. Populated by testMirror(); each entry is either
// { testing: true } while in flight or the backend response shape on
// completion. Rendered inline under each mirror row.
interface MirrorTestState {
testing?: boolean
reachable?: boolean
latency_ms?: number
http_status?: number | null
error?: string | null
}
const mirrorTests = ref<Record<string, MirrorTestState>>({})
async function testMirror(url: string) {
mirrorTests.value = { ...mirrorTests.value, [url]: { testing: true } }
try {
const res = await rpcClient.call<{
reachable: boolean
latency_ms: number
http_status: number | null
error: string | null
}>({ method: 'update.test-mirror', params: { url } })
mirrorTests.value = { ...mirrorTests.value, [url]: { ...res, testing: false } }
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
mirrorTests.value = { ...mirrorTests.value, [url]: { testing: false, reachable: false, error: msg } }
}
}
function openAddMirror() {
mirrorDraft.url = ''
mirrorDraft.label = ''
addingMirror.value = true
}
function cancelAddMirror() {
addingMirror.value = false
}
async function loadMirrors() {
try {
const res = await rpcClient.call<{ mirrors: UpdateMirror[] }>({ method: 'update.list-mirrors' })
@@ -621,11 +747,13 @@ async function loadStatus() {
update_available: boolean
update_in_progress: boolean
rollback_available: boolean
manifest_mirror: string | null
}>({ method: 'update.status' })
currentVersion.value = res.current_version
lastCheck.value = res.last_check
updateInProgress.value = res.update_in_progress
rollbackAvailable.value = res.rollback_available
manifestMirror.value = res.manifest_mirror ?? null
if (res.update_in_progress) {
downloaded.value = true
@@ -645,11 +773,13 @@ async function checkForUpdates() {
update_available: boolean
update: UpdateDetail | null
update_method?: string
manifest_mirror?: string | null
}>({ method: 'update.check' })
currentVersion.value = res.current_version
lastCheck.value = res.last_check
updateInfo.value = res.update
updateMethod.value = res.update_method === 'git' ? 'git' : 'manifest'
manifestMirror.value = res.manifest_mirror ?? null
if (!res.update_available) {
showStatus(t('systemUpdate.upToDateMessage'))
}
@@ -180,6 +180,30 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- 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">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.27-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>The Update page now shows which mirror delivered your update a small "Served by" line under the new version tells you whether Server 1, Server 2, or a custom mirror was the one your node actually reached. Great for spot-checking that mirror fallback is doing its job.</p>
<p>Every mirror row has a new lightning-bolt button that pings the mirror and shows whether it's reachable, plus the round-trip latency in milliseconds. No more guessing if a mirror you just added is responding.</p>
<p>The Update mirrors section got a visual refresh: Set Primary, Remove, and the new Test action are compact icon buttons instead of crowded text, and adding a mirror now happens in a dedicated dialog that matches the rest of the UI.</p>
</div>
</div>
<!-- v1.7.26-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
@@ -1,8 +1,9 @@
<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'
const router = useRouter()
const { t } = useI18n()
@@ -13,6 +14,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 +75,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 +163,50 @@ 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 same segments as the screensaver -->
<ScreensaverRing />
<!-- 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 +247,23 @@ 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-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>