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

1839
public/js/app.js Normal file

File diff suppressed because it is too large Load Diff

245
public/js/config.js Normal file
View File

@@ -0,0 +1,245 @@
/**
* Frontend Configuration
* Central configuration for all frontend constants and settings
*/
export const CONFIG = {
// Rack Configuration
RACK: {
WIDTH: 520,
HEIGHT: 1510,
SLOTS: 42,
NAME_PREFIX_DEFAULT: 'RACK',
// Grid snapping
GRID: {
HORIZONTAL: 600,
VERTICAL: 1610
},
// Visual styling
FILL_COLOR: '#f8f8f8',
STROKE_COLOR: '#333',
STROKE_WIDTH: 2,
// Name label
NAME_OFFSET_Y: -25,
NAME_FONT_SIZE: 16,
NAME_FONT_FAMILY: 'Arial',
NAME_COLOR: '#333'
},
// Device Configuration
DEVICE: {
HEIGHT: 32,
SPACING: 2,
// Width varies by view
WIDTH: {
PHYSICAL: 500,
LOGICAL: 120
},
// Margins within rack
MARGIN: {
TOP: 10,
RIGHT: 10,
BOTTOM: 10,
LEFT: 10
},
// Visual styling
STROKE_WIDTH: 1,
CORNER_RADIUS: 4,
// Text
FONT_SIZE: 13,
FONT_FAMILY: 'Arial',
TEXT_COLOR: '#fff',
// Form factor
MIN_RACK_UNITS: 1,
MAX_RACK_UNITS: 42
},
// Connection Configuration
CONNECTION: {
STROKE_WIDTH: 2,
STROKE_COLOR: '#4A90E2',
STROKE_COLOR_HOVER: '#357ABD',
STROKE_COLOR_SELECTED: '#FF6B6B',
SELECTED_WIDTH: 3,
// Waypoint handles
HANDLE_RADIUS: 6,
HANDLE_FILL: '#4A90E2',
HANDLE_STROKE: '#fff',
HANDLE_STROKE_WIDTH: 2,
// Hit detection
HIT_STROKE_WIDTH: 10
},
// Canvas/Stage Configuration
CANVAS: {
// Initial position offset
INITIAL_OFFSET: { x: 50, y: 50 },
// Zoom limits
MIN_SCALE: 0.1,
MAX_SCALE: 3.0,
// Zoom step
ZOOM_STEP: 0.1,
// Default scale
DEFAULT_SCALE: 1.0,
// Pan cursor
PAN_CURSOR: 'grabbing'
},
// View Configuration
VIEWS: {
CANVAS: {
PHYSICAL: 'physical',
LOGICAL: 'logical'
},
TABLE: {
RACKS: 'racks',
DEVICES: 'devices',
CONNECTIONS: 'connections'
}
},
// UI Configuration
UI: {
// Toolbar height
TOOLBAR_HEIGHT: 50,
// Table pane
TABLE_PANE: {
MIN_HEIGHT: 150,
DEFAULT_HEIGHT: 300,
MAX_HEIGHT_RATIO: 0.7 // 70% of viewport
},
// Resize handle
RESIZE_HANDLE_HEIGHT: 6,
// Context menu
CONTEXT_MENU: {
MIN_WIDTH: 200,
ANIMATION_DELAY: 100
},
// Modals
MODAL: {
MAX_WIDTH: 600,
MAX_WIDTH_LARGE: 800,
MAX_HEIGHT_RATIO: 0.8
}
},
// Animation Configuration
ANIMATION: {
DURATION: 200, // ms
EASING: 'ease-in-out'
},
// Colors (Theme)
COLORS: {
PRIMARY: '#4A90E2',
PRIMARY_DARK: '#357ABD',
SECONDARY: '#f5f5f5',
SUCCESS: '#4CAF50',
DANGER: '#d32f2f',
WARNING: '#ff9800',
// Grays
GRAY_100: '#f9f9f9',
GRAY_200: '#f5f5f5',
GRAY_300: '#e0e0e0',
GRAY_400: '#d0d0d0',
GRAY_500: '#999',
GRAY_600: '#666',
GRAY_700: '#333',
// Backgrounds
BG_CANVAS: '#f5f5f5',
BG_RACK: '#f8f8f8',
BG_MODAL: 'rgba(0, 0, 0, 0.5)',
// Selection
SELECTION_BG: '#e3f2fd',
HOVER_BG: '#f0f7ff'
},
// Keyboard Shortcuts
KEYS: {
DELETE: ['Delete', 'Backspace'],
ESCAPE: 'Escape',
CTRL: 'Control',
// Future shortcuts
UNDO: 'z', // Ctrl+Z
REDO: 'y', // Ctrl+Y
SAVE: 's', // Ctrl+S
SELECT_ALL: 'a', // Ctrl+A
COPY: 'c', // Ctrl+C
PASTE: 'v', // Ctrl+V
FIT_VIEW: 'f' // F key
},
// API Configuration
API: {
BASE_URL: '', // Same origin
TIMEOUT: 30000, // 30 seconds
RETRY_ATTEMPTS: 3,
RETRY_DELAY: 1000 // 1 second
},
// Local Storage Keys
STORAGE: {
CURRENT_PROJECT_ID: 'currentProjectId',
CURRENT_CANVAS_VIEW: 'currentCanvasView',
GRID_SIZE: 'gridSize',
GRID_VERTICAL: 'gridVertical',
THEME: 'theme', // 'light' or 'dark'
ZOOM_LEVEL: 'zoomLevel'
},
// Export/Import
EXPORT: {
VERSION: '1.0',
JSON_INDENT: 2,
EXCEL_FORMATS: {
DATE: 'yyyy-mm-dd',
DATETIME: 'yyyy-mm-dd hh:mm:ss'
}
},
// Validation
VALIDATION: {
PROJECT: {
NAME_MIN: 1,
NAME_MAX: 100,
DESC_MAX: 500
},
RACK: {
NAME_MIN: 1,
NAME_MAX: 50,
COUNT_MIN: 1,
COUNT_MAX: 20
},
DEVICE: {
NAME_MIN: 1,
NAME_MAX: 50
}
},
// Debug
DEBUG: {
ENABLED: false, // Set to true for development
LOG_API_CALLS: false,
LOG_STATE_CHANGES: false,
SHOW_FPS: false,
KONVA_WARNINGS: true
}
};
// Freeze config to prevent modifications
Object.freeze(CONFIG);
Object.freeze(CONFIG.RACK);
Object.freeze(CONFIG.DEVICE);
Object.freeze(CONFIG.CONNECTION);
Object.freeze(CONFIG.CANVAS);
Object.freeze(CONFIG.VIEWS);
Object.freeze(CONFIG.UI);
Object.freeze(CONFIG.COLORS);
Object.freeze(CONFIG.KEYS);
Object.freeze(CONFIG.API);
Object.freeze(CONFIG.STORAGE);
Object.freeze(CONFIG.EXPORT);
Object.freeze(CONFIG.VALIDATION);
Object.freeze(CONFIG.DEBUG);
export default CONFIG;

217
public/js/lib/api.js Normal file
View File

@@ -0,0 +1,217 @@
/**
* API Client
* Centralized HTTP client for backend communication
*/
import CONFIG from '../config.js';
class APIClient {
constructor() {
this.baseURL = CONFIG.API.BASE_URL;
this.timeout = CONFIG.API.TIMEOUT;
this.currentProjectId = 1; // Default project
}
/**
* Set current project ID
*/
setProjectId(projectId) {
this.currentProjectId = projectId;
}
/**
* Generic HTTP request with error handling
* @private
*/
async request(url, options = {}) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(this.baseURL + url, {
headers: {
'Content-Type': 'application/json',
...options.headers
},
signal: controller.signal,
...options
});
clearTimeout(timeoutId);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (err) {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
throw new Error('Request timeout - server is not responding');
}
throw err;
}
}
// ==================== PROJECTS ====================
async getProjects() {
return this.request('/api/projects');
}
async getProject(id) {
return this.request(`/api/projects/${id}`);
}
async createProject(name, description = '') {
return this.request('/api/projects', {
method: 'POST',
body: JSON.stringify({ name, description })
});
}
async updateProject(id, name, description) {
return this.request(`/api/projects/${id}`, {
method: 'PUT',
body: JSON.stringify({ name, description })
});
}
async deleteProject(id) {
return this.request(`/api/projects/${id}`, { method: 'DELETE' });
}
// ==================== RACKS ====================
async getRacks(projectId = this.currentProjectId) {
return this.request(`/api/racks?projectId=${projectId}`);
}
async getNextRackName(prefix = 'RACK', projectId = this.currentProjectId) {
const data = await this.request(`/api/racks/next-name?projectId=${projectId}&prefix=${prefix}`);
return data.name;
}
async createRack(name, x, y, projectId = this.currentProjectId) {
return this.request('/api/racks', {
method: 'POST',
body: JSON.stringify({ projectId, name, x, y })
});
}
async updateRackPosition(id, x, y) {
return this.request(`/api/racks/${id}/position`, {
method: 'PUT',
body: JSON.stringify({ x, y })
});
}
async updateRackName(id, name) {
return this.request(`/api/racks/${id}/name`, {
method: 'PUT',
body: JSON.stringify({ name })
});
}
async deleteRack(id) {
return this.request(`/api/racks/${id}`, { method: 'DELETE' });
}
// ==================== DEVICE TYPES ====================
async getDeviceTypes() {
return this.request('/api/devices/types');
}
// ==================== DEVICES ====================
async getDevices(projectId = this.currentProjectId) {
return this.request(`/api/devices?projectId=${projectId}`);
}
async createDevice(deviceTypeId, rackId, position, name) {
return this.request('/api/devices', {
method: 'POST',
body: JSON.stringify({ deviceTypeId, rackId, position, name })
});
}
async updateDeviceRack(id, rackId, position) {
return this.request(`/api/devices/${id}/rack`, {
method: 'PUT',
body: JSON.stringify({ rackId, position })
});
}
async updateDeviceLogicalPosition(id, x, y) {
return this.request(`/api/devices/${id}/logical-position`, {
method: 'PUT',
body: JSON.stringify({ x, y })
});
}
async updateDeviceName(id, name) {
return this.request(`/api/devices/${id}/name`, {
method: 'PUT',
body: JSON.stringify({ name })
});
}
async updateDeviceRackUnits(id, rackUnits) {
return this.request(`/api/devices/${id}/rack-units`, {
method: 'PUT',
body: JSON.stringify({ rackUnits })
});
}
async getUsedPorts(deviceId) {
return this.request(`/api/devices/${deviceId}/used-ports`);
}
async deleteDevice(id) {
return this.request(`/api/devices/${id}`, { method: 'DELETE' });
}
// ==================== CONNECTIONS ====================
async getConnections(projectId = this.currentProjectId) {
return this.request(`/api/connections?projectId=${projectId}`);
}
async createConnection(sourceDeviceId, sourcePort, targetDeviceId, targetPort) {
return this.request('/api/connections', {
method: 'POST',
body: JSON.stringify({ sourceDeviceId, sourcePort, targetDeviceId, targetPort })
});
}
async updateConnection(id, sourceDeviceId, sourcePort, targetDeviceId, targetPort) {
return this.request(`/api/connections/${id}`, {
method: 'PUT',
body: JSON.stringify({ sourceDeviceId, sourcePort, targetDeviceId, targetPort })
});
}
async updateConnectionWaypoints(id, waypoints, view = null) {
return this.request(`/api/connections/${id}/waypoints`, {
method: 'PUT',
body: JSON.stringify({ waypoints, view })
});
}
async deleteConnection(id) {
return this.request(`/api/connections/${id}`, { method: 'DELETE' });
}
// ==================== HEALTH CHECK ====================
async healthCheck() {
return this.request('/api/health');
}
}
// Export singleton instance
export default new APIClient();

451
public/js/lib/ui.js Normal file
View File

@@ -0,0 +1,451 @@
/**
* UI Utilities
* Reusable UI components and helpers
*/
import CONFIG from '../config.js';
/**
* Modal Manager
* Simplifies modal open/close operations
*/
export class ModalManager {
/**
* Show a modal
* @param {string} modalId - ID of modal element
* @param {Function} onOpen - Optional callback when modal opens
*/
static show(modalId, onOpen = null) {
const modal = document.getElementById(modalId);
if (!modal) {
console.error(`Modal #${modalId} not found`);
return;
}
modal.classList.remove('hidden');
if (onOpen) {
onOpen(modal);
}
}
/**
* Hide a modal
* @param {string} modalId - ID of modal element
* @param {Function} onClose - Optional callback when modal closes
*/
static hide(modalId, onClose = null) {
const modal = document.getElementById(modalId);
if (!modal) {
console.error(`Modal #${modalId} not found`);
return;
}
modal.classList.add('hidden');
if (onClose) {
onClose(modal);
}
}
/**
* Setup modal with close handlers
* @param {string} modalId - ID of modal element
* @param {string} closeButtonId - ID of close button
* @param {Function} onClose - Optional cleanup callback
* @returns {Function} Cleanup function
*/
static setup(modalId, closeButtonId, onClose = null) {
const modal = document.getElementById(modalId);
const closeBtn = document.getElementById(closeButtonId);
if (!modal || !closeBtn) {
console.error(`Modal #${modalId} or close button #${closeButtonId} not found`);
return () => {};
}
const handleClose = () => {
this.hide(modalId, onClose);
};
// Close on button click
closeBtn.addEventListener('click', handleClose);
// Close on outside click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
handleClose();
}
});
// Close on ESC key
const handleEscape = (e) => {
if (e.key === 'Escape') {
handleClose();
}
};
document.addEventListener('keydown', handleEscape);
// Return cleanup function
return () => {
closeBtn.removeEventListener('click', handleClose);
document.removeEventListener('keydown', handleEscape);
};
}
}
/**
* Toast Notification System
* Better than window.alert()
*/
export class Toast {
static container = null;
/**
* Initialize toast container
*/
static init() {
if (this.container) return;
this.container = document.createElement('div');
this.container.id = 'toast-container';
this.container.style.cssText = `
position: fixed;
top: ${CONFIG.UI.TOOLBAR_HEIGHT + 20}px;
right: 20px;
z-index: ${CONFIG.COLORS.PRIMARY};
display: flex;
flex-direction: column;
gap: 10px;
pointer-events: none;
`;
document.body.appendChild(this.container);
}
/**
* Show a toast notification
* @param {string} message - Message to display
* @param {string} type - 'success', 'error', 'warning', 'info'
* @param {number} duration - Display duration in ms (0 = persist)
*/
static show(message, type = 'info', duration = 3000) {
this.init();
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
const colors = {
success: CONFIG.COLORS.SUCCESS,
error: CONFIG.COLORS.DANGER,
warning: CONFIG.COLORS.WARNING,
info: CONFIG.COLORS.PRIMARY
};
toast.style.cssText = `
background-color: ${colors[type] || colors.info};
color: white;
padding: 12px 20px;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-width: 400px;
word-wrap: break-word;
pointer-events: auto;
cursor: pointer;
animation: slideIn 0.3s ease;
font-size: 14px;
`;
toast.textContent = message;
// Click to dismiss
toast.addEventListener('click', () => {
this.remove(toast);
});
this.container.appendChild(toast);
// Auto-remove after duration
if (duration > 0) {
setTimeout(() => {
this.remove(toast);
}, duration);
}
return toast;
}
/**
* Remove a toast
*/
static remove(toast) {
toast.style.animation = 'slideOut 0.3s ease';
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 300);
}
// Convenience methods
static success(message, duration) {
return this.show(message, 'success', duration);
}
static error(message, duration) {
return this.show(message, 'error', duration);
}
static warning(message, duration) {
return this.show(message, 'warning', duration);
}
static info(message, duration) {
return this.show(message, 'info', duration);
}
}
/**
* Loading Indicator
*/
export class LoadingIndicator {
static overlay = null;
static show(message = 'Loading...') {
if (this.overlay) return;
this.overlay = document.createElement('div');
this.overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
`;
const spinner = document.createElement('div');
spinner.style.cssText = `
background-color: white;
padding: 30px 40px;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
text-align: center;
font-size: 16px;
color: #333;
`;
spinner.innerHTML = `
<div style="margin-bottom: 15px;">
<div class="spinner"></div>
</div>
${message}
`;
this.overlay.appendChild(spinner);
document.body.appendChild(this.overlay);
}
static hide() {
if (this.overlay && this.overlay.parentNode) {
this.overlay.parentNode.removeChild(this.overlay);
this.overlay = null;
}
}
}
/**
* Confirmation Dialog
* Better than window.confirm()
*/
export class Confirm {
/**
* Show confirmation dialog
* @param {string} message - Confirmation message
* @param {Object} options - Options {title, confirmText, cancelText, onConfirm, onCancel}
* @returns {Promise<boolean>} Resolves to true if confirmed
*/
static async show(message, options = {}) {
const {
title = 'Confirm',
confirmText = 'Confirm',
cancelText = 'Cancel',
danger = false
} = options;
return new Promise((resolve) => {
// Create modal
const modal = document.createElement('div');
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content" style="max-width: 400px;">
<div class="modal-header">
<h3>${title}</h3>
</div>
<div class="modal-body">
<p style="margin: 0; font-size: 14px; color: #666;">${message}</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary cancel-btn">${cancelText}</button>
<button class="btn ${danger ? 'btn-danger' : 'btn-primary'} confirm-btn">${confirmText}</button>
</div>
</div>
`;
document.body.appendChild(modal);
const confirmBtn = modal.querySelector('.confirm-btn');
const cancelBtn = modal.querySelector('.cancel-btn');
const cleanup = () => {
if (modal.parentNode) {
modal.parentNode.removeChild(modal);
}
};
confirmBtn.addEventListener('click', () => {
cleanup();
resolve(true);
});
cancelBtn.addEventListener('click', () => {
cleanup();
resolve(false);
});
modal.addEventListener('click', (e) => {
if (e.target === modal) {
cleanup();
resolve(false);
}
});
});
}
}
/**
* Prompt Dialog
* Better than window.prompt()
*/
export class Prompt {
/**
* Show prompt dialog
* @param {string} message - Prompt message
* @param {Object} options - Options {title, defaultValue, placeholder, okText, cancelText}
* @returns {Promise<string|null>} Resolves to input value or null if cancelled
*/
static async show(message, options = {}) {
const {
title = 'Input Required',
defaultValue = '',
placeholder = '',
okText = 'OK',
cancelText = 'Cancel'
} = options;
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content" style="max-width: 400px;">
<div class="modal-header">
<h3>${title}</h3>
</div>
<div class="modal-body">
<label style="display: block; margin-bottom: 8px; font-size: 14px; color: #666;">${message}</label>
<input type="text" class="form-input prompt-input" value="${defaultValue}" placeholder="${placeholder}" />
</div>
<div class="modal-footer">
<button class="btn btn-secondary cancel-btn">${cancelText}</button>
<button class="btn btn-primary ok-btn">${okText}</button>
</div>
</div>
`;
document.body.appendChild(modal);
const input = modal.querySelector('.prompt-input');
const okBtn = modal.querySelector('.ok-btn');
const cancelBtn = modal.querySelector('.cancel-btn');
input.focus();
input.select();
const cleanup = () => {
if (modal.parentNode) {
modal.parentNode.removeChild(modal);
}
};
const submit = () => {
const value = input.value.trim();
cleanup();
resolve(value || null);
};
okBtn.addEventListener('click', submit);
cancelBtn.addEventListener('click', () => {
cleanup();
resolve(null);
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
submit();
} else if (e.key === 'Escape') {
cleanup();
resolve(null);
}
});
});
}
}
/**
* Add CSS animations for toasts
*/
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid ${CONFIG.COLORS.PRIMARY};
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
// Initialize toast system
Toast.init();

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