aws-vis/ex3.html

459 lines
18 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AWS VPC Diagram (Cytoscape + ELK)</title>
<!-- Cytoscape core -->
<script src="https://unpkg.com/cytoscape@3.28.1/dist/cytoscape.min.js"></script>
<!-- ELK layout engine and Cytoscape adapter -->
<script src="https://unpkg.com/elkjs/lib/elk.bundled.js"></script>
<script src="https://unpkg.com/cytoscape-elk@2.2.1/cytoscape-elk.js"></script>
<!-- Dagre fallback -->
<script src="https://unpkg.com/dagre@0.8.5/dist/dagre.min.js"></script>
<script src="https://unpkg.com/cytoscape-dagre@2.5.0/cytoscape-dagre.js"></script>
<style>
:root {
--bg: #0b1220;
--panel: #0f172a;
--muted: #334155;
--text: #e5e7eb;
--accent: #60a5fa;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial; background: var(--bg); color: var(--text); }
header { padding: 12px 16px; border-bottom: 1px solid #1f2937; display: flex; gap: 16px; align-items: center; background: #0b1220; position: sticky; top: 0; z-index: 10; }
header h1 { font-size: 16px; margin: 0 8px 0 0; font-weight: 600; color: #cbd5e1; }
label { font-size: 13px; color: #cbd5e1; }
input[type="text"] { background: var(--panel); color: var(--text); border: 1px solid #1f2937; border-radius: 8px; padding: 8px 10px; min-width: 260px; }
button { background: #111827; color: var(--text); border: 1px solid #1f2937; padding: 8px 10px; border-radius: 8px; cursor: pointer; }
button:hover { border-color: #334155; }
main { display: grid; grid-template-columns: 1fr 320px; height: calc(100vh - 56px); }
#cy { width: 100%; height: 100%; background-color: #ffffff; /* graph paper */
background-image:
repeating-linear-gradient(0deg, rgba(59,130,246,0.12) 0 1px, transparent 1px 20px),
repeating-linear-gradient(90deg, rgba(59,130,246,0.12) 0 1px, transparent 1px 20px),
repeating-linear-gradient(0deg, rgba(59,130,246,0.08) 0 1px, transparent 1px 100px),
repeating-linear-gradient(90deg, rgba(59,130,246,0.08) 0 1px, transparent 1px 100px);
}
aside { border-left: 1px solid #1f2937; background: var(--panel); padding: 10px; overflow: auto; }
aside h2 { font-size: 14px; font-weight: 600; color: #d1d5db; margin: 6px 0 8px; }
.muted { color: #94a3b8; font-size: 12px; }
pre { background: #0b1020; border: 1px solid #1f2937; border-radius: 8px; padding: 10px; overflow: auto; }
.legend { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; font-size: 12px; }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.chip { width: 12px; height: 12px; border-radius: 3px; display: inline-block; border: 1px solid #0b1220; }
</style>
</head>
<body>
<header>
<h1>AWS VPC (example)</h1>
<label><input id="toggle-sg" type="checkbox" checked /> Show security groups</label>
<label><input id="toggle-routes" type="checkbox" checked /> Show routes (IGW/NAT)</label>
<input id="search" type="text" placeholder="Search nodes by name/id/IP…" />
<button id="btn-fit" title="Fit to screen">Fit</button>
<button id="btn-reflow" title="Run layout again">Relayout</button>
<label><input id="toggle-label-edit" type="checkbox" /> Edit labels</label>
<button id="btn-save" title="Download positions and label offsets">Save layout</button>
<button id="btn-load" title="Load layout">Load layout</button>
<input id="load-file" type="file" accept="application/json" style="display:none" />
<div class="legend" style="margin-left:auto">
<span><i class="chip" style="background:#f9fafb;border-color:#111827"></i>VPC</span>
<span><i class="chip" style="background:#f3f4f6;border-color:#9ca3af"></i>Subnet</span>
<span><i class="chip" style="background:#fee2e2;border-color:#ef4444"></i>ALB/NLB</span>
<span><i class="chip" style="background:#dbeafe;border-color:#3b82f6"></i>EC2</span>
<span><i class="chip" style="background:#dcfce7;border-color:#10b981"></i>RDS</span>
<span><i class="chip" style="background:#fff7ed;border-color:#f59e0b"></i>SG</span>
</div>
</header>
<main>
<div id="cy"></div>
<aside>
<h2>Details</h2>
<div class="muted">Click any node or edge.</div>
<pre id="details"> </pre>
</aside>
</main>
<script>
// Example hook: build and inject into cytoscape you already created
const elements = loadGraphAndBuildElements('us-west-1');
// Register layout plugins if present (ELK preferred, Dagre as fallback)
if (window.cytoscapeElk) { cytoscape.use(window.cytoscapeElk); }
if (window.cytoscapeDagre) { cytoscape.use(window.cytoscapeDagre); }
// --- Example graph data ---
// Compact labels; richer data available in data{} for the details panel.
async function loadGraphAndBuildElements(region = 'us-west-1') {
// If opening via file://, use a tiny server: `python -m http.server`
const resp = await fetch('graph.json');
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 || [];
// Indexes for quick lookups
const subnetById = Object.fromEntries(subnets.map(s => [s.id, s]));
const sgById = Object.fromEntries(sgs.map(s => [s.id, s]));
// VPC nodes
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 }
});
}
// Subnet nodes (children of VPC)
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 }
});
}
// SG nodes (kept outside VPC for clarity)
for (const sg of sgs) {
const rulesIn = sg.rules_in || [];
const rulesOut = sg.rules_out || [];
const label = `${sg.name || sg.id}\ningress: ${(rulesIn[0]||'—')}${rulesIn.length>1?'…':''}`;
elements.push({ data: { id: sg.id, type: 'sg', label, rules: {in: rulesIn, out: rulesOut} } });
}
// EC2 nodes
for (const i of ec2s) {
const label = `${i.name || i.id}\n${i.id}\n${i.type}\n${i.privateIp || ''}`;
elements.push({ data: { id: i.id, type: 'ec2', label, name: i.name, instanceId: i.id,
privateIp: i.privateIp, parent: i.subnet, sgs: i.sgs || [] } });
// SG attachments
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 nodes
for (const db of rds) {
// pick a subnet (first in the subnetGroup) to nest under
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' } });
}
}
// LB nodes + edges to targets
for (const lb of lbs) {
// place LB in its first subnet if present
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 || [] } });
// SG attachments (ALB/NLB)
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' } });
}
// edges to targets with best-effort port label
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 of Object.keys(targetToPort)) {
// Prefer EC2 target IDs; if none, you could add ENIs or IPs similarly
const port = targetToPort[t];
const exists = ec2s.find(e => e.id === t);
if (exists) elements.push({ data: { id: `${lb.id}->${t}`, source: lb.id, target: t, label: port ? `tcp ${port}` : '' } });
}
}
return elements;
}
// --- Create Cytoscape instance ---
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',
'label': 'data(label)',
'text-wrap': 'wrap',
'text-max-width': 180,
'font-size': 12,
'padding': '10px',
'text-valign': 'top',
'text-halign': 'left',
'text-margin-x': 'data(lx)',
'text-margin-y': 'data(ly)'
}},
{ 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'
}},
{ selector: 'node[type = "alb"], node[type = "nlb"]', style: {
'background-color': '#fee2e2',
'border-color': '#ef4444',
'border-width': 2
}},
{ selector: 'node[type = "ec2"]', style: {
'background-color': '#dbeafe',
'border-color': '#3b82f6'
}},
{ 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: 'edge', style: {
'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',
'text-background-color': '#ffffff',
'text-background-opacity': 0.7,
'text-border-opacity': 0,
'text-margin-y': -6
}},
{ 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'
}}
],
elements
});
function runLayout() {
const haveElk = !!window.cytoscapeElk;
const 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 };
const layout = cy.layout(haveElk ? elkOpts : (haveDagre ? dagreOpts : gridOpts));
layout.run();
}
cy.json({ elements }); runLayout();
// Initialize default label offsets
cy.startBatch();
cy.nodes().forEach(n => {
if (n.data('lx') == null) n.data('lx', 0);
if (n.data('ly') == null) n.data('ly', 0);
});
cy.endBatch();
// --- UI: toggles, search, details ---
const $details = document.getElementById('details');
function showDetails(ele) {
if (!ele) { $details.textContent = ''; return; }
const d = ele.data();
// A little formatting sugar by type
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, listeners: d.listeners, sgs: d.sgs }; break;
case 'ec2': extra = { name: d.name, instanceId: d.instanceId, privateIp: d.privateIp, sgs: d.sgs }; break;
case 'rds': extra = { engine: d.engine, port: d.port, publiclyAccessible: d.publiclyAccessible, sgs: d.sgs }; break;
case 'sg': extra = d.rules || {}; 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').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: drag labels independently of node positions
let labelMode = false, labelTarget = null, startPos = null, startMargins = null, prevPan = true;
const $labelToggle = document.getElementById('toggle-label-edit');
$labelToggle.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;
const 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 => {
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 applyLayoutData(data) {
cy.batch(() => {
const map = (data && data.nodes) || {};
Object.keys(map).forEach(id => {
const n = cy.getElementById(id);
if (!n || n.empty()) return;
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);
}
document.getElementById('btn-save').addEventListener('click', () => {
downloadJSON(snapshotLayout(), 'vpc-layout.json');
});
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 (err) { alert('Could not parse JSON'); }
};
reader.readAsText(file);
});
// Lightweight search: dim non-matching nodes. Matches on id/label/IP/name.
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();
</script>
</body>
</html>