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>
This commit is contained in:
Dorian 2026-04-21 15:06:37 -04:00
parent c3b3b03ee1
commit 79ae14a127
10 changed files with 273 additions and 133 deletions

2
core/Cargo.lock generated
View File

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

View File

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

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(),
},
]
}

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));
}

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>

View File

@ -180,6 +180,18 @@ 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">

View File

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

View File

@ -1,27 +1,26 @@
{
"version": "1.7.27-alpha",
"version": "1.7.28-alpha",
"release_date": "2026-04-21",
"changelog": [
"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. Useful for confirming mirror fallback is doing its job.",
"Every mirror row has a new lightning-bolt button that pings the mirror and shows reachable / unreachable plus the round-trip latency in milliseconds. No more guessing if a mirror you just added is actually responding.",
"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."
"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."
],
"components": [
{
"name": "archipelago",
"current_version": "1.7.26-alpha",
"new_version": "1.7.27-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.27-alpha/archipelago",
"sha256": "00a592041430ec62fd2f5d52b501af863dc9a4404f94147d9f3cd7ed3df05950",
"size_bytes": 40889384
"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
},
{
"name": "archipelago-frontend-1.7.27-alpha.tar.gz",
"current_version": "1.7.26-alpha",
"new_version": "1.7.27-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.27-alpha/archipelago-frontend-1.7.27-alpha.tar.gz",
"sha256": "05604617ca1977905c6b941fb0d471df34d235ea70a921f40191bf8ff2e45c47",
"size_bytes": 77000601
"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
}
]
}

Binary file not shown.