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:
2
core/Cargo.lock
generated
2
core/Cargo.lock
generated
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.4.0"
|
||||
version = "1.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user