diff --git a/diagram-config.json b/diagram-config.json new file mode 100644 index 0000000..73f29a0 --- /dev/null +++ b/diagram-config.json @@ -0,0 +1,13 @@ +{ + "subnetTab": { + "translateXExtra": 150, + "translateXMultiplier": -0.3, + "translateYOffset": -8, + "initialYOffset": -5 + }, + "subnetSizer": { + "extraWidth": 100, + "extraHeight": 60, + "minimumHeight": 60 + } +} diff --git a/diagram.js b/diagram.js new file mode 100644 index 0000000..ab8c44f --- /dev/null +++ b/diagram.js @@ -0,0 +1,726 @@ +// 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; + +// 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[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' }} + ] +}); + +// --------------------------------------------------------------------------- +// 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 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 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) : ''}`; + 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(){ + 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 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 = "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 badge=vpnBadge(name); + return `
+
${escapeHtml(name)}${badge}
+ ${lines.join('')} +
`; + } + }, + { + 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)}
`; } + } + ]); + } + + // 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()); + }); + } + + // Re-run every label sizing step so HTML overlays stay in sync with the graph. + function refreshLabelGeometry(){ + prepareSubnetTabs(); + positionSubnetTabs(); + ensureEmptySubnetSizers(); + updateEmptySubnetSizerSizes(); + } + + 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 }; 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 }; } + 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(); +})(); diff --git a/gather.py b/gather.py new file mode 100755 index 0000000..2d96e10 --- /dev/null +++ b/gather.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +import boto3, json, os, sys + +def name_tag(tags): + return next((t['Value'] for t in (tags or []) if t['Key']=='Name'), None) + +def collect(region): + s = boto3.Session(region_name=region) + ec2, elb, rds = s.client('ec2'), s.client('elbv2'), s.client('rds') + out = {k:[] for k in ['vpcs','subnets','sgs','enis','ec2','lbs','rds','exposures']} + + # VPCs, Subnets, SGs, ENIs, Instances + vpcs = ec2.describe_vpcs()['Vpcs'] + subnets = ec2.describe_subnets()['Subnets'] + sgs = ec2.describe_security_groups()['SecurityGroups'] + enis = ec2.describe_network_interfaces()['NetworkInterfaces'] + resv = ec2.describe_instances()['Reservations'] + + for v in vpcs: + out['vpcs'].append({'id':v['VpcId'],'cidr':v['CidrBlock'],'name':name_tag(v.get('Tags')) or v['VpcId']}) + for sn in subnets: + out['subnets'].append({ + 'id':sn['SubnetId'],'vpc':sn['VpcId'],'cidr':sn['CidrBlock'], + 'az':sn['AvailabilityZone'],'public':sn.get('MapPublicIpOnLaunch',False) + }) + for g in sgs: + def flatten(perms): + r=[] + for p in perms: + proto=p.get('IpProtocol'); frm=p.get('FromPort'); to=p.get('ToPort') + port = f"{frm}" if frm==to else f"{frm}-{to}" if frm is not None else "all" + for ipr in p.get('IpRanges',[]) + p.get('Ipv6Ranges',[]): + cidr = ipr.get('CidrIp') or ipr.get('CidrIpv6') + r.append(f"{proto} {port} from {cidr}") + for sgr in p.get('UserIdGroupPairs',[]): + r.append(f"{proto} {port} from {sgr['GroupId']}") + return r + out['sgs'].append({'id':g['GroupId'],'name':g.get('GroupName'), + 'rules_in':flatten(g.get('IpPermissions',[])), + 'rules_out':flatten(g.get('IpPermissionsEgress',[]))}) + for ni in enis: + assoc = ni.get('Association',{}) + out['enis'].append({ + 'id':ni['NetworkInterfaceId'],'subnet':ni['SubnetId'], + 'sgs':[sg['GroupId'] for sg in ni.get('Groups',[])], + 'privateIp':ni.get('PrivateIpAddress'), + 'publicIp':assoc.get('PublicIp') + }) + for r in resv: + for i in r['Instances']: + n = name_tag(i.get('Tags')) or i['InstanceId'] + eni = (i.get('NetworkInterfaces') or [{}])[0] + out['ec2'].append({ + 'id': i['InstanceId'], + 'name': n, + 'type': i['InstanceType'], + 'privateIp': i.get('PrivateIpAddress'), + 'publicIp': i.get('PublicIpAddress'), # shows on label + 'state': i.get('State', {}).get('Name', 'unknown'), # running/stopped + 'sgs': [g['GroupId'] for g in i.get('SecurityGroups',[])], + 'subnet': i.get('SubnetId') + }) + + # Load balancers (ALB/NLB), listeners, target groups/targets + try: + lbs = elb.describe_load_balancers()['LoadBalancers'] + except elb.exceptions.UnsupportedFeatureException: + lbs = [] + for lb in lbs: + lbid = lb['LoadBalancerName'] + listeners = elb.describe_listeners(LoadBalancerArn=lb['LoadBalancerArn'])['Listeners'] + lst = [{'proto':L['Protocol'],'port':L['Port']} for L in listeners] + tgs = elb.describe_target_groups(LoadBalancerArn=lb['LoadBalancerArn'])['TargetGroups'] + tga=[] + for tg in tgs: + th = elb.describe_target_health(TargetGroupArn=tg['TargetGroupArn'])['TargetHealthDescriptions'] + targets=[thd['Target']['Id'] for thd in th] + tga.append({'port':tg.get('Port'),'targets':targets}) + out['lbs'].append({ + 'id': lbid, + 'scheme': lb['Scheme'], + 'type': lb['Type'], + 'dns': lb.get('DNSName'), # add me (handy on the label) + 'subnets': [z['SubnetId'] for z in lb.get('AvailabilityZones',[])], + 'securityGroups': lb.get('SecurityGroups',[]), + 'listeners': lst, 'targetGroups': tga + }) + # Simple exposure: internet-facing LB listeners are public surfaces + if lb['Scheme']=='internet-facing': + for L in lst: + out['exposures'].append({'surface':f"{lbid}:{L['port']}", + 'world_open':True,'via':lb['Type'].upper(), + 'to': [f"{t}:{next((tg['port'] for tg in tga if t in tg['targets']),None)}" for t in sum([tg['targets'] for tg in tga],[])]}) + + # RDS + for db in rds.describe_db_instances()['DBInstances']: + out['rds'].append({ + 'id':db['DBInstanceIdentifier'],'engine':db['Engine'], + 'port':db['DbInstancePort'] if 'DbInstancePort' in db else db.get('Endpoint',{}).get('Port'), + 'publiclyAccessible':db.get('PubliclyAccessible',False), + 'sgs':[v['VpcSecurityGroupId'] for v in db.get('VpcSecurityGroups',[])], + 'subnetGroup':[s['SubnetIdentifier'] for s in db.get('DBSubnetGroup',{}).get('Subnets',[])] + }) + return out + +def main(): + regions = sys.argv[1:] or [boto3.Session().region_name or 'us-west-1'] + graph = {'regions':{}} + for r in regions: + graph['regions'][r] = collect(r) + with open('graph.json','w') as f: json.dump(graph, f, indent=2) + print('Wrote graph.json for regions:', ', '.join(regions)) + +if __name__=='__main__': main() diff --git a/layout.json b/layout.json new file mode 100755 index 0000000..7da99b0 --- /dev/null +++ b/layout.json @@ -0,0 +1,428 @@ +{ + "nodes": { + "vpc-87832de2": { + "position": { + "x": 756.8840099911947, + "y": 1038.4416407451458 + }, + "lx": 77.51854351992131, + "ly": 139.5230395934425 + }, + "vpc-c84eabad": { + "position": { + "x": -922.864188724591, + "y": 204.9358257138463 + }, + "lx": 0, + "ly": 0 + }, + "vpc-331ad056": { + "position": { + "x": -967.090481563079, + "y": 1291.3678057051704 + }, + "lx": -134.22057243218103, + "ly": 551.6318031828098 + }, + "vpc-afe722ca": { + "position": { + "x": 593.7116051419532, + "y": 2085.060815603146 + }, + "lx": 0, + "ly": 0 + }, + "vpc-03f799f47b766798c": { + "position": { + "x": -326.8836182123258, + "y": 2966.579150844579 + }, + "lx": 0, + "ly": 0 + }, + "subnet-260ee27f": { + "position": { + "x": -921.340481563079, + "y": 1121.1067694739904 + }, + "lx": 0, + "ly": 0 + }, + "subnet-f73afeae": { + "position": { + "x": -963.9358574966905, + "y": 1846.410916593678 + }, + "lx": 0, + "ly": 0 + }, + "subnet-ebc4188e": { + "position": { + "x": 642.7195008673785, + "y": 1787.9702751209763 + }, + "lx": 0, + "ly": 0 + }, + "subnet-005bfd58e719e031f": { + "position": { + "x": 954.3699608755894, + "y": 767.1904595742493 + }, + "lx": 0, + "ly": 0 + }, + "subnet-035f81142068bf961": { + "position": { + "x": -727.9701282543923, + "y": 2945.1772029052686 + }, + "lx": 0, + "ly": 0 + }, + "subnet-d137fd88": { + "position": { + "x": 1035.1966337030872, + "y": 1260.4186551195244 + }, + "lx": -95.54884268669878, + "ly": -183.7839139599664 + }, + "subnet-e98e088c": { + "position": { + "x": -556.0376711165936, + "y": 1846.2285577580044 + }, + "lx": 0, + "ly": 0 + }, + "subnet-08e40dcdc103438b8": { + "position": { + "x": 549.0070924119668, + "y": 1405.912770994567 + }, + "lx": 0, + "ly": 0 + }, + "subnet-0cb2aa7457413f5fe": { + "position": { + "x": -806.65058364074, + "y": 3191.268048237053 + }, + "lx": 0, + "ly": 0 + }, + "subnet-247a9341": { + "position": { + "x": -675.3228356289688, + "y": 122.98852970650637 + }, + "lx": 0, + "ly": 0 + }, + "subnet-34173f72": { + "position": { + "x": -1080.405541820213, + "y": 323.3831217211862 + }, + "lx": 0, + "ly": 0 + }, + "subnet-05b5dcdc91a8d3da2": { + "position": { + "x": 550.3010345611413, + "y": 1208.7638869426428 + }, + "lx": -56.280967047329966, + "ly": 12.354358620145604 + }, + "subnet-16594550": { + "position": { + "x": 638.2037094165278, + "y": 2341.8441019824586 + }, + "lx": 0, + "ly": 0 + }, + "subnet-051f6d705e415834d": { + "position": { + "x": 108.34330910685321, + "y": 2965.3802332565056 + }, + "lx": 0, + "ly": 0 + }, + "sg-642cf701": { + "position": { + "x": 1368.280952213551, + "y": 1732.122796782028 + }, + "lx": 0, + "ly": 0 + }, + "sg-00600deefd8e47cd0": { + "position": { + "x": 86.73398115413455, + "y": -110.88418680061903 + }, + "lx": 0, + "ly": 0 + }, + "sg-da75adbf": { + "position": { + "x": 1654.4153990786074, + "y": 1502.4913097934757 + }, + "lx": 0, + "ly": 0 + }, + "sg-c05a2ba5": { + "position": { + "x": -139.91274956991595, + "y": 23.51131978002694 + }, + "lx": 0, + "ly": 0 + }, + "sg-0c00315bbb744b876": { + "position": { + "x": 90.45962176444954, + "y": 173.12646633830315 + }, + "lx": 0, + "ly": 0 + }, + "sg-495ab32c": { + "position": { + "x": 26.527694171634298, + "y": 307.9443846885058 + }, + "lx": 0, + "ly": 0 + }, + "sg-0f2465c4421c2d5c2": { + "position": { + "x": 1501.4220287756627, + "y": 1088.2588218739315 + }, + "lx": 0, + "ly": 0 + }, + "sg-8b746aec": { + "position": { + "x": 11.800399713295008, + "y": 754.3055340378875 + }, + "lx": -46.67202145388339, + "ly": 75.49885823422312 + }, + "sg-0a872ac3a6d9132a6": { + "position": { + "x": -273.8173000765115, + "y": 2531.372427285566 + }, + "lx": 0, + "ly": 0 + }, + "sg-076d2ea1df0054074": { + "position": { + "x": 297.4040219907455, + "y": 2392.000439156539 + }, + "lx": 0, + "ly": 0 + }, + "sg-b45a2bd1": { + "position": { + "x": 1496.2207074206092, + "y": 700.5423557214449 + }, + "lx": 0, + "ly": 0 + }, + "sg-4235ee27": { + "position": { + "x": 1642.8599868444235, + "y": 2133.250105847895 + }, + "lx": 0, + "ly": 0 + }, + "sg-04167d2e18ba15e45": { + "position": { + "x": -660.0058544043945, + "y": 2397.5181639300217 + }, + "lx": 0, + "ly": 0 + }, + "sg-06348763": { + "position": { + "x": 23.18530161221225, + "y": 976.1080421357864 + }, + "lx": 0, + "ly": 0 + }, + "sg-08fb4b412f90d5913": { + "position": { + "x": 57.433554297476746, + "y": 1529.3600510391416 + }, + "lx": 0, + "ly": 0 + }, + "sg-09621eafa81d9554d": { + "position": { + "x": 1492.9199816248513, + "y": 472.53158920519866 + }, + "lx": 0, + "ly": 0 + }, + "i-0dc281f8d162602c8": { + "position": { + "x": -1201.9240820025686, + "y": 1170.4054563966229 + }, + "lx": 0, + "ly": 0 + }, + "i-0997a73b08f6e5862": { + "position": { + "x": 696.9537094165278, + "y": 2289.286847879602 + }, + "lx": 0, + "ly": 0 + }, + "i-0c8100e3460fa8fd0": { + "position": { + "x": -530.5097131980381, + "y": 1487.4564852956444 + }, + "lx": 0, + "ly": 0 + }, + "i-041021cd89e15282c": { + "position": { + "x": 702.4695008673785, + "y": 1812.2202751209763 + }, + "lx": 0, + "ly": 0 + }, + "i-073b97cbda2b200c3": { + "position": { + "x": 1032.1453149375961, + "y": 1118.630957918435 + }, + "lx": 0, + "ly": 0 + }, + "i-0669f35ab2d0fc444": { + "position": { + "x": 1029.7295075033508, + "y": 1292.025634730957 + }, + "lx": 0, + "ly": 0 + }, + "i-01b3f3cd57976bdf3": { + "position": { + "x": 1157.7479524685782, + "y": 1450.706352320614 + }, + "lx": 0, + "ly": 0 + }, + "i-0272763b46610ac1b": { + "position": { + "x": -1204.2644686266099, + "y": 853.4607536515473 + }, + "lx": 238.46757499625102, + "ly": -50.2036999992107 + }, + "i-0c82adf476c7c5e32": { + "position": { + "x": -1193.7978959064258, + "y": 1010.1880963959779 + }, + "lx": 0, + "ly": 0 + }, + "i-0636fd0b033c9b32a": { + "position": { + "x": -515.2568811235894, + "y": 1298.8022809002794 + }, + "lx": 0, + "ly": 0 + }, + "i-09241599c2590b66a": { + "position": { + "x": -555.6056787872835, + "y": 886.9481918477713 + }, + "lx": 0, + "ly": 0 + }, + "i-0d7f643cb9d960645": { + "position": { + "x": 57.30327099761796, + "y": 2832.8902534521053 + }, + "lx": 0, + "ly": 0 + }, + "i-082b27477bbe6d8b5": { + "position": { + "x": -666.2201282543923, + "y": 2969.4272029052686 + }, + "lx": 0, + "ly": 0 + }, + "f8-db-01": { + "position": { + "x": -518.2876711165936, + "y": 1864.4785577580044 + }, + "lx": 0, + "ly": 0 + }, + "logon-db-02": { + "position": { + "x": 694.8906262727602, + "y": 2442.9013560853155 + }, + "lx": 0, + "ly": 0 + }, + "vault-db-production-v2": { + "position": { + "x": 991.0522677808019, + "y": 699.176929169678 + }, + "lx": 0, + "ly": 0 + }, + "vault-db-staging-v2": { + "position": { + "x": 989.1876539703769, + "y": 871.7039899788206 + }, + "lx": 0, + "ly": 0 + }, + "NodeAppALB": { + "position": { + "x": 282.38334721608845, + "y": 3145.8702130609063 + }, + "lx": 0, + "ly": 0 + } + } +} diff --git a/layout.php b/layout.php new file mode 100644 index 0000000..d2de255 --- /dev/null +++ b/layout.php @@ -0,0 +1,54 @@ + new stdClass()], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + exit; +} + +if ($method === 'POST') { + $raw = file_get_contents('php://input'); + if ($raw === false || trim($raw) === '') { + http_response_code(400); + echo json_encode(['error' => 'Empty request body']); + exit; + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + http_response_code(400); + echo json_encode(['error' => 'Invalid JSON payload']); + exit; + } + + $encoded = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($encoded === false) { + http_response_code(500); + echo json_encode(['error' => 'Failed to encode layout']); + exit; + } + + $result = @file_put_contents($layoutPath, $encoded . PHP_EOL, LOCK_EX); + if ($result === false) { + http_response_code(500); + echo json_encode(['error' => 'Failed to write layout file']); + exit; + } + + echo json_encode(['status' => 'ok']); + exit; +} + +http_response_code(405); +header('Allow: GET, POST'); +echo json_encode(['error' => 'Method not allowed']);