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