First commit
This commit is contained in:
1839
public/js/app.js
Normal file
1839
public/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
245
public/js/config.js
Normal file
245
public/js/config.js
Normal 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
217
public/js/lib/api.js
Normal 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
451
public/js/lib/ui.js
Normal 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();
|
||||
1006
public/js/managers/connection-manager.js
Normal file
1006
public/js/managers/connection-manager.js
Normal file
File diff suppressed because it is too large
Load Diff
610
public/js/managers/device-manager.js
Normal file
610
public/js/managers/device-manager.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
487
public/js/managers/rack-manager.js
Normal file
487
public/js/managers/rack-manager.js
Normal 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;
|
||||
}
|
||||
}
|
||||
805
public/js/managers/table-manager.js
Normal file
805
public/js/managers/table-manager.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user