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

View File

@@ -53,10 +53,11 @@
<div class="w-2.5 h-2.5 rounded-full shrink-0" :class="isOnline(node) ? 'bg-green-400' : 'bg-white/30'"></div>
<span class="text-sm font-medium text-white truncate" :title="node.did">{{ nodeName(node) }}</span>
<span
class="text-xs shrink-0"
:class="nodeTransportIcon(node.did).color"
:title="'Transport: ' + nodeTransportIcon(node.did).label"
>{{ nodeTransportIcon(node.did).icon }}</span>
v-if="transportBadge(node)"
class="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded shrink-0"
:class="transportBadge(node)!.cls"
:title="transportBadge(node)!.title"
>{{ transportBadge(node)!.label }}</span>
</div>
<span
class="text-xs px-2 py-0.5 rounded-full shrink-0"
@@ -114,6 +115,12 @@
<div class="flex items-center gap-3 min-w-0">
<div class="w-2.5 h-2.5 rounded-full shrink-0" :class="isOnline(node) ? 'bg-green-400' : 'bg-white/30'"></div>
<span class="text-sm font-medium text-white truncate" :title="node.did">{{ nodeName(node) }}</span>
<span
v-if="transportBadge(node)"
class="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded shrink-0"
:class="transportBadge(node)!.cls"
:title="transportBadge(node)!.title"
>{{ transportBadge(node)!.label }}</span>
</div>
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="trustBadgeClass(node.trust_level)">{{ node.trust_level }}</span>
</div>
@@ -130,7 +137,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useTransportStore } from '@/stores/transport'
import type { FederatedNode, SyncResult } from './types'
import { nodeName, nodeNameFromDid, timeAgo, formatTimeAgo, trustBadgeClass, isOnline } from './utils'
@@ -149,19 +155,44 @@ defineEmits<{
'cleanup-dead': []
}>()
const transportStore = useTransportStore()
const trustedNodes = computed(() => props.nodes.filter(n => n.trust_level === 'trusted'))
const peerNodes = computed(() => props.nodes.filter(n => n.trust_level !== 'trusted'))
function nodeTransportIcon(did: string): { icon: string; color: string; label: string } {
const peer = transportStore.peers.find(p => p.did === did)
if (!peer) return { icon: '?', color: 'text-white/30', label: 'unknown' }
switch (peer.preferred_transport) {
case 'mesh': return { icon: '\u{1F4E1}', color: 'text-orange-400', label: 'mesh' }
case 'lan': return { icon: '\u{1F310}', color: 'text-green-400', label: 'lan' }
case 'tor': return { icon: '\u{1F9C5}', color: 'text-purple-400', label: 'tor' }
default: return { icon: '?', color: 'text-white/30', label: 'unknown' }
// Badge showing the actual transport the most recent reach used —
// NOT a prediction. If we've never reached the peer, return null so
// the badge stays hidden rather than lying. When the transport is
// fips, the tooltip also shows how recent the reading is so stale
// data is visible at a glance.
function transportBadge(node: FederatedNode): { label: string; cls: string; title: string } | null {
if (!node.last_transport) return null
const age = node.last_transport_at ? timeAgo(node.last_transport_at) : 'unknown'
switch (node.last_transport) {
case 'fips':
return {
label: 'FIPS',
cls: 'bg-cyan-500/20 text-cyan-300 ring-1 ring-cyan-400/40',
title: `Last reached via FIPS mesh · ${age}`,
}
case 'tor':
return {
label: 'TOR',
cls: 'bg-purple-500/20 text-purple-300 ring-1 ring-purple-400/40',
title: `Last reached via Tor · ${age}`,
}
case 'lan':
return {
label: 'LAN',
cls: 'bg-green-500/20 text-green-300 ring-1 ring-green-400/40',
title: `Last reached via LAN · ${age}`,
}
case 'mesh':
return {
label: 'MESH',
cls: 'bg-orange-500/20 text-orange-300 ring-1 ring-orange-400/40',
title: `Last reached via mesh radio · ${age}`,
}
default:
return null
}
}
</script>

View File

@@ -25,6 +25,12 @@ export interface FederatedNode {
name?: string
last_seen?: string
last_state?: NodeState
/** bech32 FIPS npub this peer advertised (when known). */
fips_npub?: string
/** Transport used on the most recent successful reach: 'fips' | 'tor' | 'mesh' | 'lan'. */
last_transport?: 'fips' | 'tor' | 'mesh' | 'lan'
/** RFC 3339 timestamp of last_transport. */
last_transport_at?: string
}
export interface DwnStatus {