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

993 lines
39 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);
}
.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>