Browse Source

major refactor to plugin architecture; some plugins still need to be added

pull/1/head
Stephanie Gredell 7 months ago
parent
commit
ed3705a9de
  1. 177
      static/app.js
  2. 38
      static/game.html
  3. 37
      static/node.js
  4. 11
      static/pluginRegistry.js
  5. 13
      static/plugins/cache.js
  6. 10
      static/plugins/database.js
  7. 10
      static/plugins/loadbalancer.js
  8. 11
      static/plugins/messageQueue.js
  9. 10
      static/plugins/user.js
  10. 10
      static/plugins/webserver.js
  11. 8
      static/utils.js

177
static/app.js

@ -1,5 +1,12 @@ @@ -1,5 +1,12 @@
import { ComponentNode } from './node.js'
import { generateNodeId, createSVGElement } from './utils.js';
import { generateNodeId, createSVGElement, generateDefaultProps } from './utils.js';
import './plugins/user.js';
import './plugins/webserver.js';
import './plugins/cache.js';
import './plugins/loadbalancer.js';
import './plugins/database.js';
import './plugins/messageQueue.js';
import { PluginRegistry } from './pluginRegistry.js';
export class CanvasApp {
constructor() {
@ -23,21 +30,11 @@ export class CanvasApp { @@ -23,21 +30,11 @@ export class CanvasApp {
this.dbGroup = document.getElementById('db-group');
this.cacheGroup = document.getElementById('cache-group');
this.selectedNode = null;
this.computeTypes = ['webserver', 'microservice'];
this.computeGroup = document.getElementById('compute-group');
this.lbGroup = document.getElementById('lb-group');
this.mqGroup = document.getElementById('mq-group');
this.placeholderText = createSVGElement('text', {
x: '50%',
y: '50%',
'text-anchor': 'middle',
'dominant-baseline': 'middle',
fill: '#444',
'font-size': 18,
'pointer-events': 'none'
});
this.placeholderText.textContent = 'Drag and drop elements to start building your system. Press backspace or delete to remove elements.';
this.canvas.appendChild(this.placeholderText);
this.initEventHandlers();
}
@ -56,10 +53,12 @@ export class CanvasApp { @@ -56,10 +53,12 @@ export class CanvasApp {
}
});
this.sidebar.addEventListener('dragstart', (e) => {
if (e.target.classList.contains('component-icon')) {
e.dataTransfer.setData('text/plain', e.target.getAttribute('data-type'));
e.target.classList.add('dragging');
}
const type = e.target.getAttribute('data-type');
const plugin = PluginRegistry.get(type);
if (!plugin) return;
e.dataTransfer.setData('text/plain', type)
});
this.sidebar.addEventListener('dragend', (e) => {
if (e.target.classList.contains('component-icon')) {
@ -69,26 +68,27 @@ export class CanvasApp { @@ -69,26 +68,27 @@ export class CanvasApp {
this.canvasContainer.addEventListener('dragover', (e) => e.preventDefault());
this.canvasContainer.addEventListener('drop', (e) => {
e.preventDefault();
const type = e.dataTransfer.getData('text/plain');
const plugin = PluginRegistry.get(type);
if (!plugin) return;
const pt = this.canvas.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
const svgP = pt.matrixTransform(this.canvas.getScreenCTM().inverse());
const x = svgP.x - this.componentSize.width / 2;
const y = svgP.y - this.componentSize.height / 2;
const node = new ComponentNode(type, x, y, this);
const props = generateDefaultProps(plugin);
const node = new ComponentNode(type, x, y, this, props);
node.x = x;
node.y = y;
if (this.placeholderText) {
this.placeholderText.remove();
this.placeholderText = null;
}
});
this.runButton.addEventListener('click', () => {
const designData = this.exportDesign();
console.log(JSON.stringify(designData));
console.log(JSON.stringify(designData))
});
this.canvas.addEventListener('click', () => {
@ -102,21 +102,35 @@ export class CanvasApp { @@ -102,21 +102,35 @@ export class CanvasApp {
this.propsSaveBtn.addEventListener('click', () => {
if (!this.activeNode) return;
const nodeObj = this.activeNode;
const node = this.activeNode;
const panel = this.nodePropsPanel;
const newLabel = panel.querySelector("input[name='label']").value;
nodeObj.updateLabel(newLabel);
if (nodeObj.type === 'Database') {
nodeObj.props.replication = parseInt(panel.querySelector("input[name='replication']").value, 10);
}
if (nodeObj.type === 'cache') {
nodeObj.props.cacheTTL = parseInt(panel.querySelector("input[name='cacheTTL']").value, 10);
nodeObj.props.maxEntries = parseInt(panel.querySelector("input[name='maxEntries']").value, 10);
nodeObj.props.evictionPolicy = panel.querySelector("select[name='evictionPolicy']").value;
const plugin = PluginRegistry.get(node.type);
if (!plugin || !plugin.props) {
this.hidePropsPanel();
return;
}
if (this.computeTypes.includes(nodeObj.type)) {
nodeObj.props.instanceSize = panel.querySelector("select[name='instanceSize']").value;
// Loop through plugin-defined props and update the node
for (const prop of plugin.props) {
const input = panel.querySelector(`[name='${prop.name}']`);
if (!input) continue;
let value;
if (prop.type === 'number') {
value = parseFloat(input.value);
if (isNaN(value)) value = prop.default ?? 0;
} else {
value = input.value;
}
node.props[prop.name] = value;
if (prop.name === 'label') {
node.updateLabel(value);
}
}
this.hidePropsPanel();
});
@ -147,54 +161,47 @@ export class CanvasApp { @@ -147,54 +161,47 @@ export class CanvasApp {
});
}
showPropsPanel(nodeObj) {
this.activeNode = nodeObj;
showPropsPanel(node) {
this.activeNode = node;
const plugin = PluginRegistry.get(node.type);
const panel = this.nodePropsPanel;
if (nodeObj.type === 'user') {
if (!plugin || this.arrowMode) {
this.hidePropsPanel();
return;
}
// Position the panel (optional, or you can use fixed top/right)
const bbox = nodeObj.group.getBBox();
const ctm = nodeObj.group.getCTM();
const bbox = node.group.getBBox();
const ctm = node.group.getCTM();
const screenX = ctm.e + bbox.x;
const screenY = ctm.f + bbox.y + bbox.height;
panel.style.left = (screenX + this.canvasContainer.getBoundingClientRect().left) + 'px';
panel.style.top = (screenY + this.canvasContainer.getBoundingClientRect().top) + 'px';
// Always show label group
this.labelGroup.style.display = 'block';
panel.querySelector("input[name='label']").value = nodeObj.props.label;
// Hide all groups first
const allGroups = panel.querySelectorAll('.prop-group, #label-group, #compute-group, #lb-group');
allGroups.forEach(g => g.style.display = 'none');
// Show DB fields if it's a Database
this.dbGroup.style.display = nodeObj.type === 'Database' ? 'block' : 'none';
if (nodeObj.type === 'Database') {
this.dbGroup.querySelector("input[name='replication']").value = nodeObj.props.replication;
}
const shownGroups = new Set();
for (const prop of plugin.props) {
const group = panel.querySelector(`[data-group='${prop.group}']`);
const input = panel.querySelector(`[name='${prop.name}']`);
// Show group once
if (group && !shownGroups.has(group)) {
group.style.display = 'block';
shownGroups.add(group);
}
// Show cache fields if it's a cache
const isCache = nodeObj.type === 'cache';
this.cacheGroup.style.display = isCache ? 'block' : 'none';
if (isCache) {
this.cacheGroup.querySelector("input[name='cacheTTL']").value = nodeObj.props.cacheTTL ?? 0;
panel.querySelector('input[name="cacheTTL"]').value = nodeObj.props.cacheTTL;
panel.querySelector("input[name='maxEntries']").value = nodeObj.props.maxEntries || 100000;
panel.querySelector("select[name='evictionPolicy']").value = nodeObj.props.evictionPolicy || 'LRU';
// Set value
if (input) {
input.value = node.props[prop.name] ?? prop.default;
}
}
this.propsSaveBtn.disabled = false;
panel.style.display = 'block';
const isCompute = this.computeTypes.includes(nodeObj.type);
console.log(isCompute)
console.log(nodeObj)
this.computeGroup.style.display = isCompute ? 'block' : 'none';
if (isCompute) {
panel.querySelector("select[name='instanceSize']").value = nodeObj.props.instanceSize || 'medium';
}
}
hidePropsPanel() {
@ -228,35 +235,19 @@ export class CanvasApp { @@ -228,35 +235,19 @@ export class CanvasApp {
const nodes = this.placedComponents
.filter(n => n.type !== 'user')
.map(n => {
const baseProps = { label: n.props.label };
// Add only if it's a database
if (n.type === 'Database') {
baseProps.replication = n.props.replication;
}
// Add only if it's a cache
if (n.type === 'cache') {
baseProps.cacheTTL = n.props.cacheTTL;
baseProps.maxEntries = n.props.maxEntries;
baseProps.evictionPolicy = n.props.evictionPolicy;
}
// Add only if it's a compute node
const computeTypes = ['WebServer', 'Microservice'];
if (computeTypes.includes(n.type)) {
baseProps.instanceSize = n.props.instanceSize;
}
return {
const plugin = PluginRegistry.get(n.type);
const result = {
id: n.id,
type: n.type,
props: baseProps,
position: {
x: n.x,
y: n.y
}
position: { x: n.x, y: n.y },
props: {}
};
plugin?.props?.forEach(p => {
result.props[p.name] = n.props[p.name];
});
return result;
});
const connections = this.connections.map(c => ({

38
static/game.html

@ -604,55 +604,45 @@ @@ -604,55 +604,45 @@
<div id="sidebar">
<div class="component-icon" draggable="true" data-type="user">
user
<span class="tooltip">simulates user traffic</span>
</div>
<div class="component-icon" draggable="true" data-type="load balancer">
<div class="component-icon" draggable="true" data-type="loadBalancer">
load balancer
<span class="tooltip">cost: $5/mo<br>distributes traffic evenly<br>latency: 5 ms</span>
</div>
<div class="component-icon" draggable="true" data-type="webserver">
webserver
<span class="tooltip">cost: varies<br>capacity and latency depends on instance size</span>
</div>
<div class="component-icon" draggable="true" data-type="database">
database
<span class="tooltip">cost: $20/mo<br>read capacity: 150 rps<br>base latency: 80 ms<br>supports replication</span>
</div>
<div class="component-icon" draggable="true" data-type="cache">
cache
</div>
<div class="component-icon" draggable="true" data-type="messagequeue">
<div class="component-icon" draggable="true" data-type="messageQueue">
message queue
<span class="tooltip">cost: $15/mo<br>decouples components<br>useful for batching writes</span>
</div>
<div class="component-icon" draggable="true" data-type="cdn">
CDN
<span class="tooltip">cost: $0.03/gb<br>improves global latency<br>caches static content</span>
</div>
<div class="component-icon" draggable="true" data-type="microservice">
microservice node
<span class="tooltip">cost: $10/mo<br>stateless container<br>use for modular logic</span>
</div>
<div class="component-icon" draggable="true" data-type="data pipeline">
data pipeline
<span class="tooltip">cost: $25/mo<br>stream or batch processing<br>used for analytics / etl</span>
</div>
<div class="component-icon" draggable="true" data-type="monitoring/alerting">
monitoring/alerting
<span class="tooltip">cost: $5/mo<br>health checks + logs<br>alerts on failures</span>
</div>
<div class="component-icon" draggable="true" data-type="third party service">
third-party service
<span class="tooltip">external apis<br>latency + cost vary<br>examples: payment, email, search</span>
</div>
</div>
@ -728,13 +718,14 @@ @@ -728,13 +718,14 @@
</svg>
<div id="node-props-panel">
<h3>node properties</h3>
<div id="label-group">
<label>label:<input type="text" name="label" /></label>
<div id="label-group" data-group="label-group">
<label>label:</label>
<input type="text" name="label" />
</div>
<div id="db-group" class="prop-group">
<div id="db-group" class="prop-group" data-group="db-group">
<label>replication factor:<input type="number" name="replication" min="1" step="1" /></label>
</div>
<div id="cache-group" class="prop-group">
<div id="cache-group" class="prop-group" data-group="cache-group">
<label>cache ttl (secs):<input type="number" name="cacheTTL" min="0" step="60" /></label>
<label>Max Entries: <input name="maxEntries" type="number" /></label>
<label>Eviction Policy:
@ -745,7 +736,7 @@ @@ -745,7 +736,7 @@
</select>
</label>
</div>
<div id="compute-group">
<div id="compute-group" data-group="compute-group" class="prop-group">
<label>Instance Size:</label>
<select name="instanceSize">
<option value="small">Small</option>
@ -753,7 +744,20 @@ @@ -753,7 +744,20 @@
<option value="large">Large</option>
</select>
</div>
<div id="lb-group" data-group="lb-group" class="prop-group">
<label>Algorithm</label>
<select name="algorithm">
<option value="round-robin">Round Robin</option>
<option value="least-connections">Least Connections</option>
</select>
</div>
<div id="mq-group" data-group="mq-group" class="prop-group">
<label>Max Size</label>
<input type="number" name="maxSize" min="1" />
<label>Retention Time (sec)</label>
<input type="number" name="retentionSeconds" min="1" />
</div>
<button id="node-props-save" disabled>save</button>
</div>
<div id="bottom-panel">

37
static/node.js

@ -2,7 +2,7 @@ import { Connection } from './connection.js'; @@ -2,7 +2,7 @@ import { Connection } from './connection.js';
import { generateNodeId, createSVGElement } from './utils.js';
export class ComponentNode {
constructor(type, x, y, app) {
constructor(type, x, y, app, props = {}) {
this.id = generateNodeId();
this.type = type;
this.app = app;
@ -10,19 +10,24 @@ export class ComponentNode { @@ -10,19 +10,24 @@ export class ComponentNode {
label: type,
replication: 1,
cacheTTL: 0,
instanceSize: 'medium'
instanceSize: 'medium',
...props
};
this.group = createSVGElement('g', { class: 'dropped', 'data-type': type });
const rect = createSVGElement('rect', {
x, y,
width: 0,
x,
y,
width: 0, // will be updated after measuring text
height: app.componentSize.height,
fill: '#121212',
stroke: '#00ff88',
'stroke-width': 1,
rx: 4, ry: 4
rx: 4,
ry: 4
});
this.group.appendChild(rect);
this.text = createSVGElement('text', {
x: x + app.componentSize.width / 2,
y: y + app.componentSize.height / 2 + 5,
@ -30,17 +35,28 @@ export class ComponentNode { @@ -30,17 +35,28 @@ export class ComponentNode {
'font-size': 16,
fill: '#ccc'
});
this.text.textContent = this.props.label;
this.app.canvas.appendChild(this.text); // temporarily append to measure
// Temporarily add text to canvas to measure its width
app.canvas.appendChild(this.text);
const textWidth = this.text.getBBox().width;
const padding = 20;
const finalWidth = textWidth + padding;
// Update rect width and center text
rect.setAttribute('width', finalWidth);
this.text.setAttribute('x', x + finalWidth / 2);
// Clean up temporary text
app.canvas.removeChild(this.text);
this.group.appendChild(rect);
this.group.appendChild(this.text);
this.group.__nodeObj = this;
this.initDrag();
this.group.addEventListener('click', (e) => {
e.stopPropagation();
if (app.arrowMode) {
@ -50,15 +66,20 @@ export class ComponentNode { @@ -50,15 +66,20 @@ export class ComponentNode {
this.select();
}
});
this.group.addEventListener('dblclick', (e) => {
e.stopPropagation();
if (!app.arrowMode) {
app.showPropsPanel(this);
}
});
app.canvas.appendChild(this.group);
app.canvas.appendChild(this.group); // ✅ now correctly adding full group
app.placedComponents.push(this);
app.runButton.disabled = false;
this.x = x;
this.y = y;
}
initDrag() {

11
static/pluginRegistry.js

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
export class PluginRegistry {
static plugins = {}
static register(type, plugin) {
this.plugins[type] = plugin;
}
static get(type) {
return this.plugins[type];
}
}

13
static/plugins/cache.js

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
import { PluginRegistry } from "../pluginRegistry.js";
PluginRegistry.register('cache', {
type: 'cache',
label: 'Cache',
props: [
{ name: 'label', type: 'string', default: 'Cache', group: 'label-group' },
{ name: 'cacheTTL', type: 'number', default: 60, group: 'cache-group' },
{ name: 'maxEntries', type: 'number', default: 100000, group: 'cache-group' },
{ name: 'evictionPolicy', type: 'string', default: 'LRU', group: 'cache-group' }
]
});

10
static/plugins/database.js

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
import { PluginRegistry } from "../pluginRegistry.js";
PluginRegistry.register('database', {
type: 'database',
label: 'Database',
props: [
{ name: 'label', type: 'string', default: 'Database', group: 'label-group' },
{ name: 'replication', type: 'number', default: 1, group: 'db-group' }
]
});

10
static/plugins/loadbalancer.js

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
import { PluginRegistry } from "../pluginRegistry.js";
PluginRegistry.register('loadBalancer', {
type: 'loadBalancer',
label: 'Load Balancer',
props: [
{ name: 'label', type: 'string', default: 'Load Balancer', group: 'label-group' },
{ name: 'algorithm', type: 'string', default: 'round-robin', group: 'lb-group' }
]
});

11
static/plugins/messageQueue.js

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
import { PluginRegistry } from "../pluginRegistry.js";
PluginRegistry.register('messageQueue', {
type: 'messageQueue',
label: 'Message Queue',
props: [
{ name: 'label', type: 'string', default: 'MQ', group: 'label-group' },
{ name: 'maxSize', type: 'number', default: 10000, group: 'mq-group' },
{ name: 'retentionSeconds', type: 'number', default: 600, group: 'mq-group' }
]
});

10
static/plugins/user.js

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
import { PluginRegistry } from "../pluginRegistry.js";
PluginRegistry.register('user', {
type: 'user',
label: 'User',
isVisualOnly: true,
props: [
{ name: 'label', type: 'string', default: 'User', group: 'label-group' }
]
});

10
static/plugins/webserver.js

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
import { PluginRegistry } from "../pluginRegistry.js";
PluginRegistry.register('webserver', {
type: 'webserver',
label: 'Web Server',
props: [
{ name: 'label', type: 'string', default: 'Web Server', group: 'label-group' },
{ name: 'instanceSize', type: 'string', default: 'medium', group: 'compute-group' }
]
});

8
static/utils.js

@ -22,3 +22,11 @@ export function createSVGElement(tag, attrs) { @@ -22,3 +22,11 @@ export function createSVGElement(tag, attrs) {
}
return elem;
}
export function generateDefaultProps(plugin) {
const defaults = {};
plugin.props?.forEach(p => {
defaults[p.name] = p.default;
});
return defaults;
}

Loading…
Cancel
Save