First commit

This commit is contained in:
Stefano Manfredi
2025-10-27 11:57:38 +00:00
commit 3431a121a9
34 changed files with 17474 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,610 @@
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);
}
});
}
}

View File

@@ -0,0 +1,487 @@
export class RackManager {
constructor(layer, api, deviceManager) {
this.layer = layer;
this.api = api;
this.deviceManager = deviceManager;
this.racks = new Map();
this.rackPrefix = 'RACK';
this.rackWidth = 520; // Fits 500px wide devices with margins
this.rackHeight = 1485; // Fits 42 devices (42 * 30px + 41 * 5px spacing + 20px margins)
this.rackSpacing = 80;
this.gridSize = 600; // Default: rack width + spacing
this.gridVertical = 1585; // Default: rack height + spacing (1485 + 100)
this.racksLocked = true; // Start with racks locked
this.nextX = 0; // Start at grid origin
this.nextY = 0; // Start at grid origin
this.contextMenuHandler = null; // Store the current context menu handler
// Note: loadSpacing() will be called after project ID is set
}
loadSpacing() {
const projectId = this.api.currentProjectId;
const savedGridSize = localStorage.getItem(`gridSize_${projectId}`);
const savedGridVertical = localStorage.getItem(`gridVertical_${projectId}`);
if (savedGridSize) {
this.gridSize = parseInt(savedGridSize);
this.rackSpacing = this.gridSize - this.rackWidth;
} else {
this.gridSize = 600; // Default: rack width + spacing
}
if (savedGridVertical) {
this.gridVertical = parseInt(savedGridVertical);
} else {
this.gridVertical = 1585; // Default: rack height + spacing (fits 42 devices)
}
}
saveSpacing() {
const projectId = this.api.currentProjectId;
localStorage.setItem(`gridSize_${projectId}`, this.gridSize.toString());
localStorage.setItem(`gridVertical_${projectId}`, this.gridVertical.toString());
}
async toggleRacksLock() {
this.racksLocked = !this.racksLocked;
this.racks.forEach(rack => {
rack.shape.draggable(!this.racksLocked);
});
// Update device draggability
if (this.deviceManager) {
this.deviceManager.updateDevicesDraggability(!this.racksLocked);
}
// If locking, compact the grid (remove empty columns from the left)
if (this.racksLocked) {
await this.compactGrid();
}
return this.racksLocked;
}
async compactGrid() {
if (this.racks.size === 0) return;
// Get all rack positions and calculate their grid coordinates
const rackPositions = [];
this.racks.forEach((rack, id) => {
const gridX = Math.round(rack.data.x / this.gridSize);
const gridY = Math.round(rack.data.y / this.gridVertical);
rackPositions.push({ id, rack, gridX, gridY });
});
// Find the minimum grid X (leftmost column that has racks)
const minGridX = Math.min(...rackPositions.map(r => r.gridX));
// If minGridX is 0, grid is already compact
if (minGridX === 0) return;
// Shift all racks left by minGridX columns
const updatePromises = [];
for (const rackPos of rackPositions) {
const newGridX = rackPos.gridX - minGridX;
const newX = newGridX * this.gridSize;
const newY = rackPos.gridY * this.gridVertical;
// Update visual position
rackPos.rack.shape.position({ x: newX, y: newY });
// Update data
rackPos.rack.data.x = newX;
rackPos.rack.data.y = newY;
// Queue database update
updatePromises.push(this.api.updateRackPosition(rackPos.id, newX, newY));
}
// Redraw once
this.layer.batchDraw();
// Wait for all updates
await Promise.all(updatePromises);
}
snapToGrid(value, gridSize) {
return Math.round(value / gridSize) * gridSize;
}
async loadRacks() {
try {
const racks = await this.api.getRacks();
racks.forEach(rackData => {
this.createRackShape(rackData);
});
this.layer.batchDraw();
} catch (err) {
console.error('Failed to load racks:', err);
}
}
createRackShape(rackData) {
const group = new Konva.Group({
x: rackData.x,
y: rackData.y,
draggable: !this.racksLocked, // Locked by default
id: `rack-${rackData.id}`
});
// Rack background
const rect = new Konva.Rect({
width: rackData.width || this.rackWidth,
height: rackData.height || this.rackHeight,
fill: '#ffffff',
stroke: '#333',
strokeWidth: 2,
shadowColor: 'black',
shadowBlur: 5,
shadowOpacity: 0.1,
shadowOffset: { x: 2, y: 2 }
});
// Rack name label
const nameLabel = new Konva.Text({
x: 0,
y: -30,
width: rackData.width || this.rackWidth,
text: rackData.name,
fontSize: 16,
fontStyle: 'bold',
fill: '#333',
align: 'center',
name: 'rack-name'
});
// Double-click to rename (consistent with device behavior)
nameLabel.on('dblclick', () => {
window.dispatchEvent(new CustomEvent('rename-rack', {
detail: { rackId: rackData.id, rackData, rackShape: group }
}));
});
// Container for devices
const devicesLayer = new Konva.Group({
name: 'devices-container'
});
group.add(rect);
group.add(nameLabel);
group.add(devicesLayer);
// Grid snapping during drag
group.on('dragmove', () => {
const x = this.snapToGrid(group.x(), this.gridSize);
const y = this.snapToGrid(group.y(), this.gridVertical);
group.position({ x, y });
});
// Drag end - update position in DB with smart positioning
group.on('dragend', async () => {
try {
const newX = this.snapToGrid(group.x(), this.gridSize);
const newY = this.snapToGrid(group.y(), this.gridVertical);
// Check if position is occupied by another rack
await this.handleRackPlacement(rackData.id, newX, newY);
} catch (err) {
console.error('Failed to update rack position:', err);
}
});
// Right-click context menu
group.on('contextmenu', (e) => {
e.evt.preventDefault();
this.showContextMenu(e, rackData, group);
});
this.layer.add(group);
this.racks.set(rackData.id, { data: rackData, shape: group });
return group;
}
async addRack() {
try {
const nextName = await this.api.getNextRackName(this.rackPrefix);
const rackData = await this.api.createRack(nextName, this.nextX, this.nextY);
this.createRackShape(rackData);
this.layer.batchDraw();
// Update next position (using grid sizes)
this.nextX += this.gridSize;
if (this.nextX > 1200) {
this.nextX = 0;
this.nextY += this.gridVertical;
}
// Notify table to sync
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
return rackData;
} catch (err) {
console.error('Failed to add rack:', err);
throw err;
}
}
async deleteRack(rackId, group, suppressEvent = false) {
try {
await this.api.deleteRack(rackId);
group.destroy();
this.racks.delete(rackId);
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 rack:', err);
}
}
showContextMenu(e, rackData, group) {
const contextMenu = document.getElementById('contextMenu');
const contextMenuList = document.getElementById('contextMenuList');
const lockText = this.racksLocked ? 'Unlock All Racks' : 'Lock All Racks';
// Build device types list with header
let deviceTypesHTML = '<li class="menu-header">Add device:</li>';
if (this.deviceManager && this.deviceManager.deviceTypes) {
this.deviceManager.deviceTypes.forEach(type => {
deviceTypesHTML += `<li data-action="add-device" data-device-type-id="${type.id}" data-device-type-name="${type.name}">${type.name}</li>`;
});
}
// Build unlock/management options
let managementHTML = `<li data-action="toggle-lock">${lockText}</li>`;
// Show delete and spacing controls only when unlocked
if (!this.racksLocked) {
const horizontalSpacing = this.gridSize - this.rackWidth;
const verticalSpacing = this.gridVertical - this.rackHeight;
managementHTML += `
<li data-action="delete">Delete Rack</li>
<li class="divider"></li>
<li class="spacing-control">
<span class="spacing-label">Horizontal spacing: ${horizontalSpacing}px</span>
<div class="spacing-buttons">
<button class="spacing-btn" data-action="h-spacing-decrease"></button>
<button class="spacing-btn" data-action="h-spacing-increase">+</button>
</div>
</li>
<li class="spacing-control">
<span class="spacing-label">Vertical spacing: ${verticalSpacing}px</span>
<div class="spacing-buttons">
<button class="spacing-btn" data-action="v-spacing-decrease"></button>
<button class="spacing-btn" data-action="v-spacing-increase">+</button>
</div>
</li>
`;
}
contextMenuList.innerHTML = `
${deviceTypesHTML}
<li class="divider"></li>
${managementHTML}
`;
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;
// For spacing buttons, prevent default and stop propagation
if (action && action.includes('spacing')) {
evt.preventDefault();
evt.stopPropagation();
}
if (action === 'add-device') {
const deviceTypeId = parseInt(evt.target.dataset.deviceTypeId);
const deviceTypeName = evt.target.dataset.deviceTypeName;
const deviceName = prompt(`Enter name for ${deviceTypeName}:`, deviceTypeName);
if (deviceName) {
try {
// Check if name will be auto-numbered
const uniqueName = this.deviceManager.generateUniqueName(deviceName);
if (uniqueName !== deviceName) {
const proceed = confirm(`Device name "${deviceName}" is already in use.\n\nDevice will be named "${uniqueName}" instead.\n\nContinue?`);
if (!proceed) {
return;
}
}
const position = this.deviceManager.getNextDevicePosition(rackData.id);
await this.deviceManager.addDevice(deviceTypeId, rackData.id, position, deviceName);
} catch (err) {
alert('Failed to add device: ' + err.message);
}
}
} else if (action === 'delete') {
if (confirm(`Delete rack ${rackData.name}?`)) {
this.deleteRack(rackData.id, group);
}
} else if (action === 'toggle-lock') {
const isLocked = await this.toggleRacksLock();
const statusText = isLocked ? 'Racks locked (grid compacted)' : 'Racks unlocked';
// Close and reopen menu to refresh the lock state
contextMenu.classList.add('hidden');
setTimeout(() => {
this.showContextMenu(e, rackData, group);
}, 10);
return; // Don't close menu handler
} else if (action === 'h-spacing-increase') {
await this.adjustSpacing('horizontal', 10);
return; // Don't close menu
} else if (action === 'h-spacing-decrease') {
await this.adjustSpacing('horizontal', -10);
return; // Don't close menu
} else if (action === 'v-spacing-increase') {
await this.adjustSpacing('vertical', 50);
return; // Don't close menu
} else if (action === 'v-spacing-decrease') {
await this.adjustSpacing('vertical', -50);
return; // Don't close menu
}
contextMenu.classList.add('hidden');
contextMenuList.removeEventListener('click', handleAction);
this.contextMenuHandler = null;
};
// Store and add the new handler
this.contextMenuHandler = handleAction;
contextMenuList.addEventListener('click', handleAction);
}
getRackShape(rackId) {
const rack = this.racks.get(rackId);
return rack ? rack.shape : null;
}
getRackData(rackId) {
const rack = this.racks.get(rackId);
return rack ? rack.data : null;
}
async handleRackPlacement(movedRackId, newX, newY) {
// Get all racks in the same row (same Y coordinate)
const racksInRow = [];
this.racks.forEach((rack, id) => {
if (id !== movedRackId && rack.data.y === newY) {
racksInRow.push({ id, rack, x: rack.data.x });
}
});
// Sort by X position
racksInRow.sort((a, b) => a.x - b.x);
// Check if new position is occupied
const occupiedRack = racksInRow.find(r => r.x === newX);
if (occupiedRack) {
// Position is occupied - shift all racks at and to the right of this position
const racksToShift = racksInRow.filter(r => r.x >= newX);
// Shift each rack one grid position to the right
for (const rackInfo of racksToShift) {
const newRackX = rackInfo.x + this.gridSize;
// Update visual position
rackInfo.rack.shape.position({ x: newRackX, y: newY });
// Update data
rackInfo.rack.data.x = newRackX;
rackInfo.rack.data.y = newY;
// Update in database
await this.api.updateRackPosition(rackInfo.id, newRackX, newY);
}
}
// Update the moved rack
const movedRack = this.racks.get(movedRackId);
if (movedRack) {
movedRack.shape.position({ x: newX, y: newY });
movedRack.data.x = newX;
movedRack.data.y = newY;
await this.api.updateRackPosition(movedRackId, newX, newY);
}
// Redraw
this.layer.batchDraw();
}
async adjustSpacing(direction, delta) {
// Calculate grid coordinates for all racks BEFORE changing spacing
const rackGridPositions = new Map();
this.racks.forEach((rack, id) => {
const gridX = Math.round(rack.data.x / this.gridSize);
const gridY = Math.round(rack.data.y / this.gridVertical);
rackGridPositions.set(id, { gridX, gridY });
});
// Adjust spacing (this updates the grid references)
if (direction === 'horizontal') {
const newSpacing = (this.gridSize - this.rackWidth) + delta;
if (newSpacing < 10) return; // Minimum spacing
this.gridSize = this.rackWidth + newSpacing;
this.rackSpacing = newSpacing; // Update the spacing value
} else {
const newSpacing = (this.gridVertical - this.rackHeight) + delta;
if (newSpacing < 10) return; // Minimum spacing
this.gridVertical = this.rackHeight + newSpacing;
}
// Batch all position updates
const updatePromises = [];
// Recalculate all rack positions at once
for (const [id, gridPos] of rackGridPositions) {
const rack = this.racks.get(id);
if (!rack) continue;
const newX = gridPos.gridX * this.gridSize;
const newY = gridPos.gridY * this.gridVertical;
// Update visual position
rack.shape.position({ x: newX, y: newY });
// Update data
rack.data.x = newX;
rack.data.y = newY;
// Queue database update (don't await yet)
updatePromises.push(this.api.updateRackPosition(id, newX, newY));
}
// Redraw once for all changes
this.layer.batchDraw();
// Wait for all database updates to complete
await Promise.all(updatePromises);
// Save spacing to localStorage
this.saveSpacing();
// Update status
const horizontalSpacing = this.gridSize - this.rackWidth;
const verticalSpacing = this.gridVertical - this.rackHeight;
}
}

View File

@@ -0,0 +1,805 @@
export class TableManager {
constructor(api, rackManager, deviceManager, connectionManager) {
this.api = api;
this.rackManager = rackManager;
this.deviceManager = deviceManager;
this.connectionManager = connectionManager;
this.currentTable = null; // 'racks', 'devices', 'connections'
this.gridApi = null;
this.gridColumnApi = null;
this.tableContainer = document.getElementById('tableContent');
}
isTableVisible() {
return this.currentTable !== null;
}
getCurrentTableType() {
return this.currentTable;
}
// Show specific table view
async showTable(tableType) {
// tableType can be: 'racks-table', 'devices-table', 'connections-table'
const tableMap = {
'racks-table': 'racks',
'devices-table': 'devices',
'connections-table': 'connections'
};
this.currentTable = tableMap[tableType];
// Clear existing grid
if (this.gridApi) {
this.gridApi.destroy();
this.gridApi = null;
}
// Clear container to ensure no stale DOM elements
this.tableContainer.innerHTML = '';
// Render appropriate table
switch (this.currentTable) {
case 'racks':
await this.showRacksTable();
break;
case 'devices':
await this.showDevicesTable();
break;
case 'connections':
await this.showConnectionsTable();
break;
}
}
hideTable() {
if (this.gridApi) {
this.gridApi.destroy();
this.gridApi = null;
}
this.currentTable = null;
this.tableContainer.innerHTML = '';
}
// ===== RACKS TABLE =====
async showRacksTable() {
const racks = await this.api.getRacks();
// Sort alphabetically by name
const sortedRacks = racks.sort((a, b) => a.name.localeCompare(b.name));
const columnDefs = [
{
headerName: 'Rack Name',
field: 'name',
editable: true,
sortable: true,
filter: true,
checkboxSelection: true,
headerCheckboxSelection: true
},
{
headerName: 'Position X',
field: 'x',
editable: false,
sortable: true,
valueFormatter: params => `${Math.round(params.value)}px`
},
{
headerName: 'Position Y',
field: 'y',
editable: false,
sortable: true,
valueFormatter: params => `${Math.round(params.value)}px`
},
{
headerName: 'Width',
field: 'width',
editable: false,
sortable: true,
valueFormatter: params => `${params.value}px`
},
{
headerName: 'Height',
field: 'height',
editable: false,
sortable: true,
valueFormatter: params => `${params.value}px`
},
{
headerName: 'Device Count',
field: 'deviceCount',
editable: false,
sortable: true,
valueGetter: params => {
// Count devices in this rack
const devices = this.deviceManager.getAllDevices();
return devices.filter(d => d.rack_id === params.data.id).length;
}
}
];
const gridOptions = {
columnDefs: columnDefs,
rowData: sortedRacks,
rowSelection: 'multiple',
animateRows: true,
enableCellTextSelection: true,
defaultColDef: {
flex: 1,
minWidth: 100,
resizable: true
},
onCellValueChanged: (params) => this.onRackCellValueChanged(params),
onSelectionChanged: () => this.updateToolbarButtons(),
overlayNoRowsTemplate: '<span style="padding: 10px; color: #999;">No racks found</span>'
};
this.gridApi = agGrid.createGrid(this.tableContainer, gridOptions);
}
async onRackCellValueChanged(params) {
const rackId = params.data.id;
const field = params.colDef.field;
const newValue = params.newValue;
try {
if (field === 'name') {
await this.api.updateRackName(rackId, newValue);
// Update canvas
const rackShape = this.rackManager.getRackShape(rackId);
if (rackShape) {
const nameLabel = rackShape.findOne('.rack-name');
if (nameLabel) {
nameLabel.text(newValue);
this.rackManager.layer.batchDraw();
}
}
// Update local data
const rackData = this.rackManager.getRackData(rackId);
if (rackData) {
rackData.name = newValue;
}
}
} catch (err) {
console.error('Failed to update rack:', err);
alert('Failed to update rack: ' + err.message);
// Revert the change
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
}
}
// ===== DEVICES TABLE =====
async showDevicesTable() {
const devices = await this.api.getDevices();
const racks = await this.api.getRacks();
const deviceTypes = await this.api.getDeviceTypes();
const columnDefs = [
{
headerName: 'Device Name',
field: 'name',
editable: true,
sortable: true,
filter: true,
checkboxSelection: true,
headerCheckboxSelection: true
},
{
headerName: 'Type',
field: 'type_name',
editable: true,
sortable: true,
filter: true,
cellEditor: 'agSelectCellEditor',
cellEditorParams: {
values: deviceTypes.map(t => t.name)
}
},
{
headerName: 'Rack',
field: 'rack_name',
editable: true,
sortable: true,
filter: true,
cellEditor: 'agSelectCellEditor',
cellEditorParams: {
values: racks.map(r => r.name)
},
valueGetter: params => {
const rack = racks.find(r => r.id === params.data.rack_id);
return rack ? rack.name : '';
}
},
{
headerName: 'Slot/Position',
field: 'position',
editable: true,
sortable: true,
filter: 'agNumberColumnFilter',
valueFormatter: params => `U${params.value}`,
cellEditor: 'agNumberCellEditor',
cellEditorParams: {
min: 1,
max: 42,
precision: 0
},
valueSetter: params => {
const newValue = parseInt(params.newValue);
if (newValue >= 1 && newValue <= 42) {
params.data.position = newValue;
return true;
}
return false;
}
},
{
headerName: 'Form Factor',
field: 'rack_units',
editable: true,
sortable: true,
filter: 'agNumberColumnFilter',
valueFormatter: params => `${params.value || 1}U`,
cellEditor: 'agNumberCellEditor',
cellEditorParams: {
min: 1,
max: 42,
precision: 0
},
valueSetter: params => {
const newValue = parseInt(params.newValue);
if (newValue >= 1 && newValue <= 42) {
params.data.rack_units = newValue;
return true;
}
return false;
}
},
{
headerName: 'Ports',
field: 'ports_count',
editable: false,
sortable: true,
filter: 'agNumberColumnFilter'
},
{
headerName: 'Color',
field: 'color',
editable: false,
sortable: false,
cellRenderer: params => {
return `<div style="width: 100%; height: 100%; background-color: ${params.value}; border-radius: 3px;"></div>`;
}
},
{
headerName: 'Connections',
field: 'connectionCount',
editable: false,
sortable: true,
valueGetter: params => {
// Count connections for this device
const connections = Array.from(this.connectionManager.connections.values());
return connections.filter(c =>
c.data.source_device_id === params.data.id ||
c.data.target_device_id === params.data.id
).length;
}
}
];
const gridOptions = {
columnDefs: columnDefs,
rowData: devices,
rowSelection: 'multiple',
animateRows: true,
enableCellTextSelection: true,
defaultColDef: {
flex: 1,
minWidth: 100,
resizable: true
},
onCellValueChanged: (params) => this.onDeviceCellValueChanged(params, racks, deviceTypes),
onSelectionChanged: () => this.updateToolbarButtons(),
overlayNoRowsTemplate: '<span style="padding: 10px; color: #999;">No devices found</span>'
};
this.gridApi = agGrid.createGrid(this.tableContainer, gridOptions);
}
async onDeviceCellValueChanged(params, racks, deviceTypes) {
const deviceId = params.data.id;
const field = params.colDef.field;
const newValue = params.newValue;
try {
if (field === 'name') {
// Check if name is already taken
if (this.deviceManager.isDeviceNameTaken(newValue, deviceId)) {
alert(`Device name "${newValue}" is already in use. Please choose a different name.`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
await this.api.updateDeviceName(deviceId, newValue);
// Update canvas
const deviceShape = this.deviceManager.getDeviceShape(deviceId);
if (deviceShape) {
const nameLabel = deviceShape.findOne('.device-text');
if (nameLabel) {
nameLabel.text(newValue);
this.deviceManager.layer.batchDraw();
}
}
// Update local data
const deviceData = this.deviceManager.getDeviceData(deviceId);
if (deviceData) {
deviceData.name = newValue;
}
} else if (field === 'rack_name') {
// Find the rack by name
const rack = racks.find(r => r.name === newValue);
if (rack) {
const newPosition = this.deviceManager.getNextDevicePosition(rack.id);
await this.api.request(`/api/devices/${deviceId}/rack`, {
method: 'PUT',
body: JSON.stringify({ rackId: rack.id, position: newPosition })
});
// Update device on canvas
const deviceShape = this.deviceManager.getDeviceShape(deviceId);
const deviceData = this.deviceManager.getDeviceData(deviceId);
if (deviceShape && deviceData) {
const oldRackId = deviceData.rack_id;
deviceData.rack_id = rack.id;
deviceData.position = newPosition;
// Move to new rack's container
const newRackShape = this.rackManager.getRackShape(rack.id);
if (newRackShape) {
const newDevicesContainer = newRackShape.findOne('.devices-container');
deviceShape.moveTo(newDevicesContainer);
// Calculate visual position using helper method
const rackUnits = deviceData.rack_units || 1;
const rackData = this.rackManager.getRackData(rack.id);
const rackHeight = rackData?.height || this.rackManager.rackHeight;
const newY = this.deviceManager.calculateDeviceY(newPosition, rackUnits, rackHeight);
deviceShape.position({ x: 10, y: newY });
// Compact old rack
if (oldRackId !== rack.id) {
this.deviceManager.compactRackDevices(oldRackId);
}
this.deviceManager.layer.batchDraw();
}
}
// Refresh table to show updated position
this.refreshTable();
}
} else if (field === 'position') {
const rackId = params.data.rack_id;
const newSlot = parseInt(newValue);
const rackUnits = params.data.rack_units || 1;
// Validate slot range (1-42)
if (newSlot < 1 || newSlot > 42) {
alert('Slot position must be between U1 and U42');
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
// Validate that device with its rack_units fits in the rack
if (newSlot + rackUnits - 1 > 42) {
alert(`Device with ${rackUnits}U form factor cannot fit at position U${newSlot}. Maximum position is U${43 - rackUnits}.`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
// Check for slot conflicts with other devices
const conflict = this.deviceManager.checkSlotConflict(rackId, newSlot, rackUnits, deviceId);
if (conflict) {
alert(`Slot conflict detected: ${conflict}`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
await this.api.request(`/api/devices/${deviceId}/rack`, {
method: 'PUT',
body: JSON.stringify({ rackId: rackId, position: newSlot })
});
// Update device position on canvas using helper method
const deviceShape = this.deviceManager.getDeviceShape(deviceId);
const deviceData = this.deviceManager.getDeviceData(deviceId);
if (deviceShape && deviceData) {
deviceData.position = newSlot;
const rackUnits = deviceData.rack_units || 1;
const rackData = this.rackManager.getRackData(rackId);
const rackHeight = rackData?.height || this.rackManager.rackHeight;
const newY = this.deviceManager.calculateDeviceY(newSlot, rackUnits, rackHeight);
deviceShape.position({ x: 10, y: newY });
this.deviceManager.layer.batchDraw();
}
} else if (field === 'rack_units') {
const rackId = params.data.rack_id;
const position = params.data.position;
const newRackUnits = parseInt(newValue);
// Validate that device with its new rack_units fits in the rack
if (position + newRackUnits - 1 > 42) {
alert(`Device with ${newRackUnits}U form factor cannot fit at position U${position}. Maximum form factor at this position is ${43 - position}U.`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
// Check for slot conflicts with other devices
const conflict = this.deviceManager.checkSlotConflict(rackId, position, newRackUnits, deviceId);
if (conflict) {
alert(`Slot conflict detected: ${conflict}`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
await this.api.request(`/api/devices/${deviceId}/rack-units`, {
method: 'PUT',
body: JSON.stringify({ rackUnits: newRackUnits })
});
// Update device rendering on canvas
const deviceShape = this.deviceManager.getDeviceShape(deviceId);
const deviceData = this.deviceManager.getDeviceData(deviceId);
if (deviceShape && deviceData) {
deviceData.rack_units = newRackUnits;
// Update device height
const newHeight = (this.deviceManager.deviceHeight * newRackUnits) + (this.deviceManager.deviceSpacing * (newRackUnits - 1));
const rect = deviceShape.findOne('Rect');
const text = deviceShape.findOne('.device-text');
if (rect) {
rect.height(newHeight);
}
if (text) {
text.height(newHeight);
}
// Reposition device since height changed using helper method
const rackData = this.rackManager.getRackData(rackId);
const rackHeight = rackData?.height || this.rackManager.rackHeight;
const newY = this.deviceManager.calculateDeviceY(position, newRackUnits, rackHeight);
deviceShape.position({ x: 10, y: newY });
this.deviceManager.layer.batchDraw();
}
// Notify canvas that data changed
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
} else if (field === 'type_name') {
// Find device type by name
const deviceType = deviceTypes.find(dt => dt.name === newValue);
if (deviceType) {
// Note: We would need an API endpoint to update device type
// For now, just show a message
alert('Changing device type requires updating the device_type_id in the database. This feature needs backend support.');
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
}
}
} catch (err) {
console.error('Failed to update device:', err);
alert('Failed to update device: ' + err.message);
// Revert the change
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
}
}
// ===== CONNECTIONS TABLE =====
async showConnectionsTable() {
const connections = await this.api.getConnections();
const devices = await this.api.getDevices();
// Enrich connection data with device names
const enrichedConnections = connections.map(conn => {
const sourceDevice = devices.find(d => d.id === conn.source_device_id);
const targetDevice = devices.find(d => d.id === conn.target_device_id);
return {
...conn,
source_device_name: sourceDevice ? sourceDevice.name : 'Unknown',
target_device_name: targetDevice ? targetDevice.name : 'Unknown',
source_device_type: sourceDevice ? sourceDevice.type_name : '',
target_device_type: targetDevice ? targetDevice.type_name : ''
};
});
const columnDefs = [
{
headerName: 'Source Device',
field: 'source_device_name',
editable: true,
sortable: true,
filter: true,
checkboxSelection: true,
headerCheckboxSelection: true,
cellEditor: 'agSelectCellEditor',
cellEditorParams: {
values: devices.map(d => d.name)
}
},
{
headerName: 'Source Port',
field: 'source_port',
editable: true,
sortable: true,
filter: 'agNumberColumnFilter',
valueFormatter: params => `Port ${params.value}`
},
{
headerName: 'Dest Device',
field: 'target_device_name',
editable: true,
sortable: true,
filter: true,
cellEditor: 'agSelectCellEditor',
cellEditorParams: {
values: devices.map(d => d.name)
}
},
{
headerName: 'Dest Port',
field: 'target_port',
editable: true,
sortable: true,
filter: 'agNumberColumnFilter',
valueFormatter: params => `Port ${params.value}`
},
{
headerName: 'Status',
field: 'status',
editable: false,
sortable: true,
valueGetter: params => {
// Validate connection
const sourceDevice = devices.find(d => d.id === params.data.source_device_id);
const targetDevice = devices.find(d => d.id === params.data.target_device_id);
if (!sourceDevice || !targetDevice) return 'Invalid';
if (params.data.source_port >= sourceDevice.ports_count) return 'Invalid Port';
if (params.data.target_port >= targetDevice.ports_count) return 'Invalid Port';
return 'Valid';
},
cellStyle: params => {
if (params.value === 'Valid') {
return { color: '#4CAF50', fontWeight: 'bold' };
} else {
return { color: '#d32f2f', fontWeight: 'bold' };
}
}
}
];
const gridOptions = {
columnDefs: columnDefs,
rowData: enrichedConnections,
rowSelection: 'multiple',
animateRows: true,
enableCellTextSelection: true,
defaultColDef: {
flex: 1,
minWidth: 120,
resizable: true
},
onCellValueChanged: (params) => this.onConnectionCellValueChanged(params, devices),
onSelectionChanged: () => this.updateToolbarButtons(),
overlayNoRowsTemplate: '<span style="padding: 10px; color: #999;">No connections found</span>'
};
this.gridApi = agGrid.createGrid(this.tableContainer, gridOptions);
}
async onConnectionCellValueChanged(params, devices) {
const connectionId = params.data.id;
const field = params.colDef.field;
const newValue = params.newValue;
try {
let sourceDeviceId = params.data.source_device_id;
let sourcePort = params.data.source_port;
let targetDeviceId = params.data.target_device_id;
let targetPort = params.data.target_port;
// Update the field that was changed
if (field === 'source_device_name') {
const device = devices.find(d => d.name === newValue);
if (!device) {
alert(`Device "${newValue}" not found.`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
sourceDeviceId = device.id;
params.data.source_device_id = device.id;
params.data.source_device_type = device.type_name;
} else if (field === 'source_port') {
sourcePort = parseInt(newValue);
const sourceDevice = devices.find(d => d.id === sourceDeviceId);
if (sourcePort < 0 || sourcePort >= sourceDevice.ports_count) {
alert(`Invalid source port. Device "${sourceDevice.name}" has ports 0-${sourceDevice.ports_count - 1}.`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
// Check if port is already in use by another connection
const connections = await this.api.getConnections();
const portInUse = connections.some(c =>
c.id !== connectionId &&
((c.source_device_id === sourceDeviceId && c.source_port === sourcePort) ||
(c.target_device_id === sourceDeviceId && c.target_port === sourcePort))
);
if (portInUse) {
alert(`Port ${sourcePort} is already in use on device "${sourceDevice.name}".`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
} else if (field === 'target_device_name') {
const device = devices.find(d => d.name === newValue);
if (!device) {
alert(`Device "${newValue}" not found.`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
targetDeviceId = device.id;
params.data.target_device_id = device.id;
params.data.target_device_type = device.type_name;
} else if (field === 'target_port') {
targetPort = parseInt(newValue);
const targetDevice = devices.find(d => d.id === targetDeviceId);
if (targetPort < 0 || targetPort >= targetDevice.ports_count) {
alert(`Invalid target port. Device "${targetDevice.name}" has ports 0-${targetDevice.ports_count - 1}.`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
// Check if port is already in use by another connection
const connections = await this.api.getConnections();
const portInUse = connections.some(c =>
c.id !== connectionId &&
((c.source_device_id === targetDeviceId && c.source_port === targetPort) ||
(c.target_device_id === targetDeviceId && c.target_port === targetPort))
);
if (portInUse) {
alert(`Port ${targetPort} is already in use on device "${targetDevice.name}".`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
}
// Update connection in database
await this.api.request(`/api/connections/${connectionId}`, {
method: 'PUT',
body: JSON.stringify({
sourceDeviceId,
sourcePort,
targetDeviceId,
targetPort
})
});
// Update canvas - delete and recreate the connection
await this.connectionManager.deleteConnection(connectionId);
const newConnection = await this.api.getConnections();
const updatedConnection = newConnection.find(c => c.id === connectionId);
if (updatedConnection) {
this.connectionManager.createConnectionShape(updatedConnection);
this.connectionManager.layer.batchDraw();
}
// Refresh table to show updated data
this.refreshTable();
} catch (err) {
console.error('Failed to update connection:', err);
alert('Failed to update connection: ' + err.message);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
}
}
// ===== REFRESH & SYNC =====
async refreshTable() {
if (!this.currentTable) return;
const tableType = `${this.currentTable}-table`;
await this.showTable(tableType);
}
async syncFromCanvas() {
// Called when canvas data changes - refresh the table
if (this.isTableVisible()) {
await this.refreshTable();
}
}
// ===== CRUD OPERATIONS =====
async addRow() {
try {
if (this.currentTable === 'racks') {
await this.rackManager.addRack();
await this.refreshTable();
} else if (this.currentTable === 'devices') {
alert('To add a device, please use the canvas view (right-click on a rack).');
} else if (this.currentTable === 'connections') {
alert('To add a connection, please use the canvas view (right-click on a device).');
}
} catch (err) {
console.error('Failed to add row:', err);
alert('Failed to add row: ' + err.message);
}
}
async deleteSelectedRows() {
const selectedRows = this.gridApi.getSelectedRows();
if (selectedRows.length === 0) {
alert('Please select rows to delete.');
return;
}
if (!confirm(`Delete ${selectedRows.length} row(s)?`)) {
return;
}
try {
// Delete all rows with suppressed events to avoid race conditions
for (const row of selectedRows) {
if (this.currentTable === 'racks') {
const rackShape = this.rackManager.getRackShape(row.id);
await this.rackManager.deleteRack(row.id, rackShape, true); // suppress event
} else if (this.currentTable === 'devices') {
const deviceShape = this.deviceManager.getDeviceShape(row.id);
await this.deviceManager.deleteDevice(row.id, deviceShape, true); // suppress event
} else if (this.currentTable === 'connections') {
const conn = this.connectionManager.connections.get(row.id);
const line = conn ? conn.shape : null;
const handles = conn ? conn.handles : null;
await this.connectionManager.deleteConnection(row.id, line, handles, true); // suppress event
}
}
// Dispatch single event after all deletions complete
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
await this.refreshTable();
} catch (err) {
console.error('Failed to delete rows:', err);
alert('Failed to delete rows: ' + err.message);
}
}
updateToolbarButtons() {
const deleteBtn = document.getElementById('deleteTableRowBtn');
if (deleteBtn && this.gridApi) {
const selectedRows = this.gridApi.getSelectedRows();
deleteBtn.disabled = selectedRows.length === 0;
}
}
}