727 lines
34 KiB
JavaScript
727 lines
34 KiB
JavaScript
// 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();
|
|
})();
|