initial
This commit is contained in:
commit
31d28a5f97
|
|
@ -0,0 +1,37 @@
|
|||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- Root: static demos and data.
|
||||
- `index.php`: FlowGrid (Vue 2) POC; parses inline DSL and renders lanes/steps.
|
||||
- `ex4.html`: Cytoscape-based AWS VPC diagrams that fetch `graph.json`.
|
||||
- `graph.json`: Example cloud inventory used by `ex3.html`/`ex4.html`.
|
||||
- Add new demos as `exN.html` near existing files; keep shared data in JSON.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- View live site at https://phowell.org/flow/index.php (or other filename)
|
||||
- Working php server
|
||||
- Basic JS/HTML sanity (manual): open in Chrome/Firefox and watch the console for errors; ensure `graph.json` loads without CORS issues.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Indentation: 2 spaces for HTML/CSS/JS.
|
||||
- JavaScript: camelCase variables/functions; semicolons; small, pure helpers.
|
||||
- CSS: use CSS custom properties (`--kebab-case`) and utility-like classes.
|
||||
- HTML: double-quoted attributes; lightweight, semantic elements.
|
||||
- File naming: demos `ex<N>.html`; data `*.json`; avoid frameworks/build steps.
|
||||
|
||||
## Testing Guidelines
|
||||
- No formal test runner configured. Validate by:
|
||||
- Loading each page via `php -S` and exercising UI (toggles, search, layout buttons).
|
||||
- For `graph.json` changes, verify nodes/edges render and controls respond.
|
||||
- Optional lint: `php -l index.php` (syntax check) and run an HTML/JS linter locally if available.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Commits: imperative, scoped summaries (e.g., `feat: add RDS labels`, `fix: guard null css vars`).
|
||||
- PRs: include a concise description, screenshots/GIFs of the UI, and reference issues.
|
||||
- Keep diffs minimal; explain data/schema changes to `graph.json` and their UI impact.
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Serve over `http://` locally (not `file://`) so `fetch('graph.json')` works.
|
||||
- Do not commit credentials or sensitive infrastructure details in `graph.json`.
|
||||
- External CDNs are used (Vue, Cytoscape, ELK); vendor locally only if required.
|
||||
|
||||
|
|
@ -0,0 +1,458 @@
|
|||
<!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>
|
||||
|
|
@ -0,0 +1,375 @@
|
|||
<!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>
|
||||
<!-- 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>
|
||||
<style>
|
||||
:root { --bg:#0b1220; --panel:#0f172a; --muted:#334155; --text:#e5e7eb; --accent:#60a5fa;
|
||||
--name-size:16px; --detail-size:12px; }
|
||||
*{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; flex-wrap:wrap; }
|
||||
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; }
|
||||
input[type="range"]{ vertical-align:middle }
|
||||
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:#fff;
|
||||
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; }
|
||||
|
||||
/* HTML label cards */
|
||||
.ncard{ display:inline-grid; grid-auto-flow:column; gap:8px; align-items:start; background:#fff; border:1px solid #cbd5e1; border-radius:8px; padding:8px 10px; box-shadow:0 1px 2px rgba(0,0,0,.06); }
|
||||
.n-ec2 .icon,.n-rds .icon,.n-alb .icon,.n-nlb .icon,.n-sg .icon,.n-igw .icon,.n-nat .icon{ width:18px; height:18px; }
|
||||
.n-title{ font-weight:700; font-size:var(--name-size); line-height:1.1; color:#0f172a; }
|
||||
.n-sub{ font-size:var(--detail-size); color:#475569; line-height:1.25; }
|
||||
.badge{ font-size:10px; background:#1f2937; color:#fff; border-radius:6px; padding:2px 6px; margin-left:6px; }
|
||||
.status{ display:inline-flex; align-items:center; gap:6px; }
|
||||
.dot{ width:8px; height:8px; border-radius:50%; display:inline-block; }
|
||||
.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; }
|
||||
</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>
|
||||
<label>Size <input id="size-slider" type="range" min="0.8" max="1.6" step="0.05" value="1"></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>
|
||||
// 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);
|
||||
|
||||
// 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',
|
||||
'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)',
|
||||
'text-opacity': 0 /* hide built-in labels; we render HTML labels */
|
||||
}},
|
||||
{ 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',
|
||||
'width': 'label',
|
||||
'height': 'label',
|
||||
'padding': '12px',
|
||||
'text-opacity': 1, /* show built-in text for EC2 */
|
||||
'text-margin-x': 0,
|
||||
'text-margin-y': 0,
|
||||
'text-wrap': 'wrap',
|
||||
'text-max-width': 220,
|
||||
'text-valign': 'top',
|
||||
'text-halign': 'left'
|
||||
}},
|
||||
{ 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' }}
|
||||
]
|
||||
});
|
||||
|
||||
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') {
|
||||
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 } } });
|
||||
}
|
||||
// EC2
|
||||
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,
|
||||
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(){
|
||||
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 (exclude EC2; they use built-in labels sized to node)
|
||||
if (cy.nodeHtmlLabel) {
|
||||
cy.nodeHtmlLabel([
|
||||
{
|
||||
query:'node[type != "vpc"][type != "ec2"]', halign:'left', valign:'top', halignBox:'left', valignBox:'top',
|
||||
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 detailA=(type==='ec2')?(d.privateIp||''):(type==='rds'?`${d.engine||''}:${d.port||''}`:(type==='alb'||type==='nlb')?(d.scheme||''):(d.cidr||''));
|
||||
const detailB=pub?`Public: ${escapeHtml(pub)}`:'';
|
||||
const extra=(type==='ec2')?statusDot(d.state):(type==='sg')?`${sgRuleCount(d)} rules`:'';
|
||||
const badge=vpnBadge(name);
|
||||
const ml=(d.lx||0), mt=(d.ly||0);
|
||||
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>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
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>`; }
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
// --- 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').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=>{ 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)||{};
|
||||
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);
|
||||
}
|
||||
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{ alert('Could not parse JSON'); } }; reader.readAsText(file);
|
||||
});
|
||||
|
||||
// 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(); }
|
||||
sizeSlider.addEventListener('input',()=> applySizeScale(parseFloat(sizeSlider.value)));
|
||||
applySizeScale(parseFloat(sizeSlider.value));
|
||||
|
||||
// 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{} }
|
||||
restoreLocal();
|
||||
cy.on('position','node', saveLocal);
|
||||
cy.on('mouseup', ()=> { if(labelMode) saveLocal(); });
|
||||
|
||||
// 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();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,935 @@
|
|||
{
|
||||
"regions": {
|
||||
"us-west-1": {
|
||||
"vpcs": [
|
||||
{
|
||||
"id": "vpc-87832de2",
|
||||
"cidr": "10.4.0.0/16",
|
||||
"name": "vault.vpc.01"
|
||||
},
|
||||
{
|
||||
"id": "vpc-c84eabad",
|
||||
"cidr": "172.31.0.0/16",
|
||||
"name": "vpc-c84eabad"
|
||||
},
|
||||
{
|
||||
"id": "vpc-331ad056",
|
||||
"cidr": "10.3.0.0/16",
|
||||
"name": "efw.vpc.02"
|
||||
},
|
||||
{
|
||||
"id": "vpc-afe722ca",
|
||||
"cidr": "10.2.0.0/16",
|
||||
"name": "efw.vpc.01"
|
||||
},
|
||||
{
|
||||
"id": "vpc-03f799f47b766798c",
|
||||
"cidr": "10.10.0.0/16",
|
||||
"name": "ALB-VPC"
|
||||
}
|
||||
],
|
||||
"subnets": [
|
||||
{
|
||||
"id": "subnet-260ee27f",
|
||||
"vpc": "vpc-331ad056",
|
||||
"cidr": "10.3.0.0/24",
|
||||
"az": "us-west-1b",
|
||||
"public": false
|
||||
},
|
||||
{
|
||||
"id": "subnet-f73afeae",
|
||||
"vpc": "vpc-331ad056",
|
||||
"cidr": "10.3.1.0/24",
|
||||
"az": "us-west-1b",
|
||||
"public": false
|
||||
},
|
||||
{
|
||||
"id": "subnet-ebc4188e",
|
||||
"vpc": "vpc-afe722ca",
|
||||
"cidr": "10.2.1.0/24",
|
||||
"az": "us-west-1c",
|
||||
"public": false
|
||||
},
|
||||
{
|
||||
"id": "subnet-005bfd58e719e031f",
|
||||
"vpc": "vpc-87832de2",
|
||||
"cidr": "10.4.11.0/24",
|
||||
"az": "us-west-1c",
|
||||
"public": false
|
||||
},
|
||||
{
|
||||
"id": "subnet-035f81142068bf961",
|
||||
"vpc": "vpc-03f799f47b766798c",
|
||||
"cidr": "10.10.2.0/24",
|
||||
"az": "us-west-1b",
|
||||
"public": false
|
||||
},
|
||||
{
|
||||
"id": "subnet-d137fd88",
|
||||
"vpc": "vpc-87832de2",
|
||||
"cidr": "10.4.0.0/24",
|
||||
"az": "us-west-1b",
|
||||
"public": false
|
||||
},
|
||||
{
|
||||
"id": "subnet-e98e088c",
|
||||
"vpc": "vpc-331ad056",
|
||||
"cidr": "10.3.2.0/24",
|
||||
"az": "us-west-1c",
|
||||
"public": false
|
||||
},
|
||||
{
|
||||
"id": "subnet-08e40dcdc103438b8",
|
||||
"vpc": "vpc-87832de2",
|
||||
"cidr": "10.4.2.0/24",
|
||||
"az": "us-west-1c",
|
||||
"public": false
|
||||
},
|
||||
{
|
||||
"id": "subnet-0cb2aa7457413f5fe",
|
||||
"vpc": "vpc-03f799f47b766798c",
|
||||
"cidr": "10.10.3.0/24",
|
||||
"az": "us-west-1c",
|
||||
"public": true
|
||||
},
|
||||
{
|
||||
"id": "subnet-247a9341",
|
||||
"vpc": "vpc-c84eabad",
|
||||
"cidr": "172.31.16.0/20",
|
||||
"az": "us-west-1c",
|
||||
"public": true
|
||||
},
|
||||
{
|
||||
"id": "subnet-34173f72",
|
||||
"vpc": "vpc-c84eabad",
|
||||
"cidr": "172.31.0.0/20",
|
||||
"az": "us-west-1b",
|
||||
"public": true
|
||||
},
|
||||
{
|
||||
"id": "subnet-05b5dcdc91a8d3da2",
|
||||
"vpc": "vpc-87832de2",
|
||||
"cidr": "10.4.10.0/24",
|
||||
"az": "us-west-1b",
|
||||
"public": false
|
||||
},
|
||||
{
|
||||
"id": "subnet-16594550",
|
||||
"vpc": "vpc-afe722ca",
|
||||
"cidr": "10.2.2.0/24",
|
||||
"az": "us-west-1b",
|
||||
"public": false
|
||||
},
|
||||
{
|
||||
"id": "subnet-051f6d705e415834d",
|
||||
"vpc": "vpc-03f799f47b766798c",
|
||||
"cidr": "10.10.1.0/24",
|
||||
"az": "us-west-1b",
|
||||
"public": true
|
||||
}
|
||||
],
|
||||
"sgs": [
|
||||
{
|
||||
"id": "sg-642cf701",
|
||||
"name": "efw.vpc.01-securitygroup-web01",
|
||||
"rules_in": [
|
||||
"tcp 80 from 0.0.0.0/0",
|
||||
"-1 None from sg-da75adbf",
|
||||
"icmp 0--1 from 0.0.0.0/0",
|
||||
"tcp 12489 from 64.71.160.96/27",
|
||||
"tcp 5666 from 64.71.160.96/27",
|
||||
"tcp 3389 from 76.126.204.87/32",
|
||||
"tcp 3389 from 64.71.160.98/32",
|
||||
"tcp 3389 from 50.156.113.43/32",
|
||||
"tcp 3389 from 162.222.31.254/32",
|
||||
"tcp 3389 from 173.164.212.89/32",
|
||||
"tcp 3389 from 174.85.92.60/32",
|
||||
"tcp 443 from 0.0.0.0/0",
|
||||
"tcp 8172 from 173.10.69.6/32",
|
||||
"tcp 8172 from 173.164.212.89/32",
|
||||
"icmp 8--1 from 0.0.0.0/0"
|
||||
],
|
||||
"rules_out": [
|
||||
"-1 None from 0.0.0.0/0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sg-00600deefd8e47cd0",
|
||||
"name": "efw.vpc.02-securitygroup-rds",
|
||||
"rules_in": [
|
||||
"tcp 3306 from 173.164.212.89/32"
|
||||
],
|
||||
"rules_out": [
|
||||
"-1 None from 0.0.0.0/0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sg-da75adbf",
|
||||
"name": "default",
|
||||
"rules_in": [
|
||||
"-1 None from sg-da75adbf",
|
||||
"tcp 22 from 0.0.0.0/0",
|
||||
"tcp 22 from sg-4235ee27",
|
||||
"udp 11094 from 0.0.0.0/0",
|
||||
"tcp 3389 from sg-4235ee27",
|
||||
"icmp -1 from sg-4235ee27"
|
||||
],
|
||||
"rules_out": [
|
||||
"-1 None from 0.0.0.0/0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sg-c05a2ba5",
|
||||
"name": "default",
|
||||
"rules_in": [
|
||||
"-1 None from sg-c05a2ba5"
|
||||
],
|
||||
"rules_out": [
|
||||
"-1 None from 0.0.0.0/0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sg-0c00315bbb744b876",
|
||||
"name": "default",
|
||||
"rules_in": [
|
||||
"-1 None from sg-0c00315bbb744b876"
|
||||
],
|
||||
"rules_out": [
|
||||
"-1 None from 0.0.0.0/0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sg-495ab32c",
|
||||
"name": "default",
|
||||
"rules_in": [
|
||||
"-1 None from sg-495ab32c",
|
||||
"tcp 3306 from 54.85.48.0/24",
|
||||
"tcp 3306 from 173.164.212.89/32"
|
||||
],
|
||||
"rules_out": [
|
||||
"-1 None from 0.0.0.0/0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sg-0f2465c4421c2d5c2",
|
||||
"name": "x-to-pdf-AppSG-4DM5CX7D35LE",
|
||||
"rules_in": [
|
||||
"-1 None from sg-0f2465c4421c2d5c2",
|
||||
"tcp 1-65000 from sg-b45a2bd1"
|
||||
],
|
||||
"rules_out": [
|
||||
"-1 None from 0.0.0.0/0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sg-8b746aec",
|
||||
"name": "launch-wizard-1",
|
||||
"rules_in": [
|
||||
"tcp 80 from 47.208.245.238/32",
|
||||
"tcp 22 from 47.208.245.238/32",
|
||||
"tcp 22 from 64.71.160.98/32",
|
||||
"tcp 443 from 47.208.245.238/32",
|
||||
"icmp 8--1 from 47.208.245.238/32"
|
||||
],
|
||||
"rules_out": [
|
||||
"-1 None from 0.0.0.0/0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sg-0a872ac3a6d9132a6",
|
||||
"name": "node-app-alb-stack-AppAccessSG-DLeS5dz4pWwk",
|
||||
"rules_in": [
|
||||
"tcp 22 from 0.0.0.0/0",
|
||||
"tcp 11094 from 0.0.0.0/0",
|
||||
"udp 11094 from 0.0.0.0/0"
|
||||
],
|
||||
"rules_out": [
|
||||
"-1 None from 0.0.0.0/0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sg-076d2ea1df0054074",
|
||||
"name": "node-app-alb-stack-LoadBalancerSG-LgFQ2PFaob27",
|
||||
"rules_in": [
|
||||
"tcp 80 from 0.0.0.0/0",
|
||||
"tcp 443 from 0.0.0.0/0"
|
||||
],
|
||||
"rules_out": [
|
||||
"-1 None from 0.0.0.0/0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sg-b45a2bd1",
|
||||
"name": "vault.vpc.01-securitygroup",
|
||||
"rules_in": [
|
||||
"tcp 80 from 0.0.0.0/0",
|
||||
"tcp 8080 from 76.246.46.113/32",
|
||||
"icmp 0--1 from 0.0.0.0/0",
|
||||
"icmp 0--1 from ::/0",
|
||||
"tcp 22 from 73.90.210.219/32",
|
||||
"tcp 22 from 73.90.209.115/32",
|
||||
"tcp 22 from 47.208.245.238/32",
|
||||
"tcp 22 from 73.90.211.68/32",
|
||||
"tcp 22 from 166.170.37.145/32",
|
||||
"tcp 22 from 135.26.148.110/32",
|
||||
"tcp 22 from 108.82.46.156/32",
|
||||
"tcp 22 from 47.208.247.13/32",
|
||||
"tcp 22 from 98.150.190.32/32",
|
||||
"tcp 22 from 64.71.160.0/24",
|
||||
"tcp 22 from 76.126.204.87/32",
|
||||
"tcp 22 from 13.52.6.112/29",
|
||||
"tcp 22 from 174.85.92.60/32",
|
||||
"tcp 22 from 45.28.138.65/32",
|
||||
"tcp 5666 from 64.71.160.96/27",
|
||||
"udp 11094 from 0.0.0.0/0",
|
||||
"tcp 443 from 0.0.0.0/0",
|
||||
"icmp 8--1 from 0.0.0.0/0"
|
||||
],
|
||||
"rules_out": [
|
||||
"-1 None from 0.0.0.0/0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sg-4235ee27",
|
||||
"name": "efw.vpc.01-securitygroup-db01",
|
||||
"rules_in": [
|
||||
"tcp 1433 from 10.2.2.0/24",
|
||||
"-1 None from sg-da75adbf"
|
||||
],
|
||||
"rules_out": [
|
||||
"-1 None from 0.0.0.0/0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sg-04167d2e18ba15e45",
|
||||
"name": "node-app-alb-stack-AppSG-tYWl4C0wtvl6",
|
||||
"rules_in": [
|
||||
"tcp 8080 from sg-076d2ea1df0054074"
|
||||
],
|
||||
"rules_out": [
|
||||
"-1 None from 0.0.0.0/0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sg-06348763",
|
||||
"name": "default",
|
||||
"rules_in": [
|
||||
"tcp 1433 from 173.164.212.89/32",
|
||||
"tcp 1433 from 72.173.157.190/32",
|
||||
"tcp 1433 from 75.101.48.86/32",
|
||||
"tcp 1433 from 107.3.172.143/32",
|
||||
"tcp 1433 from 10.3.0.0/16",
|
||||
"tcp 80 from 0.0.0.0/0",
|
||||
"tcp 5432 from 10.3.0.157/32",
|
||||
"tcp 5432 from 67.139.78.162/32",
|
||||
"tcp 5432 from 209.210.190.204/32",
|
||||
"tcp 5432 from 10.3.0.232/32",
|
||||
"tcp 5432 from 10.3.0.247/32",
|
||||
"tcp 5432 from sg-08fb4b412f90d5913",
|
||||
"tcp 4021 from 173.164.212.89/32",
|
||||
"tcp 5666 from 64.71.160.121/32",
|
||||
"tcp 4020 from 173.164.212.89/32",
|
||||
"tcp 3389 from 76.126.204.87/32",
|
||||
"tcp 3389 from 64.71.160.98/32",
|
||||
"tcp 3389 from 173.164.212.89/32",
|
||||
"tcp 3389 from 72.173.157.190/32",
|
||||
"tcp 3389 from 10.3.0.0/16",
|
||||
"tcp 445 from 10.3.0.0/16",
|
||||
"tcp 443 from 0.0.0.0/0",
|
||||
"tcp 4026 from 173.164.212.89/32",
|
||||
"tcp 4026 from 10.3.0.0/16",
|
||||
"tcp 8172 from 173.164.212.89/32",
|
||||
"tcp 8172 from 10.3.0.0/16",
|
||||
"icmp 0--1 from 0.0.0.0/0",
|
||||
"icmp 0--1 from 173.164.212.89/32",
|
||||
"tcp 22 from 0.0.0.0/0",
|
||||
"udp 1194 from 0.0.0.0/0",
|
||||
"tcp 4024 from 173.164.212.89/32",
|
||||
"tcp 1194 from 0.0.0.0/0",
|
||||
"tcp 12489 from 64.71.160.121/32",
|
||||
"tcp 4027 from 10.3.0.0/16",
|
||||
"udp 11094 from 0.0.0.0/0",
|
||||
"icmp 8--1 from 0.0.0.0/0"
|
||||
],
|
||||
"rules_out": [
|
||||
"-1 None from 0.0.0.0/0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sg-08fb4b412f90d5913",
|
||||
"name": "eco.lambda.securitygroup",
|
||||
"rules_in": [],
|
||||
"rules_out": [
|
||||
"-1 None from 0.0.0.0/0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sg-09621eafa81d9554d",
|
||||
"name": "vault.vpc.01.securitygroup-rds-pvt",
|
||||
"rules_in": [
|
||||
"tcp 3306 from 173.164.212.89/32",
|
||||
"tcp 3306 from sg-b45a2bd1"
|
||||
],
|
||||
"rules_out": [
|
||||
"-1 None from 0.0.0.0/0"
|
||||
]
|
||||
}
|
||||
],
|
||||
"enis": [
|
||||
{
|
||||
"id": "eni-03cb7e3c6efba4fe6",
|
||||
"subnet": "subnet-260ee27f",
|
||||
"sgs": [
|
||||
"sg-06348763"
|
||||
],
|
||||
"privateIp": "10.3.0.216",
|
||||
"publicIp": "52.8.79.80"
|
||||
},
|
||||
{
|
||||
"id": "eni-0c6e3761c14140e52",
|
||||
"subnet": "subnet-051f6d705e415834d",
|
||||
"sgs": [
|
||||
"sg-076d2ea1df0054074"
|
||||
],
|
||||
"privateIp": "10.10.1.212",
|
||||
"publicIp": "50.18.175.227"
|
||||
},
|
||||
{
|
||||
"id": "eni-6a099b32",
|
||||
"subnet": "subnet-260ee27f",
|
||||
"sgs": [
|
||||
"sg-06348763"
|
||||
],
|
||||
"privateIp": "10.3.0.207",
|
||||
"publicIp": null
|
||||
},
|
||||
{
|
||||
"id": "eni-072ff2545f56240a7",
|
||||
"subnet": "subnet-16594550",
|
||||
"sgs": [
|
||||
"sg-4235ee27"
|
||||
],
|
||||
"privateIp": "10.2.2.16",
|
||||
"publicIp": null
|
||||
},
|
||||
{
|
||||
"id": "eni-064252b93ca105b7c",
|
||||
"subnet": "subnet-05b5dcdc91a8d3da2",
|
||||
"sgs": [
|
||||
"sg-b45a2bd1"
|
||||
],
|
||||
"privateIp": "10.4.10.82",
|
||||
"publicIp": null
|
||||
},
|
||||
{
|
||||
"id": "eni-08d57de7981ae0020",
|
||||
"subnet": "subnet-035f81142068bf961",
|
||||
"sgs": [
|
||||
"sg-04167d2e18ba15e45",
|
||||
"sg-0a872ac3a6d9132a6"
|
||||
],
|
||||
"privateIp": "10.10.2.106",
|
||||
"publicIp": null
|
||||
},
|
||||
{
|
||||
"id": "eni-00feca9ca48c7ae0c",
|
||||
"subnet": "subnet-05b5dcdc91a8d3da2",
|
||||
"sgs": [
|
||||
"sg-b45a2bd1"
|
||||
],
|
||||
"privateIp": "10.4.10.141",
|
||||
"publicIp": null
|
||||
},
|
||||
{
|
||||
"id": "eni-8041e3da",
|
||||
"subnet": "subnet-260ee27f",
|
||||
"sgs": [
|
||||
"sg-06348763"
|
||||
],
|
||||
"privateIp": "10.3.0.201",
|
||||
"publicIp": null
|
||||
},
|
||||
{
|
||||
"id": "eni-9d73dbc7",
|
||||
"subnet": "subnet-260ee27f",
|
||||
"sgs": [
|
||||
"sg-06348763"
|
||||
],
|
||||
"privateIp": "10.3.0.98",
|
||||
"publicIp": null
|
||||
},
|
||||
{
|
||||
"id": "eni-04237833786a67013",
|
||||
"subnet": "subnet-051f6d705e415834d",
|
||||
"sgs": [
|
||||
"sg-0a872ac3a6d9132a6"
|
||||
],
|
||||
"privateIp": "10.10.1.166",
|
||||
"publicIp": "52.53.243.75"
|
||||
},
|
||||
{
|
||||
"id": "eni-084002309358bb545",
|
||||
"subnet": "subnet-f73afeae",
|
||||
"sgs": [
|
||||
"sg-06348763"
|
||||
],
|
||||
"privateIp": "10.3.1.134",
|
||||
"publicIp": null
|
||||
},
|
||||
{
|
||||
"id": "eni-0827085d571dccab7",
|
||||
"subnet": "subnet-d137fd88",
|
||||
"sgs": [
|
||||
"sg-b45a2bd1"
|
||||
],
|
||||
"privateIp": "10.4.0.221",
|
||||
"publicIp": "52.53.117.111"
|
||||
},
|
||||
{
|
||||
"id": "eni-0e844b9d718f3daa0",
|
||||
"subnet": "subnet-16594550",
|
||||
"sgs": [
|
||||
"sg-642cf701"
|
||||
],
|
||||
"privateIp": "10.2.2.226",
|
||||
"publicIp": "52.8.26.159"
|
||||
},
|
||||
{
|
||||
"id": "eni-0d6f412450d0e082b",
|
||||
"subnet": "subnet-260ee27f",
|
||||
"sgs": [
|
||||
"sg-06348763"
|
||||
],
|
||||
"privateIp": "10.3.0.63",
|
||||
"publicIp": "52.8.7.0"
|
||||
},
|
||||
{
|
||||
"id": "eni-05788a45421ce580f",
|
||||
"subnet": "subnet-d137fd88",
|
||||
"sgs": [
|
||||
"sg-b45a2bd1"
|
||||
],
|
||||
"privateIp": "10.4.0.197",
|
||||
"publicIp": "52.8.75.57"
|
||||
},
|
||||
{
|
||||
"id": "eni-0775830b9669fa479",
|
||||
"subnet": "subnet-16594550",
|
||||
"sgs": [
|
||||
"sg-642cf701"
|
||||
],
|
||||
"privateIp": "10.2.2.18",
|
||||
"publicIp": "54.153.101.192"
|
||||
},
|
||||
{
|
||||
"id": "eni-0f54a2ee3a5237ae1",
|
||||
"subnet": "subnet-260ee27f",
|
||||
"sgs": [
|
||||
"sg-06348763"
|
||||
],
|
||||
"privateIp": "10.3.0.111",
|
||||
"publicIp": "54.153.3.41"
|
||||
},
|
||||
{
|
||||
"id": "eni-08eb3a38264ccd212",
|
||||
"subnet": "subnet-260ee27f",
|
||||
"sgs": [
|
||||
"sg-06348763"
|
||||
],
|
||||
"privateIp": "10.3.0.73",
|
||||
"publicIp": null
|
||||
},
|
||||
{
|
||||
"id": "eni-0afbe7eb22dd6e8e3",
|
||||
"subnet": "subnet-260ee27f",
|
||||
"sgs": [
|
||||
"sg-06348763"
|
||||
],
|
||||
"privateIp": "10.3.0.112",
|
||||
"publicIp": "13.52.49.251"
|
||||
},
|
||||
{
|
||||
"id": "eni-018923614c623ef74",
|
||||
"subnet": "subnet-260ee27f",
|
||||
"sgs": [
|
||||
"sg-06348763"
|
||||
],
|
||||
"privateIp": "10.3.0.122",
|
||||
"publicIp": "52.8.85.37"
|
||||
},
|
||||
{
|
||||
"id": "eni-0f310dd2e36654ed7",
|
||||
"subnet": "subnet-260ee27f",
|
||||
"sgs": [
|
||||
"sg-06348763"
|
||||
],
|
||||
"privateIp": "10.3.0.120",
|
||||
"publicIp": null
|
||||
},
|
||||
{
|
||||
"id": "eni-00172626263229e1a",
|
||||
"subnet": "subnet-051f6d705e415834d",
|
||||
"sgs": [],
|
||||
"privateIp": "10.10.1.37",
|
||||
"publicIp": "13.57.111.5"
|
||||
},
|
||||
{
|
||||
"id": "eni-0893189c99a73d4f2",
|
||||
"subnet": "subnet-260ee27f",
|
||||
"sgs": [
|
||||
"sg-06348763"
|
||||
],
|
||||
"privateIp": "10.3.0.167",
|
||||
"publicIp": null
|
||||
},
|
||||
{
|
||||
"id": "eni-0d0b9801f1e66ec76",
|
||||
"subnet": "subnet-d137fd88",
|
||||
"sgs": [],
|
||||
"privateIp": "10.4.0.246",
|
||||
"publicIp": "184.169.224.203"
|
||||
},
|
||||
{
|
||||
"id": "eni-b574dcef",
|
||||
"subnet": "subnet-16594550",
|
||||
"sgs": [
|
||||
"sg-642cf701"
|
||||
],
|
||||
"privateIp": "10.2.2.48",
|
||||
"publicIp": null
|
||||
},
|
||||
{
|
||||
"id": "eni-0080f66b73a68d1d1",
|
||||
"subnet": "subnet-d137fd88",
|
||||
"sgs": [
|
||||
"sg-b45a2bd1"
|
||||
],
|
||||
"privateIp": "10.4.0.117",
|
||||
"publicIp": "52.8.219.246"
|
||||
},
|
||||
{
|
||||
"id": "eni-02075fc7f6b5ed6f3",
|
||||
"subnet": "subnet-260ee27f",
|
||||
"sgs": [
|
||||
"sg-06348763"
|
||||
],
|
||||
"privateIp": "10.3.0.211",
|
||||
"publicIp": "13.57.152.11"
|
||||
},
|
||||
{
|
||||
"id": "eni-026ab4825bc4dfb8b",
|
||||
"subnet": "subnet-260ee27f",
|
||||
"sgs": [
|
||||
"sg-06348763"
|
||||
],
|
||||
"privateIp": "10.3.0.154",
|
||||
"publicIp": null
|
||||
},
|
||||
{
|
||||
"id": "eni-0ac39f75ad7dc0709",
|
||||
"subnet": "subnet-0cb2aa7457413f5fe",
|
||||
"sgs": [
|
||||
"sg-076d2ea1df0054074"
|
||||
],
|
||||
"privateIp": "10.10.3.88",
|
||||
"publicIp": "52.52.1.145"
|
||||
},
|
||||
{
|
||||
"id": "eni-0015cacdc10d467e8",
|
||||
"subnet": "subnet-005bfd58e719e031f",
|
||||
"sgs": [
|
||||
"sg-09621eafa81d9554d"
|
||||
],
|
||||
"privateIp": "10.4.11.198",
|
||||
"publicIp": null
|
||||
},
|
||||
{
|
||||
"id": "eni-039a605986c15dabf",
|
||||
"subnet": "subnet-005bfd58e719e031f",
|
||||
"sgs": [
|
||||
"sg-b45a2bd1"
|
||||
],
|
||||
"privateIp": "10.4.11.166",
|
||||
"publicIp": null
|
||||
},
|
||||
{
|
||||
"id": "eni-01f9242eb64928de8",
|
||||
"subnet": "subnet-005bfd58e719e031f",
|
||||
"sgs": [
|
||||
"sg-09621eafa81d9554d"
|
||||
],
|
||||
"privateIp": "10.4.11.139",
|
||||
"publicIp": null
|
||||
},
|
||||
{
|
||||
"id": "eni-09a54855abf31a012",
|
||||
"subnet": "subnet-005bfd58e719e031f",
|
||||
"sgs": [
|
||||
"sg-b45a2bd1"
|
||||
],
|
||||
"privateIp": "10.4.11.69",
|
||||
"publicIp": null
|
||||
},
|
||||
{
|
||||
"id": "eni-0787462fd9a1d1b6d",
|
||||
"subnet": "subnet-ebc4188e",
|
||||
"sgs": [
|
||||
"sg-da75adbf"
|
||||
],
|
||||
"privateIp": "10.2.1.156",
|
||||
"publicIp": "54.241.122.239"
|
||||
}
|
||||
],
|
||||
"ec2": [
|
||||
{
|
||||
"id": "i-0dc281f8d162602c8",
|
||||
"name": "efw.web.06b public-site",
|
||||
"type": "r5.xlarge",
|
||||
"privateIp": "10.3.0.211",
|
||||
"publicIp": "13.57.152.11",
|
||||
"sgs": [
|
||||
"sg-06348763"
|
||||
],
|
||||
"subnet": "subnet-260ee27f"
|
||||
},
|
||||
{
|
||||
"id": "i-0997a73b08f6e5862",
|
||||
"name": "efw.web.03b logon",
|
||||
"type": "t3.medium",
|
||||
"privateIp": "10.2.2.18",
|
||||
"publicIp": "54.153.101.192",
|
||||
"sgs": [
|
||||
"sg-642cf701"
|
||||
],
|
||||
"subnet": "subnet-16594550"
|
||||
},
|
||||
{
|
||||
"id": "i-0c8100e3460fa8fd0",
|
||||
"name": "vpn-default-vpc-02-001",
|
||||
"type": "t4g.nano",
|
||||
"privateIp": "10.3.0.112",
|
||||
"publicIp": "13.52.49.251",
|
||||
"sgs": [
|
||||
"sg-06348763"
|
||||
],
|
||||
"subnet": "subnet-260ee27f"
|
||||
},
|
||||
{
|
||||
"id": "i-041021cd89e15282c",
|
||||
"name": "vpn-vpc-01",
|
||||
"type": "t4g.nano",
|
||||
"privateIp": "10.2.1.156",
|
||||
"publicIp": "54.241.122.239",
|
||||
"sgs": [
|
||||
"sg-da75adbf"
|
||||
],
|
||||
"subnet": "subnet-ebc4188e"
|
||||
},
|
||||
{
|
||||
"id": "i-073b97cbda2b200c3",
|
||||
"name": "vault.staging.05",
|
||||
"type": "t3a.medium",
|
||||
"privateIp": "10.4.0.117",
|
||||
"publicIp": "52.8.219.246",
|
||||
"sgs": [
|
||||
"sg-b45a2bd1"
|
||||
],
|
||||
"subnet": "subnet-d137fd88"
|
||||
},
|
||||
{
|
||||
"id": "i-0669f35ab2d0fc444",
|
||||
"name": "vault.production.02",
|
||||
"type": "t3a.medium",
|
||||
"privateIp": "10.4.0.197",
|
||||
"publicIp": "52.8.75.57",
|
||||
"sgs": [
|
||||
"sg-b45a2bd1"
|
||||
],
|
||||
"subnet": "subnet-d137fd88"
|
||||
},
|
||||
{
|
||||
"id": "i-01b3f3cd57976bdf3",
|
||||
"name": "efw-vpn-default-vault-vpn-01",
|
||||
"type": "t4g.nano",
|
||||
"privateIp": "10.4.0.221",
|
||||
"publicIp": "52.53.117.111",
|
||||
"sgs": [
|
||||
"sg-b45a2bd1"
|
||||
],
|
||||
"subnet": "subnet-d137fd88"
|
||||
},
|
||||
{
|
||||
"id": "i-0272763b46610ac1b",
|
||||
"name": "efw.web.04d edit-site",
|
||||
"type": "m7i.xlarge",
|
||||
"privateIp": "10.3.0.122",
|
||||
"publicIp": "52.8.85.37",
|
||||
"sgs": [
|
||||
"sg-06348763"
|
||||
],
|
||||
"subnet": "subnet-260ee27f"
|
||||
},
|
||||
{
|
||||
"id": "i-0c82adf476c7c5e32",
|
||||
"name": "efw.web.06d public-site",
|
||||
"type": "m7i.xlarge",
|
||||
"privateIp": "10.3.0.63",
|
||||
"publicIp": "52.8.7.0",
|
||||
"sgs": [
|
||||
"sg-06348763"
|
||||
],
|
||||
"subnet": "subnet-260ee27f"
|
||||
},
|
||||
{
|
||||
"id": "i-0636fd0b033c9b32a",
|
||||
"name": "efw.acmetest",
|
||||
"type": "t3.medium",
|
||||
"privateIp": "10.3.0.73",
|
||||
"publicIp": null,
|
||||
"sgs": [
|
||||
"sg-06348763"
|
||||
],
|
||||
"subnet": "subnet-260ee27f"
|
||||
},
|
||||
{
|
||||
"id": "i-09241599c2590b66a",
|
||||
"name": "efw.acmetest2.linux",
|
||||
"type": "t3.micro",
|
||||
"privateIp": "10.3.0.167",
|
||||
"publicIp": null,
|
||||
"sgs": [
|
||||
"sg-06348763"
|
||||
],
|
||||
"subnet": "subnet-260ee27f"
|
||||
},
|
||||
{
|
||||
"id": "i-0d7f643cb9d960645",
|
||||
"name": "efw.vpn10.acmetest",
|
||||
"type": "t4g.nano",
|
||||
"privateIp": "10.10.1.166",
|
||||
"publicIp": "52.53.243.75",
|
||||
"sgs": [
|
||||
"sg-0a872ac3a6d9132a6"
|
||||
],
|
||||
"subnet": "subnet-051f6d705e415834d"
|
||||
},
|
||||
{
|
||||
"id": "i-082b27477bbe6d8b5",
|
||||
"name": "efw.acmetest3.linux",
|
||||
"type": "t3.micro",
|
||||
"privateIp": "10.10.2.106",
|
||||
"publicIp": null,
|
||||
"sgs": [
|
||||
"sg-04167d2e18ba15e45",
|
||||
"sg-0a872ac3a6d9132a6"
|
||||
],
|
||||
"subnet": "subnet-035f81142068bf961"
|
||||
}
|
||||
],
|
||||
"lbs": [
|
||||
{
|
||||
"id": "NodeAppALB",
|
||||
"scheme": "internet-facing",
|
||||
"type": "application",
|
||||
"subnets": [
|
||||
"subnet-051f6d705e415834d",
|
||||
"subnet-0cb2aa7457413f5fe"
|
||||
],
|
||||
"securityGroups": [
|
||||
"sg-076d2ea1df0054074"
|
||||
],
|
||||
"listeners": [
|
||||
{
|
||||
"proto": "HTTP",
|
||||
"port": 80
|
||||
},
|
||||
{
|
||||
"proto": "HTTPS",
|
||||
"port": 443
|
||||
}
|
||||
],
|
||||
"targetGroups": [
|
||||
{
|
||||
"port": 8080,
|
||||
"targets": [
|
||||
"i-082b27477bbe6d8b5"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"rds": [
|
||||
{
|
||||
"id": "f8-db-01",
|
||||
"engine": "sqlserver-web",
|
||||
"port": 0,
|
||||
"publiclyAccessible": false,
|
||||
"sgs": [
|
||||
"sg-06348763"
|
||||
],
|
||||
"subnetGroup": [
|
||||
"subnet-e98e088c",
|
||||
"subnet-f73afeae"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "logon-db-02",
|
||||
"engine": "sqlserver-web",
|
||||
"port": 0,
|
||||
"publiclyAccessible": false,
|
||||
"sgs": [
|
||||
"sg-4235ee27"
|
||||
],
|
||||
"subnetGroup": [
|
||||
"subnet-16594550",
|
||||
"subnet-ebc4188e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "vault-db-production-v2",
|
||||
"engine": "aurora-mysql",
|
||||
"port": 0,
|
||||
"publiclyAccessible": false,
|
||||
"sgs": [
|
||||
"sg-09621eafa81d9554d"
|
||||
],
|
||||
"subnetGroup": [
|
||||
"subnet-005bfd58e719e031f",
|
||||
"subnet-05b5dcdc91a8d3da2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "vault-db-staging-v2",
|
||||
"engine": "aurora-mysql",
|
||||
"port": 0,
|
||||
"publiclyAccessible": false,
|
||||
"sgs": [
|
||||
"sg-09621eafa81d9554d"
|
||||
],
|
||||
"subnetGroup": [
|
||||
"subnet-005bfd58e719e031f",
|
||||
"subnet-05b5dcdc91a8d3da2"
|
||||
]
|
||||
}
|
||||
],
|
||||
"exposures": [
|
||||
{
|
||||
"surface": "NodeAppALB:80",
|
||||
"world_open": true,
|
||||
"via": "APPLICATION",
|
||||
"to": [
|
||||
"i-082b27477bbe6d8b5:8080"
|
||||
]
|
||||
},
|
||||
{
|
||||
"surface": "NodeAppALB:443",
|
||||
"world_open": true,
|
||||
"via": "APPLICATION",
|
||||
"to": [
|
||||
"i-082b27477bbe6d8b5:8080"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FlowGrid (Vue 2 POC)</title>
|
||||
<style>
|
||||
:root{
|
||||
--ink:#0f172a;
|
||||
--reqBorder:#2e7d32; --reqFill:#eef7ef;
|
||||
--recBorder:#8a8a8a; --recFill:#ffffff;
|
||||
--doneBorder:#9ca3af; --doneInk:#475569;
|
||||
--modeCol:180px; --gap:12px;
|
||||
--step:260px; --arrow:34px; --done:110px;
|
||||
--bg:#f6f7fb;
|
||||
}
|
||||
html,body{margin:0;background:var(--bg);font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:var(--ink)}
|
||||
.wrap{margin:24px auto 48px;padding:0 16px}
|
||||
h1{font-size:22px;margin:0 0 8px}
|
||||
.grid{display:flex;flex-direction:column;gap:18px}
|
||||
.lane{display:grid;grid-template-columns:var(--modeCol) 1fr;gap:var(--gap);align-items:center;background:#ffffffcc;padding:12px;border-radius:12px}
|
||||
.mode{font-weight:700;text-align:center;background:#fff;padding:16px 10px}
|
||||
.flow{display:grid;align-items:center;gap:8px;padding:8px 0;}
|
||||
.header {grid-column: span 4; }
|
||||
.step{border-radius:10px;padding:10px 12px;border:2px solid var(--reqBorder);background:var(--reqFill);min-height:64px}
|
||||
.step .title{font-weight:700}
|
||||
.step .sub{font-size:12px;opacity:.8}
|
||||
.step.rec{border-color:var(--recBorder);border-style:dashed;background:var(--recFill)}
|
||||
.slot{}
|
||||
.arrow{font-size:22px;line-height:1;text-align:center}
|
||||
.arrow.blank{color:transparent}
|
||||
.done{justify-self:start;border-radius:999px;border:2px dashed var(--doneBorder);padding:10px 14px;color:var(--doneInk);background:#fff;text-align:center}
|
||||
@media (max-width:900px){
|
||||
.lane{grid-template-columns:1fr}
|
||||
.mode{order:-1}
|
||||
.flow{grid-template-columns:1fr; background:none}
|
||||
.arrow{display:none}
|
||||
}
|
||||
.tag{display:inline-block;padding:2px 8px;border-radius:999px;border:1.5px solid var(--reqBorder);background:var(--reqFill);font-size:12px}
|
||||
.tag.rec{border-color:var(--recBorder);background:var(--recFill);border-style:dashed}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="wrap">
|
||||
<div class="grid">
|
||||
<!-- Title lane -->
|
||||
<div class="lane">
|
||||
<div class="mode"> </div>
|
||||
<div class="flow" :style="{gridTemplateColumns: columns}">
|
||||
<div class="header"><h1>{{ doc.title }}</h1></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lanes -->
|
||||
<div class="lane" v-for="(lane, li) in doc.lanes" :key="li">
|
||||
<div class="mode" v-html="lane.name"></div>
|
||||
<div class="flow" :style="{gridTemplateColumns: columns}">
|
||||
<template v-for="(step, si) in lane.steps">
|
||||
<div class="step" :class="{ rec: step.tag === 'rec' }">
|
||||
<div class="title">{{ step.code }}</div>
|
||||
<div class="sub" v-html="formatSub(step)"></div>
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
</template>
|
||||
|
||||
<!-- padding cells so columns align across lanes -->
|
||||
<template v-for="n in (maxSteps - lane.steps.length)">
|
||||
<div class="slot"></div>
|
||||
<div class="arrow blank">→</div>
|
||||
</template>
|
||||
|
||||
<div class="done">Done</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The DSL spec lives directly in this script tag -->
|
||||
<script type="text/plain" id="flow-spec">
|
||||
TITLE: Online Teaching Requirements and Recommendations
|
||||
VAR: --step=280px; --modeCol=180px
|
||||
|
||||
LANE: In Person (with Canvas)
|
||||
STEP: GOTT 1 | Intro to Online Teaching with Canvas | weeks=2; hours=20; tag=rec
|
||||
|
||||
LANE: Online
|
||||
STEP: GOTT 1 | Intro to Online Teaching with Canvas | weeks=2; hours=20; tag=req
|
||||
STEP: GOTT 2 | Introduction to Asynchronous Online Teaching and Learning | weeks=4; hours=40; tag=req
|
||||
|
||||
LANE: Hybrid
|
||||
STEP: GOTT 1 | Intro to Online Teaching with Canvas | weeks=2; hours=20; tag=req
|
||||
STEP: GOTT 2 | Introduction to Asynchronous Online Teaching and Learning | weeks=4; hours=40; tag=req
|
||||
STEP: GOTT 5 | Essentials of Blended Learning | weeks=2; hours=20; tag=rec
|
||||
|
||||
LANE: Online Live
|
||||
STEP: GOTT 1 | Intro to Online Teaching with Canvas | weeks=2; hours=20; tag=req
|
||||
STEP: GOTT 2 | Introduction to Asynchronous Online Teaching and Learning | weeks=4; hours=40; tag=req
|
||||
STEP: GOTT 6 | Introduction to Live Online Teaching and Learning | weeks=2; hours=20; tag=rec
|
||||
|
||||
LANE: HyFlex
|
||||
STEP: GOTT 1 | Intro to Online Teaching with Canvas | weeks=2; hours=20; tag=req
|
||||
STEP: GOTT 2 | Introduction to Asynchronous Online Teaching and Learning | weeks=4; hours=40; tag=req
|
||||
STEP: GOTT 6 | Introduction to Live Online Teaching and Learning | weeks=2; hours=20; tag=rec
|
||||
STEP: HyFlex Tech Training | ~1 hour on-site | desc=~1 hour on-site; tag=rec
|
||||
</script>
|
||||
|
||||
<!-- Vue 2 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
|
||||
|
||||
<script>
|
||||
function normalizeTag(tag) {
|
||||
if (!tag) return null;
|
||||
const t = String(tag).toLowerCase().trim();
|
||||
if (t === 'req' || t === 'required') return 'req';
|
||||
if (t === 'rec' || t === 'recommended') return 'rec';
|
||||
if (['none','na','n/a','optional'].includes(t)) return null;
|
||||
return t;
|
||||
}
|
||||
|
||||
function parseSpec(text) {
|
||||
const lines = text.split(/\r?\n/).map(l => l.trim());
|
||||
const doc = { title: 'Untitled Diagram', lanes: [], cssVars: {} };
|
||||
let currentLane = null;
|
||||
|
||||
for (const raw of lines) {
|
||||
if (!raw || raw.startsWith('#')) continue;
|
||||
let m;
|
||||
|
||||
if ((m = raw.match(/^TITLE\s*:\s*(.+)$/i))) {
|
||||
doc.title = m[1].trim();
|
||||
continue;
|
||||
}
|
||||
if ((m = raw.match(/^VAR\s*:\s*(.+)$/i))) {
|
||||
const parts = m[1].split(';').map(p => p.trim()).filter(Boolean);
|
||||
for (const p of parts) {
|
||||
const eq = p.indexOf('=');
|
||||
if (eq > -1) {
|
||||
const k = p.slice(0, eq).trim();
|
||||
const v = p.slice(eq+1).trim();
|
||||
doc.cssVars[k] = v;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ((m = raw.match(/^LANE\s*:\s*(.+)$/i))) {
|
||||
currentLane = { name: m[1].trim(), steps: [] };
|
||||
doc.lanes.push(currentLane);
|
||||
continue;
|
||||
}
|
||||
if ((m = raw.match(/^STEP\s*:\s*(.+)$/i))) {
|
||||
if (!currentLane) throw new Error('STEP appears before any LANE');
|
||||
const body = m[1];
|
||||
const parts = body.split('|').map(s => s.trim());
|
||||
if (parts.length < 2) throw new Error('STEP needs CODE | LABEL | ...');
|
||||
|
||||
const code = parts[0];
|
||||
const label = parts[1];
|
||||
const attrsBlob = parts[2] || '';
|
||||
const kw = {};
|
||||
|
||||
if (attrsBlob) {
|
||||
attrsBlob.split(';').map(s => s.trim()).filter(Boolean).forEach(kv => {
|
||||
const eq = kv.indexOf('=');
|
||||
if (eq > -1) {
|
||||
kw[kv.slice(0,eq).trim().toLowerCase()] = kv.slice(eq+1).trim();
|
||||
} else if (['req','rec'].includes(kv.toLowerCase())) {
|
||||
kw['tag'] = kv.toLowerCase();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
currentLane.steps.push({
|
||||
code,
|
||||
label,
|
||||
weeks: kw['weeks'] || kw['w'] || null,
|
||||
hours: kw['hours'] || kw['hrs'] || kw['h'] || null,
|
||||
tag: normalizeTag(kw['tag']),
|
||||
desc: kw['desc'] || null,
|
||||
klass: kw['class'] || kw['klass'] || null
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error('Unrecognized line: ' + raw);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#app',
|
||||
data() {
|
||||
const specText = document.getElementById('flow-spec').textContent;
|
||||
const doc = parseSpec(specText);
|
||||
|
||||
// Apply CSS custom properties from VAR:
|
||||
for (const k in doc.cssVars) {
|
||||
if (k.trim().startsWith('--')) {
|
||||
document.documentElement.style.setProperty(k.trim(), doc.cssVars[k]);
|
||||
}
|
||||
}
|
||||
return { doc };
|
||||
},
|
||||
computed: {
|
||||
maxSteps() {
|
||||
if (!this.doc.lanes.length) return 1;
|
||||
return Math.max.apply(null, this.doc.lanes.map(l => l.steps.length || 0).concat([1]));
|
||||
},
|
||||
columns() {
|
||||
const pairs = Array(this.maxSteps).fill('var(--step) var(--arrow)').join(' ');
|
||||
return pairs + ' var(--done)';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
formatSub(step) {
|
||||
let core = '';
|
||||
if (step.desc) {
|
||||
core = this.escapeHTML(step.desc);
|
||||
} else {
|
||||
const bits = [this.escapeHTML(step.label)];
|
||||
if (step.weeks && step.hours) {
|
||||
bits.push(` · ${this.escapeHTML(step.weeks)} weeks (~${this.escapeHTML(step.hours)} hrs)`);
|
||||
} else if (step.weeks) {
|
||||
bits.push(` · ${this.escapeHTML(step.weeks)} weeks`);
|
||||
} else if (step.hours) {
|
||||
bits.push(` · ~${this.escapeHTML(step.hours)} hrs`);
|
||||
}
|
||||
core = bits.join('');
|
||||
}
|
||||
let tagHTML = '';
|
||||
if (step.tag === 'req') tagHTML = '<span class="tag">Required</span>';
|
||||
else if (step.tag === 'rec') tagHTML = '<span class="tag rec">Recommended</span>';
|
||||
return tagHTML ? core + ' · ' + tagHTML : core;
|
||||
},
|
||||
escapeHTML(s) {
|
||||
const d = document.createElement('div');
|
||||
d.innerText = String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue