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:
parent
2e8417e39b
commit
d63cd92bee
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();
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user