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

611 lines
20 KiB
JavaScript

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 = `
<li data-action="connect">Create Connection</li>
<li class="divider"></li>
<li data-action="delete">Delete Device</li>
`;
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);
}
});
}
}