feat: add D3.js network map visualization to Federation page

- Install d3@7 and @types/d3@7
- NetworkMap.vue: force-directed graph with draggable nodes, trust-level
  coloring (green/amber/red), online/offline opacity, dashed links
- Federation.vue: List/Map tab switcher with localStorage persistence
- Wire map to real federation data (self node centered, peers as satellites)
- Default to map view when 3+ nodes federated

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-13 02:55:16 +00:00
parent 696c6d176b
commit 3bbb5c17bb
5 changed files with 1006 additions and 4 deletions

View File

@@ -6,6 +6,25 @@
<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">
@@ -164,6 +183,8 @@
</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/60 backdrop-blur-sm" @click.self="selectedNode = null; confirmRemove = false">
<div class="glass-card p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto">
@@ -337,6 +358,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import NetworkMap from '@/components/federation/NetworkMap.vue'
interface AppStatus {
id: string
@@ -391,6 +413,56 @@ 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
@@ -605,8 +677,14 @@ function trustBadgeClass(level: string): string {
}
}
onMounted(() => {
onMounted(async () => {
loadNodes()
loadDwnStatus()
try {
const result = await rpcClient.getNodeDid()
selfDid.value = result.did
} catch {
// Self DID not available
}
})
</script>