1295 lines
46 KiB
HTML
1295 lines
46 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="nl">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Structurizr Network Topology</title>
|
||
<script src="https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.1/dist/svg-pan-zoom.min.js"></script>
|
||
<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;
|
||
}
|
||
|
||
.main-svg {
|
||
flex: 1;
|
||
width: 100%;
|
||
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.08);
|
||
filter: drop-shadow(0 0 15px currentColor);
|
||
}
|
||
|
||
.node.selected {
|
||
transform: scale(1.12);
|
||
filter: drop-shadow(0 0 20px currentColor);
|
||
}
|
||
|
||
.node-circle {
|
||
stroke-width: 2;
|
||
transition: stroke-width 0.3s ease, stroke 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;
|
||
fill: #f1f5f9;
|
||
}
|
||
|
||
.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;
|
||
transition: stroke 0.3s ease;
|
||
}
|
||
|
||
.subnet-container:hover {
|
||
stroke: #475569;
|
||
}
|
||
|
||
.subnet-label {
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
fill: #94a3b8;
|
||
text-anchor: middle;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* 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;
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 6px;
|
||
cursor: pointer;
|
||
transition: opacity 0.3s ease;
|
||
padding: 4px 8px;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.legend-item:hover {
|
||
opacity: 0.9;
|
||
background: rgba(71, 85, 105, 0.3);
|
||
}
|
||
|
||
.legend-color {
|
||
width: 14px;
|
||
height: 14px;
|
||
border-radius: 50%;
|
||
border: 2px solid;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* 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:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.tag.warning {
|
||
color: #f87171;
|
||
border-color: rgba(248, 113, 113, 0.3);
|
||
background: rgba(248, 113, 113, 0.1);
|
||
}
|
||
|
||
.tag.success {
|
||
color: #34d399;
|
||
border-color: rgba(52, 211, 153, 0.3);
|
||
background: rgba(52, 211, 153, 0.1);
|
||
}
|
||
|
||
.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 */
|
||
.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%, 100% { opacity: 1; }
|
||
50% { opacity: 0.7; }
|
||
}
|
||
|
||
.node.active .node-circle {
|
||
animation: pulse 2s infinite;
|
||
}
|
||
|
||
/* Scrollbar styling */
|
||
.detail-panel::-webkit-scrollbar {
|
||
width: 8px;
|
||
}
|
||
|
||
.detail-panel::-webkit-scrollbar-track {
|
||
background: rgba(15, 23, 42, 0.5);
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.detail-panel::-webkit-scrollbar-thumb {
|
||
background: #475569;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.detail-panel::-webkit-scrollbar-thumb:hover {
|
||
background: #60a5fa;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="header">
|
||
<h1>🌐 Structurizr 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>
|
||
|
||
<svg class="main-svg" id="network-svg" viewBox="0 0 1600 900">
|
||
<!-- Definitions -->
|
||
<defs>
|
||
<!-- Gradients -->
|
||
<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>
|
||
|
||
<!-- Glow filter -->
|
||
<filter id="glow">
|
||
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
|
||
<feMerge>
|
||
<feMergeNode in="coloredBlur"/>
|
||
<feMergeNode in="SourceGraphic"/>
|
||
</feMerge>
|
||
</filter>
|
||
|
||
<!-- Arrow marker -->
|
||
<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>
|
||
|
||
<!-- 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>
|
||
|
||
<!-- Node groups will be generated by JavaScript -->
|
||
<g id="nodes-group"></g>
|
||
<g id="connections-group"></g>
|
||
</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>
|
||
|
||
<!-- Structurizr DSL Model -->
|
||
<script type="text/x-structurizr" id="structurizr-dsl">
|
||
workspace "Home Network" {
|
||
model {
|
||
person mike "Mike" "Network Administrator" {
|
||
tags client
|
||
}
|
||
person lotte "Lotte" "User" {
|
||
tags client
|
||
}
|
||
|
||
softwareSystem internet "Internet" "Cloudflare DNS<br/>5.132.33.195" {
|
||
tags internet
|
||
}
|
||
|
||
softwareSystem router "Zyxel VMG8825-T50" "Main gateway router<br/>hub.lan<br/>192.168.1.1" {
|
||
tags router
|
||
}
|
||
|
||
deploymentNode lan "LAN 192.168.1.0/24" {
|
||
deploymentNode coreGroup "Core_Server_TOUR" {
|
||
container traefik "Traefik" "Reverse Proxy" {
|
||
tags core
|
||
}
|
||
container dokku "Dokku PaaS" "Platform as a Service" {
|
||
tags core
|
||
}
|
||
container gitea "Gitea" "Git service" {
|
||
tags core
|
||
url "git.appmodel.nl"
|
||
}
|
||
container auction "Auction Stack" "Auction application" {
|
||
tags core
|
||
url "auction.appmodel.nl"
|
||
}
|
||
container mi50 "MI50 / Ollama" "AI/ML inference" {
|
||
tags core
|
||
url "ollama.lan"
|
||
}
|
||
}
|
||
|
||
deploymentNode infraGroup "Infra_DNS" {
|
||
container xu4 "XU4 DNS" "Primary DNS (Odroid XU4)<br/>192.168.1.163" {
|
||
tags infra dns
|
||
}
|
||
container cu2 "C2 DNS" "Secondary DNS (Odroid C2)<br/>192.168.1.227" {
|
||
tags infra dns
|
||
}
|
||
container adguard "AdGuard DNS" "Network-wide ad blocking" {
|
||
tags infra dns
|
||
}
|
||
}
|
||
|
||
container atlas "Atlas" "Worker node<br/>192.168.1.100" {
|
||
tags worker
|
||
services "Docker, Portainer"
|
||
}
|
||
|
||
container ha "Home Assistant" "Home automation<br/>192.168.1.193" {
|
||
tags infra
|
||
}
|
||
|
||
container iot_tv "Kamer-TV" "Smart TV<br/>192.168.1.240" {
|
||
tags iot
|
||
}
|
||
|
||
container iot_hue "Philips Hue" "Smart lighting<br/>192.168.1.49" {
|
||
tags iot
|
||
}
|
||
|
||
container iot_eufy "Eufy S380HB" "Security camera<br/>192.168.1.59" {
|
||
tags iot
|
||
}
|
||
|
||
container iot_misc "IoT Devices" "Nest Mini, Roborock, ESP, Printer" {
|
||
tags iot
|
||
}
|
||
|
||
container clientMike "MIKE PC" "Workstation<br/>192.168.1.100<br/>LAN + Tether gateway" {
|
||
tags client
|
||
}
|
||
|
||
container clientLotte "Lotte" "Mobile device<br/>192.168.1.133" {
|
||
tags client
|
||
}
|
||
}
|
||
|
||
deploymentNode tether "Tether_LAN_192.168.137" {
|
||
container hermes "Hermes" "Tether node<br/>192.168.137.239" {
|
||
tags worker
|
||
}
|
||
|
||
container plato "Plato" "LLM server<br/>192.168.137.239<br/>llm.plato.lan" {
|
||
tags worker
|
||
}
|
||
}
|
||
|
||
# Relationships
|
||
internet -> router "provides DNS"
|
||
router -> traefik "routes traffic"
|
||
router -> xu4 "routes traffic"
|
||
router -> atlas "routes traffic"
|
||
router -> ha "routes traffic"
|
||
router -> iot_tv "routes traffic"
|
||
router -> iot_hue "routes traffic"
|
||
router -> iot_eufy "routes traffic"
|
||
router -> iot_misc "routes traffic"
|
||
router -> clientMike "routes traffic"
|
||
router -> clientLotte "routes traffic"
|
||
|
||
clientMike -> hermes "tether bridge"
|
||
clientMike -> plato "tether bridge"
|
||
|
||
traefik -> gitea "proxies"
|
||
traefik -> dokku "proxies"
|
||
traefik -> auction "proxies"
|
||
|
||
# DNS flows (dashed)
|
||
xu4 -.-> adguard "filters"
|
||
cu2 -.-> adguard "filters"
|
||
atlas -.-> adguard "uses"
|
||
ha -.-> adguard "uses"
|
||
hermes -.-> adguard "uses"
|
||
plato -.-> adguard "uses"
|
||
}
|
||
|
||
views {
|
||
systemLandscape networkDiagram {
|
||
include *
|
||
autolayout lr
|
||
}
|
||
}
|
||
|
||
styles {
|
||
element router {
|
||
color #f59e0b
|
||
shape rectangle
|
||
}
|
||
element internet {
|
||
color #0ea5e9
|
||
shape ellipse
|
||
}
|
||
element core {
|
||
color #ef4444
|
||
shape hexagon
|
||
}
|
||
element infra {
|
||
color #10b981
|
||
shape rectangle
|
||
}
|
||
element worker {
|
||
color #8b5cf6
|
||
shape hexagon
|
||
}
|
||
element iot {
|
||
color #f97316
|
||
shape roundedbox
|
||
}
|
||
element client {
|
||
color #6b7280
|
||
shape person
|
||
}
|
||
element dns {
|
||
icon cloud
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<script>
|
||
// Model parser and diagram generator
|
||
class StructurizrParser {
|
||
constructor(dsl) {
|
||
this.dsl = dsl;
|
||
this.model = { nodes: [], relationships: [] };
|
||
this.styles = {};
|
||
this.parse();
|
||
}
|
||
|
||
parse() {
|
||
// Extract model block
|
||
const modelMatch = this.dsl.match(/model\s*{([\s\S]*?)^\s*}/m);
|
||
if (!modelMatch) return;
|
||
|
||
const modelContent = modelMatch[1];
|
||
|
||
// Parse styles first
|
||
this.parseStyles();
|
||
|
||
// Parse people
|
||
this.parsePeople(modelContent);
|
||
|
||
// Parse software systems
|
||
this.parseSoftwareSystems(modelContent);
|
||
|
||
// Parse deployment nodes
|
||
this.parseDeploymentNodes(modelContent);
|
||
|
||
// Parse relationships
|
||
this.parseRelationships(modelContent);
|
||
}
|
||
|
||
parseStyles() {
|
||
const stylesMatch = this.dsl.match(/styles\s*{([\s\S]*?)^\s*}/m);
|
||
if (!stylesMatch) return;
|
||
|
||
const stylesContent = stylesMatch[1];
|
||
const elementRegex = /element\s+(\w+)\s*{([^}]+)}/g;
|
||
let match;
|
||
|
||
while ((match = elementRegex.exec(stylesContent)) !== null) {
|
||
const [, type, props] = match;
|
||
this.styles[type] = {};
|
||
|
||
const colorMatch = props.match(/color\s+#([0-9a-fA-F]{6})/);
|
||
if (colorMatch) this.styles[type].color = `#${colorMatch[1]}`;
|
||
|
||
const shapeMatch = props.match(/shape\s+(\w+)/);
|
||
if (shapeMatch) this.styles[type].shape = shapeMatch[1];
|
||
}
|
||
}
|
||
|
||
parsePeople(content) {
|
||
const personRegex = /person\s+(\w+)\s+"([^"]+)"\s+"([^"]+)"\s*{([^}]*)}/g;
|
||
let match;
|
||
|
||
while ((match = personRegex.exec(content)) !== null) {
|
||
const [, id, name, desc, block] = match;
|
||
const tagsMatch = block.match(/tags\s+(.+)/);
|
||
|
||
this.model.nodes.push({
|
||
id,
|
||
name,
|
||
description: desc,
|
||
type: tagsMatch ? tagsMatch[1].split(/\s+/)[0] : 'person',
|
||
category: 'person'
|
||
});
|
||
}
|
||
}
|
||
|
||
parseSoftwareSystems(content) {
|
||
const systemRegex = /softwareSystem\s+(\w+)\s+"([^"]+)"\s+"([^"]+)"\s*{([^}]*)}/g;
|
||
let match;
|
||
|
||
while ((match = systemRegex.exec(content)) !== null) {
|
||
const [, id, name, desc, block] = match;
|
||
const tagsMatch = block.match(/tags\s+(.+)/);
|
||
|
||
this.model.nodes.push({
|
||
id,
|
||
name,
|
||
description: desc,
|
||
type: tagsMatch ? tagsMatch[1] : 'system',
|
||
category: 'softwareSystem'
|
||
});
|
||
}
|
||
}
|
||
|
||
parseDeploymentNodes(content) {
|
||
const nodeRegex = /deploymentNode\s+(\w+)\s+"([^"]+)"\s*{([\s\S]*?)^\s*}/gm;
|
||
let nodeMatch;
|
||
|
||
while ((nodeMatch = nodeRegex.exec(content)) !== null) {
|
||
const [, id, name, nodeContent] = nodeMatch;
|
||
|
||
// Parse child containers
|
||
this.parseContainers(id, nodeContent);
|
||
}
|
||
}
|
||
|
||
parseContainers(parentId, content) {
|
||
const containerRegex = /container\s+(\w+)\s+"([^"]+)"\s+"([^"]+)"\s*{([^}]*)}/g;
|
||
let match;
|
||
|
||
while ((match = containerRegex.exec(content)) !== null) {
|
||
const [, id, name, desc, block] = match;
|
||
const tagsMatch = block.match(/tags\s+(.+)/);
|
||
const servicesMatch = block.match(/services\s+"([^"]+)"/);
|
||
const ipMatch = desc.match(/(\d+\.\d+\.\d+\.\d+)/);
|
||
const urlMatch = block.match(/url\s+"([^"]+)"/);
|
||
|
||
this.model.nodes.push({
|
||
id,
|
||
name,
|
||
description: desc,
|
||
type: tagsMatch ? tagsMatch[1].split(/\s+/)[0] : 'container',
|
||
category: 'container',
|
||
parent: parentId,
|
||
ip: ipMatch ? ipMatch[1] : null,
|
||
url: urlMatch ? urlMatch[1] : null,
|
||
services: servicesMatch ? servicesMatch[1].split(', ') : []
|
||
});
|
||
}
|
||
}
|
||
|
||
parseRelationships(content) {
|
||
const relRegex = /(\w+)\s*(-\.?>)?\s*(\w+)\s*"([^"]+)"/g;
|
||
let match;
|
||
|
||
while ((match = relRegex.exec(content)) !== null) {
|
||
const [, from, dashed, to, label] = match;
|
||
this.model.relationships.push({
|
||
from,
|
||
to,
|
||
label,
|
||
style: dashed ? 'dashed' : 'solid'
|
||
});
|
||
}
|
||
}
|
||
|
||
getNodeStyle(type) {
|
||
const style = this.styles[type] || {};
|
||
const colors = {
|
||
router: '#f59e0b',
|
||
internet: '#0ea5e9',
|
||
core: '#ef4444',
|
||
infra: '#10b981',
|
||
worker: '#8b5cf6',
|
||
iot: '#f97316',
|
||
client: '#6b7280',
|
||
dns: '#0ea5e9'
|
||
};
|
||
|
||
return {
|
||
color: style.color || colors[type] || '#6b7280',
|
||
shape: style.shape || 'circle'
|
||
};
|
||
}
|
||
}
|
||
|
||
// Diagram generator
|
||
class TopologyGenerator {
|
||
constructor(svg, model, styles) {
|
||
this.svg = svg;
|
||
this.model = model;
|
||
this.styles = styles;
|
||
this.nodePositions = new Map();
|
||
this.panZoom = null;
|
||
this.selectedNode = null;
|
||
this.init();
|
||
}
|
||
|
||
init() {
|
||
this.generateLayout();
|
||
this.render();
|
||
this.setupPanZoom();
|
||
this.setupEventListeners();
|
||
}
|
||
|
||
generateLayout() {
|
||
// Predefined layout based on the DSL structure
|
||
const layout = {
|
||
internet: { x: 800, y: 50, group: null },
|
||
router: { x: 800, y: 150, group: null },
|
||
|
||
// Core group
|
||
traefik: { x: 200, y: 300, group: 'core' },
|
||
dokku: { x: 350, y: 300, group: 'core' },
|
||
gitea: { x: 275, y: 380, group: 'core' },
|
||
auction: { x: 150, y: 380, group: 'core' },
|
||
mi50: { x: 400, y: 380, group: 'core' },
|
||
|
||
// Infra group
|
||
xu4: { x: 600, y: 320, group: 'infra' },
|
||
cu2: { x: 750, y: 320, group: 'infra' },
|
||
adguard: { x: 675, y: 400, group: 'infra' },
|
||
|
||
// Workers and clients
|
||
atlas: { x: 900, y: 320, group: null },
|
||
ha: { x: 1050, y: 320, group: null },
|
||
|
||
// IoT
|
||
iot_tv: { x: 200, y: 550, group: null },
|
||
iot_hue: { x: 350, y: 550, group: null },
|
||
iot_eufy: { x: 500, y: 550, group: null },
|
||
iot_misc: { x: 650, y: 550, group: null },
|
||
|
||
// Clients
|
||
clientMike: { x: 900, y: 550, group: null },
|
||
clientLotte: { x: 1100, y: 550, group: null },
|
||
|
||
// Tether
|
||
hermes: { x: 1300, y: 500, group: null },
|
||
plato: { x: 1450, y: 500, group: null }
|
||
};
|
||
|
||
this.model.nodes.forEach(node => {
|
||
const pos = layout[node.id];
|
||
if (pos) {
|
||
this.nodePositions.set(node.id, pos);
|
||
} else {
|
||
// Fallback positioning
|
||
this.nodePositions.set(node.id, {
|
||
x: 800 + Math.random() * 200,
|
||
y: 400 + Math.random() * 200,
|
||
group: null
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
render() {
|
||
this.renderNodes();
|
||
this.renderRelationships();
|
||
}
|
||
|
||
renderNodes() {
|
||
const nodesGroup = document.getElementById('nodes-group');
|
||
nodesGroup.innerHTML = '';
|
||
|
||
this.model.nodes.forEach(node => {
|
||
const pos = this.nodePositions.get(node.id);
|
||
if (!pos) return;
|
||
|
||
const style = this.styles[node.type] || {};
|
||
const colors = {
|
||
router: '#f59e0b',
|
||
internet: '#0ea5e9',
|
||
core: '#ef4444',
|
||
infra: '#10b981',
|
||
worker: '#8b5cf6',
|
||
iot: '#f97316',
|
||
client: '#6b7280',
|
||
dns: '#0ea5e9'
|
||
};
|
||
const color = colors[node.type] || '#6b7280';
|
||
const gradientId = `gradient-${node.type}`;
|
||
|
||
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||
g.classList.add('node');
|
||
g.setAttribute('data-id', node.id);
|
||
g.setAttribute('data-type', node.type);
|
||
g.setAttribute('transform', `translate(${pos.x}, ${pos.y})`);
|
||
|
||
// Create shape based on type
|
||
if (node.type === 'router' || node.id === 'clientMike') {
|
||
// Rectangle for router and MIKE PC
|
||
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||
rect.classList.add('node-circle');
|
||
rect.setAttribute('x', '-35');
|
||
rect.setAttribute('y', '-20');
|
||
rect.setAttribute('width', '70');
|
||
rect.setAttribute('height', '40');
|
||
rect.setAttribute('rx', '5');
|
||
rect.setAttribute('fill', `url(#${gradientId})`);
|
||
rect.setAttribute('stroke', color);
|
||
g.appendChild(rect);
|
||
} else {
|
||
// Circle for others
|
||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||
circle.classList.add('node-circle');
|
||
circle.setAttribute('r', node.type === 'core' || node.type === 'infra' ? '20' : '20');
|
||
circle.setAttribute('fill', `url(#${gradientId})`);
|
||
circle.setAttribute('stroke', color);
|
||
g.appendChild(circle);
|
||
}
|
||
|
||
// Add text
|
||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||
text.classList.add('node-text');
|
||
text.setAttribute('y', '5');
|
||
const icon = node.category === 'person' ? '👤 ' :
|
||
node.type === 'router' ? '🛜 ' :
|
||
node.type === 'internet' ? '☁️ ' :
|
||
node.type === 'iot' ? '📱 ' :
|
||
node.type === 'worker' ? '💻 ' :
|
||
node.type === 'infra' ? '🔧 ' :
|
||
node.type === 'core' ? '⚡ ' : '🖥️ ';
|
||
text.textContent = icon + node.name;
|
||
g.appendChild(text);
|
||
|
||
// Add subtext for IP/hostname
|
||
if (node.ip || node.url) {
|
||
const subtext = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||
subtext.classList.add('node-subtext');
|
||
subtext.setAttribute('y', node.type === 'router' || node.id === 'clientMike' ? '50' : '30');
|
||
subtext.textContent = node.ip || node.url;
|
||
g.appendChild(subtext);
|
||
}
|
||
|
||
// Add description subtext if exists
|
||
if (node.description && node.description.length > 20) {
|
||
const descText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||
descText.classList.add('node-subtext');
|
||
descText.setAttribute('y', node.type === 'router' || node.id === 'clientMike' ? '65' : '45');
|
||
descText.textContent = node.description.substring(0, 30) + '...';
|
||
g.appendChild(descText);
|
||
}
|
||
|
||
nodesGroup.appendChild(g);
|
||
});
|
||
}
|
||
|
||
renderRelationships() {
|
||
const connectionsGroup = document.getElementById('connections-group');
|
||
connectionsGroup.innerHTML = '';
|
||
|
||
this.model.relationships.forEach(rel => {
|
||
const fromPos = this.nodePositions.get(rel.from);
|
||
const toPos = this.nodePositions.get(rel.to);
|
||
|
||
if (!fromPos || !toPos) return;
|
||
|
||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
path.classList.add('connection');
|
||
if (rel.style === 'dashed') {
|
||
path.setAttribute('stroke-dasharray', '5,5');
|
||
}
|
||
|
||
// Simple straight line for now
|
||
path.setAttribute('d', `M ${fromPos.x} ${fromPos.y} L ${toPos.x} ${toPos.y}`);
|
||
path.setAttribute('marker-end', 'url(#arrowhead)');
|
||
|
||
connectionsGroup.appendChild(path);
|
||
});
|
||
}
|
||
|
||
setupPanZoom() {
|
||
this.panZoom = svgPanZoom('#network-svg', {
|
||
zoomEnabled: true,
|
||
controlIconsEnabled: false,
|
||
fit: false,
|
||
center: false,
|
||
minZoom: 0.5,
|
||
maxZoom: 3,
|
||
dblClickZoomEnabled: true,
|
||
mouseWheelZoomEnabled: true,
|
||
preventMouseEventsDefault: false
|
||
});
|
||
|
||
// Custom click/drag handling
|
||
let isDragging = false;
|
||
let dragThreshold = 5;
|
||
let startPos = { x: 0, y: 0 };
|
||
let currentPos = { x: 0, y: 0 };
|
||
|
||
this.svg.addEventListener('mousedown', (e) => {
|
||
if (e.target.classList.contains('node-circle')) {
|
||
isDragging = false;
|
||
startPos = { x: e.clientX, y: e.clientY };
|
||
}
|
||
});
|
||
|
||
this.svg.addEventListener('mousemove', (e) => {
|
||
if (e.buttons === 1 && !isDragging) {
|
||
currentPos = { x: e.clientX, y: e.clientY };
|
||
const distance = Math.sqrt(
|
||
Math.pow(currentPos.x - startPos.x, 2) +
|
||
Math.pow(currentPos.y - startPos.y, 2)
|
||
);
|
||
if (distance > dragThreshold) {
|
||
isDragging = true;
|
||
}
|
||
}
|
||
});
|
||
|
||
this.svg.addEventListener('mouseup', (e) => {
|
||
if (!isDragging && e.target.classList.contains('node-circle')) {
|
||
const nodeEl = e.target.closest('.node');
|
||
const nodeId = nodeEl.dataset.id;
|
||
this.selectNode(nodeId);
|
||
}
|
||
isDragging = false;
|
||
});
|
||
}
|
||
|
||
setupEventListeners() {
|
||
// Node hover and click
|
||
this.svg.addEventListener('mouseover', (e) => {
|
||
if (e.target.classList.contains('node-circle')) {
|
||
const nodeEl = e.target.closest('.node');
|
||
const nodeId = nodeEl.dataset.id;
|
||
const data = this.model.nodes.find(n => n.id === nodeId);
|
||
if (data) {
|
||
this.showTooltip(e, data);
|
||
this.highlightConnections(nodeId);
|
||
}
|
||
}
|
||
});
|
||
|
||
this.svg.addEventListener('mouseout', (e) => {
|
||
if (e.target.classList.contains('node-circle')) {
|
||
this.hideTooltip();
|
||
this.unhighlightConnections();
|
||
}
|
||
});
|
||
|
||
// Legend toggle
|
||
document.querySelectorAll('.legend-item').forEach(item => {
|
||
item.addEventListener('click', () => {
|
||
const type = item.dataset.type;
|
||
this.toggleNodeType(type);
|
||
});
|
||
});
|
||
}
|
||
|
||
selectNode(nodeId) {
|
||
document.querySelectorAll('.node').forEach(n => n.classList.remove('selected'));
|
||
|
||
const nodeEl = document.querySelector(`[data-id="${nodeId}"]`);
|
||
if (nodeEl) {
|
||
nodeEl.classList.add('selected');
|
||
this.selectedNode = nodeId;
|
||
this.showDetailPanel(nodeId);
|
||
}
|
||
}
|
||
|
||
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.url ? `URL: ${data.url}<br>` : ''}
|
||
${data.description ? `${data.description}<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');
|
||
}
|
||
|
||
hideTooltip() {
|
||
document.getElementById('tooltip').classList.remove('show');
|
||
}
|
||
|
||
highlightConnections(nodeId) {
|
||
// For simplicity, highlight all connections
|
||
document.querySelectorAll('.connection').forEach(conn => {
|
||
conn.style.stroke = '#60a5fa';
|
||
conn.style.strokeWidth = '3';
|
||
});
|
||
}
|
||
|
||
unhighlightConnections() {
|
||
document.querySelectorAll('.connection').forEach(conn => {
|
||
conn.style.stroke = '';
|
||
conn.style.strokeWidth = '';
|
||
});
|
||
}
|
||
|
||
showDetailPanel(nodeId) {
|
||
const data = this.model.nodes.find(n => n.id === 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.description || 'No description available'}</p>
|
||
</div>
|
||
`;
|
||
|
||
if (data.ip || data.url) {
|
||
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.url ? `<div class="detail-item"><strong>URL:</strong><br><code class="tag">${data.url}</code></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>
|
||
`;
|
||
}
|
||
|
||
// Show relationships
|
||
const related = this.model.relationships.filter(r => r.from === nodeId || r.to === nodeId);
|
||
if (related.length > 0) {
|
||
html += `
|
||
<div class="detail-section">
|
||
<h3>🔗 Connections</h3>
|
||
<div class="detail-grid">
|
||
`;
|
||
related.forEach(rel => {
|
||
const otherId = rel.from === nodeId ? rel.to : rel.from;
|
||
const otherNode = this.model.nodes.find(n => n.id === otherId);
|
||
if (otherNode) {
|
||
const direction = rel.from === nodeId ? '→' : '←';
|
||
html += `
|
||
<div class="detail-item">
|
||
<strong>${direction} ${otherNode.name}</strong><br>
|
||
<small>${rel.label}</small>
|
||
</div>
|
||
`;
|
||
}
|
||
});
|
||
html += `
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
content.innerHTML = html;
|
||
overlay.classList.add('show');
|
||
panel.classList.add('show');
|
||
}
|
||
|
||
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';
|
||
});
|
||
}
|
||
|
||
reset() {
|
||
if (this.panZoom) {
|
||
this.panZoom.reset();
|
||
}
|
||
closePanel();
|
||
}
|
||
|
||
toggleAnimation() {
|
||
document.querySelectorAll('.node[data-type="core"], .node[data-type="infra"]').forEach(node => {
|
||
node.classList.toggle('active');
|
||
});
|
||
}
|
||
|
||
export() {
|
||
const svgData = new XMLSerializer().serializeToString(this.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);
|
||
}
|
||
}
|
||
|
||
// Global functions
|
||
let generator;
|
||
|
||
function resetView() {
|
||
generator.reset();
|
||
}
|
||
|
||
function toggleAnimation() {
|
||
generator.toggleAnimation();
|
||
}
|
||
|
||
function exportSVG() {
|
||
generator.export();
|
||
}
|
||
|
||
function closePanel() {
|
||
document.getElementById('overlay').classList.remove('show');
|
||
document.getElementById('detail-panel').classList.remove('show');
|
||
document.querySelectorAll('.node').forEach(n => n.classList.remove('selected'));
|
||
}
|
||
|
||
// Initialize
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const dslScript = document.getElementById('structurizr-dsl');
|
||
const parser = new StructurizrParser(dslScript.textContent);
|
||
const svg = document.getElementById('network-svg');
|
||
|
||
generator = new TopologyGenerator(svg, parser.model, parser.styles);
|
||
});
|
||
</script>
|
||
</body>
|
||
</html> |