Files
nex/public/d2.html
2025-12-08 22:08:21 +01:00

1465 lines
61 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>Interactive Network Topology - D2</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: 12px;
font-weight: 700;
text-anchor: start;
pointer-events: none;
opacity: 0.7;
}
/* 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-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>
</head>
<body>
<div class="container">
<div class="header">
<h1>🏠 Interactive 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);
// Add network zone backgrounds (will be positioned after simulation)
const zonesGroup = g.append('g').attr('class', 'zones');
// ISP/Internet Zone
const ispZone = zonesGroup.append('rect')
.attr('class', 'network-zone zone-isp');
const ispLabel = zonesGroup.append('text')
.attr('class', 'zone-label')
.text('☁️ ISP / Internet');
const ispSublabel = zonesGroup.append('text')
.attr('class', 'zone-sublabel')
.text('External Services');
// LAN Zone
const lanZone = zonesGroup.append('rect')
.attr('class', 'network-zone zone-lan');
const lanLabel = zonesGroup.append('text')
.attr('class', 'zone-label')
.text('🏠 Home LAN');
const lanSublabel = zonesGroup.append('text')
.attr('class', 'zone-sublabel')
.text('192.168.1.0/24');
// Applications Zone (inside LAN)
const appsZone = zonesGroup.append('rect')
.attr('class', 'network-zone zone-apps');
const appsLabel = zonesGroup.append('text')
.attr('class', 'zone-label')
.text('⚡ Application Stack');
const appsSublabel = zonesGroup.append('text')
.attr('class', 'zone-sublabel')
.text('Traefik + Services (192.168.1.159)');
// Tether Zone
const tetherZone = zonesGroup.append('rect')
.attr('class', 'network-zone zone-tether');
const tetherLabel = zonesGroup.append('text')
.attr('class', 'zone-label')
.text('📱 Tether Network');
const tetherSublabel = zonesGroup.append('text')
.attr('class', 'zone-sublabel')
.text('192.168.137.0/24 (USB Bridge)');
// Type group zones (same-colored environments)
const typeGroups = [
{ type: 'core', label: 'Core Services' },
{ type: 'infra', label: 'Infrastructure' },
{ type: 'worker', label: 'Workers' },
{ type: 'iot', label: 'IoT Devices' },
{ type: 'client', label: 'Clients' }
];
const typeZones = typeGroups.map(grp => ({
...grp,
rect: zonesGroup.append('rect')
.attr('class', `type-zone type-${grp.type}`)
.style('display', 'none'),
labelEl: zonesGroup.append('text')
.attr('class', 'type-zone-label')
.text(grp.label)
.style('display', 'none')
}));
// 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');
// 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 || 150)
.strength(0.55))
.force('charge', d3.forceManyBody()
.strength(-900)
.distanceMax(450))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide()
.radius(d => (nodeTypes[d.type]?.radius || 30) + 16)
.strength(0.8))
// Keep a slight horizontal centering to avoid drift
.force('x', d3.forceX(width / 2).strength(0.04))
// Stronger vertical force to keep nodes in their lanes
.force('laneY', d3.forceY(d => laneConfig.centerFor(d)).strength(0.18))
.alphaDecay(0.02)
.velocityDecay(0.42)
.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 and Infra DNS)
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);
}
}
// 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: '☁️ ISP / Internet' },
{ id: 'gateway', label: '🛜 Gateway (Router/Traefik ingress)' },
{ id: 'core', label: '⚡ Core Services (behind Traefik)' },
{ id: 'infra', label: '🔧 Infrastructure DNS (XU4/C2/HA)' },
{ id: 'clients', label: '👥 Clients & IoT' },
{ 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', 12)
.attr('y', i * bandH + 18)
.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' || node.id === 'Traefik') return 'gateway';
if (['Traefik', 'Dokku', 'Gitea', 'Auction', 'MI50'].includes(node.id)) return 'core';
if (['XU4', 'C2', 'HA'].includes(node.id)) return 'infra';
if (['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.100', 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 = [
// Main connections
{ source: 'Internet', target: 'Router', label: 'DNS', distance: 200 },
{ source: 'Router', target: 'Traefik', distance: 120 },
{ source: 'Router', target: 'Dokku', distance: 120 },
// Ingress for core services flows via Traefik, not directly
// (Router direct links to core services removed)
{ source: 'Router', target: 'AdGuard', distance: 140 },
{ source: 'Router', target: 'XU4', distance: 140 },
{ source: 'Router', target: 'C2', distance: 140 },
{ source: 'Router', target: 'HA', distance: 140 },
{ source: 'Router', target: 'Atlas', distance: 140 },
{ source: 'Router', target: 'TV', distance: 160 },
{ source: 'Router', target: 'Hue', distance: 160 },
{ source: 'Router', target: 'Eufy', distance: 160 },
{ source: 'Router', target: 'IoT', distance: 160 },
{ source: 'Router', target: 'MIKE', distance: 140 },
{ source: 'Router', target: 'Lotte', distance: 160 },
// Proxy connections (Core services are behind Traefik)
{ source: 'Traefik', target: 'Gitea', distance: 80 },
{ source: 'Traefik', target: 'Dokku', distance: 80 },
{ source: 'Traefik', target: 'Auction', distance: 80 },
{ source: 'Traefik', target: 'MI50', distance: 100 },
// Tether connections
{ source: 'MIKE', target: 'Hermes', distance: 100 },
{ source: 'MIKE', target: 'Plato', distance: 100 },
// Core uses Infra DNS (dashed back-edges to DNS servers)
{ source: 'Traefik', target: 'XU4', dashed: true, distance: 120 },
{ source: 'Traefik', target: 'C2', dashed: true, distance: 120 },
// Clients and services use DNS (representative dashed edges)
{ source: 'Atlas', target: 'XU4', dashed: true, distance: 120 },
{ source: 'HA', target: 'C2', dashed: true, distance: 120 },
{ source: 'Hermes', target: 'XU4', dashed: true, distance: 150 },
{ source: 'Plato', target: 'C2', dashed: true, distance: 150 }
];
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>