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 9a22a3c5df
commit 0a9a255cc5
5 changed files with 1006 additions and 4 deletions

View File

@ -530,11 +530,11 @@
### Sprint 46: Node Visualization Map (July 2026 Week 1-2)
- [ ] **MAP-01** — Install D3.js and create network topology component. Run `cd neode-ui && npm install d3@^7 && npm install -D @types/d3@^7`. Create `neode-ui/src/components/federation/NetworkMap.vue` — a force-directed graph component using `d3-force`. Nodes are circles: size proportional to app count, color by trust level (green=trusted `#4ade80`, amber=observer `#fb923c`, red=untrusted `#ef4444`), opacity by online/offline (1.0=online, 0.4=offline). Edges are lines between federated nodes: solid green when both online, dashed gray when one offline. Add labels showing node name (truncated DID or custom alias). Use `bg-black/60 backdrop-blur-glass rounded-xl border border-white/10` container to match glassmorphism design. SVG fills the container, responsive to window resize. Add CSS classes to `neode-ui/src/style.css`. **Acceptance**: Component renders a graph with 4 test nodes (mock data). Nodes repel/attract via force simulation. Looks consistent with Archipelago glass aesthetic.
- [x] **MAP-01** — Install D3.js and create network topology component. Run `cd neode-ui && npm install d3@^7 && npm install -D @types/d3@^7`. Create `neode-ui/src/components/federation/NetworkMap.vue` — a force-directed graph component using `d3-force`. Nodes are circles: size proportional to app count, color by trust level (green=trusted `#4ade80`, amber=observer `#fb923c`, red=untrusted `#ef4444`), opacity by online/offline (1.0=online, 0.4=offline). Edges are lines between federated nodes: solid green when both online, dashed gray when one offline. Add labels showing node name (truncated DID or custom alias). Use `bg-black/60 backdrop-blur-glass rounded-xl border border-white/10` container to match glassmorphism design. SVG fills the container, responsive to window resize. Add CSS classes to `neode-ui/src/style.css`. **Acceptance**: Component renders a graph with 4 test nodes (mock data). Nodes repel/attract via force simulation. Looks consistent with Archipelago glass aesthetic.
- [ ] **MAP-02** — Wire network map to real federation data. In `NetworkMap.vue`, accept a `nodes` prop of type `FederatedNode[]` (from `federation.list-nodes` response). Add the current node as the center node (use `node.did` RPC to get own identity). Map each node to a D3 node: id=DID, label=name or truncated DID, trust_level, online=(last_seen within 10 minutes), cpu_usage, memory_percent, app_count from `last_state`. Edges connect each peer to the local node (star topology for now). Add node tooltips on hover showing: full DID, onion address (truncated), CPU/memory/disk percentages, app count, last seen time. Click a node to open the existing node detail modal. Poll `federation.list-nodes` every 30 seconds and update the graph with smooth transitions (D3 enter/update/exit). **Acceptance**: Network map shows all real federated nodes with live data. Online/offline status updates when a server goes down. Tooltips show real metrics. Deploy and verify.
- [x] **MAP-02** — Wire network map to real federation data. In `NetworkMap.vue`, accept a `nodes` prop of type `FederatedNode[]` (from `federation.list-nodes` response). Add the current node as the center node (use `node.did` RPC to get own identity). Map each node to a D3 node: id=DID, label=name or truncated DID, trust_level, online=(last_seen within 10 minutes), cpu_usage, memory_percent, app_count from `last_state`. Edges connect each peer to the local node (star topology for now). Add node tooltips on hover showing: full DID, onion address (truncated), CPU/memory/disk percentages, app count, last seen time. Click a node to open the existing node detail modal. Poll `federation.list-nodes` every 30 seconds and update the graph with smooth transitions (D3 enter/update/exit). **Acceptance**: Network map shows all real federated nodes with live data. Online/offline status updates when a server goes down. Tooltips show real metrics. Deploy and verify.
- [ ] **MAP-03** — Add network map as tab in Federation page. In `neode-ui/src/views/Federation.vue`, add a tab switcher at the top: "List View" (current) and "Network Map". List view shows the existing node cards. Network Map tab shows `NetworkMap.vue` taking full width of the content area. Remember selected tab in localStorage. Default to Map view when 3+ nodes are federated, List view otherwise. Add the tab styling as global classes in `style.css` following the existing tab patterns (if any) or using `.glass-tab` / `.glass-tab-active` classes with `bg-white/5` inactive and `bg-white/10 border-b-2 border-orange-400` active. **Acceptance**: Can switch between list and map views. Map shows live federation data. Tab selection persists across page navigations. Deploy and verify.
- [x] **MAP-03** — Add network map as tab in Federation page. In `neode-ui/src/views/Federation.vue`, add a tab switcher at the top: "List View" (current) and "Network Map". List view shows the existing node cards. Network Map tab shows `NetworkMap.vue` taking full width of the content area. Remember selected tab in localStorage. Default to Map view when 3+ nodes are federated, List view otherwise. Add the tab styling as global classes in `style.css` following the existing tab patterns (if any) or using `.glass-tab` / `.glass-tab-active` classes with `bg-white/5` inactive and `bg-white/10 border-b-2 border-orange-400` active. **Acceptance**: Can switch between list and map views. Map shows live federation data. Tab selection persists across page navigations. Deploy and verify.
- [ ] **MAP-04** — Add DWN management section to Web5 page. In `neode-ui/src/views/Web5.vue`, enhance the existing DWN section with: (1) A "Manage Protocols" subsection showing registered protocols in a list with delete buttons, plus an "Add Protocol" form (URL input), (2) A "Message Store" subsection showing total message count, storage size in bytes (human-readable), and a "Browse Messages" button that opens a modal with a paginated message list (fetch via `dwn.query-messages` with limit/offset), (3) A "Sync Targets" subsection showing which peers are configured for DWN sync and their last sync status. Wire to existing `dwn.*` RPC endpoints. **Acceptance**: Can add/remove protocols, browse stored messages, and see sync status from the Web5 page. Deploy and verify.

View File

@ -8,6 +8,7 @@
"name": "neode-ui",
"version": "0.0.0",
"dependencies": {
"d3": "^7.9.0",
"dockerode": "^4.0.9",
"fast-json-patch": "^3.1.1",
"fuse.js": "^7.1.0",
@ -18,6 +19,7 @@
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@types/d3": "^7.4.3",
"@types/node": "^24.10.0",
"@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-vue": "^6.0.1",
@ -3499,6 +3501,290 @@
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/d3-axis": "*",
"@types/d3-brush": "*",
"@types/d3-chord": "*",
"@types/d3-color": "*",
"@types/d3-contour": "*",
"@types/d3-delaunay": "*",
"@types/d3-dispatch": "*",
"@types/d3-drag": "*",
"@types/d3-dsv": "*",
"@types/d3-ease": "*",
"@types/d3-fetch": "*",
"@types/d3-force": "*",
"@types/d3-format": "*",
"@types/d3-geo": "*",
"@types/d3-hierarchy": "*",
"@types/d3-interpolate": "*",
"@types/d3-path": "*",
"@types/d3-polygon": "*",
"@types/d3-quadtree": "*",
"@types/d3-random": "*",
"@types/d3-scale": "*",
"@types/d3-scale-chromatic": "*",
"@types/d3-selection": "*",
"@types/d3-shape": "*",
"@types/d3-time": "*",
"@types/d3-time-format": "*",
"@types/d3-timer": "*",
"@types/d3-transition": "*",
"@types/d3-zoom": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-axis": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-brush": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-chord": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-contour": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/geojson": "*"
}
},
"node_modules/@types/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-dispatch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-dsv": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-fetch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-dsv": "*"
}
},
"node_modules/@types/d3-force": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-format": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/d3-hierarchy": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-polygon": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-quadtree": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-random": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-time-format": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@ -3513,6 +3799,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.12.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
@ -5128,6 +5421,429 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
"license": "ISC",
"dependencies": {
"d3-array": "3",
"d3-axis": "3",
"d3-brush": "3",
"d3-chord": "3",
"d3-color": "3",
"d3-contour": "4",
"d3-delaunay": "6",
"d3-dispatch": "3",
"d3-drag": "3",
"d3-dsv": "3",
"d3-ease": "3",
"d3-fetch": "3",
"d3-force": "3",
"d3-format": "3",
"d3-geo": "3",
"d3-hierarchy": "3",
"d3-interpolate": "3",
"d3-path": "3",
"d3-polygon": "3",
"d3-quadtree": "3",
"d3-random": "3",
"d3-scale": "4",
"d3-scale-chromatic": "3",
"d3-selection": "3",
"d3-shape": "3",
"d3-time": "3",
"d3-time-format": "4",
"d3-timer": "3",
"d3-transition": "3",
"d3-zoom": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-axis": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-brush": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "3",
"d3-transition": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-chord": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
"license": "ISC",
"dependencies": {
"d3-path": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-contour": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
"license": "ISC",
"dependencies": {
"d3-array": "^3.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
"license": "ISC",
"dependencies": {
"delaunator": "5"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
"license": "ISC",
"dependencies": {
"commander": "7",
"iconv-lite": "0.6",
"rw": "1"
},
"bin": {
"csv2json": "bin/dsv2json.js",
"csv2tsv": "bin/dsv2dsv.js",
"dsv2dsv": "bin/dsv2dsv.js",
"dsv2json": "bin/dsv2json.js",
"json2csv": "bin/json2dsv.js",
"json2dsv": "bin/json2dsv.js",
"json2tsv": "bin/json2dsv.js",
"tsv2csv": "bin/dsv2dsv.js",
"tsv2json": "bin/dsv2json.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv/node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/d3-dsv/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-fetch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
"license": "ISC",
"dependencies": {
"d3-dsv": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-hierarchy": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-polygon": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-random": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/data-urls": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
@ -5312,6 +6028,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/delaunator": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
"license": "ISC",
"dependencies": {
"robust-predicates": "^3.0.2"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@ -6544,6 +7269,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -8490,6 +9224,12 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
"license": "Unlicense"
},
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
@ -8567,6 +9307,12 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",

View File

@ -23,6 +23,7 @@
"generate-welcome-speech": "node scripts/generate-welcome-speech.js"
},
"dependencies": {
"d3": "^7.9.0",
"dockerode": "^4.0.9",
"fast-json-patch": "^3.1.1",
"fuse.js": "^7.1.0",
@ -33,6 +34,7 @@
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@types/d3": "^7.4.3",
"@types/node": "^24.10.0",
"@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-vue": "^6.0.1",

View 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>

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>