feat(federation): v1.5.0 bump + transport badge on each node card

Every federated node card now shows a colored badge indicating how
archipelago actually reached the peer on the most recent successful
call — FIPS / TOR / LAN / MESH — not a prediction based on available
addresses. The badge is hidden when we've never reached the peer.

Backend:
- Cargo.toml: 1.4.0 → 1.5.0 (visible in the sidebar health endpoint).
- FederatedNode gains last_transport + last_transport_at (serde
  default for back-compat with v1.4 nodes.json files).
- federation::storage::record_peer_transport(did, onion, transport)
  — writes both fields plus last_seen after each successful peer
  call. Matches by DID first, falls back to onion.
- federation::sync::sync_with_peer now calls record_peer_transport
  immediately after a successful PeerRequest return, so the badge
  on the sync'ing peer's card reflects the transport the call
  actually rode (fips vs tor).

Frontend:
- types.ts FederatedNode gains last_transport / last_transport_at
  (union-typed to the four known kinds).
- NodeList.vue: new transportBadge(node) returns {label, cls, title}
  tuned per transport. Hidden when last_transport is absent so we
  never lie. Tooltip shows "Last reached via <x> · <time ago>" so
  stale data is self-evident. Removed the predictive icon from the
  transport store — badge is now 100% ground-truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-19 02:51:26 -04:00
parent 95f52572fc
commit 4c8c4ebc47
10 changed files with 130 additions and 19 deletions

2
core/Cargo.lock generated
View File

@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.4.0"
version = "1.5.0"
dependencies = [
"anyhow",
"archipelago-container",

View File

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

View File

@@ -485,6 +485,8 @@ impl RpcHandler {
last_seen: None,
last_state: None,
fips_npub,
last_transport: None,
last_transport_at: None,
};
federation::add_node(&self.config.data_dir, node).await?;

View File

@@ -163,6 +163,8 @@ pub async fn accept_invite(
last_seen: None,
last_state: None,
fips_npub: fips_npub.clone(),
last_transport: None,
last_transport_at: None,
};
add_node(data_dir, node.clone()).await?;

View File

@@ -12,9 +12,10 @@ mod types;
// Re-export all public items so `crate::federation::*` continues to work.
pub use invites::{accept_invite, create_invite};
#[allow(unused_imports)]
pub use storage::{
add_node, fips_npub_for_onion, load_nodes, remove_node, save_nodes, set_trust_level,
update_node,
add_node, fips_npub_for_onion, load_nodes, record_peer_transport, remove_node, save_nodes,
set_trust_level, update_node,
};
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer};
pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};

View File

@@ -60,6 +60,43 @@ pub async fn fips_npub_for_onion(data_dir: &Path, onion: &str) -> Option<String>
.and_then(|n| n.fips_npub.clone())
}
/// Record the transport used on the most recent successful peer reach.
/// Used for the "FIPS"/"Tor" badge on each node card in the UI — we write
/// what we actually used, not what was predicted.
///
/// Matches by DID first (precise) and falls back to onion (when the
/// caller didn't carry the DID through). No-op if the peer isn't in
/// our federation list.
pub async fn record_peer_transport(
data_dir: &Path,
did: Option<&str>,
onion: Option<&str>,
transport: &str,
) -> Result<()> {
let mut nodes = load_nodes(data_dir).await?;
let now = chrono::Utc::now().to_rfc3339();
let onion_target = onion.map(|o| o.trim_end_matches(".onion"));
let mut modified = false;
for node in nodes.iter_mut() {
let did_match = did.is_some_and(|d| d == node.did);
let onion_match = onion_target
.is_some_and(|t| node.onion.trim_end_matches(".onion") == t);
if did_match || onion_match {
node.last_transport = Some(transport.to_string());
node.last_transport_at = Some(now.clone());
node.last_seen = Some(now.clone());
modified = true;
break;
}
}
if modified {
save_nodes(data_dir, &nodes).await?;
}
Ok(())
}
pub async fn save_nodes(data_dir: &Path, nodes: &[FederatedNode]) -> Result<()> {
let dir = ensure_dir(data_dir).await?;
let file = NodesFile {
@@ -186,6 +223,8 @@ mod tests {
last_seen: None,
last_state: None,
fips_npub: None,
last_transport: None,
last_transport_at: None,
}
}

View File

@@ -43,6 +43,16 @@ pub async fn sync_with_peer(
anyhow::bail!("Peer returned {} (via {})", resp.status(), transport);
}
// Record transport used so the UI badge on this peer's card reflects
// the transport that actually carried the call, not a prediction.
let _ = super::storage::record_peer_transport(
data_dir,
Some(&peer.did),
Some(&peer.onion),
&transport.to_string(),
)
.await;
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
let state_val = result
.get("result")
@@ -113,6 +123,8 @@ async fn merge_transitive_peers(
last_seen: None,
last_state: None,
fips_npub: hint.fips_npub.clone(),
last_transport: None,
last_transport_at: None,
});
added += 1;
}
@@ -284,6 +296,8 @@ mod tests {
last_seen: None,
last_state: None,
fips_npub: Some("npub1a".into()),
last_transport: None,
last_transport_at: None,
},
FederatedNode {
did: "did:key:zObserver".into(),
@@ -295,6 +309,8 @@ mod tests {
last_seen: None,
last_state: None,
fips_npub: Some("npub1b".into()),
last_transport: None,
last_transport_at: None,
},
FederatedNode {
did: "did:key:zUntrusted".into(),
@@ -306,6 +322,8 @@ mod tests {
last_seen: None,
last_state: None,
fips_npub: None,
last_transport: None,
last_transport_at: None,
},
];
let state = build_local_state(

View File

@@ -39,6 +39,16 @@ pub struct FederatedNode {
/// Lets the transport router prefer FIPS over Tor for peer traffic.
#[serde(default)]
pub fips_npub: Option<String>,
/// Transport kind used on the most recent successful reach
/// ("fips" | "tor" | "mesh" | "lan"). Written after each successful
/// PeerRequest so the UI can show a ground-truth badge ("this peer
/// is currently being reached over FIPS") instead of a prediction
/// based on available addresses.
#[serde(default)]
pub last_transport: Option<String>,
/// RFC 3339 timestamp of the last_transport value.
#[serde(default)]
pub last_transport_at: Option<String>,
}
/// State snapshot received from a federated peer during sync.
@@ -140,6 +150,8 @@ mod tests {
last_seen: None,
last_state: None,
fips_npub: None,
last_transport: None,
last_transport_at: None,
};
let json = serde_json::to_string(&node).unwrap();
let parsed: FederatedNode = serde_json::from_str(&json).unwrap();