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

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 {