major upgrade

This commit is contained in:
Peter Howell 2025-10-08 20:50:13 +00:00
parent e491e365cd
commit d4e143e06c
5 changed files with 1335 additions and 0 deletions

13
diagram-config.json Normal file
View File

@ -0,0 +1,13 @@
{
"subnetTab": {
"translateXExtra": 150,
"translateXMultiplier": -0.3,
"translateYOffset": -8,
"initialYOffset": -5
},
"subnetSizer": {
"extraWidth": 100,
"extraHeight": 60,
"minimumHeight": 60
}
}

726
diagram.js Normal file
View File

@ -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 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(){
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=>({"&":"&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,${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();
})();

114
gather.py Executable file
View File

@ -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()

428
layout.json Executable file
View File

@ -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
}
}
}

54
layout.php Normal file
View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
$layoutPath = __DIR__ . '/layout.json';
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'GET') {
if (is_file($layoutPath)) {
readfile($layoutPath);
} else {
echo json_encode(['nodes' => 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']);