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

View File

@@ -8,5 +8,5 @@ COPY public/ ./
# If the site references additional resources, copy them too # If the site references additional resources, copy them too
COPY resources/ ./resources COPY resources/ ./resources
# Optional: provide your own nginx.conf for SPA routing # Provide custom nginx.conf for clean URLs
# COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf

View File

@@ -62,7 +62,20 @@ python main.py
Output files will be generated in the current directory as PNG images. Output files will be generated in the current directory as PNG images.
## Available Diagrams ## 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 - `lan_architecture.py` - Home Lab / Auction Stack Architecture diagram
- `main.py` - Network architecture diagram - `main.py` - Network architecture diagram

22
nginx.conf Normal file
View 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
View File

@@ -0,0 +1,993 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Interactive Network Topology - D2</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
color: #e2e8f0; overflow: hidden;
}
.container { width: 100vw; height: 100vh; display: flex; flex-direction: column; }
.header {
background: rgba(15, 23, 42, 0.9);
backdrop-filter: blur(10px);
padding: 20px 30px;
border-bottom: 1px solid #334155;
z-index: 10; display: flex; justify-content: space-between; align-items: center;
}
.header h1 {
font-size: 1.8em;
background: linear-gradient(90deg, #60a5fa 0%, #818cf8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.controls { display: flex; gap: 10px; }
.control-btn {
background: rgba(30, 41, 59, 0.7);
border: 1px solid #475569;
color: #cbd5e1;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
font-size: 14px;
}
.control-btn:hover {
background: rgba(56, 70, 100, 0.8);
border-color: #60a5fa;
}
.svg-container {
flex: 1;
width: 100%;
position: relative;
overflow: hidden;
min-height: 0; /* Important for flex children */
}
#d2-output {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
.svg-container svg {
width: 100%;
height: 100%;
cursor: grab;
}
.svg-container svg:active { cursor: grabbing; }
/* D2 styling */
:root {
--router-fill: #f59e0b; --router-stroke: #fbbf24;
--core-fill: #ef4444; --core-stroke: #f87171;
--infra-fill: #10b981; --infra-stroke: #34d399;
--worker-fill: #8b5cf6; --worker-stroke: #a78bfa;
--iot-fill: #f97316; --iot-stroke: #fb923c;
--client-fill: #6b7280; --client-stroke: #9ca3af;
--dns-fill: #0ea5e9; --dns-stroke: #38bdf8;
}
.d2 .router .shape { fill: var(--router-fill) !important; stroke: var(--router-stroke) !important; }
.d2 .core .shape { fill: var(--core-fill) !important; stroke: var(--core-stroke) !important; }
.d2 .infra .shape { fill: var(--infra-fill) !important; stroke: var(--infra-stroke) !important; }
.d2 .worker .shape { fill: var(--worker-fill) !important; stroke: var(--worker-stroke) !important; }
.d2 .iot .shape { fill: var(--iot-fill) !important; stroke: var(--iot-stroke) !important; }
.d2 .client .shape { fill: var(--client-fill) !important; stroke: var(--client-stroke) !important; }
.d2 .dns .shape { fill: var(--dns-fill) !important; stroke: var(--dns-stroke) !important; }
.d2 .node:hover .shape {
filter: drop-shadow(0 0 10px currentColor);
cursor: pointer;
}
.d2 .connection:hover {
stroke: #60a5fa !important;
stroke-width: 3 !important;
}
/* Tooltip */
.tooltip {
position: fixed;
background: rgba(15, 23, 42, 0.95);
color: #e2e8f0;
padding: 12px 16px;
border-radius: 8px;
font-size: 12px;
pointer-events: none;
z-index: 1000;
opacity: 0;
transition: opacity 0.3s ease;
border: 1px solid #475569;
backdrop-filter: blur(10px);
max-width: 300px;
line-height: 1.5;
}
.tooltip.show { opacity: 1; }
/* Detail Panel */
.detail-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.95);
background: rgba(15, 23, 42, 0.98);
border: 1px solid #475569;
border-radius: 16px;
padding: 30px;
min-width: 450px;
max-width: 650px;
max-height: 80vh;
z-index: 2000;
opacity: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(20px);
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
overflow-y: auto;
}
.detail-panel.show {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #334155;
}
.detail-title {
font-size: 1.5em;
color: #60a5fa;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
color: #94a3b8;
font-size: 24px;
cursor: pointer;
transition: color 0.3s ease;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover { color: #e2e8f0; }
.detail-content { line-height: 1.6; }
.detail-section { margin-bottom: 24px; }
.detail-section h3 {
color: #818cf8;
margin-bottom: 12px;
font-size: 1.1em;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.tag {
display: inline-block;
background: rgba(96, 165, 250, 0.15);
color: #60a5fa;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
margin: 2px;
border: 1px solid rgba(96, 165, 250, 0.2);
font-family: 'Courier New', monospace;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
}
.detail-item {
background: rgba(30, 41, 59, 0.5);
padding: 10px;
border-radius: 8px;
border: 1px solid #334155;
}
.detail-item strong {
color: #94a3b8;
font-size: 0.9em;
}
.detail-item code {
font-family: 'Courier New', monospace;
color: #e2e8f0;
}
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1500;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.overlay.show {
opacity: 1;
pointer-events: all;
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1.2em;
color: #60a5fa;
}
.zoom-controls {
position: absolute;
top: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 5px;
z-index: 100;
}
.zoom-btn {
background: rgba(30, 41, 59, 0.7);
border: 1px solid #475569;
color: #cbd5e1;
width: 40px;
height: 40px;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.zoom-btn:hover {
background: rgba(56, 70, 100, 0.8);
border-color: #60a5fa;
}
/* D3 Network Styles */
#d2-output svg {
background: transparent;
}
.node-group {
cursor: pointer;
transition: all 0.3s ease;
}
.node-circle {
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
transition: all 0.3s ease;
}
.node-group:hover .node-circle {
filter: drop-shadow(0 4px 12px rgba(96, 165, 250, 0.6));
transform: scale(1.1);
}
.node-label {
pointer-events: none;
user-select: none;
font-size: 11px;
font-weight: 600;
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
}
.node-sublabel {
pointer-events: none;
user-select: none;
font-size: 9px;
opacity: 0.8;
}
.link {
stroke: #475569;
stroke-opacity: 0.6;
fill: none;
transition: all 0.3s ease;
}
.link:hover {
stroke: #60a5fa;
stroke-opacity: 1;
stroke-width: 3px;
}
.link.dashed {
stroke-dasharray: 5,5;
}
.link-label {
font-size: 9px;
fill: #94a3b8;
pointer-events: none;
user-select: none;
}
.node-group.selected .node-circle {
stroke-width: 4px !important;
filter: drop-shadow(0 0 20px currentColor);
}
/* Network Zones */
.network-zone {
fill: rgba(30, 41, 59, 0.3);
stroke: #475569;
stroke-width: 2;
stroke-dasharray: 10,5;
rx: 20;
ry: 20;
pointer-events: none;
}
.zone-label {
fill: #94a3b8;
font-size: 14px;
font-weight: 600;
text-anchor: middle;
pointer-events: none;
}
/* Legend */
.legend {
position: fixed;
bottom: 20px;
left: 20px;
background: rgba(15, 23, 42, 0.9);
backdrop-filter: blur(10px);
border: 1px solid #475569;
border-radius: 12px;
padding: 15px;
display: flex;
gap: 15px;
z-index: 100;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: all 0.3s ease;
padding: 5px 10px;
border-radius: 6px;
}
.legend-item:hover {
background: rgba(56, 70, 100, 0.5);
}
.legend-color {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid;
}
.legend-item span {
font-size: 12px;
color: #cbd5e1;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🏠 Interactive Network Topology</h1>
<div class="controls">
<button class="control-btn" onclick="resetView()">🎯 Reset</button>
<button class="control-btn" onclick="toggleAnimation()">✨ Animate</button>
<button class="control-btn" onclick="exportSVG()">💾 Export</button>
</div>
</div>
<div class="svg-container">
<div class="loading" id="loading">Loading D2 runtime...</div>
<div id="d2-output"></div>
<div class="zoom-controls">
<button class="zoom-btn" onclick="zoomIn()">+</button>
<button class="zoom-btn" onclick="zoomOut()"></button>
<button class="zoom-btn" onclick="fitToScreen()"></button>
</div>
<div class="legend">
<div class="legend-item" data-type="router">
<div class="legend-color" style="border-color: var(--router-stroke); background: var(--router-fill);"></div>
<span>Router</span>
</div>
<div class="legend-item" data-type="core">
<div class="legend-color" style="border-color: var(--core-stroke); background: var(--core-fill);"></div>
<span>Core Server</span>
</div>
<div class="legend-item" data-type="infra">
<div class="legend-color" style="border-color: var(--infra-stroke); background: var(--infra-fill);"></div>
<span>Infra/DNS</span>
</div>
<div class="legend-item" data-type="worker">
<div class="legend-color" style="border-color: var(--worker-stroke); background: var(--worker-fill);"></div>
<span>Worker/Edge</span>
</div>
<div class="legend-item" data-type="iot">
<div class="legend-color" style="border-color: var(--iot-stroke); background: var(--iot-fill);"></div>
<span>IoT Devices</span>
</div>
<div class="legend-item" data-type="client">
<div class="legend-color" style="border-color: var(--client-stroke); background: var(--client-fill);"></div>
<span>Clients</span>
</div>
</div>
</div>
</div>
<div class="tooltip" id="tooltip"></div>
<div class="overlay" id="overlay" onclick="closePanel()"></div>
<div class="detail-panel" id="detail-panel">
<div class="detail-header">
<div class="detail-title" id="detail-title">Node Details</div>
<button class="close-btn" onclick="closePanel()">×</button>
</div>
<div class="detail-content" id="detail-content"></div>
</div>
<!-- D3.js v7 - The gold standard for interactive visualizations -->
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<script>
// Check if D3 loaded
console.log('D3 version:', typeof d3 !== 'undefined' ? d3.version : 'NOT LOADED');
if (typeof d3 === 'undefined') {
document.getElementById('loading').innerHTML =
'<div style="color: #ef4444;">D3.js failed to load from CDN. Check internet connection.</div>';
}
// Network graph configuration
const nodeTypes = {
dns: { fill: '#0ea5e9', stroke: '#38bdf8', radius: 40 },
router: { fill: '#f59e0b', stroke: '#fbbf24', radius: 45 },
core: { fill: '#ef4444', stroke: '#f87171', radius: 35 },
infra: { fill: '#10b981', stroke: '#34d399', radius: 30 },
worker: { fill: '#8b5cf6', stroke: '#a78bfa', radius: 30 },
iot: { fill: '#f97316', stroke: '#fb923c', radius: 25 },
client: { fill: '#6b7280', stroke: '#9ca3af', radius: 28 }
};
// Network data for tooltips and details
const networkData = {
Internet: { name: "Internet / Cloudflare DNS", ip: "5.132.33.195", desc: "External DNS provider", type: "internet" },
Router: { name: "Zyxel VM8825-T50", ip: "192.168.1.1", hostname: "hub.lan", desc: "Main gateway router", type: "router", services: ["NAT", "DHCP", "DNS Proxy"] },
Traefik: { name: "Traefik", ip: "192.168.1.159", desc: "Reverse Proxy", type: "core", services: ["Load Balancer", "SSL Termination"] },
Dokku: { name: "Dokku PaaS", ip: "192.168.1.159", desc: "Platform as a Service", type: "core", services: ["Docker Management", "App Deployment"] },
Gitea: { name: "Gitea", ip: "192.168.1.159", hostname: "git.appmodel.nl", desc: "Self-hosted Git service", type: "core" },
Auction: { name: "Auction Stack", ip: "192.168.1.159", hostname: "auction.appmodel.nl", desc: "Auction application", type: "core" },
MI50: { name: "MI50 / Ollama", ip: "192.168.1.159", hostname: "ollama.lan", desc: "AI/ML inference server", type: "core" },
AdGuard: { name: "AdGuard DNS", desc: "Network-wide ad blocking", type: "infra", services: ["DNS Filtering", "Ad Blocking"] },
XU4: { name: "XU4 DNS", ip: "192.168.1.163", desc: "Primary DNS (Odroid XU4)", type: "infra dns" },
C2: { name: "C2 DNS", ip: "192.168.1.227", desc: "Secondary DNS (Odroid C2)", type: "infra dns" },
HA: { name: "Home Assistant", ip: "192.168.1.193", desc: "Home automation platform", type: "infra" },
Atlas: { name: "Atlas", ip: "192.168.1.100", desc: "Worker node", type: "worker", services: ["Docker", "Portainer"] },
TV: { name: "Kamer-TV", ip: "192.168.1.240", desc: "Smart TV/Chromecast", type: "iot" },
Hue: { name: "Philips Hue", ip: "192.168.1.49", desc: "Smart lighting bridge", type: "iot" },
Eufy: { name: "Eufy S380HB", ip: "192.168.1.59", desc: "Security camera", type: "iot" },
IoT: { name: "IoT Devices", desc: "Nest Mini, Roborock, ESP, Printer", type: "iot", devices: ["Nest Mini", "Roborock Vacuum", "ESP Devices", "HP Printer"] },
MIKE: { name: "MIKE PC", ip: "192.168.1.100", desc: "Workstation with tether gateway", type: "client", interfaces: ["Ethernet", "USB Tether"] },
Lotte: { name: "Lotte", ip: "192.168.1.133", desc: "Mobile device", type: "client" },
Hermes: { name: "Hermes", ip: "192.168.137.239", desc: "Tether network node", type: "worker" },
Plato: { name: "Plato", ip: "192.168.137.239", hostname: "llm.plato.lan", desc: "LLM server", type: "worker" }
};
let d2svg, simulation, zoom, g;
let selectedNode = null;
// Initialize D3 network graph
async function initD2() {
const loadingEl = document.getElementById('loading');
const container = document.getElementById('d2-output');
try {
console.log('Initializing D3 network...');
loadingEl.textContent = 'Building network topology...';
// Create graph data
const graphData = createGraphData();
console.log('Graph data created:', graphData.nodes.length, 'nodes,', graphData.links.length, 'links');
// Setup SVG - get dimensions from parent svg-container
const svgContainer = container.parentElement;
const width = svgContainer.clientWidth;
const height = svgContainer.clientHeight;
console.log('Container dimensions:', width, 'x', height);
const svg = d3.select(container)
.append('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', [0, 0, width, height]);
d2svg = svg.node();
// Add zoom behavior
zoom = d3.zoom()
.scaleExtent([0.3, 3])
.on('zoom', (event) => {
g.attr('transform', event.transform);
});
svg.call(zoom);
// Create main group for graph elements
g = svg.append('g');
// Add network zone backgrounds (will be positioned after simulation)
const zonesGroup = g.append('g').attr('class', 'zones');
const lanZone = zonesGroup.append('rect')
.attr('class', 'network-zone lan-zone')
.attr('fill', 'rgba(16, 185, 129, 0.1)');
const tetherZone = zonesGroup.append('rect')
.attr('class', 'network-zone tether-zone')
.attr('fill', 'rgba(139, 92, 246, 0.1)');
const lanLabel = zonesGroup.append('text')
.attr('class', 'zone-label')
.text('LAN 192.168.1.0/24');
const tetherLabel = zonesGroup.append('text')
.attr('class', 'zone-label')
.text('Tether 192.168.137.0/24');
// Create arrow markers for directed edges
svg.append('defs').selectAll('marker')
.data(['arrow', 'arrow-dashed'])
.join('marker')
.attr('id', d => d)
.attr('viewBox', '0 -5 10 10')
.attr('refX', 20)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', '#475569');
// Create links
const link = g.append('g')
.selectAll('path')
.data(graphData.links)
.join('path')
.attr('class', d => `link ${d.dashed ? 'dashed' : ''}`)
.attr('stroke-width', 2)
.attr('marker-end', d => `url(#${d.dashed ? 'arrow-dashed' : 'arrow'})`);
// Create nodes
const node = g.append('g')
.selectAll('g')
.data(graphData.nodes)
.join('g')
.attr('class', 'node-group')
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended));
// Add circles to nodes
node.append('circle')
.attr('class', 'node-circle')
.attr('r', d => nodeTypes[d.type]?.radius || 30)
.attr('fill', d => nodeTypes[d.type]?.fill || '#6b7280')
.attr('stroke', d => nodeTypes[d.type]?.stroke || '#9ca3af')
.attr('stroke-width', 3);
// Add icon/emoji to nodes
node.append('text')
.attr('class', 'node-icon')
.attr('text-anchor', 'middle')
.attr('dy', '.35em')
.attr('font-size', d => (nodeTypes[d.type]?.radius || 30) * 0.6)
.text(d => d.icon);
// Add labels below nodes
node.append('text')
.attr('class', 'node-label')
.attr('text-anchor', 'middle')
.attr('dy', d => (nodeTypes[d.type]?.radius || 30) + 16)
.attr('fill', '#e2e8f0')
.text(d => d.label);
// Add sublabels
node.append('text')
.attr('class', 'node-sublabel')
.attr('text-anchor', 'middle')
.attr('dy', d => (nodeTypes[d.type]?.radius || 30) + 28)
.attr('fill', '#94a3b8')
.text(d => d.sublabel || '');
// Node interactions
node.on('mouseover', function(event, d) {
const data = networkData[d.id];
if (data) {
showTooltip(event, data);
}
d3.select(this).raise();
})
.on('mouseout', function() {
hideTooltip();
})
.on('click', function(event, d) {
event.stopPropagation();
selectNode(d.id);
});
// Click on background to deselect
svg.on('click', () => {
if (selectedNode) {
d3.selectAll('.node-group').classed('selected', false);
selectedNode = null;
closePanel();
}
});
// Create force simulation
simulation = d3.forceSimulation(graphData.nodes)
.force('link', d3.forceLink(graphData.links)
.id(d => d.id)
.distance(d => d.distance || 150))
.force('charge', d3.forceManyBody()
.strength(-800))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide()
.radius(d => (nodeTypes[d.type]?.radius || 30) + 10))
.on('tick', ticked);
function ticked() {
link.attr('d', d => {
const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
const dr = Math.sqrt(dx * dx + dy * dy);
// Calculate offset for arrow
const targetRadius = nodeTypes[d.target.type]?.radius || 30;
const offsetX = (dx / dr) * targetRadius;
const offsetY = (dy / dr) * targetRadius;
return `M${d.source.x},${d.source.y}L${d.target.x - offsetX},${d.target.y - offsetY}`;
});
node.attr('transform', d => `translate(${d.x},${d.y})`);
}
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
// Hide loading
loadingEl.style.display = 'none';
console.log('D3 network graph rendered successfully!');
// Add interactivity
addInteractivity();
} catch (error) {
console.error('D3 rendering error:', error);
console.error('Stack trace:', error.stack);
loadingEl.innerHTML = `<div style="color: #ef4444;">Error: ${error.message}<br><small>Check console for details</small></div>`;
}
}
// Create graph data structure from network data
function createGraphData() {
const nodes = [
{ id: 'Internet', label: 'Internet', sublabel: '5.132.33.195', icon: '☁️', type: 'dns' },
{ id: 'Router', label: 'Zyxel Router', sublabel: '192.168.1.1', icon: '🛜', type: 'router' },
{ id: 'Traefik', label: 'Traefik', sublabel: '192.168.1.159', icon: '⚡', type: 'core' },
{ id: 'Dokku', label: 'Dokku', sublabel: '192.168.1.159', icon: '⚡', type: 'core' },
{ id: 'Gitea', label: 'Gitea', sublabel: 'git.appmodel.nl', icon: '⚡', type: 'core' },
{ id: 'Auction', label: 'Auction', sublabel: 'auction.appmodel.nl', icon: '⚡', type: 'core' },
{ id: 'MI50', label: 'MI50/Ollama', sublabel: 'ollama.lan', icon: '⚡', type: 'core' },
{ id: 'AdGuard', label: 'AdGuard', sublabel: 'DNS Filter', icon: '🔧', type: 'infra' },
{ id: 'XU4', label: 'XU4 DNS', sublabel: '192.168.1.163', icon: '🔧', type: 'infra' },
{ id: 'C2', label: 'C2 DNS', sublabel: '192.168.1.227', icon: '🔧', type: 'infra' },
{ id: 'HA', label: 'Home Assistant', sublabel: '192.168.1.193', icon: '🔧', type: 'infra' },
{ id: 'Atlas', label: 'Atlas', sublabel: '192.168.1.100', icon: '💻', type: 'worker' },
{ id: 'TV', label: 'Kamer-TV', sublabel: '192.168.1.240', icon: '📺', type: 'iot' },
{ id: 'Hue', label: 'Philips Hue', sublabel: '192.168.1.49', icon: '💡', type: 'iot' },
{ id: 'Eufy', label: 'Eufy S380HB', sublabel: '192.168.1.59', icon: '📹', type: 'iot' },
{ id: 'IoT', label: 'IoT Devices', sublabel: 'Various', icon: '🔌', type: 'iot' },
{ id: 'MIKE', label: 'MIKE PC', sublabel: '192.168.1.100', icon: '💻', type: 'client' },
{ id: 'Lotte', label: 'Lotte', sublabel: '192.168.1.133', icon: '📱', type: 'client' },
{ id: 'Hermes', label: 'Hermes', sublabel: '192.168.137.239', icon: '💻', type: 'worker' },
{ id: 'Plato', label: 'Plato', sublabel: 'llm.plato.lan', icon: '💻', type: 'worker' }
];
const links = [
// Main connections
{ source: 'Internet', target: 'Router', label: 'DNS', distance: 200 },
{ source: 'Router', target: 'Traefik', distance: 120 },
{ source: 'Router', target: 'Dokku', distance: 120 },
{ source: 'Router', target: 'Gitea', distance: 120 },
{ source: 'Router', target: 'Auction', distance: 120 },
{ source: 'Router', target: 'MI50', distance: 120 },
{ source: 'Router', target: 'AdGuard', distance: 140 },
{ source: 'Router', target: 'XU4', distance: 140 },
{ source: 'Router', target: 'C2', distance: 140 },
{ source: 'Router', target: 'HA', distance: 140 },
{ source: 'Router', target: 'Atlas', distance: 140 },
{ source: 'Router', target: 'TV', distance: 160 },
{ source: 'Router', target: 'Hue', distance: 160 },
{ source: 'Router', target: 'Eufy', distance: 160 },
{ source: 'Router', target: 'IoT', distance: 160 },
{ source: 'Router', target: 'MIKE', distance: 140 },
{ source: 'Router', target: 'Lotte', distance: 160 },
// Proxy connections
{ source: 'Traefik', target: 'Gitea', distance: 80 },
{ source: 'Traefik', target: 'Dokku', distance: 80 },
{ source: 'Traefik', target: 'Auction', distance: 80 },
// Tether connections
{ source: 'MIKE', target: 'Hermes', distance: 100 },
{ source: 'MIKE', target: 'Plato', distance: 100 },
// DNS filtering (dashed)
{ source: 'XU4', target: 'AdGuard', dashed: true, distance: 100 },
{ source: 'C2', target: 'AdGuard', dashed: true, distance: 100 },
{ source: 'Atlas', target: 'AdGuard', dashed: true, distance: 120 },
{ source: 'HA', target: 'AdGuard', dashed: true, distance: 120 },
{ source: 'Hermes', target: 'AdGuard', dashed: true, distance: 150 },
{ source: 'Plato', target: 'AdGuard', dashed: true, distance: 150 }
];
return { nodes, links };
}
function addInteractivity() {
// Legend toggle
document.querySelectorAll('.legend-item').forEach(item => {
item.addEventListener('click', () => {
const type = item.dataset.type;
toggleNodeType(type);
});
});
}
function selectNode(nodeId) {
if (!d2svg) return;
// Highlight selected node
d3.selectAll('.node-group').classed('selected', false);
d3.selectAll('.node-group')
.filter(d => d.id === nodeId)
.classed('selected', true);
selectedNode = nodeId;
showDetailPanel(nodeId);
}
function showTooltip(event, data) {
const tooltip = document.getElementById('tooltip');
const services = data.services ? data.services.join(', ') : '';
const devices = data.devices ? data.devices.join(', ') : '';
tooltip.innerHTML = `
<strong>${data.name}</strong><br>
${data.ip ? `IP: ${data.ip}<br>` : ''}
${data.hostname ? `Host: ${data.hostname}<br>` : ''}
${data.desc ? `${data.desc}<br>` : ''}
${services ? `<small>Services: ${services}</small><br>` : ''}
${devices ? `<small>Devices: ${devices}</small>` : ''}
`;
tooltip.style.left = event.clientX + 15 + 'px';
tooltip.style.top = event.clientY - 10 + 'px';
tooltip.classList.add('show');
}
function hideTooltip() {
document.getElementById('tooltip').classList.remove('show');
}
function highlightConnections(nodeId) {
if (!d2svg) return;
d2svg.querySelectorAll('.connection').forEach(conn => {
conn.style.stroke = '#60a5fa';
conn.style.strokeWidth = '3';
});
}
function unhighlightConnections() {
if (!d2svg) return;
d2svg.querySelectorAll('.connection').forEach(conn => {
conn.style.stroke = '';
conn.style.strokeWidth = '';
});
}
function showDetailPanel(nodeId) {
const data = networkData[nodeId];
if (!data) return;
const panel = document.getElementById('detail-panel');
const overlay = document.getElementById('overlay');
const title = document.getElementById('detail-title');
const content = document.getElementById('detail-content');
title.textContent = data.name;
let html = `
<div class="detail-section">
<p>${data.desc || 'No description available'}</p>
</div>
`;
if (data.ip || data.hostname) {
html += `
<div class="detail-section">
<h3>📡 Network</h3>
<div class="detail-grid">
${data.ip ? `<div class="detail-item"><strong>IP:</strong><br><code class="tag">${data.ip}</code></div>` : ''}
${data.hostname ? `<div class="detail-item"><strong>Hostname:</strong><br><code class="tag">${data.hostname}</code></div>` : ''}
${data.type ? `<div class="detail-item"><strong>Type:</strong><br><span class="tag">${data.type}</span></div>` : ''}
</div>
</div>
`;
}
if (data.services && data.services.length > 0) {
html += `
<div class="detail-section">
<h3>🚀 Services</h3>
${data.services.map(s => `<span class="tag">${s}</span>`).join('')}
</div>
`;
}
if (data.interfaces && data.interfaces.length > 0) {
html += `
<div class="detail-section">
<h3>🔌 Interfaces</h3>
${data.interfaces.map(i => `<span class="tag">${i}</span>`).join('')}
</div>
`;
}
if (data.devices && data.devices.length > 0) {
html += `
<div class="detail-section">
<h3>📱 Sub-Devices</h3>
${data.devices.map(d => `<span class="tag">${d}</span>`).join('')}
</div>
`;
}
const related = getRelatedNodes(nodeId);
if (related.length > 0) {
html += `
<div class="detail-section">
<h3>🔗 Connections</h3>
<div class="detail-grid">
`;
related.forEach(rel => {
html += `
<div class="detail-item">
<strong>→ ${rel.name}</strong><br>
<small>${rel.label || 'Connected'}</small>
</div>
`;
});
html += `
</div>
</div>
`;
}
content.innerHTML = html;
overlay.classList.add('show');
panel.classList.add('show');
}
function getRelatedNodes(nodeId) {
const relationships = {
Router: ['Traefik', 'XU4', 'Atlas', 'TV', 'Hue', 'Eufy', 'IoT', 'MIKE', 'Lotte', 'AdGuard', 'C2', 'HA'],
Traefik: ['Gitea', 'Dokku', 'Auction'],
MIKE: ['Hermes', 'Plato'],
XU4: ['AdGuard'],
C2: ['AdGuard'],
Atlas: ['AdGuard'],
HA: ['AdGuard'],
Hermes: ['AdGuard'],
Plato: ['AdGuard']
};
const related = relationships[nodeId] || [];
return related.map(id => ({ name: networkData[id]?.name || id, label: 'routes' }));
}
function closePanel() {
document.getElementById('overlay').classList.remove('show');
document.getElementById('detail-panel').classList.remove('show');
if (d2svg) {
d2svg.querySelectorAll('.node').forEach(n => n.classList.remove('selected'));
}
}
function toggleNodeType(type) {
if (!d2svg) return;
d3.selectAll('.node-group')
.filter(d => d.type === type)
.transition()
.duration(300)
.style('opacity', function() {
const current = d3.select(this).style('opacity');
return current === '0' ? '1' : '0';
})
.style('pointer-events', function() {
const current = d3.select(this).style('opacity');
return current === '0' ? 'all' : 'none';
});
}
function resetView() {
if (!d2svg || !zoom) return;
d3.select(d2svg)
.transition()
.duration(750)
.call(zoom.transform, d3.zoomIdentity);
closePanel();
if (simulation) simulation.alpha(0.3).restart();
}
function zoomIn() {
if (!d2svg || !zoom) return;
d3.select(d2svg)
.transition()
.duration(300)
.call(zoom.scaleBy, 1.3);
}
function zoomOut() {
if (!d2svg || !zoom) return;
d3.select(d2svg)
.transition()
.duration(300)
.call(zoom.scaleBy, 0.7);
}
function fitToScreen() {
resetView();
}
function toggleAnimation() {
if (!simulation) return;
// Reheat the simulation for animated rearrangement
simulation.alpha(0.5).restart();
}
function exportSVG() {
if (!d2svg) return;
const svgData = new XMLSerializer().serializeToString(d2svg);
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'network-topology.svg';
a.click();
URL.revokeObjectURL(url);
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initD2);
} else {
initD2();
}
</script>
</body>
</html>

151
public/network.d2 Normal file
View 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

File diff suppressed because it is too large Load Diff

933
public/top.html Normal file
View 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>