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 } 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: !this.rackManager.racksLocked, // Draggable when racks are unlocked 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 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' }); // Make name clickable for renaming text.on('click', (e) => { e.cancelBubble = true; // Prevent group drag window.dispatchEvent(new CustomEvent('rename-device', { detail: { deviceId: deviceData.id, deviceData, deviceShape: group } })); }); text.on('mouseenter', () => { document.body.style.cursor = 'text'; text.fontStyle('bold italic'); this.layer.batchDraw(); }); text.on('mouseleave', () => { document.body.style.cursor = 'default'; text.fontStyle('bold'); this.layer.batchDraw(); }); group.add(rect); group.add(text); // Drag and drop between racks group.on('dragstart', () => { // Store original parent and position group.setAttr('originalParent', group.getParent()); group.setAttr('originalPosition', group.position()); // 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 () => { group.opacity(1); await this.handleDeviceDrop(deviceData.id, group); }); // Right-click context menu group.on('contextmenu', (e) => { e.evt.preventDefault(); this.showDeviceContextMenu(e, deviceData, group); }); devicesContainer.add(group); 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) { try { await this.api.deleteDevice(deviceId); group.destroy(); this.devices.delete(deviceId); this.layer.batchDraw(); // Notify table to sync 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'); 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); }; contextMenuList.addEventListener('click', handleAction); } getNextDevicePosition(rackId) { // Find the lowest available slot (1-42) // U1 is at the bottom, so we fill from bottom to top const usedSlots = new Set(); this.devices.forEach(device => { if (device.data.rack_id === rackId) { usedSlots.add(device.data.position); } }); // Find first available slot starting from U1 (bottom) for (let slot = 1; slot <= 42; slot++) { if (!usedSlots.has(slot)) { return slot; } } // If all slots are full, return next slot (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) { const device = this.devices.get(deviceId); if (!device) return; // Get device's center point for more accurate drop detection const absolutePos = deviceShape.getAbsolutePosition(); const deviceCenterX = absolutePos.x + (this.deviceWidth / 2); const deviceCenterY = absolutePos.y + (this.deviceHeight / 2); // Find which rack the device is over let targetRack = null; let targetRackId = null; this.rackManager.racks.forEach((rack, rackId) => { const rackPos = rack.shape.getAbsolutePosition(); const rackWidth = rack.data.width || this.rackManager.rackWidth; const rackHeight = rack.data.height || this.rackManager.rackHeight; // Check if device center is within rack bounds if (deviceCenterX >= rackPos.x && deviceCenterX <= rackPos.x + rackWidth && deviceCenterY >= rackPos.y && deviceCenterY <= rackPos.y + rackHeight) { targetRack = rack; targetRackId = rackId; } }); // 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 = device.data.rack_id; // Calculate position within target rack const rackShape = targetRack.shape; const rackAbsolutePos = rackShape.getAbsolutePosition(); const relativeY = absolutePos.y - rackAbsolutePos.y; // Convert visual Y to slot position (1-42, where U1 is at bottom) const maxSlots = 42; const visualPosition = Math.round((relativeY - 10) / (this.deviceHeight + this.deviceSpacing)); let newPosition = maxSlots - visualPosition; // Invert: bottom (high Y) = low slot number newPosition = Math.max(1, Math.min(42, newPosition)); // Clamp to 1-42 // Get devices in target rack and check for conflicts const devicesInTargetRack = Array.from(this.devices.values()) .filter(d => d.data.rack_id === targetRackId && d.data.id !== deviceId) .sort((a, b) => a.data.position - b.data.position); // Find available position let finalPosition = newPosition; const occupiedPositions = new Set(devicesInTargetRack.map(d => d.data.position)); while (occupiedPositions.has(finalPosition)) { finalPosition++; } 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); // Reposition device using helper method const rackUnits = device.data.rack_units || 1; 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 }); // Compact positions in original rack if different if (originalRackId !== targetRackId) { this.compactRackDevices(originalRackId); } this.layer.batchDraw(); // 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) { this.devices.forEach(device => { device.shape.draggable(draggable); }); } 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'); if (rect) { rect.width(this.deviceWidth); } if (text) { text.width(this.deviceWidth); } }); } }