2026-03-11 10:44:56 +00:00
|
|
|
<template>
|
2026-03-14 17:12:41 +00:00
|
|
|
<div class="pb-6">
|
2026-03-19 19:44:54 +00:00
|
|
|
<div class="mb-6">
|
2026-03-14 04:14:04 +00:00
|
|
|
<button
|
|
|
|
|
@click="router.push('/dashboard/web5')"
|
|
|
|
|
class="flex items-center gap-2 text-white/50 hover:text-white/80 transition-colors text-sm mb-4"
|
|
|
|
|
>
|
|
|
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
|
|
|
|
</svg>
|
|
|
|
|
Back to Web5
|
|
|
|
|
</button>
|
2026-03-19 19:44:54 +00:00
|
|
|
<div class="flex items-start justify-between gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 class="text-3xl font-bold text-white mb-2">Federation & Peers</h1>
|
|
|
|
|
<p class="text-white/70">Connect, sync, and share with trusted nodes</p>
|
2026-03-19 19:31:03 +00:00
|
|
|
</div>
|
2026-03-19 19:44:54 +00:00
|
|
|
<!-- Your Node DID — top right -->
|
|
|
|
|
<div v-if="selfDid" class="hidden md:flex items-center gap-2 shrink-0 mt-1">
|
|
|
|
|
<p class="text-xs text-white/40 font-mono truncate max-w-[200px] cursor-pointer" :title="selfDid" @click="copyDid">{{ didCopied ? 'Copied!' : shortDid(selfDid) }}</p>
|
|
|
|
|
<button @click="copyDid" class="glass-button px-2 py-1 rounded text-[10px]">{{ didCopied ? 'Copied!' : 'Copy DID' }}</button>
|
|
|
|
|
<button @click="showRotateModal = true" class="glass-button px-2 py-1 rounded text-[10px] text-orange-300">Rotate</button>
|
2026-03-19 19:31:03 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-19 19:44:54 +00:00
|
|
|
<!-- Mobile: DID below title -->
|
|
|
|
|
<div v-if="selfDid" class="md:hidden flex items-center gap-2 mt-3">
|
|
|
|
|
<p class="text-xs text-white/40 font-mono truncate flex-1" :title="selfDid" @click="copyDid">{{ didCopied ? 'Copied!' : shortDid(selfDid) }}</p>
|
|
|
|
|
<button @click="copyDid" class="glass-button px-2 py-1 rounded text-[10px]">{{ didCopied ? 'Copied!' : 'Copy' }}</button>
|
|
|
|
|
<button @click="showRotateModal = true" class="glass-button px-2 py-1 rounded text-[10px] text-orange-300">Rotate</button>
|
|
|
|
|
</div>
|
2026-03-19 19:31:03 +00:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Rotate DID Modal -->
|
|
|
|
|
<Teleport to="body">
|
|
|
|
|
<Transition name="modal">
|
|
|
|
|
<div v-if="showRotateModal" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click.self="showRotateModal = false">
|
|
|
|
|
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
|
|
|
|
<div class="glass-card p-6 max-w-md w-full relative z-10">
|
|
|
|
|
<h3 class="text-lg font-semibold text-white mb-2">Rotate Node DID</h3>
|
|
|
|
|
<p class="text-sm text-white/60 mb-4">This generates a new identity keypair and notifies all federated peers. Your old DID will no longer be valid.</p>
|
|
|
|
|
<input v-model="rotatePassword" type="password" placeholder="Enter your password to confirm" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-orange-500/50 mb-4" />
|
|
|
|
|
<p v-if="rotateError" class="text-red-400 text-xs mb-3">{{ rotateError }}</p>
|
|
|
|
|
<p v-if="rotateSuccess" class="text-green-400 text-xs mb-3">{{ rotateSuccess }}</p>
|
|
|
|
|
<div class="flex gap-3">
|
|
|
|
|
<button @click="showRotateModal = false; rotatePassword = ''; rotateError = ''; rotateSuccess = ''" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
|
|
|
|
|
<button @click="rotateDid" :disabled="rotatingDid || !rotatePassword" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50">
|
|
|
|
|
{{ rotatingDid ? 'Rotating...' : 'Rotate & Notify Peers' }}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Transition>
|
|
|
|
|
</Teleport>
|
|
|
|
|
|
2026-03-13 02:55:16 +00:00
|
|
|
<!-- View Tabs -->
|
|
|
|
|
<div v-if="nodes.length > 0" class="flex gap-1 mb-6 p-1 bg-black/20 rounded-lg w-fit">
|
|
|
|
|
<button
|
|
|
|
|
v-for="tab in viewTabs"
|
|
|
|
|
:key="tab.id"
|
|
|
|
|
class="px-4 py-2 rounded text-sm font-medium transition-colors"
|
|
|
|
|
:class="activeView === tab.id ? 'bg-white/10 text-white border-b-2 border-orange-400' : 'text-white/50 hover:text-white/70'"
|
|
|
|
|
@click="setView(tab.id)"
|
|
|
|
|
>
|
|
|
|
|
{{ tab.label }}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Network Map View -->
|
|
|
|
|
<div v-if="activeView === 'map' && nodes.length > 0" class="mb-6">
|
|
|
|
|
<NetworkMap :nodes="mapNodes" :links="mapLinks" />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<template v-if="activeView === 'list'">
|
2026-03-11 10:44:56 +00:00
|
|
|
<!-- Quick Actions -->
|
|
|
|
|
<div class="glass-card p-6 mb-6">
|
2026-03-19 19:56:24 +00:00
|
|
|
<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) -->
|
2026-03-11 10:44:56 +00:00
|
|
|
<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">
|
2026-03-19 19:56:24 +00:00
|
|
|
<p class="text-sm font-medium text-white">Invite a Peer</p>
|
|
|
|
|
<p class="text-xs text-white/60">Share public content</p>
|
2026-03-11 10:44:56 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
2026-03-19 19:56:24 +00:00
|
|
|
@click="inviteType = 'observer'; generateInvite()"
|
2026-03-11 10:44:56 +00:00
|
|
|
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"
|
|
|
|
|
>
|
2026-03-19 19:56:24 +00:00
|
|
|
{{ generatingInvite && inviteType === 'observer' ? 'Generating...' : 'Generate Code' }}
|
2026-03-11 10:44:56 +00:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-19 19:56:24 +00:00
|
|
|
<!-- Join (accept code) -->
|
2026-03-11 10:44:56 +00:00
|
|
|
<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">
|
2026-03-19 19:56:24 +00:00
|
|
|
<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" />
|
2026-03-11 10:44:56 +00:00
|
|
|
</svg>
|
|
|
|
|
<div class="min-w-0">
|
|
|
|
|
<p class="text-sm font-medium text-white">Join</p>
|
|
|
|
|
<p class="text-xs text-white/60">Accept an invite code</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
@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"
|
|
|
|
|
>
|
2026-03-19 19:56:24 +00:00
|
|
|
Enter Code
|
2026-03-11 10:44:56 +00:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Sync State -->
|
|
|
|
|
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
|
|
|
</svg>
|
|
|
|
|
<div class="min-w-0">
|
|
|
|
|
<p class="text-sm font-medium text-white">Sync</p>
|
|
|
|
|
<p class="text-xs text-white/60">Refresh all node states</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
@click="syncAll"
|
|
|
|
|
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="syncing"
|
|
|
|
|
>
|
|
|
|
|
{{ syncing ? 'Syncing...' : 'Sync Now' }}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Invite Code Display -->
|
|
|
|
|
<div v-if="inviteCode" class="glass-card p-6 mb-6">
|
|
|
|
|
<div class="flex items-center justify-between mb-4">
|
|
|
|
|
<h2 class="text-lg font-semibold text-white">Invite Code</h2>
|
|
|
|
|
<button @click="inviteCode = ''" class="text-white/40 hover:text-white/70 transition-colors text-sm">Dismiss</button>
|
|
|
|
|
</div>
|
|
|
|
|
<p class="text-xs text-white/60 mb-3">Share this code with the node you want to federate with. It can only be used once.</p>
|
|
|
|
|
<div class="bg-black/30 rounded-lg p-4 font-mono text-xs text-orange-300 break-all select-all">{{ inviteCode }}</div>
|
|
|
|
|
<button
|
|
|
|
|
@click="copyInviteCode"
|
|
|
|
|
class="mt-3 px-4 py-2 glass-button rounded text-sm text-white/90 hover:text-white transition-colors"
|
|
|
|
|
>
|
|
|
|
|
{{ copiedInvite ? 'Copied' : 'Copy to Clipboard' }}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Sync Results -->
|
|
|
|
|
<div v-if="syncResults.length > 0" class="glass-card p-6 mb-6">
|
|
|
|
|
<div class="flex items-center justify-between mb-4">
|
|
|
|
|
<h2 class="text-lg font-semibold text-white">Sync Results</h2>
|
|
|
|
|
<button @click="syncResults = []" class="text-white/40 hover:text-white/70 transition-colors text-sm">Dismiss</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
<div v-for="r in syncResults" :key="r.did" class="flex items-center gap-3 p-3 bg-white/5 rounded-lg">
|
|
|
|
|
<div class="w-2 h-2 rounded-full shrink-0" :class="r.status === 'ok' ? 'bg-green-400' : 'bg-red-400'"></div>
|
|
|
|
|
<span class="text-sm text-white/80 font-mono truncate">{{ shortDid(r.did) }}</span>
|
|
|
|
|
<span v-if="r.status === 'ok'" class="text-xs text-green-400">{{ r.apps }} apps</span>
|
|
|
|
|
<span v-else class="text-xs text-red-400 truncate">{{ r.error }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Error Display -->
|
|
|
|
|
<div v-if="error" class="glass-card p-4 mb-6 border-red-400/30">
|
|
|
|
|
<p class="text-sm text-red-400">{{ error }}</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Federated Nodes List -->
|
|
|
|
|
<div class="glass-card p-6 mb-6">
|
2026-03-19 19:56:24 +00:00
|
|
|
<!-- 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>
|
2026-03-11 10:44:56 +00:00
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
<span class="text-white/60 text-sm">Loading nodes...</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-else-if="nodes.length === 0" class="text-center py-12">
|
|
|
|
|
<svg class="w-16 h-16 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
|
|
|
|
</svg>
|
|
|
|
|
<p class="text-white/50 text-sm mb-2">No federated nodes yet</p>
|
|
|
|
|
<p class="text-white/30 text-xs">Generate an invite code or join an existing federation</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-else class="space-y-3">
|
|
|
|
|
<div
|
2026-03-19 19:56:24 +00:00
|
|
|
v-for="node in trustedNodes"
|
2026-03-11 10:44:56 +00:00
|
|
|
: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>
|
2026-03-18 18:41:35 +00:00
|
|
|
<span class="text-sm font-medium text-white truncate" :title="node.did">{{ node.name || shortDid(node.did) }}</span>
|
2026-03-17 00:45:37 +00:00
|
|
|
<span
|
|
|
|
|
class="text-xs shrink-0"
|
|
|
|
|
:class="nodeTransportIcon(node.did).color"
|
|
|
|
|
:title="'Transport: ' + nodeTransportIcon(node.did).label"
|
|
|
|
|
>{{ nodeTransportIcon(node.did).icon }}</span>
|
2026-03-11 10:44:56 +00:00
|
|
|
</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="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs text-white/50">
|
|
|
|
|
<div>
|
|
|
|
|
<span class="text-white/30">Apps:</span>
|
|
|
|
|
{{ node.last_state?.apps?.length ?? '--' }}
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<span class="text-white/30">CPU:</span>
|
|
|
|
|
{{ node.last_state?.cpu_usage_percent != null ? node.last_state.cpu_usage_percent.toFixed(1) + '%' : '--' }}
|
|
|
|
|
</div>
|
2026-03-13 02:50:55 +00:00
|
|
|
<div class="flex items-center gap-1">
|
|
|
|
|
<span class="text-white/30">DWN:</span>
|
|
|
|
|
<span class="w-1.5 h-1.5 rounded-full" :class="dwnSyncDotClass"></span>
|
2026-03-11 10:44:56 +00:00
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<span class="text-white/30">Seen:</span>
|
|
|
|
|
{{ node.last_seen ? timeAgo(node.last_seen) : 'never' }}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-19 19:56:24 +00:00
|
|
|
<!-- 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>
|
|
|
|
|
|
2026-03-13 02:55:16 +00:00
|
|
|
</template>
|
|
|
|
|
|
2026-03-11 10:44:56 +00:00
|
|
|
<!-- Node Detail Modal -->
|
2026-03-14 17:12:41 +00:00
|
|
|
<div v-if="selectedNode" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md" @click.self="selectedNode = null; confirmRemove = false">
|
2026-03-11 10:44:56 +00:00
|
|
|
<div class="glass-card p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto">
|
|
|
|
|
<div class="flex items-center justify-between mb-6">
|
|
|
|
|
<h2 class="text-xl font-semibold text-white">Node Details</h2>
|
|
|
|
|
<button @click="selectedNode = null; confirmRemove = false" class="text-white/40 hover:text-white/70 transition-colors">
|
|
|
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="space-y-4">
|
|
|
|
|
<div class="bg-white/5 rounded-lg p-3">
|
|
|
|
|
<p class="text-xs text-white/40 mb-1">DID</p>
|
|
|
|
|
<p class="text-sm text-white/80 font-mono break-all">{{ selectedNode.did }}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="bg-white/5 rounded-lg p-3">
|
|
|
|
|
<p class="text-xs text-white/40 mb-1">Onion Address</p>
|
|
|
|
|
<p class="text-sm text-white/80 font-mono break-all">{{ selectedNode.onion }}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="bg-white/5 rounded-lg p-3">
|
|
|
|
|
<p class="text-xs text-white/40 mb-1">Trust Level</p>
|
|
|
|
|
<div class="flex items-center gap-2 mt-1">
|
|
|
|
|
<select
|
|
|
|
|
:value="selectedNode.trust_level"
|
|
|
|
|
@change="changeTrust(selectedNode.did, ($event.target as HTMLSelectElement).value)"
|
|
|
|
|
class="bg-black/30 text-white text-sm rounded px-2 py-1 border border-white/10"
|
|
|
|
|
>
|
|
|
|
|
<option value="trusted">Trusted</option>
|
|
|
|
|
<option value="observer">Observer</option>
|
|
|
|
|
<option value="untrusted">Untrusted</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="bg-white/5 rounded-lg p-3">
|
|
|
|
|
<p class="text-xs text-white/40 mb-1">Added</p>
|
|
|
|
|
<p class="text-sm text-white/80">{{ selectedNode.added_at }}</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-if="selectedNode.last_state" class="bg-white/5 rounded-lg p-3">
|
|
|
|
|
<p class="text-xs text-white/40 mb-2">Resource Usage</p>
|
|
|
|
|
<div class="grid grid-cols-2 gap-2 text-sm text-white/70">
|
|
|
|
|
<div>CPU: {{ selectedNode.last_state.cpu_usage_percent?.toFixed(1) ?? '--' }}%</div>
|
|
|
|
|
<div>Uptime: {{ selectedNode.last_state.uptime_secs ? formatUptime(selectedNode.last_state.uptime_secs) : '--' }}</div>
|
|
|
|
|
<div>RAM: {{ formatBytes(selectedNode.last_state.mem_used_bytes) }} / {{ formatBytes(selectedNode.last_state.mem_total_bytes) }}</div>
|
|
|
|
|
<div>Disk: {{ formatBytes(selectedNode.last_state.disk_used_bytes) }} / {{ formatBytes(selectedNode.last_state.disk_total_bytes) }}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-if="selectedNode.last_state?.apps?.length" class="bg-white/5 rounded-lg p-3">
|
|
|
|
|
<p class="text-xs text-white/40 mb-2">Apps ({{ selectedNode.last_state.apps.length }})</p>
|
|
|
|
|
<div class="space-y-1">
|
|
|
|
|
<div v-for="app in selectedNode.last_state.apps" :key="app.id" class="flex items-center justify-between text-sm">
|
|
|
|
|
<span class="text-white/80">{{ app.id }}</span>
|
|
|
|
|
<span class="text-xs" :class="app.status === 'running' ? 'text-green-400' : 'text-white/40'">{{ app.status }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Deploy App (trusted only) -->
|
|
|
|
|
<div v-if="selectedNode.trust_level === 'trusted'" class="bg-white/5 rounded-lg p-3">
|
|
|
|
|
<p class="text-xs text-white/40 mb-2">Deploy App</p>
|
|
|
|
|
<div class="flex gap-2">
|
|
|
|
|
<input
|
|
|
|
|
v-model="deployAppId"
|
|
|
|
|
placeholder="App ID (e.g. bitcoin)"
|
|
|
|
|
class="flex-1 bg-black/30 text-white text-sm rounded px-2 py-1.5 border border-white/10 focus:border-orange-400/50 focus:outline-none"
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
@click="deployApp(selectedNode.did)"
|
|
|
|
|
class="px-3 py-1.5 glass-button rounded text-xs text-white/90 font-medium disabled:opacity-50"
|
|
|
|
|
:disabled="deploying || !deployAppId.trim()"
|
|
|
|
|
>
|
|
|
|
|
{{ deploying ? 'Deploying...' : 'Deploy' }}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<p v-if="deployResult" class="text-xs mt-2" :class="deployResult.startsWith('Error') ? 'text-red-400' : 'text-green-400'">{{ deployResult }}</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-13 02:50:55 +00:00
|
|
|
<!-- DWN Sync -->
|
|
|
|
|
<div class="bg-white/5 rounded-lg p-3">
|
|
|
|
|
<div class="flex items-center justify-between mb-2">
|
|
|
|
|
<p class="text-xs text-white/40">DWN Sync</p>
|
|
|
|
|
<div class="flex items-center gap-1.5">
|
|
|
|
|
<span class="w-1.5 h-1.5 rounded-full" :class="dwnSyncDotClass"></span>
|
|
|
|
|
<span class="text-xs text-white/50">{{ dwnSyncLabel }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="grid grid-cols-2 gap-2 text-sm text-white/70 mb-3">
|
|
|
|
|
<div><span class="text-white/30">Messages:</span> {{ dwnStatus?.message_count ?? '--' }}</div>
|
|
|
|
|
<div><span class="text-white/30">Last sync:</span> {{ dwnStatus?.last_sync ? timeAgo(dwnStatus.last_sync) : 'never' }}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
@click="triggerDwnSync"
|
|
|
|
|
class="px-3 py-1.5 glass-button rounded text-xs text-white/90 font-medium disabled:opacity-50"
|
|
|
|
|
:disabled="dwnSyncing"
|
|
|
|
|
>
|
|
|
|
|
{{ dwnSyncing ? 'Syncing...' : 'Sync Now' }}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-11 10:44:56 +00:00
|
|
|
<div v-if="!confirmRemove">
|
|
|
|
|
<button
|
|
|
|
|
@click="confirmRemove = true"
|
security+feat: v1.3.0 — pentest remediation, container reliability, UI overhaul
Security (33 pentest findings addressed):
- CRITICAL: backend binds 127.0.0.1, path traversal in tor.rs/dwn fixed
- HIGH: federation requires signatures, XSS login redirect, RBAC viewer restricted
- HIGH: tar slip prevention, S3 SSRF validation, backup ID validation
- MEDIUM: remember-me random secret, TOTP session rotation, password re-auth
- LOW: CSP unsafe-inline removed, CORS dev-only, onion/webhook validation
Container reliability:
- Memory limits on all 37 containers (OOM prevention)
- Exited vs stopped state distinction with health-aware status badges
- Crash recovery coordination (no more restart cascade)
- User-stopped tracking survives reboots
- Tiered boot recovery (databases → core → services → apps)
UI:
- Wallet TransactionsModal, health-aware app status badges
- Restart button on containers, exited/crashed red state
- Mesh view overhaul, glass button updates, BaseModal/ToggleSwitch
- Apps sticky header removed, dev faucet, mutable mock wallet
Infrastructure:
- LND REST port 8080 exposed over Tor (LND Connect fix)
- Nginx cookie_session fix, deploy script Tor config updated
- Dev environment: podman auto-start, boot mode simulation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:44:31 +00:00
|
|
|
class="w-full mt-4 px-4 py-2 rounded text-sm glass-button glass-button-danger transition-colors"
|
2026-03-11 10:44:56 +00:00
|
|
|
>
|
|
|
|
|
Remove from Federation
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-else class="mt-4 p-3 bg-red-400/10 rounded-lg border border-red-400/20">
|
|
|
|
|
<p class="text-sm text-red-400 mb-3">Are you sure? This node will be removed from your federation.</p>
|
|
|
|
|
<div class="flex gap-3">
|
|
|
|
|
<button
|
|
|
|
|
@click="confirmRemove = false"
|
|
|
|
|
class="flex-1 px-3 py-1.5 glass-button rounded text-sm text-white/70"
|
|
|
|
|
>Cancel</button>
|
|
|
|
|
<button
|
|
|
|
|
@click="removeNode(selectedNode!.did)"
|
security+feat: v1.3.0 — pentest remediation, container reliability, UI overhaul
Security (33 pentest findings addressed):
- CRITICAL: backend binds 127.0.0.1, path traversal in tor.rs/dwn fixed
- HIGH: federation requires signatures, XSS login redirect, RBAC viewer restricted
- HIGH: tar slip prevention, S3 SSRF validation, backup ID validation
- MEDIUM: remember-me random secret, TOTP session rotation, password re-auth
- LOW: CSP unsafe-inline removed, CORS dev-only, onion/webhook validation
Container reliability:
- Memory limits on all 37 containers (OOM prevention)
- Exited vs stopped state distinction with health-aware status badges
- Crash recovery coordination (no more restart cascade)
- User-stopped tracking survives reboots
- Tiered boot recovery (databases → core → services → apps)
UI:
- Wallet TransactionsModal, health-aware app status badges
- Restart button on containers, exited/crashed red state
- Mesh view overhaul, glass button updates, BaseModal/ToggleSwitch
- Apps sticky header removed, dev faucet, mutable mock wallet
Infrastructure:
- LND REST port 8080 exposed over Tor (LND Connect fix)
- Nginx cookie_session fix, deploy script Tor config updated
- Dev environment: podman auto-start, boot mode simulation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:44:31 +00:00
|
|
|
class="flex-1 px-3 py-1.5 rounded text-sm glass-button glass-button-danger transition-colors font-medium"
|
2026-03-11 10:44:56 +00:00
|
|
|
>Confirm Remove</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Join Modal -->
|
2026-03-14 17:12:41 +00:00
|
|
|
<div v-if="showJoinModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md" @click.self="showJoinModal = false">
|
2026-03-11 10:44:56 +00:00
|
|
|
<div class="glass-card p-6 w-full max-w-md">
|
|
|
|
|
<div class="flex items-center justify-between mb-6">
|
|
|
|
|
<h2 class="text-xl font-semibold text-white">Join Federation</h2>
|
|
|
|
|
<button @click="showJoinModal = false" class="text-white/40 hover:text-white/70 transition-colors">
|
|
|
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<p class="text-sm text-white/60 mb-4">Paste the invite code from the node you want to federate with.</p>
|
|
|
|
|
|
|
|
|
|
<textarea
|
|
|
|
|
v-model="joinCode"
|
|
|
|
|
placeholder="fed1:..."
|
|
|
|
|
rows="3"
|
|
|
|
|
class="w-full bg-black/30 text-white text-sm rounded-lg p-3 border border-white/10 focus:border-orange-400/50 focus:outline-none font-mono resize-none"
|
|
|
|
|
></textarea>
|
|
|
|
|
|
|
|
|
|
<div v-if="joinError" class="mt-3 text-sm text-red-400">{{ joinError }}</div>
|
|
|
|
|
<div v-if="joinSuccess" class="mt-3 text-sm text-green-400">Successfully joined federation</div>
|
|
|
|
|
|
|
|
|
|
<div class="flex gap-3 mt-4">
|
|
|
|
|
<button
|
|
|
|
|
@click="showJoinModal = false"
|
|
|
|
|
class="flex-1 px-4 py-2 glass-button rounded text-sm text-white/70"
|
|
|
|
|
>Cancel</button>
|
|
|
|
|
<button
|
|
|
|
|
@click="joinFederation"
|
|
|
|
|
class="flex-1 px-4 py-2 glass-button rounded text-sm text-white font-medium disabled:opacity-50"
|
|
|
|
|
:disabled="joining || !joinCode.trim()"
|
|
|
|
|
>
|
|
|
|
|
{{ joining ? 'Joining...' : 'Join' }}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-03-13 02:50:55 +00:00
|
|
|
import { ref, computed, onMounted } from 'vue'
|
2026-03-14 04:14:04 +00:00
|
|
|
import { useRouter } from 'vue-router'
|
2026-03-11 10:44:56 +00:00
|
|
|
import { rpcClient } from '@/api/rpc-client'
|
2026-03-17 00:45:37 +00:00
|
|
|
import { useTransportStore } from '@/stores/transport'
|
2026-03-13 02:55:16 +00:00
|
|
|
import NetworkMap from '@/components/federation/NetworkMap.vue'
|
2026-03-11 10:44:56 +00:00
|
|
|
|
2026-03-14 04:14:04 +00:00
|
|
|
const router = useRouter()
|
2026-03-17 00:45:37 +00:00
|
|
|
const transportStore = useTransportStore()
|
2026-03-14 04:14:04 +00:00
|
|
|
|
2026-03-11 10:44:56 +00:00
|
|
|
interface AppStatus {
|
|
|
|
|
id: string
|
|
|
|
|
status: string
|
|
|
|
|
version?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface NodeState {
|
|
|
|
|
timestamp: string
|
|
|
|
|
apps: AppStatus[]
|
|
|
|
|
cpu_usage_percent?: number
|
|
|
|
|
mem_used_bytes?: number
|
|
|
|
|
mem_total_bytes?: number
|
|
|
|
|
disk_used_bytes?: number
|
|
|
|
|
disk_total_bytes?: number
|
|
|
|
|
uptime_secs?: number
|
|
|
|
|
tor_active?: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface FederatedNode {
|
|
|
|
|
did: string
|
|
|
|
|
pubkey: string
|
|
|
|
|
onion: string
|
|
|
|
|
trust_level: string
|
|
|
|
|
added_at: string
|
|
|
|
|
name?: string
|
|
|
|
|
last_seen?: string
|
|
|
|
|
last_state?: NodeState
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nodes = ref<FederatedNode[]>([])
|
|
|
|
|
const loading = ref(true)
|
|
|
|
|
const error = ref('')
|
|
|
|
|
const selectedNode = ref<FederatedNode | null>(null)
|
2026-03-19 19:56:24 +00:00
|
|
|
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'))
|
2026-03-11 10:44:56 +00:00
|
|
|
|
|
|
|
|
const inviteCode = ref('')
|
|
|
|
|
const generatingInvite = ref(false)
|
|
|
|
|
const copiedInvite = ref(false)
|
|
|
|
|
|
|
|
|
|
const showJoinModal = ref(false)
|
|
|
|
|
const joinCode = ref('')
|
|
|
|
|
const joining = ref(false)
|
|
|
|
|
const joinError = ref('')
|
|
|
|
|
const joinSuccess = ref(false)
|
|
|
|
|
|
|
|
|
|
const syncing = ref(false)
|
|
|
|
|
const syncResults = ref<Array<{ did: string; status: string; apps?: number; error?: string }>>([])
|
|
|
|
|
|
|
|
|
|
const confirmRemove = ref(false)
|
|
|
|
|
|
|
|
|
|
const deployAppId = ref('')
|
|
|
|
|
const deploying = ref(false)
|
|
|
|
|
const deployResult = ref('')
|
|
|
|
|
|
2026-03-13 02:55:16 +00:00
|
|
|
const viewTabs = [
|
|
|
|
|
{ id: 'list', label: 'List View' },
|
|
|
|
|
{ id: 'map', label: 'Network Map' },
|
|
|
|
|
] as const
|
|
|
|
|
|
|
|
|
|
type ViewId = typeof viewTabs[number]['id']
|
|
|
|
|
const activeView = ref<ViewId>(
|
|
|
|
|
(localStorage.getItem('federation-view') as ViewId) || (nodes.value.length >= 3 ? 'map' : 'list')
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
function setView(id: ViewId) {
|
|
|
|
|
activeView.value = id
|
|
|
|
|
localStorage.setItem('federation-view', id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const selfDid = ref('')
|
|
|
|
|
|
|
|
|
|
const mapNodes = computed(() => {
|
|
|
|
|
const result = []
|
|
|
|
|
if (selfDid.value) {
|
|
|
|
|
result.push({
|
|
|
|
|
did: selfDid.value,
|
|
|
|
|
label: 'This Node',
|
|
|
|
|
trust_level: 'trusted' as const,
|
|
|
|
|
online: true,
|
|
|
|
|
app_count: 0,
|
|
|
|
|
is_self: true,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
for (const node of nodes.value) {
|
|
|
|
|
result.push({
|
|
|
|
|
did: node.did,
|
|
|
|
|
label: node.name || shortDid(node.did),
|
|
|
|
|
trust_level: node.trust_level as 'trusted' | 'observer' | 'untrusted',
|
|
|
|
|
online: isOnline(node),
|
|
|
|
|
app_count: node.last_state?.apps?.length ?? 0,
|
|
|
|
|
is_self: false,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const mapLinks = computed(() => {
|
|
|
|
|
if (!selfDid.value) return []
|
|
|
|
|
return nodes.value.map(n => ({
|
|
|
|
|
source: selfDid.value,
|
|
|
|
|
target: n.did,
|
|
|
|
|
}))
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-13 02:50:55 +00:00
|
|
|
interface DwnStatus {
|
|
|
|
|
sync_status: string
|
|
|
|
|
last_sync: string | null
|
|
|
|
|
messages_synced: number
|
|
|
|
|
message_count: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const dwnStatus = ref<DwnStatus | null>(null)
|
|
|
|
|
const dwnSyncing = ref(false)
|
|
|
|
|
|
|
|
|
|
const dwnSyncDotClass = computed(() => {
|
|
|
|
|
if (!dwnStatus.value) return 'bg-white/30'
|
|
|
|
|
switch (dwnStatus.value.sync_status) {
|
|
|
|
|
case 'synced': return 'bg-green-400'
|
|
|
|
|
case 'syncing': return 'bg-yellow-400 animate-pulse'
|
|
|
|
|
case 'error': return 'bg-red-400'
|
|
|
|
|
default: return 'bg-white/30'
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const dwnSyncLabel = computed(() => {
|
|
|
|
|
if (!dwnStatus.value) return 'Unknown'
|
|
|
|
|
switch (dwnStatus.value.sync_status) {
|
|
|
|
|
case 'synced': return 'Synced'
|
|
|
|
|
case 'syncing': return 'Syncing...'
|
|
|
|
|
case 'error': return 'Error'
|
|
|
|
|
default: return dwnStatus.value.sync_status
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-11 10:44:56 +00:00
|
|
|
async function loadNodes() {
|
|
|
|
|
try {
|
|
|
|
|
loading.value = true
|
|
|
|
|
const result = await rpcClient.federationListNodes()
|
|
|
|
|
nodes.value = result.nodes
|
|
|
|
|
} catch (e) {
|
|
|
|
|
error.value = e instanceof Error ? e.message : 'Failed to load nodes'
|
|
|
|
|
} finally {
|
|
|
|
|
loading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function generateInvite() {
|
|
|
|
|
try {
|
|
|
|
|
generatingInvite.value = true
|
|
|
|
|
error.value = ''
|
|
|
|
|
const result = await rpcClient.federationInvite()
|
|
|
|
|
inviteCode.value = result.code
|
|
|
|
|
} catch (e) {
|
|
|
|
|
error.value = e instanceof Error ? e.message : 'Failed to generate invite'
|
|
|
|
|
} finally {
|
|
|
|
|
generatingInvite.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function copyInviteCode() {
|
|
|
|
|
try {
|
|
|
|
|
await window.navigator.clipboard.writeText(inviteCode.value)
|
|
|
|
|
} catch {
|
|
|
|
|
const ta = document.createElement('textarea')
|
|
|
|
|
ta.value = inviteCode.value
|
|
|
|
|
ta.style.position = 'fixed'
|
|
|
|
|
ta.style.opacity = '0'
|
|
|
|
|
document.body.appendChild(ta)
|
|
|
|
|
ta.select()
|
|
|
|
|
document.execCommand('copy')
|
|
|
|
|
document.body.removeChild(ta)
|
|
|
|
|
}
|
|
|
|
|
copiedInvite.value = true
|
|
|
|
|
setTimeout(() => { copiedInvite.value = false }, 2000)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function joinFederation() {
|
|
|
|
|
try {
|
|
|
|
|
joining.value = true
|
|
|
|
|
joinError.value = ''
|
|
|
|
|
joinSuccess.value = false
|
|
|
|
|
await rpcClient.federationJoin(joinCode.value.trim())
|
|
|
|
|
joinSuccess.value = true
|
|
|
|
|
joinCode.value = ''
|
|
|
|
|
await loadNodes()
|
|
|
|
|
setTimeout(() => { showJoinModal.value = false; joinSuccess.value = false }, 1500)
|
|
|
|
|
} catch (e) {
|
|
|
|
|
joinError.value = e instanceof Error ? e.message : 'Failed to join'
|
|
|
|
|
} finally {
|
|
|
|
|
joining.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function syncAll() {
|
|
|
|
|
try {
|
|
|
|
|
syncing.value = true
|
|
|
|
|
error.value = ''
|
|
|
|
|
const result = await rpcClient.federationSyncState()
|
|
|
|
|
syncResults.value = result.results
|
|
|
|
|
await loadNodes()
|
|
|
|
|
} catch (e) {
|
|
|
|
|
error.value = e instanceof Error ? e.message : 'Sync failed'
|
|
|
|
|
} finally {
|
|
|
|
|
syncing.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function changeTrust(did: string, level: string) {
|
|
|
|
|
try {
|
|
|
|
|
await rpcClient.federationSetTrust(did, level as 'trusted' | 'observer' | 'untrusted')
|
|
|
|
|
await loadNodes()
|
|
|
|
|
if (selectedNode.value?.did === did) {
|
|
|
|
|
selectedNode.value = nodes.value.find(n => n.did === did) ?? null
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
error.value = e instanceof Error ? e.message : 'Failed to update trust level'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function removeNode(did: string) {
|
|
|
|
|
try {
|
|
|
|
|
await rpcClient.federationRemoveNode(did)
|
|
|
|
|
confirmRemove.value = false
|
|
|
|
|
selectedNode.value = null
|
|
|
|
|
await loadNodes()
|
|
|
|
|
} catch (e) {
|
|
|
|
|
confirmRemove.value = false
|
|
|
|
|
error.value = e instanceof Error ? e.message : 'Failed to remove node'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function deployApp(did: string) {
|
|
|
|
|
try {
|
|
|
|
|
deploying.value = true
|
|
|
|
|
deployResult.value = ''
|
|
|
|
|
await rpcClient.federationDeployApp({
|
|
|
|
|
did,
|
|
|
|
|
appId: deployAppId.value.trim(),
|
|
|
|
|
})
|
|
|
|
|
deployResult.value = `Successfully deployed ${deployAppId.value} to remote node`
|
|
|
|
|
deployAppId.value = ''
|
|
|
|
|
} catch (e) {
|
|
|
|
|
deployResult.value = `Error: ${e instanceof Error ? e.message : 'Deploy failed'}`
|
|
|
|
|
} finally {
|
|
|
|
|
deploying.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 02:50:55 +00:00
|
|
|
async function loadDwnStatus() {
|
|
|
|
|
try {
|
|
|
|
|
const result = await rpcClient.call<DwnStatus>({ method: 'dwn.status' })
|
|
|
|
|
dwnStatus.value = result
|
|
|
|
|
} catch {
|
|
|
|
|
dwnStatus.value = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function triggerDwnSync() {
|
|
|
|
|
try {
|
|
|
|
|
dwnSyncing.value = true
|
|
|
|
|
await rpcClient.call({ method: 'dwn.sync', timeout: 120000 })
|
|
|
|
|
await loadDwnStatus()
|
|
|
|
|
} catch {
|
|
|
|
|
// Silently handle sync errors
|
|
|
|
|
} finally {
|
|
|
|
|
dwnSyncing.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 10:44:56 +00:00
|
|
|
function isOnline(node: FederatedNode): boolean {
|
|
|
|
|
if (!node.last_seen) return false
|
|
|
|
|
const lastSeen = new Date(node.last_seen).getTime()
|
|
|
|
|
const tenMinutesAgo = Date.now() - 10 * 60 * 1000
|
|
|
|
|
return lastSeen > tenMinutesAgo
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function shortDid(did: string): string {
|
|
|
|
|
if (did.length <= 24) return did
|
|
|
|
|
return did.slice(0, 16) + '...' + did.slice(-8)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function timeAgo(iso: string): string {
|
|
|
|
|
const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
|
|
|
|
|
if (seconds < 60) return 'just now'
|
|
|
|
|
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago'
|
|
|
|
|
if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ago'
|
|
|
|
|
return Math.floor(seconds / 86400) + 'd ago'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatBytes(bytes?: number): string {
|
|
|
|
|
if (bytes == null || bytes === 0) return '--'
|
|
|
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
|
|
|
let i = 0
|
|
|
|
|
let val = bytes
|
|
|
|
|
while (val >= 1024 && i < units.length - 1) {
|
|
|
|
|
val /= 1024
|
|
|
|
|
i++
|
|
|
|
|
}
|
|
|
|
|
return val.toFixed(1) + ' ' + units[i]
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 19:56:24 +00:00
|
|
|
// 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`
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 19:31:03 +00:00
|
|
|
// DID rotation
|
|
|
|
|
const showRotateModal = ref(false)
|
|
|
|
|
const rotatePassword = ref('')
|
|
|
|
|
const rotatingDid = ref(false)
|
|
|
|
|
const rotateError = ref('')
|
|
|
|
|
const rotateSuccess = ref('')
|
2026-03-19 19:44:54 +00:00
|
|
|
const didCopied = ref(false)
|
2026-03-19 19:31:03 +00:00
|
|
|
|
|
|
|
|
function copyDid() {
|
|
|
|
|
if (selfDid.value) {
|
|
|
|
|
navigator.clipboard.writeText(selfDid.value).catch(() => {})
|
2026-03-19 19:44:54 +00:00
|
|
|
didCopied.value = true
|
|
|
|
|
setTimeout(() => { didCopied.value = false }, 2000)
|
2026-03-19 19:31:03 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function rotateDid() {
|
|
|
|
|
if (!rotatePassword.value) return
|
|
|
|
|
rotatingDid.value = true
|
|
|
|
|
rotateError.value = ''
|
|
|
|
|
rotateSuccess.value = ''
|
|
|
|
|
try {
|
|
|
|
|
const result = await rpcClient.call<{
|
|
|
|
|
old_did: string; new_did: string; proof_signature: string; proof_message: string
|
|
|
|
|
}>({ method: 'node.rotate-did', params: { password: rotatePassword.value } })
|
|
|
|
|
|
|
|
|
|
selfDid.value = result.new_did
|
|
|
|
|
rotateSuccess.value = `DID rotated. Notifying peers...`
|
|
|
|
|
|
|
|
|
|
// Notify federation peers
|
|
|
|
|
const notify = await rpcClient.call<{ notified: number; failed: number }>({
|
|
|
|
|
method: 'federation.notify-did-change',
|
|
|
|
|
params: {
|
|
|
|
|
old_did: result.old_did,
|
|
|
|
|
new_did: result.new_did,
|
|
|
|
|
proof_signature: result.proof_signature,
|
|
|
|
|
proof_message: result.proof_message,
|
|
|
|
|
},
|
|
|
|
|
timeout: 120000,
|
|
|
|
|
})
|
|
|
|
|
rotateSuccess.value = `DID rotated successfully. ${notify.notified} peers notified${notify.failed > 0 ? `, ${notify.failed} failed` : ''}.`
|
|
|
|
|
rotatePassword.value = ''
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
rotateError.value = err instanceof Error ? err.message : 'Rotation failed'
|
|
|
|
|
} finally {
|
|
|
|
|
rotatingDid.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 10:44:56 +00:00
|
|
|
function formatUptime(secs: number): string {
|
|
|
|
|
const days = Math.floor(secs / 86400)
|
|
|
|
|
const hours = Math.floor((secs % 86400) / 3600)
|
|
|
|
|
if (days > 0) return `${days}d ${hours}h`
|
|
|
|
|
const mins = Math.floor((secs % 3600) / 60)
|
|
|
|
|
return `${hours}h ${mins}m`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function trustBadgeClass(level: string): string {
|
|
|
|
|
switch (level) {
|
|
|
|
|
case 'trusted': return 'bg-green-400/20 text-green-400'
|
|
|
|
|
case 'observer': return 'bg-blue-400/20 text-blue-400'
|
|
|
|
|
case 'untrusted': return 'bg-white/10 text-white/50'
|
|
|
|
|
default: return 'bg-white/10 text-white/50'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 00:45:37 +00:00
|
|
|
/** Get the preferred transport icon for a federated node by DID. */
|
|
|
|
|
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: '📡', color: 'text-orange-400', label: 'mesh' }
|
|
|
|
|
case 'lan': return { icon: '🌐', color: 'text-green-400', label: 'lan' }
|
|
|
|
|
case 'tor': return { icon: '🧅', color: 'text-purple-400', label: 'tor' }
|
|
|
|
|
default: return { icon: '?', color: 'text-white/30', label: 'unknown' }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 02:55:16 +00:00
|
|
|
onMounted(async () => {
|
2026-03-13 02:50:55 +00:00
|
|
|
loadNodes()
|
|
|
|
|
loadDwnStatus()
|
2026-03-17 00:45:37 +00:00
|
|
|
transportStore.fetchPeers()
|
2026-03-13 02:55:16 +00:00
|
|
|
try {
|
|
|
|
|
const result = await rpcClient.getNodeDid()
|
|
|
|
|
selfDid.value = result.did
|
|
|
|
|
} catch {
|
|
|
|
|
// Self DID not available
|
|
|
|
|
}
|
2026-03-13 02:50:55 +00:00
|
|
|
})
|
2026-03-11 10:44:56 +00:00
|
|
|
</script>
|