You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
484 lines
17 KiB
484 lines
17 KiB
import { ComponentNode } from './node.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 './plugins/cdn.js'; |
|
import './plugins/microservice.js'; |
|
import './plugins/datapipeline.js'; |
|
import './plugins/monitorAlerting.js'; |
|
import './plugins/thirdPartyService.js'; |
|
import { PluginRegistry } from './pluginRegistry.js'; |
|
|
|
export class CanvasApp { |
|
constructor() { |
|
this.placedComponents = []; |
|
this.connections = []; |
|
this.componentSize = { width: 120, height: 40 }; |
|
this.arrowMode = false; |
|
this.connectionStart = null; |
|
this.pendingConnection = null; |
|
this.activeNode = null; |
|
this.selectedConnection = null; |
|
|
|
this.sidebar = document.getElementById('sidebar'); |
|
this.arrowToolBtn = document.getElementById('arrow-tool-btn'); |
|
this.canvasContainer = document.getElementById('canvas-container'); |
|
this.canvas = document.getElementById('canvas'); |
|
this.runButton = document.getElementById('run-button'); |
|
this.nodePropsPanel = document.getElementById('node-props-panel'); |
|
this.propsSaveBtn = document.getElementById('node-props-save'); |
|
this.labelGroup = document.getElementById('label-group'); |
|
this.dbGroup = document.getElementById('db-group'); |
|
this.cacheGroup = document.getElementById('cache-group'); |
|
this.selectedNode = null; |
|
this.computeGroup = document.getElementById('compute-group'); |
|
this.lbGroup = document.getElementById('lb-group'); |
|
this.mqGroup = document.getElementById('mq-group'); |
|
this.startChatBtn = document.getElementById('start-chat'); |
|
this.chatElement = document.getElementById('chat-box'); |
|
this.chatTextField = document.getElementById('chat-message-box'); |
|
this.chatMessages = document.getElementById('messages'); |
|
this.chatLoadingIndicator = document.getElementById('loading-indicator'); |
|
this.level = window.levelData; |
|
this.ws = null; |
|
this.plugins = PluginRegistry.getAll() |
|
this.createDesignBtn = document.getElementById('create-design-button'); |
|
this.learnMoreBtn = document.getElementById('learn-more-button'); |
|
this.tabs = document.getElementsByClassName('tabinput'); |
|
|
|
console.log(this.tabs) |
|
this._reconnectDelay = 1000; |
|
this._maxReconnectDelay = 15000; |
|
this._reconnectTimer = null; |
|
|
|
this.initEventHandlers(); |
|
} |
|
|
|
initEventHandlers() { |
|
const requirementstab = this.tabs[1]; |
|
const designtab = this.tabs[1]; |
|
const resourcestab = this.tabs[2]; |
|
|
|
this.learnMoreBtn.addEventListener('click', () => { |
|
requirementstab.checked = false; |
|
resourcestab.checked = true; |
|
}); |
|
|
|
this.createDesignBtn.addEventListener('click', () => { |
|
requirementstab.checked = false; |
|
designtab.checked = true; |
|
}); |
|
|
|
this.arrowToolBtn.addEventListener('click', () => { |
|
this.arrowMode = !this.arrowMode; |
|
if (this.arrowMode) { |
|
this.arrowToolBtn.classList.add('active'); |
|
this.hidePropsPanel(); |
|
} else { |
|
this.arrowToolBtn.classList.remove('active'); |
|
if (this.connectionStart) { |
|
this.connectionStart.group.classList.remove('selected'); |
|
this.connectionStart = null; |
|
} |
|
} |
|
}); |
|
this.startChatBtn.addEventListener('click', () => { |
|
const scheme = location.protocol === "https:" ? "wss://" : "ws://"; |
|
|
|
this.ws = new WebSocket(scheme + location.host + "/ws"); |
|
|
|
this.ws.onopen = () => { |
|
this.ws.send(JSON.stringify({ |
|
'designPayload': JSON.stringify(this.exportDesign()), |
|
'message': '' |
|
})); |
|
} |
|
|
|
this.ws.onmessage = (e) => { |
|
this.chatLoadingIndicator.style.display = 'none'; |
|
this.chatTextField.disabled = false; |
|
this.chatTextField.focus(); |
|
const message = document.createElement('p'); |
|
message.innerHTML = e.data; |
|
message.className = "other"; |
|
this.chatMessages.insertBefore(message, this.chatLoadingIndicator) |
|
} |
|
|
|
this.ws.onerror = (err) => { |
|
console.log("ws error:", err); |
|
this._scheduleReconnect() |
|
}; |
|
|
|
this.ws.onclose = () => { |
|
console.log("leaving chat...") |
|
this.ws = null; |
|
this._sentJoin = false; |
|
delete this.players[this.pageData.username] |
|
this._scheduleReconnect() |
|
} |
|
|
|
|
|
}) |
|
this.chatTextField.addEventListener('keydown', (e) => { |
|
|
|
if (e.key === 'Enter' && !e.shiftKey) { |
|
e.preventDefault(); |
|
console.log('you sent a message') |
|
|
|
const message = document.createElement('p'); |
|
message.innerHTML = this.chatTextField.value; |
|
message.className = "me"; |
|
this.chatMessages.insertBefore(message, this.chatLoadingIndicator); |
|
|
|
|
|
this.ws.send(JSON.stringify({ |
|
'message': this.chatTextField.value, |
|
'designPayload': JSON.stringify(this.exportDesign()), |
|
})); |
|
|
|
this.chatTextField.value = ''; |
|
this.chatLoadingIndicator.style.display = 'block'; |
|
} |
|
|
|
}) |
|
// start a ws connection |
|
// onopen, send the payload |
|
|
|
this.sidebar.addEventListener('dragstart', (e) => { |
|
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')) { |
|
e.target.classList.remove('dragging'); |
|
} |
|
}); |
|
|
|
this.canvasContainer.addEventListener('dragover', (e) => e.preventDefault()); |
|
this.canvasContainer.addEventListener('drop', (e) => { |
|
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 props = generateDefaultProps(plugin); |
|
const node = new ComponentNode(type, x, y, this, props); |
|
node.x = x; |
|
node.y = y; |
|
}); |
|
|
|
this.runButton.addEventListener('click', async () => { |
|
const designData = this.exportDesign(); |
|
|
|
// Try to get level info from URL or page context |
|
const levelInfo = this.getLevelInfo(); |
|
|
|
const requestBody = { |
|
design: designData, |
|
...levelInfo |
|
}; |
|
|
|
console.log('Sending design to simulation:', JSON.stringify(requestBody)); |
|
|
|
// Disable button and show loading state |
|
this.runButton.disabled = true; |
|
this.runButton.textContent = 'Running Simulation...'; |
|
|
|
try { |
|
const response = await fetch('/simulate', { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
}, |
|
body: JSON.stringify(requestBody) |
|
}); |
|
|
|
if (!response.ok) { |
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`); |
|
} |
|
|
|
const result = await response.json(); |
|
|
|
if (result.Success) { |
|
console.log('Simulation successful:', result); |
|
this.showResults(result); |
|
} else { |
|
console.error('Simulation failed:', result.Error); |
|
this.showError(result.Error || 'Simulation failed'); |
|
} |
|
|
|
} catch (error) { |
|
console.error('Network error:', error); |
|
this.showError('Failed to run simulation: ' + error.message); |
|
} finally { |
|
// Re-enable button |
|
this.runButton.disabled = false; |
|
this.runButton.textContent = 'Test Design'; |
|
} |
|
}); |
|
|
|
this.canvas.addEventListener('click', () => { |
|
if (this.connectionStart) { |
|
this.connectionStart.group.classList.remove('selected'); |
|
this.connectionStart = null; |
|
} |
|
this.hidePropsPanel(); |
|
this.clearSelection(); |
|
}); |
|
|
|
this.propsSaveBtn.addEventListener('click', () => { |
|
if (!this.activeNode) return; |
|
|
|
const node = this.activeNode; |
|
const panel = this.nodePropsPanel; |
|
const plugin = PluginRegistry.get(node.type); |
|
|
|
if (!plugin || !plugin.props) { |
|
this.hidePropsPanel(); |
|
return; |
|
} |
|
|
|
// 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(); |
|
}); |
|
|
|
document.addEventListener('keydown', (e) => { |
|
if (e.key === 'Backspace' || e.key === 'Delete') { |
|
if (this.selectedConnection) { |
|
this.canvas.removeChild(this.selectedConnection.line); |
|
this.canvas.removeChild(this.selectedConnection.text); |
|
const index = this.connections.indexOf(this.selectedConnection); |
|
if (index !== -1) this.connections.splice(index, 1); |
|
this.selectedConnection = null; |
|
} else if (this.selectedNode) { |
|
this.canvas.removeChild(this.selectedNode.group); |
|
this.placedComponents = this.placedComponents.filter(n => n !== this.selectedNode); |
|
this.connections = this.connections.filter(conn => { |
|
if (conn.start === this.selectedNode || conn.end === this.selectedNode) { |
|
this.canvas.removeChild(conn.line); |
|
this.canvas.removeChild(conn.text); |
|
return false; |
|
} |
|
return true; |
|
}); |
|
this.selectedNode = null; |
|
this.activeNode = null; |
|
this.hidePropsPanel(); |
|
} |
|
} |
|
}); |
|
} |
|
|
|
showPropsPanel(node) { |
|
this.activeNode = node; |
|
const plugin = PluginRegistry.get(node.type); |
|
const panel = this.nodePropsPanel; |
|
|
|
if (!plugin || this.arrowMode) { |
|
this.hidePropsPanel(); |
|
return; |
|
} |
|
|
|
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'; |
|
|
|
// Hide all groups first |
|
const allGroups = panel.querySelectorAll('.prop-group, #label-group, #compute-group, #lb-group'); |
|
allGroups.forEach(g => g.style.display = 'none'); |
|
|
|
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); |
|
} |
|
|
|
// Set value |
|
if (input) { |
|
input.value = node.props[prop.name] ?? prop.default; |
|
} |
|
} |
|
|
|
this.propsSaveBtn.disabled = false; |
|
panel.style.display = 'block'; |
|
} |
|
|
|
hidePropsPanel() { |
|
this.nodePropsPanel.style.display = 'none'; |
|
this.propsSaveBtn.disabled = true; |
|
this.activeNode = null; |
|
} |
|
|
|
updateConnectionsFor(movedNode) { |
|
this.connections.forEach(conn => { |
|
if (conn.start === movedNode || conn.end === movedNode) { |
|
conn.updatePosition(); |
|
} |
|
}); |
|
} |
|
|
|
clearSelection() { |
|
if (this.selectedConnection) { |
|
this.selectedConnection.deselect(); |
|
this.selectedConnection = null; |
|
} |
|
|
|
if (this.selectedNode) { |
|
this.selectedNode.deselect(); |
|
this.selectedNode = null; |
|
this.hidePropsPanel(); |
|
} |
|
} |
|
|
|
exportDesign() { |
|
const nodes = this.placedComponents |
|
.filter(n => n.type !== 'user') |
|
.map(n => { |
|
const plugin = PluginRegistry.get(n.type); |
|
const result = { |
|
id: n.id, |
|
type: n.type, |
|
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 => ({ |
|
source: c.start.id, |
|
target: c.end.id, |
|
label: c.label || '', |
|
direction: c.direction, |
|
protocol: c.protocol || '', |
|
tls: !!c.tls, |
|
capacity: c.capacity || 1000 |
|
})); |
|
|
|
return { |
|
nodes, |
|
connections, |
|
level: JSON.parse(this.level), |
|
availableComponents: JSON.stringify(this.plugins) |
|
}; |
|
} |
|
|
|
getLevelInfo() { |
|
// Try to extract level info from URL path like /play/url-shortener |
|
const pathParts = window.location.pathname.split('/'); |
|
if (pathParts.length >= 3 && pathParts[1] === 'play') { |
|
const levelId = decodeURIComponent(pathParts[2]); |
|
return { |
|
levelId: levelId |
|
}; |
|
} |
|
return {}; |
|
} |
|
|
|
showResults(result) { |
|
const metrics = result.Metrics; |
|
let message = ''; |
|
|
|
// Level validation results |
|
if (result.LevelName) { |
|
if (result.Passed) { |
|
message += `Level "${result.LevelName}" PASSED!\n`; |
|
message += `Score: ${result.Score}/100\n\n`; |
|
} else { |
|
message += `Level "${result.LevelName}" FAILED\n`; |
|
message += `Score: ${result.Score}/100\n\n`; |
|
} |
|
|
|
// Add detailed feedback |
|
if (result.Feedback && result.Feedback.length > 0) { |
|
message += result.Feedback.join('\n') + '\n\n'; |
|
} |
|
} else { |
|
message += `Simulation Complete!\n\n`; |
|
} |
|
|
|
// Performance metrics |
|
message += `Performance Metrics:\n`; |
|
message += `• Throughput: ${metrics.throughput} req/sec\n`; |
|
message += `• Avg Latency: ${metrics.latency_avg}ms\n`; |
|
message += `• Availability: ${metrics.availability.toFixed(1)}%\n`; |
|
message += `• Monthly Cost: $${metrics.cost_monthly}\n\n`; |
|
message += `Timeline: ${result.Timeline.length} ticks simulated`; |
|
|
|
alert(message); |
|
|
|
// TODO: Later replace with redirect to results page or modal |
|
console.log('Full simulation data:', result); |
|
} |
|
|
|
showError(errorMessage) { |
|
alert(`Simulation Error:\n\n${errorMessage}\n\nPlease check your design and try again.`); |
|
} |
|
|
|
_scheduleReconnect() { |
|
if (this._stopped) return; |
|
|
|
if (this._reconnectTimer) { |
|
clearTimeout(this._reconnectTimer) |
|
this._reconnectTimer = null; |
|
} |
|
|
|
const jitter = this._reconnectDelay * (Math.random() * 0.4 - 0.2); |
|
const delay = Math.max(250, Math.min(this._maxReconnectDelay, this._reconnectDelay + jitter)); |
|
console.log(`Reconnecting websocket...`) |
|
|
|
this._reconnectTimer = setTimeout(() => { |
|
this._reconnectTimer = null; |
|
this._initWebSocket(); |
|
}, delay); |
|
|
|
this._reconnectDelay = Math.min(this._maxReconnectDelay, Math.round(this._reconnectDelay * 1.8)); |
|
} |
|
|
|
}
|
|
|