major upgrade
This commit is contained in:
parent
e491e365cd
commit
d4e143e06c
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"subnetTab": {
|
||||
"translateXExtra": 150,
|
||||
"translateXMultiplier": -0.3,
|
||||
"translateYOffset": -8,
|
||||
"initialYOffset": -5
|
||||
},
|
||||
"subnetSizer": {
|
||||
"extraWidth": 100,
|
||||
"extraHeight": 60,
|
||||
"minimumHeight": 60
|
||||
}
|
||||
}
|
||||
|
|
@ -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=>({"&":"&","<":"<",">":">","\"":""","'":"'"}[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();
|
||||
})();
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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']);
|
||||
Loading…
Reference in New Issue