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:
parent
923d6804a7
commit
09d1adc042
@ -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('')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user