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

514 lines
16 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
}
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 = `
<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');
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);
}
});
}
}