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

View File

@@ -0,0 +1,741 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
overflow: hidden;
background-color: #f5f5f5;
margin: 0;
padding: 0;
height: 100vh;
display: flex;
flex-direction: column;
}
.toolbar {
background-color: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
padding: 10px 20px;
display: flex;
align-items: center;
gap: 15px;
height: 50px;
flex-shrink: 0;
}
.toolbar-spacer {
flex: 1;
}
.toolbar-info {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: #666;
}
.toolbar-info .separator {
color: #ccc;
}
.project-selector {
display: flex;
align-items: center;
gap: 8px;
margin-right: 20px;
}
.project-selector label {
font-size: 13px;
font-weight: 500;
color: #666;
}
.view-switcher-group {
display: flex;
gap: 15px;
align-items: center;
}
.view-switcher {
display: flex;
gap: 0;
border: 1px solid #e0e0e0;
border-radius: 5px;
overflow: hidden;
background-color: white;
}
.btn-view {
padding: 6px 16px;
font-size: 13px;
font-weight: 500;
background-color: white;
color: #666;
border: none;
border-right: 1px solid #e0e0e0;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-view:last-child {
border-right: none;
}
.btn-view:hover:not(.active) {
background-color: #f5f5f5;
color: #333;
}
.btn-view.active {
background-color: #4A90E2;
color: white;
font-weight: 600;
}
.form-select {
padding: 6px 12px;
border: 1px solid #e0e0e0;
border-radius: 5px;
font-size: 13px;
background-color: white;
cursor: pointer;
min-width: 200px;
}
.form-select:focus {
outline: none;
border-color: #4A90E2;
}
.btn-sm {
padding: 6px 12px;
font-size: 13px;
font-weight: 500;
background-color: #fff;
color: #333;
border: 1px solid #e0e0e0;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-sm:hover {
background-color: #f5f5f5;
border-color: #4A90E2;
}
.zoom-input {
width: 60px;
padding: 6px 8px;
font-size: 13px;
font-weight: 500;
background-color: #fff;
color: #333;
border: 1px solid #e0e0e0;
border-radius: 4px;
text-align: center;
transition: all 0.2s ease;
}
.zoom-input:focus {
outline: none;
border-color: #4A90E2;
background-color: #f0f7ff;
}
.zoom-input:hover {
border-color: #4A90E2;
}
.zoom-unit {
font-size: 13px;
font-weight: 500;
color: #666;
margin-left: -8px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 5px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary {
background-color: #4A90E2;
color: white;
}
.btn-primary:hover {
background-color: #357ABD;
box-shadow: 0 2px 8px rgba(74, 144, 226, 0.3);
}
.btn-secondary {
background-color: #f5f5f5;
color: #333;
}
.btn-secondary:hover {
background-color: #e0e0e0;
}
/* Main Content Area with Split Panes */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.canvas-pane {
flex: 1;
overflow: hidden;
position: relative;
min-height: 200px;
}
#canvasWrapper {
width: 100%;
height: 100%;
}
.resize-handle {
height: 6px;
background-color: #e0e0e0;
cursor: ns-resize;
position: relative;
flex-shrink: 0;
transition: background-color 0.2s ease;
}
.resize-handle:hover {
background-color: #4A90E2;
}
.resize-handle.hidden {
display: none;
}
.table-pane {
display: flex;
flex-direction: column;
background-color: #fff;
overflow: hidden;
min-height: 150px;
height: 300px;
}
.table-pane.hidden {
display: none;
}
/* Context Menu */
.context-menu {
position: fixed;
background-color: white;
border: 1px solid #e0e0e0;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 200px;
z-index: 1000;
overflow: hidden;
}
.context-menu.hidden {
display: none;
}
.context-menu ul {
list-style: none;
margin: 0;
padding: 5px 0;
}
.context-menu li {
padding: 10px 20px;
cursor: pointer;
font-size: 14px;
color: #333;
transition: background-color 0.15s ease;
}
.context-menu li:hover {
background-color: #f5f5f5;
}
.context-menu li.divider {
height: 1px;
background-color: #e0e0e0;
margin: 5px 0;
padding: 0;
cursor: default;
}
.context-menu li.divider:hover {
background-color: #e0e0e0;
}
.context-menu li.menu-header {
padding: 8px 20px 4px 20px;
font-size: 12px;
font-weight: 600;
color: #999;
text-transform: uppercase;
cursor: default;
pointer-events: none;
}
.context-menu li.menu-header:hover {
background-color: transparent;
}
.context-menu li.spacing-control {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
cursor: default;
}
.context-menu li.spacing-control:hover {
background-color: #f5f5f5;
}
.context-menu .spacing-label {
font-size: 13px;
color: #333;
}
.context-menu .spacing-buttons {
display: flex;
gap: 4px;
}
.context-menu .spacing-btn {
width: 24px;
height: 24px;
padding: 0;
border: 1px solid #e0e0e0;
border-radius: 3px;
background-color: #fff;
color: #333;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.context-menu .spacing-btn:hover {
background-color: #4A90E2;
color: white;
border-color: #4A90E2;
}
.context-menu .submenu-indicator {
float: right;
margin-left: 10px;
}
/* Modal */
.modal {
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: 2000;
}
.modal.hidden {
display: none;
}
.modal-content {
background-color: white;
border-radius: 8px;
min-width: 400px;
max-width: 600px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.modal-content.modal-large {
max-width: 800px;
min-width: 600px;
}
.modal-header {
padding: 20px;
border-bottom: 1px solid #e0e0e0;
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-header h3 {
font-size: 18px;
color: #333;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
color: #999;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s ease;
}
.modal-close:hover {
background-color: #f5f5f5;
color: #333;
}
.modal-body {
padding: 20px;
overflow-y: auto;
}
.port-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
gap: 10px;
}
.port-button {
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 6px;
background-color: #fff;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s ease;
text-align: center;
}
.port-button:hover:not(.used) {
border-color: #4A90E2;
background-color: #f0f7ff;
transform: translateY(-2px);
}
.port-button.used {
background-color: #f5f5f5;
color: #999;
cursor: not-allowed;
border-color: #d0d0d0;
}
.port-button.selected {
background-color: #4A90E2;
color: white;
border-color: #4A90E2;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c0c0c0;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a0a0a0;
}
/* Form Elements */
.form-section {
margin-bottom: 25px;
padding: 15px;
background-color: #f9f9f9;
border-radius: 6px;
}
.form-section h4 {
margin: 0 0 15px 0;
font-size: 15px;
font-weight: 600;
color: #333;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-bottom: 15px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 500;
color: #333;
}
.form-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #e0e0e0;
border-radius: 5px;
font-size: 14px;
transition: border-color 0.2s ease;
font-family: inherit;
}
.form-input:focus {
outline: none;
border-color: #4A90E2;
}
textarea.form-input {
resize: vertical;
min-height: 80px;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.radio-group label {
display: flex;
align-items: center;
font-weight: normal;
cursor: pointer;
}
.radio-group input[type="radio"] {
margin-right: 8px;
cursor: pointer;
}
.modal-footer {
margin-top: 20px;
display: flex;
gap: 10px;
justify-content: flex-end;
}
/* Device Type Grid */
.device-type-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
margin-top: 10px;
}
.device-type-card {
background-color: #f9f9f9;
border: 2px solid #e0e0e0;
border-radius: 6px;
padding: 15px;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
}
.device-type-card:hover {
background-color: #f0f0f0;
border-color: #4A90E2;
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(74, 144, 226, 0.2);
}
.device-type-card .device-type-name {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 6px;
}
.device-type-card .device-type-ports {
font-size: 12px;
color: #666;
}
/* Preview Box */
.preview-box {
margin-top: 15px;
padding: 12px;
background-color: #fff;
border: 1px solid #e0e0e0;
border-radius: 5px;
}
.preview-box strong {
display: block;
margin-bottom: 8px;
font-size: 13px;
color: #666;
}
.preview-names {
font-family: 'Courier New', monospace;
font-size: 14px;
color: #4A90E2;
font-weight: 600;
}
/* Projects List */
.projects-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.project-card {
background-color: #f9f9f9;
border: 2px solid #e0e0e0;
border-radius: 6px;
padding: 15px;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s ease;
}
.project-card:hover {
background-color: #f5f5f5;
border-color: #d0d0d0;
}
.project-card.active {
background-color: #e3f2fd;
border-color: #4A90E2;
}
.project-info {
flex: 1;
}
.project-name {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.project-description {
font-size: 13px;
color: #666;
margin-bottom: 6px;
}
.project-meta {
font-size: 12px;
color: #999;
}
.project-actions {
display: flex;
gap: 8px;
}
.btn-icon {
padding: 8px 12px;
border: none;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
background-color: #fff;
color: #666;
border: 1px solid #e0e0e0;
}
.btn-icon:hover {
background-color: #f5f5f5;
color: #333;
}
.btn-danger {
background-color: #fff;
color: #d32f2f;
border-color: #d32f2f;
}
.btn-danger:hover {
background-color: #d32f2f;
color: white;
}
.btn-success {
background-color: #4CAF50;
color: white;
border-color: #4CAF50;
}
.btn-success:hover {
background-color: #45a049;
}
.table-toolbar {
padding: 10px 15px;
border-bottom: 1px solid #e0e0e0;
background-color: #f9f9f9;
display: flex;
gap: 10px;
align-items: center;
flex-shrink: 0;
}
#tableContent {
flex: 1;
width: 100%;
overflow: hidden;
}
/* ag-Grid customization */
.ag-theme-alpine {
--ag-header-height: 40px;
--ag-row-height: 35px;
--ag-font-size: 13px;
--ag-header-foreground-color: #333;
--ag-header-background-color: #f5f5f5;
--ag-odd-row-background-color: #fafafa;
--ag-row-hover-color: #f0f7ff;
--ag-selected-row-background-color: #e3f2fd;
}

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<text y="80" font-size="80">🗄️</text>
</svg>

After

Width:  |  Height:  |  Size: 115 B

View File

@@ -0,0 +1,193 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Datacenter Designer</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="css/style.css">
<!-- ag-Grid -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ag-grid-community@31/styles/ag-grid.css"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ag-grid-community@31/styles/ag-theme-alpine.css"/>
</head>
<body>
<!-- Top Toolbar -->
<div class="toolbar">
<div class="project-selector">
<select id="projectSelect" class="form-select">
<option value="1">Loading...</option>
</select>
</div>
<div class="view-switcher-group">
<div class="view-switcher">
<button id="physicalViewBtn" class="btn-view active" title="Physical layout with racks">Physical</button>
<button id="logicalViewBtn" class="btn-view" title="Logical topology without racks">Logical</button>
</div>
<div class="view-switcher">
<button id="racksTableBtn" class="btn-view" title="Toggle racks table view">Racks</button>
<button id="devicesTableBtn" class="btn-view" title="Toggle devices table view">Devices</button>
<button id="connectionsTableBtn" class="btn-view" title="Toggle connections table view">Connections</button>
</div>
</div>
<div class="toolbar-spacer"></div>
<div class="toolbar-actions">
<button id="exportProjectBtn" class="btn-sm" title="Export project to JSON file">Export Project</button>
<button id="importProjectBtn" class="btn-sm" title="Import project from JSON file">Import Project</button>
<input type="file" id="importProjectInput" accept=".json" style="display: none;">
</div>
<div class="toolbar-info">
<input type="number" id="zoomInput" class="zoom-input" min="10" max="300" step="10" value="100" title="Enter zoom percentage">
<span class="zoom-unit">%</span>
<button id="fitViewBtn" class="btn-sm" title="Fit all racks in view">Fit</button>
</div>
</div>
<!-- Main Content Area with Split Panes -->
<div class="main-content">
<!-- Canvas Container (Top Pane) -->
<div id="canvasPane" class="canvas-pane">
<div id="canvasWrapper"></div>
</div>
<!-- Resize Handle -->
<div id="resizeHandle" class="resize-handle hidden"></div>
<!-- Table Container (Bottom Pane) -->
<div id="tablePane" class="table-pane hidden">
<div class="table-toolbar">
<button id="addTableRowBtn" class="btn btn-primary">+ Add Row</button>
<button id="deleteTableRowBtn" class="btn btn-secondary">Delete Selected</button>
<div style="flex: 1;"></div>
<button id="exportExcelBtn" class="btn btn-success" title="Export all tables to Excel (.xlsx)">Export to Excel</button>
</div>
<div id="tableContent" class="ag-theme-alpine"></div>
</div>
</div>
<!-- Context Menu -->
<div id="contextMenu" class="context-menu hidden">
<ul id="contextMenuList"></ul>
</div>
<!-- Port Selection Modal -->
<div id="portModal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3 id="portModalTitle">Select Port</h3>
<button class="modal-close" id="portModalClose">&times;</button>
</div>
<div class="modal-body">
<div id="portList" class="port-list"></div>
</div>
</div>
</div>
<!-- Add Rack Modal -->
<div id="addRackModal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3>Add Rack(s)</h3>
<button class="modal-close" id="addRackModalClose">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="rackCount">Number of racks:</label>
<input type="number" id="rackCount" min="1" max="20" value="3" class="form-input">
</div>
<div class="form-group">
<label for="rackPrefix">Name prefix:</label>
<input type="text" id="rackPrefix" value="RACK" class="form-input" placeholder="RACK">
</div>
<div class="form-group">
<label>Position:</label>
<div class="radio-group">
<label>
<input type="radio" name="rowPosition" value="continue" checked>
Continue row
<select id="continueRowSelect" class="form-select" style="margin-left: 8px; min-width: 80px; display: inline-block;">
<option value="0">1</option>
</select>
</label>
<label><input type="radio" name="rowPosition" value="below"> New row below</label>
<label><input type="radio" name="rowPosition" value="above"> New row above</label>
</div>
</div>
<div class="preview-box">
<strong>Preview:</strong>
<div id="rackNamePreview" class="preview-names">RACK01, RACK02, RACK03</div>
</div>
<div class="modal-footer">
<button id="createRacksBtn" class="btn btn-primary">Create</button>
<button id="cancelRacksBtn" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Add Device Modal -->
<div id="addDeviceModal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3>Add Device</h3>
<button class="modal-close" id="addDeviceModalClose">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Select device type:</label>
<div id="deviceTypeList" class="device-type-grid"></div>
</div>
</div>
</div>
</div>
<!-- Manage Projects Modal -->
<div id="manageProjectsModal" class="modal hidden">
<div class="modal-content modal-large">
<div class="modal-header">
<h3>Manage Projects</h3>
<button class="modal-close" id="manageProjectsModalClose">&times;</button>
</div>
<div class="modal-body">
<div class="modal-section">
<button id="newProjectBtnFromManage" class="btn btn-primary" style="margin-bottom: 20px;">+ New Project</button>
</div>
<div id="projectsList" class="projects-list"></div>
</div>
</div>
</div>
<!-- New/Edit Project Modal -->
<div id="projectFormModal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3 id="projectFormTitle">New Project</h3>
<button class="modal-close" id="projectFormModalClose">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="projectName">Project name:</label>
<input type="text" id="projectName" class="form-input" placeholder="My Datacenter Project">
</div>
<div class="form-group">
<label for="projectDescription">Description (optional):</label>
<textarea id="projectDescription" class="form-input" rows="3" placeholder="Project description..."></textarea>
</div>
<div class="modal-footer">
<button id="saveProjectBtn" class="btn btn-primary">Save</button>
<button id="cancelProjectBtn" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/ag-grid-community@31/dist/ag-grid-community.min.js"></script>
<script src="https://unpkg.com/konva@9/konva.min.js"></script>
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
<script src="js/app.js" type="module"></script>
</body>
</html>

1719
archive/old_public/js/app.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,901 @@
export class ConnectionManager {
constructor(layer, api, deviceManager, rackManager) {
this.layer = layer;
this.api = api;
this.deviceManager = deviceManager;
this.rackManager = rackManager;
this.connections = new Map();
this.connectionLayer = new Konva.Layer();
this.pendingConnection = null;
this.tempLine = null;
this.currentView = 'physical'; // Track current view
this.selectedConnection = null; // Track selected connection for keyboard deletion
// Set up keyboard event listener for Delete key
this.setupKeyboardListeners();
}
setupKeyboardListeners() {
document.addEventListener('keydown', (e) => {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (this.selectedConnection) {
e.preventDefault();
const conn = this.connections.get(this.selectedConnection);
if (conn) {
if (confirm('Delete this connection?')) {
this.deleteConnection(this.selectedConnection, conn.shape, conn.handles);
}
}
}
} else if (e.key === 'Escape') {
// Deselect connection on Escape
this.deselectConnection();
}
});
}
selectConnection(connectionId, line, handles) {
// Deselect previous connection
this.deselectConnection();
// Select this connection
this.selectedConnection = connectionId;
line.stroke('#FF6B6B'); // Red highlight for selected
line.strokeWidth(3);
handles.forEach(h => h.opacity(1));
this.connectionLayer.batchDraw();
}
deselectConnection() {
if (this.selectedConnection) {
const conn = this.connections.get(this.selectedConnection);
if (conn) {
conn.shape.stroke('#000000');
conn.shape.strokeWidth(1);
conn.handles.forEach(h => h.opacity(0));
this.connectionLayer.batchDraw();
}
this.selectedConnection = null;
}
}
getConnectionLayer() {
return this.connectionLayer;
}
setCurrentView(viewType) {
this.currentView = viewType;
// Reload all connections to use waypoints for the new view
this.reloadConnectionsForView();
}
async reloadConnectionsForView() {
// Clear existing connections from layer
this.connections.forEach(conn => {
conn.shape.destroy();
conn.handles.forEach(h => h.destroy());
});
this.connections.clear();
// Reload connections with view-specific waypoints
try {
const connections = await this.api.getConnections();
connections.forEach(connData => {
this.createConnectionLine(connData);
});
this.connectionLayer.batchDraw();
} catch (err) {
console.error('Failed to reload connections:', err);
}
}
isConnectionMode() {
return this.pendingConnection !== null;
}
async loadConnections() {
try {
const connections = await this.api.getConnections();
connections.forEach(connData => {
this.createConnectionLine(connData);
});
this.connectionLayer.batchDraw();
} catch (err) {
console.error('Failed to load connections:', err);
}
}
createConnectionLine(connData) {
const sourceDevice = this.deviceManager.getDeviceShape(connData.source_device_id);
const targetDevice = this.deviceManager.getDeviceShape(connData.target_device_id);
if (!sourceDevice || !targetDevice) {
console.error('Device shapes not found for connection:', connData);
return;
}
const conn = this.drawConnection(sourceDevice, targetDevice, connData);
this.connections.set(connData.id, conn);
return conn;
}
// Alias for table-manager compatibility
createConnectionShape(connData) {
return this.createConnectionLine(connData);
}
drawConnection(sourceShape, targetShape, connData) {
const deviceWidth = this.deviceManager.deviceWidth;
const deviceHeight = this.deviceManager.deviceHeight;
const sourcePos = this.getDeviceAbsolutePosition(sourceShape);
const targetPos = this.getDeviceAbsolutePosition(targetShape);
// Calculate edge points based on relative positions
const { sourcePoint, targetPoint } = this.getEdgeConnectionPoints(
sourcePos, targetPos, deviceWidth, deviceHeight
);
// Load waypoints based on current view
let points;
let viewWaypoints = null;
// Get view-specific waypoints
if (this.currentView === 'physical' && connData.waypoints_physical) {
viewWaypoints = typeof connData.waypoints_physical === 'string'
? JSON.parse(connData.waypoints_physical)
: connData.waypoints_physical;
} else if (this.currentView === 'logical' && connData.waypoints_logical) {
viewWaypoints = typeof connData.waypoints_logical === 'string'
? JSON.parse(connData.waypoints_logical)
: connData.waypoints_logical;
} else if (connData.waypoints) {
// Fallback to legacy waypoints column
viewWaypoints = typeof connData.waypoints === 'string'
? JSON.parse(connData.waypoints)
: connData.waypoints;
}
if (viewWaypoints && viewWaypoints.length > 0) {
// Use saved waypoints for this view
const hasEdgePoints = viewWaypoints[0].isEdge !== undefined;
if (hasEdgePoints) {
// Use saved edge points and middle waypoints
const edgeStart = viewWaypoints.find((p, i) => p.isEdge && i === 0) || { x: sourcePoint.x, y: sourcePoint.y };
const edgeEnd = viewWaypoints.find((p, i) => p.isEdge && i === viewWaypoints.length - 1) || { x: targetPoint.x, y: targetPoint.y };
const middleWaypoints = viewWaypoints.filter(p => !p.isEdge);
points = this.rebuildOrthogonalPath(edgeStart, middleWaypoints, edgeEnd);
} else {
// Old format: saved waypoints are only middle points
points = this.rebuildOrthogonalPath(sourcePoint, viewWaypoints, targetPoint);
}
} else {
// No waypoints for this view - create default path
points = this.createSimpleOrthogonalPath(sourcePoint, targetPoint);
}
// Create the line
const line = new Konva.Line({
points: points,
stroke: '#000000',
strokeWidth: 1,
lineCap: 'round',
lineJoin: 'round',
id: `connection-${connData.id}`,
opacity: 0.8,
hitStrokeWidth: 10
});
// Add line to layer FIRST so it's drawn underneath handles
this.connectionLayer.add(line);
// Create draggable handles for ALL points (including start and end)
const handles = [];
const numPoints = points.length / 2;
for (let i = 0; i < numPoints; i++) {
const handle = new Konva.Circle({
x: points[i * 2],
y: points[i * 2 + 1],
radius: 4,
fill: '#4A90E2',
stroke: '#fff',
strokeWidth: 1,
draggable: true,
opacity: 0, // Hidden by default
name: `handle-${i}`
});
handles.push(handle);
// Add handle AFTER line, so handles are on top
this.connectionLayer.add(handle);
}
// Click to select connection
line.on('click', (e) => {
e.cancelBubble = true;
this.selectConnection(connData.id, line, handles);
});
// Hover behavior: show handles and highlight line
line.on('mouseenter', () => {
if (this.selectedConnection !== connData.id) {
line.stroke('#4A90E2'); // Blue highlight
line.strokeWidth(2);
handles.forEach(h => h.opacity(1));
this.connectionLayer.batchDraw();
}
});
line.on('mouseleave', () => {
// Only hide if no handle is being dragged and not selected
const isDragging = handles.some(h => h.isDragging());
if (!isDragging && this.selectedConnection !== connData.id) {
line.stroke('#000000');
line.strokeWidth(1);
handles.forEach(h => h.opacity(0));
this.connectionLayer.batchDraw();
}
});
// Simplified handle event listeners
handles.forEach((handle, idx) => {
const isEdgeHandle = (idx === 0 || idx === handles.length - 1);
handle.on('mouseenter', () => {
// Keep line highlighted and handles visible when hovering over handles
line.stroke('#4A90E2');
line.strokeWidth(2);
handles.forEach(h => h.opacity(1));
this.connectionLayer.batchDraw();
});
handle.on('mousedown', (e) => e.cancelBubble = true);
handle.on('dragmove', () => {
this.updateLineFromHandles(line, handles);
});
handle.on('dragend', () => {
// Snap edge handles to nearest device edge
if (isEdgeHandle) {
const snappedPos = this.snapToNearestDeviceEdge(handle.x(), handle.y());
handle.position(snappedPos);
this.updateLineFromHandles(line, handles);
}
this.saveWaypoints(connData.id, handles);
// After drag, check if mouse is still over line or handles to keep handles visible
const stage = this.connectionLayer.getStage();
const pointerPos = stage.getPointerPosition();
if (pointerPos) {
const shape = this.connectionLayer.getIntersection(pointerPos);
const isOverLineOrHandle = shape === line || handles.includes(shape);
if (!isOverLineOrHandle) {
// Mouse not over line or handles, hide handles and reset line style
line.stroke('#000000');
line.strokeWidth(1);
handles.forEach(h => h.opacity(0));
this.connectionLayer.batchDraw();
}
}
});
});
// Double-click on line to add waypoint
line.on('dblclick', (e) => {
const stage = this.connectionLayer.getStage();
const pointerPos = stage.getPointerPosition();
const scale = stage.scaleX();
const stagePos = stage.position();
// Convert to world coordinates
const worldX = (pointerPos.x - stagePos.x) / scale;
const worldY = (pointerPos.y - stagePos.y) / scale;
// Create new waypoint at double-click position
this.addWaypointAtPosition(line, handles, worldX, worldY, connData);
});
// Right-click to delete
line.on('contextmenu', (e) => {
e.evt.preventDefault();
this.showConnectionContextMenu(e, connData, line, handles);
});
// Update line when devices move
const updateLine = () => {
const newSourcePos = this.getDeviceAbsolutePosition(sourceShape);
const newTargetPos = this.getDeviceAbsolutePosition(targetShape);
const { sourcePoint: newSourcePoint, targetPoint: newTargetPoint } =
this.getEdgeConnectionPoints(newSourcePos, newTargetPos, this.deviceManager.deviceWidth, this.deviceManager.deviceHeight);
// Update first and last handle positions (start and end)
handles[0].position(newSourcePoint);
handles[handles.length - 1].position(newTargetPoint);
// Get current middle waypoints
const waypoints = handles.slice(1, -1).map(h => ({ x: h.x(), y: h.y() }));
// Rebuild path with new endpoints and current waypoints
const newPoints = this.rebuildOrthogonalPath(newSourcePoint, waypoints, newTargetPoint);
line.points(newPoints);
this.connectionLayer.batchDraw();
};
// Attach update listeners based on current view
if (this.currentView === 'logical') {
// In logical view, devices are on main layer - attach listeners to devices
sourceShape.on('dragmove.connection', updateLine);
targetShape.on('dragmove.connection', updateLine);
} else {
// In physical view, devices are in racks - attach listeners to racks
const sourceRack = sourceShape.getParent().getParent();
const targetRack = targetShape.getParent().getParent();
if (sourceRack) sourceRack.on('dragmove.connection', updateLine);
if (targetRack) targetRack.on('dragmove.connection', updateLine);
}
// Line already added earlier (before handles, for correct z-order)
return { data: connData, shape: line, handles: handles, sourceShape, targetShape };
}
addWaypointAtPosition(line, handles, worldX, worldY, connData) {
const conn = this.connections.get(connData.id);
if (!conn) return;
// Get current points
const startPoint = { x: handles[0].x(), y: handles[0].y() };
const endPoint = { x: handles[handles.length - 1].x(), y: handles[handles.length - 1].y() };
const waypoints = handles.slice(1, -1).map(h => ({ x: h.x(), y: h.y() }));
// Add new waypoint
waypoints.push({ x: worldX, y: worldY });
// Recreate handles
this.recreateHandles(line, handles, startPoint, waypoints, endPoint, connData);
// Show handles and highlight line (we just added a waypoint)
line.stroke('#4A90E2');
line.strokeWidth(2);
handles.forEach(h => h.opacity(1));
// Save
this.saveWaypoints(connData.id, handles);
this.connectionLayer.batchDraw();
}
recreateHandles(line, handles, startPoint, waypoints, endPoint, connData) {
// Destroy old handles
handles.forEach(h => h.destroy());
handles.length = 0;
// Rebuild path
const newPoints = this.rebuildOrthogonalPath(startPoint, waypoints, endPoint);
line.points(newPoints);
// Create new handles
const allPoints = [startPoint, ...waypoints, endPoint];
allPoints.forEach((pt, i) => {
const isEdgeHandle = (i === 0 || i === allPoints.length - 1);
const handle = new Konva.Circle({
x: pt.x,
y: pt.y,
radius: 4,
fill: '#4A90E2',
stroke: '#fff',
strokeWidth: 1,
draggable: true,
opacity: 0, // Hidden by default
name: `handle-${i}`
});
// Attach event listeners
handle.on('mouseenter', () => {
// Keep line highlighted and handles visible when hovering over handles
line.stroke('#4A90E2');
line.strokeWidth(2);
handles.forEach(h => h.opacity(1));
this.connectionLayer.batchDraw();
});
handle.on('mousedown', (e) => e.cancelBubble = true);
handle.on('dragmove', () => {
this.updateLineFromHandles(line, handles);
});
handle.on('dragend', () => {
if (isEdgeHandle) {
const snappedPos = this.snapToNearestDeviceEdge(handle.x(), handle.y());
handle.position(snappedPos);
this.updateLineFromHandles(line, handles);
}
this.saveWaypoints(connData.id, handles);
// After drag, check if mouse is still over line or handles to keep handles visible
const stage = this.connectionLayer.getStage();
const pointerPos = stage.getPointerPosition();
if (pointerPos) {
const shape = this.connectionLayer.getIntersection(pointerPos);
const isOverLineOrHandle = shape === line || handles.includes(shape);
if (!isOverLineOrHandle) {
// Mouse not over line or handles, hide handles and reset line style
line.stroke('#000000');
line.strokeWidth(1);
handles.forEach(h => h.opacity(0));
this.connectionLayer.batchDraw();
}
}
});
handles.push(handle);
this.connectionLayer.add(handle);
});
// Update connection reference
const conn = this.connections.get(connData.id);
if (conn) conn.handles = handles;
}
async saveWaypoints(connectionId, handles) {
try {
// Save ALL waypoints including edge points
const allPoints = handles.map((h, i) => ({
x: h.x(),
y: h.y(),
isEdge: i === 0 || i === handles.length - 1
}));
// Save to view-specific column
await this.api.updateConnectionWaypoints(connectionId, allPoints, this.currentView);
} catch (err) {
console.error('Failed to save waypoints:', err);
}
}
createSimpleOrthogonalPath(start, end) {
// Create a proper orthogonal path with right angles only
const dx = Math.abs(end.x - start.x);
const dy = Math.abs(end.y - start.y);
// If points are already aligned (same x or y), draw straight line
if (dx === 0 || dy === 0) {
return [start.x, start.y, end.x, end.y];
}
// Create L-shaped path based on which direction is dominant
if (dx > dy) {
// Horizontal first, then vertical
return [
start.x, start.y,
end.x, start.y,
end.x, end.y
];
} else {
// Vertical first, then horizontal
return [
start.x, start.y,
start.x, end.y,
end.x, end.y
];
}
}
snapToNearestDeviceEdge(x, y) {
const deviceWidth = this.deviceManager.deviceWidth;
const deviceHeight = this.deviceManager.deviceHeight;
let nearestEdge = null;
let minDistance = Infinity;
// Get all devices from deviceManager
this.deviceManager.devices.forEach((device, deviceId) => {
const deviceShape = device.shape;
if (!deviceShape) return;
const devicePos = this.getDeviceAbsolutePosition(deviceShape);
// Calculate all 4 edges
const edges = [
{
type: 'left',
x: devicePos.x,
y: Math.max(devicePos.y, Math.min(devicePos.y + deviceHeight, y)),
line: { x1: devicePos.x, y1: devicePos.y, x2: devicePos.x, y2: devicePos.y + deviceHeight }
},
{
type: 'right',
x: devicePos.x + deviceWidth,
y: Math.max(devicePos.y, Math.min(devicePos.y + deviceHeight, y)),
line: { x1: devicePos.x + deviceWidth, y1: devicePos.y, x2: devicePos.x + deviceWidth, y2: devicePos.y + deviceHeight }
},
{
type: 'top',
x: Math.max(devicePos.x, Math.min(devicePos.x + deviceWidth, x)),
y: devicePos.y,
line: { x1: devicePos.x, y1: devicePos.y, x2: devicePos.x + deviceWidth, y2: devicePos.y }
},
{
type: 'bottom',
x: Math.max(devicePos.x, Math.min(devicePos.x + deviceWidth, x)),
y: devicePos.y + deviceHeight,
line: { x1: devicePos.x, y1: devicePos.y + deviceHeight, x2: devicePos.x + deviceWidth, y2: devicePos.y + deviceHeight }
}
];
// Find nearest edge point
edges.forEach(edge => {
const dist = Math.sqrt(Math.pow(edge.x - x, 2) + Math.pow(edge.y - y, 2));
if (dist < minDistance) {
minDistance = dist;
nearestEdge = { x: edge.x, y: edge.y };
}
});
});
// Return snapped position or original if no devices found
return nearestEdge || { x, y };
}
getEdgeConnectionPoints(sourcePos, targetPos, deviceWidth, deviceHeight) {
// Calculate center points
const sourceCenterX = sourcePos.x + deviceWidth / 2;
const sourceCenterY = sourcePos.y + deviceHeight / 2;
const targetCenterX = targetPos.x + deviceWidth / 2;
const targetCenterY = targetPos.y + deviceHeight / 2;
const dx = targetCenterX - sourceCenterX;
const dy = targetCenterY - sourceCenterY;
let sourcePoint, targetPoint;
// Determine which edges to connect based on relative positions
if (Math.abs(dx) > Math.abs(dy)) {
// Primarily horizontal - use left/right edges
if (dx > 0) {
// Source right edge, target left edge
sourcePoint = { x: sourcePos.x + deviceWidth, y: sourceCenterY };
targetPoint = { x: targetPos.x, y: targetCenterY };
} else {
// Source left edge, target right edge
sourcePoint = { x: sourcePos.x, y: sourceCenterY };
targetPoint = { x: targetPos.x + deviceWidth, y: targetCenterY };
}
} else {
// Primarily vertical - use top/bottom edges
if (dy > 0) {
// Source bottom edge, target top edge
sourcePoint = { x: sourceCenterX, y: sourcePos.y + deviceHeight };
targetPoint = { x: targetCenterX, y: targetPos.y };
} else {
// Source top edge, target bottom edge
sourcePoint = { x: sourceCenterX, y: sourcePos.y };
targetPoint = { x: targetCenterX, y: targetPos.y + deviceHeight };
}
}
return { sourcePoint, targetPoint };
}
updateLineFromHandles(line, handles) {
if (handles.length < 2) return;
const startPoint = { x: handles[0].x(), y: handles[0].y() };
const endPoint = { x: handles[handles.length - 1].x(), y: handles[handles.length - 1].y() };
const waypoints = handles.slice(1, -1).map(h => ({ x: h.x(), y: h.y() }));
const newPoints = this.rebuildOrthogonalPath(startPoint, waypoints, endPoint);
line.points(newPoints);
this.connectionLayer.batchDraw();
}
rebuildOrthogonalPath(start, waypoints, end) {
// Simple orthogonal path: just connect all points with right angles
const points = [start.x, start.y];
let prev = start;
const allPoints = [...waypoints, end];
allPoints.forEach(curr => {
const dx = Math.abs(curr.x - prev.x);
const dy = Math.abs(curr.y - prev.y);
// Add corner point if needed
if (dx > 0 && dy > 0) {
// Choose direction based on larger distance
if (dx > dy) {
points.push(curr.x, prev.y); // Horizontal first
} else {
points.push(prev.x, curr.y); // Vertical first
}
}
points.push(curr.x, curr.y);
prev = curr;
});
return points;
}
getDeviceAbsolutePosition(deviceShape) {
if (this.currentView === 'logical') {
// In logical view, devices are on main layer with absolute positioning
return {
x: deviceShape.x(),
y: deviceShape.y()
};
} else {
// In physical view, devices are in rack containers with relative positioning
const parent = deviceShape.getParent();
if (!parent || parent === this.layer) {
// Device is on main layer (shouldn't happen in physical view, but handle it)
return {
x: deviceShape.x(),
y: deviceShape.y()
};
}
const rack = parent.getParent();
return {
x: rack.x() + deviceShape.x(),
y: rack.y() + deviceShape.y()
};
}
}
updateAllConnections() {
// Update all connection paths
this.connections.forEach(conn => {
if (!conn.sourceShape || !conn.targetShape) return;
const deviceWidth = this.deviceManager.deviceWidth;
const deviceHeight = this.deviceManager.deviceHeight;
const sourcePos = this.getDeviceAbsolutePosition(conn.sourceShape);
const targetPos = this.getDeviceAbsolutePosition(conn.targetShape);
const { sourcePoint, targetPoint } = this.getEdgeConnectionPoints(
sourcePos, targetPos, deviceWidth, deviceHeight
);
// Update first and last handle positions (start and end)
conn.handles[0].position(sourcePoint);
conn.handles[conn.handles.length - 1].position(targetPoint);
// Get middle waypoint positions from handles
const waypoints = conn.handles.slice(1, -1).map(h => ({ x: h.x(), y: h.y() }));
// Rebuild path
const points = this.rebuildOrthogonalPath(sourcePoint, waypoints, targetPoint);
conn.shape.points(points);
});
this.connectionLayer.batchDraw();
}
startConnection(deviceId, deviceShape) {
// Cancel any existing connection mode
if (this.tempLine) {
this.tempLine.destroy();
this.tempLine = null;
}
const deviceData = this.deviceManager.getDeviceData(deviceId);
if (!deviceData) return;
const deviceWidth = this.deviceManager.deviceWidth;
const deviceHeight = this.deviceManager.deviceHeight;
const deviceAbsPos = this.getDeviceAbsolutePosition(deviceShape);
// Start from device center (will be adjusted to edge when connection completes)
const startPoint = {
x: deviceAbsPos.x + deviceWidth / 2,
y: deviceAbsPos.y + deviceHeight / 2
};
// Create temporary line
this.tempLine = new Konva.Line({
points: [startPoint.x, startPoint.y, startPoint.x, startPoint.y],
stroke: '#000000',
strokeWidth: 1,
dash: [10, 5],
listening: false
});
this.connectionLayer.add(this.tempLine);
this.tempLine.moveToTop();
this.pendingConnection = {
sourceDeviceId: deviceId,
sourceDeviceShape: deviceShape,
sourceDeviceData: deviceData,
startPoint: startPoint,
deviceAbsPos: deviceAbsPos
};
// Update temp line on mouse move
const stage = this.connectionLayer.getStage();
stage.on('mousemove.connection', () => {
if (this.tempLine && this.pendingConnection) {
const pos = stage.getPointerPosition();
const scale = stage.scaleX();
const stagePos = stage.position();
const worldX = (pos.x - stagePos.x) / scale;
const worldY = (pos.y - stagePos.y) / scale;
// Calculate which edge to use based on cursor position
const dx = worldX - (this.pendingConnection.deviceAbsPos.x + deviceWidth / 2);
const dy = worldY - (this.pendingConnection.deviceAbsPos.y + deviceHeight / 2);
let edgePoint;
if (Math.abs(dx) > Math.abs(dy)) {
// Use left or right edge
if (dx > 0) {
edgePoint = {
x: this.pendingConnection.deviceAbsPos.x + deviceWidth,
y: this.pendingConnection.deviceAbsPos.y + deviceHeight / 2
};
} else {
edgePoint = {
x: this.pendingConnection.deviceAbsPos.x,
y: this.pendingConnection.deviceAbsPos.y + deviceHeight / 2
};
}
} else {
// Use top or bottom edge
if (dy > 0) {
edgePoint = {
x: this.pendingConnection.deviceAbsPos.x + deviceWidth / 2,
y: this.pendingConnection.deviceAbsPos.y + deviceHeight
};
} else {
edgePoint = {
x: this.pendingConnection.deviceAbsPos.x + deviceWidth / 2,
y: this.pendingConnection.deviceAbsPos.y
};
}
}
this.tempLine.points([edgePoint.x, edgePoint.y, worldX, worldY]);
this.connectionLayer.batchDraw();
}
});
}
async completeConnection(targetDeviceId, targetDeviceShape) {
if (!this.pendingConnection) return;
const sourceDeviceId = this.pendingConnection.sourceDeviceId;
if (sourceDeviceId === targetDeviceId) {
this.cancelConnection();
return;
}
try {
const sourceUsedPorts = await this.api.getUsedPorts(sourceDeviceId);
const targetUsedPorts = await this.api.getUsedPorts(targetDeviceId);
const sourcePort = this.getNextAvailablePort(this.pendingConnection.sourceDeviceData, sourceUsedPorts);
const targetData = this.deviceManager.getDeviceData(targetDeviceId);
const targetPort = this.getNextAvailablePort(targetData, targetUsedPorts);
if (sourcePort === null || targetPort === null) {
this.cancelConnection();
return;
}
const connData = await this.api.createConnection(
sourceDeviceId,
sourcePort,
targetDeviceId,
targetPort
);
const connections = await this.api.getConnections();
const newConn = connections.find(c => c.id === connData.id);
if (newConn) {
this.createConnectionLine(newConn);
this.connectionLayer.batchDraw();
}
} catch (err) {
console.error('Failed to create connection:', err);
}
this.cancelConnection();
}
getNextAvailablePort(deviceData, usedPorts) {
const portsCount = deviceData.ports_count || 24;
for (let port = 1; port <= portsCount; port++) {
if (!usedPorts.includes(port)) {
return port;
}
}
return null;
}
cancelConnection() {
if (this.tempLine) {
this.tempLine.destroy();
this.tempLine = null;
}
const stage = this.connectionLayer.getStage();
if (stage) {
stage.off('mousemove.connection');
}
this.pendingConnection = null;
this.connectionLayer.batchDraw();
}
async deleteConnection(connId, line, handles) {
try {
await this.api.deleteConnection(connId);
line.destroy();
handles.forEach(h => h.destroy());
this.connections.delete(connId);
// Clear selection if this was the selected connection
if (this.selectedConnection === connId) {
this.selectedConnection = null;
}
this.connectionLayer.batchDraw();
// Notify table to sync
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
} catch (err) {
console.error('Failed to delete connection:', err);
}
}
showConnectionContextMenu(e, connData, line, handles) {
const contextMenu = document.getElementById('contextMenu');
const contextMenuList = document.getElementById('contextMenuList');
const sourceDevice = this.deviceManager.getDeviceData(connData.source_device_id);
const targetDevice = this.deviceManager.getDeviceData(connData.target_device_id);
contextMenuList.innerHTML = `
<li class="menu-header">Connection</li>
<li style="cursor: default; padding: 8px 20px; font-size: 12px; color: #666;">
${sourceDevice.name}:${connData.source_port}${targetDevice.name}:${connData.target_port}
</li>
<li class="divider"></li>
<li data-action="delete">Delete Connection</li>
`;
contextMenu.style.left = `${e.evt.pageX}px`;
contextMenu.style.top = `${e.evt.pageY}px`;
contextMenu.classList.remove('hidden');
const handleAction = (evt) => {
const action = evt.target.dataset.action;
if (action === 'delete') {
if (confirm('Delete this connection?')) {
this.deleteConnection(connData.id, line, handles);
}
}
contextMenu.classList.add('hidden');
contextMenuList.removeEventListener('click', handleAction);
};
contextMenuList.addEventListener('click', handleAction);
}
}

View File

@@ -0,0 +1,513 @@
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
}
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: !this.rackManager.racksLocked, // Draggable when racks are unlocked
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
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'
});
// Make name clickable for renaming
text.on('click', (e) => {
e.cancelBubble = true; // Prevent group drag
window.dispatchEvent(new CustomEvent('rename-device', {
detail: { deviceId: deviceData.id, deviceData, deviceShape: group }
}));
});
text.on('mouseenter', () => {
document.body.style.cursor = 'text';
text.fontStyle('bold italic');
this.layer.batchDraw();
});
text.on('mouseleave', () => {
document.body.style.cursor = 'default';
text.fontStyle('bold');
this.layer.batchDraw();
});
group.add(rect);
group.add(text);
// Drag and drop between racks
group.on('dragstart', () => {
// Store original parent and position
group.setAttr('originalParent', group.getParent());
group.setAttr('originalPosition', group.position());
// 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 () => {
group.opacity(1);
await this.handleDeviceDrop(deviceData.id, group);
});
// Right-click context menu
group.on('contextmenu', (e) => {
e.evt.preventDefault();
this.showDeviceContextMenu(e, deviceData, group);
});
devicesContainer.add(group);
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) {
try {
await this.api.deleteDevice(deviceId);
group.destroy();
this.devices.delete(deviceId);
this.layer.batchDraw();
// Notify table to sync
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');
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);
};
contextMenuList.addEventListener('click', handleAction);
}
getNextDevicePosition(rackId) {
// Find the lowest available slot (1-42)
// U1 is at the bottom, so we fill from bottom to top
const usedSlots = new Set();
this.devices.forEach(device => {
if (device.data.rack_id === rackId) {
usedSlots.add(device.data.position);
}
});
// Find first available slot starting from U1 (bottom)
for (let slot = 1; slot <= 42; slot++) {
if (!usedSlots.has(slot)) {
return slot;
}
}
// If all slots are full, return next slot (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) {
const device = this.devices.get(deviceId);
if (!device) return;
// Get device's center point for more accurate drop detection
const absolutePos = deviceShape.getAbsolutePosition();
const deviceCenterX = absolutePos.x + (this.deviceWidth / 2);
const deviceCenterY = absolutePos.y + (this.deviceHeight / 2);
// Find which rack the device is over
let targetRack = null;
let targetRackId = null;
this.rackManager.racks.forEach((rack, rackId) => {
const rackPos = rack.shape.getAbsolutePosition();
const rackWidth = rack.data.width || this.rackManager.rackWidth;
const rackHeight = rack.data.height || this.rackManager.rackHeight;
// Check if device center is within rack bounds
if (deviceCenterX >= rackPos.x && deviceCenterX <= rackPos.x + rackWidth &&
deviceCenterY >= rackPos.y && deviceCenterY <= rackPos.y + rackHeight) {
targetRack = rack;
targetRackId = rackId;
}
});
// 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 = device.data.rack_id;
// Calculate position within target rack
const rackShape = targetRack.shape;
const rackAbsolutePos = rackShape.getAbsolutePosition();
const relativeY = absolutePos.y - rackAbsolutePos.y;
// Convert visual Y to slot position (1-42, where U1 is at bottom)
const maxSlots = 42;
const visualPosition = Math.round((relativeY - 10) / (this.deviceHeight + this.deviceSpacing));
let newPosition = maxSlots - visualPosition; // Invert: bottom (high Y) = low slot number
newPosition = Math.max(1, Math.min(42, newPosition)); // Clamp to 1-42
// Get devices in target rack and check for conflicts
const devicesInTargetRack = Array.from(this.devices.values())
.filter(d => d.data.rack_id === targetRackId && d.data.id !== deviceId)
.sort((a, b) => a.data.position - b.data.position);
// Find available position
let finalPosition = newPosition;
const occupiedPositions = new Set(devicesInTargetRack.map(d => d.data.position));
while (occupiedPositions.has(finalPosition)) {
finalPosition++;
}
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);
// Reposition device using helper method
const rackUnits = device.data.rack_units || 1;
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 });
// Compact positions in original rack if different
if (originalRackId !== targetRackId) {
this.compactRackDevices(originalRackId);
}
this.layer.batchDraw();
// 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) {
this.devices.forEach(device => {
device.shape.draggable(draggable);
});
}
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');
if (rect) {
rect.width(this.deviceWidth);
}
if (text) {
text.width(this.deviceWidth);
}
});
}
}

View File

@@ -0,0 +1,488 @@
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 = 1510; // Fits 42 devices (42 * 35px + margins)
this.rackSpacing = 80;
this.gridSize = 600; // Default: rack width + spacing
this.gridVertical = 1610; // Default: rack height + spacing (1510 + 100)
this.racksLocked = true; // Start with racks locked
this.nextX = 0; // Start at grid origin
this.nextY = 0; // Start at grid origin
// 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 = 1610; // 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 (clickable)
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'
});
// Make name clickable
nameLabel.on('click', () => {
window.dispatchEvent(new CustomEvent('rename-rack', {
detail: { rackId: rackData.id, rackData, rackShape: group }
}));
});
nameLabel.on('mouseenter', () => {
document.body.style.cursor = 'pointer';
nameLabel.fill('#4A90E2');
this.layer.batchDraw();
});
nameLabel.on('mouseleave', () => {
document.body.style.cursor = 'default';
nameLabel.fill('#333');
this.layer.batchDraw();
});
// 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) {
try {
await this.api.deleteRack(rackId);
group.destroy();
this.racks.delete(rackId);
this.layer.batchDraw();
// Notify table to sync
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');
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);
};
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,792 @@
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;
}
// 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()
};
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()
};
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()
};
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 {
for (const row of selectedRows) {
if (this.currentTable === 'racks') {
const rackShape = this.rackManager.getRackShape(row.id);
await this.rackManager.deleteRack(row.id, rackShape);
} else if (this.currentTable === 'devices') {
const deviceShape = this.deviceManager.getDeviceShape(row.id);
await this.deviceManager.deleteDevice(row.id, deviceShape);
} else if (this.currentTable === 'connections') {
await this.connectionManager.deleteConnection(row.id);
}
}
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;
}
}
}