This commit is contained in:
Tour
2025-12-08 21:19:34 +01:00
parent 730359e67a
commit 3382c5e1cd
7 changed files with 3409 additions and 2 deletions

993
public/d2.html Normal file
View File

@@ -0,0 +1,993 @@
<!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);
}
.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);
}
/* Network Zones */
.network-zone {
fill: rgba(30, 41, 59, 0.3);
stroke: #475569;
stroke-width: 2;
stroke-dasharray: 10,5;
rx: 20;
ry: 20;
pointer-events: none;
}
.zone-label {
fill: #94a3b8;
font-size: 14px;
font-weight: 600;
text-anchor: middle;
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');
// Add network zone backgrounds (will be positioned after simulation)
const zonesGroup = g.append('g').attr('class', 'zones');
const lanZone = zonesGroup.append('rect')
.attr('class', 'network-zone lan-zone')
.attr('fill', 'rgba(16, 185, 129, 0.1)');
const tetherZone = zonesGroup.append('rect')
.attr('class', 'network-zone tether-zone')
.attr('fill', 'rgba(139, 92, 246, 0.1)');
const lanLabel = zonesGroup.append('text')
.attr('class', 'zone-label')
.text('LAN 192.168.1.0/24');
const tetherLabel = zonesGroup.append('text')
.attr('class', 'zone-label')
.text('Tether 192.168.137.0/24');
// 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));
// Add circles to nodes
node.append('circle')
.attr('class', 'node-circle')
.attr('r', d => nodeTypes[d.type]?.radius || 30)
.attr('fill', d => nodeTypes[d.type]?.fill || '#6b7280')
.attr('stroke', d => nodeTypes[d.type]?.stroke || '#9ca3af')
.attr('stroke-width', 3);
// Add icon/emoji to nodes
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
simulation = d3.forceSimulation(graphData.nodes)
.force('link', d3.forceLink(graphData.links)
.id(d => d.id)
.distance(d => d.distance || 150))
.force('charge', d3.forceManyBody()
.strength(-800))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide()
.radius(d => (nodeTypes[d.type]?.radius || 30) + 10))
.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})`);
}
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
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;
}
// 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' },
{ id: 'Router', label: 'Zyxel Router', sublabel: '192.168.1.1', icon: '🛜', type: 'router' },
{ id: 'Traefik', label: 'Traefik', sublabel: '192.168.1.159', icon: '⚡', type: 'core' },
{ id: 'Dokku', label: 'Dokku', sublabel: '192.168.1.159', icon: '⚡', type: 'core' },
{ id: 'Gitea', label: 'Gitea', sublabel: 'git.appmodel.nl', icon: '⚡', type: 'core' },
{ id: 'Auction', label: 'Auction', sublabel: 'auction.appmodel.nl', icon: '⚡', type: 'core' },
{ id: 'MI50', label: 'MI50/Ollama', sublabel: 'ollama.lan', icon: '⚡', type: 'core' },
{ id: 'AdGuard', label: 'AdGuard', sublabel: 'DNS Filter', icon: '🔧', type: 'infra' },
{ id: 'XU4', label: 'XU4 DNS', sublabel: '192.168.1.163', icon: '🔧', type: 'infra' },
{ id: 'C2', label: 'C2 DNS', sublabel: '192.168.1.227', icon: '🔧', type: 'infra' },
{ id: 'HA', label: 'Home Assistant', sublabel: '192.168.1.193', icon: '🔧', type: 'infra' },
{ id: 'Atlas', label: 'Atlas', sublabel: '192.168.1.100', icon: '💻', type: 'worker' },
{ id: 'TV', label: 'Kamer-TV', sublabel: '192.168.1.240', icon: '📺', type: 'iot' },
{ id: 'Hue', label: 'Philips Hue', sublabel: '192.168.1.49', icon: '💡', type: 'iot' },
{ id: 'Eufy', label: 'Eufy S380HB', sublabel: '192.168.1.59', icon: '📹', type: 'iot' },
{ id: 'IoT', label: 'IoT Devices', sublabel: 'Various', icon: '🔌', type: 'iot' },
{ id: 'MIKE', label: 'MIKE PC', sublabel: '192.168.1.100', icon: '💻', type: 'client' },
{ id: 'Lotte', label: 'Lotte', sublabel: '192.168.1.133', icon: '📱', type: 'client' },
{ id: 'Hermes', label: 'Hermes', sublabel: '192.168.137.239', icon: '💻', type: 'worker' },
{ id: 'Plato', label: 'Plato', sublabel: 'llm.plato.lan', icon: '💻', type: 'worker' }
];
const links = [
// Main connections
{ source: 'Internet', target: 'Router', label: 'DNS', distance: 200 },
{ source: 'Router', target: 'Traefik', distance: 120 },
{ source: 'Router', target: 'Dokku', distance: 120 },
{ source: 'Router', target: 'Gitea', distance: 120 },
{ source: 'Router', target: 'Auction', distance: 120 },
{ source: 'Router', target: 'MI50', distance: 120 },
{ 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
{ source: 'Traefik', target: 'Gitea', distance: 80 },
{ source: 'Traefik', target: 'Dokku', distance: 80 },
{ source: 'Traefik', target: 'Auction', distance: 80 },
// Tether connections
{ source: 'MIKE', target: 'Hermes', distance: 100 },
{ source: 'MIKE', target: 'Plato', distance: 100 },
// DNS filtering (dashed)
{ source: 'XU4', target: 'AdGuard', dashed: true, distance: 100 },
{ source: 'C2', target: 'AdGuard', dashed: true, distance: 100 },
{ source: 'Atlas', target: 'AdGuard', dashed: true, distance: 120 },
{ source: 'HA', target: 'AdGuard', dashed: true, distance: 120 },
{ source: 'Hermes', target: 'AdGuard', dashed: true, distance: 150 },
{ source: 'Plato', target: 'AdGuard', 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>