Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e2c6d210b | ||
|
|
7d8ddcccef | ||
|
|
f853d14421 |
Generated
+1
-1
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.16-alpha"
|
||||
version = "1.7.19-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.16-alpha"
|
||||
version = "1.7.19-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -420,6 +420,7 @@ impl RpcHandler {
|
||||
"update.status" => self.handle_update_status().await,
|
||||
"update.dismiss" => self.handle_update_dismiss().await,
|
||||
"update.download" => self.handle_update_download().await,
|
||||
"update.cancel-download" => self.handle_update_cancel_download().await,
|
||||
"update.apply" => self.handle_update_apply().await,
|
||||
"update.git-apply" => self.handle_update_git_apply().await,
|
||||
"update.rollback" => self.handle_update_rollback().await,
|
||||
|
||||
@@ -168,6 +168,27 @@ impl RpcHandler {
|
||||
let active = total > 0 && downloaded < total;
|
||||
let completed = total > 0 && downloaded >= total;
|
||||
|
||||
// Stall detection: if the progress-at timestamp hasn't advanced
|
||||
// for 30+ seconds while active, the download is wedged (usually
|
||||
// HTTP stream silently dropped and reqwest is waiting out its
|
||||
// read timeout). The UI uses this to surface a Cancel button
|
||||
// with explanatory copy.
|
||||
let stalled = if active {
|
||||
let last_at = update::DOWNLOAD_PROGRESS_AT
|
||||
.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if last_at > 0 {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
now.saturating_sub(last_at) > 30_000
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"current_version": state.current_version,
|
||||
"last_check": state.last_check,
|
||||
@@ -179,6 +200,7 @@ impl RpcHandler {
|
||||
"bytes_downloaded": downloaded,
|
||||
"total_bytes": total,
|
||||
"active": active,
|
||||
"stalled": stalled,
|
||||
}))
|
||||
} else { None },
|
||||
}))
|
||||
@@ -200,6 +222,13 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Cancel an in-flight or stuck download. Clears the live counters
|
||||
/// and staging dir so the UI returns to the "Download Update" state.
|
||||
pub(super) async fn handle_update_cancel_download(&self) -> Result<serde_json::Value> {
|
||||
update::cancel_download(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "canceled": true }))
|
||||
}
|
||||
|
||||
/// Apply the staged update.
|
||||
pub(super) async fn handle_update_apply(&self) -> Result<serde_json::Value> {
|
||||
update::apply_update(&self.config.data_dir).await?;
|
||||
|
||||
@@ -342,6 +342,7 @@ mod tests {
|
||||
"local.onion",
|
||||
"localpub",
|
||||
None,
|
||||
None,
|
||||
|_| "test-sig".to_string(),
|
||||
)
|
||||
.await
|
||||
@@ -376,6 +377,7 @@ mod tests {
|
||||
"local.onion",
|
||||
"localpub",
|
||||
None,
|
||||
None,
|
||||
|_| "test-sig".to_string(),
|
||||
)
|
||||
.await
|
||||
@@ -409,6 +411,7 @@ mod tests {
|
||||
"local.onion",
|
||||
"localpub",
|
||||
None,
|
||||
None,
|
||||
|_| "test-sig".to_string(),
|
||||
)
|
||||
.await
|
||||
@@ -421,6 +424,7 @@ mod tests {
|
||||
"local.onion",
|
||||
"localpub",
|
||||
None,
|
||||
None,
|
||||
|_| "test-sig".to_string(),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -107,8 +107,10 @@ pub async fn sync_with_peer_by_did(
|
||||
}
|
||||
|
||||
/// Merge peers advertised by a Trusted federated node into our own
|
||||
/// federation list. New peers are added at `Observer` trust (not
|
||||
/// Trusted — that requires a direct invite). Existing peers get their
|
||||
/// federation list. New peers are added at `Trusted` — hints only
|
||||
/// arrive from peers we already trust, and `build_local_state` only
|
||||
/// re-exports our Trusted list, so transitive membership carries the
|
||||
/// same trust the direct-invite path gives. Existing peers get their
|
||||
/// `fips_npub` refreshed if we hadn't learned it yet.
|
||||
///
|
||||
/// Peers we are (us) or that we already track by DID are skipped.
|
||||
@@ -142,7 +144,7 @@ async fn merge_transitive_peers(
|
||||
pubkey: hint.pubkey.clone(),
|
||||
onion: hint.onion.clone(),
|
||||
name: hint.name.clone(),
|
||||
trust_level: TrustLevel::Observer,
|
||||
trust_level: TrustLevel::Trusted,
|
||||
added_at: chrono::Utc::now().to_rfc3339(),
|
||||
last_seen: None,
|
||||
last_state: None,
|
||||
|
||||
+203
-19
@@ -4,7 +4,7 @@ use anyhow::{Context, Result};
|
||||
use chrono::Timelike;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use tokio::fs;
|
||||
use tracing::{debug, info};
|
||||
|
||||
@@ -14,6 +14,52 @@ use tracing::{debug, info};
|
||||
/// download runs in one place at a time; no need for per-handler state.
|
||||
pub static DOWNLOAD_BYTES: AtomicU64 = AtomicU64::new(0);
|
||||
pub static DOWNLOAD_TOTAL: AtomicU64 = AtomicU64::new(0);
|
||||
/// Set true to ask the in-flight download loop to bail out at the next
|
||||
/// chunk boundary. Read via `is_canceled`; reset at the start of every
|
||||
/// `download_update` run. Also flipped by the `cancel_download` RPC.
|
||||
pub static DOWNLOAD_CANCEL: AtomicBool = AtomicBool::new(false);
|
||||
/// Monotonic ms timestamp of the last time DOWNLOAD_BYTES advanced.
|
||||
/// Lets `update.status` flag a download as "stalled" when no bytes have
|
||||
/// arrived for a while, so the UI can offer a Cancel button with more
|
||||
/// confidence than "looks stuck at 0%".
|
||||
pub static DOWNLOAD_PROGRESS_AT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn is_canceled() -> bool {
|
||||
DOWNLOAD_CANCEL.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Parse "MAJOR.MINOR.PATCH[-suffix]" into a tuple; suffix is ignored.
|
||||
/// Returns None if the numeric portion can't be parsed — callers should
|
||||
/// fall back to string comparison in that case so we don't silently
|
||||
/// mis-rank versions we don't understand.
|
||||
fn parse_version_triple(v: &str) -> Option<(u32, u32, u32)> {
|
||||
let core = v.split('-').next().unwrap_or(v);
|
||||
let mut parts = core.split('.');
|
||||
let major: u32 = parts.next()?.parse().ok()?;
|
||||
let minor: u32 = parts.next()?.parse().ok()?;
|
||||
let patch: u32 = parts.next()?.parse().ok()?;
|
||||
Some((major, minor, patch))
|
||||
}
|
||||
|
||||
/// Is `candidate` strictly newer than `current`? Used to guard against
|
||||
/// the manifest offering a version we've already passed (e.g. a stale
|
||||
/// cached manifest or a node that sideloaded past the manifest's
|
||||
/// latest). Falls back to string inequality if either version doesn't
|
||||
/// parse, preserving the old behaviour for unusual version strings.
|
||||
fn is_newer(candidate: &str, current: &str) -> bool {
|
||||
match (parse_version_triple(candidate), parse_version_triple(current)) {
|
||||
(Some(a), Some(b)) => a > b,
|
||||
_ => candidate != current,
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_UPDATE_MANIFEST_URL: &str =
|
||||
"https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
||||
@@ -96,13 +142,13 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
|
||||
let running = env!("CARGO_PKG_VERSION");
|
||||
if state.current_version != running {
|
||||
state.current_version = running.to_string();
|
||||
// Clear any stale "available_update" that matched the old
|
||||
// current_version — the new binary will re-check on its own.
|
||||
if let Some(ref avail) = state.available_update {
|
||||
if avail.version == running {
|
||||
state.available_update = None;
|
||||
}
|
||||
}
|
||||
// Binary version changed (sideload or apply). Any stored
|
||||
// `available_update` is either redundant (points at the running
|
||||
// version) or stale (points at a version we've already passed —
|
||||
// which would surface as a "downgrade" offer in the UI). Clear
|
||||
// it unconditionally; the next check_for_updates will repopulate
|
||||
// if there's genuinely something newer.
|
||||
state.available_update = None;
|
||||
save_state(data_dir, &state).await?;
|
||||
}
|
||||
Ok(state)
|
||||
@@ -140,7 +186,7 @@ pub async fn check_for_updates(data_dir: &Path) -> Result<UpdateState> {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
match resp.json::<UpdateManifest>().await {
|
||||
Ok(manifest) => {
|
||||
if manifest.version != state.current_version {
|
||||
if is_newer(&manifest.version, &state.current_version) {
|
||||
info!(
|
||||
current = %state.current_version,
|
||||
available = %manifest.version,
|
||||
@@ -148,7 +194,16 @@ pub async fn check_for_updates(data_dir: &Path) -> Result<UpdateState> {
|
||||
);
|
||||
state.available_update = Some(manifest);
|
||||
} else {
|
||||
debug!("Already on latest version: {}", state.current_version);
|
||||
// Manifest version matches us or is behind
|
||||
// us — either we're current, or the remote
|
||||
// manifest is stale. Either way don't offer
|
||||
// it as an "update" (that would be a
|
||||
// downgrade prompt).
|
||||
debug!(
|
||||
current = %state.current_version,
|
||||
manifest = %manifest.version,
|
||||
"No newer version in manifest"
|
||||
);
|
||||
state.available_update = None;
|
||||
}
|
||||
handled = true;
|
||||
@@ -223,12 +278,28 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||
let mut downloaded = 0u64;
|
||||
let total_bytes: u64 = manifest.components.iter().map(|c| c.size_bytes).sum();
|
||||
|
||||
// Seed the live counters so polls during the handshake show the
|
||||
// right denominator immediately instead of 0/0 → NaN%.
|
||||
info!(
|
||||
version = %manifest.version,
|
||||
components = manifest.components.len(),
|
||||
total_bytes,
|
||||
staging = %staging_dir.display(),
|
||||
"Starting update download"
|
||||
);
|
||||
|
||||
// Clear any stale cancel flag from a prior aborted run, then seed
|
||||
// the live counters so polls during the handshake show the right
|
||||
// denominator immediately instead of 0/0 → NaN%.
|
||||
DOWNLOAD_CANCEL.store(false, Ordering::Relaxed);
|
||||
DOWNLOAD_TOTAL.store(total_bytes, Ordering::Relaxed);
|
||||
DOWNLOAD_BYTES.store(0, Ordering::Relaxed);
|
||||
DOWNLOAD_PROGRESS_AT.store(now_ms(), Ordering::Relaxed);
|
||||
|
||||
for component in &manifest.components {
|
||||
if is_canceled() {
|
||||
DOWNLOAD_TOTAL.store(0, Ordering::Relaxed);
|
||||
DOWNLOAD_BYTES.store(0, Ordering::Relaxed);
|
||||
anyhow::bail!("Download canceled");
|
||||
}
|
||||
info!(name = %component.name, url = %component.download_url, "Downloading component");
|
||||
let dest = staging_dir.join(&component.name);
|
||||
download_component_resumable(&client, component, &dest, downloaded).await?;
|
||||
@@ -289,7 +360,18 @@ async fn download_component_resumable(
|
||||
delay,
|
||||
last_err.as_ref().map(|e| e.to_string()).unwrap_or_default()
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
|
||||
// Sleep in 500ms slices so a Cancel during backoff wakes
|
||||
// promptly instead of waiting out the full exponential window.
|
||||
let slices = delay * 2;
|
||||
for _ in 0..slices {
|
||||
if is_canceled() {
|
||||
anyhow::bail!("Download canceled");
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
if is_canceled() {
|
||||
anyhow::bail!("Download canceled");
|
||||
}
|
||||
|
||||
let mut req = client.get(&component.download_url);
|
||||
@@ -348,7 +430,12 @@ async fn download_component_resumable(
|
||||
let mut resp = resp;
|
||||
let mut stream_err = false;
|
||||
let mut on_disk = existing_len;
|
||||
let mut canceled = false;
|
||||
loop {
|
||||
if is_canceled() {
|
||||
canceled = true;
|
||||
break;
|
||||
}
|
||||
match resp.chunk().await {
|
||||
Ok(Some(bytes)) => {
|
||||
if let Err(e) = file.write_all(&bytes).await {
|
||||
@@ -361,6 +448,7 @@ async fn download_component_resumable(
|
||||
prior_total + on_disk.min(component.size_bytes),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
DOWNLOAD_PROGRESS_AT.store(now_ms(), Ordering::Relaxed);
|
||||
}
|
||||
Ok(None) => break, // stream ended cleanly
|
||||
Err(e) => {
|
||||
@@ -370,6 +458,13 @@ async fn download_component_resumable(
|
||||
}
|
||||
}
|
||||
}
|
||||
if canceled {
|
||||
let _ = file.flush().await;
|
||||
drop(file);
|
||||
DOWNLOAD_TOTAL.store(0, Ordering::Relaxed);
|
||||
DOWNLOAD_BYTES.store(0, Ordering::Relaxed);
|
||||
anyhow::bail!("Download canceled");
|
||||
}
|
||||
let _ = file.flush().await;
|
||||
let _ = file.sync_all().await;
|
||||
drop(file);
|
||||
@@ -414,6 +509,40 @@ async fn download_component_resumable(
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed without a captured error")))
|
||||
}
|
||||
|
||||
/// Cancel an in-flight download. Sets the cancellation flag so the
|
||||
/// download loop bails out at the next chunk or backoff boundary, then
|
||||
/// zeros the live counters and wipes the staging directory so the UI
|
||||
/// sees "no active download" immediately and the next attempt starts
|
||||
/// clean. Safe to call even when no download is running.
|
||||
pub async fn cancel_download(data_dir: &Path) -> Result<()> {
|
||||
DOWNLOAD_CANCEL.store(true, Ordering::Relaxed);
|
||||
DOWNLOAD_BYTES.store(0, Ordering::Relaxed);
|
||||
DOWNLOAD_TOTAL.store(0, Ordering::Relaxed);
|
||||
let staging = data_dir.join("update-staging");
|
||||
let wiped = if staging.exists() {
|
||||
tokio::fs::remove_dir_all(&staging).await.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// Clear the "downloaded, ready to apply" marker too — a canceled
|
||||
// download is not a staged update.
|
||||
let mut cleared_marker = false;
|
||||
if let Ok(mut state) = load_state(data_dir).await {
|
||||
if state.update_in_progress {
|
||||
state.update_in_progress = false;
|
||||
let _ = save_state(data_dir, &state).await;
|
||||
cleared_marker = true;
|
||||
}
|
||||
}
|
||||
info!(
|
||||
staging = %staging.display(),
|
||||
wiped,
|
||||
cleared_marker,
|
||||
"Update download canceled"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a command as root, but *outside* the archipelago service's
|
||||
/// restricted mount namespace.
|
||||
///
|
||||
@@ -452,6 +581,12 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
.await
|
||||
.context("Failed to create backup dir")?;
|
||||
|
||||
info!(
|
||||
staging = %staging_dir.display(),
|
||||
backup = %backup_dir.display(),
|
||||
"Applying staged update"
|
||||
);
|
||||
|
||||
// Back up current backend binary
|
||||
let current_binary = Path::new("/usr/local/bin/archipelago");
|
||||
if current_binary.exists() {
|
||||
@@ -825,6 +960,52 @@ mod tests {
|
||||
assert_eq!(state.schedule, UpdateSchedule::DailyCheck);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_triple() {
|
||||
assert_eq!(parse_version_triple("1.7.18"), Some((1, 7, 18)));
|
||||
assert_eq!(parse_version_triple("1.7.18-alpha"), Some((1, 7, 18)));
|
||||
assert_eq!(parse_version_triple("0.0.1"), Some((0, 0, 1)));
|
||||
assert_eq!(parse_version_triple("garbage"), None);
|
||||
assert_eq!(parse_version_triple("1.2"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_newer() {
|
||||
assert!(is_newer("1.7.19-alpha", "1.7.18-alpha"));
|
||||
assert!(is_newer("1.8.0-alpha", "1.7.99-alpha"));
|
||||
assert!(is_newer("1.7.10-alpha", "1.7.9-alpha")); // numeric, not lexical
|
||||
assert!(!is_newer("1.7.18-alpha", "1.7.18-alpha"));
|
||||
assert!(!is_newer("1.7.17-alpha", "1.7.18-alpha")); // would-be downgrade
|
||||
assert!(!is_newer("1.7.9-alpha", "1.7.10-alpha"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_state_clears_stale_available_on_version_bump() {
|
||||
// Simulates a sideload: state file on disk says we're on
|
||||
// 1.7.16-alpha with 1.7.17-alpha staged as the pending update,
|
||||
// but the running binary is 1.7.18-alpha (skipped a version).
|
||||
// load_state must drop the stale available_update so the UI
|
||||
// doesn't offer a downgrade.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let stale = UpdateState {
|
||||
current_version: "1.7.16-alpha".to_string(),
|
||||
available_update: Some(UpdateManifest {
|
||||
version: "1.7.17-alpha".to_string(),
|
||||
release_date: "2026-04-20".to_string(),
|
||||
changelog: vec![],
|
||||
components: vec![],
|
||||
}),
|
||||
..UpdateState::default()
|
||||
};
|
||||
save_state(dir.path(), &stale).await.unwrap();
|
||||
let loaded = load_state(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.current_version, env!("CARGO_PKG_VERSION"));
|
||||
assert!(
|
||||
loaded.available_update.is_none(),
|
||||
"stale available_update must be cleared after version bump"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_state_creates_default_when_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -860,13 +1041,14 @@ mod tests {
|
||||
};
|
||||
save_state(dir.path(), &state).await.unwrap();
|
||||
let loaded = load_state(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.current_version, "1.0.0");
|
||||
// load_state rewrites current_version to match the running
|
||||
// binary (sideload self-heal), so don't assert on the saved
|
||||
// value. The migration also clears available_update when the
|
||||
// version changes — check the other fields survived.
|
||||
assert_eq!(loaded.current_version, env!("CARGO_PKG_VERSION"));
|
||||
assert!(loaded.update_in_progress);
|
||||
assert_eq!(loaded.schedule, UpdateSchedule::Manual);
|
||||
let manifest = loaded.available_update.unwrap();
|
||||
assert_eq!(manifest.version, "1.1.0");
|
||||
assert_eq!(manifest.components.len(), 1);
|
||||
assert_eq!(manifest.components[0].size_bytes, 5000);
|
||||
assert!(loaded.available_update.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -916,7 +1098,9 @@ mod tests {
|
||||
};
|
||||
save_state(dir.path(), &state).await.unwrap();
|
||||
let status = get_status(dir.path()).await.unwrap();
|
||||
assert_eq!(status.current_version, "3.0.0");
|
||||
// get_status → load_state, which rewrites current_version to
|
||||
// match the running binary (see the sideload-self-heal path).
|
||||
assert_eq!(status.current_version, env!("CARGO_PKG_VERSION"));
|
||||
assert!(status.rollback_available);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.3.5",
|
||||
"version": "1.6.0-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.3.5",
|
||||
"version": "1.6.0-alpha",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
|
||||
@@ -696,7 +696,15 @@
|
||||
"gitMethodHint": "This node builds from source. Update will git-pull, rebuild the backend and UI, then restart — takes a few minutes.",
|
||||
"gitApplyTitle": "Pull & Rebuild?",
|
||||
"gitApplyMessage": "Archipelago will pull the latest code, rebuild, and restart. This can take several minutes and the UI will be briefly unavailable.",
|
||||
"gitApplyStarted": "Update started. The backend will rebuild and restart — this can take a few minutes."
|
||||
"gitApplyStarted": "Update started. The backend will rebuild and restart — this can take a few minutes.",
|
||||
"cancelDownload": "Cancel Download",
|
||||
"cancelingDownload": "Canceling…",
|
||||
"cancelDownloadTitle": "Cancel Download?",
|
||||
"cancelDownloadConfirm": "This will stop the current download and discard the partial file. You can start again from scratch afterwards.",
|
||||
"cancelDownloadButton": "Cancel Download",
|
||||
"cancelDownloadSuccess": "Download canceled. You can try again.",
|
||||
"cancelDownloadFailed": "Failed to cancel download.",
|
||||
"downloadStalled": "Download appears stuck — try Cancel and start again."
|
||||
},
|
||||
"kiosk": {
|
||||
"pressEsc": "Press ESC to exit",
|
||||
|
||||
@@ -684,18 +684,26 @@
|
||||
"rollbackSuccess": "Se revirti\u00f3 a la versi\u00f3n anterior. El servicio se reiniciar\u00e1.",
|
||||
"rollbackFailed": "Error al revertir.",
|
||||
"pullAndRebuild": "Pull y Recompilar",
|
||||
"finishingDownload": "Terminando descarga — verificando checksum…",
|
||||
"overlayApplying": "Instalando actualizaci\u00f3n…",
|
||||
"overlayRestarting": "Reiniciando servidor…",
|
||||
"overlayReconnecting": "Reconectando a la nueva versi\u00f3n…",
|
||||
"overlayReady": "Actualizaci\u00f3n instalada — recargando…",
|
||||
"finishingDownload": "Terminando descarga \u2014 verificando checksum\u2026",
|
||||
"overlayApplying": "Instalando actualizaci\u00f3n\u2026",
|
||||
"overlayRestarting": "Reiniciando servidor\u2026",
|
||||
"overlayReconnecting": "Reconectando a la nueva versi\u00f3n\u2026",
|
||||
"overlayReady": "Actualizaci\u00f3n instalada \u2014 recargando\u2026",
|
||||
"overlayStalled": "Tardando m\u00e1s de lo esperado",
|
||||
"overlayTarget": "Instalando v{version}",
|
||||
"overlayReloadNow": "Recargar ahora",
|
||||
"gitMethodHint": "Este nodo compila desde el c\u00f3digo fuente. La actualizaci\u00f3n har\u00e1 git-pull, recompilar\u00e1 y reiniciar\u00e1 — tarda unos minutos.",
|
||||
"gitMethodHint": "Este nodo compila desde el c\u00f3digo fuente. La actualizaci\u00f3n har\u00e1 git-pull, recompilar\u00e1 y reiniciar\u00e1 \u2014 tarda unos minutos.",
|
||||
"gitApplyTitle": "\u00bfPull y Recompilar?",
|
||||
"gitApplyMessage": "Archipelago descargar\u00e1 el c\u00f3digo m\u00e1s reciente, lo compilar\u00e1 y reiniciar\u00e1. Puede tardar varios minutos y la UI estar\u00e1 brevemente no disponible.",
|
||||
"gitApplyStarted": "Actualizaci\u00f3n iniciada. El backend se recompilar\u00e1 y reiniciar\u00e1 — puede tardar unos minutos."
|
||||
"gitApplyStarted": "Actualizaci\u00f3n iniciada. El backend se recompilar\u00e1 y reiniciar\u00e1 \u2014 puede tardar unos minutos.",
|
||||
"cancelDownload": "Cancelar descarga",
|
||||
"cancelingDownload": "Cancelando\u2026",
|
||||
"cancelDownloadTitle": "\u00bfCancelar descarga?",
|
||||
"cancelDownloadConfirm": "Esto detendr\u00e1 la descarga actual y descartar\u00e1 el archivo parcial. Podr\u00e1s volver a empezar desde cero.",
|
||||
"cancelDownloadButton": "Cancelar descarga",
|
||||
"cancelDownloadSuccess": "Descarga cancelada. Puedes intentarlo de nuevo.",
|
||||
"cancelDownloadFailed": "No se pudo cancelar la descarga.",
|
||||
"downloadStalled": "La descarga parece atascada \u2014 prueba a cancelar y volver a empezar."
|
||||
},
|
||||
"kiosk": {
|
||||
"pressEsc": "Presione ESC para salir",
|
||||
|
||||
@@ -109,17 +109,30 @@
|
||||
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.downloading') }}</h2>
|
||||
<div class="w-full h-3 bg-white/10 rounded-full overflow-hidden mb-2">
|
||||
<div
|
||||
class="h-full bg-orange-400 rounded-full transition-all duration-500"
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
:class="downloadStalled ? 'bg-amber-400' : 'bg-orange-400'"
|
||||
:style="{ width: downloadPercentFormatted + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="downloadFinishing" class="w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full animate-spin shrink-0"></div>
|
||||
<p class="text-xs text-white/60">
|
||||
{{ downloadFinishing
|
||||
? t('systemUpdate.finishingDownload')
|
||||
: t('systemUpdate.percentComplete', { percent: downloadPercentFormatted }) }}
|
||||
</p>
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div v-if="downloadFinishing && !downloadStalled" class="w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full animate-spin shrink-0"></div>
|
||||
<p class="text-xs" :class="downloadStalled ? 'text-amber-300' : 'text-white/60'">
|
||||
{{ downloadStalled
|
||||
? t('systemUpdate.downloadStalled')
|
||||
: downloadFinishing
|
||||
? t('systemUpdate.finishingDownload')
|
||||
: t('systemUpdate.percentComplete', { percent: downloadPercentFormatted }) }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="requestCancelDownload"
|
||||
:disabled="cancelingDownload"
|
||||
class="glass-button rounded-lg px-4 py-1.5 text-xs font-medium disabled:opacity-40 shrink-0"
|
||||
:class="downloadStalled ? 'bg-amber-500/20 border-amber-400/40 text-amber-200' : ''"
|
||||
>
|
||||
{{ cancelingDownload ? t('systemUpdate.cancelingDownload') : t('systemUpdate.cancelDownload') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -253,14 +266,18 @@
|
||||
? t('systemUpdate.rollbackTitle')
|
||||
: confirmAction === 'git-apply'
|
||||
? t('systemUpdate.gitApplyTitle')
|
||||
: t('systemUpdate.applyTitle') }}
|
||||
: confirmAction === 'cancel-download'
|
||||
? t('systemUpdate.cancelDownloadTitle')
|
||||
: t('systemUpdate.applyTitle') }}
|
||||
</h3>
|
||||
<p class="text-sm text-white/70 mb-6">
|
||||
{{ confirmAction === 'rollback'
|
||||
? t('systemUpdate.rollbackMessage')
|
||||
: confirmAction === 'git-apply'
|
||||
? t('systemUpdate.gitApplyMessage')
|
||||
: t('systemUpdate.applyMessage') }}
|
||||
: confirmAction === 'cancel-download'
|
||||
? t('systemUpdate.cancelDownloadConfirm')
|
||||
: t('systemUpdate.applyMessage') }}
|
||||
</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button @click="cancelConfirm" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">
|
||||
@@ -269,13 +286,15 @@
|
||||
<button
|
||||
@click="executeConfirm"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
:class="confirmAction === 'rollback' ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
|
||||
:class="(confirmAction === 'rollback' || confirmAction === 'cancel-download') ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
|
||||
>
|
||||
{{ confirmAction === 'rollback'
|
||||
? t('systemUpdate.rollbackButton')
|
||||
: confirmAction === 'git-apply'
|
||||
? t('systemUpdate.pullAndRebuild')
|
||||
: t('systemUpdate.applyNow') }}
|
||||
: confirmAction === 'cancel-download'
|
||||
? t('systemUpdate.cancelDownloadButton')
|
||||
: t('systemUpdate.applyNow') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -313,7 +332,9 @@ const loading = ref(false)
|
||||
const downloading = ref(false)
|
||||
const downloaded = ref(false)
|
||||
const applying = ref(false)
|
||||
const confirmAction = ref<'apply' | 'git-apply' | 'rollback' | null>(null)
|
||||
const cancelingDownload = ref(false)
|
||||
const downloadStalled = ref(false)
|
||||
const confirmAction = ref<'apply' | 'git-apply' | 'rollback' | 'cancel-download' | null>(null)
|
||||
const currentVersion = ref('0.0.0')
|
||||
const lastCheck = ref<string | null>(null)
|
||||
const updateInfo = ref<UpdateDetail | null>(null)
|
||||
@@ -335,13 +356,16 @@ async function pollDownloadProgress(): Promise<boolean> {
|
||||
bytes_downloaded: number
|
||||
total_bytes: number
|
||||
active: boolean
|
||||
stalled?: boolean
|
||||
} | null
|
||||
}>({ method: 'update.status' })
|
||||
const p = res.download_progress
|
||||
if (p && p.total_bytes > 0) {
|
||||
downloadPercent.value = Math.min(100, (p.bytes_downloaded / p.total_bytes) * 100)
|
||||
downloadStalled.value = !!p.stalled
|
||||
return p.active
|
||||
}
|
||||
downloadStalled.value = false
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
@@ -547,6 +571,10 @@ function requestRollback() {
|
||||
confirmAction.value = 'rollback'
|
||||
}
|
||||
|
||||
function requestCancelDownload() {
|
||||
confirmAction.value = 'cancel-download'
|
||||
}
|
||||
|
||||
function cancelConfirm() {
|
||||
confirmAction.value = null
|
||||
}
|
||||
@@ -560,6 +588,25 @@ async function executeConfirm() {
|
||||
await applyUpdateGitWithOverlay()
|
||||
} else if (action === 'rollback') {
|
||||
await rollbackUpdate()
|
||||
} else if (action === 'cancel-download') {
|
||||
await cancelDownload()
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelDownload() {
|
||||
cancelingDownload.value = true
|
||||
try {
|
||||
await rpcClient.call({ method: 'update.cancel-download' })
|
||||
downloading.value = false
|
||||
downloaded.value = false
|
||||
downloadPercent.value = 0
|
||||
downloadStalled.value = false
|
||||
showStatus(t('systemUpdate.cancelDownloadSuccess'))
|
||||
} catch (e) {
|
||||
showStatus(t('systemUpdate.cancelDownloadFailed'), true)
|
||||
if (import.meta.env.DEV) console.warn('Cancel download failed', e)
|
||||
} finally {
|
||||
cancelingDownload.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -180,6 +180,41 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.19-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.19-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>Your node no longer offers a version you've already passed as an "available update". If you sideload or skip a release, any stored pointer to an earlier version is dropped on next restart, and the System Update page offers only the genuinely newer release — no more seeing an older version listed as something to install.</p>
|
||||
<p>Version comparison is now numeric, not alphabetic. 1.7.10 correctly outranks 1.7.9 (earlier naive string-order would have got this backwards once the patch number hits double digits), so update prompts and "up to date" checks stay accurate past the nines.</p>
|
||||
<p>A stale manifest from a slow cache or proxy can no longer downgrade your node. If the manifest reports a version equal to or behind what's running, your node treats that as "up to date" rather than offering the older version as an update.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.18-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.18-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Nodes discovered through a trusted peer now land as Trusted instead of Observer. When your federated peer shares its own peer list with you, those nodes get the same trust level as a direct invite — the link they came through is already one you vetted, so you no longer need to promote them by hand before they can be used normally.</p>
|
||||
<p>The update flow now writes clearer logs at every step. Start of download, cancel, and apply each emit a one-line entry to the system journal with the staging path and the affected files, so if a download misbehaves on your node it's easy to see exactly where it got to.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.17-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.17-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>When a download gets stuck, you can now cancel it. A new Cancel Download button sits next to the progress bar — it stops the transfer, clears the partial file, and returns you to a clean state so you can retry. No more staring at a frozen bar with no way to recover.</p>
|
||||
<p>Downloads that stall for 30 seconds or more now say so. The progress bar turns amber and shows 'Download appears stuck — try Cancel and start again' instead of just sitting silently at whatever percent it reached.</p>
|
||||
<p>Canceling is fast. It no longer has to wait out the retry timer — the download bails within half a second, so you're not stuck watching a stuck screen while you wait to unstick it.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.16-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
Reference in New Issue
Block a user