611 lines
20 KiB
JavaScript
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);
|
|
}
|
|
});
|
|
}
|
|
}
|