aws-vis/ex4.html

555 lines
33 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AWS VPC Diagram (Cytoscape + ELK)</title>
<!-- Cytoscape core -->
<script src="https://unpkg.com/cytoscape@3.28.1/dist/cytoscape.min.js"></script>
<!-- ELK layout engine and Cytoscape adapter -->
<script src="https://unpkg.com/elkjs/lib/elk.bundled.js"></script>
<script src="https://unpkg.com/cytoscape-elk@2.2.1/cytoscape-elk.js"></script>
<!-- Dagre fallback -->
<script src="https://unpkg.com/dagre@0.8.5/dist/dagre.min.js"></script>
<script src="https://unpkg.com/cytoscape-dagre@2.5.0/cytoscape-dagre.js"></script>
<!-- HTML label plugin -->
<script src="https://cdn.jsdelivr.net/npm/cytoscape-node-html-label@1.2.2/dist/cytoscape-node-html-label.min.js"></script>
<style>
:root { --bg:#0b1220; --panel:#0f172a; --muted:#334155; --text:#e5e7eb; --accent:#60a5fa;
--name-size:16px; --detail-size:12px; }
*{box-sizing:border-box}
body{ margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial; background:var(--bg); color:var(--text); }
header{ padding:12px 16px; border-bottom:1px solid #1f2937; display:flex; gap:16px; align-items:center; background:#0b1220; position:sticky; top:0; z-index:10; flex-wrap:wrap; }
header h1{ font-size:16px; margin:0 8px 0 0; font-weight:600; color:#cbd5e1; }
label{ font-size:13px; color:#cbd5e1; }
input[type="text"]{ background:var(--panel); color:var(--text); border:1px solid #1f2937; border-radius:8px; padding:8px 10px; min-width:260px; }
input[type="range"]{ vertical-align:middle }
button{ background:#111827; color:var(--text); border:1px solid #1f2937; padding:8px 10px; border-radius:8px; cursor:pointer; }
button:hover{ border-color:#334155; }
main{ display:grid; grid-template-columns:1fr 320px; height:calc(100vh - 56px); }
#cy{ width:100%; height:100%; background:#fff;
background-image:
repeating-linear-gradient(0deg, rgba(59,130,246,0.12) 0 1px, transparent 1px 20px),
repeating-linear-gradient(90deg, rgba(59,130,246,0.12) 0 1px, transparent 1px 20px),
repeating-linear-gradient(0deg, rgba(59,130,246,0.08) 0 1px, transparent 1px 100px),
repeating-linear-gradient(90deg, rgba(59,130,246,0.08) 0 1px, transparent 1px 100px);
}
aside{ border-left:1px solid #1f2937; background:var(--panel); padding:10px; overflow:auto; }
aside h2{ font-size:14px; font-weight:600; color:#d1d5db; margin:6px 0 8px; }
.muted{ color:#94a3b8; font-size:12px; }
pre{ background:#0b1020; border:1px solid #1f2937; border-radius:8px; padding:10px; overflow:auto; }
.legend{ display:flex; flex-wrap:wrap; gap:6px 14px; align-items:center; font-size:12px; }
.legend span{ display:inline-flex; align-items:center; gap:6px; }
.chip{ width:12px; height:12px; border-radius:3px; display:inline-block; border:1px solid #0b1220; }
/* HTML label cards */
.ncard{ display:inline-grid; grid-auto-flow:column; gap:8px; align-items:start; background:#fff; border:1px solid #cbd5e1; border-radius:8px; padding:8px 10px; box-shadow:0 1px 2px rgba(0,0,0,.06); }
.n-ec2 .icon,.n-rds .icon,.n-alb .icon,.n-nlb .icon,.n-sg .icon,.n-igw .icon,.n-nat .icon{ width:18px; height:18px; }
.n-title{ font-weight:700; font-size:var(--name-size); line-height:1.1; color:#0f172a; }
.n-sub{ font-size:var(--detail-size); color:#475569; line-height:1.25; }
.badge{ font-size:10px; background:#1f2937; color:#fff; border-radius:6px; padding:2px 6px; margin-left:6px; }
.status{ display:inline-flex; align-items:center; gap:6px; }
.dot{ width:8px; height:8px; border-radius:50%; display:inline-block; }
.dot.run{ background:#10b981; } .dot.stop{ background:#ef4444; } .dot.unk{ background:#9ca3af; }
.vpc-big{ font-weight:800; font-size:clamp(18px,6vw,48px); color:#94a3b8; opacity:.35; text-shadow:0 1px 0 #fff; }
/* Subnet tab label */
.subnet-tab{ display:inline-block; background:#f3f4f6; color:#0f172a; border:1px solid #9ca3af; border-bottom:none; border-top-left-radius:4px; border-top-right-radius:4px; padding:6px 10px; box-shadow:0 1px 0 rgba(0,0,0,.05) inset; pointer-events:none; }
.subnet-tab .t-name{ font-weight:700; font-size:12px; line-height:1.1; }
.subnet-tab .t-sub{ font-size:11px; color:#475569; line-height:1.2; }
</style>
</head>
<body>
<header>
<h1>AWS VPC (example)</h1>
<label><input id="toggle-sg" type="checkbox" checked> Show security groups</label>
<label><input id="toggle-routes" type="checkbox" checked> Show routes (IGW/NAT)</label>
<input id="search" type="text" placeholder="Search nodes by name/id/IP…">
<button id="btn-fit" title="Fit to screen">Fit</button>
<button id="btn-reflow" title="Run layout again">Relayout</button>
<label><input id="toggle-label-edit" type="checkbox"> Edit labels</label>
<label>Size <input id="size-slider" type="range" min="0.8" max="1.6" step="0.05" value="1"></label>
<button id="btn-save" title="Download positions and label offsets">Save layout</button>
<button id="btn-load" title="Load layout">Load layout</button>
<input id="load-file" type="file" accept="application/json" style="display:none">
<div class="legend" style="margin-left:auto">
<span><i class="chip" style="background:#f9fafb;border-color:#111827"></i>VPC</span>
<span><i class="chip" style="background:#f3f4f6;border-color:#9ca3af"></i>Subnet</span>
<span><i class="chip" style="background:#fee2e2;border-color:#ef4444"></i>ALB/NLB</span>
<span><i class="chip" style="background:#dbeafe;border-color:#3b82f6"></i>EC2</span>
<span><i class="chip" style="background:#dcfce7;border-color:#10b981"></i>RDS</span>
<span><i class="chip" style="background:#fff7ed;border-color:#f59e0b"></i>SG</span>
</div>
</header>
<main>
<div id="cy"></div>
<aside>
<h2>Details</h2>
<div class="muted">Click any node or edge.</div>
<pre id="details"></pre>
</aside>
</main>
<script>
// Register plugins (ELK preferred), and HTML label plugin
if (window.cytoscapeElk) cytoscape.use(window.cytoscapeElk);
if (window.cytoscapeDagre) cytoscape.use(window.cytoscapeDagre);
if (window.nodeHtmlLabel) cytoscape.use(window.nodeHtmlLabel);
// Create Cytoscape (start empty; we'll add elements after fetch)
const cy = cytoscape({
container: document.getElementById('cy'),
boxSelectionEnabled: false,
wheelSensitivity: 0.2,
pixelRatio: 1,
style: [
{ selector: 'node', style: {
'shape': 'round-rectangle',
'background-color': '#e5e7eb',
'border-width': 1,
'border-color': '#9ca3af',
'text-wrap': 'wrap',
'text-max-width': 180,
'font-size': 12,
'padding': '10px',
'text-valign': 'top',
'text-halign': 'left',
'text-opacity': 0 /* hide built-in labels; we render HTML labels */
}},
{ selector: 'node[lx]', style: { 'text-margin-x': 'data(lx)' }},
{ selector: 'node[ly]', style: { 'text-margin-y': 'data(ly)' }},
{ selector: 'node[label]', style: { 'label': 'data(label)' }},
{ selector: 'node[type = "vpc"]', style: {
'background-color': '#f9fafb','border-color': '#111827','border-width': 2,'padding': '30px','font-weight': 700
}},
{ selector: 'node[type = "subnet"]', style: {
'background-color': '#f3f4f6',
'border-color': '#9ca3af',
'border-radius': 4
}},
{ selector: 'node[type = "subnet-sizer"]', style: {
'shape': 'rectangle',
'background-opacity': 0,
'border-width': 0,
'width': 1,
'height': 1,
'events': 'no',
'opacity': 0
}},
{ selector: 'node[type = "alb"], node[type = "nlb"]', style: {
'background-color': '#fee2e2',
'border-color': '#ef4444',
'border-width': 2,
'width': 260,
'height': 100,
'padding': '12px'
}},
{ selector: 'node[type = "ec2"]', style: {
'background-color': '#dbeafe',
'border-color': '#3b82f6',
'width': 260,
'height': 100,
'padding': '12px',
'text-opacity': 0 /* use HTML labels for EC2 */
}},
{ selector: 'node[type = "rds"]', style: {
'background-color': '#dcfce7',
'border-color': '#10b981',
'width': 260,
'height': 100,
'padding': '12px'
}},
{ selector: 'node[type = "sg"]', style: {
'background-color': '#fff7ed',
'border-color': '#f59e0b',
'width': 260,
'height': 90,
'padding': '12px'
}},
{ selector: 'node[type = "igw"], node[type = "nat"]', style: { 'background-color': '#ffe4e6','border-color': '#fb7185' }},
{ selector: 'edge', style: {
'width': 2,'line-color': '#9ca3af','target-arrow-shape': 'triangle','target-arrow-color': '#9ca3af',
'curve-style': 'bezier','font-size': 11,'text-rotation': 'autorotate',
'text-background-color': '#ffffff','text-background-opacity': 0.7,'text-border-opacity': 0,'text-margin-y': -6
}},
{ selector: 'edge[label]', style: { 'label': 'data(label)' }},
{ selector: 'edge.route', style: { 'line-style': 'dashed','line-color': '#94a3b8','target-arrow-color': '#94a3b8' }},
{ selector: 'edge.sg-attach', style: { 'line-style': 'dotted','line-color': '#f59e0b','target-arrow-color': '#f59e0b' }},
{ selector: 'edge.sg-sg', style: { 'line-color': '#f59e0b','target-arrow-color': '#f59e0b','source-arrow-color': '#f59e0b' }},
{ selector: 'edge.sg-sg[tarrow > 0]', style: { 'target-arrow-shape': 'triangle' }},
{ selector: 'edge.sg-sg[sarrow > 0]', style: { 'source-arrow-shape': 'triangle' }}
]
});
function runLayout() {
const haveElk = !!window.cytoscapeElk, haveDagre = !!window.cytoscapeDagre;
const elkOpts = { name: 'elk', elk: { algorithm: 'layered','elk.direction':'RIGHT','elk.layered.spacing.nodeNodeBetweenLayers':55,'elk.spacing.nodeNode':28,'elk.layered.considerModelOrder.strategy':'NODES_AND_EDGES' }, fit:true, padding:24 };
const dagreOpts = { name: 'dagre', rankDir:'LR', nodeSep:28, rankSep:60, fit:true, padding:24 };
const gridOpts = { name: 'grid', fit:true, padding:24 };
(cy.layout(haveElk ? elkOpts : (haveDagre ? dagreOpts : gridOpts))).run();
}
// --- Build elements from graph.json in the browser ---
async function loadGraphAndBuildElements(region = 'us-west-1') {
const resp = await fetch('graph.json'); // serve over http://
const graph = await resp.json();
const g = (graph.regions && graph.regions[region]) || {};
const elements = [];
const vpcs = g.vpcs || [];
const subnets = g.subnets || [];
const sgs = g.sgs || [];
const ec2s = g.ec2 || [];
const rds = g.rds || [];
const lbs = g.lbs || [];
const sgById = Object.fromEntries(sgs.map(s => [s.id, s]));
// VPCs
for (const v of vpcs) {
elements.push({ data: { id: v.id, type: 'vpc', label: `${v.name}\n${v.cidr}`, cidr: v.cidr, name: v.name } });
}
// Subnets
for (const s of subnets) {
const label = `${(s.name||s.id)}\n${s.cidr}\n${s.az}`;
elements.push({ data: { id: s.id, type: 'subnet', label, cidr: s.cidr, az: s.az, public: !!s.public, parent: s.vpc } });
}
// SGs
for (const sg of sgs) {
const rin = sg.rules_in || [], rout = sg.rules_out || [];
const label = `${sg.name || sg.id}\ningress: ${(rin[0]||'—')}${rin.length>1?'…':''}`;
elements.push({ data: { id: sg.id, type: 'sg', label, rules: { in: rin, out: rout } } });
}
// SG<->SG edges based on rules referencing other SGs
function parseSgRef(rule){
// match: "proto port from sg-xxxx"
const m = (rule||'').match(/^(\S+)\s+([^\s]+)\s+from\s+(sg-[0-9a-zA-Z]+)/);
if(!m) return null; return { proto:m[1], port:m[2], sg:m[3] };
}
const pairMap = new Map(); // key: A|B -> {inOn:{}, outFrom:{}}
function keyAB(a,b){ return a<b ? (a+'|'+b) : (b+'|'+a); }
function ensurePair(a,b){ const k=keyAB(a,b); if(!pairMap.has(k)) pairMap.set(k,{inOn:{},outFrom:{}}); return pairMap.get(k); }
function addIn(target, src, proto, port){ const p=ensurePair(target,src); (p.inOn[target]||(p.inOn[target]=[])).push({proto,port,src}); }
function addOut(src, target, proto, port){ const p=ensurePair(src,target); (p.outFrom[src]||(p.outFrom[src]=[])).push({proto,port,target}); }
for (const sg of sgs) {
for (const r of (sg.rules_in||[])) { const x=parseSgRef(r); if(x && sgById[x.sg]) addIn(sg.id, x.sg, x.proto, x.port); }
for (const r of (sg.rules_out||[])) { const x=parseSgRef(r); if(x && sgById[x.sg]) addOut(sg.id, x.sg, x.proto, x.port); }
}
function summarize(list){ const uniq=new Set(); for(const it of (list||[])){ const text=`${it.proto} ${it.port}`; uniq.add(text); } return Array.from(uniq).join(', '); }
for (const [k, val] of pairMap.entries()){
const [a,b]=k.split('|');
const s=a, t=b; // orient stable by id
const s2tList = (val.outFrom[s]||[]).concat(val.inOn[t]||[]);
const t2sList = (val.outFrom[t]||[]).concat(val.inOn[s]||[]);
const tarrow = s2tList.length ? 1 : 0; // arrow at target if flow S->T
const sarrow = t2sList.length ? 1 : 0; // arrow at source if flow T->S
const label = [summarize(s2tList), summarize(t2sList)].filter(Boolean).join(' | ');
elements.push({ data: { id:`sg:${s}|${t}`, source:s, target:t, label, tarrow, sarrow }, classes:'sg-sg' });
}
// EC2
for (const i of ec2s) {
const label = `${i.name || i.id}\n${i.id}\n${i.type || ''}\n${i.privateIp ? ('Private IP: ' + i.privateIp) : ''}`;
elements.push({ data: { id: i.id, type: 'ec2', label,
name: i.name, instanceId: i.id, privateIp: i.privateIp,
publicIp: i.publicIp, state: i.state, parent: i.subnet, sgs: i.sgs || [] } });
for (const sgid of (i.sgs || [])) if (sgById[sgid]) elements.push({ data: { id:`sg-${sgid}->${i.id}`, source: sgid, target: i.id, label:'attached', class:'sg-attach' }});
}
// RDS
for (const db of rds) {
const parentSubnet = (db.subnetGroup && db.subnetGroup[0]) || null;
const label = `${db.engine}\n${db.port}\n${db.publiclyAccessible?'public':'private'}`;
elements.push({ data: { id: db.id, type:'rds', label, engine:db.engine, port:db.port, publiclyAccessible:!!db.publiclyAccessible, parent: parentSubnet, sgs: db.sgs || [] }});
for (const sgid of (db.sgs || [])) if (sgById[sgid]) elements.push({ data: { id:`sg-${sgid}->${db.id}`, source: sgid, target: db.id, label:'attached', class:'sg-attach' }});
}
// LBs
for (const lb of lbs) {
const parent = (lb.subnets && lb.subnets[0]) || (subnets[0] && subnets[0].id) || null;
const lstText = (lb.listeners || []).map(L => L.port).join(',');
const label = `${lb.id}\nlisteners: ${lstText || '—'}\n${lb.scheme}`;
elements.push({ data: { id: lb.id, type: (lb.type==='network'?'nlb':'alb'), label, scheme: lb.scheme, listeners: lb.listeners || [], parent, sgs: lb.securityGroups || [], dns: lb.dns } });
for (const sgid of (lb.securityGroups || [])) if (sgById[sgid]) elements.push({ data: { id:`sg-${sgid}->${lb.id}`, source: sgid, target: lb.id, label:'attached', class:'sg-attach' }});
const tgs = lb.targetGroups || []; const targetToPort = {};
for (const tg of tgs) for (const t of (tg.targets || [])) targetToPort[t] = tg.port || targetToPort[t] || '';
for (const [t, port] of Object.entries(targetToPort)) elements.push({ data: { id:`${lb.id}->${t}`, source: lb.id, target: t, label: (port ? `tcp ${port}` : '') }});
}
return elements;
}
// Initialize: fetch graph, add elements, run layout, set up HTML labels and helpers
(async function init(){
const elements = await loadGraphAndBuildElements('us-west-1');
cy.add(elements);
cy.nodes().forEach(n => { if (n.data('lx') == null) n.data('lx',0); if (n.data('ly') == null) n.data('ly',0); });
runLayout();
// HTML label helpers
function escapeHtml(s){ return (s||'').toString().replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[c])); }
function iconSvg(type){
switch(type){
case 'ec2': return '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="6" width="18" height="12" rx="2" stroke-width="1.5"/><path d="M7 10h10M7 14h6" stroke-width="1.5"/></svg>';
case 'rds': return '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"><ellipse cx="12" cy="6" rx="7" ry="3" stroke-width="1.5"/><path d="M5 6v8c0 1.7 3.1 3 7 3s7-1.3 7-3V6" stroke-width="1.5"/></svg>';
case 'alb': return '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="4" stroke-width="1.5"/><path d="M12 2v6M12 16v6M2 12h6M16 12h6" stroke-width="1.5"/></svg>';
case 'nlb': return '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="4" y="4" width="16" height="16" rx="2" stroke-width="1.5"/><path d="M8 12h8" stroke-width="1.5"/></svg>';
case 'sg': return '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 3l7 3v6c0 5-4 7-7 9-3-2-7-4-7-9V6l7-3z" stroke-width="1.5"/></svg>';
case 'nat': return '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 12h16M12 4v4M12 16v4" stroke-width="1.5"/></svg>';
case 'igw': return '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 2v20M2 12h20" stroke-width="1.5"/></svg>';
default: return '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="5" y="5" width="14" height="14" rx="2" stroke-width="1.5"/></svg>';
}
}
function vpnBadge(name){ return (name && /vpn/i.test(name)) ? '<span class="badge">VPN</span>' : ''; }
function statusDot(state){ const cls=(state==='running')?'run':(state==='stopped'?'stop':'unk'); const txt=state||'unknown'; return `<span class="status"><i class="dot ${cls}"></i>${escapeHtml(txt)}</span>`; }
function sgRuleCount(d){ const rin=(d.rules&&d.rules.in)?d.rules.in.length:0; const rout=(d.rules&&d.rules.out)?d.rules.out.length:0; return rin+rout; }
// Attach HTML labels for SG/ALB/NLB/RDS/EC2 centered inside nodes and Subnet at top-left
if (cy.nodeHtmlLabel) {
cy.nodeHtmlLabel([
{
query:'node[type = "sg"], node[type = "alb"], node[type = "nlb"], node[type = "rds"], node[type = "ec2"]',
halign:'center', valign:'center', halignBox:'center', valignBox:'center',
tpl: function(d){
const type=d.type; const name=d.name||(d.label||'').split('\n')[0]||d.id;
const pub=d.publicIp||d.publicIpAddress||d.public_ip||d.dns||'';
const priv=d.privateIp||d.private_ip||'';
const detailA=(type==='rds')?`${d.engine||''}:${d.port||''}`:(type==='alb'||type==='nlb')?(d.scheme||''):(type==='sg')?`${sgRuleCount(d)} rules`:(type==='ec2')? (d.state||'') : (d.cidr||'');
const lines = [];
if (type==='ec2'){
if (d.state) lines.push(`<div class="n-sub">${statusDot(d.state)}</div>`);
if (d.instanceId) lines.push(`<div class="n-sub">${escapeHtml(d.instanceId)}</div>`);
if (priv) lines.push(`<div class="n-sub">Private IP: ${escapeHtml(priv)}</div>`);
if (pub) lines.push(`<div class="n-sub">Public: ${escapeHtml(pub)}</div>`);
} else {
if (detailA) lines.push(`<div class="n-sub">${escapeHtml(detailA)}</div>`);
if (priv) lines.push(`<div class="n-sub">Private IP: ${escapeHtml(priv)}</div>`);
if (pub) lines.push(`<div class="n-sub">Public: ${escapeHtml(pub)}</div>`);
}
const badge=vpnBadge(name);
return `<div class="ncard n-${type}" style="background:none;border:none;box-shadow:none;padding:0;margin:0;display:block;">
<div class="n-title">${escapeHtml(name)}${badge}</div>
${lines.join('')}
</div>`;
}
},
{
query:'node[type = "subnet"]',
halign:'left', valign:'top', halignBox:'left', valignBox:'top',
tpl: function(d){
const name=(d.name||(d.label||'').split('\n')[0]||d.id);
const cidr=d.cidr||'';
const az=d.az||'';
const subline=[cidr, az].filter(Boolean).join(' \u2022 ');
return `<div class=\"subnet-tab\" data-node-id=\"${d.id}\">
<div class=\"t-name\">${escapeHtml(name)}</div>
${subline?`<div class=\"t-sub\">${escapeHtml(subline)}</div>`:''}
</div>`;
}
},
{
query:'node[type = "vpc"]', halign:'center', valign:'center', halignBox:'center', valignBox:'center',
tpl: function(d){ const name=d.name||(d.label||'').split('\n')[0]||d.id; return `<div class="vpc-big">${escapeHtml(name)}</div>`; }
}
]);
}
// Wrap subnet tabs and reset outer transform so plugin can position parent
function prepareSubnetTabs(){
const tabs = Array.from(document.querySelectorAll('.subnet-tab'));
tabs.forEach(el => {
if (!el.querySelector('.subnet-tab-inner')){
const inner=document.createElement('div'); inner.className='subnet-tab-inner';
while (el.firstChild) inner.appendChild(el.firstChild);
el.appendChild(inner);
}
el.style.transform = 'translate(0px,-1px)';
});
}
// Position subnet tabs at left + labelWidth + 10% of node width and match colors
function positionSubnetTabs(){
const containerRect = cy.container().getBoundingClientRect();
const tabs = Array.from(document.querySelectorAll('.subnet-tab'));
if (!tabs.length) return;
const subnets = cy.nodes('[type = "subnet"]');
if (subnets.empty()) return;
for (const el of tabs) {
const r = el.getBoundingClientRect();
const px = r.left + 20, py = r.top + 2; // near top-left of tab
const hit = subnets.filter(n=>{
const bb = n.renderedBoundingBox({ includeNodes:true, includeLabels:false, includeEdges:false });
const left=containerRect.left+bb.x1, top=containerRect.top+bb.y1, right=containerRect.left+bb.x2, bottom=containerRect.top+bb.y2;
return px>=left && px<=right && py>=top && py<=bottom;
})[0];
if (!hit) continue;
const nw = hit.renderedWidth();
const lw = r.width;
const offx = Math.round(lw + nw * 0.10);
el.style.transform = `translate(${offx}px,-1px)`;
// Match node colors
const bg = hit.style('background-color') || '#f3f4f6';
const br = hit.style('border-color') || '#9ca3af';
el.style.backgroundColor = bg;
el.style.borderColor = br;
}
}
// Ensure empty subnets are at least larger than their tab label
function ensureEmptySubnetSizers(){
const subnets = cy.nodes('[type = "subnet"]');
subnets.forEach(sn => {
const nonSizerKids = sn.children().filter('[type != "subnet-sizer"]');
const sid = 'sizer:'+sn.id();
const had = !cy.getElementById(sid).empty();
if (nonSizerKids.length > 0) {
if (had) cy.remove(cy.getElementById(sid));
} else {
if (!had) cy.add({ group:'nodes', data:{ id:sid, type:'subnet-sizer', parent: sn.id() } });
}
});
}
function updateEmptySubnetSizerSizes(){
const tabs = Array.from(document.querySelectorAll('.subnet-tab'));
if (!tabs.length) return;
tabs.forEach(el => {
const id = el.getAttribute('data-node-id');
const sn = id ? cy.getElementById(id) : null;
if (!sn || sn.empty()) return;
const sid = 'sizer:'+sn.id();
const sizer = cy.getElementById(sid);
if (sizer.empty()) return;
const r = el.getBoundingClientRect();
const minW = Math.ceil(r.width) + 24;
const minH = Math.ceil(r.height) + 24;
sizer.style({ width: minW, height: Math.max(60, minH) });
sizer.position(sn.position());
});
}
// Initial and reactive updates
requestAnimationFrame(()=>{ prepareSubnetTabs(); positionSubnetTabs(); ensureEmptySubnetSizers(); updateEmptySubnetSizerSizes(); });
cy.on('layoutstop zoom', () => requestAnimationFrame(()=>{ prepareSubnetTabs(); positionSubnetTabs(); ensureEmptySubnetSizers(); updateEmptySubnetSizerSizes(); }));
window.addEventListener('resize', () => requestAnimationFrame(()=>{ prepareSubnetTabs(); positionSubnetTabs(); updateEmptySubnetSizerSizes(); }));
// --- UI, details, toggles, label edit, save/load, size, search, SG sort ---
const $details=document.getElementById('details');
function ipSortKey(s){
const parts=(s||'').split(' '); const port=parts[1]||''; const src=parts.slice(3).join(' ')||'';
const ip=src.split('/')[0]; const isIPv4=ip.split('.').length===4 && ip.split('.').every(k=>{const n=parseInt(k,10);return !isNaN(n)&&n>=0&&n<=255;});
const ipKey=isIPv4? ip.split('.').map(k=>k.padStart(3,'0')).join('.') + '/' + ((src.split('/')[1]||'32').padStart(2,'0')) : 'zzz~'+src;
const p=parseInt((port.split('-')[0]||'0'),10); const pKey=(isNaN(p)?'99999':(''+p).padStart(5,'0'));
return ipKey+' '+pKey;
}
function sortRules(arr){ return (arr||[]).slice().sort((a,b)=> ipSortKey(a).localeCompare(ipSortKey(b))); }
function showDetails(ele){
if(!ele){ $details.textContent=''; return; }
const d=ele.data(); const base={ id:d.id, type:d.type, label:d.label }; let extra={};
switch(d.type){
case 'vpc': extra={ cidr:d.cidr, name:d.name }; break;
case 'subnet': extra={ cidr:d.cidr, az:d.az, public:d.public }; break;
case 'alb': extra={ scheme:d.scheme, dns:d.dns, listeners:d.listeners, sgs:d.sgs }; break;
case 'ec2': extra={ name:d.name, instanceId:d.instanceId, privateIp:d.privateIp, publicIp:d.publicIp, state:d.state, sgs:d.sgs }; break;
case 'rds': extra={ engine:d.engine, port:d.port, publiclyAccessible:d.publiclyAccessible, sgs:d.sgs }; break;
case 'sg': extra={ inbound_sorted: sortRules(d.rules && d.rules.in), outbound_sorted: sortRules(d.rules && d.rules.out) }; break;
default: extra=d; break;
}
$details.textContent=JSON.stringify({ ...base, ...extra }, null, 2);
}
cy.on('tap','node, edge',(evt)=>showDetails(evt.target));
cy.on('tap',(evt)=>{ if(evt.target===cy) showDetails(null); });
document.getElementById('toggle-sg').addEventListener('change',(e)=>{
const show=e.target.checked;
cy.batch(()=>{ cy.nodes('[type = "sg"]').style('display', show?'element':'none'); cy.edges('.sg-attach, .sg-sg').style('display', show?'element':'none'); });
});
document.getElementById('toggle-routes').addEventListener('change',(e)=>{
const show=e.target.checked;
cy.batch(()=>{ cy.nodes('[type = "igw"],[type = "nat"]').style('display', show?'element':'none'); cy.edges('.route').style('display', show?'element':'none'); });
});
document.getElementById('btn-fit').addEventListener('click', ()=> cy.fit(undefined,24));
document.getElementById('btn-reflow').addEventListener('click', runLayout);
// Label edit mode
let labelMode=false, labelTarget=null, startPos=null, startMargins=null, prevPan=true;
document.getElementById('toggle-label-edit').addEventListener('change',(e)=>{
labelMode=e.target.checked;
if(labelMode){ cy.nodes().ungrabify(); cy.container().style.cursor='crosshair'; }
else{ cy.nodes().grabify(); cy.container().style.cursor=''; }
});
cy.on('mousedown','node',(e)=>{
if(!labelMode) return;
labelTarget=e.target; startPos=e.position;
startMargins={ lx: labelTarget.data('lx')||0, ly: labelTarget.data('ly')||0 };
prevPan=cy.panningEnabled(); cy.panningEnabled(false);
});
cy.on('mousemove',(e)=>{
if(!labelMode || !labelTarget) return;
const dx=e.position.x-startPos.x, dy=e.position.y-startPos.y;
labelTarget.data('lx', startMargins.lx+dx); labelTarget.data('ly', startMargins.ly+dy);
});
cy.on('mouseup',()=>{ if(!labelMode) return; labelTarget=null; cy.panningEnabled(prevPan); });
// Save / Load layout (positions + label offsets)
function snapshotLayout(){ const nodes={}; cy.nodes().forEach(n=>{ if(n.data('type')==='subnet-sizer') return; nodes[n.id()]={ position:n.position(), lx:n.data('lx')||0, ly:n.data('ly')||0 }; }); return { nodes }; }
function downloadJSON(obj,filename){ const blob=new Blob([JSON.stringify(obj,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=filename; a.click(); setTimeout(()=>URL.revokeObjectURL(url),1000); }
function applyLayoutData(data){
cy.batch(()=>{
const map=(data&&data.nodes)||{};
for(const id of Object.keys(map)){ const n=cy.getElementById(id); if(!n||n.empty()) continue; const v=map[id]; if(v.position) n.position(v.position); if(typeof v.lx==='number') n.data('lx',v.lx); if(typeof v.ly==='number') n.data('ly',v.ly); }
});
cy.fit(undefined,24);
}
async function tryLoadStartupLayout(){
try{
const resp = await fetch('layout.json', { cache: 'no-cache' });
if(resp && resp.ok){ const data = await resp.json(); applyLayoutData(data); }
}catch(e){ /* ignore if missing */ }
}
document.getElementById('btn-save').addEventListener('click',()=> downloadJSON(snapshotLayout(),'vpc-layout.json'));
document.getElementById('btn-load').addEventListener('click',()=> document.getElementById('load-file').click());
document.getElementById('load-file').addEventListener('change',(e)=>{
const file=e.target.files[0]; if(!file) return;
const reader=new FileReader(); reader.onload=()=>{ try{ applyLayoutData(JSON.parse(reader.result)); } catch{ alert('Could not parse JSON'); } }; reader.readAsText(file);
});
// Size slider adjusts typography + padding
const sizeSlider=document.getElementById('size-slider');
function applySizeScale(v){
document.documentElement.style.setProperty('--name-size',(16*v)+'px');
document.documentElement.style.setProperty('--detail-size',(12*v)+'px');
cy.style().selector('node').style('padding',(10*v)+'px').update();
requestAnimationFrame(()=>{ prepareSubnetTabs(); positionSubnetTabs(); ensureEmptySubnetSizers(); updateEmptySubnetSizerSizes(); });
}
sizeSlider.addEventListener('input',()=> applySizeScale(parseFloat(sizeSlider.value)));
applySizeScale(parseFloat(sizeSlider.value));
// No additional resize hooks required for subnet tabs
// Autosave positions/offsets so layouts survive re-scrapes
const LKEY='vpc-layout-autosave';
function saveLocal(){ try{ localStorage.setItem(LKEY, JSON.stringify(snapshotLayout())); }catch{} }
function restoreLocal(){ try{ const raw=localStorage.getItem(LKEY); if(raw) applyLayoutData(JSON.parse(raw)); }catch{} }
// Load file-based layout first (if present), then restore any local override
await tryLoadStartupLayout();
restoreLocal();
cy.on('position','node', saveLocal);
cy.on('mouseup', ()=> { if(labelMode) saveLocal(); });
// Search
const $search=document.getElementById('search');
$search.addEventListener('input',()=>{
const q=$search.value.trim().toLowerCase();
cy.batch(()=>{
if(!q){ cy.elements().removeClass('dim'); return; }
cy.nodes().forEach(n=>{
const d=n.data(); const hay=[d.id,d.label,d.name,d.privateIp,d.cidr].join(' ').toLowerCase();
if(hay.includes(q)) n.removeClass('dim'); else n.addClass('dim');
});
});
});
cy.style().selector('.dim').style({ 'opacity': 0.25 }).update();
})();
</script>
</body>
</html>