1465 lines
61 KiB
HTML
1465 lines
61 KiB
HTML
<!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> |