release(v1.7.27-alpha): mirror transparency — served-by line + one-click test button
All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 15m40s

- 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>
This commit is contained in:
Dorian
2026-04-21 13:05:42 -04:00
parent 97a3803640
commit c3b3b03ee1
10 changed files with 295 additions and 60 deletions

View File

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

View File

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

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(

View File

@@ -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();