// High-level walkthrough: this file wires Cytoscape to our AWS graph data, sets up HTML labels, // and layers on editing + layout persistence. Each section below is documented so you can skim // from top to bottom and understand the flow end to end. // 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); // --------------------------------------------------------------------------- // Config loading + persistence // --------------------------------------------------------------------------- const diagramConfig = { subnetTab: { translateXExtra: 0, translateXMultiplier: 0.1, translateYOffset: -1, initialYOffset: -1 }, subnetSizer: { extraWidth: 24, extraHeight: 24, minimumHeight: 60 } }; const CFG_STORAGE_KEY = 'diagram-config-overrides'; const CONTROL_PANEL_STORAGE_KEY = 'label-control-panel-visible'; let diagramConfigOverrides = {}; let initialDiagramConfig = null; const cssEscape = (window.CSS && typeof window.CSS.escape === 'function') ? window.CSS.escape.bind(window.CSS) : function(value) { return String(value).replace(/(["\\\s#.:\[\]\(\)])/g, '\\$1'); }; // Helper to deep-clone the config so we can reset back to defaults later. function cloneDiagramConfig(obj) { return JSON.parse(JSON.stringify(obj)); } // Load any fine-tuning the user stored locally via the control panel. function loadConfigOverridesFromStorage() { try { const raw = localStorage.getItem(CFG_STORAGE_KEY); if (!raw) return {}; const parsed = JSON.parse(raw); return parsed && typeof parsed === 'object' ? parsed : {}; } catch { return {}; } } // Persist overrides back to localStorage, cleaning up when empty. function saveConfigOverridesToStorage() { try { const empty = !diagramConfigOverrides || Object.keys(diagramConfigOverrides).length === 0; if (empty) { localStorage.removeItem(CFG_STORAGE_KEY); } else { localStorage.setItem(CFG_STORAGE_KEY, JSON.stringify(diagramConfigOverrides)); } } catch { /* ignore */ } } // Tidy empty sections so the stored config stays compact. function pruneOverrideSection(section) { if (diagramConfigOverrides && diagramConfigOverrides[section] && Object.keys(diagramConfigOverrides[section]).length === 0) { delete diagramConfigOverrides[section]; } } // Merge a config override tree into the live diagram config. function mergeDiagramConfig(target, source) { if (!source || typeof source !== 'object') return; for (const [section, overrides] of Object.entries(source)) { const current = target[section]; if (!current || typeof overrides !== 'object') continue; for (const [key, value] of Object.entries(overrides)) { if (Object.prototype.hasOwnProperty.call(current, key) && typeof value === 'number') { current[key] = value; } } } } async function loadDiagramConfig() { try { const resp = await fetch('diagram-config.json', { cache: 'no-cache' }); if (resp && resp.ok) { mergeDiagramConfig(diagramConfig, await resp.json()); } } catch (err) { console.warn('Could not load diagram-config.json, using defaults.', err); } if (initialDiagramConfig === null) { initialDiagramConfig = cloneDiagramConfig(diagramConfig); } diagramConfigOverrides = loadConfigOverridesFromStorage(); mergeDiagramConfig(diagramConfig, diagramConfigOverrides); return diagramConfig; } // --------------------------------------------------------------------------- // Layout persistence helpers (server + local) // --------------------------------------------------------------------------- async function fetchServerLayout() { try { const resp = await fetch('layout.php', { cache: 'no-cache' }); if (!resp || !resp.ok) return null; return await resp.json(); } catch (err) { console.warn('Could not retrieve server layout.', err); return null; } } async function saveLayoutToServer(layout) { try { const resp = await fetch('layout.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(layout) }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); return await resp.json(); } catch (err) { console.warn('Failed to save layout to server.', err); throw err; } } // 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[type = "account"]', style: { 'background-color': '#0b1220', 'background-opacity': 0.45, 'border-color': 'data(color)', 'border-width': 2, 'padding': '90px', 'shape': 'round-rectangle', 'text-opacity': 0, 'z-compound-depth': 'bottom' }}, { 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-opacity': 0, 'border-width': 0, 'width': 260, 'height': 32, 'padding': 8 }}, { 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' }} ] }); // --------------------------------------------------------------------------- // Graph assembly: turn the JSON inventory into Cytoscape elements // --------------------------------------------------------------------------- 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') { // Fetch the latest inventory and flatten it into Cytoscape nodes + edges. const resp = await fetch('graph.json'); // serve over http:// const graph = await resp.json(); const g = (graph.regions && graph.regions[region]) || {}; const elements = []; const accountPalette = ['#7c3aed','#0ea5e9','#f97316','#10b981','#facc15','#f472b6']; const accountMeta = new Map(); function canonicalAccountId(value, fallbackIndex){ const raw = (value ?? '').toString().trim(); return raw ? raw.replace(/\s+/g,'-') : `account-${fallbackIndex}`; } function pickAccountColor(index){ return accountPalette[index % accountPalette.length]; } function registerAccount(rawId, label, color, alias){ const info = { id: rawId, label, color, nodeId: `account:${rawId}`, alias: alias || null }; accountMeta.set(rawId, info); elements.push({ data: { id: info.nodeId, type:'account', label, color, rawId, alias: info.alias } }); return info; } // Accounts are optional; gather them from the top-level array and from any // `accountId` fields present on VPC records so multiple AWS accounts can share one canvas. const accounts = Array.isArray(graph.accounts) ? graph.accounts : []; accounts.forEach((acct, index) => { const rawId = canonicalAccountId(acct.id || acct.accountId || acct.alias, index + 1); const color = acct.color || acct.colour || acct.accent || pickAccountColor(index); const label = acct.label || acct.name || rawId; const alias = acct.alias || acct.accountAlias || null; registerAccount(rawId, label, color, alias); }); function ensureAccount(raw){ if (raw == null) return null; const canonical = canonicalAccountId(raw, accountMeta.size + 1); if (accountMeta.has(canonical)) return accountMeta.get(canonical); return registerAccount(canonical, canonical, pickAccountColor(accountMeta.size), null); } 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 vpcAccountMeta = new Map(); const subnetAccountMeta = new Map(); const sgById = Object.fromEntries(sgs.map(s => [s.id, s])); // VPCs for (const v of vpcs) { const acctInfo = ensureAccount(v.accountId || v.account || v.account_id || null); if (acctInfo) vpcAccountMeta.set(v.id, acctInfo); const parent = acctInfo ? acctInfo.nodeId : undefined; elements.push({ data: { id: v.id, type: 'vpc', label: `${v.name}\n${v.cidr}`, cidr: v.cidr, name: v.name, accountId: acctInfo ? acctInfo.id : null, accountLabel: acctInfo ? acctInfo.label : null, parent } }); } // Subnets for (const s of subnets) { const label = `${(s.name||s.id)}\n${s.cidr}\n${s.az}`; const fallbackAcct = vpcAccountMeta.get(s.vpc || '') || null; const acctInfo = ensureAccount(s.accountId || (fallbackAcct && fallbackAcct.id) || null) || fallbackAcct; if (acctInfo) subnetAccountMeta.set(s.id, acctInfo); elements.push({ data: { id: s.id, type: 'subnet', label, cidr: s.cidr, az: s.az, public: !!s.public, parent: s.vpc, accountId: acctInfo ? acctInfo.id : null } }); } // SGs for (const sg of sgs) { const name = sg.name || sg.id; const label = `${name}\n${sg.id}`; const rin = sg.rules_in || [], rout = sg.rules_out || []; const fallbackAcct = vpcAccountMeta.get(sg.vpc || '') || null; const acctInfo = ensureAccount(sg.accountId || (fallbackAcct && fallbackAcct.id) || null) || fallbackAcct; const parent = acctInfo ? acctInfo.nodeId : undefined; elements.push({ data: { id: sg.id, type: 'sg', label, name, rules: { in: rin, out: rout }, parent, accountId: acctInfo ? acctInfo.id : null, vpc: sg.vpc || null } }); } // 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 aT 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) : ''}`; const fallbackAcct = subnetAccountMeta.get(i.subnet || '') || vpcAccountMeta.get(i.vpc || '') || null; const acctInfo = ensureAccount(i.accountId || (fallbackAcct && fallbackAcct.id) || null) || fallbackAcct; 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 || [], accountId: acctInfo ? acctInfo.id : null, vpc: i.vpc || null } }); 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 primaryLine = (db.engine || '').split('\n')[0]; const label = primaryLine ? `${db.id}\n${primaryLine}` : db.id; const fallbackAcct = subnetAccountMeta.get(parentSubnet || '') || null; const acctInfo = ensureAccount(db.accountId || (fallbackAcct && fallbackAcct.id) || null) || fallbackAcct; elements.push({ data: { id: db.id, type:'rds', label, engine:db.engine, port:db.port, publiclyAccessible:!!db.publiclyAccessible, parent: parentSubnet, sgs: db.sgs || [], accountId: acctInfo ? acctInfo.id : null } }); 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}`; const fallbackAcct = subnetAccountMeta.get(parent || '') || null; const acctInfo = ensureAccount(lb.accountId || (fallbackAcct && fallbackAcct.id) || null) || fallbackAcct; 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, accountId: acctInfo ? acctInfo.id : null } }); 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(){ await loadDiagramConfig(); 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=>({"&":"&","<":"<",">":">","\"":""","'":"'"}[c])); } function iconSvg(type){ switch(type){ case 'ec2': return ''; case 'rds': return ''; case 'alb': return ''; case 'nlb': return ''; case 'sg': return ''; case 'nat': return ''; case 'igw': return ''; default: return ''; } } function vpnBadge(name){ return (name && /vpn/i.test(name)) ? 'VPN' : ''; } function typeBadge(type){ switch(type){ case 'nat': return 'NAT'; case 'igw': return 'IGW'; case 'endpoint': case 'vpce': case 'gateway-endpoint': return 'VPCE'; case 'sg': return 'SG'; default: return ''; } } function statusDot(state){ const cls=(state==='running')?'run':(state==='stopped'?'stop':'unk'); const txt=state||'unknown'; return `${escapeHtml(txt)}`; } 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 = "account"]', halign:'left', valign:'top', halignBox:'left', valignBox:'top', tpl: function(d){ const accent=d.color||'#38bdf8'; const name=d.label||(d.rawId||d.id||'account'); return `
`; } }, { 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(`
${statusDot(d.state)}
`); if (d.instanceId) lines.push(`
${escapeHtml(d.instanceId)}
`); if (priv) lines.push(`
Private IP: ${escapeHtml(priv)}
`); if (pub) lines.push(`
Public: ${escapeHtml(pub)}
`); } else { if (detailA) lines.push(`
${escapeHtml(detailA)}
`); if (priv) lines.push(`
Private IP: ${escapeHtml(priv)}
`); if (pub) lines.push(`
Public: ${escapeHtml(pub)}
`); } const badges=[]; const vpnChip=vpnBadge(name); if (vpnChip) badges.push(vpnChip); const typeChip=typeBadge(type); if (typeChip) badges.push(typeChip); const badgeHtml=badges.join(''); return `
${iconSvg(type)}${escapeHtml(name)}${badgeHtml}
${lines.join('')}
`; } }, { query:'node[type = "alb"], node[type = "nlb"]', halign:'center', valign:'center', halignBox:'center', valignBox:'center', tpl: function(d){ const icon = iconSvg(d.type === 'nlb' ? 'nlb' : 'alb'); const name = (d.label || '').split('\n')[0] || d.id; const dns = d.dns ? `
${escapeHtml(d.dns)}
` : ''; const listeners = Array.isArray(d.listeners) ? d.listeners.map(L => L.port).join(', ') : ''; const listenerLine = listeners ? `
listeners: ${escapeHtml(listeners)}
` : ''; const badges=[]; const typeChip=typeBadge(d.type); if (typeChip) badges.push(typeChip); const badgeHtml=badges.join(''); return `
${icon} ${escapeHtml(name)}${badgeHtml}
${dns} ${listenerLine}
`; } }, { query:'node[type = "sg"]', halign:'center', valign:'center', halignBox:'center', valignBox:'center', tpl: function(d){ const rules = sgRuleCount(d); const badges=[]; const typeChip=typeBadge('sg'); if (typeChip) badges.push(typeChip); const badgeHtml=badges.join(''); return `
${escapeHtml(d.name || d.id)}${badgeHtml}
${escapeHtml(d.id)} · ${escapeHtml(`${rules} rule${rules===1?'':'s'}`)}
`; } }, { query:'node[type = "nat"], node[type = "igw"], node[type = "vpn"]', halign:'center', valign:'center', halignBox:'center', valignBox:'center', tpl: function(d){ const name=d.name||(d.label||'').split('\n')[0]||d.id; const icon = iconSvg(d.type); const badges=[]; const vpnChip=vpnBadge(name); if (vpnChip) badges.push(vpnChip); const typeChip=typeBadge(d.type); if (typeChip) badges.push(typeChip); const badgeHtml=badges.join(''); return `
${icon} ${escapeHtml(name)}${badgeHtml}
`; } }, { 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 `
${escapeHtml(name)}
${subline?`
${escapeHtml(subline)}
`:''}
`; } }, { 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 `
${escapeHtml(name)}
`; } } ]); requestAnimationFrame(ensureNodeWidthsFitLabels); } // 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,${diagramConfig.subnetTab.initialYOffset}px)`; }); } // After the plugin renders a subnet tab, align it relative to the owning subnet node. function positionSubnetTabs(){ const tabs = Array.from(document.querySelectorAll('.subnet-tab')); if (!tabs.length) return; for (const el of tabs) { const r = el.getBoundingClientRect(); const nodeId = el.getAttribute('data-node-id'); if (!nodeId) continue; const subnetNode = cy.getElementById(nodeId); if (!subnetNode || subnetNode.empty()) continue; const tabCfg = diagramConfig.subnetTab; const nw = subnetNode.renderedWidth(); const lw = r.width; const offx = Math.round(lw + nw * tabCfg.translateXMultiplier + tabCfg.translateXExtra); el.style.transform = `translate(${offx}px,${tabCfg.translateYOffset}px)`; // Match node colors const bg = subnetNode.style('background-color') || '#f3f4f6'; const br = subnetNode.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 scfg = diagramConfig.subnetSizer; const minW = Math.ceil(r.width) + scfg.extraWidth; const minH = Math.ceil(r.height) + scfg.extraHeight; sizer.style({ width: minW, height: Math.max(scfg.minimumHeight, minH) }); sizer.position(sn.position()); }); } const NODE_MIN_WIDTH = { alb: 260, nlb: 260, ec2: 260, rds: 260, sg: 220 }; function ensureNodeWidthsFitLabels(){ const nodes = cy.nodes('[type = "alb"], [type = "nlb"], [type = "ec2"], [type = "rds"], [type = "sg"]'); if (nodes.empty()) return; const margin = 32; cy.batch(() => { nodes.forEach(node => { const id = node.id(); const labelEl = document.querySelector(`.ncard[data-node-id="${cssEscape(id)}"]`); if (!labelEl) return; const rect = labelEl.getBoundingClientRect(); if (!rect || !rect.width) return; const type = node.data('type'); const minWidth = NODE_MIN_WIDTH[type] || 220; const desired = Math.max(minWidth, Math.ceil(rect.width) + margin); if (Math.abs(node.width() - desired) > 1) { node.style('width', desired); } }); }); } // Re-run every label sizing step so HTML overlays stay in sync with the graph. function refreshLabelGeometry(){ prepareSubnetTabs(); positionSubnetTabs(); ensureEmptySubnetSizers(); updateEmptySubnetSizerSizes(); requestAnimationFrame(ensureNodeWidthsFitLabels); } let renderRefreshScheduled = false; cy.on('render', () => { if (renderRefreshScheduled) return; renderRefreshScheduled = true; requestAnimationFrame(() => { renderRefreshScheduled = false; refreshLabelGeometry(); }); }); // Initial and reactive updates requestAnimationFrame(refreshLabelGeometry); cy.on('layoutstop zoom', () => requestAnimationFrame(refreshLabelGeometry)); window.addEventListener('resize', () => requestAnimationFrame(refreshLabelGeometry)); // --- 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, accountId:d.accountId, accountLabel:d.accountLabel }; break; case 'account': extra={ label:d.label, color:d.color, rawId:d.rawId, alias:d.alias }; 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, accountId:d.accountId }; break; case 'ec2': extra={ name:d.name, instanceId:d.instanceId, privateIp:d.privateIp, publicIp:d.publicIp, state:d.state, sgs:d.sgs, accountId:d.accountId }; break; case 'rds': extra={ engine:d.engine, port:d.port, publiclyAccessible:d.publiclyAccessible, sgs:d.sgs, accountId:d.accountId }; break; case 'sg': extra={ inbound_sorted: sortRules(d.rules && d.rules.in), outbound_sorted: sortRules(d.rules && d.rules.out), accountId: d.accountId }; 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 }; } const SERVER_SAVE_DEBOUNCE_MS = 2000; let serverSaveTimer = null; let serverSaveReady = false; // Batch quick drags into a single POST so we do not hammer the server while editing. function queueServerSave(){ if (!serverSaveReady) return; if (serverSaveTimer) clearTimeout(serverSaveTimer); serverSaveTimer = setTimeout(async () => { serverSaveTimer = null; try { await saveLayoutToServer(snapshotLayout()); } catch (err) { console.warn('Deferred server save failed.', err); } }, SERVER_SAVE_DEBOUNCE_MS); } 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(){ const data = await fetchServerLayout(); if (data) applyLayoutData(data); } document.getElementById('btn-save').addEventListener('click', async()=>{ const snapshot = snapshotLayout(); downloadJSON(snapshot,'vpc-layout.json'); try { if (serverSaveTimer) { clearTimeout(serverSaveTimer); serverSaveTimer = null; } await saveLayoutToServer(snapshot); } catch (err) { alert('Could not save layout to server. A download was provided instead.'); } }); 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); }); // Bind UI inputs to config keys so the control panel feels like a mini playground. const configBindings=[ { id:'cfg-translate-extra', section:'subnetTab', key:'translateXExtra' }, { id:'cfg-translate-mult', section:'subnetTab', key:'translateXMultiplier' }, { id:'cfg-translate-y', section:'subnetTab', key:'translateYOffset' }, { id:'cfg-initial-y', section:'subnetTab', key:'initialYOffset' }, { id:'cfg-sizer-extra-w', section:'subnetSizer', key:'extraWidth' }, { id:'cfg-sizer-extra-h', section:'subnetSizer', key:'extraHeight' }, { id:'cfg-sizer-min-h', section:'subnetSizer', key:'minimumHeight' } ]; function setConfigValue(section, key, value){ if (!diagramConfig[section] || typeof value !== 'number' || Number.isNaN(value)) return; diagramConfig[section][key] = value; const hasInitial = initialDiagramConfig && initialDiagramConfig[section] && typeof initialDiagramConfig[section][key] === 'number'; const initialValue = hasInitial ? initialDiagramConfig[section][key] : undefined; if (hasInitial && initialValue === value) { if (diagramConfigOverrides[section]) { delete diagramConfigOverrides[section][key]; pruneOverrideSection(section); } } else { if (!diagramConfigOverrides[section]) diagramConfigOverrides[section] = {}; diagramConfigOverrides[section][key] = value; } saveConfigOverridesToStorage(); requestAnimationFrame(refreshLabelGeometry); } function applyInputsFromConfig(){ for (const binding of configBindings) { const input=document.getElementById(binding.id); if (!input) continue; const section=diagramConfig[binding.section]; if (!section) continue; const val=section[binding.key]; if (typeof val === 'number') input.value = String(val); } } function setupConfigControls(){ applyInputsFromConfig(); for (const binding of configBindings) { const input=document.getElementById(binding.id); if (!input) continue; input.addEventListener('input',()=>{ const num=Number(input.value); if (!Number.isFinite(num)) return; setConfigValue(binding.section, binding.key, num); input.value = String(num); }); input.addEventListener('change',()=>{ const num=Number(input.value); if (!Number.isFinite(num)) { applyInputsFromConfig(); } }); } const resetBtn=document.getElementById('cfg-reset'); if (resetBtn) { resetBtn.addEventListener('click',()=>{ if (!initialDiagramConfig) return; diagramConfigOverrides = {}; for (const binding of configBindings) { const baseSection = initialDiagramConfig[binding.section]; if (!baseSection) continue; const baseVal = baseSection[binding.key]; if (typeof baseVal !== 'number') continue; diagramConfig[binding.section][binding.key] = baseVal; } saveConfigOverridesToStorage(); applyInputsFromConfig(); requestAnimationFrame(refreshLabelGeometry); }); } } // 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(refreshLabelGeometry); } 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(); const controlPanel = document.getElementById('label-control-panel'); const toggleLabelControls = document.getElementById('toggle-label-controls'); function applyControlPanelVisibility(show){ if (!controlPanel) return; controlPanel.classList.toggle('hidden', !show); } let controlPanelVisible = false; try{ const stored = localStorage.getItem(CONTROL_PANEL_STORAGE_KEY); if (stored !== null) controlPanelVisible = stored === 'true'; }catch{} applyControlPanelVisibility(controlPanelVisible); if (toggleLabelControls) { toggleLabelControls.checked = controlPanelVisible; toggleLabelControls.addEventListener('change',()=>{ controlPanelVisible = !!toggleLabelControls.checked; applyControlPanelVisibility(controlPanelVisible); try{ if (controlPanelVisible) localStorage.setItem(CONTROL_PANEL_STORAGE_KEY,'true'); else localStorage.setItem(CONTROL_PANEL_STORAGE_KEY,'false'); }catch{} if (controlPanelVisible) requestAnimationFrame(refreshLabelGeometry); }); } setupConfigControls(); setTimeout(()=>{ serverSaveReady = true; }, 0); cy.on('position','node', ()=>{ saveLocal(); queueServerSave(); }); cy.on('mouseup', ()=> { if(labelMode) { saveLocal(); queueServerSave(); } }); // 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(); })();