feat: Federation & Peers — split nodes/peers, invite types, cleanup dead nodes

- Page title: "Federation & Peers"
- "Link Your Nodes" generates trusted invite, "Invite a Peer" generates observer invite
- "Your Nodes" section shows trusted nodes, "Peers" section shows observer/untrusted
- "Remove Dead Nodes" button cleans up unreachable nodes with no last_seen
- DID in header with "Copied!" feedback
- Node count in section headers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-19 19:56:24 +00:00
parent 923d6804a7
commit 09d1adc042

View File

@ -73,32 +73,52 @@
<template v-if="activeView === 'list'">
<!-- Quick Actions -->
<div class="glass-card p-6 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<!-- Generate Invite -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Link Your Nodes (Trusted) -->
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex items-center gap-3 min-w-0">
<svg class="w-5 h-5 text-green-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<div class="min-w-0">
<p class="text-sm font-medium text-white">Link Your Nodes</p>
<p class="text-xs text-white/60">Full trust, sync everything</p>
</div>
</div>
<button
@click="inviteType = 'trusted'; generateInvite()"
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
:disabled="generatingInvite"
>
{{ generatingInvite && inviteType === 'trusted' ? 'Generating...' : 'Generate Code' }}
</button>
</div>
<!-- Invite a Peer (Observer) -->
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex items-center gap-3 min-w-0">
<svg class="w-5 h-5 text-orange-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<div class="min-w-0">
<p class="text-sm font-medium text-white">Invite Node</p>
<p class="text-xs text-white/60">Generate invite code</p>
<p class="text-sm font-medium text-white">Invite a Peer</p>
<p class="text-xs text-white/60">Share public content</p>
</div>
</div>
<button
@click="generateInvite"
@click="inviteType = 'observer'; generateInvite()"
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
:disabled="generatingInvite"
>
{{ generatingInvite ? 'Generating...' : 'Generate' }}
{{ generatingInvite && inviteType === 'observer' ? 'Generating...' : 'Generate Code' }}
</button>
</div>
<!-- Join Federation -->
<!-- Join (accept code) -->
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex items-center gap-3 min-w-0">
<svg class="w-5 h-5 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
</svg>
<div class="min-w-0">
<p class="text-sm font-medium text-white">Join</p>
@ -109,7 +129,7 @@
@click="showJoinModal = true"
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
Join
Enter Code
</button>
</div>
@ -174,7 +194,8 @@
<!-- Federated Nodes List -->
<div class="glass-card p-6 mb-6">
<h2 class="text-lg font-semibold text-white mb-4">Federated Nodes <span v-if="nodes.length > 0" class="text-sm font-normal text-white/50">({{ nodes.length }})</span></h2>
<!-- Your Nodes (Trusted) -->
<h2 class="text-lg font-semibold text-white mb-4">Your Nodes <span v-if="trustedNodes.length > 0" class="text-sm font-normal text-white/50">({{ trustedNodes.length }})</span></h2>
<div v-if="loading" class="flex items-center gap-3 py-8 justify-center">
<div class="w-5 h-5 border-2 border-white/20 border-t-orange-400 rounded-full animate-spin"></div>
@ -191,7 +212,7 @@
<div v-else class="space-y-3">
<div
v-for="node in nodes"
v-for="node in trustedNodes"
:key="node.did"
class="bg-black/20 rounded-xl border border-white/10 p-4 cursor-pointer hover:border-white/20 transition-colors"
@click="selectedNode = node"
@ -234,6 +255,44 @@
</div>
</div>
<!-- Peers Section (Observer level) -->
<div class="glass-card p-6 mb-6 mt-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">Peers <span v-if="peerNodes.length > 0" class="text-sm font-normal text-white/50">({{ peerNodes.length }})</span></h2>
<button
v-if="nodes.some(n => !isOnline(n) && n.last_seen === 'never')"
@click="cleanupDeadNodes"
:disabled="cleaningNodes"
class="glass-button px-3 py-1.5 rounded-lg text-xs text-red-300"
>
{{ cleaningNodes ? 'Removing...' : 'Remove Dead Nodes' }}
</button>
</div>
<div v-if="peerNodes.length === 0" class="text-center py-6">
<p class="text-white/50 text-sm">No peers yet</p>
<p class="text-white/30 text-xs mt-1">Invite a peer to share public content</p>
</div>
<div v-else class="space-y-3">
<div
v-for="node in peerNodes"
:key="node.did"
class="bg-black/20 rounded-xl border border-white/10 p-4 cursor-pointer hover:border-white/20 transition-colors"
@click="selectedNode = node"
>
<div class="flex items-center justify-between mb-2">
<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">{{ node.name || shortDid(node.did) }}</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>
<div class="text-xs text-white/40">
<span>Seen: {{ node.last_seen ? formatTimeAgo(node.last_seen) : 'never' }}</span>
</div>
</div>
</div>
</div>
</template>
<!-- Node Detail Modal -->
@ -449,6 +508,11 @@ const nodes = ref<FederatedNode[]>([])
const loading = ref(true)
const error = ref('')
const selectedNode = ref<FederatedNode | null>(null)
const inviteType = ref<'trusted' | 'observer'>('trusted')
// Split nodes into Your Nodes (trusted) and Peers (observer/untrusted)
const trustedNodes = computed(() => nodes.value.filter(n => n.trust_level === 'trusted'))
const peerNodes = computed(() => nodes.value.filter(n => n.trust_level !== 'trusted'))
const inviteCode = ref('')
const generatingInvite = ref(false)
@ -716,6 +780,32 @@ function formatBytes(bytes?: number): string {
return val.toFixed(1) + ' ' + units[i]
}
// Dead node cleanup
const cleaningNodes = ref(false)
async function cleanupDeadNodes() {
cleaningNodes.value = true
try {
const deadNodes = nodes.value.filter(n => !isOnline(n) && (!n.last_seen || n.last_seen === 'never'))
for (const node of deadNodes) {
await rpcClient.federationRemoveNode(node.did)
}
await loadNodes()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Cleanup failed'
} finally {
cleaningNodes.value = false
}
}
function formatTimeAgo(iso: string): string {
if (!iso || iso === 'never') return 'never'
const ms = Date.now() - new Date(iso).getTime()
if (ms < 60000) return 'just now'
if (ms < 3600000) return `${Math.floor(ms / 60000)}m ago`
if (ms < 86400000) return `${Math.floor(ms / 3600000)}h ago`
return `${Math.floor(ms / 86400000)}d ago`
}
// DID rotation
const showRotateModal = ref(false)
const rotatePassword = ref('')