more w/ styling

This commit is contained in:
Peter Howell 2025-08-28 19:37:36 +00:00
parent b7434fb205
commit 97b3486c72
1 changed files with 170 additions and 29 deletions

199
ex4.html
View File

@ -14,7 +14,7 @@
<script src="https://unpkg.com/cytoscape-dagre@2.5.0/cytoscape-dagre.js"></script> <script src="https://unpkg.com/cytoscape-dagre@2.5.0/cytoscape-dagre.js"></script>
<!-- HTML label plugin --> <!-- HTML label plugin -->
<script src="https://cdn.jsdelivr.net/npm/cytoscape-node-html-label@1.2.2/dist/cytoscape-node-html-label.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/cytoscape-node-html-label@1.2.2/dist/cytoscape-node-html-label.min.js"></script>
<style> <style>
:root { --bg:#0b1220; --panel:#0f172a; --muted:#334155; --text:#e5e7eb; --accent:#60a5fa; :root { --bg:#0b1220; --panel:#0f172a; --muted:#334155; --text:#e5e7eb; --accent:#60a5fa;
--name-size:16px; --detail-size:12px; } --name-size:16px; --detail-size:12px; }
*{box-sizing:border-box} *{box-sizing:border-box}
@ -54,6 +54,10 @@
.dot{ width:8px; height:8px; border-radius:50%; display:inline-block; } .dot{ width:8px; height:8px; border-radius:50%; display:inline-block; }
.dot.run{ background:#10b981; } .dot.stop{ background:#ef4444; } .dot.unk{ background:#9ca3af; } .dot.run{ background:#10b981; } .dot.stop{ background:#ef4444; } .dot.unk{ background:#9ca3af; }
.vpc-big{ font-weight:800; font-size:clamp(18px,6vw,48px); color:#94a3b8; opacity:.35; text-shadow:0 1px 0 #fff; } .vpc-big{ font-weight:800; font-size:clamp(18px,6vw,48px); color:#94a3b8; opacity:.35; text-shadow:0 1px 0 #fff; }
/* Subnet tab label */
.subnet-tab{ display:inline-block; background:#f3f4f6; color:#0f172a; border:1px solid #9ca3af; border-bottom:none; border-top-left-radius:4px; border-top-right-radius:4px; padding:6px 10px; box-shadow:0 1px 0 rgba(0,0,0,.05) inset; pointer-events:none; }
.subnet-tab .t-name{ font-weight:700; font-size:12px; line-height:1.1; }
.subnet-tab .t-sub{ font-size:11px; color:#475569; line-height:1.2; }
</style> </style>
</head> </head>
<body> <body>
@ -106,45 +110,71 @@
'background-color': '#e5e7eb', 'background-color': '#e5e7eb',
'border-width': 1, 'border-width': 1,
'border-color': '#9ca3af', 'border-color': '#9ca3af',
'label': 'data(label)',
'text-wrap': 'wrap', 'text-wrap': 'wrap',
'text-max-width': 180, 'text-max-width': 180,
'font-size': 12, 'font-size': 12,
'padding': '10px', 'padding': '10px',
'text-valign': 'top', 'text-valign': 'top',
'text-halign': 'left', 'text-halign': 'left',
'text-margin-x': 'data(lx)',
'text-margin-y': 'data(ly)',
'text-opacity': 0 /* hide built-in labels; we render HTML labels */ '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: { { selector: 'node[type = "vpc"]', style: {
'background-color': '#f9fafb','border-color': '#111827','border-width': 2,'padding': '30px','font-weight': 700 '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' }}, { selector: 'node[type = "subnet"]', style: {
{ selector: 'node[type = "alb"], node[type = "nlb"]', style: { 'background-color': '#fee2e2','border-color': '#ef4444','border-width': 2 }}, '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: { { selector: 'node[type = "ec2"]', style: {
'background-color': '#dbeafe', 'background-color': '#dbeafe',
'border-color': '#3b82f6', 'border-color': '#3b82f6',
'width': 'label', 'width': 260,
'height': 'label', 'height': 100,
'padding': '12px', 'padding': '12px',
'text-opacity': 1, /* show built-in text for EC2 */ 'text-opacity': 0 /* use HTML labels for EC2 */
'text-wrap': 'wrap', }},
'text-max-width': 240, { selector: 'node[type = "rds"]', style: {
'text-halign': 'center', /* place label inside node */ 'background-color': '#dcfce7',
'text-valign': 'center', 'border-color': '#10b981',
'text-justification': 'left', /* left-align lines within block */ 'width': 260,
'text-margin-x': 0, 'height': 100,
'text-margin-y': 0 'padding': '12px'
}},
{ selector: 'node[type = "sg"]', style: {
'background-color': '#fff7ed',
'border-color': '#f59e0b',
'width': 260,
'height': 90,
'padding': '12px'
}}, }},
{ selector: 'node[type = "rds"]', style: { 'background-color': '#dcfce7','border-color': '#10b981' }},
{ selector: 'node[type = "sg"]', style: { 'background-color': '#fff7ed','border-color': '#f59e0b' }},
{ selector: 'node[type = "igw"], node[type = "nat"]', style: { 'background-color': '#ffe4e6','border-color': '#fb7185' }}, { selector: 'node[type = "igw"], node[type = "nat"]', style: { 'background-color': '#ffe4e6','border-color': '#fb7185' }},
{ selector: 'edge', style: { { selector: 'edge', style: {
'width': 2,'line-color': '#9ca3af','target-arrow-shape': 'triangle','target-arrow-color': '#9ca3af', 'width': 2,'line-color': '#9ca3af','target-arrow-shape': 'triangle','target-arrow-color': '#9ca3af',
'curve-style': 'bezier','label': 'data(label)','font-size': 11,'text-rotation': 'autorotate', '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 '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.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-attach', style: { 'line-style': 'dotted','line-color': '#f59e0b','target-arrow-color': '#f59e0b' }}
] ]
@ -191,7 +221,7 @@
} }
// EC2 // EC2
for (const i of ec2s) { for (const i of ec2s) {
const label = `${i.name || i.id}\n${i.id}\n${i.type || ''}\n${i.privateIp || ''}`; 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, elements.push({ data: { id: i.id, type: 'ec2', label,
name: i.name, instanceId: i.id, privateIp: i.privateIp, name: i.name, instanceId: i.id, privateIp: i.privateIp,
publicIp: i.publicIp, state: i.state, parent: i.subnet, sgs: i.sgs || [] } }); publicIp: i.publicIp, state: i.state, parent: i.subnet, sgs: i.sgs || [] } });
@ -243,20 +273,47 @@
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 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; } 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 (exclude EC2; they use built-in labels sized to node) // Attach HTML labels for SG/ALB/NLB/RDS/EC2 centered inside nodes and Subnet at top-left
if (cy.nodeHtmlLabel) { if (cy.nodeHtmlLabel) {
cy.nodeHtmlLabel([ cy.nodeHtmlLabel([
{ {
query:'node[type != "vpc"][type != "ec2"]', halign:'left', valign:'top', halignBox:'left', valignBox:'top', 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){ tpl: function(d){
const type=d.type; const name=d.name||(d.label||'').split('\n')[0]||d.id; 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 pub=d.publicIp||d.publicIpAddress||d.public_ip||d.dns||'';
const detailA=(type==='ec2')?(d.privateIp||''):(type==='rds'?`${d.engine||''}:${d.port||''}`:(type==='alb'||type==='nlb')?(d.scheme||''):(d.cidr||'')); const priv=d.privateIp||d.private_ip||'';
const detailB=pub?`Public: ${escapeHtml(pub)}`:''; 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 extra=(type==='ec2')?statusDot(d.state):(type==='sg')?`${sgRuleCount(d)} rules`:''; 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); const badge=vpnBadge(name);
const ml=(d.lx||0), mt=(d.ly||0); return `<div class="ncard n-${type}" style="background:none;border:none;box-shadow:none;padding:0;margin:0;display:block;">
return `<div class="ncard n-${type}" style="margin-left:${ml}px; margin-top:${mt}px;"><div>${iconSvg(type)}</div><div><div class="n-title">${escapeHtml(name)}${badge}</div><div class="n-sub">${escapeHtml(detailA)}</div>${detailB?`<div class="n-sub">${escapeHtml(detailB)}</div>`:''}${extra?`<div class="n-sub">${extra}</div>`:''}</div></div>`; <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>`;
} }
}, },
{ {
@ -265,6 +322,83 @@
} }
]); ]);
} }
// 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,-1px)';
});
}
// Position subnet tabs at left + labelWidth + 10% of node width and match colors
function positionSubnetTabs(){
const containerRect = cy.container().getBoundingClientRect();
const tabs = Array.from(document.querySelectorAll('.subnet-tab'));
if (!tabs.length) return;
const subnets = cy.nodes('[type = "subnet"]');
if (subnets.empty()) return;
for (const el of tabs) {
const r = el.getBoundingClientRect();
const px = r.left + 20, py = r.top + 2; // near top-left of tab
const hit = subnets.filter(n=>{
const bb = n.renderedBoundingBox({ includeNodes:true, includeLabels:false, includeEdges:false });
const left=containerRect.left+bb.x1, top=containerRect.top+bb.y1, right=containerRect.left+bb.x2, bottom=containerRect.top+bb.y2;
return px>=left && px<=right && py>=top && py<=bottom;
})[0];
if (!hit) continue;
const nw = hit.renderedWidth();
const lw = r.width;
const offx = Math.round(lw + nw * 0.10);
el.style.transform = `translate(${offx}px,-1px)`;
// Match node colors
const bg = hit.style('background-color') || '#f3f4f6';
const br = hit.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 minW = Math.ceil(r.width) + 24;
const minH = Math.ceil(r.height) + 24;
sizer.style({ width: minW, height: Math.max(60, minH) });
sizer.position(sn.position());
});
}
// Initial and reactive updates
requestAnimationFrame(()=>{ prepareSubnetTabs(); positionSubnetTabs(); ensureEmptySubnetSizers(); updateEmptySubnetSizerSizes(); });
cy.on('layoutstop zoom', () => requestAnimationFrame(()=>{ prepareSubnetTabs(); positionSubnetTabs(); ensureEmptySubnetSizers(); updateEmptySubnetSizerSizes(); }));
window.addEventListener('resize', () => requestAnimationFrame(()=>{ prepareSubnetTabs(); positionSubnetTabs(); updateEmptySubnetSizerSizes(); }));
// --- UI, details, toggles, label edit, save/load, size, search, SG sort --- // --- UI, details, toggles, label edit, save/load, size, search, SG sort ---
@ -327,7 +461,7 @@
cy.on('mouseup',()=>{ if(!labelMode) return; labelTarget=null; cy.panningEnabled(prevPan); }); cy.on('mouseup',()=>{ if(!labelMode) return; labelTarget=null; cy.panningEnabled(prevPan); });
// Save / Load layout (positions + label offsets) // Save / Load layout (positions + label offsets)
function snapshotLayout(){ const nodes={}; cy.nodes().forEach(n=>{ nodes[n.id()]={ position:n.position(), lx:n.data('lx')||0, ly:n.data('ly')||0 }; }); return { nodes }; } 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 }; }
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 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){ function applyLayoutData(data){
cy.batch(()=>{ cy.batch(()=>{
@ -345,10 +479,17 @@
// Size slider adjusts typography + padding // Size slider adjusts typography + padding
const sizeSlider=document.getElementById('size-slider'); 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(); } 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(()=>{ prepareSubnetTabs(); positionSubnetTabs(); ensureEmptySubnetSizers(); updateEmptySubnetSizerSizes(); });
}
sizeSlider.addEventListener('input',()=> applySizeScale(parseFloat(sizeSlider.value))); sizeSlider.addEventListener('input',()=> applySizeScale(parseFloat(sizeSlider.value)));
applySizeScale(parseFloat(sizeSlider.value)); applySizeScale(parseFloat(sizeSlider.value));
// No additional resize hooks required for subnet tabs
// Autosave positions/offsets so layouts survive re-scrapes // Autosave positions/offsets so layouts survive re-scrapes
const LKEY='vpc-layout-autosave'; const LKEY='vpc-layout-autosave';
function saveLocal(){ try{ localStorage.setItem(LKEY, JSON.stringify(snapshotLayout())); }catch{} } function saveLocal(){ try{ localStorage.setItem(LKEY, JSON.stringify(snapshotLayout())); }catch{} }