703 lines
26 KiB
Vue
703 lines
26 KiB
Vue
<template>
|
|
<div class="pb-6">
|
|
<div class="mb-8">
|
|
<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>
|
|
<h1 class="text-3xl font-bold text-white mb-2">Federation</h1>
|
|
<p class="text-white/70">Manage trusted node clusters and sync state across your network</p>
|
|
<p class="text-sm text-white/60 mt-2">{{ nodes.length }} federated node{{ nodes.length !== 1 ? 's' : '' }}</p>
|
|
</div>
|
|
|
|
<!-- 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'">
|
|
<!-- 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 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>
|
|
</div>
|
|
</div>
|
|
<button
|
|
@click="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' }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Join Federation -->
|
|
<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" />
|
|
</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"
|
|
>
|
|
Join
|
|
</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">
|
|
<h2 class="text-lg font-semibold text-white mb-4">Federated Nodes</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>
|
|
<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
|
|
v-for="node in nodes"
|
|
: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">{{ 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="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>
|
|
<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>
|
|
</div>
|
|
<div>
|
|
<span class="text-white/30">Seen:</span>
|
|
{{ node.last_seen ? timeAgo(node.last_seen) : 'never' }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</template>
|
|
|
|
<!-- Node Detail Modal -->
|
|
<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">
|
|
<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>
|
|
|
|
<!-- 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>
|
|
|
|
<div v-if="!confirmRemove">
|
|
<button
|
|
@click="confirmRemove = true"
|
|
class="w-full mt-4 px-4 py-2 rounded text-sm text-red-400 border border-red-400/30 hover:bg-red-400/10 transition-colors"
|
|
>
|
|
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)"
|
|
class="flex-1 px-3 py-1.5 rounded text-sm text-red-400 border border-red-400/30 hover:bg-red-400/10 transition-colors font-medium"
|
|
>Confirm Remove</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Join Modal -->
|
|
<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">
|
|
<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">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { rpcClient } from '@/api/rpc-client'
|
|
import NetworkMap from '@/components/federation/NetworkMap.vue'
|
|
|
|
const router = useRouter()
|
|
|
|
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)
|
|
|
|
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('')
|
|
|
|
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,
|
|
}))
|
|
})
|
|
|
|
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
|
|
}
|
|
})
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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]
|
|
}
|
|
|
|
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'
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
loadNodes()
|
|
loadDwnStatus()
|
|
try {
|
|
const result = await rpcClient.getNodeDid()
|
|
selfDid.value = result.did
|
|
} catch {
|
|
// Self DID not available
|
|
}
|
|
})
|
|
</script>
|