bug fixing and deploy and build diagnostics
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="mb-6">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<!-- Your Node DID — top right card (desktop) -->
|
||||
<div v-if="selfDid" class="hidden md:block shrink-0">
|
||||
<div class="glass-card px-4 py-3 flex items-center gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[10px] text-white/40 mb-0.5">{{ serverName }}</p>
|
||||
<p class="text-xs text-white/80 font-mono cursor-pointer" :title="selfDid" @click="handleCopy">{{ didCopied ? 'Copied!' : shortDidDisplay }}</p>
|
||||
</div>
|
||||
<button @click="handleCopy" class="glass-button px-2.5 py-1 rounded text-[10px]">{{ didCopied ? 'Copied!' : 'Copy' }}</button>
|
||||
<button @click="$emit('rotate')" class="glass-button px-2.5 py-1 rounded text-[10px] text-orange-300">Rotate</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile: DID below title -->
|
||||
<div v-if="selfDid" class="md:hidden glass-card px-4 py-3 mt-3 flex items-center gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[10px] text-white/40 mb-0.5">{{ serverName }}</p>
|
||||
<p class="text-xs text-white/80 font-mono truncate cursor-pointer" :title="selfDid" @click="handleCopy">{{ didCopied ? 'Copied!' : shortDidDisplay }}</p>
|
||||
</div>
|
||||
<button @click="handleCopy" class="glass-button px-2.5 py-1 rounded text-[10px]">{{ didCopied ? 'Copied!' : 'Copy' }}</button>
|
||||
<button @click="$emit('rotate')" class="glass-button px-2.5 py-1 rounded text-[10px] text-orange-300">Rotate</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { shortDid } from './utils'
|
||||
|
||||
const props = defineProps<{
|
||||
selfDid: string
|
||||
serverName: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
rotate: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const didCopied = ref(false)
|
||||
|
||||
const shortDidDisplay = computed(() => shortDid(props.selfDid))
|
||||
|
||||
function handleCopy() {
|
||||
if (props.selfDid) {
|
||||
navigator.clipboard.writeText(props.selfDid).catch(() => {})
|
||||
didCopied.value = true
|
||||
setTimeout(() => { didCopied.value = false }, 2000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="visible" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click.self="$emit('close')">
|
||||
<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">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-white">Join Federation</h2>
|
||||
<button @click="$emit('close')" 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="error" class="mt-3 text-sm text-red-400">{{ error }}</div>
|
||||
<div v-if="success" class="mt-3 text-sm text-green-400">Successfully joined federation</div>
|
||||
|
||||
<div class="flex gap-3 mt-4">
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="flex-1 px-4 py-2 glass-button rounded text-sm text-white/70"
|
||||
>Cancel</button>
|
||||
<button
|
||||
@click="handleJoin"
|
||||
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>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
joining: boolean
|
||||
error: string
|
||||
success: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
join: [code: string]
|
||||
}>()
|
||||
|
||||
const joinCode = ref('')
|
||||
|
||||
function handleJoin() {
|
||||
if (joinCode.value.trim()) {
|
||||
emit('join', joinCode.value.trim())
|
||||
}
|
||||
}
|
||||
|
||||
// Clear code on successful join
|
||||
watch(() => props.success, (val) => {
|
||||
if (val) joinCode.value = ''
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div v-if="node" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md" @click.self="handleClose">
|
||||
<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="handleClose" 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">{{ node.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 v-if="node.trust_level === 'trusted'" class="text-sm text-white/80 font-mono break-all">{{ node.onion }}</p>
|
||||
<p v-else class="text-sm text-white/30 italic">Not visible to peers</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="node.trust_level"
|
||||
@change="emit('change-trust', node!.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">Blocked</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">{{ node.added_at }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="node.trust_level === 'trusted' && node.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: {{ node.last_state.cpu_usage_percent?.toFixed(1) ?? '--' }}%</div>
|
||||
<div>Uptime: {{ node.last_state.uptime_secs ? formatUptime(node.last_state.uptime_secs) : '--' }}</div>
|
||||
<div>RAM: {{ formatBytes(node.last_state.mem_used_bytes) }} / {{ formatBytes(node.last_state.mem_total_bytes) }}</div>
|
||||
<div>Disk: {{ formatBytes(node.last_state.disk_used_bytes) }} / {{ formatBytes(node.last_state.disk_total_bytes) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="node.last_state?.apps?.length && node.trust_level === 'trusted'" class="bg-white/5 rounded-lg p-3">
|
||||
<p class="text-xs text-white/40 mb-2">Apps ({{ node.last_state.apps.length }})</p>
|
||||
<div class="space-y-1">
|
||||
<div v-for="app in node.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="node.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="handleDeploy"
|
||||
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> {{ dwnMessageCount }}</div>
|
||||
<div><span class="text-white/30">Last sync:</span> {{ dwnLastSync }}</div>
|
||||
</div>
|
||||
<button
|
||||
@click="emit('dwn-sync')"
|
||||
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 glass-button glass-button-danger 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="emit('remove-node', node!.did)"
|
||||
class="flex-1 px-3 py-1.5 rounded text-sm glass-button glass-button-danger transition-colors font-medium"
|
||||
>Confirm Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { FederatedNode } from './types'
|
||||
import { formatBytes, formatUptime } from './utils'
|
||||
|
||||
const props = defineProps<{
|
||||
node: FederatedNode | null
|
||||
dwnSyncDotClass: string
|
||||
dwnSyncLabel: string
|
||||
dwnMessageCount: string
|
||||
dwnLastSync: string
|
||||
dwnSyncing: boolean
|
||||
deploying: boolean
|
||||
deployResult: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
'change-trust': [did: string, level: string]
|
||||
'remove-node': [did: string]
|
||||
'deploy-app': [did: string, appId: string]
|
||||
'dwn-sync': []
|
||||
}>()
|
||||
|
||||
const confirmRemove = ref(false)
|
||||
const deployAppId = ref('')
|
||||
|
||||
function handleClose() {
|
||||
confirmRemove.value = false
|
||||
deployAppId.value = ''
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleDeploy() {
|
||||
if (props.node && deployAppId.value.trim()) {
|
||||
emit('deploy-app', props.node.did, deployAppId.value.trim())
|
||||
deployAppId.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<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="$emit('clear-sync-results')" 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 truncate" :title="r.did">{{ nodeNameFromDid(r.did, nodes) }}</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>
|
||||
|
||||
<!-- Two-column: Your Nodes + Peers -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
|
||||
<!-- Your Nodes (Trusted) -->
|
||||
<div class="glass-card p-6 max-h-[60vh] flex flex-col">
|
||||
<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>
|
||||
<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 overflow-y-auto">
|
||||
<div
|
||||
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="$emit('select-node', 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">{{ 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>
|
||||
</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>
|
||||
|
||||
<!-- Peers (Observer level) -->
|
||||
<div class="glass-card p-6 max-h-[60vh] flex flex-col">
|
||||
<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="$emit('cleanup-dead')"
|
||||
: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 overflow-y-auto">
|
||||
<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="$emit('select-node', 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">{{ nodeName(node) }}</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>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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'
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: FederatedNode[]
|
||||
loading: boolean
|
||||
error: string
|
||||
syncResults: SyncResult[]
|
||||
dwnSyncDotClass: string
|
||||
cleaningNodes: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'select-node': [node: FederatedNode]
|
||||
'clear-sync-results': []
|
||||
'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' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Quick Actions -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<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="$emit('generate-invite', 'trusted')"
|
||||
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 a Peer</p>
|
||||
<p class="text-xs text-white/60">Share public content</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('generate-invite', 'observer')"
|
||||
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 === 'observer' ? 'Generating...' : 'Generate Code' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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="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>
|
||||
<p class="text-xs text-white/60">Accept an invite code</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('show-join')"
|
||||
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"
|
||||
>
|
||||
Enter Code
|
||||
</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="$emit('sync')"
|
||||
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 Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="inviteCode" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click.self="$emit('clear-invite')">
|
||||
<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">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-white">{{ inviteType === 'trusted' ? 'Link Your Nodes — Invite Code' : 'Peer Invite Code' }}</h2>
|
||||
<button @click="$emit('clear-invite')" 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-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="handleCopyInvite"
|
||||
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>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
generatingInvite: boolean
|
||||
inviteType: 'trusted' | 'observer'
|
||||
inviteCode: string
|
||||
syncing: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'generate-invite': [type: 'trusted' | 'observer']
|
||||
'show-join': []
|
||||
'sync': []
|
||||
'clear-invite': []
|
||||
}>()
|
||||
|
||||
const copiedInvite = ref(false)
|
||||
|
||||
async function handleCopyInvite() {
|
||||
try {
|
||||
await window.navigator.clipboard.writeText(props.inviteCode)
|
||||
} catch {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = props.inviteCode
|
||||
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)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="visible" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click.self="$emit('close')">
|
||||
<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="password" 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="error" class="text-red-400 text-xs mb-3">{{ error }}</p>
|
||||
<p v-if="success" class="text-green-400 text-xs mb-3">{{ success }}</p>
|
||||
<div class="flex gap-3">
|
||||
<button @click="handleClose" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
|
||||
<button @click="$emit('rotate', password)" :disabled="rotating || !password" 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">
|
||||
{{ rotating ? 'Rotating...' : 'Rotate & Notify Peers' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
rotating: boolean
|
||||
error: string
|
||||
success: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
rotate: [password: string]
|
||||
}>()
|
||||
|
||||
const password = ref('')
|
||||
|
||||
function handleClose() {
|
||||
password.value = ''
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Reset password when modal opens/closes
|
||||
watch(() => props.visible, (val) => {
|
||||
if (!val) password.value = ''
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
export interface AppStatus {
|
||||
id: string
|
||||
status: string
|
||||
version?: string
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export interface FederatedNode {
|
||||
did: string
|
||||
pubkey: string
|
||||
onion: string
|
||||
trust_level: string
|
||||
added_at: string
|
||||
name?: string
|
||||
last_seen?: string
|
||||
last_state?: NodeState
|
||||
}
|
||||
|
||||
export interface DwnStatus {
|
||||
sync_status: string
|
||||
last_sync: string | null
|
||||
messages_synced: number
|
||||
message_count: number
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
did: string
|
||||
status: string
|
||||
apps?: number
|
||||
error?: string
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/** User-friendly node display name. Prefers name, falls back to "Node-XXXX" from DID hash. */
|
||||
export function nodeName(node: { name?: string | null; did: string }): string {
|
||||
if (node.name) return node.name
|
||||
const suffix = node.did.replace(/^did:key:z6Mk/, '').slice(-6).toUpperCase()
|
||||
return `Node-${suffix}`
|
||||
}
|
||||
|
||||
/** Look up display name from DID using a node list. */
|
||||
export function nodeNameFromDid(did: string, nodes: { name?: string; did: string }[]): string {
|
||||
const node = nodes.find(n => n.did === did)
|
||||
if (node) return nodeName(node)
|
||||
const suffix = did.replace(/^did:key:z6Mk/, '').slice(-6).toUpperCase()
|
||||
return `Node-${suffix}`
|
||||
}
|
||||
|
||||
export function shortDid(did: string): string {
|
||||
if (did.length <= 24) return did
|
||||
return did.slice(0, 16) + '...' + did.slice(-8)
|
||||
}
|
||||
|
||||
export 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'
|
||||
}
|
||||
|
||||
export 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`
|
||||
}
|
||||
|
||||
export 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]
|
||||
}
|
||||
|
||||
export 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`
|
||||
}
|
||||
|
||||
export 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'
|
||||
}
|
||||
}
|
||||
|
||||
export function isOnline(node: { last_seen?: string }): 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
|
||||
}
|
||||
Reference in New Issue
Block a user