import { RackManager } from './managers/rack-manager.js'; import { DeviceManager } from './managers/device-manager.js'; import { ConnectionManager } from './managers/connection-manager.js'; import { TableManager } from './managers/table-manager.js'; class API { constructor() { this.currentProjectId = 1; // Default project } setProjectId(projectId) { this.currentProjectId = projectId; } async request(url, options = {}) { const response = await fetch(url, { headers: { 'Content-Type': 'application/json', ...options.headers }, ...options }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Request failed'); } return response.json(); } // Projects getProjects() { return this.request('/api/projects'); } getProject(id) { return this.request(`/api/projects/${id}`); } createProject(name, description) { return this.request('/api/projects', { method: 'POST', body: JSON.stringify({ name, description }) }); } deleteProject(id) { return this.request(`/api/projects/${id}`, { method: 'DELETE' }); } // Racks getRacks() { return this.request(`/api/racks?projectId=${this.currentProjectId}`); } getNextRackName(prefix) { return this.request(`/api/racks/next-name?projectId=${this.currentProjectId}&prefix=${prefix}`).then(r => r.name); } createRack(name, x, y) { return this.request('/api/racks', { method: 'POST', body: JSON.stringify({ projectId: this.currentProjectId, name, x, y }) }); } updateRackPosition(id, x, y) { return this.request(`/api/racks/${id}/position`, { method: 'PUT', body: JSON.stringify({ x, y }) }); } updateRackName(id, name) { return this.request(`/api/racks/${id}/name`, { method: 'PUT', body: JSON.stringify({ name }) }); } deleteRack(id) { return this.request(`/api/racks/${id}`, { method: 'DELETE' }); } // Device Types getDeviceTypes() { return this.request('/api/devices/types'); } // Devices getDevices() { return this.request(`/api/devices?projectId=${this.currentProjectId}`); } createDevice(deviceTypeId, rackId, position, name) { return this.request('/api/devices', { method: 'POST', body: JSON.stringify({ deviceTypeId, rackId, position, name }) }); } deleteDevice(id) { return this.request(`/api/devices/${id}`, { method: 'DELETE' }); } updateDeviceName(id, name) { return this.request(`/api/devices/${id}/name`, { method: 'PUT', body: JSON.stringify({ name }) }); } getUsedPorts(deviceId) { return this.request(`/api/devices/${deviceId}/used-ports`); } // Connections getConnections() { return this.request(`/api/connections?projectId=${this.currentProjectId}`); } createConnection(sourceDeviceId, sourcePort, targetDeviceId, targetPort) { return this.request('/api/connections', { method: 'POST', body: JSON.stringify({ sourceDeviceId, sourcePort, targetDeviceId, targetPort }) }); } updateConnectionWaypoints(id, waypoints, view = null) { return this.request(`/api/connections/${id}/waypoints`, { method: 'PUT', body: JSON.stringify({ waypoints, view }) }); } deleteConnection(id) { return this.request(`/api/connections/${id}`, { method: 'DELETE' }); } } class DatacenterDesigner { constructor() { this.api = new API(); this.stage = null; this.layer = null; this.rackManager = null; this.deviceManager = null; this.connectionManager = null; this.tableManager = null; this.currentScale = 1; this.minScale = 0.1; this.maxScale = 3; this.currentCanvasView = 'physical'; // 'physical' or 'logical' this.currentTableView = null; // null, 'racks', 'devices', or 'connections' // Separate view states for physical and logical views this.viewStates = { physical: { x: 50, y: 50, scale: 1 }, logical: { x: 50, y: 50, scale: 1 } }; } async init() { this.setupCanvas(); this.setupManagers(); await this.loadProjects(); // Load spacing after project ID is set this.rackManager.loadSpacing(); await this.loadData(); this.loadViewStates(); // Load saved view states this.setupEventListeners(); this.setupContextMenu(); this.setupZoomAndPan(); this.setupResizeHandle(); } async loadProjects() { try { const projects = await this.api.getProjects(); const projectSelect = document.getElementById('projectSelect'); projectSelect.innerHTML = ''; // Add existing projects projects.forEach(project => { const option = document.createElement('option'); option.value = project.id; option.textContent = project.name; projectSelect.appendChild(option); }); // Add separator const separator = document.createElement('option'); separator.disabled = true; separator.textContent = '─────────────────────'; projectSelect.appendChild(separator); // Add "Create New Project" option const createOption = document.createElement('option'); createOption.value = '__create__'; createOption.textContent = 'Create New Project'; projectSelect.appendChild(createOption); // Add "Manage Projects" option const manageOption = document.createElement('option'); manageOption.value = '__manage__'; manageOption.textContent = 'Manage Projects'; projectSelect.appendChild(manageOption); // Set current project const currentProjectId = parseInt(localStorage.getItem('currentProjectId') || '1'); projectSelect.value = currentProjectId; this.api.setProjectId(currentProjectId); } catch (err) { console.error('Failed to load projects:', err); } } async switchProject(projectId) { this.api.setProjectId(projectId); localStorage.setItem('currentProjectId', projectId); // Clear canvas this.rackManager.racks.clear(); this.deviceManager.devices.clear(); this.connectionManager.connections.clear(); this.layer.destroyChildren(); this.connectionManager.getConnectionLayer().destroyChildren(); // Reload spacing for this project this.rackManager.loadSpacing(); // Reset both view states this.viewStates.physical = { x: 50, y: 50, scale: 1 }; this.viewStates.logical = { x: 50, y: 50, scale: 1 }; this.saveViewStates(); // Reset view (pan and zoom) this.resetView(); // Reload data for new project await this.loadData(); this.layer.batchDraw(); this.connectionManager.getConnectionLayer().batchDraw(); } loadViewStates() { try { const saved = localStorage.getItem(`viewStates_${this.api.currentProjectId}`); if (saved) { this.viewStates = JSON.parse(saved); } } catch (err) { console.error('Failed to load view states:', err); } } saveViewStates() { try { localStorage.setItem(`viewStates_${this.api.currentProjectId}`, JSON.stringify(this.viewStates)); } catch (err) { console.error('Failed to save view states:', err); } } saveCurrentViewState() { this.viewStates[this.currentCanvasView] = { x: this.stage.x(), y: this.stage.y(), scale: this.stage.scaleX() }; this.saveViewStates(); } restoreViewState(viewType) { const state = this.viewStates[viewType]; this.stage.position({ x: state.x, y: state.y }); this.stage.scale({ x: state.scale, y: state.scale }); this.currentScale = state.scale; this.updateZoomDisplay(state.scale); this.stage.batchDraw(); } setupCanvas() { const container = document.getElementById('canvasWrapper'); const width = container.offsetWidth; const height = container.offsetHeight; this.stage = new Konva.Stage({ container: 'canvasWrapper', width: width, height: height }); this.layer = new Konva.Layer(); this.stage.add(this.layer); // Add initial offset for visual margins (without changing grid coordinates) this.stage.position({ x: 50, y: 50 }); } setupManagers() { // Create device manager first (needed by rack manager) this.deviceManager = new DeviceManager(this.layer, this.api, null); // Create rack manager with device manager reference this.rackManager = new RackManager(this.layer, this.api, this.deviceManager); // Set rack manager reference in device manager this.deviceManager.rackManager = this.rackManager; this.connectionManager = new ConnectionManager( this.layer, this.api, this.deviceManager, this.rackManager ); // Set connection manager reference in device manager this.deviceManager.connectionManager = this.connectionManager; // Add connection layer on top of main layer so connections are visible this.stage.add(this.connectionManager.getConnectionLayer()); this.connectionManager.getConnectionLayer().moveToTop(); // Create table manager this.tableManager = new TableManager( this.api, this.rackManager, this.deviceManager, this.connectionManager ); } async loadData() { await this.deviceManager.loadDeviceTypes(); await this.rackManager.loadRacks(); await this.deviceManager.loadDevices(); await this.connectionManager.loadConnections(); } setupZoomAndPan() { const container = this.stage.container(); // Zoom with Ctrl + Wheel container.addEventListener('wheel', (e) => { if (!e.ctrlKey) return; e.preventDefault(); const oldScale = this.stage.scaleX(); const pointer = this.stage.getPointerPosition(); const mousePointTo = { x: (pointer.x - this.stage.x()) / oldScale, y: (pointer.y - this.stage.y()) / oldScale }; const delta = e.deltaY > 0 ? 0.9 : 1.1; let newScale = oldScale * delta; // Clamp scale newScale = Math.max(this.minScale, Math.min(this.maxScale, newScale)); this.stage.scale({ x: newScale, y: newScale }); const newPos = { x: pointer.x - mousePointTo.x * newScale, y: pointer.y - mousePointTo.y * newScale }; this.stage.position(newPos); this.stage.batchDraw(); this.currentScale = newScale; this.updateZoomDisplay(newScale); this.saveCurrentViewState(); // Save zoom state }); // Pan with Ctrl + drag let isPanning = false; let startPos = null; // Listen to mousedown on container level instead of stage // This way we can control panning without interfering with Konva's drag system container.addEventListener('mousedown', (evt) => { // Ignore right-clicks for panning if (evt.button === 2) { return; } // Hide context menu on left click this.hideContextMenu(); // Only pan when Ctrl is held down if (evt.ctrlKey) { // Check if we're clicking on a Konva element const stage = this.stage; const pos = stage.getPointerPosition(); if (!pos) return; // Get what's under the cursor const shape = stage.getIntersection(pos); // Don't pan if clicking on a draggable element if (shape && shape.draggable && shape.draggable()) { console.log('Clicked on draggable element, not panning'); return; } isPanning = true; startPos = pos; container.style.cursor = 'grabbing'; } }); container.addEventListener('mousemove', (evt) => { if (!isPanning) return; const pos = this.stage.getPointerPosition(); if (!pos) return; const dx = pos.x - startPos.x; const dy = pos.y - startPos.y; this.stage.position({ x: this.stage.x() + dx, y: this.stage.y() + dy }); startPos = pos; this.stage.batchDraw(); }); container.addEventListener('mouseup', () => { if (isPanning) { this.saveCurrentViewState(); // Save pan state } isPanning = false; container.style.cursor = 'default'; }); container.addEventListener('mouseleave', () => { isPanning = false; container.style.cursor = 'default'; }); } setupEventListeners() { // Canvas view switcher (Physical / Logical) document.getElementById('physicalViewBtn').addEventListener('click', () => { this.switchCanvasView('physical'); }); document.getElementById('logicalViewBtn').addEventListener('click', () => { this.switchCanvasView('logical'); }); // Table view switcher (Racks / Devices / Connections) - Toggle behavior document.getElementById('racksTableBtn').addEventListener('click', () => { this.toggleTableView('racks'); }); document.getElementById('devicesTableBtn').addEventListener('click', () => { this.toggleTableView('devices'); }); document.getElementById('connectionsTableBtn').addEventListener('click', () => { this.toggleTableView('connections'); }); // Table toolbar buttons document.getElementById('addTableRowBtn').addEventListener('click', () => { this.tableManager.addRow(); }); document.getElementById('deleteTableRowBtn').addEventListener('click', () => { this.tableManager.deleteSelectedRows(); }); // Load saved view preferences const savedCanvasView = localStorage.getItem('currentCanvasView') || 'physical'; this.switchCanvasView(savedCanvasView); // Project selector document.getElementById('projectSelect').addEventListener('change', async (e) => { const value = e.target.value; // Handle special options if (value === '__create__') { // Reset dropdown to current project e.target.value = this.api.currentProjectId; // Show create modal this.showProjectFormModal(); return; } if (value === '__manage__') { // Reset dropdown to current project e.target.value = this.api.currentProjectId; // Show manage modal this.showManageProjectsModal(); return; } // Normal project switch const projectId = parseInt(value); await this.switchProject(projectId); }); // Right-click context menu handler this.stage.on('contextmenu', (e) => { e.evt.preventDefault(); // Right-click on empty canvas if (e.target === this.stage) { this.showCanvasContextMenu(e); return; } // Rack right-clicks are handled by RackManager's own context menu }); // ESC key to cancel connection document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.connectionManager.isConnectionMode()) { this.connectionManager.cancelConnection(); } }); // Device click handler for connections (when racks are locked) this.layer.on('click', (e) => { const target = e.target; // Check if clicked on a device (find parent group) let deviceGroup = target; while (deviceGroup && !(deviceGroup.id() && deviceGroup.id().startsWith('device-'))) { deviceGroup = deviceGroup.getParent(); if (!deviceGroup || deviceGroup === this.layer) { deviceGroup = null; break; } } if (deviceGroup) { const deviceId = parseInt(deviceGroup.id().replace('device-', '')); // Only handle clicks when racks are locked if (this.rackManager.racksLocked) { if (this.connectionManager.isConnectionMode()) { // Complete connection this.connectionManager.completeConnection(deviceId, deviceGroup); } else { // Start connection this.connectionManager.startConnection(deviceId, deviceGroup); } } } else { // Clicked on empty space - deselect any selected connection this.connectionManager.deselectConnection(); } }); // Rename rack event window.addEventListener('rename-rack', async (e) => { const { rackId, rackData, rackShape } = e.detail; await this.renameRack(rackId, rackData, rackShape); }); // Rename device event window.addEventListener('rename-device', async (e) => { const { deviceId, deviceData, deviceShape } = e.detail; await this.renameDevice(deviceId, deviceData, deviceShape); }); // Canvas data changed - sync to table window.addEventListener('canvas-data-changed', async () => { if (this.currentTableView) { await this.tableManager.syncFromCanvas(); } }); // Window resize window.addEventListener('resize', () => { const container = document.getElementById('canvasWrapper'); this.stage.width(container.offsetWidth); this.stage.height(container.offsetHeight); }); // Zoom input field const zoomInput = document.getElementById('zoomInput'); zoomInput.addEventListener('change', (e) => { const percentage = parseInt(e.target.value); if (!isNaN(percentage) && percentage >= 10 && percentage <= 300) { this.setZoom(percentage / 100); } }); // Also handle Enter key zoomInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { const percentage = parseInt(e.target.value); if (!isNaN(percentage) && percentage >= 10 && percentage <= 300) { this.setZoom(percentage / 100); } } }); // Fit view button document.getElementById('fitViewBtn').addEventListener('click', () => { this.fitView(); }); // Export/Import project buttons document.getElementById('exportProjectBtn').addEventListener('click', () => { this.exportProject(); }); document.getElementById('importProjectBtn').addEventListener('click', () => { document.getElementById('importProjectInput').click(); }); document.getElementById('importProjectInput').addEventListener('change', (e) => { const file = e.target.files[0]; if (file) { this.importProject(file); // Reset input so same file can be selected again e.target.value = ''; } }); // Export to Excel button document.getElementById('exportExcelBtn').addEventListener('click', () => { this.exportToExcel(); }); } async showManageProjectsModal() { const modal = document.getElementById('manageProjectsModal'); const closeBtn = document.getElementById('manageProjectsModalClose'); const newProjectBtn = document.getElementById('newProjectBtnFromManage'); const projectsList = document.getElementById('projectsList'); modal.classList.remove('hidden'); // Load and display projects await this.renderProjectsList(); const handleClose = () => { modal.classList.add('hidden'); closeBtn.removeEventListener('click', handleClose); newProjectBtn.removeEventListener('click', handleNewProject); }; const handleNewProject = () => { this.showProjectFormModal(); }; closeBtn.addEventListener('click', handleClose); newProjectBtn.addEventListener('click', handleNewProject); } async renderProjectsList() { const projectsList = document.getElementById('projectsList'); const projects = await this.api.getProjects(); const currentProjectId = this.api.currentProjectId; projectsList.innerHTML = ''; projects.forEach(project => { const card = document.createElement('div'); card.className = 'project-card'; if (project.id === currentProjectId) { card.classList.add('active'); } const date = new Date(project.updated_at).toLocaleDateString(); card.innerHTML = `
${project.name}
${project.description || 'No description'}
Last updated: ${date}
${project.id !== currentProjectId ? `` : ''}
`; // Add event listeners to action buttons card.querySelectorAll('[data-action]').forEach(btn => { btn.addEventListener('click', async (e) => { const action = e.target.dataset.action; const id = parseInt(e.target.dataset.id); if (action === 'switch') { document.getElementById('projectSelect').value = id; await this.switchProject(id); await this.renderProjectsList(); } else if (action === 'edit') { this.showProjectFormModal(project); } else if (action === 'delete') { await this.deleteProject(project); } }); }); projectsList.appendChild(card); }); } showProjectFormModal(project = null) { const modal = document.getElementById('projectFormModal'); const title = document.getElementById('projectFormTitle'); const saveBtn = document.getElementById('saveProjectBtn'); const cancelBtn = document.getElementById('cancelProjectBtn'); const closeBtn = document.getElementById('projectFormModalClose'); const nameInput = document.getElementById('projectName'); const descInput = document.getElementById('projectDescription'); // Set form mode const isEdit = !!project; title.textContent = isEdit ? 'Edit Project' : 'New Project'; nameInput.value = isEdit ? project.name : ''; descInput.value = isEdit ? (project.description || '') : ''; modal.classList.remove('hidden'); nameInput.focus(); const handleSave = async () => { const name = nameInput.value.trim(); const description = descInput.value.trim(); if (!name) { alert('Please enter a project name'); return; } try { if (isEdit) { await this.api.request(`/api/projects/${project.id}`, { method: 'PUT', body: JSON.stringify({ name, description }) }); } else { const newProject = await this.api.createProject(name, description); // Switch to new project await this.loadProjects(); document.getElementById('projectSelect').value = newProject.id; await this.switchProject(newProject.id); } modal.classList.add('hidden'); // Reload projects and refresh manage modal if open await this.loadProjects(); const manageModal = document.getElementById('manageProjectsModal'); if (!manageModal.classList.contains('hidden')) { await this.renderProjectsList(); } } catch (err) { alert('Failed to save project: ' + err.message); } cleanup(); }; const handleCancel = () => { modal.classList.add('hidden'); cleanup(); }; const cleanup = () => { saveBtn.removeEventListener('click', handleSave); cancelBtn.removeEventListener('click', handleCancel); closeBtn.removeEventListener('click', handleCancel); }; saveBtn.addEventListener('click', handleSave); cancelBtn.addEventListener('click', handleCancel); closeBtn.addEventListener('click', handleCancel); } async deleteProject(project) { const confirmMsg = `Are you sure you want to delete "${project.name}"?\n\nThis will permanently delete:\n- All racks in this project\n- All devices\n- All connections\n\nThis action cannot be undone.`; if (!confirm(confirmMsg)) { return; } try { await this.api.deleteProject(project.id); // Reload projects await this.loadProjects(); // If we deleted the current project, switch to the first available if (project.id === this.api.currentProjectId) { const projects = await this.api.getProjects(); if (projects.length > 0) { document.getElementById('projectSelect').value = projects[0].id; await this.switchProject(projects[0].id); } } // Refresh the project list await this.renderProjectsList(); } catch (err) { alert('Failed to delete project: ' + err.message); } } showCanvasContextMenu(e) { // Don't show context menu in logical view if (this.currentView === 'logical') { return; } const contextMenu = document.getElementById('contextMenu'); const contextMenuList = document.getElementById('contextMenuList'); contextMenuList.innerHTML = `
  • Add Rack(s)
  • `; contextMenu.style.left = `${e.evt.pageX}px`; contextMenu.style.top = `${e.evt.pageY}px`; contextMenu.classList.remove('hidden'); // Mark that menu was just shown (prevents immediate hiding) this.contextMenuJustShown = true; setTimeout(() => { this.contextMenuJustShown = false; }, 100); // Remove any existing listeners const oldHandler = this.contextMenuHandler; if (oldHandler) { contextMenuList.removeEventListener('click', oldHandler); } // Create new handler this.contextMenuHandler = (evt) => { const action = evt.target.dataset.action; if (action === 'add-racks') { this.showAddRackModal(); } this.hideContextMenu(); }; contextMenuList.addEventListener('click', this.contextMenuHandler); } hideContextMenu() { // Don't hide if menu was just shown if (this.contextMenuJustShown) { return; } const contextMenu = document.getElementById('contextMenu'); if (contextMenu) { contextMenu.classList.add('hidden'); } } async getNextRackNumber(prefix) { const racks = await this.api.getRacks(); const existingRacks = racks.filter(r => r.name.startsWith(prefix)); if (existingRacks.length === 0) { return 1; } // Find the highest number let maxNum = 0; existingRacks.forEach(rack => { const match = rack.name.match(/\d+$/); if (match) { const num = parseInt(match[0]); if (num > maxNum) maxNum = num; } }); return maxNum + 1; } async updateRackNamesPreview() { const count = parseInt(document.getElementById('rackCount').value) || 1; const prefix = document.getElementById('rackPrefix').value.trim() || 'RACK'; const startNum = await this.getNextRackNumber(prefix); const previews = []; for (let i = 0; i < Math.min(count, 5); i++) { const num = String(startNum + i).padStart(2, '0'); previews.push(`${prefix}${num}`); } if (count > 5) { previews.push('...'); } document.getElementById('rackNamePreview').textContent = previews.join(', '); } async populateRowDropdown() { const existingRacks = await this.api.getRacks(); const rowSelect = document.getElementById('continueRowSelect'); if (existingRacks.length === 0) { // No racks, just show row 1 rowSelect.innerHTML = ''; return; } // Get unique Y coordinates (rows) and sort them const uniqueRows = [...new Set(existingRacks.map(r => r.y))].sort((a, b) => a - b); // Build dropdown options rowSelect.innerHTML = ''; uniqueRows.forEach((yCoord, index) => { const option = document.createElement('option'); option.value = yCoord; option.textContent = index + 1; // Display as 1-based row numbers rowSelect.appendChild(option); }); // Select the last row by default rowSelect.value = uniqueRows[uniqueRows.length - 1]; } async showAddRackModal() { const modal = document.getElementById('addRackModal'); const createBtn = document.getElementById('createRacksBtn'); const cancelBtn = document.getElementById('cancelRacksBtn'); const closeBtn = document.getElementById('addRackModalClose'); // Populate row dropdown await this.populateRowDropdown(); modal.classList.remove('hidden'); this.updateRackNamesPreview(); // Add input listeners for live preview const countInput = document.getElementById('rackCount'); const prefixInput = document.getElementById('rackPrefix'); const updatePreview = () => this.updateRackNamesPreview(); countInput.addEventListener('input', updatePreview); prefixInput.addEventListener('input', updatePreview); const handleCreate = async () => { const count = parseInt(document.getElementById('rackCount').value); const prefix = document.getElementById('rackPrefix').value.trim() || 'RACK'; const position = document.querySelector('input[name="rowPosition"]:checked').value; const selectedRow = position === 'continue' ? parseInt(document.getElementById('continueRowSelect').value) : null; try { await this.createMultipleRacks(count, prefix, position, selectedRow); modal.classList.add('hidden'); } catch (err) { alert('Failed to create racks: ' + err.message); } cleanup(); }; const handleCancel = () => { modal.classList.add('hidden'); cleanup(); }; const cleanup = () => { createBtn.removeEventListener('click', handleCreate); cancelBtn.removeEventListener('click', handleCancel); closeBtn.removeEventListener('click', handleCancel); countInput.removeEventListener('input', updatePreview); prefixInput.removeEventListener('input', updatePreview); }; createBtn.addEventListener('click', handleCreate); cancelBtn.addEventListener('click', handleCancel); closeBtn.addEventListener('click', handleCancel); } async createMultipleRacks(count, prefix, position, selectedRowY = null) { const existingRacks = await this.api.getRacks(); // Use current grid dimensions from RackManager const gridSize = this.rackManager.gridSize; const gridVertical = this.rackManager.gridVertical; const startX = 0; // Start at grid origin const startY = 0; // Start at grid origin let x, y; // Determine starting position based on position type if (existingRacks.length === 0) { // First racks ever x = startX; y = startY; } else if (position === 'continue') { // Continue on the selected row const rowY = selectedRowY; const rowRacks = existingRacks.filter(r => r.y === rowY); if (rowRacks.length > 0) { const maxX = Math.max(...rowRacks.map(r => r.x)); x = maxX + gridSize; } else { // No racks in this row yet, start at beginning x = startX; } y = rowY; } else if (position === 'below') { // New row below const maxY = Math.max(...existingRacks.map(r => r.y)); x = startX; y = maxY + gridVertical; } else if (position === 'above') { // New row above const minY = Math.min(...existingRacks.map(r => r.y)); x = startX; y = minY - gridVertical; } // Get starting number for sequential naming const startNum = await this.getNextRackNumber(prefix); // Create racks for (let i = 0; i < count; i++) { const num = String(startNum + i).padStart(2, '0'); const name = `${prefix}${num}`; const rackX = x + (i * gridSize); const rackY = y; const rackData = await this.api.createRack(name, rackX, rackY); this.rackManager.createRackShape(rackData); } this.layer.batchDraw(); } showAddDeviceModal(rackId) { const modal = document.getElementById('addDeviceModal'); const deviceTypeList = document.getElementById('deviceTypeList'); const closeBtn = document.getElementById('addDeviceModalClose'); // Populate device types deviceTypeList.innerHTML = ''; this.deviceManager.deviceTypes.forEach(type => { const card = document.createElement('div'); card.className = 'device-type-card'; card.innerHTML = `
    ${type.name}
    ${type.ports_count} ports
    `; card.addEventListener('click', async () => { const deviceName = prompt(`Enter name for ${type.name}:`, type.name); if (deviceName) { try { // Check if name will be auto-numbered const uniqueName = this.deviceManager.generateUniqueName(deviceName); if (uniqueName !== deviceName) { const proceed = confirm(`Device name "${deviceName}" is already in use.\n\nDevice will be named "${uniqueName}" instead.\n\nContinue?`); if (!proceed) { return; } } const position = this.deviceManager.getNextDevicePosition(rackId, type.rack_units); await this.deviceManager.addDevice(type.id, rackId, position, deviceName); modal.classList.add('hidden'); } catch (err) { alert('Failed to add device: ' + err.message); } } }); deviceTypeList.appendChild(card); }); modal.classList.remove('hidden'); const handleClose = () => { modal.classList.add('hidden'); closeBtn.removeEventListener('click', handleClose); }; closeBtn.addEventListener('click', handleClose); } async renameRack(rackId, rackData, rackShape) { const newName = prompt('Enter new rack name:', rackData.name); if (newName && newName !== rackData.name) { try { await this.api.updateRackName(rackId, newName); // Update the rack name in the shape const nameLabel = rackShape.findOne('Text'); if (nameLabel) { nameLabel.text(newName); this.layer.batchDraw(); } // Update local data rackData.name = newName; // Notify table to sync window.dispatchEvent(new CustomEvent('canvas-data-changed')); } catch (err) { alert('Failed to rename rack: ' + err.message); } } } async renameDevice(deviceId, deviceData, deviceShape) { const newName = prompt('Enter new device name:', deviceData.name); if (newName && newName !== deviceData.name) { // Check if name is already taken if (this.deviceManager.isDeviceNameTaken(newName, deviceId)) { alert(`Device name "${newName}" is already in use. Please choose a different name.`); return; } try { await this.api.updateDeviceName(deviceId, newName); // Update the device name in the shape const nameLabel = deviceShape.findOne('.device-text'); if (nameLabel) { nameLabel.text(newName); this.layer.batchDraw(); } // Update local data deviceData.name = newName; // Notify table to sync window.dispatchEvent(new CustomEvent('canvas-data-changed')); } catch (err) { alert('Failed to rename device: ' + err.message); } } } setupContextMenu() { // Hide context menu on any click/mousedown anywhere const hideHandler = (e) => { const contextMenu = document.getElementById('contextMenu'); // Don't hide if clicking inside the context menu itself if (contextMenu && !contextMenu.contains(e.target)) { this.hideContextMenu(); } }; // Listen on document for clicks outside the canvas document.addEventListener('mousedown', hideHandler); document.addEventListener('click', hideHandler); } resetView() { // Reset to default position and zoom this.stage.position({ x: 50, y: 50 }); this.stage.scale({ x: 1, y: 1 }); this.currentScale = 1; this.updateZoomDisplay(1); this.stage.batchDraw(); } fitView() { let minX = Infinity, minY = Infinity; let maxX = -Infinity, maxY = -Infinity; if (this.currentCanvasView === 'logical') { // In logical view, fit to devices const devices = Array.from(this.deviceManager.devices.values()); if (devices.length === 0) { this.resetView(); return; } devices.forEach(device => { const pos = device.shape.position(); const x = pos.x; const y = pos.y; const width = this.deviceManager.deviceWidth; const height = device.shape.height(); minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x + width); maxY = Math.max(maxY, y + height); }); } else { // In physical view, fit to racks const racks = Array.from(this.rackManager.racks.values()); if (racks.length === 0) { this.resetView(); return; } racks.forEach(rack => { const x = rack.data.x; const y = rack.data.y; const width = rack.data.width || this.rackManager.rackWidth; const height = rack.data.height || this.rackManager.rackHeight; minX = Math.min(minX, x); minY = Math.min(minY, y - 30); // Include rack name maxX = Math.max(maxX, x + width); maxY = Math.max(maxY, y + height); }); } // Add padding const padding = 100; minX -= padding; minY -= padding; maxX += padding; maxY += padding; const contentWidth = maxX - minX; const contentHeight = maxY - minY; // Calculate scale to fit const containerWidth = this.stage.width(); const containerHeight = this.stage.height(); const scaleX = containerWidth / contentWidth; const scaleY = containerHeight / contentHeight; const scale = Math.min(scaleX, scaleY, this.maxScale); // Clamp to min/max scale const finalScale = Math.max(this.minScale, Math.min(this.maxScale, scale)); // Calculate position to center the content const centerX = (minX + maxX) / 2; const centerY = (minY + maxY) / 2; const newX = containerWidth / 2 - centerX * finalScale; const newY = containerHeight / 2 - centerY * finalScale; // Apply the transformation this.stage.scale({ x: finalScale, y: finalScale }); this.stage.position({ x: newX, y: newY }); this.currentScale = finalScale; this.updateZoomDisplay(finalScale); this.stage.batchDraw(); this.saveCurrentViewState(); // Save state after fit } updateZoomDisplay(scale) { const percentage = Math.round(scale * 100); document.getElementById('zoomInput').value = percentage; } setZoom(scale) { // Clamp scale to min/max const newScale = Math.max(this.minScale, Math.min(this.maxScale, scale)); // Get current center point in world coordinates const containerWidth = this.stage.width(); const containerHeight = this.stage.height(); const centerX = containerWidth / 2; const centerY = containerHeight / 2; // Convert to world coordinates const oldScale = this.stage.scaleX(); const worldX = (centerX - this.stage.x()) / oldScale; const worldY = (centerY - this.stage.y()) / oldScale; // Apply new scale this.stage.scale({ x: newScale, y: newScale }); // Recalculate position to keep center point fixed const newPos = { x: centerX - worldX * newScale, y: centerY - worldY * newScale }; this.stage.position(newPos); this.currentScale = newScale; this.updateZoomDisplay(newScale); this.stage.batchDraw(); this.saveCurrentViewState(); // Save state after zoom change } async switchCanvasView(canvasViewType) { if (canvasViewType !== 'physical' && canvasViewType !== 'logical') { console.error('Invalid canvas view type:', canvasViewType); return; } // Save current view state before switching this.saveCurrentViewState(); this.currentCanvasView = canvasViewType; localStorage.setItem('currentCanvasView', canvasViewType); // Update button states const physicalBtn = document.getElementById('physicalViewBtn'); const logicalBtn = document.getElementById('logicalViewBtn'); physicalBtn.classList.remove('active'); logicalBtn.classList.remove('active'); if (canvasViewType === 'physical') { physicalBtn.classList.add('active'); } else { logicalBtn.classList.add('active'); } // Update device manager's view (changes device width) this.deviceManager.setCurrentView(canvasViewType); if (canvasViewType === 'physical') { this.renderPhysicalView(); } else { this.renderLogicalView(); } // Update connection manager's view (reloads connections with view-specific waypoints) await this.connectionManager.setCurrentView(canvasViewType); // Restore the target view's saved state this.restoreViewState(canvasViewType); // Sync table if visible if (this.currentTableView) { await this.tableManager.refreshTable(); } } async toggleTableView(tableViewType) { const racksTableBtn = document.getElementById('racksTableBtn'); const devicesTableBtn = document.getElementById('devicesTableBtn'); const connectionsTableBtn = document.getElementById('connectionsTableBtn'); const tablePane = document.getElementById('tablePane'); const resizeHandle = document.getElementById('resizeHandle'); // If clicking the same table view, close it (toggle off) if (this.currentTableView === tableViewType) { this.currentTableView = null; tablePane.classList.add('hidden'); resizeHandle.classList.add('hidden'); // Remove active state from all table buttons racksTableBtn.classList.remove('active'); devicesTableBtn.classList.remove('active'); connectionsTableBtn.classList.remove('active'); this.tableManager.hideTable(); this.resizeCanvas(); return; } // Otherwise, switch to the new table view or open it this.currentTableView = tableViewType; // Show table pane and resize handle tablePane.classList.remove('hidden'); resizeHandle.classList.remove('hidden'); // Update button states racksTableBtn.classList.remove('active'); devicesTableBtn.classList.remove('active'); connectionsTableBtn.classList.remove('active'); if (tableViewType === 'racks') { racksTableBtn.classList.add('active'); } else if (tableViewType === 'devices') { devicesTableBtn.classList.add('active'); } else if (tableViewType === 'connections') { connectionsTableBtn.classList.add('active'); } // Show the table await this.tableManager.showTable(`${tableViewType}-table`); this.resizeCanvas(); } setupResizeHandle() { const resizeHandle = document.getElementById('resizeHandle'); const tablePane = document.getElementById('tablePane'); const canvasPane = document.getElementById('canvasPane'); let isResizing = false; let startY = 0; let startHeight = 0; resizeHandle.addEventListener('mousedown', (e) => { isResizing = true; startY = e.clientY; startHeight = tablePane.offsetHeight; document.body.style.cursor = 'ns-resize'; document.body.style.userSelect = 'none'; }); document.addEventListener('mousemove', (e) => { if (!isResizing) return; const deltaY = startY - e.clientY; // Negative delta = drag down // Get actual available height (main-content area) const mainContent = document.querySelector('.main-content'); const availableHeight = mainContent.offsetHeight; const resizeHandleHeight = resizeHandle.offsetHeight || 4; const minHeight = 0; // Allow collapsing completely const maxHeight = availableHeight - resizeHandleHeight; // Up to fill entire main-content const newHeight = Math.max(minHeight, Math.min(maxHeight, startHeight + deltaY)); tablePane.style.height = `${newHeight}px`; this.resizeCanvas(); }); document.addEventListener('mouseup', () => { if (isResizing) { isResizing = false; document.body.style.cursor = 'default'; document.body.style.userSelect = 'auto'; } }); } resizeCanvas() { // Use requestAnimationFrame to ensure DOM has completed reflow requestAnimationFrame(() => { const canvasWrapper = document.getElementById('canvasWrapper'); if (this.stage && canvasWrapper) { const width = canvasWrapper.offsetWidth; const height = canvasWrapper.offsetHeight; // Only resize if dimensions are valid (non-zero) if (width > 0 && height > 0) { this.stage.width(width); this.stage.height(height); this.stage.batchDraw(); } } // Trigger table grid resize if (this.tableManager.gridApi) { // ag-Grid automatically handles resize, but we can trigger it explicitly setTimeout(() => { if (this.tableManager.gridApi) { this.tableManager.gridApi.sizeColumnsToFit(); } }, 100); } }); } renderPhysicalView() { // Show racks this.rackManager.racks.forEach((rack) => { rack.shape.visible(true); }); // Move devices back into racks and position them relatively this.deviceManager.devices.forEach((device, deviceId) => { const deviceData = device.data; const rackShape = this.rackManager.getRackShape(deviceData.rack_id); if (rackShape) { const devicesContainer = rackShape.findOne('.devices-container'); // Move device back into its rack's container if (device.shape.getParent() !== devicesContainer) { device.shape.moveTo(devicesContainer); } // Calculate relative position within rack (using the rack assignment stored in DB) // U1 (slot 1) is at the bottom, U42 (slot 42) is at the top const maxSlots = 42; const visualPosition = maxSlots - deviceData.position; const y = 10 + (visualPosition * (this.deviceManager.deviceHeight + this.deviceManager.deviceSpacing)); device.shape.position({ x: 10, y: y }); // Remove logical view drag handlers device.shape.off('dragstart'); device.shape.off('dragmove'); device.shape.off('dragmove.connection'); device.shape.off('dragend'); device.shape.off('dragend.logical'); // Re-add physical view drag handlers device.shape.on('dragstart', () => { // Store original parent and position device.shape.setAttr('originalParent', device.shape.getParent()); device.shape.setAttr('originalPosition', device.shape.position()); // Move to main layer to be on top of everything const absolutePos = device.shape.getAbsolutePosition(); device.shape.moveTo(this.layer); device.shape.setAbsolutePosition(absolutePos); device.shape.moveToTop(); device.shape.opacity(0.7); }); device.shape.on('dragend', async () => { device.shape.opacity(1); await this.deviceManager.handleDeviceDrop(deviceData.id, device.shape); }); // Devices are always draggable in physical view device.shape.draggable(true); // Enable context menu for device deletion (context menu handler is already attached) device.shape.listening(true); } }); this.layer.batchDraw(); this.connectionManager.updateAllConnections(); } renderLogicalView() { // Hide racks this.rackManager.racks.forEach((rack) => { rack.shape.visible(false); }); // Move devices to main layer and position them at logical positions this.deviceManager.devices.forEach((device, deviceId) => { const deviceData = device.data; // Use logical position if available, otherwise calculate from physical position let logicalX = deviceData.logical_x; let logicalY = deviceData.logical_y; if (logicalX === null || logicalX === undefined) { // First time in logical view - calculate position from physical layout const rack = this.rackManager.racks.get(deviceData.rack_id); if (rack) { logicalX = rack.data.x + 100; // Offset from rack position logicalY = rack.data.y + deviceData.position * 40; } else { logicalX = 200; logicalY = 200; } // Save this initial logical position this.api.request(`/api/devices/${deviceId}/logical-position`, { method: 'PUT', body: JSON.stringify({ x: logicalX, y: logicalY }) }).catch(err => console.error('Failed to save logical position:', err)); } // Move device to main layer (out of rack container) if (device.shape.getParent() !== this.layer) { device.shape.moveTo(this.layer); } // Position device at logical coordinates (absolute positioning) device.shape.position({ x: logicalX, y: logicalY }); device.shape.draggable(true); // IMPORTANT: Remove ALL existing drag handlers (including physical view handlers) device.shape.off('dragstart'); device.shape.off('dragmove'); device.shape.off('dragmove.connection'); device.shape.off('dragend'); device.shape.off('dragend.logical'); // Add ONLY logical view drag handler - does NOT change rack assignment device.shape.on('dragend.logical', async () => { const pos = device.shape.position(); try { // Update ONLY logical position, never rack_id or position await this.api.request(`/api/devices/${deviceId}/logical-position`, { method: 'PUT', body: JSON.stringify({ x: pos.x, y: pos.y }) }); // Update local data deviceData.logical_x = pos.x; deviceData.logical_y = pos.y; // Update connections this.connectionManager.updateAllConnections(); } catch (err) { console.error('Failed to save logical position:', err); } }); }); this.layer.batchDraw(); this.connectionManager.updateAllConnections(); } async exportProject() { try { // Get current project info const project = await this.api.getProject(this.api.currentProjectId); const racks = await this.api.getRacks(); const devices = await this.api.getDevices(); const connections = await this.api.getConnections(); // Create export data const exportData = { version: '1.0', exportDate: new Date().toISOString(), project: { name: project.name, description: project.description }, racks: racks, devices: devices, connections: connections, gridSettings: { gridSize: this.rackManager.gridSize, gridVertical: this.rackManager.gridVertical } }; // Convert to JSON const jsonString = JSON.stringify(exportData, null, 2); // Create blob and download const blob = new Blob([jsonString], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${project.name.replace(/[^a-z0-9]/gi, '_')}_${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); alert(`Project "${project.name}" exported successfully!`); } catch (err) { console.error('Failed to export project:', err); alert('Failed to export project: ' + err.message); } } async importProject(file) { try { const text = await file.text(); const importData = JSON.parse(text); // Validate import data if (!importData.version || !importData.project) { throw new Error('Invalid project file format'); } const confirmMsg = `Import project "${importData.project.name}"?\n\nThis will create a new project with:\n- ${importData.racks?.length || 0} racks\n- ${importData.devices?.length || 0} devices\n- ${importData.connections?.length || 0} connections`; if (!confirm(confirmMsg)) { return; } // Create new project const newProject = await this.api.createProject( importData.project.name + ' (Imported)', importData.project.description || '' ); // Switch to new project await this.loadProjects(); document.getElementById('projectSelect').value = newProject.id; await this.switchProject(newProject.id); // Import grid settings if available if (importData.gridSettings) { this.rackManager.gridSize = importData.gridSettings.gridSize || 600; this.rackManager.gridVertical = importData.gridSettings.gridVertical || 1610; this.rackManager.saveSpacing(); } // Import racks const rackIdMap = new Map(); // Map old IDs to new IDs if (importData.racks) { for (const rack of importData.racks) { const newRack = await this.api.createRack(rack.name, rack.x, rack.y); rackIdMap.set(rack.id, newRack.id); this.rackManager.createRackShape(newRack); } } // Import devices const deviceIdMap = new Map(); // Map old IDs to new IDs if (importData.devices) { for (const device of importData.devices) { const newRackId = rackIdMap.get(device.rack_id); if (newRackId) { const newDevice = await this.api.createDevice( device.device_type_id, newRackId, device.position, device.name ); deviceIdMap.set(device.id, newDevice.id); // Fetch complete device data const devices = await this.api.getDevices(); const deviceData = devices.find(d => d.id === newDevice.id); if (deviceData) { // Update rack_units and logical position if available if (device.rack_units) { await this.api.request(`/api/devices/${newDevice.id}/rack-units`, { method: 'PUT', body: JSON.stringify({ rackUnits: device.rack_units }) }); deviceData.rack_units = device.rack_units; } if (device.logical_x !== null && device.logical_y !== null) { await this.api.request(`/api/devices/${newDevice.id}/logical-position`, { method: 'PUT', body: JSON.stringify({ x: device.logical_x, y: device.logical_y }) }); } this.deviceManager.createDeviceShape(deviceData); } } } } // Import connections if (importData.connections) { for (const conn of importData.connections) { const newSourceId = deviceIdMap.get(conn.source_device_id); const newTargetId = deviceIdMap.get(conn.target_device_id); if (newSourceId && newTargetId) { const newConn = await this.api.createConnection( newSourceId, conn.source_port, newTargetId, conn.target_port ); // Update waypoints if available if (conn.waypoints_physical) { await this.api.updateConnectionWaypoints( newConn.id, typeof conn.waypoints_physical === 'string' ? JSON.parse(conn.waypoints_physical) : conn.waypoints_physical, 'physical' ); } if (conn.waypoints_logical) { await this.api.updateConnectionWaypoints( newConn.id, typeof conn.waypoints_logical === 'string' ? JSON.parse(conn.waypoints_logical) : conn.waypoints_logical, 'logical' ); } } } // Reload connections to display them await this.connectionManager.loadConnections(); } this.layer.batchDraw(); this.connectionManager.getConnectionLayer().batchDraw(); alert(`Project imported successfully as "${newProject.name}"!`); } catch (err) { console.error('Failed to import project:', err); alert('Failed to import project: ' + err.message); } } async exportToExcel() { try { // Get current project data const project = await this.api.getProject(this.api.currentProjectId); const racks = await this.api.getRacks(); const devices = await this.api.getDevices(); const connections = await this.api.getConnections(); // Create workbook const wb = XLSX.utils.book_new(); // Racks sheet const racksData = racks.map(r => ({ 'Rack Name': r.name, 'Position X': r.x, 'Position Y': r.y, 'Width': r.width, 'Height': r.height })); const racksWs = XLSX.utils.json_to_sheet(racksData); XLSX.utils.book_append_sheet(wb, racksWs, 'Racks'); // Devices sheet const racksMap = new Map(racks.map(r => [r.id, r.name])); const devicesData = devices.map(d => ({ 'Device Name': d.name, 'Type': d.type_name, 'Rack': racksMap.get(d.rack_id) || 'Unknown', 'Slot': `U${d.position}`, 'Form Factor': `${d.rack_units || 1}U`, 'Ports': d.ports_count, 'Color': d.color })); const devicesWs = XLSX.utils.json_to_sheet(devicesData); XLSX.utils.book_append_sheet(wb, devicesWs, 'Devices'); // Connections sheet const devicesMap = new Map(devices.map(d => [d.id, d.name])); const connectionsData = connections.map(c => ({ 'Source Device': devicesMap.get(c.source_device_id) || 'Unknown', 'Source Port': c.source_port, 'Target Device': devicesMap.get(c.target_device_id) || 'Unknown', 'Target Port': c.target_port })); const connectionsWs = XLSX.utils.json_to_sheet(connectionsData); XLSX.utils.book_append_sheet(wb, connectionsWs, 'Connections'); // Generate filename const filename = `${project.name.replace(/[^a-z0-9]/gi, '_')}_${new Date().toISOString().split('T')[0]}.xlsx`; // Write file XLSX.writeFile(wb, filename); alert(`Excel file "${filename}" downloaded successfully!`); } catch (err) { console.error('Failed to export to Excel:', err); alert('Failed to export to Excel: ' + err.message); } } } // Initialize app const app = new DatacenterDesigner(); app.init();