fgdf
This commit is contained in:
@@ -8,5 +8,5 @@ COPY public/ ./
|
||||
# If the site references additional resources, copy them too
|
||||
COPY resources/ ./resources
|
||||
|
||||
# Optional: provide your own nginx.conf for SPA routing
|
||||
# COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
# Provide custom nginx.conf for clean URLs
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
13
README.md
13
README.md
@@ -62,7 +62,20 @@ python main.py
|
||||
Output files will be generated in the current directory as PNG images.
|
||||
|
||||
## Available Diagrams
|
||||
# Network Topology Viewer
|
||||
|
||||
A modern, interactive network topology visualization tool built with D2 diagrams.
|
||||
|
||||
## Features
|
||||
|
||||
- Interactive network diagram with pan/zoom
|
||||
- Real-time node details and tooltips
|
||||
- Clean, modern UI with dark theme
|
||||
- Export capabilities
|
||||
|
||||
## Running with Docker
|
||||
|
||||
Build and run:
|
||||
- `lan_architecture.py` - Home Lab / Auction Stack Architecture diagram
|
||||
- `main.py` - Network architecture diagram
|
||||
|
||||
|
||||
22
nginx.conf
Normal file
22
nginx.conf
Normal file
@@ -0,0 +1,22 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Enable clean URLs without .html extension
|
||||
location / {
|
||||
# Try the exact URI, then with .html, then as directory with index.html, then 404
|
||||
try_files $uri $uri.html $uri/ =404;
|
||||
}
|
||||
|
||||
# Optional: Redirect .html URLs to clean URLs
|
||||
if ($request_uri ~ ^/(.*)\.html(\?|$)) {
|
||||
return 301 /$1$2;
|
||||
}
|
||||
|
||||
# Gzip compression for better performance
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
}
|
||||
993
public/d2.html
Normal file
993
public/d2.html
Normal file
@@ -0,0 +1,993 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Interactive Network Topology - D2</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
color: #e2e8f0; overflow: hidden;
|
||||
}
|
||||
.container { width: 100vw; height: 100vh; display: flex; flex-direction: column; }
|
||||
.header {
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 20px 30px;
|
||||
border-bottom: 1px solid #334155;
|
||||
z-index: 10; display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 1.8em;
|
||||
background: linear-gradient(90deg, #60a5fa 0%, #818cf8 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.controls { display: flex; gap: 10px; }
|
||||
.control-btn {
|
||||
background: rgba(30, 41, 59, 0.7);
|
||||
border: 1px solid #475569;
|
||||
color: #cbd5e1;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
font-size: 14px;
|
||||
}
|
||||
.control-btn:hover {
|
||||
background: rgba(56, 70, 100, 0.8);
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
.svg-container {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 0; /* Important for flex children */
|
||||
}
|
||||
#d2-output {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.svg-container svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
}
|
||||
.svg-container svg:active { cursor: grabbing; }
|
||||
|
||||
/* D2 styling */
|
||||
:root {
|
||||
--router-fill: #f59e0b; --router-stroke: #fbbf24;
|
||||
--core-fill: #ef4444; --core-stroke: #f87171;
|
||||
--infra-fill: #10b981; --infra-stroke: #34d399;
|
||||
--worker-fill: #8b5cf6; --worker-stroke: #a78bfa;
|
||||
--iot-fill: #f97316; --iot-stroke: #fb923c;
|
||||
--client-fill: #6b7280; --client-stroke: #9ca3af;
|
||||
--dns-fill: #0ea5e9; --dns-stroke: #38bdf8;
|
||||
}
|
||||
.d2 .router .shape { fill: var(--router-fill) !important; stroke: var(--router-stroke) !important; }
|
||||
.d2 .core .shape { fill: var(--core-fill) !important; stroke: var(--core-stroke) !important; }
|
||||
.d2 .infra .shape { fill: var(--infra-fill) !important; stroke: var(--infra-stroke) !important; }
|
||||
.d2 .worker .shape { fill: var(--worker-fill) !important; stroke: var(--worker-stroke) !important; }
|
||||
.d2 .iot .shape { fill: var(--iot-fill) !important; stroke: var(--iot-stroke) !important; }
|
||||
.d2 .client .shape { fill: var(--client-fill) !important; stroke: var(--client-stroke) !important; }
|
||||
.d2 .dns .shape { fill: var(--dns-fill) !important; stroke: var(--dns-stroke) !important; }
|
||||
.d2 .node:hover .shape {
|
||||
filter: drop-shadow(0 0 10px currentColor);
|
||||
cursor: pointer;
|
||||
}
|
||||
.d2 .connection:hover {
|
||||
stroke: #60a5fa !important;
|
||||
stroke-width: 3 !important;
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
color: #e2e8f0;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
border: 1px solid #475569;
|
||||
backdrop-filter: blur(10px);
|
||||
max-width: 300px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.tooltip.show { opacity: 1; }
|
||||
|
||||
/* Detail Panel */
|
||||
.detail-panel {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0.95);
|
||||
background: rgba(15, 23, 42, 0.98);
|
||||
border: 1px solid #475569;
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
min-width: 450px;
|
||||
max-width: 650px;
|
||||
max-height: 80vh;
|
||||
z-index: 2000;
|
||||
opacity: 0;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.detail-panel.show {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
.detail-title {
|
||||
font-size: 1.5em;
|
||||
color: #60a5fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s ease;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.close-btn:hover { color: #e2e8f0; }
|
||||
.detail-content { line-height: 1.6; }
|
||||
.detail-section { margin-bottom: 24px; }
|
||||
.detail-section h3 {
|
||||
color: #818cf8;
|
||||
margin-bottom: 12px;
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background: rgba(96, 165, 250, 0.15);
|
||||
color: #60a5fa;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
margin: 2px;
|
||||
border: 1px solid rgba(96, 165, 250, 0.2);
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.detail-item {
|
||||
background: rgba(30, 41, 59, 0.5);
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
.detail-item strong {
|
||||
color: #94a3b8;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.detail-item code {
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 1500;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.overlay.show {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
.loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 1.2em;
|
||||
color: #60a5fa;
|
||||
}
|
||||
.zoom-controls {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
z-index: 100;
|
||||
}
|
||||
.zoom-btn {
|
||||
background: rgba(30, 41, 59, 0.7);
|
||||
border: 1px solid #475569;
|
||||
color: #cbd5e1;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.zoom-btn:hover {
|
||||
background: rgba(56, 70, 100, 0.8);
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
/* D3 Network Styles */
|
||||
#d2-output svg {
|
||||
background: transparent;
|
||||
}
|
||||
.node-group {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.node-circle {
|
||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.node-group:hover .node-circle {
|
||||
filter: drop-shadow(0 4px 12px rgba(96, 165, 250, 0.6));
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.node-label {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
|
||||
}
|
||||
.node-sublabel {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
font-size: 9px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.link {
|
||||
stroke: #475569;
|
||||
stroke-opacity: 0.6;
|
||||
fill: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.link:hover {
|
||||
stroke: #60a5fa;
|
||||
stroke-opacity: 1;
|
||||
stroke-width: 3px;
|
||||
}
|
||||
.link.dashed {
|
||||
stroke-dasharray: 5,5;
|
||||
}
|
||||
.link-label {
|
||||
font-size: 9px;
|
||||
fill: #94a3b8;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
.node-group.selected .node-circle {
|
||||
stroke-width: 4px !important;
|
||||
filter: drop-shadow(0 0 20px currentColor);
|
||||
}
|
||||
|
||||
/* Network Zones */
|
||||
.network-zone {
|
||||
fill: rgba(30, 41, 59, 0.3);
|
||||
stroke: #475569;
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 10,5;
|
||||
rx: 20;
|
||||
ry: 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
.zone-label {
|
||||
fill: #94a3b8;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-anchor: middle;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Legend */
|
||||
.legend {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid #475569;
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
z-index: 100;
|
||||
}
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.legend-item:hover {
|
||||
background: rgba(56, 70, 100, 0.5);
|
||||
}
|
||||
.legend-color {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid;
|
||||
}
|
||||
.legend-item span {
|
||||
font-size: 12px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🏠 Interactive Network Topology</h1>
|
||||
<div class="controls">
|
||||
<button class="control-btn" onclick="resetView()">🎯 Reset</button>
|
||||
<button class="control-btn" onclick="toggleAnimation()">✨ Animate</button>
|
||||
<button class="control-btn" onclick="exportSVG()">💾 Export</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="svg-container">
|
||||
<div class="loading" id="loading">Loading D2 runtime...</div>
|
||||
<div id="d2-output"></div>
|
||||
|
||||
<div class="zoom-controls">
|
||||
<button class="zoom-btn" onclick="zoomIn()">+</button>
|
||||
<button class="zoom-btn" onclick="zoomOut()">−</button>
|
||||
<button class="zoom-btn" onclick="fitToScreen()">⊡</button>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-item" data-type="router">
|
||||
<div class="legend-color" style="border-color: var(--router-stroke); background: var(--router-fill);"></div>
|
||||
<span>Router</span>
|
||||
</div>
|
||||
<div class="legend-item" data-type="core">
|
||||
<div class="legend-color" style="border-color: var(--core-stroke); background: var(--core-fill);"></div>
|
||||
<span>Core Server</span>
|
||||
</div>
|
||||
<div class="legend-item" data-type="infra">
|
||||
<div class="legend-color" style="border-color: var(--infra-stroke); background: var(--infra-fill);"></div>
|
||||
<span>Infra/DNS</span>
|
||||
</div>
|
||||
<div class="legend-item" data-type="worker">
|
||||
<div class="legend-color" style="border-color: var(--worker-stroke); background: var(--worker-fill);"></div>
|
||||
<span>Worker/Edge</span>
|
||||
</div>
|
||||
<div class="legend-item" data-type="iot">
|
||||
<div class="legend-color" style="border-color: var(--iot-stroke); background: var(--iot-fill);"></div>
|
||||
<span>IoT Devices</span>
|
||||
</div>
|
||||
<div class="legend-item" data-type="client">
|
||||
<div class="legend-color" style="border-color: var(--client-stroke); background: var(--client-fill);"></div>
|
||||
<span>Clients</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tooltip" id="tooltip"></div>
|
||||
<div class="overlay" id="overlay" onclick="closePanel()"></div>
|
||||
<div class="detail-panel" id="detail-panel">
|
||||
<div class="detail-header">
|
||||
<div class="detail-title" id="detail-title">Node Details</div>
|
||||
<button class="close-btn" onclick="closePanel()">×</button>
|
||||
</div>
|
||||
<div class="detail-content" id="detail-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- D3.js v7 - The gold standard for interactive visualizations -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
|
||||
|
||||
<script>
|
||||
// Check if D3 loaded
|
||||
console.log('D3 version:', typeof d3 !== 'undefined' ? d3.version : 'NOT LOADED');
|
||||
|
||||
if (typeof d3 === 'undefined') {
|
||||
document.getElementById('loading').innerHTML =
|
||||
'<div style="color: #ef4444;">D3.js failed to load from CDN. Check internet connection.</div>';
|
||||
}
|
||||
|
||||
// Network graph configuration
|
||||
const nodeTypes = {
|
||||
dns: { fill: '#0ea5e9', stroke: '#38bdf8', radius: 40 },
|
||||
router: { fill: '#f59e0b', stroke: '#fbbf24', radius: 45 },
|
||||
core: { fill: '#ef4444', stroke: '#f87171', radius: 35 },
|
||||
infra: { fill: '#10b981', stroke: '#34d399', radius: 30 },
|
||||
worker: { fill: '#8b5cf6', stroke: '#a78bfa', radius: 30 },
|
||||
iot: { fill: '#f97316', stroke: '#fb923c', radius: 25 },
|
||||
client: { fill: '#6b7280', stroke: '#9ca3af', radius: 28 }
|
||||
};
|
||||
|
||||
// Network data for tooltips and details
|
||||
const networkData = {
|
||||
Internet: { name: "Internet / Cloudflare DNS", ip: "5.132.33.195", desc: "External DNS provider", type: "internet" },
|
||||
Router: { name: "Zyxel VM8825-T50", ip: "192.168.1.1", hostname: "hub.lan", desc: "Main gateway router", type: "router", services: ["NAT", "DHCP", "DNS Proxy"] },
|
||||
Traefik: { name: "Traefik", ip: "192.168.1.159", desc: "Reverse Proxy", type: "core", services: ["Load Balancer", "SSL Termination"] },
|
||||
Dokku: { name: "Dokku PaaS", ip: "192.168.1.159", desc: "Platform as a Service", type: "core", services: ["Docker Management", "App Deployment"] },
|
||||
Gitea: { name: "Gitea", ip: "192.168.1.159", hostname: "git.appmodel.nl", desc: "Self-hosted Git service", type: "core" },
|
||||
Auction: { name: "Auction Stack", ip: "192.168.1.159", hostname: "auction.appmodel.nl", desc: "Auction application", type: "core" },
|
||||
MI50: { name: "MI50 / Ollama", ip: "192.168.1.159", hostname: "ollama.lan", desc: "AI/ML inference server", type: "core" },
|
||||
AdGuard: { name: "AdGuard DNS", desc: "Network-wide ad blocking", type: "infra", services: ["DNS Filtering", "Ad Blocking"] },
|
||||
XU4: { name: "XU4 DNS", ip: "192.168.1.163", desc: "Primary DNS (Odroid XU4)", type: "infra dns" },
|
||||
C2: { name: "C2 DNS", ip: "192.168.1.227", desc: "Secondary DNS (Odroid C2)", type: "infra dns" },
|
||||
HA: { name: "Home Assistant", ip: "192.168.1.193", desc: "Home automation platform", type: "infra" },
|
||||
Atlas: { name: "Atlas", ip: "192.168.1.100", desc: "Worker node", type: "worker", services: ["Docker", "Portainer"] },
|
||||
TV: { name: "Kamer-TV", ip: "192.168.1.240", desc: "Smart TV/Chromecast", type: "iot" },
|
||||
Hue: { name: "Philips Hue", ip: "192.168.1.49", desc: "Smart lighting bridge", type: "iot" },
|
||||
Eufy: { name: "Eufy S380HB", ip: "192.168.1.59", desc: "Security camera", type: "iot" },
|
||||
IoT: { name: "IoT Devices", desc: "Nest Mini, Roborock, ESP, Printer", type: "iot", devices: ["Nest Mini", "Roborock Vacuum", "ESP Devices", "HP Printer"] },
|
||||
MIKE: { name: "MIKE PC", ip: "192.168.1.100", desc: "Workstation with tether gateway", type: "client", interfaces: ["Ethernet", "USB Tether"] },
|
||||
Lotte: { name: "Lotte", ip: "192.168.1.133", desc: "Mobile device", type: "client" },
|
||||
Hermes: { name: "Hermes", ip: "192.168.137.239", desc: "Tether network node", type: "worker" },
|
||||
Plato: { name: "Plato", ip: "192.168.137.239", hostname: "llm.plato.lan", desc: "LLM server", type: "worker" }
|
||||
};
|
||||
|
||||
let d2svg, simulation, zoom, g;
|
||||
let selectedNode = null;
|
||||
|
||||
// Initialize D3 network graph
|
||||
async function initD2() {
|
||||
const loadingEl = document.getElementById('loading');
|
||||
const container = document.getElementById('d2-output');
|
||||
|
||||
try {
|
||||
console.log('Initializing D3 network...');
|
||||
loadingEl.textContent = 'Building network topology...';
|
||||
|
||||
// Create graph data
|
||||
const graphData = createGraphData();
|
||||
console.log('Graph data created:', graphData.nodes.length, 'nodes,', graphData.links.length, 'links');
|
||||
|
||||
// Setup SVG - get dimensions from parent svg-container
|
||||
const svgContainer = container.parentElement;
|
||||
const width = svgContainer.clientWidth;
|
||||
const height = svgContainer.clientHeight;
|
||||
|
||||
console.log('Container dimensions:', width, 'x', height);
|
||||
|
||||
const svg = d3.select(container)
|
||||
.append('svg')
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.attr('viewBox', [0, 0, width, height]);
|
||||
|
||||
d2svg = svg.node();
|
||||
|
||||
// Add zoom behavior
|
||||
zoom = d3.zoom()
|
||||
.scaleExtent([0.3, 3])
|
||||
.on('zoom', (event) => {
|
||||
g.attr('transform', event.transform);
|
||||
});
|
||||
|
||||
svg.call(zoom);
|
||||
|
||||
// Create main group for graph elements
|
||||
g = svg.append('g');
|
||||
|
||||
// Add network zone backgrounds (will be positioned after simulation)
|
||||
const zonesGroup = g.append('g').attr('class', 'zones');
|
||||
|
||||
const lanZone = zonesGroup.append('rect')
|
||||
.attr('class', 'network-zone lan-zone')
|
||||
.attr('fill', 'rgba(16, 185, 129, 0.1)');
|
||||
|
||||
const tetherZone = zonesGroup.append('rect')
|
||||
.attr('class', 'network-zone tether-zone')
|
||||
.attr('fill', 'rgba(139, 92, 246, 0.1)');
|
||||
|
||||
const lanLabel = zonesGroup.append('text')
|
||||
.attr('class', 'zone-label')
|
||||
.text('LAN 192.168.1.0/24');
|
||||
|
||||
const tetherLabel = zonesGroup.append('text')
|
||||
.attr('class', 'zone-label')
|
||||
.text('Tether 192.168.137.0/24');
|
||||
|
||||
// Create arrow markers for directed edges
|
||||
svg.append('defs').selectAll('marker')
|
||||
.data(['arrow', 'arrow-dashed'])
|
||||
.join('marker')
|
||||
.attr('id', d => d)
|
||||
.attr('viewBox', '0 -5 10 10')
|
||||
.attr('refX', 20)
|
||||
.attr('refY', 0)
|
||||
.attr('markerWidth', 6)
|
||||
.attr('markerHeight', 6)
|
||||
.attr('orient', 'auto')
|
||||
.append('path')
|
||||
.attr('d', 'M0,-5L10,0L0,5')
|
||||
.attr('fill', '#475569');
|
||||
|
||||
// Create links
|
||||
const link = g.append('g')
|
||||
.selectAll('path')
|
||||
.data(graphData.links)
|
||||
.join('path')
|
||||
.attr('class', d => `link ${d.dashed ? 'dashed' : ''}`)
|
||||
.attr('stroke-width', 2)
|
||||
.attr('marker-end', d => `url(#${d.dashed ? 'arrow-dashed' : 'arrow'})`);
|
||||
|
||||
// Create nodes
|
||||
const node = g.append('g')
|
||||
.selectAll('g')
|
||||
.data(graphData.nodes)
|
||||
.join('g')
|
||||
.attr('class', 'node-group')
|
||||
.call(d3.drag()
|
||||
.on('start', dragstarted)
|
||||
.on('drag', dragged)
|
||||
.on('end', dragended));
|
||||
|
||||
// Add circles to nodes
|
||||
node.append('circle')
|
||||
.attr('class', 'node-circle')
|
||||
.attr('r', d => nodeTypes[d.type]?.radius || 30)
|
||||
.attr('fill', d => nodeTypes[d.type]?.fill || '#6b7280')
|
||||
.attr('stroke', d => nodeTypes[d.type]?.stroke || '#9ca3af')
|
||||
.attr('stroke-width', 3);
|
||||
|
||||
// Add icon/emoji to nodes
|
||||
node.append('text')
|
||||
.attr('class', 'node-icon')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dy', '.35em')
|
||||
.attr('font-size', d => (nodeTypes[d.type]?.radius || 30) * 0.6)
|
||||
.text(d => d.icon);
|
||||
|
||||
// Add labels below nodes
|
||||
node.append('text')
|
||||
.attr('class', 'node-label')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dy', d => (nodeTypes[d.type]?.radius || 30) + 16)
|
||||
.attr('fill', '#e2e8f0')
|
||||
.text(d => d.label);
|
||||
|
||||
// Add sublabels
|
||||
node.append('text')
|
||||
.attr('class', 'node-sublabel')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dy', d => (nodeTypes[d.type]?.radius || 30) + 28)
|
||||
.attr('fill', '#94a3b8')
|
||||
.text(d => d.sublabel || '');
|
||||
|
||||
// Node interactions
|
||||
node.on('mouseover', function(event, d) {
|
||||
const data = networkData[d.id];
|
||||
if (data) {
|
||||
showTooltip(event, data);
|
||||
}
|
||||
d3.select(this).raise();
|
||||
})
|
||||
.on('mouseout', function() {
|
||||
hideTooltip();
|
||||
})
|
||||
.on('click', function(event, d) {
|
||||
event.stopPropagation();
|
||||
selectNode(d.id);
|
||||
});
|
||||
|
||||
// Click on background to deselect
|
||||
svg.on('click', () => {
|
||||
if (selectedNode) {
|
||||
d3.selectAll('.node-group').classed('selected', false);
|
||||
selectedNode = null;
|
||||
closePanel();
|
||||
}
|
||||
});
|
||||
|
||||
// Create force simulation
|
||||
simulation = d3.forceSimulation(graphData.nodes)
|
||||
.force('link', d3.forceLink(graphData.links)
|
||||
.id(d => d.id)
|
||||
.distance(d => d.distance || 150))
|
||||
.force('charge', d3.forceManyBody()
|
||||
.strength(-800))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collision', d3.forceCollide()
|
||||
.radius(d => (nodeTypes[d.type]?.radius || 30) + 10))
|
||||
.on('tick', ticked);
|
||||
|
||||
function ticked() {
|
||||
link.attr('d', d => {
|
||||
const dx = d.target.x - d.source.x;
|
||||
const dy = d.target.y - d.source.y;
|
||||
const dr = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// Calculate offset for arrow
|
||||
const targetRadius = nodeTypes[d.target.type]?.radius || 30;
|
||||
const offsetX = (dx / dr) * targetRadius;
|
||||
const offsetY = (dy / dr) * targetRadius;
|
||||
|
||||
return `M${d.source.x},${d.source.y}L${d.target.x - offsetX},${d.target.y - offsetY}`;
|
||||
});
|
||||
|
||||
node.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||
}
|
||||
|
||||
function dragstarted(event, d) {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
}
|
||||
|
||||
function dragged(event, d) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
}
|
||||
|
||||
function dragended(event, d) {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
}
|
||||
|
||||
// Hide loading
|
||||
loadingEl.style.display = 'none';
|
||||
|
||||
console.log('D3 network graph rendered successfully!');
|
||||
|
||||
// Add interactivity
|
||||
addInteractivity();
|
||||
|
||||
} catch (error) {
|
||||
console.error('D3 rendering error:', error);
|
||||
console.error('Stack trace:', error.stack);
|
||||
loadingEl.innerHTML = `<div style="color: #ef4444;">Error: ${error.message}<br><small>Check console for details</small></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Create graph data structure from network data
|
||||
function createGraphData() {
|
||||
const nodes = [
|
||||
{ id: 'Internet', label: 'Internet', sublabel: '5.132.33.195', icon: '☁️', type: 'dns' },
|
||||
{ id: 'Router', label: 'Zyxel Router', sublabel: '192.168.1.1', icon: '🛜', type: 'router' },
|
||||
{ id: 'Traefik', label: 'Traefik', sublabel: '192.168.1.159', icon: '⚡', type: 'core' },
|
||||
{ id: 'Dokku', label: 'Dokku', sublabel: '192.168.1.159', icon: '⚡', type: 'core' },
|
||||
{ id: 'Gitea', label: 'Gitea', sublabel: 'git.appmodel.nl', icon: '⚡', type: 'core' },
|
||||
{ id: 'Auction', label: 'Auction', sublabel: 'auction.appmodel.nl', icon: '⚡', type: 'core' },
|
||||
{ id: 'MI50', label: 'MI50/Ollama', sublabel: 'ollama.lan', icon: '⚡', type: 'core' },
|
||||
{ id: 'AdGuard', label: 'AdGuard', sublabel: 'DNS Filter', icon: '🔧', type: 'infra' },
|
||||
{ id: 'XU4', label: 'XU4 DNS', sublabel: '192.168.1.163', icon: '🔧', type: 'infra' },
|
||||
{ id: 'C2', label: 'C2 DNS', sublabel: '192.168.1.227', icon: '🔧', type: 'infra' },
|
||||
{ id: 'HA', label: 'Home Assistant', sublabel: '192.168.1.193', icon: '🔧', type: 'infra' },
|
||||
{ id: 'Atlas', label: 'Atlas', sublabel: '192.168.1.100', icon: '💻', type: 'worker' },
|
||||
{ id: 'TV', label: 'Kamer-TV', sublabel: '192.168.1.240', icon: '📺', type: 'iot' },
|
||||
{ id: 'Hue', label: 'Philips Hue', sublabel: '192.168.1.49', icon: '💡', type: 'iot' },
|
||||
{ id: 'Eufy', label: 'Eufy S380HB', sublabel: '192.168.1.59', icon: '📹', type: 'iot' },
|
||||
{ id: 'IoT', label: 'IoT Devices', sublabel: 'Various', icon: '🔌', type: 'iot' },
|
||||
{ id: 'MIKE', label: 'MIKE PC', sublabel: '192.168.1.100', icon: '💻', type: 'client' },
|
||||
{ id: 'Lotte', label: 'Lotte', sublabel: '192.168.1.133', icon: '📱', type: 'client' },
|
||||
{ id: 'Hermes', label: 'Hermes', sublabel: '192.168.137.239', icon: '💻', type: 'worker' },
|
||||
{ id: 'Plato', label: 'Plato', sublabel: 'llm.plato.lan', icon: '💻', type: 'worker' }
|
||||
];
|
||||
|
||||
const links = [
|
||||
// Main connections
|
||||
{ source: 'Internet', target: 'Router', label: 'DNS', distance: 200 },
|
||||
{ source: 'Router', target: 'Traefik', distance: 120 },
|
||||
{ source: 'Router', target: 'Dokku', distance: 120 },
|
||||
{ source: 'Router', target: 'Gitea', distance: 120 },
|
||||
{ source: 'Router', target: 'Auction', distance: 120 },
|
||||
{ source: 'Router', target: 'MI50', distance: 120 },
|
||||
{ source: 'Router', target: 'AdGuard', distance: 140 },
|
||||
{ source: 'Router', target: 'XU4', distance: 140 },
|
||||
{ source: 'Router', target: 'C2', distance: 140 },
|
||||
{ source: 'Router', target: 'HA', distance: 140 },
|
||||
{ source: 'Router', target: 'Atlas', distance: 140 },
|
||||
{ source: 'Router', target: 'TV', distance: 160 },
|
||||
{ source: 'Router', target: 'Hue', distance: 160 },
|
||||
{ source: 'Router', target: 'Eufy', distance: 160 },
|
||||
{ source: 'Router', target: 'IoT', distance: 160 },
|
||||
{ source: 'Router', target: 'MIKE', distance: 140 },
|
||||
{ source: 'Router', target: 'Lotte', distance: 160 },
|
||||
// Proxy connections
|
||||
{ source: 'Traefik', target: 'Gitea', distance: 80 },
|
||||
{ source: 'Traefik', target: 'Dokku', distance: 80 },
|
||||
{ source: 'Traefik', target: 'Auction', distance: 80 },
|
||||
// Tether connections
|
||||
{ source: 'MIKE', target: 'Hermes', distance: 100 },
|
||||
{ source: 'MIKE', target: 'Plato', distance: 100 },
|
||||
// DNS filtering (dashed)
|
||||
{ source: 'XU4', target: 'AdGuard', dashed: true, distance: 100 },
|
||||
{ source: 'C2', target: 'AdGuard', dashed: true, distance: 100 },
|
||||
{ source: 'Atlas', target: 'AdGuard', dashed: true, distance: 120 },
|
||||
{ source: 'HA', target: 'AdGuard', dashed: true, distance: 120 },
|
||||
{ source: 'Hermes', target: 'AdGuard', dashed: true, distance: 150 },
|
||||
{ source: 'Plato', target: 'AdGuard', dashed: true, distance: 150 }
|
||||
];
|
||||
|
||||
return { nodes, links };
|
||||
}
|
||||
|
||||
function addInteractivity() {
|
||||
// Legend toggle
|
||||
document.querySelectorAll('.legend-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const type = item.dataset.type;
|
||||
toggleNodeType(type);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function selectNode(nodeId) {
|
||||
if (!d2svg) return;
|
||||
|
||||
// Highlight selected node
|
||||
d3.selectAll('.node-group').classed('selected', false);
|
||||
d3.selectAll('.node-group')
|
||||
.filter(d => d.id === nodeId)
|
||||
.classed('selected', true);
|
||||
|
||||
selectedNode = nodeId;
|
||||
showDetailPanel(nodeId);
|
||||
}
|
||||
|
||||
function showTooltip(event, data) {
|
||||
const tooltip = document.getElementById('tooltip');
|
||||
const services = data.services ? data.services.join(', ') : '';
|
||||
const devices = data.devices ? data.devices.join(', ') : '';
|
||||
|
||||
tooltip.innerHTML = `
|
||||
<strong>${data.name}</strong><br>
|
||||
${data.ip ? `IP: ${data.ip}<br>` : ''}
|
||||
${data.hostname ? `Host: ${data.hostname}<br>` : ''}
|
||||
${data.desc ? `${data.desc}<br>` : ''}
|
||||
${services ? `<small>Services: ${services}</small><br>` : ''}
|
||||
${devices ? `<small>Devices: ${devices}</small>` : ''}
|
||||
`;
|
||||
|
||||
tooltip.style.left = event.clientX + 15 + 'px';
|
||||
tooltip.style.top = event.clientY - 10 + 'px';
|
||||
tooltip.classList.add('show');
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
document.getElementById('tooltip').classList.remove('show');
|
||||
}
|
||||
|
||||
function highlightConnections(nodeId) {
|
||||
if (!d2svg) return;
|
||||
d2svg.querySelectorAll('.connection').forEach(conn => {
|
||||
conn.style.stroke = '#60a5fa';
|
||||
conn.style.strokeWidth = '3';
|
||||
});
|
||||
}
|
||||
|
||||
function unhighlightConnections() {
|
||||
if (!d2svg) return;
|
||||
d2svg.querySelectorAll('.connection').forEach(conn => {
|
||||
conn.style.stroke = '';
|
||||
conn.style.strokeWidth = '';
|
||||
});
|
||||
}
|
||||
|
||||
function showDetailPanel(nodeId) {
|
||||
const data = networkData[nodeId];
|
||||
if (!data) return;
|
||||
|
||||
const panel = document.getElementById('detail-panel');
|
||||
const overlay = document.getElementById('overlay');
|
||||
const title = document.getElementById('detail-title');
|
||||
const content = document.getElementById('detail-content');
|
||||
|
||||
title.textContent = data.name;
|
||||
|
||||
let html = `
|
||||
<div class="detail-section">
|
||||
<p>${data.desc || 'No description available'}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (data.ip || data.hostname) {
|
||||
html += `
|
||||
<div class="detail-section">
|
||||
<h3>📡 Network</h3>
|
||||
<div class="detail-grid">
|
||||
${data.ip ? `<div class="detail-item"><strong>IP:</strong><br><code class="tag">${data.ip}</code></div>` : ''}
|
||||
${data.hostname ? `<div class="detail-item"><strong>Hostname:</strong><br><code class="tag">${data.hostname}</code></div>` : ''}
|
||||
${data.type ? `<div class="detail-item"><strong>Type:</strong><br><span class="tag">${data.type}</span></div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (data.services && data.services.length > 0) {
|
||||
html += `
|
||||
<div class="detail-section">
|
||||
<h3>🚀 Services</h3>
|
||||
${data.services.map(s => `<span class="tag">${s}</span>`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (data.interfaces && data.interfaces.length > 0) {
|
||||
html += `
|
||||
<div class="detail-section">
|
||||
<h3>🔌 Interfaces</h3>
|
||||
${data.interfaces.map(i => `<span class="tag">${i}</span>`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (data.devices && data.devices.length > 0) {
|
||||
html += `
|
||||
<div class="detail-section">
|
||||
<h3>📱 Sub-Devices</h3>
|
||||
${data.devices.map(d => `<span class="tag">${d}</span>`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const related = getRelatedNodes(nodeId);
|
||||
if (related.length > 0) {
|
||||
html += `
|
||||
<div class="detail-section">
|
||||
<h3>🔗 Connections</h3>
|
||||
<div class="detail-grid">
|
||||
`;
|
||||
related.forEach(rel => {
|
||||
html += `
|
||||
<div class="detail-item">
|
||||
<strong>→ ${rel.name}</strong><br>
|
||||
<small>${rel.label || 'Connected'}</small>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
content.innerHTML = html;
|
||||
overlay.classList.add('show');
|
||||
panel.classList.add('show');
|
||||
}
|
||||
|
||||
function getRelatedNodes(nodeId) {
|
||||
const relationships = {
|
||||
Router: ['Traefik', 'XU4', 'Atlas', 'TV', 'Hue', 'Eufy', 'IoT', 'MIKE', 'Lotte', 'AdGuard', 'C2', 'HA'],
|
||||
Traefik: ['Gitea', 'Dokku', 'Auction'],
|
||||
MIKE: ['Hermes', 'Plato'],
|
||||
XU4: ['AdGuard'],
|
||||
C2: ['AdGuard'],
|
||||
Atlas: ['AdGuard'],
|
||||
HA: ['AdGuard'],
|
||||
Hermes: ['AdGuard'],
|
||||
Plato: ['AdGuard']
|
||||
};
|
||||
|
||||
const related = relationships[nodeId] || [];
|
||||
return related.map(id => ({ name: networkData[id]?.name || id, label: 'routes' }));
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
document.getElementById('overlay').classList.remove('show');
|
||||
document.getElementById('detail-panel').classList.remove('show');
|
||||
if (d2svg) {
|
||||
d2svg.querySelectorAll('.node').forEach(n => n.classList.remove('selected'));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleNodeType(type) {
|
||||
if (!d2svg) return;
|
||||
d3.selectAll('.node-group')
|
||||
.filter(d => d.type === type)
|
||||
.transition()
|
||||
.duration(300)
|
||||
.style('opacity', function() {
|
||||
const current = d3.select(this).style('opacity');
|
||||
return current === '0' ? '1' : '0';
|
||||
})
|
||||
.style('pointer-events', function() {
|
||||
const current = d3.select(this).style('opacity');
|
||||
return current === '0' ? 'all' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
if (!d2svg || !zoom) return;
|
||||
d3.select(d2svg)
|
||||
.transition()
|
||||
.duration(750)
|
||||
.call(zoom.transform, d3.zoomIdentity);
|
||||
closePanel();
|
||||
if (simulation) simulation.alpha(0.3).restart();
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
if (!d2svg || !zoom) return;
|
||||
d3.select(d2svg)
|
||||
.transition()
|
||||
.duration(300)
|
||||
.call(zoom.scaleBy, 1.3);
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
if (!d2svg || !zoom) return;
|
||||
d3.select(d2svg)
|
||||
.transition()
|
||||
.duration(300)
|
||||
.call(zoom.scaleBy, 0.7);
|
||||
}
|
||||
|
||||
function fitToScreen() {
|
||||
resetView();
|
||||
}
|
||||
|
||||
function toggleAnimation() {
|
||||
if (!simulation) return;
|
||||
// Reheat the simulation for animated rearrangement
|
||||
simulation.alpha(0.5).restart();
|
||||
}
|
||||
|
||||
function exportSVG() {
|
||||
if (!d2svg) return;
|
||||
const svgData = new XMLSerializer().serializeToString(d2svg);
|
||||
const blob = new Blob([svgData], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'network-topology.svg';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initD2);
|
||||
} else {
|
||||
initD2();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
151
public/network.d2
Normal file
151
public/network.d2
Normal file
@@ -0,0 +1,151 @@
|
||||
title: Home Network Topology | font-size: 24
|
||||
|
||||
classes: {
|
||||
router: {
|
||||
style: {
|
||||
fill: "#f59e0b"
|
||||
stroke: "#fbbf24"
|
||||
stroke-width: 2
|
||||
shape: rectangle
|
||||
border-radius: 5
|
||||
}
|
||||
}
|
||||
core: {
|
||||
style: {
|
||||
fill: "#ef4444"
|
||||
stroke: "#f87171"
|
||||
stroke-width: 2
|
||||
shape: hexagon
|
||||
}
|
||||
}
|
||||
infra: {
|
||||
style: {
|
||||
fill: "#10b981"
|
||||
stroke: "#34d399"
|
||||
stroke-width: 2
|
||||
}
|
||||
}
|
||||
worker: {
|
||||
style: {
|
||||
fill: "#8b5cf6"
|
||||
stroke: "#a78bfa"
|
||||
stroke-width: 2
|
||||
shape: hexagon
|
||||
}
|
||||
}
|
||||
iot: {
|
||||
style: {
|
||||
fill: "#f97316"
|
||||
stroke: "#fb923c"
|
||||
stroke-width: 2
|
||||
shape: rounded-box
|
||||
}
|
||||
}
|
||||
client: {
|
||||
style: {
|
||||
fill: "#6b7280"
|
||||
stroke: "#9ca3af"
|
||||
stroke-width: 2
|
||||
shape: rectangle
|
||||
border-radius: 5
|
||||
}
|
||||
}
|
||||
dns: {
|
||||
style: {
|
||||
fill: "#0ea5e9"
|
||||
stroke: "#38bdf8"
|
||||
stroke-width: 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Internet: "☁️ Internet\nCloudflare DNS\n5.132.33.195" {
|
||||
class: dns
|
||||
near: top-center
|
||||
}
|
||||
|
||||
Router: "🛜 Zyxel Router\nhub.lan\n192.168.1.1" {
|
||||
class: router
|
||||
near: Router.n
|
||||
}
|
||||
|
||||
LAN: {
|
||||
label: "LAN 192.168.1.0/24"
|
||||
shape: rectangle
|
||||
style: {
|
||||
fill: "#1e293b"
|
||||
stroke: "#475569"
|
||||
stroke-width: 2
|
||||
stroke-dash: 5
|
||||
opacity: 0.3
|
||||
}
|
||||
|
||||
Traefik: "⚡ Traefik\nReverse Proxy\n192.168.1.159" {class: core}
|
||||
Dokku: "⚡ Dokku PaaS\n192.168.1.159" {class: core}
|
||||
Gitea: "⚡ Gitea\ngit.appmodel.nl" {class: core}
|
||||
Auction: "⚡ Auction\nauction.appmodel.nl" {class: core}
|
||||
MI50: "⚡ MI50/Ollama\nollama.lan" {class: core}
|
||||
|
||||
AdGuard: "🔧 AdGuard\nDNS Filter" {class: infra}
|
||||
XU4: "🔧 XU4 DNS\n192.168.1.163" {class: infra}
|
||||
C2: "🔧 C2 DNS\n192.168.1.227" {class: infra}
|
||||
HA: "🔧 Home Assistant\n192.168.1.193" {class: infra}
|
||||
|
||||
Atlas: "💻 Atlas\n192.168.1.100" {class: worker}
|
||||
|
||||
TV: "📺 Kamer-TV\n192.168.1.240" {class: iot}
|
||||
Hue: "💡 Philips Hue\n192.168.1.49" {class: iot}
|
||||
Eufy: "📹 Eufy S380HB\n192.168.1.59" {class: iot}
|
||||
IoT: "🔌 IoT Devices\nNest/Roborock/ESP/Printer" {class: iot}
|
||||
|
||||
MIKE: "💻 MIKE PC\n192.168.1.100\nLAN + Tether" {class: client}
|
||||
Lotte: "📱 Lotte\n192.168.1.133" {class: client}
|
||||
}
|
||||
|
||||
Tether: {
|
||||
label: "Tether 192.168.137.0/24"
|
||||
shape: rectangle
|
||||
style: {
|
||||
fill: "#0f172a"
|
||||
stroke: "#334155"
|
||||
stroke-width: 2
|
||||
stroke-dash: 5
|
||||
opacity: 0.3
|
||||
}
|
||||
|
||||
Hermes: "💻 Hermes\n192.168.137.239" {class: worker}
|
||||
Plato: "💻 Plato\n192.168.137.239\nllm.plato.lan" {class: worker}
|
||||
}
|
||||
|
||||
Internet -> Router: provides DNS
|
||||
|
||||
Router -> LAN.Traefik: routes
|
||||
Router -> LAN.Dokku: routes
|
||||
Router -> LAN.Gitea: routes
|
||||
Router -> LAN.Auction: routes
|
||||
Router -> LAN.MI50: routes
|
||||
Router -> LAN.AdGuard: routes
|
||||
Router -> LAN.XU4: routes
|
||||
Router -> LAN.C2: routes
|
||||
Router -> LAN.HA: routes
|
||||
Router -> LAN.Atlas: routes
|
||||
Router -> LAN.TV: routes
|
||||
Router -> LAN.Hue: routes
|
||||
Router -> LAN.Eufy: routes
|
||||
Router -> LAN.IoT: routes
|
||||
Router -> LAN.MIKE: routes
|
||||
Router -> LAN.Lotte: routes
|
||||
|
||||
LAN.MIKE -> Tether.Hermes: tether bridge
|
||||
LAN.MIKE -> Tether.Plato: tether bridge
|
||||
|
||||
LAN.Traefik -> LAN.Gitea: proxies
|
||||
LAN.Traefik -> LAN.Dokku: proxies
|
||||
LAN.Traefik -> LAN.Auction: proxies
|
||||
|
||||
LAN.XU4 -.-> LAN.AdGuard: filters
|
||||
LAN.C2 -.-> LAN.AdGuard: filters
|
||||
LAN.Atlas -.-> LAN.AdGuard: uses
|
||||
LAN.HA -.-> LAN.AdGuard: uses
|
||||
Tether.Hermes -.-> LAN.AdGuard: uses
|
||||
Tether.Plato -.-> LAN.AdGuard: uses
|
||||
1295
public/struz.html
Normal file
1295
public/struz.html
Normal file
File diff suppressed because it is too large
Load Diff
933
public/top.html
Normal file
933
public/top.html
Normal file
@@ -0,0 +1,933 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Interactive Network Topology</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;
|
||||
border-bottom: 1px solid #334155;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.8em;
|
||||
background: linear-gradient(90deg, #60a5fa 0%, #818cf8 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: rgba(56, 70, 100, 0.8);
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
.main-svg {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: calc(100% - 80px);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.main-svg:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Node Styles */
|
||||
.node {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
.node:hover {
|
||||
transform: scale(1.1);
|
||||
filter: drop-shadow(0 0 15px currentColor);
|
||||
}
|
||||
|
||||
.node.selected {
|
||||
transform: scale(1.15);
|
||||
filter: drop-shadow(0 0 20px currentColor);
|
||||
}
|
||||
|
||||
.node-circle {
|
||||
stroke-width: 2;
|
||||
transition: stroke-width 0.3s ease;
|
||||
}
|
||||
|
||||
.node:hover .node-circle {
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.node-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-anchor: middle;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.node-subtext {
|
||||
font-size: 10px;
|
||||
fill: #94a3b8;
|
||||
text-anchor: middle;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Connection Styles */
|
||||
.connection {
|
||||
fill: none;
|
||||
stroke: #475569;
|
||||
stroke-width: 2;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.connection:hover {
|
||||
stroke: #60a5fa;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.connection.highlighted {
|
||||
stroke: #818cf8;
|
||||
stroke-width: 3;
|
||||
filter: drop-shadow(0 0 5px #818cf8);
|
||||
}
|
||||
|
||||
/* Subnet Containers */
|
||||
.subnet-container {
|
||||
fill: none;
|
||||
stroke: #334155;
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5,5;
|
||||
rx: 15;
|
||||
ry: 15;
|
||||
}
|
||||
|
||||
.subnet-label {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
fill: #94a3b8;
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
/* Legend */
|
||||
.legend {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
padding: 15px;
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid #334155;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.legend-item:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.tooltip.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Detail Panel */
|
||||
.detail-panel {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
background: rgba(15, 23, 42, 0.98);
|
||||
border: 1px solid #475569;
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detail-section h3 {
|
||||
color: #818cf8;
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background: rgba(96, 165, 250, 0.2);
|
||||
color: #60a5fa;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
margin: 2px;
|
||||
border: 1px solid rgba(96, 165, 250, 0.3);
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: #f87171;
|
||||
border-color: rgba(248, 113, 113, 0.3);
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
}
|
||||
|
||||
/* Overlay */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Animation for active nodes */
|
||||
@keyframes pulse {
|
||||
0% { r: 15; }
|
||||
50% { r: 18; opacity: 0.7; }
|
||||
100% { r: 15; }
|
||||
}
|
||||
|
||||
.node.active .node-circle {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🌐 Interactive Network Topology</h1>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="control-btn" onclick="resetView()">🎯 Reset View</button>
|
||||
<button class="control-btn" onclick="toggleAnimation()">✨ Animate</button>
|
||||
<button class="control-btn" onclick="exportSVG()">💾 Export</button>
|
||||
</div>
|
||||
|
||||
<svg class="main-svg" id="network-svg" viewBox="0 0 1600 900">
|
||||
<defs>
|
||||
<!-- Gradient definitions -->
|
||||
<linearGradient id="gradient-router" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient-core" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#ef4444;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f87171;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient-infra" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#10b981;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#34d399;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient-worker" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#a78bfa;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient-iot" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#f97316;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#fb923c;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient-client" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#6b7280;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#9ca3af;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient-dns" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0ea5e9;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#38bdf8;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Filter for glow effects -->
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Subnet containers -->
|
||||
<rect class="subnet-container" x="50" y="200" width="1100" height="650" />
|
||||
<text class="subnet-label" x="600" y="230">LAN 192.168.1.0/24</text>
|
||||
|
||||
<rect class="subnet-container" x="1200" y="400" width="350" height="350" />
|
||||
<text class="subnet-label" x="1375" y="430">Tether_LAN_192.168.137</text>
|
||||
|
||||
<!-- Nodes -->
|
||||
<g class="node" data-id="net" data-type="internet" transform="translate(800, 50)">
|
||||
<circle class="node-circle" r="30" fill="url(#gradient-dns)" stroke="#0ea5e9" />
|
||||
<text class="node-text" y="5">☁️ Internet</text>
|
||||
<text class="node-subtext" y="45">5.132.33.195</text>
|
||||
</g>
|
||||
|
||||
<g class="node" data-id="router" data-type="router" transform="translate(800, 150)">
|
||||
<rect class="node-circle" x="-35" y="-20" width="70" height="40" rx="5" fill="url(#gradient-router)" stroke="#f59e0b" />
|
||||
<text class="node-text" y="5">🛜 Router</text>
|
||||
<text class="node-subtext" y="50">hub.lan</text>
|
||||
<text class="node-subtext" y="65">192.168.1.1</text>
|
||||
</g>
|
||||
|
||||
<!-- CORE SERVER Group -->
|
||||
<g class="node-group" data-group="core">
|
||||
<g class="node" data-id="traefik" data-type="core" transform="translate(200, 300)">
|
||||
<circle class="node-circle" r="20" fill="url(#gradient-core)" stroke="#ef4444" />
|
||||
<text class="node-text" y="25">Traefik</text>
|
||||
<text class="node-subtext" y="38">Reverse Proxy</text>
|
||||
</g>
|
||||
|
||||
<g class="node" data-id="dokku" data-type="core" transform="translate(350, 300)">
|
||||
<circle class="node-circle" r="20" fill="url(#gradient-core)" stroke="#ef4444" />
|
||||
<text class="node-text" y="25">Dokku PaaS</text>
|
||||
</g>
|
||||
|
||||
<g class="node" data-id="gitea" data-type="core" transform="translate(275, 380)">
|
||||
<circle class="node-circle" r="20" fill="url(#gradient-core)" stroke="#ef4444" />
|
||||
<text class="node-text" y="25">Gitea</text>
|
||||
<text class="node-subtext" y="38">git.appmodel.nl</text>
|
||||
</g>
|
||||
|
||||
<g class="node" data-id="auction" data-type="core" transform="translate(150, 380)">
|
||||
<circle class="node-circle" r="20" fill="url(#gradient-core)" stroke="#ef4444" />
|
||||
<text class="node-text" y="25">Auction Stack</text>
|
||||
<text class="node-subtext" y="38">auction.appmodel.nl</text>
|
||||
</g>
|
||||
|
||||
<g class="node" data-id="mi50" data-type="core" transform="translate(400, 380)">
|
||||
<circle class="node-circle" r="20" fill="url(#gradient-core)" stroke="#ef4444" />
|
||||
<text class="node-text" y="25">MI50 / Ollama</text>
|
||||
<text class="node-subtext" y="38">ollama.lan</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- INFRA Group -->
|
||||
<g class="node-group" data-group="infra">
|
||||
<g class="node" data-id="xu4" data-type="infra" transform="translate(600, 320)">
|
||||
<circle class="node-circle" r="25" fill="url(#gradient-infra)" stroke="#10b981" />
|
||||
<text class="node-text" y="5">XU4 DNS</text>
|
||||
<text class="node-subtext" y="55">192.168.1.163</text>
|
||||
</g>
|
||||
|
||||
<g class="node" data-id="cu2" data-type="infra" transform="translate(750, 320)">
|
||||
<circle class="node-circle" r="25" fill="url(#gradient-infra)" stroke="#10b981" />
|
||||
<text class="node-text" y="5">C2 DNS</text>
|
||||
<text class="node-subtext" y="55">192.168.1.227</text>
|
||||
</g>
|
||||
|
||||
<g class="node" data-id="adguard" data-type="infra" transform="translate(675, 400)">
|
||||
<circle class="node-circle" r="25" fill="url(#gradient-infra)" stroke="#10b981" />
|
||||
<text class="node-text" y="5">AdGuard DNS</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Workers -->
|
||||
<g class="node" data-id="atlas" data-type="worker" transform="translate(900, 320)">
|
||||
<circle class="node-circle" r="25" fill="url(#gradient-worker)" stroke="#8b5cf6" />
|
||||
<text class="node-text" y="5">Atlas</text>
|
||||
<text class="node-subtext" y="55">192.168.1.100</text>
|
||||
</g>
|
||||
|
||||
<g class="node" data-id="ha" data-type="infra" transform="translate(1050, 320)">
|
||||
<circle class="node-circle" r="25" fill="url(#gradient-infra)" stroke="#10b981" />
|
||||
<text class="node-text" y="5">Home Assistant</text>
|
||||
<text class="node-subtext" y="55">192.168.1.193</text>
|
||||
</g>
|
||||
|
||||
<!-- IoT Devices -->
|
||||
<g class="node" data-id="iot_tv" data-type="iot" transform="translate(200, 550)">
|
||||
<circle class="node-circle" r="20" fill="url(#gradient-iot)" stroke="#f97316" />
|
||||
<text class="node-text" y="25">📺 Kamer-TV</text>
|
||||
<text class="node-subtext" y="38">192.168.1.240</text>
|
||||
</g>
|
||||
|
||||
<g class="node" data-id="iot_hue" data-type="iot" transform="translate(350, 550)">
|
||||
<circle class="node-circle" r="20" fill="url(#gradient-iot)" stroke="#f97316" />
|
||||
<text class="node-text" y="25">💡 Philips Hue</text>
|
||||
<text class="node-subtext" y="38">192.168.1.49</text>
|
||||
</g>
|
||||
|
||||
<g class="node" data-id="iot_eufy" data-type="iot" transform="translate(500, 550)">
|
||||
<circle class="node-circle" r="20" fill="url(#gradient-iot)" stroke="#f97316" />
|
||||
<text class="node-text" y="25">📹 Eufy S380HB</text>
|
||||
<text class="node-subtext" y="38">192.168.1.59</text>
|
||||
</g>
|
||||
|
||||
<g class="node" data-id="iot_misc" data-type="iot" transform="translate(650, 550)">
|
||||
<circle class="node-circle" r="20" fill="url(#gradient-iot)" stroke="#f97316" />
|
||||
<text class="node-text" y="25">🔌 IoT Devices</text>
|
||||
<text class="node-subtext" y="38">Nest / Roborock / ESP / Printer</text>
|
||||
</g>
|
||||
|
||||
<!-- Clients -->
|
||||
<g class="node" data-id="clientMike" data-type="client" transform="translate(900, 550)">
|
||||
<rect class="node-circle" x="-40" y="-20" width="80" height="40" rx="5" fill="url(#gradient-client)" stroke="#6b7280" />
|
||||
<text class="node-text" y="5">💻 MIKE PC</text>
|
||||
<text class="node-subtext" y="50">192.168.1.100</text>
|
||||
<text class="node-subtext" y="65">LAN + Tether gateway</text>
|
||||
</g>
|
||||
|
||||
<g class="node" data-id="clientLotte" data-type="client" transform="translate(1100, 550)">
|
||||
<circle class="node-circle" r="20" fill="url(#gradient-client)" stroke="#6b7280" />
|
||||
<text class="node-text" y="25">📱 Lotte</text>
|
||||
<text class="node-subtext" y="38">192.168.1.133</text>
|
||||
</g>
|
||||
|
||||
<!-- Tether Subnet -->
|
||||
<g class="node" data-id="hermes" data-type="worker" transform="translate(1300, 500)">
|
||||
<circle class="node-circle" r="20" fill="url(#gradient-worker)" stroke="#8b5cf6" />
|
||||
<text class="node-text" y="25">Hermes</text>
|
||||
<text class="node-subtext" y="38">192.168.137.239</text>
|
||||
</g>
|
||||
|
||||
<g class="node" data-id="plato" data-type="worker" transform="translate(1450, 500)">
|
||||
<circle class="node-circle" r="20" fill="url(#gradient-worker)" stroke="#8b5cf6" />
|
||||
<text class="node-text" y="25">Plato</text>
|
||||
<text class="node-subtext" y="38">192.168.137.239</text>
|
||||
<text class="node-subtext" y="51">llm.plato.lan</text>
|
||||
</g>
|
||||
|
||||
<!-- Connections -->
|
||||
<g id="connections">
|
||||
<!-- Main connections -->
|
||||
<path class="connection" d="M 800 80 L 800 130" marker-end="url(#arrowhead)" />
|
||||
<path class="connection" d="M 800 190 Q 500 250 200 300" />
|
||||
<path class="connection" d="M 800 190 Q 700 250 600 320" />
|
||||
<path class="connection" d="M 800 190 Q 850 250 900 320" />
|
||||
<path class="connection" d="M 800 190 Q 950 250 1050 320" />
|
||||
<path class="connection" d="M 800 190 Q 500 400 200 550" />
|
||||
<path class="connection" d="M 800 190 Q 600 400 350 550" />
|
||||
<path class="connection" d="M 800 190 Q 700 400 500 550" />
|
||||
<path class="connection" d="M 800 190 Q 800 400 650 550" />
|
||||
<path class="connection" d="M 800 190 Q 850 400 900 550" />
|
||||
<path class="connection" d="M 800 190 Q 950 400 1100 550" />
|
||||
|
||||
<!-- Tether bridge -->
|
||||
<path class="connection" d="M 940 570 L 1300 500" stroke-dasharray="5,5" />
|
||||
|
||||
<!-- Internal group connections -->
|
||||
<path class="connection" d="M 220 320 Q 300 320 330 320" />
|
||||
<path class="connection" d="M 220 320 Q 240 350 260 380" />
|
||||
<path class="connection" d="M 220 320 Q 180 350 170 380" />
|
||||
<path class="connection" d="M 330 320 Q 350 350 370 380" />
|
||||
<path class="connection" d="M 620 345 Q 640 360 660 375" />
|
||||
<path class="connection" d="M 730 345 Q 710 360 690 375" />
|
||||
</g>
|
||||
|
||||
<!-- Marker definition for arrows -->
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#475569" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-item" data-type="internet">
|
||||
<div class="legend-color" style="border-color: #0ea5e9; background: linear-gradient(135deg, #0ea5e9 0%, #38bdf8 100%);"></div>
|
||||
<span>Internet/DNS</span>
|
||||
</div>
|
||||
<div class="legend-item" data-type="router">
|
||||
<div class="legend-color" style="border-color: #f59e0b; background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%);"></div>
|
||||
<span>Router</span>
|
||||
</div>
|
||||
<div class="legend-item" data-type="core">
|
||||
<div class="legend-color" style="border-color: #ef4444; background: linear-gradient(135deg, #ef4444 0%, #f87171 100%);"></div>
|
||||
<span>Core Server</span>
|
||||
</div>
|
||||
<div class="legend-item" data-type="infra">
|
||||
<div class="legend-color" style="border-color: #10b981; background: linear-gradient(135deg, #10b981 0%, #34d399 100%);"></div>
|
||||
<span>Infra/DNS</span>
|
||||
</div>
|
||||
<div class="legend-item" data-type="worker">
|
||||
<div class="legend-color" style="border-color: #8b5cf6; background: linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%);"></div>
|
||||
<span>Worker/Edge</span>
|
||||
</div>
|
||||
<div class="legend-item" data-type="iot">
|
||||
<div class="legend-color" style="border-color: #f97316; background: linear-gradient(135deg, #f97316 0%, #fb923c 100%);"></div>
|
||||
<span>IoT Devices</span>
|
||||
</div>
|
||||
<div class="legend-item" data-type="client">
|
||||
<div class="legend-color" style="border-color: #6b7280; background: linear-gradient(135deg, #6b7280 0%, #9ca3af 100%);"></div>
|
||||
<span>Clients</span>
|
||||
</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>
|
||||
|
||||
<script>
|
||||
const networkData = {
|
||||
net: { name: 'Internet / Cloudflare DNS', ip: '5.132.33.195', type: 'internet', desc: 'External network and DNS provider' },
|
||||
router: { name: 'Zyxel VMG8825-T50', ip: '192.168.1.1', hostname: 'hub.lan', type: 'router', desc: 'Main gateway router' },
|
||||
|
||||
// Core Server Group
|
||||
traefik: { name: 'Traefik', type: 'core', desc: 'Reverse Proxy', services: ['Reverse Proxy', 'Load Balancer'] },
|
||||
dokku: { name: 'Dokku PaaS', type: 'core', desc: 'Platform as a Service', services: ['Docker Management', 'App Deployment'] },
|
||||
gitea: { name: 'Gitea', ip: '192.168.1.159', type: 'core', desc: 'Self-hosted Git service', hostname: 'git.appmodel.nl' },
|
||||
auction: { name: 'Auction Stack', ip: '192.168.1.159', type: 'core', desc: 'Auction application', hostname: 'auction.appmodel.nl' },
|
||||
mi50: { name: 'MI50 / Ollama', ip: '192.168.1.159', type: 'core', desc: 'AI/ML inference server', hostname: 'ollama.lan' },
|
||||
|
||||
// Infra Group
|
||||
xu4: { name: 'XU4 DNS', ip: '192.168.1.163', type: 'infra', desc: 'Primary DNS server (Odroid XU4)', services: ['DNS', 'Pi-hole/AdGuard'] },
|
||||
cu2: { name: 'C2 DNS', ip: '192.168.1.227', type: 'infra', desc: 'Secondary DNS server (Odroid C2)', services: ['DNS'] },
|
||||
adguard: { name: 'AdGuard DNS', type: 'infra', desc: 'Network-wide ad blocking', services: ['DNS Filtering', 'Ad Blocking'] },
|
||||
|
||||
// Workers
|
||||
atlas: { name: 'Atlas', ip: '192.168.1.100', type: 'worker', desc: 'Worker node', services: ['Docker', 'Portainer'] },
|
||||
ha: { name: 'Home Assistant', ip: '192.168.1.193', type: 'infra', desc: 'Home automation platform', services: ['Automation', 'IoT Control'] },
|
||||
|
||||
// IoT Devices
|
||||
iot_tv: { name: 'Kamer-TV', ip: '192.168.1.240', type: 'iot', desc: 'Chromecast/Smart TV', device: 'Smart TV' },
|
||||
iot_hue: { name: 'Philips Hue', ip: '192.168.1.49', type: 'iot', desc: 'Smart lighting bridge' },
|
||||
iot_eufy: { name: 'Eufy S380HB', ip: '192.168.1.59', type: 'iot', desc: 'Security camera', device: 'Beveiligingscamera' },
|
||||
iot_misc: { name: 'IoT Devices', type: 'iot', desc: 'Various IoT devices', devices: ['Nest Mini', 'Roborock Vacuum', 'ESP devices', 'HP Printer'] },
|
||||
|
||||
// Clients
|
||||
clientMike: { name: 'MIKE PC', ip: '192.168.1.100', type: 'client', desc: 'Workstation with tether gateway', interfaces: ['LAN', 'Tether Bridge'] },
|
||||
clientLotte: { name: 'Lotte', ip: '192.168.1.133', type: 'client', desc: 'Mobile device' },
|
||||
|
||||
// Tether
|
||||
hermes: { name: 'Hermes', ip: '192.168.137.239', type: 'worker', desc: 'Tether network node' },
|
||||
plato: { name: 'Plato', ip: '192.168.137.239', type: 'worker', desc: 'LLM server', hostname: 'llm.plato.lan' }
|
||||
};
|
||||
|
||||
let selectedNode = null;
|
||||
let isAnimating = true;
|
||||
let svgPanZoom;
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializeSVG();
|
||||
setupEventListeners();
|
||||
startAnimation();
|
||||
});
|
||||
|
||||
function initializeSVG() {
|
||||
const svg = document.getElementById('network-svg');
|
||||
|
||||
// Add pan and zoom
|
||||
svgPanZoom = svgPanZoomInstance(svg, {
|
||||
zoomEnabled: true,
|
||||
controlIconsEnabled: false,
|
||||
fit: true,
|
||||
center: true,
|
||||
minZoom: 0.5,
|
||||
maxZoom: 3
|
||||
});
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
// Node click events
|
||||
document.querySelectorAll('.node').forEach(node => {
|
||||
node.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const nodeId = node.dataset.id;
|
||||
selectNode(nodeId);
|
||||
});
|
||||
|
||||
node.addEventListener('mouseenter', (e) => {
|
||||
const nodeId = node.dataset.id;
|
||||
const data = networkData[nodeId];
|
||||
showTooltip(e, data);
|
||||
highlightConnections(nodeId);
|
||||
});
|
||||
|
||||
node.addEventListener('mouseleave', () => {
|
||||
hideTooltip();
|
||||
unhighlightConnections();
|
||||
});
|
||||
});
|
||||
|
||||
// Legend interactions
|
||||
document.querySelectorAll('.legend-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const type = item.dataset.type;
|
||||
toggleNodeType(type);
|
||||
});
|
||||
});
|
||||
|
||||
// Connection hover
|
||||
document.querySelectorAll('.connection').forEach(conn => {
|
||||
conn.addEventListener('mouseenter', (e) => {
|
||||
conn.classList.add('highlighted');
|
||||
});
|
||||
|
||||
conn.addEventListener('mouseleave', (e) => {
|
||||
conn.classList.remove('highlighted');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function selectNode(nodeId) {
|
||||
// Remove previous selection
|
||||
document.querySelectorAll('.node').forEach(n => n.classList.remove('selected'));
|
||||
|
||||
// Select new node
|
||||
const node = document.querySelector(`[data-id="${nodeId}"]`);
|
||||
if (node) {
|
||||
node.classList.add('selected');
|
||||
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) {
|
||||
// This would parse the diagram to highlight related connections
|
||||
// For simplicity, we'll just highlight all connections temporarily
|
||||
document.querySelectorAll('.connection').forEach(conn => {
|
||||
conn.style.stroke = '#60a5fa';
|
||||
conn.style.strokeWidth = '3';
|
||||
});
|
||||
}
|
||||
|
||||
function unhighlightConnections() {
|
||||
document.querySelectorAll('.connection').forEach(conn => {
|
||||
conn.style.stroke = '';
|
||||
conn.style.strokeWidth = '';
|
||||
});
|
||||
}
|
||||
|
||||
function showDetailPanel(nodeId) {
|
||||
const data = networkData[nodeId];
|
||||
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) {
|
||||
html += `
|
||||
<div class="detail-section">
|
||||
<h3>📡 Network Information</h3>
|
||||
<div><strong>IP Address:</strong> <code class="tag">${data.ip}</code></div>
|
||||
${data.hostname ? `<div><strong>Hostname:</strong> <code class="tag">${data.hostname}</code></div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (data.services) {
|
||||
html += `
|
||||
<div class="detail-section">
|
||||
<h3>🚀 Services</h3>
|
||||
${data.services.map(s => `<span class="tag">${s}</span>`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (data.interfaces) {
|
||||
html += `
|
||||
<div class="detail-section">
|
||||
<h3>🔌 Interfaces</h3>
|
||||
${data.interfaces.map(i => `<span class="tag">${i}</span>`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (data.devices) {
|
||||
html += `
|
||||
<div class="detail-section">
|
||||
<h3>📱 Sub-Devices</h3>
|
||||
${data.devices.map(d => `<span class="tag">${d}</span>`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add related connections
|
||||
html += `
|
||||
<div class="detail-section">
|
||||
<h3>🔗 Connections</h3>
|
||||
<p style="color: #94a3b8; font-size: 0.9em;">
|
||||
Click on connections in the diagram to see details
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
content.innerHTML = html;
|
||||
overlay.classList.add('show');
|
||||
panel.classList.add('show');
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
document.getElementById('overlay').classList.remove('show');
|
||||
document.getElementById('detail-panel').classList.remove('show');
|
||||
document.querySelectorAll('.node').forEach(n => n.classList.remove('selected'));
|
||||
selectedNode = null;
|
||||
}
|
||||
|
||||
function toggleNodeType(type) {
|
||||
document.querySelectorAll(`.node[data-type="${type}"]`).forEach(node => {
|
||||
const isVisible = node.style.display !== 'none';
|
||||
node.style.display = isVisible ? 'none' : 'block';
|
||||
node.style.opacity = isVisible ? '0' : '1';
|
||||
});
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
svgPanZoom.reset();
|
||||
closePanel();
|
||||
}
|
||||
|
||||
function toggleAnimation() {
|
||||
isAnimating = !isAnimating;
|
||||
if (isAnimating) {
|
||||
startAnimation();
|
||||
} else {
|
||||
stopAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
function startAnimation() {
|
||||
document.querySelectorAll('.node[data-type="core"], .node[data-type="infra"]').forEach(node => {
|
||||
node.classList.add('active');
|
||||
});
|
||||
}
|
||||
|
||||
function stopAnimation() {
|
||||
document.querySelectorAll('.node').forEach(node => {
|
||||
node.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
function exportSVG() {
|
||||
const svg = document.getElementById('network-svg');
|
||||
const svgData = new XMLSerializer().serializeToString(svg);
|
||||
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);
|
||||
}
|
||||
|
||||
// Simple pan/zoom implementation (if you don't want to use a library)
|
||||
function svgPanZoomInstance(svg, options) {
|
||||
let isPanning = false;
|
||||
let startPoint = { x: 0, y: 0 };
|
||||
let startTranslate = { x: 0, y: 0 };
|
||||
let scale = 1;
|
||||
|
||||
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
g.id = 'pan-zoom-group';
|
||||
while (svg.firstChild) {
|
||||
g.appendChild(svg.firstChild);
|
||||
}
|
||||
svg.appendChild(g);
|
||||
|
||||
svg.addEventListener('mousedown', (e) => {
|
||||
if (e.target === svg || e.target.tagName === 'path') {
|
||||
isPanning = true;
|
||||
startPoint = { x: e.clientX, y: e.clientY };
|
||||
const transform = g.getAttribute('transform') || '';
|
||||
const matches = transform.match(/translate\(([^,]+),([^)]+)\)/);
|
||||
if (matches) {
|
||||
startTranslate = { x: parseFloat(matches[1]), y: parseFloat(matches[2]) };
|
||||
}
|
||||
svg.style.cursor = 'grabbing';
|
||||
}
|
||||
});
|
||||
|
||||
svg.addEventListener('mousemove', (e) => {
|
||||
if (!isPanning) return;
|
||||
const dx = e.clientX - startPoint.x;
|
||||
const dy = e.clientY - startPoint.y;
|
||||
const newX = startTranslate.x + dx;
|
||||
const newY = startTranslate.y + dy;
|
||||
updateTransform(newX, newY, scale);
|
||||
});
|
||||
|
||||
svg.addEventListener('mouseup', () => {
|
||||
isPanning = false;
|
||||
svg.style.cursor = 'grab';
|
||||
});
|
||||
|
||||
svg.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
scale = Math.max(options.minZoom, Math.min(options.maxZoom, scale * delta));
|
||||
updateTransform(0, 0, scale);
|
||||
});
|
||||
|
||||
function updateTransform(x, y, s) {
|
||||
g.setAttribute('transform', `translate(${x},${y}) scale(${s})`);
|
||||
}
|
||||
|
||||
return {
|
||||
reset: () => {
|
||||
scale = 1;
|
||||
updateTransform(0, 0, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user