export class DeviceManager { constructor(layer, api, rackManager) { this.layer = layer; this.api = api; this.rackManager = rackManager; this.devices = new Map(); this.deviceTypes = []; this.deviceHeight = 30; this.deviceSpacing = 5; this.deviceWidth = 500; // Physical view width this.currentView = 'physical'; // Track current view this.contextMenuHandler = null; // Store the current context menu handler } async loadDeviceTypes() { try { this.deviceTypes = await this.api.getDeviceTypes(); } catch (err) { console.error('Failed to load device types:', err); } } async loadDevices() { try { const devices = await this.api.getDevices(); devices.forEach(deviceData => { this.createDeviceShape(deviceData); }); this.layer.batchDraw(); } catch (err) { console.error('Failed to load devices:', err); } } createDeviceShape(deviceData) { const rackShape = this.rackManager.getRackShape(deviceData.rack_id); if (!rackShape) { console.error('Rack not found for device:', deviceData); return; } const devicesContainer = rackShape.findOne('.devices-container'); // Convert slot position (1-42) to visual Y position // Slot 1 (U1) is at the bottom, slot 42 (U42) is at the top const rackData = this.rackManager.getRackData(deviceData.rack_id); const rackHeight = rackData?.height || this.rackManager.rackHeight; const maxSlots = 42; // Calculate device height based on rack_units const rackUnits = deviceData.rack_units || 1; const deviceHeight = (this.deviceHeight * rackUnits) + (this.deviceSpacing * (rackUnits - 1)); // Calculate Y position using helper method const y = this.calculateDeviceY(deviceData.position, rackUnits, rackHeight); const group = new Konva.Group({ x: 10, y: y, draggable: true, // Always draggable id: `device-${deviceData.id}` }); // Device rectangle const rect = new Konva.Rect({ width: this.deviceWidth, height: deviceHeight, fill: deviceData.color || '#4A90E2', stroke: '#333', strokeWidth: 1, cornerRadius: 4, name: 'device-rect' }); // Device name - set listening to false to let events pass through to group const text = new Konva.Text({ x: 0, y: 0, width: this.deviceWidth, height: deviceHeight, text: deviceData.name, fontSize: 14, fontStyle: 'bold', fill: '#fff', align: 'center', verticalAlign: 'middle', padding: 5, name: 'device-text', listening: false // Don't intercept events, let them pass to group }); group.add(rect); group.add(text); // Double-click anywhere on device to rename group.on('dblclick', (e) => { e.cancelBubble = true; window.dispatchEvent(new CustomEvent('rename-device', { detail: { deviceId: deviceData.id, deviceData, deviceShape: group } })); }); // Drag and drop between racks group.on('dragstart', () => { // Store original parent and position group.setAttr('originalParent', group.getParent()); group.setAttr('originalPosition', group.position()); group.setAttr('originalRackId', deviceData.rack_id); // Move to main layer to be on top of everything const absolutePos = group.getAbsolutePosition(); group.moveTo(this.layer); group.setAbsolutePosition(absolutePos); group.moveToTop(); group.opacity(0.7); }); group.on('dragend', async (e) => { group.opacity(1); // Pass the event to get pointer position await this.handleDeviceDrop(deviceData.id, group, e); }); // Right-click context menu group.on('contextmenu', (e) => { e.evt.preventDefault(); e.cancelBubble = true; // Stop propagation to prevent rack menu this.showDeviceContextMenu(e, deviceData, group); }); devicesContainer.add(group); // Ensure devices-container is always on top of the rack devicesContainer.moveToTop(); this.devices.set(deviceData.id, { data: deviceData, shape: group }); return group; } async addDevice(deviceTypeId, rackId, position, name) { try { // Generate unique name if needed const uniqueName = this.generateUniqueName(name); const response = await this.api.createDevice(deviceTypeId, rackId, position, uniqueName); // Reload devices to get full data const devices = await this.api.getDevices(); const newDevice = devices.find(d => d.id === response.id); if (newDevice) { this.createDeviceShape(newDevice); this.layer.batchDraw(); } // Notify table to sync window.dispatchEvent(new CustomEvent('canvas-data-changed')); return newDevice; } catch (err) { console.error('Failed to add device:', err); throw err; } } async deleteDevice(deviceId, group, suppressEvent = false) { try { await this.api.deleteDevice(deviceId); group.destroy(); this.devices.delete(deviceId); this.layer.batchDraw(); // Notify table to sync (unless suppressed for bulk operations) if (!suppressEvent) { window.dispatchEvent(new CustomEvent('canvas-data-changed')); } } catch (err) { console.error('Failed to delete device:', err); } } showDeviceContextMenu(e, deviceData, group) { const contextMenu = document.getElementById('contextMenu'); const contextMenuList = document.getElementById('contextMenuList'); contextMenuList.innerHTML = `
  • Create Connection
  • Delete Device
  • `; contextMenu.style.left = `${e.evt.pageX}px`; contextMenu.style.top = `${e.evt.pageY}px`; contextMenu.classList.remove('hidden'); // Remove previous event listener if exists if (this.contextMenuHandler) { contextMenuList.removeEventListener('click', this.contextMenuHandler); } const handleAction = async (evt) => { const action = evt.target.dataset.action; if (action === 'delete') { if (confirm(`Delete device ${deviceData.name}?`)) { this.deleteDevice(deviceData.id, group); } } else if (action === 'connect') { // Trigger connection creation window.dispatchEvent(new CustomEvent('create-connection', { detail: { deviceData, deviceShape: group } })); } contextMenu.classList.add('hidden'); contextMenuList.removeEventListener('click', handleAction); this.contextMenuHandler = null; }; // Store and add the new handler this.contextMenuHandler = handleAction; contextMenuList.addEventListener('click', handleAction); } getNextDevicePosition(rackId, requiredRackUnits = 1) { // Find the lowest available slot (1-42) that can fit a device with requiredRackUnits // U1 is at the bottom, so we fill from bottom to top const usedSlots = new Set(); // Mark ALL slots occupied by each device (accounting for rack_units) this.devices.forEach(device => { if (device.data.rack_id === rackId) { const rackUnits = device.data.rack_units || 1; // Mark all slots this device occupies for (let i = 0; i < rackUnits; i++) { usedSlots.add(device.data.position + i); } } }); // Find first available slot starting from U1 (bottom) that has enough consecutive space for (let slot = 1; slot <= 42; slot++) { // Check if this slot and the next (requiredRackUnits - 1) slots are all free let hasSpace = true; for (let i = 0; i < requiredRackUnits; i++) { if (usedSlots.has(slot + i) || (slot + i) > 42) { hasSpace = false; break; } } if (hasSpace) { return slot; } } // If no space found, return next slot after maximum (will overflow) return 43; } getDeviceShape(deviceId) { const device = this.devices.get(deviceId); return device ? device.shape : null; } getDeviceData(deviceId) { const device = this.devices.get(deviceId); return device ? device.data : null; } getAllDevices() { return Array.from(this.devices.values()).map(d => d.data); } // Calculate Y position for a device at a given slot with given rack units calculateDeviceY(position, rackUnits = 1, rackHeight = null) { const maxSlots = 42; // Use same margin as left/right (10px) const topMargin = 10; // Device at position X with N rack units occupies slots X (bottom) to X+N-1 (top) const topSlot = position + (rackUnits - 1); const visualPosition = maxSlots - topSlot; return topMargin + (visualPosition * (this.deviceHeight + this.deviceSpacing)); } // Check if a device at a given position with given rack_units conflicts with other devices // Returns null if no conflict, or a descriptive error message if there is a conflict checkSlotConflict(rackId, position, rackUnits, excludeDeviceId = null) { const slotsOccupied = []; for (let i = 0; i < rackUnits; i++) { slotsOccupied.push(position + i); } // Check all devices in the same rack const devicesInRack = Array.from(this.devices.values()) .filter(d => d.data.rack_id === rackId && d.data.id !== excludeDeviceId); for (const device of devicesInRack) { const deviceRackUnits = device.data.rack_units || 1; const deviceSlotsOccupied = []; for (let i = 0; i < deviceRackUnits; i++) { deviceSlotsOccupied.push(device.data.position + i); } // Check for overlap const overlap = slotsOccupied.some(slot => deviceSlotsOccupied.includes(slot)); if (overlap) { const conflictSlots = slotsOccupied.filter(slot => deviceSlotsOccupied.includes(slot)); return `Device "${device.data.name}" already occupies slot(s) U${conflictSlots.join(', U')}`; } } return null; // No conflict } // Check if a device name already exists (case-insensitive) isDeviceNameTaken(name, excludeDeviceId = null) { const nameLower = name.toLowerCase(); return Array.from(this.devices.values()).some(device => { if (excludeDeviceId && device.data.id === excludeDeviceId) { return false; // Exclude the device being renamed } return device.data.name.toLowerCase() === nameLower; }); } // Generate a unique device name by adding _XX suffix generateUniqueName(baseName) { // Remove any existing _XX suffix from the base name const cleanBaseName = baseName.replace(/_\d+$/, ''); // If the clean name is available, use it if (!this.isDeviceNameTaken(cleanBaseName)) { return cleanBaseName; } // Find the highest existing number suffix let maxNumber = 0; const pattern = new RegExp(`^${cleanBaseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}_?(\\d+)$`, 'i'); Array.from(this.devices.values()).forEach(device => { const match = device.data.name.match(pattern); if (match) { const num = parseInt(match[1]) || 0; if (num > maxNumber) { maxNumber = num; } } }); // Generate next number with padding const nextNumber = (maxNumber + 1).toString().padStart(2, '0'); return `${cleanBaseName}_${nextNumber}`; } async handleDeviceDrop(deviceId, deviceShape, event) { const device = this.devices.get(deviceId); if (!device) return; // Get the stage and mouse pointer position const stage = this.layer.getStage(); const pointerPos = stage.getPointerPosition(); if (!pointerPos) { const originalParent = deviceShape.getAttr('originalParent'); const originalPosition = deviceShape.getAttr('originalPosition'); if (originalParent) { deviceShape.moveTo(originalParent); deviceShape.position(originalPosition); } this.layer.batchDraw(); return; } // Convert pointer position from screen coordinates to world coordinates // Account for stage position (pan) and scale (zoom) const scale = stage.scaleX(); // Assumes uniform scaling (scaleX === scaleY) const stagePos = stage.position(); const worldX = (pointerPos.x - stagePos.x) / scale; const worldY = (pointerPos.y - stagePos.y) / scale; const rackUnits = device.data.rack_units || 1; // Find which rack the pointer is over let targetRack = null; let targetRackId = null; // Convert Map to array to use find() instead of forEach const racksArray = Array.from(this.rackManager.racks.entries()); for (const [rackId, rack] of racksArray) { const rackX = rack.data.x; const rackY = rack.data.y; const rackWidth = rack.data.width || this.rackManager.rackWidth; const rackHeight = rack.data.height || this.rackManager.rackHeight; // Check if world-space pointer is within rack bounds if (worldX >= rackX && worldX <= rackX + rackWidth && worldY >= rackY && worldY <= rackY + rackHeight) { targetRack = rack; targetRackId = rackId; break; // Use first matching rack } } // If not over any rack, return device to original position if (!targetRack) { const originalParent = deviceShape.getAttr('originalParent'); const originalPosition = deviceShape.getAttr('originalPosition'); if (originalParent) { deviceShape.moveTo(originalParent); deviceShape.position(originalPosition); } this.layer.batchDraw(); return; } const originalRackId = deviceShape.getAttr('originalRackId') || device.data.rack_id; // Get the rack shape for later use const rackShape = targetRack.shape; // Calculate position within target rack using world coordinates const rackY = targetRack.data.y; // Use the world Y position for slot detection const relativeY = worldY - rackY; // Convert visual Y to slot position (1-42, where U1 is at bottom) const maxSlots = 42; const slotHeight = this.deviceHeight + this.deviceSpacing; const topMargin = 10; // Calculate which slot the pointer is in const visualSlotFromTop = Math.floor((relativeY - topMargin) / slotHeight); let newPosition = maxSlots - visualSlotFromTop; // Invert: bottom (high Y) = low slot number newPosition = Math.max(1, Math.min(42, newPosition)); // Clamp to 1-42 // Check for conflicts with existing devices in this rack // Note: rackUnits already declared at the beginning of this function const conflict = this.checkSlotConflict(targetRackId, newPosition, rackUnits, deviceId); if (conflict) { // Position is occupied, revert to original position const originalParent = deviceShape.getAttr('originalParent'); const originalPosition = deviceShape.getAttr('originalPosition'); if (originalParent) { deviceShape.moveTo(originalParent); deviceShape.position(originalPosition); } this.layer.batchDraw(); return; } const finalPosition = newPosition; // Check if device actually moved if (originalRackId === targetRackId && device.data.position === finalPosition) { // Device didn't move, but snap it back to proper slot position const devicesContainer = rackShape.findOne('.devices-container'); deviceShape.moveTo(devicesContainer); // Recalculate proper Y position to snap to slot const rackData = this.rackManager.getRackData(targetRackId); const rackHeight = rackData?.height || this.rackManager.rackHeight; const correctY = this.calculateDeviceY(finalPosition, rackUnits, rackHeight); deviceShape.position({ x: 10, y: correctY }); this.layer.batchDraw(); return; } try { // Update device in database await this.api.request(`/api/devices/${deviceId}/rack`, { method: 'PUT', body: JSON.stringify({ rackId: targetRackId, position: finalPosition }) }); // Update local data device.data.rack_id = targetRackId; device.data.position = finalPosition; // Move device to new rack's devices-container const newDevicesContainer = rackShape.findOne('.devices-container'); deviceShape.moveTo(newDevicesContainer); // Ensure devices-container is on top within the rack newDevicesContainer.moveToTop(); // Reposition device using helper method // Note: rackUnits already declared above const rackData = this.rackManager.getRackData(targetRackId); const rackHeight = rackData?.height || this.rackManager.rackHeight; const newY = this.calculateDeviceY(finalPosition, rackUnits, rackHeight); deviceShape.position({ x: 10, y: newY }); // NOTE: Removed auto-compacting - it was moving other devices unexpectedly // Users can manually adjust device positions as needed this.layer.batchDraw(); // Update connections after device movement if (this.connectionManager) { this.connectionManager.updateAllConnections(); } // Notify table to sync window.dispatchEvent(new CustomEvent('canvas-data-changed')); } catch (err) { console.error('Failed to move device:', err); // Revert to original position const originalParent = deviceShape.getAttr('originalParent'); const originalPosition = deviceShape.getAttr('originalPosition'); if (originalParent) { deviceShape.moveTo(originalParent); deviceShape.position(originalPosition); } this.layer.batchDraw(); } } async compactRackDevices(rackId) { // Get all devices in this rack, sorted by position (1-42) const devicesInRack = Array.from(this.devices.values()) .filter(d => d.data.rack_id === rackId) .sort((a, b) => a.data.position - b.data.position); // Reassign positions to be sequential starting from 1 (U1 = bottom) const updatePromises = []; const maxSlots = 42; devicesInRack.forEach((device, index) => { const newSlot = index + 1; // Slots start at 1 if (device.data.position !== newSlot) { device.data.position = newSlot; // Update visual position using helper method const rackUnits = device.data.rack_units || 1; const rackData = this.rackManager.getRackData(rackId); const rackHeight = rackData?.height || this.rackManager.rackHeight; const newY = this.calculateDeviceY(newSlot, rackUnits, rackHeight); device.shape.position({ x: 10, y: newY }); // Update database updatePromises.push( this.api.request(`/api/devices/${device.data.id}/rack`, { method: 'PUT', body: JSON.stringify({ rackId: rackId, position: newSlot }) }) ); } }); await Promise.all(updatePromises); this.layer.batchDraw(); } updateDevicesDraggability(draggable) { // Devices are now always draggable, regardless of rack lock state // This method is kept for compatibility but doesn't change draggability this.devices.forEach(device => { device.shape.draggable(true); }); } setCurrentView(viewType) { this.currentView = viewType; // Set device width based on view if (viewType === 'logical') { this.deviceWidth = 200; // Narrower in logical view } else { this.deviceWidth = 500; // Normal width in physical view } // Resize all existing devices this.devices.forEach(device => { const rect = device.shape.findOne('.device-rect'); const text = device.shape.findOne('.device-text'); // In logical view: all devices same size (1U) // In physical view: size based on rack units let deviceHeight; if (viewType === 'logical') { deviceHeight = this.deviceHeight; // All devices are 1U height in logical view } else { const rackUnits = device.data.rack_units || 1; deviceHeight = (this.deviceHeight * rackUnits) + (this.deviceSpacing * (rackUnits - 1)); } if (rect) { rect.width(this.deviceWidth); rect.height(deviceHeight); } if (text) { text.width(this.deviceWidth); text.height(deviceHeight); } }); } }