diff --git a/core/Cargo.lock b/core/Cargo.lock index 0141f775..f6fadd8d 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "archipelago" -version = "1.7.26-alpha" +version = "1.7.27-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 7adfa962..a3062ce7 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "archipelago" -version = "1.7.26-alpha" +version = "1.7.27-alpha" edition = "2021" description = "Archipelago Bitcoin Node OS - Native backend" authors = ["Archipelago Team"] diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 1620a57b..b88921f7 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -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, diff --git a/core/archipelago/src/api/rpc/update.rs b/core/archipelago/src/api/rpc/update.rs index 30126ab8..6bbe9174 100644 --- a/core/archipelago/src/api/rpc/update.rs +++ b/core/archipelago/src/api/rpc/update.rs @@ -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 { + 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( diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index da0b3bb6..9dcf3ee8 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -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, } 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 { // 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 { "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 { // 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 { Ok(state) } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MirrorTestResult { + pub reachable: bool, + pub latency_ms: u64, + pub http_status: Option, + pub error: Option, +} + +/// 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 { 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(); diff --git a/neode-ui/src/views/SystemUpdate.vue b/neode-ui/src/views/SystemUpdate.vue index 5086348e..dbc1b753 100644 --- a/neode-ui/src/views/SystemUpdate.vue +++ b/neode-ui/src/views/SystemUpdate.vue @@ -42,6 +42,12 @@

{{ t('systemUpdate.updateAvailable') }}

Version {{ updateInfo.version }} — {{ updateInfo.release_date }}

+

+ + + + Served by {{ manifestMirrorLabel }} +

{{ t('systemUpdate.new') }} @@ -179,57 +185,78 @@ + @click="openAddMirror" + >+ Add mirror

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.

-
    -
  • -
    -
    -

    {{ m.label || `Mirror ${i + 1}` }}

    - PRIMARY +
      +
    • +
      +
      +
      +

      {{ m.label || `Mirror ${i + 1}` }}

      + PRIMARY +
      +

      {{ m.url }}

      +
      +
      + + +
      -

      {{ m.url }}

      -
      - - +
      + + + + + Reachable · {{ mirrorTests[m.url]?.latency_ms }}ms + + + + + + {{ mirrorTests[m.url]?.error || 'Unreachable' }} +
    -
    - - - -
    @@ -317,6 +344,51 @@ + + +
    +
    +

    Add update mirror

    +

    + The URL should point directly at a manifest.json served by a Gitea mirror or equivalent. It's added to the end of the list; use "Make primary" to change order. +

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    @@ -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(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>({}) + +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')) } diff --git a/neode-ui/src/views/settings/AccountInfoSection.vue b/neode-ui/src/views/settings/AccountInfoSection.vue index d622826d..b781b3c9 100644 --- a/neode-ui/src/views/settings/AccountInfoSection.vue +++ b/neode-ui/src/views/settings/AccountInfoSection.vue @@ -180,6 +180,18 @@ init()
    + +
    +
    + v1.7.27-alpha + Apr 21, 2026 +
    +
    +

    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.

    +

    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.

    +

    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.

    +
    +
    diff --git a/releases/manifest.json b/releases/manifest.json index b24be365..81d7d522 100644 --- a/releases/manifest.json +++ b/releases/manifest.json @@ -1,28 +1,27 @@ { - "version": "1.7.26-alpha", + "version": "1.7.27-alpha", "release_date": "2026-04-21", "changelog": [ - "Update downloads now fall through a mirror list. If the primary update server is slow or unreachable, your node automatically tries the next mirror and fetches files from there — no more stalling on a single wedged server.", - "New 'Update mirrors' section on the System Update page: see the list, add your own mirror, reorder which one is tried first (Set primary), or remove one. The primary is tagged with a green PRIMARY pill.", - "Downloads automatically follow the mirror that served the manifest. Before this, every mirror handed out the same manifest with download URLs hardcoded to one specific server, so even picking a faster mirror couldn't speed up the binary + frontend fetch. Now the backend rewrites download URLs to match whichever mirror actually responded.", - "Ships with two defaults: Server 1 (tx1138) and Server 2 (VPS). Custom mirrors take the format https://host/.../releases/manifest.json." + "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." ], "components": [ { "name": "archipelago", - "current_version": "1.7.25-alpha", - "new_version": "1.7.26-alpha", - "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/archipelago", - "sha256": "9b9be929e668525e05550bf07930018846115a8249463a9100b2acc777b5268a", - "size_bytes": 40700088 + "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 }, { - "name": "archipelago-frontend-1.7.26-alpha.tar.gz", - "current_version": "1.7.25-alpha", - "new_version": "1.7.26-alpha", - "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/archipelago-frontend-1.7.26-alpha.tar.gz", - "sha256": "b5981aba616bd15aa768c610cf3e44972d519c2a8204d4401da10bf2e4bd5886", - "size_bytes": 162084251 + "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 } ] } diff --git a/releases/v1.7.27-alpha/archipelago b/releases/v1.7.27-alpha/archipelago new file mode 100755 index 00000000..167dac41 Binary files /dev/null and b/releases/v1.7.27-alpha/archipelago differ diff --git a/releases/v1.7.27-alpha/archipelago-frontend-1.7.27-alpha.tar.gz b/releases/v1.7.27-alpha/archipelago-frontend-1.7.27-alpha.tar.gz new file mode 100644 index 00000000..8a5c9459 Binary files /dev/null and b/releases/v1.7.27-alpha/archipelago-frontend-1.7.27-alpha.tar.gz differ