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

806 lines
27 KiB
JavaScript

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;
}
}
}