Files
datacenter-designer/public/js/app.js
Stefano Manfredi 3431a121a9 First commit
2025-10-27 11:57:38 +00:00

1840 lines
58 KiB
JavaScript

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 = `
<div class="project-info">
<div class="project-name">${project.name}</div>
<div class="project-description">${project.description || 'No description'}</div>
<div class="project-meta">Last updated: ${date}</div>
</div>
<div class="project-actions">
${project.id !== currentProjectId ? `<button class="btn-icon btn-success" data-action="switch" data-id="${project.id}">Open</button>` : '<button class="btn-icon" disabled>Current</button>'}
<button class="btn-icon" data-action="edit" data-id="${project.id}">Edit</button>
<button class="btn-icon btn-danger" data-action="delete" data-id="${project.id}">Delete</button>
</div>
`;
// 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 = `
<li data-action="add-racks">Add Rack(s)</li>
`;
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 = '<option value="0">1</option>';
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 = `
<div class="device-type-name">${type.name}</div>
<div class="device-type-ports">${type.ports_count} ports</div>
`;
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();