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:
176
neode-ui/src/components/federation/NetworkMap.vue
Normal file
176
neode-ui/src/components/federation/NetworkMap.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="network-map-container">
|
||||
<svg ref="svgRef" class="w-full h-full"></svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
interface MapNode {
|
||||
did: string
|
||||
label: string
|
||||
trust_level: 'trusted' | 'observer' | 'untrusted'
|
||||
online: boolean
|
||||
app_count: number
|
||||
is_self: boolean
|
||||
}
|
||||
|
||||
interface MapLink {
|
||||
source: string
|
||||
target: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: MapNode[]
|
||||
links: MapLink[]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const svgRef = ref<SVGSVGElement>()
|
||||
|
||||
type SimNode = MapNode & d3.SimulationNodeDatum
|
||||
type SimLink = d3.SimulationLinkDatum<SimNode> & { source: string | SimNode; target: string | SimNode }
|
||||
|
||||
let simulation: d3.Simulation<SimNode, SimLink> | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
function trustColor(level: string): string {
|
||||
switch (level) {
|
||||
case 'trusted': return '#4ade80'
|
||||
case 'observer': return '#fb923c'
|
||||
case 'untrusted': return '#ef4444'
|
||||
default: return '#9ca3af'
|
||||
}
|
||||
}
|
||||
|
||||
function nodeRadius(n: MapNode): number {
|
||||
return n.is_self ? 18 : Math.max(10, Math.min(16, 8 + n.app_count * 0.5))
|
||||
}
|
||||
|
||||
function render() {
|
||||
const svg = d3.select(svgRef.value!)
|
||||
svg.selectAll('*').remove()
|
||||
|
||||
const container = containerRef.value!
|
||||
const width = container.clientWidth
|
||||
const height = container.clientHeight
|
||||
|
||||
svg.attr('viewBox', `0 0 ${width} ${height}`)
|
||||
|
||||
const simNodes: SimNode[] = props.nodes.map(n => ({ ...n }))
|
||||
const simLinks: SimLink[] = props.links.map(l => ({ ...l }))
|
||||
|
||||
// Center the self-node
|
||||
const selfNode = simNodes.find(n => n.is_self)
|
||||
if (selfNode) {
|
||||
selfNode.fx = width / 2
|
||||
selfNode.fy = height / 2
|
||||
}
|
||||
|
||||
simulation = d3.forceSimulation(simNodes)
|
||||
.force('link', d3.forceLink<SimNode, SimLink>(simLinks).id(d => d.did).distance(120))
|
||||
.force('charge', d3.forceManyBody().strength(-300))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collision', d3.forceCollide<SimNode>().radius(d => nodeRadius(d) + 5))
|
||||
|
||||
const g = svg.append('g')
|
||||
|
||||
// Links
|
||||
const link = g.append('g')
|
||||
.selectAll('line')
|
||||
.data(simLinks)
|
||||
.join('line')
|
||||
.attr('stroke', (d: SimLink) => {
|
||||
const src = typeof d.source === 'object' ? d.source : simNodes.find(n => n.did === d.source)
|
||||
const tgt = typeof d.target === 'object' ? d.target : simNodes.find(n => n.did === d.target)
|
||||
return (src as MapNode)?.online && (tgt as MapNode)?.online ? '#4ade8060' : '#6b728050'
|
||||
})
|
||||
.attr('stroke-width', 2)
|
||||
.attr('stroke-dasharray', (d: SimLink) => {
|
||||
const src = typeof d.source === 'object' ? d.source : simNodes.find(n => n.did === d.source)
|
||||
const tgt = typeof d.target === 'object' ? d.target : simNodes.find(n => n.did === d.target)
|
||||
return (src as MapNode)?.online && (tgt as MapNode)?.online ? 'none' : '6 4'
|
||||
})
|
||||
|
||||
// Node groups
|
||||
const node = g.append('g')
|
||||
.selectAll<SVGGElement, SimNode>('g')
|
||||
.data(simNodes)
|
||||
.join('g')
|
||||
.attr('cursor', 'pointer')
|
||||
.call(d3.drag<SVGGElement, SimNode>()
|
||||
.on('start', (event, d) => {
|
||||
if (!event.active) simulation!.alphaTarget(0.3).restart()
|
||||
d.fx = d.x
|
||||
d.fy = d.y
|
||||
})
|
||||
.on('drag', (event, d) => {
|
||||
d.fx = event.x
|
||||
d.fy = event.y
|
||||
})
|
||||
.on('end', (event, d) => {
|
||||
if (!event.active) simulation!.alphaTarget(0)
|
||||
if (!d.is_self) { d.fx = null; d.fy = null }
|
||||
})
|
||||
)
|
||||
|
||||
// Node circles
|
||||
node.append('circle')
|
||||
.attr('r', d => nodeRadius(d))
|
||||
.attr('fill', d => trustColor(d.trust_level))
|
||||
.attr('fill-opacity', d => d.online ? 0.8 : 0.3)
|
||||
.attr('stroke', d => d.is_self ? '#fb923c' : trustColor(d.trust_level))
|
||||
.attr('stroke-width', d => d.is_self ? 3 : 1.5)
|
||||
.attr('stroke-opacity', d => d.online ? 1 : 0.4)
|
||||
|
||||
// Node labels
|
||||
node.append('text')
|
||||
.text(d => d.label)
|
||||
.attr('dy', d => nodeRadius(d) + 14)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', 'rgba(255,255,255,0.7)')
|
||||
.attr('font-size', '11px')
|
||||
.attr('font-family', "'Avenir Next', sans-serif")
|
||||
|
||||
// Tooltip
|
||||
node.append('title')
|
||||
.text(d => `${d.did}\nApps: ${d.app_count}\n${d.online ? 'Online' : 'Offline'}`)
|
||||
|
||||
simulation.on('tick', () => {
|
||||
link
|
||||
.attr('x1', d => (d.source as SimNode).x!)
|
||||
.attr('y1', d => (d.source as SimNode).y!)
|
||||
.attr('x2', d => (d.target as SimNode).x!)
|
||||
.attr('y2', d => (d.target as SimNode).y!)
|
||||
|
||||
node.attr('transform', d => `translate(${d.x},${d.y})`)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
render()
|
||||
resizeObserver = new ResizeObserver(() => render())
|
||||
if (containerRef.value) resizeObserver.observe(containerRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
simulation?.stop()
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
watch(() => [props.nodes, props.links], () => render(), { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.network-map-container {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(24px);
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
min-height: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user