Files
nex/public/d2.html
Tour 613b6345f2 d2
2025-12-08 22:30:37 +01:00

1606 lines
46 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>App.model</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
color: #e2e8f0;
overflow: hidden;
}
.container {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: rgba(15, 23, 42, 0.9);
backdrop-filter: blur(10px);
padding: 20px 30px;
border-bottom: 1px solid #334155;
z-index: 10;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 1.8em;
background: linear-gradient(90deg, #60a5fa 0%, #818cf8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.controls {
display: flex;
gap: 10px;
}
.control-btn {
background: rgba(30, 41, 59, 0.7);
border: 1px solid #475569;
color: #cbd5e1;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
font-size: 14px;
}
.control-btn:hover {
background: rgba(56, 70, 100, 0.8);
border-color: #60a5fa;
}
.svg-container {
flex: 1;
width: 100%;
position: relative;
overflow: hidden;
min-height: 0; /* Important for flex children */
}
#d2-output {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
.svg-container svg {
width: 100%;
height: 100%;
cursor: grab;
}
.svg-container svg:active {
cursor: grabbing;
}
/* D2 styling */
:root {
--router-fill: #f59e0b;
--router-stroke: #fbbf24;
--core-fill: #ef4444;
--core-stroke: #f87171;
--infra-fill: #10b981;
--infra-stroke: #34d399;
--worker-fill: #8b5cf6;
--worker-stroke: #a78bfa;
--iot-fill: #f97316;
--iot-stroke: #fb923c;
--client-fill: #6b7280;
--client-stroke: #9ca3af;
--dns-fill: #0ea5e9;
--dns-stroke: #38bdf8;
}
.d2 .router .shape {
fill: var(--router-fill) !important;
stroke: var(--router-stroke) !important;
}
.d2 .core .shape {
fill: var(--core-fill) !important;
stroke: var(--core-stroke) !important;
}
.d2 .infra .shape {
fill: var(--infra-fill) !important;
stroke: var(--infra-stroke) !important;
}
.d2 .worker .shape {
fill: var(--worker-fill) !important;
stroke: var(--worker-stroke) !important;
}
.d2 .iot .shape {
fill: var(--iot-fill) !important;
stroke: var(--iot-stroke) !important;
}
.d2 .client .shape {
fill: var(--client-fill) !important;
stroke: var(--client-stroke) !important;
}
.d2 .dns .shape {
fill: var(--dns-fill) !important;
stroke: var(--dns-stroke) !important;
}
.d2 .node:hover .shape {
filter: drop-shadow(0 0 10px currentColor);
cursor: pointer;
}
.d2 .connection:hover {
stroke: #60a5fa !important;
stroke-width: 3 !important;
}
/* Tooltip */
.tooltip {
position: fixed;
background: rgba(15, 23, 42, 0.95);
color: #e2e8f0;
padding: 12px 16px;
border-radius: 8px;
font-size: 12px;
pointer-events: none;
z-index: 1000;
opacity: 0;
transition: opacity 0.3s ease;
border: 1px solid #475569;
backdrop-filter: blur(10px);
max-width: 300px;
line-height: 1.5;
}
.tooltip.show {
opacity: 1;
}
/* Detail Panel */
.detail-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.95);
background: rgba(15, 23, 42, 0.98);
border: 1px solid #475569;
border-radius: 16px;
padding: 30px;
min-width: 450px;
max-width: 650px;
max-height: 80vh;
z-index: 2000;
opacity: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(20px);
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
overflow-y: auto;
}
.detail-panel.show {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #334155;
}
.detail-title {
font-size: 1.5em;
color: #60a5fa;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
color: #94a3b8;
font-size: 24px;
cursor: pointer;
transition: color 0.3s ease;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: #e2e8f0;
}
.detail-content {
line-height: 1.6;
}
.detail-section {
margin-bottom: 24px;
}
.detail-section h3 {
color: #818cf8;
margin-bottom: 12px;
font-size: 1.1em;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.tag {
display: inline-block;
background: rgba(96, 165, 250, 0.15);
color: #60a5fa;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
margin: 2px;
border: 1px solid rgba(96, 165, 250, 0.2);
font-family: 'Courier New', monospace;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
}
.detail-item {
background: rgba(30, 41, 59, 0.5);
padding: 10px;
border-radius: 8px;
border: 1px solid #334155;
}
.detail-item strong {
color: #94a3b8;
font-size: 0.9em;
}
.detail-item code {
font-family: 'Courier New', monospace;
color: #e2e8f0;
}
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1500;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.overlay.show {
opacity: 1;
pointer-events: all;
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1.2em;
color: #60a5fa;
}
.zoom-controls {
position: absolute;
top: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 5px;
z-index: 100;
}
.zoom-btn {
background: rgba(30, 41, 59, 0.7);
border: 1px solid #475569;
color: #cbd5e1;
width: 40px;
height: 40px;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.zoom-btn:hover {
background: rgba(56, 70, 100, 0.8);
border-color: #60a5fa;
}
/* D3 Network Styles */
#d2-output svg {
background: transparent;
}
.node-group {
cursor: pointer;
transition: all 0.3s ease;
}
.node-circle {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
transition: all 0.3s ease;
}
.node-group:hover .node-circle {
filter: drop-shadow(0 4px 12px rgba(96, 165, 250, 0.6));
transform: scale(1.1);
}
/* Generic shape styling for all node shapes (rects, polygons, paths, etc.) */
.node-shape {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
transition: all 0.3s ease;
}
.node-group:hover .node-shape {
filter: drop-shadow(0 4px 12px rgba(96, 165, 250, 0.6));
transform: scale(1.08);
}
.node-group.selected .node-shape {
stroke-width: 4px !important;
filter: drop-shadow(0 0 20px currentColor);
}
.node-label {
pointer-events: none;
user-select: none;
font-size: 11px;
font-weight: 600;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
}
.node-sublabel {
pointer-events: none;
user-select: none;
font-size: 9px;
opacity: 0.8;
}
.link {
stroke: #475569;
stroke-opacity: 0.6;
fill: none;
transition: all 0.3s ease;
}
.link:hover {
stroke: #60a5fa;
stroke-opacity: 1;
stroke-width: 3px;
}
.link.dashed {
stroke-dasharray: 5, 5;
}
.link-label {
font-size: 9px;
fill: #94a3b8;
pointer-events: none;
user-select: none;
}
.node-group.selected .node-circle {
stroke-width: 4px !important;
filter: drop-shadow(0 0 20px currentColor);
}
/* Lane bands (top-down layers) */
.lane-band {
fill: rgba(2, 6, 23, 0.35);
stroke: #334155;
stroke-width: 1;
pointer-events: none;
}
.lane-label {
fill: #94a3b8;
font-size: 14px;
font-weight: 700;
text-anchor: middle;
pointer-events: none;
opacity: 0.5;
user-select: none;
}
/* Network Zones */
.network-zone {
stroke-width: 3;
stroke-dasharray: 10, 5;
rx: 20;
ry: 20;
pointer-events: none;
opacity: 0.8;
}
.zone-isp {
fill: rgba(14, 165, 233, 0.08);
stroke: #0ea5e9;
}
.zone-lan {
fill: rgba(16, 185, 129, 0.08);
stroke: #10b981;
}
.zone-apps {
fill: rgba(239, 68, 68, 0.08);
stroke: #ef4444;
}
.zone-tether {
fill: rgba(139, 92, 246, 0.08);
stroke: #8b5cf6;
}
.zone-label {
fill: #e2e8f0;
font-size: 13px;
font-weight: 700;
text-anchor: start;
pointer-events: none;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
}
.zone-sublabel {
fill: #94a3b8;
font-size: 10px;
font-weight: 500;
text-anchor: start;
pointer-events: none;
}
/* Type Group Zones (same-color environments) */
.type-zone {
stroke-width: 2;
stroke-dasharray: 6, 4;
rx: 14;
ry: 14;
pointer-events: none;
opacity: 0.65;
}
.type-zone-label {
fill: #cbd5e1;
font-size: 11px;
font-weight: 700;
text-anchor: start;
pointer-events: none;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
}
.type-core {
stroke: #f87171;
fill: rgba(239, 68, 68, 0.06);
}
.type-infra {
stroke: #34d399;
fill: rgba(16, 185, 129, 0.06);
}
.type-worker {
stroke: #a78bfa;
fill: rgba(139, 92, 246, 0.06);
}
.type-iot {
stroke: #fb923c;
fill: rgba(249, 115, 22, 0.06);
}
.type-client {
stroke: #9ca3af;
fill: rgba(107, 114, 128, 0.06);
}
/* Host/Environment grouped boxes */
.host-group {
stroke-width: 2.5;
rx: 18;
ry: 18;
pointer-events: none;
opacity: 0.9;
}
.host-athena {
fill: rgba(96, 165, 250, 0.08);
stroke: #60a5fa;
}
.host-infra {
fill: rgba(16, 185, 129, 0.08);
stroke: #34d399;
}
.host-mike {
fill: rgba(139, 92, 246, 0.08);
stroke: #8b5cf6;
}
.host-title {
fill: #e2e8f0;
font-size: 13px;
font-weight: 800;
text-anchor: start;
pointer-events: none;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
}
.host-subtitle {
fill: #94a3b8;
font-size: 11px;
font-weight: 600;
text-anchor: start;
pointer-events: none;
}
/* Traefik subpanel inside Athena */
.host-panel {
fill: rgba(96, 165, 250, 0.12);
stroke: #60a5fa;
stroke-dasharray: 6, 3;
rx: 10;
ry: 10;
pointer-events: none;
}
.host-panel-label {
fill: #cbd5e1;
font-size: 11px;
font-weight: 700;
text-anchor: start;
pointer-events: none;
}
/* Legend */
.legend {
position: fixed;
bottom: 20px;
left: 20px;
background: rgba(15, 23, 42, 0.9);
backdrop-filter: blur(10px);
border: 1px solid #475569;
border-radius: 12px;
padding: 15px;
display: flex;
gap: 15px;
z-index: 100;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: all 0.3s ease;
padding: 5px 10px;
border-radius: 6px;
}
.legend-item:hover {
background: rgba(56, 70, 100, 0.5);
}
.legend-color {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid;
}
.legend-item span {
font-size: 12px;
color: #cbd5e1;
}
</style>
<!-- Add the following inside the <head> of `public/d2.html` -->
<!-- (Place files at site root or adjust paths as needed) -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<meta name="theme-color" content="#0f172a">
<!-- Optional: convert SVG to PNG (example using ImageMagick) -->
<!-- convert `public/favicon.svg` -resize 32x32 `public/favicon-32x32.png` -->
</head>
<body>
<div class="container">
<div class="header">
<h1>🏠 App.model Network Topology</h1>
<div class="controls">
<button class="control-btn" onclick="resetView()">🎯 Reset</button>
<button class="control-btn" onclick="toggleAnimation()">✨ Animate</button>
<button class="control-btn" onclick="exportSVG()">💾 Export</button>
</div>
</div>
<div class="svg-container">
<div class="loading" id="loading">Loading D2 runtime...</div>
<div id="d2-output"></div>
<div class="zoom-controls">
<button class="zoom-btn" onclick="zoomIn()">+</button>
<button class="zoom-btn" onclick="zoomOut()"></button>
<button class="zoom-btn" onclick="fitToScreen()"></button>
</div>
<div class="legend">
<div class="legend-item" data-type="router">
<div class="legend-color" style="border-color: var(--router-stroke); background: var(--router-fill);"></div>
<span>Router</span>
</div>
<div class="legend-item" data-type="core">
<div class="legend-color" style="border-color: var(--core-stroke); background: var(--core-fill);"></div>
<span>Core Server</span>
</div>
<div class="legend-item" data-type="infra">
<div class="legend-color" style="border-color: var(--infra-stroke); background: var(--infra-fill);"></div>
<span>Infra/DNS</span>
</div>
<div class="legend-item" data-type="worker">
<div class="legend-color" style="border-color: var(--worker-stroke); background: var(--worker-fill);"></div>
<span>Worker/Edge</span>
</div>
<div class="legend-item" data-type="iot">
<div class="legend-color" style="border-color: var(--iot-stroke); background: var(--iot-fill);"></div>
<span>IoT Devices</span>
</div>
<div class="legend-item" data-type="client">
<div class="legend-color" style="border-color: var(--client-stroke); background: var(--client-fill);"></div>
<span>Clients</span>
</div>
</div>
</div>
</div>
<div class="tooltip" id="tooltip"></div>
<div class="overlay" id="overlay" onclick="closePanel()"></div>
<div class="detail-panel" id="detail-panel">
<div class="detail-header">
<div class="detail-title" id="detail-title">Node Details</div>
<button class="close-btn" onclick="closePanel()">×</button>
</div>
<div class="detail-content" id="detail-content"></div>
</div>
<!-- D3.js v7 - The gold standard for interactive visualizations -->
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<script>
// Check if D3 loaded
console.log('D3 version:', typeof d3 !== 'undefined' ? d3.version : 'NOT LOADED')
if (typeof d3 === 'undefined') {
document.getElementById('loading').innerHTML =
'<div style="color: #ef4444;">D3.js failed to load from CDN. Check internet connection.</div>'
}
// Network graph configuration
const nodeTypes = {
dns : { fill: '#0ea5e9', stroke: '#38bdf8', radius: 40 },
router: { fill: '#f59e0b', stroke: '#fbbf24', radius: 45 },
core : { fill: '#ef4444', stroke: '#f87171', radius: 35 },
infra : { fill: '#10b981', stroke: '#34d399', radius: 30 },
worker: { fill: '#8b5cf6', stroke: '#a78bfa', radius: 30 },
iot : { fill: '#f97316', stroke: '#fb923c', radius: 25 },
client: { fill: '#6b7280', stroke: '#9ca3af', radius: 28 }
}
// Network data for tooltips and details
const networkData = {
Internet: { name: 'Internet / Cloudflare DNS', ip: '5.132.33.195', desc: 'External DNS provider', type: 'internet' },
Router : { name: 'Zyxel VM8825-T50', ip: '192.168.1.1', hostname: 'hub.lan', desc: 'Main gateway router', type: 'router', services: ['NAT', 'DHCP', 'DNS Proxy'] },
Traefik : { name: 'Traefik', ip: '192.168.1.159', desc: 'Reverse Proxy', type: 'core', services: ['Load Balancer', 'SSL Termination'] },
Dokku : { name: 'Dokku PaaS', ip: '192.168.1.159', desc: 'Platform as a Service', type: 'core', services: ['Docker Management', 'App Deployment'] },
Gitea : { name: 'Gitea', ip: '192.168.1.159', hostname: 'git.appmodel.nl', desc: 'Self-hosted Git service', type: 'core' },
Auction : { name: 'Auction Stack', ip: '192.168.1.159', hostname: 'auction.appmodel.nl', desc: 'Auction application', type: 'core' },
MI50 : { name: 'MI50 / Ollama', ip: '192.168.1.159', hostname: 'ollama.lan', desc: 'AI/ML inference server', type: 'core' },
AdGuard : { name: 'AdGuard DNS', desc: 'Network-wide ad blocking', type: 'infra', services: ['DNS Filtering', 'Ad Blocking'] },
XU4 : { name: 'XU4 DNS', ip: '192.168.1.163', desc: 'Primary DNS (Odroid XU4)', type: 'infra dns' },
C2 : { name: 'C2 DNS', ip: '192.168.1.227', desc: 'Secondary DNS (Odroid C2)', type: 'infra dns' },
HA : { name: 'Home Assistant', ip: '192.168.1.193', desc: 'Home automation platform', type: 'infra' },
Atlas : { name: 'Atlas', ip: '192.168.1.100', desc: 'Worker node', type: 'worker', services: ['Docker', 'Portainer'] },
TV : { name: 'Kamer-TV', ip: '192.168.1.240', desc: 'Smart TV/Chromecast', type: 'iot' },
Hue : { name: 'Philips Hue', ip: '192.168.1.49', desc: 'Smart lighting bridge', type: 'iot' },
Eufy : { name: 'Eufy S380HB', ip: '192.168.1.59', desc: 'Security camera', type: 'iot' },
IoT : { name: 'IoT Devices', desc: 'Nest Mini, Roborock, ESP, Printer', type: 'iot', devices: ['Nest Mini', 'Roborock Vacuum', 'ESP Devices', 'HP Printer'] },
MIKE : { name: 'MIKE PC', ip: '192.168.1.100', desc: 'Workstation with tether gateway', type: 'client', interfaces: ['Ethernet', 'USB Tether'] },
Lotte : { name: 'Lotte', ip: '192.168.1.133', desc: 'Mobile device', type: 'client' },
Hermes : { name: 'Hermes', ip: '192.168.137.239', desc: 'Tether network node', type: 'worker' },
Plato : { name: 'Plato', ip: '192.168.137.239', hostname: 'llm.plato.lan', desc: 'LLM server', type: 'worker' }
}
let d2svg, simulation, zoom, g
let selectedNode = null
// Initialize D3 network graph
async function initD2() {
const loadingEl = document.getElementById('loading')
const container = document.getElementById('d2-output')
try {
console.log('Initializing D3 network...')
loadingEl.textContent = 'Building network topology...'
// Create graph data
const graphData = createGraphData()
console.log('Graph data created:', graphData.nodes.length, 'nodes,', graphData.links.length, 'links')
// Setup SVG - get dimensions from parent svg-container
const svgContainer = container.parentElement
const width = svgContainer.clientWidth
const height = svgContainer.clientHeight
console.log('Container dimensions:', width, 'x', height)
const svg = d3.select(container)
.append('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', [0, 0, width, height])
d2svg = svg.node()
// Add zoom behavior
zoom = d3.zoom()
.scaleExtent([0.3, 3])
.on('zoom', (event) => {
g.attr('transform', event.transform)
})
svg.call(zoom)
// Create main group for graph elements
g = svg.append('g')
// Lanes (swimlanes) to emphasize top-down flow
const laneConfig = initLanes(g, width, height)
// Network zones removed - using lane bands instead for cleaner visualization
// Host/environment groups (drawn behind links and nodes)
const hostGroups = g.append('g').attr('class', 'host-groups')
// Athena host box with Traefik subpanel
const athenaRect = hostGroups.append('rect')
.attr('class', 'host-group host-athena')
const athenaTitle = hostGroups.append('text')
.attr('class', 'host-title')
.text('Athena')
const athenaSubtitle = hostGroups.append('text')
.attr('class', 'host-subtitle')
.text('192.168.1.159')
const traefikPanel = hostGroups.append('rect')
.attr('class', 'host-panel')
const traefikPanelLabel = hostGroups.append('text')
.attr('class', 'host-panel-label')
.text('Traefik ingress')
// Infra DNS + AdGuard box
const infraRect = hostGroups.append('rect')
.attr('class', 'host-group host-infra')
const infraTitle = hostGroups.append('text')
.attr('class', 'host-title')
.text('Infra DNS + AdGuard')
const infraSubtitle = hostGroups.append('text')
.attr('class', 'host-subtitle')
.text('XU4 / C2 / AdGuard')
// MIKE-PC tether network box
const mikeRect = hostGroups.append('rect')
.attr('class', 'host-group host-mike')
const mikeTitle = hostGroups.append('text')
.attr('class', 'host-title')
.text('MIKE-PC Tether Network')
const mikeSubtitle = hostGroups.append('text')
.attr('class', 'host-subtitle')
.text('USB Bridge: 192.168.137.0/24')
// Ensure host groups sit above lane bands but behind links/nodes
hostGroups.raise()
// Create arrow markers for directed edges
svg.append('defs').selectAll('marker')
.data(['arrow', 'arrow-dashed'])
.join('marker')
.attr('id', d => d)
.attr('viewBox', '0 -5 10 10')
.attr('refX', 20)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', '#475569')
// Create links
const link = g.append('g')
.selectAll('path')
.data(graphData.links)
.join('path')
.attr('class', d => `link ${ d.dashed ? 'dashed' : '' }`)
.attr('stroke-width', 2)
.attr('marker-end', d => `url(#${ d.dashed ? 'arrow-dashed' : 'arrow' })`)
// Create nodes
const node = g.append('g')
.selectAll('g')
.data(graphData.nodes)
.join('g')
.attr('class', 'node-group')
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended))
// Draw meaningful shapes per node instead of circles
node.each(function(d) {
drawNodeShape(d3.select(this), d)
})
// Add icon/emoji to nodes (centered)
node.append('text')
.attr('class', 'node-icon')
.attr('text-anchor', 'middle')
.attr('dy', '.35em')
.attr('font-size', d => (nodeTypes[d.type]?.radius || 30) * 0.6)
.text(d => d.icon)
// Add labels below nodes
node.append('text')
.attr('class', 'node-label')
.attr('text-anchor', 'middle')
.attr('dy', d => (nodeTypes[d.type]?.radius || 30) + 16)
.attr('fill', '#e2e8f0')
.text(d => d.label)
// Add sublabels
node.append('text')
.attr('class', 'node-sublabel')
.attr('text-anchor', 'middle')
.attr('dy', d => (nodeTypes[d.type]?.radius || 30) + 28)
.attr('fill', '#94a3b8')
.text(d => d.sublabel || '')
// Node interactions
node.on('mouseover', function(event, d) {
const data = networkData[d.id]
if (data) {
showTooltip(event, data)
}
d3.select(this).raise()
})
.on('mouseout', function() {
hideTooltip()
})
.on('click', function(event, d) {
event.stopPropagation()
selectNode(d.id)
})
// Click on background to deselect
svg.on('click', () => {
if (selectedNode) {
d3.selectAll('.node-group').classed('selected', false)
selectedNode = null
closePanel()
}
})
// Create force simulation with better positioning
simulation = d3.forceSimulation(graphData.nodes)
.force('link', d3.forceLink(graphData.links)
.id(d => d.id)
.distance(d => d.distance || 160)
.strength(0.4))
.force('charge', d3.forceManyBody()
.strength(-1200)
.distanceMax(500))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide()
.radius(d => (nodeTypes[d.type]?.radius || 30) + 25)
.strength(0.9))
// Keep a slight horizontal centering to avoid drift
.force('x', d3.forceX(width / 2).strength(0.05))
// Stronger vertical force to keep nodes in their lanes
.force('laneY', d3.forceY(d => laneConfig.centerFor(d)).strength(0.25))
.alphaDecay(0.015)
.velocityDecay(0.4)
.on('tick', ticked)
function ticked() {
link.attr('d', d => {
const dx = d.target.x - d.source.x
const dy = d.target.y - d.source.y
const dr = Math.sqrt(dx * dx + dy * dy)
// Calculate offset for arrow
const targetRadius = nodeTypes[d.target.type]?.radius || 30
const offsetX = (dx / dr) * targetRadius
const offsetY = (dy / dr) * targetRadius
return `M${ d.source.x },${ d.source.y }L${ d.target.x - offsetX },${ d.target.y - offsetY }`
})
node.attr('transform', d => `translate(${ d.x },${ d.y })`)
// Update host group boxes on every tick
updateHostGroups()
}
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart()
d.fx = d.x
d.fy = d.y
}
// Helper: calculate bounding box for a group of nodes
function calculateBounds(nodes, padding = 20) {
const xs = nodes.map(n => n.x)
const ys = nodes.map(n => n.y)
const minX = Math.min(...xs) - padding
const maxX = Math.max(...xs) + padding
const minY = Math.min(...ys) - padding
const maxY = Math.max(...ys) + padding
return {
x : minX,
y : minY,
width : maxX - minX,
height: maxY - minY
}
}
// Helper: compute polygon points (e.g., hexagon) centered at 0,0
function polygonPoints(sides, radius) {
const points = []
for (let i = 0; i < sides; i++) {
const angle = (Math.PI * 2 * i) / sides - Math.PI / 2
const x = Math.cos(angle) * radius
const y = Math.sin(angle) * radius
points.push(`${ x },${ y }`)
}
return points.join(' ')
}
// Draw/update host environment boxes (Athena, Infra DNS, and MIKE-PC)
function updateHostGroups() {
// Athena contains Traefik + core services
const athenaNodes = graphData.nodes.filter(n =>
['Traefik', 'Dokku', 'Gitea', 'Auction', 'MI50'].includes(n.id)
)
if (athenaNodes.length > 0) {
const pad = 70
const b = calculateBounds(athenaNodes, pad)
athenaRect
.attr('x', b.x)
.attr('y', b.y)
.attr('width', b.width)
.attr('height', b.height)
athenaTitle
.attr('x', b.x + 12)
.attr('y', b.y + 20)
athenaSubtitle
.attr('x', b.x + 12)
.attr('y', b.y + 36)
// Traefik subpanel at top of Athena
const panelPad = 10
const panelHeight = 40
traefikPanel
.attr('x', b.x + panelPad)
.attr('y', b.y + 46)
.attr('width', Math.max(0, b.width - panelPad * 2))
.attr('height', panelHeight)
traefikPanelLabel
.attr('x', b.x + panelPad + 10)
.attr('y', b.y + 46 + 24)
}
// Infra DNS + AdGuard grouping
const infraNodes = graphData.nodes.filter(n =>
['XU4', 'C2', 'AdGuard'].includes(n.id)
)
if (infraNodes.length > 0) {
const pad = 60
const b = calculateBounds(infraNodes, pad)
infraRect
.attr('x', b.x)
.attr('y', b.y)
.attr('width', b.width)
.attr('height', b.height)
infraTitle
.attr('x', b.x + 12)
.attr('y', b.y + 20)
infraSubtitle
.attr('x', b.x + 12)
.attr('y', b.y + 36)
}
// MIKE-PC tether network grouping
const mikeNodes = graphData.nodes.filter(n =>
['MIKE', 'Hermes', 'Plato'].includes(n.id)
)
if (mikeNodes.length > 0) {
const pad = 60
const b = calculateBounds(mikeNodes, pad)
mikeRect
.attr('x', b.x)
.attr('y', b.y)
.attr('width', b.width)
.attr('height', b.height)
mikeTitle
.attr('x', b.x + 12)
.attr('y', b.y + 20)
mikeSubtitle
.attr('x', b.x + 12)
.attr('y', b.y + 36)
}
}
// Draw a shape for a node based on d.shape
function drawNodeShape(group, d) {
const r = (nodeTypes[d.type]?.radius || 30)
const fill = nodeTypes[d.type]?.fill || '#6b7280'
const stroke = nodeTypes[d.type]?.stroke || '#9ca3af'
switch (d.shape) {
case 'cloud': {
// Cloud using three ellipses
group.append('ellipse')
.attr('class', 'node-shape')
.attr('cx', 0).attr('cy', -r * 0.2)
.attr('rx', r * 0.9).attr('ry', r * 0.6)
.attr('fill', fill).attr('stroke', stroke).attr('stroke-width', 3)
group.append('ellipse')
.attr('class', 'node-shape')
.attr('cx', -r * 0.7).attr('cy', -r * 0.15)
.attr('rx', r * 0.5).attr('ry', r * 0.35)
.attr('fill', fill).attr('stroke', stroke).attr('stroke-width', 3)
group.append('ellipse')
.attr('class', 'node-shape')
.attr('cx', r * 0.7).attr('cy', -r * 0.1)
.attr('rx', r * 0.5).attr('ry', r * 0.35)
.attr('fill', fill).attr('stroke', stroke).attr('stroke-width', 3)
break
}
case 'hex': {
group.append('polygon')
.attr('class', 'node-shape')
.attr('points', polygonPoints(6, r))
.attr('fill', fill).attr('stroke', stroke).attr('stroke-width', 3)
break
}
case 'router': {
const w = r * 2.1, h = r * 1.2
group.append('rect')
.attr('class', 'node-shape')
.attr('x', -w / 2).attr('y', -h / 2)
.attr('width', w).attr('height', h)
.attr('rx', 14).attr('ry', 14)
.attr('fill', fill).attr('stroke', stroke).attr('stroke-width', 3)
// Simple antenna
group.append('line')
.attr('x1', 0).attr('y1', -h / 2)
.attr('x2', 0).attr('y2', -h / 2 - r * 0.6)
.attr('stroke', stroke).attr('stroke-width', 3)
break
}
case 'server': {
const w = r * 2.0, h = r * 1.6
group.append('rect')
.attr('class', 'node-shape')
.attr('x', -w / 2).attr('y', -h / 2)
.attr('width', w).attr('height', h)
.attr('rx', 10).attr('ry', 10)
.attr('fill', fill).attr('stroke', stroke).attr('stroke-width', 3)
// Status lights/lines
const y1 = -h / 4, y2 = 0, y3 = h / 4;
[y1, y2, y3].forEach((yy, i) => {
group.append('line')
.attr('x1', -w / 2 + 10).attr('y1', yy)
.attr('x2', w / 2 - 10).attr('y2', yy)
.attr('stroke', i === 0 ? '#93c5fd' : '#94a3b8')
.attr('stroke-width', 2).attr('opacity', 0.8)
})
break
}
case 'chip': {
const w = r * 2.0, h = r * 1.6
group.append('rect')
.attr('class', 'node-shape')
.attr('x', -w / 2).attr('y', -h / 2)
.attr('width', w).attr('height', h)
.attr('rx', 8).attr('ry', 8)
.attr('fill', fill).attr('stroke', stroke).attr('stroke-width', 3)
// Pins
const pinCount = 5, pinLen = 6
for (let i = 0; i < pinCount; i++) {
const t = ((i + 1) / (pinCount + 1))
const y = -h / 2 + t * h
// left pins
group.append('line').attr('x1', -w / 2).attr('y1', y).attr('x2', -w / 2 - pinLen).attr('y2', y).attr('stroke', stroke).attr('stroke-width', 2)
// right pins
group.append('line').attr('x1', w / 2).attr('y1', y).attr('x2', w / 2 + pinLen).attr('y2', y).attr('stroke', stroke).attr('stroke-width', 2)
}
break
}
case 'monitor': {
const w = r * 2.0, h = r * 1.3
group.append('rect')
.attr('class', 'node-shape')
.attr('x', -w / 2).attr('y', -h / 2)
.attr('width', w).attr('height', h)
.attr('rx', 6).attr('ry', 6)
.attr('fill', fill).attr('stroke', stroke).attr('stroke-width', 3)
group.append('rect')
.attr('x', -w * 0.15).attr('y', h / 2 - 4)
.attr('width', w * 0.3).attr('height', 8)
.attr('fill', stroke).attr('rx', 3).attr('ry', 3)
break
}
default: {
// Fallback circle
group.append('circle')
.attr('class', 'node-shape')
.attr('r', r)
.attr('fill', fill).attr('stroke', stroke).attr('stroke-width', 3)
}
}
// Extra overlay: red cross for AdGuard
if (d.id === 'AdGuard') {
const s = r * 0.9
group.append('line')
.attr('x1', -s / 2).attr('y1', -s / 2).attr('x2', s / 2).attr('y2', s / 2)
.attr('stroke', '#ef4444').attr('stroke-width', 4).attr('opacity', 0.9)
group.append('line')
.attr('x1', s / 2).attr('y1', -s / 2).attr('x2', -s / 2).attr('y2', s / 2)
.attr('stroke', '#ef4444').attr('stroke-width', 4).attr('opacity', 0.9)
}
}
function dragged(event, d) {
d.fx = event.x
d.fy = event.y
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0)
d.fx = null
d.fy = null
}
// Initialize lane bands and return mapping helpers
function initLanes(group, width, height) {
const lanes = [
{ id: 'internet', label: '☁️ Internet / ISP' },
{ id: 'gateway', label: '🛜 Gateway Layer' },
{ id: 'core', label: '⚡ Application Stack' },
{ id: 'infra', label: '🔧 DNS Infrastructure' },
{ id: 'services', label: '🏠 Services & Automation' },
{ id: 'clients', label: '👥 Clients & Devices' },
{ id: 'tether', label: '📱 Tether Network' }
]
const bandH = height / lanes.length
const lanesGroup = group.append('g').attr('class', 'lanes')
lanes.forEach((lane, i) => {
lanesGroup.append('rect')
.attr('class', 'lane-band')
.attr('x', 0)
.attr('y', i * bandH)
.attr('width', width)
.attr('height', bandH)
lanesGroup.append('text')
.attr('class', 'lane-label')
.attr('x', width / 2)
.attr('y', i * bandH + bandH / 2)
.attr('dy', '0.35em')
.text(lane.label)
})
// keep lanes behind everything
lanesGroup.lower()
const centers = {}
lanes.forEach((lane, i) => { centers[lane.id] = i * bandH + bandH / 2 })
function laneOf(node) {
if (node.id === 'Internet') return 'internet'
if (node.id === 'Router') return 'gateway'
if (node.id === 'Traefik') return 'gateway'
if (['Dokku', 'Gitea', 'Auction', 'MI50'].includes(node.id)) return 'core'
if (['AdGuard', 'XU4', 'C2'].includes(node.id)) return 'infra'
if (['HA', 'Atlas'].includes(node.id)) return 'services'
if (['MIKE', 'Hermes', 'Plato'].includes(node.id)) return 'tether'
return 'clients'
}
function centerFor(node) {
return centers[laneOf(node)] || (height / 2)
}
return { centers, laneOf, centerFor }
}
// Hide loading
loadingEl.style.display = 'none'
console.log('D3 network graph rendered successfully!')
// Add interactivity
addInteractivity()
} catch (error) {
console.error('D3 rendering error:', error)
console.error('Stack trace:', error.stack)
loadingEl.innerHTML = `<div style="color: #ef4444;">Error: ${ error.message }<br><small>Check console for details</small></div>`
}
}
// Create graph data structure from network data
function createGraphData() {
const nodes = [
{ id: 'Internet', label: 'Internet', sublabel: '5.132.33.195', icon: '☁️', type: 'dns', shape: 'cloud' },
{ id: 'Router', label: 'Zyxel Router', sublabel: '192.168.1.1', icon: '🛜', type: 'router', shape: 'router' },
// Application stack (Traefik with nginx-like hexagon)
{ id: 'Traefik', label: 'Traefik', sublabel: '192.168.1.159', icon: 'N', type: 'core', shape: 'hex' },
{ id: 'Dokku', label: 'Dokku', sublabel: '192.168.1.159', icon: '⚡', type: 'core', shape: 'server' },
{ id: 'Gitea', label: 'Gitea', sublabel: 'git.appmodel.nl', icon: '⚡', type: 'core', shape: 'server' },
{ id: 'Auction', label: 'Auction', sublabel: 'auction.appmodel.nl', icon: '⚡', type: 'core', shape: 'server' },
{ id: 'MI50', label: 'MI50/Ollama', sublabel: 'ollama.lan', icon: '🧠', type: 'core', shape: 'server' },
// Infra
{ id: 'AdGuard', label: 'AdGuard', sublabel: 'DNS Filter', icon: '🛡️', type: 'infra', shape: 'server' },
{ id: 'XU4', label: 'XU4 DNS', sublabel: '192.168.1.163', icon: '🔧', type: 'infra', shape: 'chip' },
{ id: 'C2', label: 'C2 DNS', sublabel: '192.168.1.227', icon: '🔧', type: 'infra', shape: 'chip' },
{ id: 'HA', label: 'Home Assistant', sublabel: '192.168.1.193', icon: '🏠', type: 'infra', shape: 'server' },
// Workers / devices
{ id: 'Atlas', label: 'Atlas', sublabel: '192.168.1.150', icon: '💻', type: 'worker', shape: 'chip' },
{ id: 'Hermes', label: 'Hermes', sublabel: '192.168.137.239', icon: '💻', type: 'worker', shape: 'chip' },
{ id: 'Plato', label: 'Plato', sublabel: 'llm.plato.lan', icon: '💻', type: 'worker', shape: 'chip' },
// IoT and clients
{ id: 'TV', label: 'Kamer-TV', sublabel: '192.168.1.240', icon: '📺', type: 'iot', shape: 'monitor' },
{ id: 'Hue', label: 'Philips Hue', sublabel: '192.168.1.49', icon: '💡', type: 'iot', shape: 'server' },
{ id: 'Eufy', label: 'Eufy S380HB', sublabel: '192.168.1.59', icon: '📹', type: 'iot', shape: 'server' },
{ id: 'IoT', label: 'IoT Devices', sublabel: 'Various', icon: '🔌', type: 'iot', shape: 'server' },
{ id: 'MIKE', label: 'MIKE PC', sublabel: '192.168.1.100', icon: '🖥️', type: 'client', shape: 'monitor' },
{ id: 'Lotte', label: 'Lotte', sublabel: '192.168.1.133', icon: '📱', type: 'client', shape: 'monitor' }
]
const links = [
// Internet to Gateway
{ source: 'Internet', target: 'Router', label: 'WAN', distance: 180 },
// Gateway layer: Router connects to Traefik only
{ source: 'Router', target: 'Traefik', distance: 120 },
// Router to DNS Infrastructure (AdGuard filters first)
{ source: 'Router', target: 'AdGuard', distance: 160 },
{ source: 'AdGuard', target: 'XU4', distance: 100 },
{ source: 'AdGuard', target: 'C2', distance: 100 },
// Traefik proxies to all core services
{ source: 'Traefik', target: 'Dokku', distance: 100 },
{ source: 'Traefik', target: 'Gitea', distance: 100 },
{ source: 'Traefik', target: 'Auction', distance: 100 },
{ source: 'Traefik', target: 'MI50', distance: 100 },
// Router to Services & Automation
{ source: 'Router', target: 'HA', distance: 160 },
{ source: 'Router', target: 'Atlas', distance: 160 },
// Router to Clients & Devices
{ source: 'Router', target: 'TV', distance: 180 },
{ source: 'Router', target: 'Hue', distance: 180 },
{ source: 'Router', target: 'Eufy', distance: 180 },
{ source: 'Router', target: 'IoT', distance: 180 },
{ source: 'Router', target: 'Lotte', distance: 180 },
// Tether network (USB bridge: Router -> MIKE -> Hermes/Plato)
{ source: 'Router', target: 'MIKE', distance: 160 },
{ source: 'MIKE', target: 'Hermes', distance: 120 },
{ source: 'MIKE', target: 'Plato', distance: 120 },
// DNS queries (dashed = logical dependency, not direct routing)
{ source: 'Traefik', target: 'XU4', dashed: true, distance: 140 },
{ source: 'Atlas', target: 'XU4', dashed: true, distance: 140 },
{ source: 'HA', target: 'C2', dashed: true, distance: 140 },
{ source: 'Hermes', target: 'XU4', dashed: true, distance: 160 },
{ source: 'Plato', target: 'C2', dashed: true, distance: 160 }
]
return { nodes, links }
}
function addInteractivity() {
// Legend toggle
document.querySelectorAll('.legend-item').forEach(item => {
item.addEventListener('click', () => {
const type = item.dataset.type
toggleNodeType(type)
})
})
}
function selectNode(nodeId) {
if (!d2svg) return
// Highlight selected node
d3.selectAll('.node-group').classed('selected', false)
d3.selectAll('.node-group')
.filter(d => d.id === nodeId)
.classed('selected', true)
selectedNode = nodeId
showDetailPanel(nodeId)
}
function showTooltip(event, data) {
const tooltip = document.getElementById('tooltip')
const services = data.services ? data.services.join(', ') : ''
const devices = data.devices ? data.devices.join(', ') : ''
tooltip.innerHTML = `
<strong>${ data.name }</strong><br>
${ data.ip ? `IP: ${ data.ip }<br>` : '' }
${ data.hostname ? `Host: ${ data.hostname }<br>` : '' }
${ data.desc ? `${ data.desc }<br>` : '' }
${ services ? `<small>Services: ${ services }</small><br>` : '' }
${ devices ? `<small>Devices: ${ devices }</small>` : '' }
`
tooltip.style.left = event.clientX + 15 + 'px'
tooltip.style.top = event.clientY - 10 + 'px'
tooltip.classList.add('show')
}
function hideTooltip() {
document.getElementById('tooltip').classList.remove('show')
}
function highlightConnections(nodeId) {
if (!d2svg) return
d2svg.querySelectorAll('.connection').forEach(conn => {
conn.style.stroke = '#60a5fa'
conn.style.strokeWidth = '3'
})
}
function unhighlightConnections() {
if (!d2svg) return
d2svg.querySelectorAll('.connection').forEach(conn => {
conn.style.stroke = ''
conn.style.strokeWidth = ''
})
}
function showDetailPanel(nodeId) {
const data = networkData[nodeId]
if (!data) return
const panel = document.getElementById('detail-panel')
const overlay = document.getElementById('overlay')
const title = document.getElementById('detail-title')
const content = document.getElementById('detail-content')
title.textContent = data.name
let html = `
<div class="detail-section">
<p>${ data.desc || 'No description available' }</p>
</div>
`
if (data.ip || data.hostname) {
html += `
<div class="detail-section">
<h3>📡 Network</h3>
<div class="detail-grid">
${ data.ip ? `<div class="detail-item"><strong>IP:</strong><br><code class="tag">${ data.ip }</code></div>` : '' }
${ data.hostname ? `<div class="detail-item"><strong>Hostname:</strong><br><code class="tag">${ data.hostname }</code></div>` : '' }
${ data.type ? `<div class="detail-item"><strong>Type:</strong><br><span class="tag">${ data.type }</span></div>` : '' }
</div>
</div>
`
}
if (data.services && data.services.length > 0) {
html += `
<div class="detail-section">
<h3>🚀 Services</h3>
${ data.services.map(s => `<span class="tag">${ s }</span>`).join('') }
</div>
`
}
if (data.interfaces && data.interfaces.length > 0) {
html += `
<div class="detail-section">
<h3>🔌 Interfaces</h3>
${ data.interfaces.map(i => `<span class="tag">${ i }</span>`).join('') }
</div>
`
}
if (data.devices && data.devices.length > 0) {
html += `
<div class="detail-section">
<h3>📱 Sub-Devices</h3>
${ data.devices.map(d => `<span class="tag">${ d }</span>`).join('') }
</div>
`
}
const related = getRelatedNodes(nodeId)
if (related.length > 0) {
html += `
<div class="detail-section">
<h3>🔗 Connections</h3>
<div class="detail-grid">
`
related.forEach(rel => {
html += `
<div class="detail-item">
<strong>→ ${ rel.name }</strong><br>
<small>${ rel.label || 'Connected' }</small>
</div>
`
})
html += `
</div>
</div>
`
}
content.innerHTML = html
overlay.classList.add('show')
panel.classList.add('show')
}
function getRelatedNodes(nodeId) {
const relationships = {
Router : ['Traefik', 'XU4', 'Atlas', 'TV', 'Hue', 'Eufy', 'IoT', 'MIKE', 'Lotte', 'AdGuard', 'C2', 'HA'],
Traefik: ['Gitea', 'Dokku', 'Auction'],
MIKE : ['Hermes', 'Plato'],
XU4 : ['AdGuard'],
C2 : ['AdGuard'],
Atlas : ['AdGuard'],
HA : ['AdGuard'],
Hermes : ['AdGuard'],
Plato : ['AdGuard']
}
const related = relationships[nodeId] || []
return related.map(id => ({ name: networkData[id]?.name || id, label: 'routes' }))
}
function closePanel() {
document.getElementById('overlay').classList.remove('show')
document.getElementById('detail-panel').classList.remove('show')
if (d2svg) {
d2svg.querySelectorAll('.node').forEach(n => n.classList.remove('selected'))
}
}
function toggleNodeType(type) {
if (!d2svg) return
d3.selectAll('.node-group')
.filter(d => d.type === type)
.transition()
.duration(300)
.style('opacity', function() {
const current = d3.select(this).style('opacity')
return current === '0' ? '1' : '0'
})
.style('pointer-events', function() {
const current = d3.select(this).style('opacity')
return current === '0' ? 'all' : 'none'
})
}
function resetView() {
if (!d2svg || !zoom) return
d3.select(d2svg)
.transition()
.duration(750)
.call(zoom.transform, d3.zoomIdentity)
closePanel()
if (simulation) simulation.alpha(0.3).restart()
}
function zoomIn() {
if (!d2svg || !zoom) return
d3.select(d2svg)
.transition()
.duration(300)
.call(zoom.scaleBy, 1.3)
}
function zoomOut() {
if (!d2svg || !zoom) return
d3.select(d2svg)
.transition()
.duration(300)
.call(zoom.scaleBy, 0.7)
}
function fitToScreen() {
resetView()
}
function toggleAnimation() {
if (!simulation) return
// Reheat the simulation for animated rearrangement
simulation.alpha(0.5).restart()
}
function exportSVG() {
if (!d2svg) return
const svgData = new XMLSerializer().serializeToString(d2svg)
const blob = new Blob([svgData], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'network-topology.svg'
a.click()
URL.revokeObjectURL(url)
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initD2)
} else {
initD2()
}
</script>
</body>
</html>