Sync network redesign repo state and cutover artifacts

This commit is contained in:
Fizzlepoof
2026-05-22 20:16:21 +00:00
parent 4b1e3d4061
commit 8c72a0c287
59 changed files with 5389 additions and 244 deletions

View File

@@ -0,0 +1,641 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Doris Network Web</title>
<style>
:root{
color-scheme:dark;
--bg:#06080d;
--panel:#101620;
--panel-soft:#151d2a;
--line:rgba(239,233,219,.10);
--text:#f2efdd;
--muted:#c7bba8;
--trusted:#6ea8fe;
--mgmt:#f2c14e;
--servers:#e07a5f;
--iot:#7bd389;
--guest:#b392f0;
--camera:#ff7b72;
--legacy:#7f8796;
--topology:#7dd3fc;
--flow:#f87171;
--allow:#7bd389;
--deny:#ff7b72;
--migrate:#f2c14e;
--shadow:0 22px 52px rgba(0,0,0,.36);
}
*{box-sizing:border-box}
body{
margin:0;
font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
background:
radial-gradient(circle at 10% 0%, rgba(242,193,78,.12), transparent 22rem),
radial-gradient(circle at 100% 10%, rgba(110,168,254,.10), transparent 24rem),
radial-gradient(circle at 60% 100%, rgba(224,122,95,.10), transparent 22rem),
linear-gradient(180deg,#06080d 0%,#0a1018 50%,#0d131d 100%);
color:var(--text);
min-height:100vh;
}
a{color:inherit}
.wrap{max-width:1680px;margin:0 auto;padding:20px}
.hero,.canvas-shell,.panel,.stage-card,.layer-card{
background:rgba(16,22,32,.92);
border:1px solid var(--line);
border-radius:24px;
box-shadow:var(--shadow);
}
.hero{
padding:22px;
display:grid;
grid-template-columns:1.2fr .8fr;
gap:18px;
margin-bottom:18px;
}
.eyebrow{margin:0 0 8px;color:var(--mgmt);text-transform:uppercase;letter-spacing:.18em;font-size:.74rem}
h1{margin:0;font-size:clamp(2.4rem,5vw,4.4rem);line-height:.93;letter-spacing:-.04em}
.sub{margin:12px 0 0;max-width:76ch;color:var(--muted);line-height:1.55}
.chips{display:flex;flex-wrap:wrap;gap:10px;margin-top:16px}
.chip{padding:7px 11px;border-radius:999px;border:1px solid var(--line);background:rgba(255,255,255,.04);font-size:.82rem}
.summary-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
.summary-card{background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.07);border-radius:18px;padding:14px}
.summary-card strong{display:block;font-size:1.05rem}
.summary-card small{display:block;margin-top:4px;color:var(--muted);line-height:1.35}
.layout{display:grid;grid-template-columns:minmax(0,1.6fr) 380px;gap:18px}
.canvas-shell{padding:14px;position:relative;overflow:hidden}
.toolbar,.subtoolbar{display:flex;flex-wrap:wrap;gap:10px}
.subtoolbar{margin-top:10px}
button{
appearance:none;border:1px solid var(--line);background:rgba(255,255,255,.04);color:var(--text);
padding:9px 12px;border-radius:999px;cursor:pointer;font:inherit;transition:background .18s ease,border-color .18s ease,transform .18s ease
}
button:hover{transform:translateY(-1px)}
button.active{background:rgba(242,193,78,.16);border-color:rgba(242,193,78,.35)}
.subtoolbar button.active[data-layer="topology"]{background:rgba(125,211,252,.16);border-color:rgba(125,211,252,.35)}
.subtoolbar button.active[data-layer="flow"]{background:rgba(248,113,113,.16);border-color:rgba(248,113,113,.35)}
.subtoolbar button.active[data-layer="migration"]{background:rgba(242,193,78,.16);border-color:rgba(242,193,78,.35)}
.panel{padding:16px}
.panel h2{margin:0 0 12px;font-size:1.15rem}
.panel p{margin:0;color:var(--muted);line-height:1.45}
.legend{display:grid;gap:10px;margin-top:14px}
.legend-row{display:grid;grid-template-columns:14px 1fr;gap:10px;align-items:center}
.swatch{width:14px;height:14px;border-radius:50%}
.stroke-swatch{width:14px;height:14px;border-radius:50%;background:transparent;border:2px solid currentColor}
.info h3{margin:0 0 6px;font-size:1.08rem}
.info .meta{display:flex;gap:8px;flex-wrap:wrap;margin:10px 0}
.info .meta span{padding:5px 8px;border-radius:999px;border:1px solid var(--line);font-size:.78rem;background:rgba(255,255,255,.04)}
.info ul{margin:10px 0 0 18px;padding:0}
.info li{margin:6px 0;color:#e6dece}
.caption{margin-top:10px;font-size:.88rem;color:var(--muted)}
svg{
display:block;width:100%;height:min(76vh,980px);border-radius:18px;
background:
radial-gradient(circle at 50% 50%, rgba(123,211,137,.04), transparent 24rem),
linear-gradient(180deg, rgba(255,255,255,.015), rgba(255,255,255,.005));
}
.ring{fill:none;stroke:rgba(255,255,255,.05);stroke-dasharray:4 8}
.mode-fade{opacity:.18}
.link{stroke:rgba(255,255,255,.15);stroke-width:2.4;fill:none;transition:stroke .18s ease, opacity .18s ease, stroke-width .18s ease}
.link.cross{stroke-dasharray:8 10;opacity:.62}
.link.highlight{stroke:#fff2cf;stroke-width:3.8;opacity:1}
.link.dim{opacity:.10}
.node{cursor:pointer;transition:opacity .18s ease, transform .18s ease}
.node.dim{opacity:.14}
.node-core{stroke:rgba(255,255,255,.16);stroke-width:2}
.node-label{font-size:14px;font-weight:700;fill:var(--text);text-anchor:middle;pointer-events:none}
.node-sub{font-size:11px;fill:var(--muted);text-anchor:middle;pointer-events:none}
.node.active .node-core{stroke:#fff2cf;stroke-width:3}
.cluster-title{font-size:15px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;fill:rgba(242,239,221,.52)}
.layer-line{fill:none;stroke-linecap:round;stroke-linejoin:round;opacity:.95;transition:opacity .18s ease}
.layer-line.dim{opacity:.12}
.layer-line.topology{stroke:var(--topology);stroke-width:4;stroke-dasharray:2 10}
.layer-line.flow{stroke:var(--flow);stroke-width:4.2;marker-end:url(#arrow-flow)}
.layer-line.flow.allow{stroke:var(--allow)}
.layer-line.flow.restrict{stroke:var(--migrate);stroke-dasharray:12 10;marker-end:url(#arrow-migrate)}
.layer-line.flow.deny{stroke:var(--deny);stroke-dasharray:5 10;marker-end:url(#arrow-deny)}
.layer-line.migration{stroke:var(--migrate);stroke-width:4;stroke-dasharray:12 12;marker-end:url(#arrow-migrate)}
.layer-label{font-size:11px;fill:#f4ead7;font-weight:700;letter-spacing:.03em;paint-order:stroke;stroke:#0b1017;stroke-width:4px}
.layer-label.dim{opacity:.12}
.stage-track{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-top:14px}
.stage-card,.layer-card{padding:12px 14px;background:rgba(255,255,255,.03);border-radius:18px}
.stage-card.active{border-color:rgba(242,193,78,.35);background:rgba(242,193,78,.08)}
.stage-card h3,.layer-card h3,.note h3{margin:0 0 8px;font-size:1rem}
.stage-card p,.layer-card p,.note li{color:#e6dece}
.stage-card p,.layer-card p{margin:0;font-size:.92rem;line-height:1.45}
.layer-grid,.note-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:18px}
.note{background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.07);border-radius:18px;padding:14px}
.note ul{margin:0 0 0 18px;padding:0}
.stage-badge{display:inline-flex;align-items:center;gap:6px;font-size:.78rem;padding:4px 8px;border-radius:999px;border:1px solid rgba(255,255,255,.08);margin-bottom:8px}
.stage-badge::before{content:'';display:block;width:8px;height:8px;border-radius:50%;background:var(--migrate)}
.small{font-size:.82rem;color:var(--muted)}
@media (max-width:1200px){
.hero,.layout{grid-template-columns:1fr}
}
@media (max-width:820px){
.wrap{padding:14px}
.summary-grid,.note-grid,.layer-grid,.stage-track{grid-template-columns:1fr}
svg{height:72vh}
}
</style>
</head>
<body>
<div class="wrap">
<section class="hero">
<div>
<p class="eyebrow">Operator map / graphical redesign artifact</p>
<h1>Network Web v2</h1>
<p class="sub">Now it is the actual operator map instead of just a conceptual graph. Same core redesign, but with three extra layers: physical/topology intent, firewall-flow reasoning, and staged migration sequencing. Click nodes or switch modes to inspect where the pain is and what the end state should look like.</p>
<div class="chips">
<span class="chip">6 target VLANs</span>
<span class="chip">Topology overlay</span>
<span class="chip">Firewall flow layer</span>
<span class="chip">4-stage migration path</span>
<span class="chip">Optional downstairs U6 LR</span>
</div>
</div>
<div class="summary-grid">
<div class="summary-card"><strong>Topology layer</strong><small>Shows the cabling/placement story: operator, gateway, switch fabric, APs, and where endpoints really hang.</small></div>
<div class="summary-card"><strong>Firewall layer</strong><small>Shows allow, deny, and exception paths so policy intent is visible instead of implied.</small></div>
<div class="summary-card"><strong>Migration layer</strong><small>Shows what moves first, what is annoying, and what can wait until the end.</small></div>
<div class="summary-card"><strong>Operator map</strong><small>Use this to reason about design, staging, and “what breaks if I move this” before touching UniFi.</small></div>
</div>
</section>
<section class="layout">
<div class="canvas-shell">
<div class="toolbar">
<button class="active" data-mode="all">Show full web</button>
<button data-mode="vlan">VLAN cores only</button>
<button data-mode="migration">Migration pain</button>
<button data-mode="wifi">Wi-Fi / coverage</button>
<button data-mode="topology">Topology view</button>
<button data-mode="policy">Firewall policy</button>
<button id="u6Toggle">Toggle downstairs U6 LR</button>
</div>
<div class="subtoolbar">
<button class="active" data-layer="topology">Topology overlay</button>
<button class="active" data-layer="flow">Firewall flows</button>
<button class="active" data-layer="migration">Migration arrows</button>
<button data-stage="0" class="active">All stages</button>
<button data-stage="1">Stage 1</button>
<button data-stage="2">Stage 2</button>
<button data-stage="3">Stage 3</button>
<button data-stage="4">Stage 4</button>
</div>
<svg id="networkSvg" viewBox="0 0 1280 960" aria-label="Home network redesign graph">
<defs>
<marker id="arrow-flow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto" markerUnits="userSpaceOnUse">
<path d="M0,0 L12,6 L0,12 z" fill="var(--flow)"></path>
</marker>
<marker id="arrow-deny" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto" markerUnits="userSpaceOnUse">
<path d="M0,0 L12,6 L0,12 z" fill="var(--deny)"></path>
</marker>
<marker id="arrow-migrate" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto" markerUnits="userSpaceOnUse">
<path d="M0,0 L12,6 L0,12 z" fill="var(--migrate)"></path>
</marker>
</defs>
<circle class="ring" cx="640" cy="470" r="125"></circle>
<circle class="ring" cx="640" cy="470" r="270"></circle>
<circle class="ring" cx="640" cy="470" r="415"></circle>
<text class="cluster-title" x="640" y="125" text-anchor="middle">Operator / routing core</text>
<text class="cluster-title" x="640" y="245" text-anchor="middle">Target VLANs and policy lanes</text>
<text class="cluster-title" x="640" y="900" text-anchor="middle">Endpoints, legacy scar tissue, migration pressure, and future growth</text>
<g id="baseLinks"></g>
<g id="layerLines"></g>
<g id="layerLabels"></g>
<g id="nodes"></g>
</svg>
<div class="stage-track" id="stageTrack">
<div class="stage-card" data-stage-card="1">
<div class="stage-badge">Stage 1</div>
<h3>Stop obvious sins</h3>
<p>Move Protect chimes out of Management, stand up Camera VLAN 60, and make Management clean again.</p>
</div>
<div class="stage-card" data-stage-card="2">
<div class="stage-badge">Stage 2</div>
<h3>Split infrastructure from life</h3>
<p>Move PD, Serenity, Nomad, and Rocinante into Servers VLAN 30 so trusted clients stop sharing a lane with infrastructure.</p>
</div>
<div class="stage-card" data-stage-card="3">
<div class="stage-badge">Stage 3</div>
<h3>Drain old IoT</h3>
<p>Migrate ecobees, appliances, and low-drama devices first. Leave Google discovery weirdness for last inside IoT.</p>
</div>
<div class="stage-card" data-stage-card="4">
<div class="stage-badge">Stage 4</div>
<h3>Refine radio and policy</h3>
<p>Decide whether the downstairs U6 LR returns, clean up SSIDs, then tune firewall exceptions with evidence instead of superstition.</p>
</div>
</div>
<div class="layer-grid">
<div class="layer-card">
<h3>Topology interpretation</h3>
<p>The UDM is routing brains, the USW is the practical center of gravity, and the APs are infrastructure nodes carrying policy-bound SSIDs. That means client traffic categories should be separated by VLAN intent, not by whatever happened to be easiest last year.</p>
</div>
<div class="layer-card">
<h3>Firewall interpretation</h3>
<p>Trusted gets explicit admin reach into Management and Servers. IoT and Cameras only get DNS/NTP/internet plus tightly scoped local-service exceptions. Guest gets nothing but internet. Old IoT gets a one-way ticket to deletion.</p>
</div>
</div>
<div class="note-grid">
<div class="note">
<h3>What this graph is saying now</h3>
<ul>
<li>Management is not a junk drawer.</li>
<li>Servers are infrastructure and deserve their own lane.</li>
<li>Cameras/security is a first-class lane because future growth is already obvious.</li>
<li>Migration difficulty lives mostly in discovery-heavy IoT, not in the easy appliances.</li>
</ul>
</div>
<div class="note">
<h3>What changes first</h3>
<ul>
<li>Move chimes out of Management.</li>
<li>Create Camera VLAN 60 now.</li>
<li>Pull core servers out of Trusted.</li>
<li>Drain and kill Old IoT / VLAN 2.</li>
</ul>
</div>
</div>
</div>
<aside class="panel info">
<h2>Selected node</h2>
<div id="infoBox">
<h3>Whole design</h3>
<p>Click a node in the graph. I will show what it is, what it should talk to, and where it sits in the migration sequence.</p>
<div class="meta">
<span>Tip: click Mgmt</span>
<span>Tip: click Cameras</span>
<span>Tip: click Old IoT</span>
</div>
</div>
<div class="legend">
<div class="legend-row"><span class="swatch" style="background:var(--mgmt)"></span><span>Management / infrastructure</span></div>
<div class="legend-row"><span class="swatch" style="background:var(--trusted)"></span><span>Trusted clients / operator lane</span></div>
<div class="legend-row"><span class="swatch" style="background:var(--servers)"></span><span>Servers / core services</span></div>
<div class="legend-row"><span class="swatch" style="background:var(--iot)"></span><span>IoT / smart home</span></div>
<div class="legend-row"><span class="swatch" style="background:var(--guest)"></span><span>Guest</span></div>
<div class="legend-row"><span class="swatch" style="background:var(--camera)"></span><span>Cameras / security</span></div>
<div class="legend-row"><span class="swatch" style="background:var(--legacy)"></span><span>Legacy / migration pain</span></div>
<div class="legend-row" style="color:var(--topology)"><span class="stroke-swatch"></span><span>Topology / physical intent overlay</span></div>
<div class="legend-row" style="color:var(--allow)"><span class="stroke-swatch"></span><span>Allowed policy path</span></div>
<div class="legend-row" style="color:var(--deny)"><span class="stroke-swatch"></span><span>Denied / blocked path</span></div>
<div class="legend-row" style="color:var(--migrate)"><span class="stroke-swatch"></span><span>Migration or restricted exception path</span></div>
</div>
<p class="caption">This is no longer just a decision picture. It is an operator map for topology, policy, and staged cutover thinking.</p>
</aside>
</section>
</div>
<script>
const palette = {
mgmt: getComputedStyle(document.documentElement).getPropertyValue('--mgmt').trim(),
trusted: getComputedStyle(document.documentElement).getPropertyValue('--trusted').trim(),
servers: getComputedStyle(document.documentElement).getPropertyValue('--servers').trim(),
iot: getComputedStyle(document.documentElement).getPropertyValue('--iot').trim(),
guest: getComputedStyle(document.documentElement).getPropertyValue('--guest').trim(),
camera: getComputedStyle(document.documentElement).getPropertyValue('--camera').trim(),
legacy: getComputedStyle(document.documentElement).getPropertyValue('--legacy').trim(),
core: '#f2efdd'
};
const nodes = [
{id:'operator', label:'John / Doris', sub:'operator lane', x:640, y:470, r:32, color:palette.core, group:'core', tags:['all'], stage:0,
info:{title:'Operator / control point', meta:['Human admin','Design center'], text:'The network is supposed to serve an operator, not confuse one. Trusted becomes the homeowner lane and the place from which management and service access are explicitly granted.', bullets:['Trusted becomes the human/operator lane.','Admin access is explicit, not accidental.']}}
,{id:'udm', label:'UDM Pro', sub:'gateway / routing', x:640, y:320, r:24, color:palette.mgmt, group:'core', tags:['all','vlan','topology','policy'], stage:0,
info:{title:'UDM Pro', meta:['Gateway','Routing core'], text:'Routing brain. Terminates VLANs and enforces policy. Management plane belongs in VLAN 10, not mixed into random endpoint space.', bullets:['Core of routing and firewall policy.','Upstream of every lane.']}}
,{id:'prohd', label:'USW Pro HD 24', sub:'main switch fabric', x:790, y:425, r:22, color:palette.mgmt, group:'core', tags:['all','vlan','topology'], stage:0,
info:{title:'USW Pro HD 24', meta:['Main access/core switch'], text:'This is the physical gravity well of the live network. Trunks to APs and servers should reflect deliberate lane design.', bullets:['Carries trunks to all major lanes.','Physical center of gravity.']}}
,{id:'u7', label:'U7 Pro', sub:'main upstairs AP', x:760, y:605, r:20, color:palette.mgmt, group:'core', tags:['all','wifi','topology'], stage:4,
info:{title:'U7 Pro', meta:['Primary AP','Infrastructure'], text:'The U7 stays as the primary AP. Infrastructure device in Management, serving SSIDs that map to real policy lanes.', bullets:['Broadcasts Trusted, IoT, Guest, Security SSIDs.','Management interface remains isolated.']}}
,{id:'u6', label:'U6 LR', sub:'downstairs / optional restore', x:500, y:610, r:18, color:palette.mgmt, group:'core optional-u6', tags:['all','wifi','topology'], hidden:true, stage:4,
info:{title:'U6 LR', meta:['Optional second AP','Coverage lever'], text:'Add back if coverage, roaming, or future camera/security RSSI actually needs it. Wired backhaul preferred.', bullets:['Treat this as a design lever, not a guess.','Verify with real coverage pain, not vibes.']}}
,{id:'mgmt', label:'Mgmt VLAN 10', sub:'10.5.10.0/24', x:640, y:195, r:28, color:palette.mgmt, group:'vlan', tags:['all','vlan','policy'], stage:1,
info:{title:'Management VLAN 10', meta:['Infra only','No junk'], text:'Gateway, switches, APs, management interfaces, and admin surfaces. This lane should be sacred and boring.', bullets:['No smart-home accessories.','No chimes.','Only explicit admin initiators reach here.']}}
,{id:'trusted', label:'Trusted VLAN 20', sub:'10.5.20.0/24', x:890, y:355, r:28, color:palette.trusted, group:'vlan', tags:['all','vlan','wifi','policy'], stage:0,
info:{title:'Trusted VLAN 20', meta:['Operator lane','Main user devices'], text:'Phones, laptops, desktops, tablets, Steam Deck, and daily-driver endpoints. This is where the humans live.', bullets:['Can administer Management and Servers with explicit rules.','Not a dumping ground for semi-smart junk.']}}
,{id:'servers', label:'Servers VLAN 30', sub:'10.5.30.0/24', x:890, y:585, r:28, color:palette.servers, group:'vlan', tags:['all','vlan','policy'], stage:2,
info:{title:'Servers VLAN 30', meta:['Core services','Homelab spine'], text:'PD, Serenity, Nomad, Rocinante, and other real service hosts belong here so policy is clean and intent is obvious.', bullets:['Split from Trusted for cleaner firewalling.','Expose only required services to other lanes.']}}
,{id:'iot', label:'IoT VLAN 40', sub:'10.5.40.0/24', x:640, y:745, r:28, color:palette.iot, group:'vlan', tags:['all','vlan','migration','wifi','policy'], stage:3,
info:{title:'IoT VLAN 40', meta:['Smart home','Default deny'], text:'Real landing zone for thermostats, appliances, Google devices, and retained smart-home junk. Explicit local-service exceptions only.', bullets:['Replaces Old IoT.','Allow DNS/NTP/internet plus specific helpers only.']}}
,{id:'guest', label:'Guest VLAN 50', sub:'10.5.50.0/24', x:390, y:585, r:26, color:palette.guest, group:'vlan', tags:['all','vlan','wifi','policy'], stage:0,
info:{title:'Guest VLAN 50', meta:['Internet only','Client isolation'], text:'Visitors and random untrusted devices get internet and nothing else. Boring is correct here.', bullets:['Pure guest SSID.','No internal access.']}}
,{id:'camera', label:'Camera VLAN 60', sub:'10.5.60.0/24', x:390, y:355, r:28, color:palette.camera, group:'vlan', tags:['all','vlan','migration','security','wifi','policy'], stage:1,
info:{title:'Camera / Security VLAN 60', meta:['Build now','Future growth'], text:'Doorbell, Protect chimes, future cameras, and other security devices get a first-class lane now instead of being bolted on later.', bullets:['Prefer wired PoE cameras as you grow.','Allow only NVR/Protect/admin flows.']}}
,{id:'pd', label:'PD', sub:'core server', x:1090, y:635, r:18, color:palette.servers, group:'device', tags:['all','vlan','topology'], stage:2,
info:{title:'PlausibleDeniability', meta:['Server VLAN resident'], text:'Infrastructure host. Belongs in Servers VLAN 30, not mixed into the human client lane.', bullets:['Keep service exposure explicit.','Infrastructure, not a casual endpoint.']}}
,{id:'serenity', label:'Serenity', sub:'core server', x:985, y:740, r:17, color:palette.servers, group:'device', tags:['all','vlan','topology'], stage:2,
info:{title:'Serenity', meta:['Server VLAN resident'], text:'Lives with the rest of the homelab spine.', bullets:['Cleaner trust boundary.']}}
,{id:'nomad', label:'Nomad', sub:'core server', x:855, y:790, r:17, color:palette.servers, group:'device', tags:['all','vlan','topology'], stage:2,
info:{title:'Nomad', meta:['Server VLAN resident'], text:'Part of the real service lane, not the personal device lane.', bullets:['Reachable from Trusted with explicit policy.']}}
,{id:'rocinante', label:'Rocinante', sub:'core host', x:1145, y:530, r:17, color:palette.servers, group:'device', tags:['all','vlan','topology'], stage:2,
info:{title:'Rocinante', meta:['Server/core host'], text:'Another device that reads as infrastructure rather than a casual client.', bullets:['Belongs in Servers unless proven otherwise.']}}
,{id:'phones', label:'Phones / laptops', sub:'trusted clients', x:1060, y:280, r:19, color:palette.trusted, group:'device', tags:['all','wifi','topology'], stage:0,
info:{title:'Trusted client devices', meta:['Trusted VLAN'], text:'Your daily-driver human devices stay on Trusted.', bullets:['Admin from here.','User experience from here.']}}
,{id:'steamdeck', label:'Steam Deck / handhelds', sub:'trusted clients', x:975, y:185, r:16, color:palette.trusted, group:'device', tags:['all','wifi'], stage:0,
info:{title:'Trusted handhelds', meta:['Trusted VLAN'], text:'Still trusted, still yours, still not IoT.', bullets:['Keep on Trusted unless an edge-case really demands otherwise.']}}
,{id:'ecobee', label:'ecobees', sub:'thermostats', x:620, y:870, r:17, color:palette.iot, group:'device', tags:['all','migration'], stage:3,
info:{title:'ecobees', meta:['IoT lane'], text:'Meaningful smart devices that belong in IoT, not in leftover legacy space.', bullets:['Validate app control after migration.']}}
,{id:'google', label:'Google Home / cast', sub:'voice + discovery pain', x:455, y:840, r:18, color:palette.iot, group:'device', tags:['all','migration','wifi'], stage:3,
info:{title:'Google ecosystem devices', meta:['IoT lane','Discovery exceptions'], text:'These are the annoying part of migration because discovery/casting can want special treatment.', bullets:['Likely the last IoT batch.','Use helpers/exceptions, not trust flattening.']}}
,{id:'appliances', label:'Appliances / MyQ', sub:'fridge dryer garage', x:760, y:845, r:17, color:palette.iot, group:'device', tags:['all','migration'], stage:3,
info:{title:'Appliances and garage', meta:['IoT lane'], text:'Easy low-drama migration targets. Keep them but do not trust them.', bullets:['Good early migration batch.']}}
,{id:'doorbell', label:'Doorbell', sub:'security endpoint', x:225, y:305, r:17, color:palette.camera, group:'device', tags:['all','security','wifi'], stage:1,
info:{title:'Doorbell', meta:['Camera VLAN'], text:'Security-class device. Belongs with the security lane and future camera footprint.', bullets:['Separate from generic IoT where possible.']}}
,{id:'chimes', label:'Protect chimes', sub:'currently misplaced', x:190, y:405, r:17, color:palette.legacy, group:'legacy', tags:['all','migration','security'], stage:1,
info:{title:'Protect chimes', meta:['Migration pain','Move now'], text:'Poster children for why Management should not be a junk drawer. Move these into Camera VLAN 60 first.', bullets:['One of the first obvious cleanups.']}}
,{id:'futurecams', label:'Future cameras', sub:'PoE preferred', x:260, y:500, r:18, color:palette.camera, group:'device', tags:['all','security','topology'], stage:1,
info:{title:'Future cameras', meta:['Growth justification'], text:'You already know more cameras are coming. That makes VLAN 60 real, not theoretical.', bullets:['Prefer wired PoE when practical.','This is why the security lane exists now.']}}
,{id:'guests', label:'Visitors', sub:'internet only', x:255, y:640, r:16, color:palette.guest, group:'device', tags:['all','wifi'], stage:0,
info:{title:'Guest devices', meta:['Guest VLAN'], text:'Boring by design. That is healthy.', bullets:['No lateral access.']}}
,{id:'oldiot', label:'Old IoT / VLAN 2', sub:'kill this', x:345, y:780, r:20, color:palette.legacy, group:'legacy', tags:['all','migration'], stage:3,
info:{title:'Old IoT / VLAN 2', meta:['Legacy pain','Retire completely'], text:'Historical scar tissue. Drain it in phases, then delete it and its stale profiles for good.', bullets:['Drain it in phases.','Delete once empty.']}}
,{id:'ssids', label:'SSID cleanup', sub:'fewer, clearer names', x:990, y:95, r:18, color:palette.trusted, group:'legacy', tags:['all','wifi','migration'], stage:4,
info:{title:'SSID cleanup', meta:['Wi-Fi policy cleanup'], text:'Long-term goal: one Trusted, one IoT, one Guest, one Security. Add more only if they solve a real problem.', bullets:['Either collapse duplicate trusted SSIDs or rename them to reflect real policy.']}}
];
const baseLinks = [
['operator','trusted'],['operator','mgmt'],['operator','servers'],
['udm','mgmt'],['udm','trusted'],['udm','servers'],['udm','iot'],['udm','guest'],['udm','camera'],
['prohd','udm'],['u7','prohd'],['u6','prohd'],
['mgmt','prohd'],['mgmt','u7'],['mgmt','u6'],
['trusted','phones'],['trusted','steamdeck'],['trusted','ssids'],
['servers','pd'],['servers','serenity'],['servers','nomad'],['servers','rocinante'],
['iot','ecobee'],['iot','google'],['iot','appliances'],['iot','oldiot','cross'],
['camera','doorbell'],['camera','futurecams'],['camera','chimes','cross'],
['guest','guests'],
['mgmt','chimes','cross'],
['trusted','servers','cross'],
['u7','trusted','cross'],['u7','iot','cross'],['u7','guest','cross'],['u7','camera','cross'],
['u6','trusted','cross optional-u6'],['u6','iot','cross optional-u6'],['u6','camera','cross optional-u6']
];
const overlays = [
{id:'topo-1', type:'topology', a:'operator', b:'udm', text:'admin path'},
{id:'topo-2', type:'topology', a:'udm', b:'prohd', text:'main trunk'},
{id:'topo-3', type:'topology', a:'prohd', b:'u7', text:'AP uplink'},
{id:'topo-4', type:'topology', a:'prohd', b:'u6', text:'optional AP uplink', optional:true},
{id:'topo-5', type:'topology', a:'prohd', b:'pd', text:'server uplink'},
{id:'topo-6', type:'topology', a:'prohd', b:'rocinante', text:'server uplink'},
{id:'flow-1', type:'flow allow', a:'trusted', b:'mgmt', text:'admin allowed'},
{id:'flow-2', type:'flow allow', a:'trusted', b:'servers', text:'service/admin allowed'},
{id:'flow-3', type:'flow allow', a:'camera', b:'servers', text:'Protect / NVR only'},
{id:'flow-4', type:'flow allow', a:'iot', b:'servers', text:'specific local helpers'},
{id:'flow-5', type:'flow deny', a:'guest', b:'mgmt', text:'blocked'},
{id:'flow-6', type:'flow deny', a:'guest', b:'servers', text:'blocked'},
{id:'flow-7', type:'flow deny', a:'iot', b:'mgmt', text:'blocked'},
{id:'flow-8', type:'flow restrict', a:'google', b:'phones', text:'discovery exception pain'},
{id:'mig-1', type:'migration', a:'chimes', b:'camera', text:'stage 1', stage:1},
{id:'mig-2', type:'migration', a:'pd', b:'servers', text:'stage 2', stage:2},
{id:'mig-3', type:'migration', a:'serenity', b:'servers', text:'stage 2', stage:2},
{id:'mig-4', type:'migration', a:'nomad', b:'servers', text:'stage 2', stage:2},
{id:'mig-5', type:'migration', a:'rocinante', b:'servers', text:'stage 2', stage:2},
{id:'mig-6', type:'migration', a:'oldiot', b:'iot', text:'stage 3', stage:3},
{id:'mig-7', type:'migration', a:'ecobee', b:'iot', text:'stage 3', stage:3},
{id:'mig-8', type:'migration', a:'appliances', b:'iot', text:'stage 3', stage:3},
{id:'mig-9', type:'migration', a:'google', b:'iot', text:'stage 3 last', stage:3},
{id:'mig-10', type:'migration', a:'ssids', b:'u7', text:'stage 4', stage:4},
{id:'mig-11', type:'migration', a:'ssids', b:'u6', text:'stage 4 option', stage:4, optional:true}
];
const svgNodes = document.getElementById('nodes');
const svgBaseLinks = document.getElementById('baseLinks');
const layerLines = document.getElementById('layerLines');
const layerLabels = document.getElementById('layerLabels');
const infoBox = document.getElementById('infoBox');
const stageCards = [...document.querySelectorAll('[data-stage-card]')];
const nodeMap = Object.fromEntries(nodes.map(n => [n.id,n]));
let selected = null;
let mode = 'all';
let u6Enabled = false;
let activeStage = 0;
const layerState = {topology:true, flow:true, migration:true};
function modeVisible(node){
if (node.hidden && !u6Enabled) return false;
if (mode === 'all') return true;
if (mode === 'vlan') return node.group === 'vlan' || node.group === 'core';
if (mode === 'migration') return node.tags.includes('migration') || node.group === 'legacy' || node.id === 'operator' || node.id === 'camera' || node.id === 'servers' || node.id === 'iot';
if (mode === 'wifi') return node.tags.includes('wifi') || node.id === 'operator';
if (mode === 'topology') return node.tags.includes('topology') || node.group === 'core' || node.group === 'vlan' || node.id === 'operator';
if (mode === 'policy') return node.tags.includes('policy') || ['phones','google'].includes(node.id) || node.id === 'operator';
return true;
}
function stageVisible(item){
return activeStage === 0 || !item.stage || item.stage === activeStage;
}
function midpoint(a,b,dx=0,dy=0){
return {x:(a.x+b.x)/2 + dx, y:(a.y+b.y)/2 + dy};
}
function createNode(n){
const g = document.createElementNS('http://www.w3.org/2000/svg','g');
g.setAttribute('class','node');
g.dataset.id = n.id;
g.innerHTML = `
<circle class="node-core" cx="${n.x}" cy="${n.y}" r="${n.r}" fill="${n.color}"></circle>
<text class="node-label" x="${n.x}" y="${n.y-4}">${n.label}</text>
<text class="node-sub" x="${n.x}" y="${n.y+14}">${n.sub}</text>
`;
g.addEventListener('click', () => selectNode(n.id));
return g;
}
function createBaseLink(link){
const [a,b,klass=''] = link;
const na = nodeMap[a], nb = nodeMap[b];
const line = document.createElementNS('http://www.w3.org/2000/svg','line');
line.setAttribute('x1', na.x); line.setAttribute('y1', na.y);
line.setAttribute('x2', nb.x); line.setAttribute('y2', nb.y);
line.setAttribute('class', `link ${klass}`.trim());
line.dataset.a = a; line.dataset.b = b;
return line;
}
function createOverlay(overlay){
const a = nodeMap[overlay.a], b = nodeMap[overlay.b];
const line = document.createElementNS('http://www.w3.org/2000/svg','line');
line.setAttribute('x1', a.x); line.setAttribute('y1', a.y);
line.setAttribute('x2', b.x); line.setAttribute('y2', b.y);
line.setAttribute('class', `layer-line ${overlay.type}`);
line.dataset.a = overlay.a; line.dataset.b = overlay.b; line.dataset.overlay = overlay.id;
const label = document.createElementNS('http://www.w3.org/2000/svg','text');
const bump = overlay.type.includes('migration') ? {x:0,y:-12} : overlay.type.includes('flow') ? {x:0,y:16} : {x:0,y:-16};
const mid = midpoint(a,b,bump.x,bump.y);
label.setAttribute('x', mid.x);
label.setAttribute('y', mid.y);
label.setAttribute('text-anchor','middle');
label.setAttribute('class','layer-label');
label.dataset.a = overlay.a; label.dataset.b = overlay.b; label.dataset.overlay = overlay.id;
label.textContent = overlay.text;
return {line,label};
}
const nodeEls = new Map();
const baseLinkEls = [];
const overlayEls = [];
nodes.forEach(n => { const el = createNode(n); nodeEls.set(n.id, el); svgNodes.appendChild(el); });
baseLinks.forEach(l => { const el = createBaseLink(l); baseLinkEls.push(el); svgBaseLinks.appendChild(el); });
overlays.forEach(o => {
const pair = createOverlay(o);
overlayEls.push({...pair, meta:o});
layerLines.appendChild(pair.line);
layerLabels.appendChild(pair.label);
});
function isRelatedToSelected(a,b){
return selected && (a === selected || b === selected);
}
function renderInfo(n){
if (!n) {
infoBox.innerHTML = `<h3>Whole design</h3><p>Click a node in the graph. I will show what it is, what it should talk to, and where it sits in the migration sequence.</p><div class="meta"><span>Tip: click Mgmt</span><span>Tip: click Cameras</span><span>Tip: click Old IoT</span></div>`;
return;
}
const stageText = n.stage ? `Stage ${n.stage}` : 'Always relevant';
infoBox.innerHTML = `
<h3>${n.info.title}</h3>
<p>${n.info.text}</p>
<div class="meta">${n.info.meta.map(m => `<span>${m}</span>`).join('')}<span>${stageText}</span></div>
<ul>${n.info.bullets.map(b => `<li>${b}</li>`).join('')}</ul>
`;
}
function updateStageCards(){
stageCards.forEach(card => {
const stage = Number(card.dataset.stageCard);
card.classList.toggle('active', activeStage === 0 || activeStage === stage);
});
}
function updateGraph(){
nodeEls.forEach((el,id) => {
const n = nodeMap[id];
const visible = modeVisible(n) && stageVisible(n);
el.style.display = visible ? 'block' : 'none';
el.classList.toggle('active', selected === id);
const related = selected && (id === selected || baseLinks.some(([a,b]) => (a===selected && b===id) || (b===selected && a===id)) || overlays.some(o => (o.a===selected && o.b===id) || (o.b===selected && o.a===id)));
el.classList.toggle('dim', !!selected && !related);
});
baseLinkEls.forEach(el => {
const a = el.dataset.a, b = el.dataset.b;
const optional = el.getAttribute('class').includes('optional-u6');
const visible = modeVisible(nodeMap[a]) && modeVisible(nodeMap[b]) && stageVisible(nodeMap[a]) && stageVisible(nodeMap[b]) && (!optional || u6Enabled);
el.style.display = visible ? 'block' : 'none';
const related = isRelatedToSelected(a,b);
el.classList.toggle('highlight', !!selected && related);
el.classList.toggle('dim', !!selected && !related);
});
overlayEls.forEach(({line,label,meta}) => {
const a = nodeMap[meta.a], b = nodeMap[meta.b];
const overlayType = meta.type.split(' ')[0];
const layerOn = overlayType === 'topology' ? layerState.topology : overlayType === 'flow' ? layerState.flow : layerState.migration;
const optional = !!meta.optional;
const modeMatch = mode === 'topology' ? overlayType === 'topology' || overlayType === 'migration' : mode === 'policy' ? overlayType === 'flow' || overlayType === 'migration' : true;
const visible = layerOn && modeMatch && modeVisible(a) && modeVisible(b) && stageVisible(meta) && stageVisible(a) && stageVisible(b) && (!optional || u6Enabled);
line.style.display = visible ? 'block' : 'none';
label.style.display = visible ? 'block' : 'none';
const related = isRelatedToSelected(meta.a, meta.b);
line.classList.toggle('dim', !!selected && !related);
label.classList.toggle('dim', !!selected && !related);
});
if (selected && (!modeVisible(nodeMap[selected]) || !stageVisible(nodeMap[selected]) || (nodeMap[selected].hidden && !u6Enabled))) {
selected = null;
renderInfo(null);
}
updateStageCards();
}
function selectNode(id){
selected = selected === id ? null : id;
renderInfo(selected ? nodeMap[selected] : null);
updateGraph();
localStorage.setItem('network-web-selected', selected || '');
}
document.querySelectorAll('[data-mode]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-mode]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
mode = btn.dataset.mode;
localStorage.setItem('network-web-mode', mode);
updateGraph();
});
});
document.querySelectorAll('[data-layer]').forEach(btn => {
btn.addEventListener('click', () => {
const layer = btn.dataset.layer;
layerState[layer] = !layerState[layer];
btn.classList.toggle('active', layerState[layer]);
localStorage.setItem(`network-web-layer-${layer}`, layerState[layer] ? '1' : '0');
updateGraph();
});
});
document.querySelectorAll('[data-stage]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-stage]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
activeStage = Number(btn.dataset.stage);
localStorage.setItem('network-web-stage', String(activeStage));
updateGraph();
});
});
document.getElementById('u6Toggle').addEventListener('click', (e) => {
u6Enabled = !u6Enabled;
e.currentTarget.classList.toggle('active', u6Enabled);
e.currentTarget.textContent = u6Enabled ? 'Hide downstairs U6 LR' : 'Toggle downstairs U6 LR';
localStorage.setItem('network-web-u6', u6Enabled ? '1' : '0');
updateGraph();
});
const savedMode = localStorage.getItem('network-web-mode');
if (savedMode) {
const btn = [...document.querySelectorAll('[data-mode]')].find(b => b.dataset.mode === savedMode);
if (btn) btn.click();
}
['topology','flow','migration'].forEach(layer => {
const saved = localStorage.getItem(`network-web-layer-${layer}`);
if (saved === '0') {
layerState[layer] = false;
const btn = document.querySelector(`[data-layer="${layer}"]`);
if (btn) btn.classList.remove('active');
}
});
const savedStage = localStorage.getItem('network-web-stage');
if (savedStage) {
const btn = [...document.querySelectorAll('[data-stage]')].find(b => b.dataset.stage === savedStage);
if (btn) btn.click();
}
u6Enabled = localStorage.getItem('network-web-u6') === '1';
if (u6Enabled) {
const btn = document.getElementById('u6Toggle');
btn.classList.add('active');
btn.textContent = 'Hide downstairs U6 LR';
}
const savedSelected = localStorage.getItem('network-web-selected');
if (savedSelected && nodeMap[savedSelected]) selected = savedSelected;
renderInfo(selected ? nodeMap[selected] : null);
updateGraph();
</script>
</body>
</html>