From 3431a121a9dec2cc14d3ad11f722863f2fb61554 Mon Sep 17 00:00:00 2001
From: Stefano Manfredi <56640837+stemanfredi@users.noreply.github.com>
Date: Mon, 27 Oct 2025 11:57:38 +0000
Subject: [PATCH] First commit
---
.gitignore | 148 ++
CLAUDE.md | 827 ++++++++
README.md | 306 +++
archive/old_public/css/style.css | 741 +++++++
archive/old_public/favicon.svg | 3 +
archive/old_public/index.html | 193 ++
archive/old_public/js/app.js | 1719 +++++++++++++++
archive/old_public/js/connection-manager.js | 901 ++++++++
archive/old_public/js/device-manager.js | 513 +++++
archive/old_public/js/rack-manager.js | 488 +++++
archive/old_public/js/table-manager.js | 792 +++++++
archive/old_server/db.js | 547 +++++
archive/old_server/server.js | 276 +++
package-lock.json | 2123 +++++++++++++++++++
package.json | 23 +
public/css/config.css | 157 ++
public/css/style.css | 642 ++++++
public/index.html | 206 ++
public/js/app.js | 1839 ++++++++++++++++
public/js/config.js | 245 +++
public/js/lib/api.js | 217 ++
public/js/lib/ui.js | 451 ++++
public/js/managers/connection-manager.js | 1006 +++++++++
public/js/managers/device-manager.js | 610 ++++++
public/js/managers/rack-manager.js | 487 +++++
public/js/managers/table-manager.js | 805 +++++++
server/config.js | 116 +
server/db.js | 408 ++++
server/lib/errorHandler.js | 66 +
server/routes/connections.js | 147 ++
server/routes/devices.js | 186 ++
server/routes/projects.js | 90 +
server/routes/racks.js | 113 +
server/server.js | 83 +
34 files changed, 17474 insertions(+)
create mode 100644 .gitignore
create mode 100644 CLAUDE.md
create mode 100644 README.md
create mode 100644 archive/old_public/css/style.css
create mode 100644 archive/old_public/favicon.svg
create mode 100644 archive/old_public/index.html
create mode 100644 archive/old_public/js/app.js
create mode 100644 archive/old_public/js/connection-manager.js
create mode 100644 archive/old_public/js/device-manager.js
create mode 100644 archive/old_public/js/rack-manager.js
create mode 100644 archive/old_public/js/table-manager.js
create mode 100644 archive/old_server/db.js
create mode 100644 archive/old_server/server.js
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 public/css/config.css
create mode 100644 public/css/style.css
create mode 100644 public/index.html
create mode 100644 public/js/app.js
create mode 100644 public/js/config.js
create mode 100644 public/js/lib/api.js
create mode 100644 public/js/lib/ui.js
create mode 100644 public/js/managers/connection-manager.js
create mode 100644 public/js/managers/device-manager.js
create mode 100644 public/js/managers/rack-manager.js
create mode 100644 public/js/managers/table-manager.js
create mode 100644 server/config.js
create mode 100644 server/db.js
create mode 100644 server/lib/errorHandler.js
create mode 100644 server/routes/connections.js
create mode 100644 server/routes/devices.js
create mode 100644 server/routes/projects.js
create mode 100644 server/routes/racks.js
create mode 100644 server/server.js
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..24057c4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,148 @@
+# ===========================
+# Datacenter Designer
+# .gitignore
+# ===========================
+
+# Dependencies
+node_modules/
+package-lock.json # Optional: Comment out if you want to commit lock file
+
+# Database Files (User Data - DO NOT COMMIT)
+database/*.db
+database/*.db-shm
+database/*.db-wal
+*.db
+*.db-shm
+*.db-wal
+
+# Exception: Allow sample database if you create one for demos
+# !database/sample.db
+
+# Logs
+logs/
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+
+# Environment Variables
+.env
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+.env*.local
+
+# IDE and Editor Files
+.vscode/
+.idea/
+*.swp
+*.swo
+*.swn
+*~
+.project
+.classpath
+.c9/
+*.launch
+.settings/
+*.sublime-workspace
+*.sublime-project
+
+# OS Files
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
+Desktop.ini
+
+# Build Outputs (if added later)
+dist/
+build/
+.cache/
+out/
+.next/
+.nuxt/
+
+# Temporary Files
+tmp/
+temp/
+*.tmp
+*.temp
+*.bak
+*.backup
+
+# Test Coverage
+coverage/
+.nyc_output/
+*.lcov
+
+# Runtime Data
+pids/
+*.pid
+*.seed
+*.pid.lock
+
+# Optional npm cache directory
+.npm/
+
+# Optional eslint cache
+.eslintcache
+
+# Optional REPL history
+.node_repl_history
+
+# Yarn
+.yarn-integrity
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
+
+# Claude-specific (optional - uncomment if needed)
+# .claude/
+# .claude/settings.local.json
+
+# macOS
+.AppleDouble
+.LSOverride
+
+# Windows
+[Dd]esktop.ini
+$RECYCLE.BIN/
+
+# Linux
+.directory
+.Trash-*
+
+# Archives (optional - uncomment if you don't want to track exports)
+# *.zip
+# *.tar.gz
+# *.rar
+# *.7z
+
+# Exported Files (optional - uncomment if you don't want to track exports)
+# exports/
+# *.json
+# *.xlsx
+
+# Documentation Build (if you add docs generation)
+docs/build/
+docs/_build/
+
+# Certificates and Keys (CRITICAL - DO NOT COMMIT)
+*.pem
+*.key
+*.crt
+*.cer
+*.p12
+*.pfx
+
+# Config Files with Secrets
+config.local.js
+secrets.json
+credentials.json
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..e773f8d
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,827 @@
+# CLAUDE.md - Developer Documentation
+
+## Project Overview
+
+**Datacenter Designer** is a web-based visual design tool for datacenter infrastructure. It allows users to create, manage, and visualize rack layouts, network devices, and their interconnections in both physical and logical views.
+
+### Core Purpose
+- Visual rack layout planning (physical positioning)
+- Device placement within racks (U1-U42 slots with multi-unit form factors)
+- Network connection mapping between devices
+- Logical topology view (independent of physical layout)
+- Export/import for data portability
+
+### Technology Stack
+- **Frontend**: Vanilla JavaScript (ES6 modules), Konva.js (canvas library), ag-Grid (tables), SheetJS (Excel export)
+- **Backend**: Node.js, Express 4.x
+- **Database**: SQLite3
+- **No build process**: Direct ES6 modules, no bundler required
+
+---
+
+## Architecture
+
+### High-Level Structure
+
+```
+┌─────────────────────────────────────────────────┐
+│ Browser │
+│ ┌────────────────────────────────────────────┐ │
+│ │ app.js (Main Controller) │ │
+│ │ - Orchestrates all managers │ │
+│ │ - Handles modals and UI │ │
+│ │ - API client │ │
+│ │ - View switching (Physical/Logical) │ │
+│ └────────────────────────────────────────────┘ │
+│ │ │ │ │ │
+│ ▼ ▼ ▼ ▼ │
+│ ┌────────┐ ┌─────────┐ ┌─────────┐ ┌────────┐ │
+│ │ Rack │ │ Device │ │Connect │ │ Table │ │
+│ │Manager │ │Manager │ │Manager │ │Manager │ │
+│ └────────┘ └─────────┘ └─────────┘ └────────┘ │
+│ │ │ │ │ │
+│ └──────────┴───────────┴──────────┘ │
+│ │ │
+│ Konva.js Canvas │
+└────────────────────│──────────────────────────────┘
+ │ HTTP/REST API
+┌────────────────────▼──────────────────────────────┐
+│ Server │
+│ ┌──────────────────────────────────────────────┐│
+│ │ server.js (Express Router) ││
+│ │ - RESTful endpoints ││
+│ │ - Request/response handling ││
+│ └──────────────────────────────────────────────┘│
+│ │ │
+│ ┌──────────────────▼──────────────────────────┐ │
+│ │ db.js (Database Layer) │ │
+│ │ - Promise-wrapped SQLite operations │ │
+│ │ - Schema management │ │
+│ └──────────────────────────────────────────────┘ │
+│ │ │
+│ SQLite Database File │
+└───────────────────────────────────────────────────┘
+```
+
+### File Organization
+
+```
+datacenter-designer/
+├── server/
+│ ├── server.js (276 LOC) - Express routes
+│ └── db.js (547 LOC) - Database operations
+├── public/
+│ ├── index.html (193 LOC) - Main HTML structure
+│ ├── css/
+│ │ └── style.css (741 LOC) - All styles
+│ └── js/
+│ ├── app.js (1719 LOC) - Main controller
+│ ├── rack-manager.js (488 LOC) - Rack rendering/interaction
+│ ├── device-manager.js (513 LOC) - Device rendering/interaction
+│ ├── connection-manager.js (901 LOC) - Connection lines/waypoints
+│ └── table-manager.js (792 LOC) - ag-Grid table views
+├── database/
+│ └── datacenter.db (Created at runtime)
+├── package.json
+└── README.md
+
+Total: ~5,236 lines of code
+```
+
+---
+
+## Critical Review
+
+### ✅ Strengths
+
+1. **Clean Separation of Concerns**: Manager pattern isolates rack, device, connection, and table logic
+2. **Simple Tech Stack**: No build process, direct ES6 modules, minimal dependencies
+3. **RESTful API**: Clear HTTP API with predictable endpoints
+4. **SQLite Persistence**: Appropriate for local/desktop deployment
+5. **Konva.js Integration**: Good choice for canvas-based diagramming
+6. **Dual View System**: Physical (rack-based) and logical (topology) views
+
+### ❌ Issues & Anti-Patterns
+
+#### 1. **Monolithic `app.js` (1719 lines)**
+- **Violation**: Single Responsibility Principle
+- **Contains**: API client, modal management, zoom/pan, view switching, event coordination, export/import
+- **Should be**: Split into smaller modules (API client, ModalManager, StateManager, UIController)
+
+#### 2. **Repetitive Code (DRY Violations)**
+```javascript
+// Repeated everywhere in server.js:
+try {
+ // ... logic
+} catch (err) {
+ res.status(500).json({ error: err.message });
+}
+
+// Repeated modal patterns in app.js
+modal.classList.remove('hidden');
+// ...setup
+modal.classList.add('hidden');
+```
+
+#### 3. **Database Layer Issues**
+- **Callback-based sqlite3**: Manually wrapping every query in Promises
+- **No migration system**: Schema changes via ALTER TABLE with error swallowing (lines 106-140 in db.js)
+- **Verbose**: Every CRUD operation is 10-15 lines of boilerplate
+- **Better alternative**: `better-sqlite3` (synchronous, simpler, faster)
+
+#### 4. **Hard-Coded Magic Numbers**
+```javascript
+// Scattered throughout codebase:
+const maxSlots = 42; // Rack slots
+const rackWidth = 520; // Rack dimensions
+const rackHeight = 1510;
+const gridSize = 600; // Grid spacing
+const deviceHeight = 32;
+const deviceSpacing = 2;
+const topMargin = 10;
+```
+**Should be**: Centralized in `config.js` with semantic names
+
+#### 5. **API Client Design**
+```javascript
+class API {
+ // One method per endpoint - not scalable
+ getRacks() { return this.request('/api/racks?projectId=...'); }
+ createRack(...) { return this.request(...); }
+ updateRackPosition(...) { return this.request(...); }
+ // ... 30+ methods
+}
+```
+**Should be**: Generic resource methods or use a library
+
+#### 6. **CSS Organization**
+- **741 lines in one file**: No modularity
+- **Hard-coded colors**: `#4A90E2`, `#f5f5f5`, etc. repeated
+- **No CSS variables**: Should use `--primary-color`, `--bg-color`, etc.
+- **No responsiveness**: No media queries, fixed dimensions
+- **No dark mode support**: Hard-coded light theme only
+
+#### 7. **State Management**
+- **Scattered state**: Each manager holds its own state in Maps
+- **DOM as state**: Using localStorage without versioning
+- **No synchronization**: Manual `canvas-data-changed` events
+- **No undo/redo**: No state history
+
+#### 8. **Input Validation**
+- **Server**: Minimal validation (trusts client)
+- **Client**: No form validation, uses `prompt()` for input
+- **No schema validation**: No Zod, Joi, or similar
+
+#### 9. **Error Handling**
+```javascript
+// Current:
+catch (err) {
+ console.error('Failed:', err);
+ alert('Failed: ' + err.message);
+}
+```
+**Should be**: Toast notifications, user-friendly messages, error boundaries
+
+#### 10. **Export/Import**
+- **No versioning**: Export format has version field but isn't checked properly
+- **Monolithic**: One giant JSON blob
+- **No incremental import**: All-or-nothing
+
+### ⚠️ Over-Engineering
+
+1. **View-Specific Waypoints**: `waypoints_physical` and `waypoints_logical` stored separately
+ - **Better**: Store once, transform with view matrix
+2. **Separate `rack_units` column**: Could be derived from `device_type`
+3. **Multiple modal patterns**: Overlapping modal handling code
+
+### ⚠️ Under-Engineering
+
+1. **No undo/redo**: Critical for a design tool
+2. **No keyboard shortcuts**: Only `Esc` and `Del` supported
+3. **No search/filter**: As data grows, navigation becomes difficult
+4. **No loading states**: UI freezes during operations
+5. **No optimistic updates**: Waits for server before updating UI
+6. **No batch operations**: Can't select multiple racks/devices
+7. **No auto-save**: Only manual saves via export
+
+---
+
+## Better Technologies / Alternatives
+
+### Database
+**Current**: `sqlite3` (callback-based)
+**Better**: `better-sqlite3`
+- Synchronous API (simpler code)
+- 2-3x faster
+- No callback hell
+```javascript
+// Current:
+return new Promise((resolve, reject) => {
+ this.db.get('SELECT * FROM racks WHERE id = ?', [id], (err, row) => {
+ if (err) reject(err);
+ else resolve(row);
+ });
+});
+
+// With better-sqlite3:
+return db.prepare('SELECT * FROM racks WHERE id = ?').get(id);
+```
+
+### State Management
+**Current**: Scattered Maps in each manager
+**Better Options**:
+1. **Zustand** (100 lines, minimal API)
+2. **Valtio** (proxy-based reactivity)
+3. **Nanostores** (atomic stores)
+
+### API Client
+**Current**: Custom 30+ method API class
+**Better**: **ky** or **axios** with interceptors
+```javascript
+// Generic resource client:
+const api = {
+ get: (url) => ky.get(url).json(),
+ post: (url, data) => ky.post(url, { json: data }).json(),
+ // ... + error handling in interceptors
+};
+```
+
+### CSS Organization
+**Current**: 741-line monolithic file
+**Better**: Split by concern + CSS variables
+```css
+/* config.css */
+:root {
+ --primary-color: #4A90E2;
+ --bg-color: #f5f5f5;
+ --rack-width: 520px;
+ --rack-height: 1510px;
+}
+
+/* components/rack.css */
+/* components/device.css */
+/* layout.css */
+/* theme.css */
+```
+
+### Configuration
+**Current**: Hard-coded everywhere
+**Better**: `config.js`
+```javascript
+export const RACK_CONFIG = {
+ WIDTH: 520,
+ HEIGHT: 1510,
+ SLOTS: 42,
+ MARGIN: { top: 10, right: 10, bottom: 10, left: 10 }
+};
+
+export const GRID_CONFIG = {
+ HORIZONTAL: 600,
+ VERTICAL: 1610
+};
+```
+
+### Validation
+**Current**: None
+**Better**: **Zod** for schema validation
+```javascript
+const RackSchema = z.object({
+ name: z.string().min(1).max(50),
+ x: z.number().finite(),
+ y: z.number().finite()
+});
+```
+
+---
+
+## Recommended Refactoring Strategy
+
+### Option A: In-Place Refactoring (Direct Edit)
+**Pros**:
+- Keep git history
+- Incremental changes
+- Can test at each step
+
+**Cons**:
+- Risk of breaking working features
+- Harder to compare old vs new
+- No fallback
+
+### Option B: Release Candidate Approach (Recommended)
+**Pros**:
+- Clean slate while keeping original
+- Can compare side-by-side
+- Easy to revert
+- Clear migration path
+
+**Cons**:
+- Duplicated files temporarily
+- Need to sync any new features
+
+**Proposed Structure**:
+```
+datacenter-designer/
+├── rc/ # New refactored version
+│ ├── server/
+│ │ ├── config.js # NEW: Configuration
+│ │ ├── db.js # REFACTORED: better-sqlite3
+│ │ ├── routes/ # NEW: Split routes
+│ │ │ ├── projects.js
+│ │ │ ├── racks.js
+│ │ │ ├── devices.js
+│ │ │ └── connections.js
+│ │ └── server.js # SIMPLIFIED: Just setup
+│ ├── public/
+│ │ ├── index.html # UPDATED: More semantic
+│ │ ├── css/
+│ │ │ ├── config.css # NEW: CSS variables
+│ │ │ ├── layout.css
+│ │ │ ├── components.css
+│ │ │ └── theme.css
+│ │ └── js/
+│ │ ├── config.js # NEW: Constants
+│ │ ├── lib/
+│ │ │ ├── api.js # REFACTORED: Simplified
+│ │ │ ├── state.js # NEW: State manager
+│ │ │ └── ui.js # NEW: UI utilities
+│ │ ├── managers/
+│ │ │ ├── rack-manager.js
+│ │ │ ├── device-manager.js
+│ │ │ ├── connection-manager.js
+│ │ │ └── table-manager.js
+│ │ └── app.js # SLIMMED: Orchestrator only
+│ └── database/
+│ └── .gitkeep
+├── server/ # OLD: Keep during transition
+├── public/ # OLD: Keep during transition
+├── .gitignore
+├── CLAUDE.md # This file
+├── README.md
+└── package.json
+```
+
+---
+
+## Refactoring Checklist
+
+### Phase 1: Foundation (Day 1-2)
+- [ ] Create `rc/` directory structure
+- [ ] Setup `.gitignore` properly
+- [ ] Create `rc/server/config.js` with all constants
+- [ ] Create `rc/public/js/config.js` with frontend constants
+- [ ] Port database to `better-sqlite3`
+- [ ] Create migration system
+- [ ] Split CSS into modules with CSS variables
+- [ ] Make layout responsive (media queries)
+
+### Phase 2: Backend (Day 3)
+- [ ] Split routes into separate files
+- [ ] Add input validation (Zod or similar)
+- [ ] Implement error middleware
+- [ ] Add request logging
+- [ ] Create API documentation (OpenAPI/Swagger)
+
+### Phase 3: Frontend Core (Day 4-5)
+- [ ] Extract API client to `lib/api.js`
+- [ ] Extract state management to `lib/state.js`
+- [ ] Extract UI utilities to `lib/ui.js` (modal manager, toast, etc.)
+- [ ] Slim down `app.js` to orchestration only
+- [ ] Refactor managers to use new state system
+
+### Phase 4: Features & UX (Day 6-7)
+- [ ] Add toast notifications (replace `alert()`)
+- [ ] Add loading states
+- [ ] Add keyboard shortcuts
+- [ ] Add undo/redo system
+- [ ] Add search/filter
+- [ ] Add batch operations
+- [ ] Improve error messages
+
+### Phase 5: Testing & Documentation (Day 8)
+- [ ] Add unit tests for backend
+- [ ] Add integration tests
+- [ ] Update README.md
+- [ ] Update CLAUDE.md
+- [ ] Add inline JSDoc comments
+- [ ] Performance testing
+
+### Phase 6: Migration (Day 9-10)
+- [ ] Test thoroughly
+- [ ] Migrate data from old DB if needed
+- [ ] Move `rc/` to root
+- [ ] Archive old code to `old/`
+- [ ] Update package.json scripts
+- [ ] Final testing
+
+---
+
+## Key Principles to Follow
+
+### KISS (Keep It Simple, Stupid)
+- Avoid abstractions unless proven necessary
+- Prefer composition over inheritance
+- Use vanilla JS unless library provides clear value
+
+### DRY (Don't Repeat Yourself)
+- Extract repeated patterns (error handling, modal management)
+- Use constants instead of magic numbers
+- Create reusable utility functions
+
+### SOLID Principles
+- **S**: Single Responsibility - Each module does one thing
+- **O**: Open/Closed - Extend without modifying
+- **L**: Liskov Substitution - Subtypes must be substitutable
+- **I**: Interface Segregation - Many specific interfaces > one general
+- **D**: Dependency Inversion - Depend on abstractions
+
+### WORM (Write Once, Read Many)
+- Optimize for read performance
+- Use indices on frequently queried columns
+- Cache frequently accessed data
+- Minimize database writes
+
+### Additional Principles
+- **Fail Fast**: Validate early, throw errors quickly
+- **Convention over Configuration**: Sensible defaults
+- **Progressive Enhancement**: Works without JS, enhanced with it
+- **Accessibility**: Keyboard navigation, ARIA labels, screen reader support
+
+---
+
+## Responsive Design Strategy
+
+### Breakpoints
+```css
+/* Mobile: < 768px */
+@media (max-width: 767px) {
+ /* Stack toolbar items, hide non-essential controls */
+}
+
+/* Tablet: 768px - 1024px */
+@media (min-width: 768px) and (max-width: 1024px) {
+ /* Optimize for touch, larger buttons */
+}
+
+/* Desktop: > 1024px */
+@media (min-width: 1025px) {
+ /* Full feature set */
+}
+```
+
+### Layout Strategies
+1. **Toolbar**: Collapse to hamburger menu on mobile
+2. **Canvas**: Always full viewport minus toolbar
+3. **Tables**: Horizontal scroll on mobile, fixed on desktop
+4. **Modals**: Full-screen on mobile, centered on desktop
+5. **Context Menus**: Bottom sheet on mobile, popup on desktop
+
+### Touch Support
+- Increase hit targets to 44x44px minimum
+- Add touch gestures (pinch-zoom, two-finger pan)
+- Show hover states on touch as active states
+- Use native select/input elements on mobile
+
+---
+
+## Performance Considerations
+
+### Canvas Rendering
+- Use `batchDraw()` instead of `draw()` for multiple updates
+- Implement viewport culling (don't render off-screen items)
+- Use `cache()` for static shapes
+- Debounce pan/zoom updates
+
+### Database
+- Add indices on foreign keys
+- Use transactions for bulk operations
+- Implement connection pooling (if moving to client-server)
+- Lazy-load connections (don't load all at startup)
+
+### Network
+- Implement request debouncing for auto-save
+- Use HTTP caching headers
+- Compress API responses (gzip)
+- Implement pagination for large datasets
+
+---
+
+## Security Considerations
+
+### SQL Injection
+- ✅ Already using parameterized queries
+- [ ] Add input sanitization layer
+- [ ] Validate all inputs on server
+
+### XSS (Cross-Site Scripting)
+- [ ] Sanitize user input before displaying
+- [ ] Use `textContent` instead of `innerHTML` where possible
+- [ ] Add Content Security Policy headers
+
+### CSRF (Cross-Site Request Forgery)
+- [ ] Add CSRF tokens for state-changing operations
+- [ ] Use SameSite cookies
+
+### File Upload
+- If adding import from file:
+ - [ ] Validate file size
+ - [ ] Validate file type
+ - [ ] Scan for malicious content
+
+---
+
+## .gitignore Strategy
+
+To avoid pushing sensitive or generated files:
+
+```gitignore
+# Dependencies
+node_modules/
+package-lock.json # Optional: some include, some exclude
+
+# Database (user data)
+database/*.db
+database/*.db-shm
+database/*.db-wal
+*.db
+*.db-shm
+*.db-wal
+
+# Logs
+logs/
+*.log
+npm-debug.log*
+
+# Environment variables
+.env
+.env.local
+.env.*.local
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+.DS_Store
+
+# Build outputs (if added later)
+dist/
+build/
+.cache/
+
+# Temporary files
+tmp/
+temp/
+*.tmp
+
+# OS files
+Thumbs.db
+.DS_Store
+
+# Claude-specific (optional)
+.claude/
+```
+
+**Important**: If database contains sample data for demonstrations, create a separate `database/sample.db` and explicitly include it:
+```gitignore
+# Ignore user databases
+database/*.db
+# But include sample
+!database/sample.db
+```
+
+---
+
+## Testing Strategy
+
+### Unit Tests
+- Test database operations in isolation
+- Test utility functions
+- Test state management
+- Use: **Vitest** or **Jest**
+
+### Integration Tests
+- Test API endpoints
+- Test full CRUD workflows
+- Use: **Supertest** + **Vitest**
+
+### E2E Tests
+- Test critical user journeys
+- Test cross-browser compatibility
+- Use: **Playwright** or **Cypress**
+
+### Test Structure
+```javascript
+// Example: tests/db.test.js
+import { describe, it, expect, beforeEach } from 'vitest';
+import db from '../server/db.js';
+
+describe('Database - Racks', () => {
+ beforeEach(() => {
+ // Reset database to known state
+ });
+
+ it('should create a rack', () => {
+ const rack = db.createRack(1, 'RACK01', 0, 0);
+ expect(rack.id).toBeDefined();
+ expect(rack.name).toBe('RACK01');
+ });
+
+ it('should not create duplicate rack names in same project', () => {
+ db.createRack(1, 'RACK01', 0, 0);
+ expect(() => db.createRack(1, 'RACK01', 100, 0)).toThrow();
+ });
+});
+```
+
+---
+
+## Future Enhancements Roadmap
+
+### Phase 1: Core UX Improvements
+- [ ] Undo/redo system
+- [ ] Enhanced keyboard shortcuts
+- [ ] Toast notifications (replace browser alerts)
+- [ ] Loading states
+- [ ] Search and filter functionality
+- [ ] Batch operations (select multiple items)
+- [ ] Auto-save functionality
+
+### Phase 2: Multi-User Support
+- [ ] User management system
+- [ ] Authentication and authorization
+- [ ] OIDC-compatible external SSO integration
+- [ ] Project sharing between users
+- [ ] Role-based access control (view/edit/admin)
+- [ ] User profile management
+
+### Phase 3: Collaboration
+- [ ] Real-time collaborative editing (WebSocket)
+- [ ] Concurrent access management
+- [ ] User presence indicators
+- [ ] Change notifications
+- [ ] Conflict resolution
+- [ ] Version history / snapshots
+- [ ] Activity audit logging
+
+### Phase 4: Advanced Features
+- [ ] Custom device types (user-defined)
+- [ ] Cable labeling and management
+- [ ] VLAN visualization
+- [ ] IP address management
+- [ ] Documentation generation (diagrams, BOMs)
+- [ ] Import from other formats (Visio, Lucidchart)
+- [ ] PostgreSQL/MySQL support
+
+### Phase 5: Platform Enhancements
+- [ ] Dark mode
+- [ ] Mobile/tablet responsive design
+- [ ] 3D rack visualization
+- [ ] Cable routing visualization
+- [ ] Enhanced export formats
+- [ ] API for 3rd party integrations
+- [ ] Backup/restore automation
+
+---
+
+## Common Gotchas & Pitfalls
+
+### Konva-Specific
+1. **Coordinate Systems**: Konva uses top-left origin, racks use bottom-up slot numbering (U1 at bottom)
+2. **Event Bubbling**: Events on canvas can propagate unexpectedly - use `e.cancelBubble = true`
+3. **Memory Leaks**: Always destroy unused layers/shapes
+4. **Performance**: Too many shapes? Use `virtualizer` pattern
+
+### SQLite-Specific
+1. **Foreign Keys**: Must enable explicitly with `PRAGMA foreign_keys = ON`
+2. **Concurrency**: Only one write at a time - queue writes or use WAL mode
+3. **Migrations**: No built-in system - roll your own or use library
+4. **Type System**: Dynamic typing can surprise you (stores as INTEGER, returns as Number)
+
+### CSS/Canvas Interaction
+1. **Canvas Size**: Must set both CSS size and Konva stage size
+2. **DPI Scaling**: High-DPI displays may need pixel ratio adjustment
+3. **Touch Events**: Handle both mouse and touch events separately
+
+---
+
+## Contributing Guidelines
+
+### Code Style
+- **Indentation**: 2 spaces
+- **Quotes**: Single quotes for strings
+- **Semicolons**: Required
+- **Naming**: camelCase for variables/functions, PascalCase for classes
+- **Line Length**: 100 characters max
+- **Comments**: JSDoc for public APIs, inline for complex logic
+
+### Commit Messages
+Follow Conventional Commits:
+```
+feat: add undo/redo system
+fix: correct device positioning in logical view
+docs: update CLAUDE.md with testing strategy
+refactor: extract API client to separate file
+test: add unit tests for database operations
+chore: update dependencies
+```
+
+### Pull Request Process
+1. Create feature branch from `main`
+2. Make changes with tests
+3. Update documentation
+4. Run linter and tests
+5. Submit PR with description
+6. Address review comments
+7. Merge when approved
+
+---
+
+## Debugging Tips
+
+### Backend Debugging
+```bash
+# Enable SQLite query logging
+DEBUG=sqlite3 npm start
+
+# Enable Express debug logging
+DEBUG=express:* npm start
+
+# Enable all debug logging
+DEBUG=* npm start
+```
+
+### Frontend Debugging
+```javascript
+// Enable Konva debugging
+Konva.showWarnings = true;
+
+// Log all API calls
+api.request = new Proxy(api.request, {
+ apply(target, thisArg, args) {
+ console.log('[API]', args[0], args[1]);
+ return target.apply(thisArg, args);
+ }
+});
+```
+
+### Common Issues
+1. **Canvas not updating**: Call `.batchDraw()` on layer
+2. **Events not firing**: Check if shape is `listening(true)`
+3. **Drag not working**: Check if shape is `draggable(true)` and parent layer is listening
+4. **Database locked**: Close all connections, check for transactions
+5. **CORS errors**: Ensure frontend and backend are on same origin or CORS is enabled
+
+---
+
+## Glossary
+
+- **Rack**: Physical equipment cabinet with 42U slots
+- **U / Rack Unit**: Standard height measurement (1U = 1.75 inches)
+- **Slot**: Position within a rack (U1 at bottom, U42 at top)
+- **Device**: Network equipment (switch, router, firewall, etc.)
+- **Form Factor**: Device height in rack units (1U, 2U, 4U, etc.)
+- **Connection**: Network link between two device ports
+- **Waypoint**: Intermediate point in a connection line for routing
+- **Physical View**: Rack-based layout showing physical positioning
+- **Logical View**: Topology view ignoring physical constraints
+- **Project**: Isolated workspace containing racks, devices, connections
+- **Canvas**: Konva.js drawing surface
+- **Stage**: Top-level Konva container
+- **Layer**: Konva organizational unit (rack layer, device layer, connection layer)
+- **Shape**: Individual Konva element (rectangle, text, line, etc.)
+
+---
+
+## Links & Resources
+
+### Dependencies
+- [Konva.js Documentation](https://konvajs.org/docs/)
+- [ag-Grid Documentation](https://www.ag-grid.com/javascript-data-grid/)
+- [SheetJS Documentation](https://docs.sheetjs.com/)
+- [SQLite Documentation](https://www.sqlite.org/docs.html)
+- [better-sqlite3](https://github.com/WiseLibs/better-sqlite3)
+
+### Design Patterns
+- [JavaScript Design Patterns](https://www.patterns.dev/)
+- [SOLID Principles](https://www.digitalocean.com/community/conceptual_articles/s-o-l-i-d-the-first-five-principles-of-object-oriented-design)
+- [Martin Fowler - Refactoring](https://refactoring.com/)
+
+### Best Practices
+- [Airbnb JavaScript Style Guide](https://github.com/airbnb/javascript)
+- [Clean Code JavaScript](https://github.com/ryanmcdermott/clean-code-javascript)
+- [Web Performance Best Practices](https://web.dev/fast/)
+
+---
+
+## Revision History
+
+| Version | Date | Author | Changes |
+|---------|------|--------|---------|
+| 0.1.0 | 2025-10-26 | Claude | Initial release - comprehensive documentation |
+
+---
+
+## License
+
+MIT License - See LICENSE file for details
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0236e95
--- /dev/null
+++ b/README.md
@@ -0,0 +1,306 @@
+# Datacenter Designer
+
+A lightweight web application for visual design of datacenter infrastructure. Plan rack layouts, place devices, and map network connections with an intuitive drag-and-drop interface.
+
+
+
+
+
+## Features
+
+- **Visual Rack Layout**: Drag-and-drop racks on an infinite canvas
+- **Device Management**: Place network devices in rack slots (U1-U42) with multi-unit form factors
+- **Network Connections**: Map port-to-port connections between devices
+- **Dual Views**: Physical (rack-based) and logical (topology) views
+- **Project Management**: Organize multiple datacenter designs
+- **Table Views**: Spreadsheet-style editing with ag-Grid integration
+- **Export/Import**: Save projects as JSON or export tables to Excel (.xlsx)
+- **Zero Configuration**: SQLite database, no setup required
+
+## Quick Start
+
+### Prerequisites
+
+- Node.js 14.0 or higher
+- npm (comes with Node.js)
+
+### Installation
+
+```bash
+# Clone the repository
+git clone https://github.com/yourusername/datacenter-designer.git
+cd datacenter-designer
+
+# Install dependencies
+npm install
+
+# Start the server
+npm start
+```
+
+The application will be available at **http://localhost:3000**
+
+## Usage Guide
+
+### Creating Racks
+
+1. Right-click on empty canvas → "Add Rack(s)"
+2. Configure the modal:
+ - **Number of racks**: Create 1-20 racks at once
+ - **Name prefix**: Custom naming prefix (default: RACK)
+ - **Position**: Continue current row, new row below, or new row above
+3. Click "Create" - racks will be generated with sequential numbering (RACK01, RACK02, etc.)
+
+### Adding Devices
+
+1. Right-click inside a rack → Select device type from modal
+2. Enter device name
+3. Device is placed in the next available slot
+4. Supports multi-unit form factors (1U to 42U)
+
+### Creating Connections
+
+1. Right-click on source device → "Create Connection"
+2. Select source port (used ports are grayed out)
+3. Click on target device
+4. Select target port
+5. Connection line is drawn automatically
+
+**Managing Connection Waypoints:**
+- **Add waypoint**: Double-click on the connection line
+- **Move waypoint**: Drag the waypoint handle to reposition
+- **Delete waypoint**: Double-click on a waypoint handle
+- **Note**: Edge connection points (at devices) cannot be deleted
+
+### Navigation
+
+- **Zoom**: `Ctrl` + Mouse Wheel
+- **Pan**: Click and drag on empty canvas
+- **Lock/Unlock Racks**: Right-click on rack → Toggle lock (prevents accidental movement)
+- **Delete**: Right-click on item → "Delete", or select connection and press `Delete` key
+
+### Views
+
+- **Physical View**: Shows devices arranged in racks (default)
+- **Logical View**: Shows network topology without rack constraints
+- **Table Views**: Toggle Racks, Devices, or Connections table for spreadsheet-style editing
+
+### Export/Import
+
+- **Export Project**: Saves complete project as JSON (includes all racks, devices, connections, and settings)
+- **Import Project**: Load a previously exported project (creates new project with " (Imported)" suffix)
+- **Export to Excel**: Downloads .xlsx file with 3 sheets: Racks, Devices, Connections
+
+## Technology Stack
+
+| Layer | Technology | Purpose |
+|-------|-----------|---------|
+| **Frontend** | Vanilla JavaScript (ES6 modules) | No build process, direct browser execution |
+| | Konva.js | HTML5 Canvas library for visual elements |
+| | ag-Grid | Spreadsheet-style table component |
+| | SheetJS | Excel export functionality |
+| **Backend** | Node.js + Express | RESTful API server with modular routes |
+| **Database** | better-sqlite3 | Synchronous SQLite, 2-3x faster than async |
+
+## Project Structure
+
+```
+datacenter-designer/
+├── server/ # Backend
+│ ├── config.js # Configuration constants
+│ ├── server.js # Express application setup
+│ ├── db.js # Database operations (better-sqlite3)
+│ ├── routes/ # Modular API routes
+│ │ ├── projects.js
+│ │ ├── racks.js
+│ │ ├── devices.js
+│ │ └── connections.js
+│ └── lib/
+│ └── errorHandler.js # Centralized error handling
+├── public/ # Frontend (served statically)
+│ ├── index.html # Main HTML file
+│ ├── css/
+│ │ ├── config.css # CSS variables and theming
+│ │ └── style.css # Component styles
+│ └── js/
+│ ├── config.js # Frontend configuration
+│ ├── app.js # Main application controller
+│ ├── lib/ # Shared utilities
+│ │ ├── api.js # API client
+│ │ └── ui.js # UI utilities (Toast, Modal, etc.)
+│ └── managers/ # Feature modules
+│ ├── rack-manager.js # Rack rendering and interaction
+│ ├── device-manager.js # Device rendering and interaction
+│ ├── connection-manager.js # Connection lines and waypoints
+│ └── table-manager.js # Table view management
+├── database/ # SQLite database (auto-created)
+│ └── datacenter.db
+└── package.json
+```
+
+## Configuration
+
+The application uses sensible defaults and requires no configuration. Key parameters:
+
+- **Port**: 3000 (change via `PORT` environment variable)
+- **Database**: `database/datacenter.db` (auto-created on first run)
+- **Rack Dimensions**: 520px × 1485px (42U standard)
+- **Grid Spacing**: 600px horizontal, 1585px vertical
+
+## Database Schema
+
+### Core Tables
+
+- **projects**: Isolated workspaces
+- **racks**: Physical rack cabinets with positions
+- **device_types**: Library of available device types (switches, routers, etc.)
+- **devices**: Device instances placed in racks
+- **connections**: Port-to-port network connections
+
+### Key Relationships
+
+- Projects → Racks (one-to-many)
+- Racks → Devices (one-to-many)
+- Devices → Connections (many-to-many via source/target)
+
+## Keyboard Shortcuts
+
+| Key | Action |
+|-----|--------|
+| `Ctrl + Wheel` | Zoom in/out |
+| `Esc` | Cancel connection mode / Deselect |
+| `Delete` / `Backspace` | Delete selected connection |
+
+## Browser Support
+
+- ✅ Chrome 90+ (recommended)
+- ✅ Firefox 88+
+- ✅ Safari 14+
+- ✅ Edge 90+
+
+## Development
+
+### Running in Development Mode
+
+```bash
+npm start
+# or
+npm run dev
+```
+
+Both commands start the same server on port 3000. The application uses ES6 modules directly, so no build process is needed.
+
+### Project Principles
+
+This project follows KISS, DRY, and SOLID principles:
+
+- **Simple tech stack**: No build tools, no heavy frameworks
+- **Modular design**: Separate managers for racks, devices, connections, and tables
+- **RESTful API**: Clean separation between frontend and backend
+- **SQLite**: Lightweight, zero-config database perfect for this use case
+
+For detailed technical documentation, see [CLAUDE.md](./CLAUDE.md).
+
+## Troubleshooting
+
+### Port Already in Use
+
+```bash
+# Linux/Mac
+PORT=3001 npm start
+
+# Windows
+set PORT=3001 && npm start
+```
+
+### Database Locked Error
+
+```bash
+# Stop all running instances
+pkill -f "node server/server.js"
+
+# Delete lock files
+rm database/*.db-shm database/*.db-wal
+
+# Restart
+npm start
+```
+
+### Canvas Not Rendering
+
+- Clear browser cache and reload
+- Check browser console for errors
+- Ensure JavaScript is enabled
+- Try a different browser
+
+## Contributing
+
+Contributions are welcome! Please follow these guidelines:
+
+1. Fork the repository
+2. Create a feature branch (`git checkout -b feature/amazing-feature`)
+3. Commit your changes (`git commit -m 'feat: add amazing feature'`)
+4. Push to the branch (`git push origin feature/amazing-feature`)
+5. Open a Pull Request
+
+See [CLAUDE.md](./CLAUDE.md) for detailed development guidelines.
+
+## Roadmap
+
+### Phase 1: Core UX Improvements
+- [ ] Undo/redo functionality
+- [ ] Enhanced keyboard shortcuts
+- [ ] Toast notifications (replace browser alerts)
+- [ ] Search and filter
+- [ ] Batch operations (select multiple items)
+- [ ] Auto-save functionality
+
+### Phase 2: Multi-User Support
+- [ ] User management system
+- [ ] Authentication and authorization
+- [ ] OIDC-compatible external SSO integration
+- [ ] Project sharing between users
+- [ ] Role-based access control (view/edit/admin)
+
+### Phase 3: Collaboration
+- [ ] Real-time collaborative editing
+- [ ] Concurrent access management
+- [ ] User presence indicators
+- [ ] Change notifications
+- [ ] Conflict resolution
+
+### Phase 4: Advanced Features
+- [ ] Custom device types
+- [ ] Cable labeling and management
+- [ ] VLAN visualization
+- [ ] IP address management
+- [ ] Documentation generation
+
+### Phase 5: Platform Enhancements
+- [ ] Dark mode
+- [ ] Mobile/tablet responsive design
+- [ ] 3D rack visualization
+- [ ] Enhanced export formats
+- [ ] API for third-party integrations
+
+## License
+
+MIT License - feel free to use this project for any purpose.
+
+## Acknowledgments
+
+- [Konva.js](https://konvajs.org/) - HTML5 Canvas library
+- [ag-Grid](https://www.ag-grid.com/) - Feature-rich data grid
+- [SheetJS](https://sheetjs.com/) - Excel file generation
+- [Express.js](https://expressjs.com/) - Web framework
+- [SQLite](https://www.sqlite.org/) - Embedded database
+
+## Support
+
+- **Issues**: [GitHub Issues](https://github.com/yourusername/datacenter-designer/issues)
+- **Discussions**: [GitHub Discussions](https://github.com/yourusername/datacenter-designer/discussions)
+- **Documentation**: See [CLAUDE.md](./CLAUDE.md) for technical details
+
+---
+
+**Built with ❤️ and KISS principles**
diff --git a/archive/old_public/css/style.css b/archive/old_public/css/style.css
new file mode 100644
index 0000000..4f0f1b9
--- /dev/null
+++ b/archive/old_public/css/style.css
@@ -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;
+}
diff --git a/archive/old_public/favicon.svg b/archive/old_public/favicon.svg
new file mode 100644
index 0000000..ae8e6ef
--- /dev/null
+++ b/archive/old_public/favicon.svg
@@ -0,0 +1,3 @@
+
diff --git a/archive/old_public/index.html b/archive/old_public/index.html
new file mode 100644
index 0000000..7a5019e
--- /dev/null
+++ b/archive/old_public/index.html
@@ -0,0 +1,193 @@
+
+
+
+
+
+ Datacenter Designer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Preview:
+
RACK01, RACK02, RACK03
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/archive/old_public/js/app.js b/archive/old_public/js/app.js
new file mode 100644
index 0000000..ee74369
--- /dev/null
+++ b/archive/old_public/js/app.js
@@ -0,0 +1,1719 @@
+import { RackManager } from './rack-manager.js';
+import { DeviceManager } from './device-manager.js';
+import { ConnectionManager } from './connection-manager.js';
+import { TableManager } from './table-manager.js';
+
+class API {
+ constructor() {
+ this.currentProjectId = 1; // Default project
+ }
+
+ setProjectId(projectId) {
+ this.currentProjectId = projectId;
+ }
+
+ async request(url, options = {}) {
+ const response = await fetch(url, {
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ },
+ ...options
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || 'Request failed');
+ }
+
+ return response.json();
+ }
+
+ // Projects
+ getProjects() {
+ return this.request('/api/projects');
+ }
+
+ getProject(id) {
+ return this.request(`/api/projects/${id}`);
+ }
+
+ createProject(name, description) {
+ return this.request('/api/projects', {
+ method: 'POST',
+ body: JSON.stringify({ name, description })
+ });
+ }
+
+ deleteProject(id) {
+ return this.request(`/api/projects/${id}`, { method: 'DELETE' });
+ }
+
+ // Racks
+ getRacks() {
+ return this.request(`/api/racks?projectId=${this.currentProjectId}`);
+ }
+
+ getNextRackName(prefix) {
+ return this.request(`/api/racks/next-name?projectId=${this.currentProjectId}&prefix=${prefix}`).then(r => r.name);
+ }
+
+ createRack(name, x, y) {
+ return this.request('/api/racks', {
+ method: 'POST',
+ body: JSON.stringify({ projectId: this.currentProjectId, name, x, y })
+ });
+ }
+
+ updateRackPosition(id, x, y) {
+ return this.request(`/api/racks/${id}/position`, {
+ method: 'PUT',
+ body: JSON.stringify({ x, y })
+ });
+ }
+
+ updateRackName(id, name) {
+ return this.request(`/api/racks/${id}/name`, {
+ method: 'PUT',
+ body: JSON.stringify({ name })
+ });
+ }
+
+ deleteRack(id) {
+ return this.request(`/api/racks/${id}`, { method: 'DELETE' });
+ }
+
+ // Device Types
+ getDeviceTypes() {
+ return this.request('/api/device-types');
+ }
+
+ // Devices
+ getDevices() {
+ return this.request(`/api/devices?projectId=${this.currentProjectId}`);
+ }
+
+ createDevice(deviceTypeId, rackId, position, name) {
+ return this.request('/api/devices', {
+ method: 'POST',
+ body: JSON.stringify({ deviceTypeId, rackId, position, name })
+ });
+ }
+
+ deleteDevice(id) {
+ return this.request(`/api/devices/${id}`, { method: 'DELETE' });
+ }
+
+ updateDeviceName(id, name) {
+ return this.request(`/api/devices/${id}/name`, {
+ method: 'PUT',
+ body: JSON.stringify({ name })
+ });
+ }
+
+ getUsedPorts(deviceId) {
+ return this.request(`/api/devices/${deviceId}/used-ports`);
+ }
+
+ // Connections
+ getConnections() {
+ return this.request(`/api/connections?projectId=${this.currentProjectId}`);
+ }
+
+ createConnection(sourceDeviceId, sourcePort, targetDeviceId, targetPort) {
+ return this.request('/api/connections', {
+ method: 'POST',
+ body: JSON.stringify({ sourceDeviceId, sourcePort, targetDeviceId, targetPort })
+ });
+ }
+
+ updateConnectionWaypoints(id, waypoints, view = null) {
+ return this.request(`/api/connections/${id}/waypoints`, {
+ method: 'PUT',
+ body: JSON.stringify({ waypoints, view })
+ });
+ }
+
+ deleteConnection(id) {
+ return this.request(`/api/connections/${id}`, { method: 'DELETE' });
+ }
+}
+
+class DatacenterDesigner {
+ constructor() {
+ this.api = new API();
+ this.stage = null;
+ this.layer = null;
+ this.rackManager = null;
+ this.deviceManager = null;
+ this.connectionManager = null;
+ this.tableManager = null;
+ this.currentScale = 1;
+ this.minScale = 0.1;
+ this.maxScale = 3;
+ this.currentCanvasView = 'physical'; // 'physical' or 'logical'
+ this.currentTableView = null; // null, 'racks', 'devices', or 'connections'
+ }
+
+ async init() {
+ this.setupCanvas();
+ this.setupManagers();
+ await this.loadProjects();
+ // Load spacing after project ID is set
+ this.rackManager.loadSpacing();
+ await this.loadData();
+ this.setupEventListeners();
+ this.setupContextMenu();
+ this.setupZoomAndPan();
+ this.setupResizeHandle();
+ }
+
+ async loadProjects() {
+ try {
+ const projects = await this.api.getProjects();
+ const projectSelect = document.getElementById('projectSelect');
+
+ projectSelect.innerHTML = '';
+
+ // Add existing projects
+ projects.forEach(project => {
+ const option = document.createElement('option');
+ option.value = project.id;
+ option.textContent = project.name;
+ projectSelect.appendChild(option);
+ });
+
+ // Add separator
+ const separator = document.createElement('option');
+ separator.disabled = true;
+ separator.textContent = '─────────────────────';
+ projectSelect.appendChild(separator);
+
+ // Add "Create New Project" option
+ const createOption = document.createElement('option');
+ createOption.value = '__create__';
+ createOption.textContent = 'Create New Project';
+ projectSelect.appendChild(createOption);
+
+ // Add "Manage Projects" option
+ const manageOption = document.createElement('option');
+ manageOption.value = '__manage__';
+ manageOption.textContent = 'Manage Projects';
+ projectSelect.appendChild(manageOption);
+
+ // Set current project
+ const currentProjectId = parseInt(localStorage.getItem('currentProjectId') || '1');
+ projectSelect.value = currentProjectId;
+ this.api.setProjectId(currentProjectId);
+ } catch (err) {
+ console.error('Failed to load projects:', err);
+ }
+ }
+
+ async switchProject(projectId) {
+ this.api.setProjectId(projectId);
+ localStorage.setItem('currentProjectId', projectId);
+
+ // Clear canvas
+ this.rackManager.racks.clear();
+ this.deviceManager.devices.clear();
+ this.connectionManager.connections.clear();
+ this.layer.destroyChildren();
+ this.connectionManager.getConnectionLayer().destroyChildren();
+
+ // Reload spacing for this project
+ this.rackManager.loadSpacing();
+
+ // Reset view (pan and zoom)
+ this.resetView();
+
+ // Reload data for new project
+ await this.loadData();
+ this.layer.batchDraw();
+ this.connectionManager.getConnectionLayer().batchDraw();
+ }
+
+ setupCanvas() {
+ const container = document.getElementById('canvasWrapper');
+ const width = container.offsetWidth;
+ const height = container.offsetHeight;
+
+ this.stage = new Konva.Stage({
+ container: 'canvasWrapper',
+ width: width,
+ height: height
+ });
+
+ this.layer = new Konva.Layer();
+ this.stage.add(this.layer);
+
+ // Add initial offset for visual margins (without changing grid coordinates)
+ this.stage.position({ x: 50, y: 50 });
+ }
+
+ setupManagers() {
+ // Create device manager first (needed by rack manager)
+ this.deviceManager = new DeviceManager(this.layer, this.api, null);
+
+ // Create rack manager with device manager reference
+ this.rackManager = new RackManager(this.layer, this.api, this.deviceManager);
+
+ // Set rack manager reference in device manager
+ this.deviceManager.rackManager = this.rackManager;
+
+ this.connectionManager = new ConnectionManager(
+ this.layer,
+ this.api,
+ this.deviceManager,
+ this.rackManager
+ );
+
+ // Add connection layer on top of main layer so connections are visible
+ this.stage.add(this.connectionManager.getConnectionLayer());
+ this.connectionManager.getConnectionLayer().moveToTop();
+
+ // Create table manager
+ this.tableManager = new TableManager(
+ this.api,
+ this.rackManager,
+ this.deviceManager,
+ this.connectionManager
+ );
+ }
+
+ async loadData() {
+
+ await this.deviceManager.loadDeviceTypes();
+ await this.rackManager.loadRacks();
+ await this.deviceManager.loadDevices();
+ await this.connectionManager.loadConnections();
+
+ }
+
+ setupZoomAndPan() {
+ const container = this.stage.container();
+
+ // Zoom with Ctrl + Wheel
+ container.addEventListener('wheel', (e) => {
+ if (!e.ctrlKey) return;
+
+ e.preventDefault();
+
+ const oldScale = this.stage.scaleX();
+ const pointer = this.stage.getPointerPosition();
+
+ const mousePointTo = {
+ x: (pointer.x - this.stage.x()) / oldScale,
+ y: (pointer.y - this.stage.y()) / oldScale
+ };
+
+ const delta = e.deltaY > 0 ? 0.9 : 1.1;
+ let newScale = oldScale * delta;
+
+ // Clamp scale
+ newScale = Math.max(this.minScale, Math.min(this.maxScale, newScale));
+
+ this.stage.scale({ x: newScale, y: newScale });
+
+ const newPos = {
+ x: pointer.x - mousePointTo.x * newScale,
+ y: pointer.y - mousePointTo.y * newScale
+ };
+
+ this.stage.position(newPos);
+ this.stage.batchDraw();
+
+ this.currentScale = newScale;
+ this.updateZoomDisplay(newScale);
+ });
+
+ // Pan with Ctrl + drag
+ let isPanning = false;
+ let startPos = null;
+
+ // Listen to mousedown on container level instead of stage
+ // This way we can control panning without interfering with Konva's drag system
+ container.addEventListener('mousedown', (evt) => {
+ // Ignore right-clicks for panning
+ if (evt.button === 2) {
+ return;
+ }
+
+ // Hide context menu on left click
+ this.hideContextMenu();
+
+ // Only pan when Ctrl is held down
+ if (evt.ctrlKey) {
+ // Check if we're clicking on a Konva element
+ const stage = this.stage;
+ const pos = stage.getPointerPosition();
+
+ if (!pos) return;
+
+ // Get what's under the cursor
+ const shape = stage.getIntersection(pos);
+
+ // Don't pan if clicking on a draggable element
+ if (shape && shape.draggable && shape.draggable()) {
+ console.log('Clicked on draggable element, not panning');
+ return;
+ }
+
+ isPanning = true;
+ startPos = pos;
+ container.style.cursor = 'grabbing';
+ }
+ });
+
+ container.addEventListener('mousemove', (evt) => {
+ if (!isPanning) return;
+
+ const pos = this.stage.getPointerPosition();
+ if (!pos) return;
+
+ const dx = pos.x - startPos.x;
+ const dy = pos.y - startPos.y;
+
+ this.stage.position({
+ x: this.stage.x() + dx,
+ y: this.stage.y() + dy
+ });
+
+ startPos = pos;
+ this.stage.batchDraw();
+ });
+
+ container.addEventListener('mouseup', () => {
+ isPanning = false;
+ container.style.cursor = 'default';
+ });
+
+ container.addEventListener('mouseleave', () => {
+ isPanning = false;
+ container.style.cursor = 'default';
+ });
+ }
+
+ setupEventListeners() {
+ // Canvas view switcher (Physical / Logical)
+ document.getElementById('physicalViewBtn').addEventListener('click', () => {
+ this.switchCanvasView('physical');
+ });
+
+ document.getElementById('logicalViewBtn').addEventListener('click', () => {
+ this.switchCanvasView('logical');
+ });
+
+ // Table view switcher (Racks / Devices / Connections) - Toggle behavior
+ document.getElementById('racksTableBtn').addEventListener('click', () => {
+ this.toggleTableView('racks');
+ });
+
+ document.getElementById('devicesTableBtn').addEventListener('click', () => {
+ this.toggleTableView('devices');
+ });
+
+ document.getElementById('connectionsTableBtn').addEventListener('click', () => {
+ this.toggleTableView('connections');
+ });
+
+ // Table toolbar buttons
+ document.getElementById('addTableRowBtn').addEventListener('click', () => {
+ this.tableManager.addRow();
+ });
+
+ document.getElementById('deleteTableRowBtn').addEventListener('click', () => {
+ this.tableManager.deleteSelectedRows();
+ });
+
+ // Load saved view preferences
+ const savedCanvasView = localStorage.getItem('currentCanvasView') || 'physical';
+ this.switchCanvasView(savedCanvasView);
+
+ // Project selector
+ document.getElementById('projectSelect').addEventListener('change', async (e) => {
+ const value = e.target.value;
+
+ // Handle special options
+ if (value === '__create__') {
+ // Reset dropdown to current project
+ e.target.value = this.api.currentProjectId;
+ // Show create modal
+ this.showProjectFormModal();
+ return;
+ }
+
+ if (value === '__manage__') {
+ // Reset dropdown to current project
+ e.target.value = this.api.currentProjectId;
+ // Show manage modal
+ this.showManageProjectsModal();
+ return;
+ }
+
+ // Normal project switch
+ const projectId = parseInt(value);
+ await this.switchProject(projectId);
+ });
+
+ // Right-click context menu handler
+ this.stage.on('contextmenu', (e) => {
+ e.evt.preventDefault();
+
+ // Right-click on empty canvas
+ if (e.target === this.stage) {
+ this.showCanvasContextMenu(e);
+ return;
+ }
+
+ // Rack right-clicks are handled by RackManager's own context menu
+ });
+
+ // ESC key to cancel connection
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && this.connectionManager.isConnectionMode()) {
+ this.connectionManager.cancelConnection();
+ }
+ });
+
+ // Device click handler for connections (when racks are locked)
+ this.layer.on('click', (e) => {
+ const target = e.target;
+
+ // Check if clicked on a device (find parent group)
+ let deviceGroup = target;
+ while (deviceGroup && !(deviceGroup.id() && deviceGroup.id().startsWith('device-'))) {
+ deviceGroup = deviceGroup.getParent();
+ if (!deviceGroup || deviceGroup === this.layer) {
+ deviceGroup = null;
+ break;
+ }
+ }
+
+ if (deviceGroup) {
+ const deviceId = parseInt(deviceGroup.id().replace('device-', ''));
+
+ // Only handle clicks when racks are locked
+ if (this.rackManager.racksLocked) {
+ if (this.connectionManager.isConnectionMode()) {
+ // Complete connection
+ this.connectionManager.completeConnection(deviceId, deviceGroup);
+ } else {
+ // Start connection
+ this.connectionManager.startConnection(deviceId, deviceGroup);
+ }
+ }
+ } else {
+ // Clicked on empty space - deselect any selected connection
+ this.connectionManager.deselectConnection();
+ }
+ });
+
+
+ // Rename rack event
+ window.addEventListener('rename-rack', async (e) => {
+ const { rackId, rackData, rackShape } = e.detail;
+ await this.renameRack(rackId, rackData, rackShape);
+ });
+
+ // Rename device event
+ window.addEventListener('rename-device', async (e) => {
+ const { deviceId, deviceData, deviceShape } = e.detail;
+ await this.renameDevice(deviceId, deviceData, deviceShape);
+ });
+
+ // Canvas data changed - sync to table
+ window.addEventListener('canvas-data-changed', async () => {
+ if (this.currentTableView) {
+ await this.tableManager.syncFromCanvas();
+ }
+ });
+
+ // Window resize
+ window.addEventListener('resize', () => {
+ const container = document.getElementById('canvasWrapper');
+ this.stage.width(container.offsetWidth);
+ this.stage.height(container.offsetHeight);
+ });
+
+ // Zoom input field
+ const zoomInput = document.getElementById('zoomInput');
+ zoomInput.addEventListener('change', (e) => {
+ const percentage = parseInt(e.target.value);
+ if (!isNaN(percentage) && percentage >= 10 && percentage <= 300) {
+ this.setZoom(percentage / 100);
+ }
+ });
+
+ // Also handle Enter key
+ zoomInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ const percentage = parseInt(e.target.value);
+ if (!isNaN(percentage) && percentage >= 10 && percentage <= 300) {
+ this.setZoom(percentage / 100);
+ }
+ }
+ });
+
+ // Fit view button
+ document.getElementById('fitViewBtn').addEventListener('click', () => {
+ this.fitView();
+ });
+
+ // Export/Import project buttons
+ document.getElementById('exportProjectBtn').addEventListener('click', () => {
+ this.exportProject();
+ });
+
+ document.getElementById('importProjectBtn').addEventListener('click', () => {
+ document.getElementById('importProjectInput').click();
+ });
+
+ document.getElementById('importProjectInput').addEventListener('change', (e) => {
+ const file = e.target.files[0];
+ if (file) {
+ this.importProject(file);
+ // Reset input so same file can be selected again
+ e.target.value = '';
+ }
+ });
+
+ // Export to Excel button
+ document.getElementById('exportExcelBtn').addEventListener('click', () => {
+ this.exportToExcel();
+ });
+ }
+
+ async showManageProjectsModal() {
+ const modal = document.getElementById('manageProjectsModal');
+ const closeBtn = document.getElementById('manageProjectsModalClose');
+ const newProjectBtn = document.getElementById('newProjectBtnFromManage');
+ const projectsList = document.getElementById('projectsList');
+
+ modal.classList.remove('hidden');
+
+ // Load and display projects
+ await this.renderProjectsList();
+
+ const handleClose = () => {
+ modal.classList.add('hidden');
+ closeBtn.removeEventListener('click', handleClose);
+ newProjectBtn.removeEventListener('click', handleNewProject);
+ };
+
+ const handleNewProject = () => {
+ this.showProjectFormModal();
+ };
+
+ closeBtn.addEventListener('click', handleClose);
+ newProjectBtn.addEventListener('click', handleNewProject);
+ }
+
+ async renderProjectsList() {
+ const projectsList = document.getElementById('projectsList');
+ const projects = await this.api.getProjects();
+ const currentProjectId = this.api.currentProjectId;
+
+ projectsList.innerHTML = '';
+
+ projects.forEach(project => {
+ const card = document.createElement('div');
+ card.className = 'project-card';
+ if (project.id === currentProjectId) {
+ card.classList.add('active');
+ }
+
+ const date = new Date(project.updated_at).toLocaleDateString();
+
+ card.innerHTML = `
+
+
${project.name}
+
${project.description || 'No description'}
+
Last updated: ${date}
+
+
+ ${project.id !== currentProjectId ? `` : ''}
+
+
+
+ `;
+
+ // Add event listeners to action buttons
+ card.querySelectorAll('[data-action]').forEach(btn => {
+ btn.addEventListener('click', async (e) => {
+ const action = e.target.dataset.action;
+ const id = parseInt(e.target.dataset.id);
+
+ if (action === 'switch') {
+ document.getElementById('projectSelect').value = id;
+ await this.switchProject(id);
+ await this.renderProjectsList();
+ } else if (action === 'edit') {
+ this.showProjectFormModal(project);
+ } else if (action === 'delete') {
+ await this.deleteProject(project);
+ }
+ });
+ });
+
+ projectsList.appendChild(card);
+ });
+ }
+
+ showProjectFormModal(project = null) {
+ const modal = document.getElementById('projectFormModal');
+ const title = document.getElementById('projectFormTitle');
+ const saveBtn = document.getElementById('saveProjectBtn');
+ const cancelBtn = document.getElementById('cancelProjectBtn');
+ const closeBtn = document.getElementById('projectFormModalClose');
+ const nameInput = document.getElementById('projectName');
+ const descInput = document.getElementById('projectDescription');
+
+ // Set form mode
+ const isEdit = !!project;
+ title.textContent = isEdit ? 'Edit Project' : 'New Project';
+ nameInput.value = isEdit ? project.name : '';
+ descInput.value = isEdit ? (project.description || '') : '';
+
+ modal.classList.remove('hidden');
+ nameInput.focus();
+
+ const handleSave = async () => {
+ const name = nameInput.value.trim();
+ const description = descInput.value.trim();
+
+ if (!name) {
+ alert('Please enter a project name');
+ return;
+ }
+
+ try {
+ if (isEdit) {
+ await this.api.request(`/api/projects/${project.id}`, {
+ method: 'PUT',
+ body: JSON.stringify({ name, description })
+ });
+ } else {
+ const newProject = await this.api.createProject(name, description);
+
+ // Switch to new project
+ await this.loadProjects();
+ document.getElementById('projectSelect').value = newProject.id;
+ await this.switchProject(newProject.id);
+ }
+
+ modal.classList.add('hidden');
+
+ // Reload projects and refresh manage modal if open
+ await this.loadProjects();
+ const manageModal = document.getElementById('manageProjectsModal');
+ if (!manageModal.classList.contains('hidden')) {
+ await this.renderProjectsList();
+ }
+ } catch (err) {
+ alert('Failed to save project: ' + err.message);
+ }
+
+ cleanup();
+ };
+
+ const handleCancel = () => {
+ modal.classList.add('hidden');
+ cleanup();
+ };
+
+ const cleanup = () => {
+ saveBtn.removeEventListener('click', handleSave);
+ cancelBtn.removeEventListener('click', handleCancel);
+ closeBtn.removeEventListener('click', handleCancel);
+ };
+
+ saveBtn.addEventListener('click', handleSave);
+ cancelBtn.addEventListener('click', handleCancel);
+ closeBtn.addEventListener('click', handleCancel);
+ }
+
+ async deleteProject(project) {
+ const confirmMsg = `Are you sure you want to delete "${project.name}"?\n\nThis will permanently delete:\n- All racks in this project\n- All devices\n- All connections\n\nThis action cannot be undone.`;
+
+ if (!confirm(confirmMsg)) {
+ return;
+ }
+
+ try {
+ await this.api.deleteProject(project.id);
+
+ // Reload projects
+ await this.loadProjects();
+
+ // If we deleted the current project, switch to the first available
+ if (project.id === this.api.currentProjectId) {
+ const projects = await this.api.getProjects();
+ if (projects.length > 0) {
+ document.getElementById('projectSelect').value = projects[0].id;
+ await this.switchProject(projects[0].id);
+ }
+ }
+
+ // Refresh the project list
+ await this.renderProjectsList();
+ } catch (err) {
+ alert('Failed to delete project: ' + err.message);
+ }
+ }
+
+ showCanvasContextMenu(e) {
+ // Don't show context menu in logical view
+ if (this.currentView === 'logical') {
+ return;
+ }
+
+ const contextMenu = document.getElementById('contextMenu');
+ const contextMenuList = document.getElementById('contextMenuList');
+
+ contextMenuList.innerHTML = `
+ Add Rack(s)
+ `;
+
+ contextMenu.style.left = `${e.evt.pageX}px`;
+ contextMenu.style.top = `${e.evt.pageY}px`;
+ contextMenu.classList.remove('hidden');
+
+ // Mark that menu was just shown (prevents immediate hiding)
+ this.contextMenuJustShown = true;
+ setTimeout(() => {
+ this.contextMenuJustShown = false;
+ }, 100);
+
+ // Remove any existing listeners
+ const oldHandler = this.contextMenuHandler;
+ if (oldHandler) {
+ contextMenuList.removeEventListener('click', oldHandler);
+ }
+
+ // Create new handler
+ this.contextMenuHandler = (evt) => {
+ const action = evt.target.dataset.action;
+ if (action === 'add-racks') {
+ this.showAddRackModal();
+ }
+ this.hideContextMenu();
+ };
+
+ contextMenuList.addEventListener('click', this.contextMenuHandler);
+ }
+
+ hideContextMenu() {
+ // Don't hide if menu was just shown
+ if (this.contextMenuJustShown) {
+ return;
+ }
+
+ const contextMenu = document.getElementById('contextMenu');
+ if (contextMenu) {
+ contextMenu.classList.add('hidden');
+ }
+ }
+
+ async getNextRackNumber(prefix) {
+ const racks = await this.api.getRacks();
+ const existingRacks = racks.filter(r => r.name.startsWith(prefix));
+
+ if (existingRacks.length === 0) {
+ return 1;
+ }
+
+ // Find the highest number
+ let maxNum = 0;
+ existingRacks.forEach(rack => {
+ const match = rack.name.match(/\d+$/);
+ if (match) {
+ const num = parseInt(match[0]);
+ if (num > maxNum) maxNum = num;
+ }
+ });
+
+ return maxNum + 1;
+ }
+
+ async updateRackNamesPreview() {
+ const count = parseInt(document.getElementById('rackCount').value) || 1;
+ const prefix = document.getElementById('rackPrefix').value.trim() || 'RACK';
+
+ const startNum = await this.getNextRackNumber(prefix);
+ const previews = [];
+
+ for (let i = 0; i < Math.min(count, 5); i++) {
+ const num = String(startNum + i).padStart(2, '0');
+ previews.push(`${prefix}${num}`);
+ }
+
+ if (count > 5) {
+ previews.push('...');
+ }
+
+ document.getElementById('rackNamePreview').textContent = previews.join(', ');
+ }
+
+ async populateRowDropdown() {
+ const existingRacks = await this.api.getRacks();
+ const rowSelect = document.getElementById('continueRowSelect');
+
+ if (existingRacks.length === 0) {
+ // No racks, just show row 1
+ rowSelect.innerHTML = '';
+ return;
+ }
+
+ // Get unique Y coordinates (rows) and sort them
+ const uniqueRows = [...new Set(existingRacks.map(r => r.y))].sort((a, b) => a - b);
+
+ // Build dropdown options
+ rowSelect.innerHTML = '';
+ uniqueRows.forEach((yCoord, index) => {
+ const option = document.createElement('option');
+ option.value = yCoord;
+ option.textContent = index + 1; // Display as 1-based row numbers
+ rowSelect.appendChild(option);
+ });
+
+ // Select the last row by default
+ rowSelect.value = uniqueRows[uniqueRows.length - 1];
+ }
+
+ async showAddRackModal() {
+ const modal = document.getElementById('addRackModal');
+ const createBtn = document.getElementById('createRacksBtn');
+ const cancelBtn = document.getElementById('cancelRacksBtn');
+ const closeBtn = document.getElementById('addRackModalClose');
+
+ // Populate row dropdown
+ await this.populateRowDropdown();
+
+ modal.classList.remove('hidden');
+ this.updateRackNamesPreview();
+
+ // Add input listeners for live preview
+ const countInput = document.getElementById('rackCount');
+ const prefixInput = document.getElementById('rackPrefix');
+
+ const updatePreview = () => this.updateRackNamesPreview();
+ countInput.addEventListener('input', updatePreview);
+ prefixInput.addEventListener('input', updatePreview);
+
+ const handleCreate = async () => {
+ const count = parseInt(document.getElementById('rackCount').value);
+ const prefix = document.getElementById('rackPrefix').value.trim() || 'RACK';
+ const position = document.querySelector('input[name="rowPosition"]:checked').value;
+ const selectedRow = position === 'continue' ? parseInt(document.getElementById('continueRowSelect').value) : null;
+
+ try {
+ await this.createMultipleRacks(count, prefix, position, selectedRow);
+ modal.classList.add('hidden');
+ } catch (err) {
+ alert('Failed to create racks: ' + err.message);
+ }
+
+ cleanup();
+ };
+
+ const handleCancel = () => {
+ modal.classList.add('hidden');
+ cleanup();
+ };
+
+ const cleanup = () => {
+ createBtn.removeEventListener('click', handleCreate);
+ cancelBtn.removeEventListener('click', handleCancel);
+ closeBtn.removeEventListener('click', handleCancel);
+ countInput.removeEventListener('input', updatePreview);
+ prefixInput.removeEventListener('input', updatePreview);
+ };
+
+ createBtn.addEventListener('click', handleCreate);
+ cancelBtn.addEventListener('click', handleCancel);
+ closeBtn.addEventListener('click', handleCancel);
+ }
+
+ async createMultipleRacks(count, prefix, position, selectedRowY = null) {
+ const existingRacks = await this.api.getRacks();
+
+ // Use current grid dimensions from RackManager
+ const gridSize = this.rackManager.gridSize;
+ const gridVertical = this.rackManager.gridVertical;
+ const startX = 0; // Start at grid origin
+ const startY = 0; // Start at grid origin
+
+ let x, y;
+
+ // Determine starting position based on position type
+ if (existingRacks.length === 0) {
+ // First racks ever
+ x = startX;
+ y = startY;
+ } else if (position === 'continue') {
+ // Continue on the selected row
+ const rowY = selectedRowY;
+ const rowRacks = existingRacks.filter(r => r.y === rowY);
+
+ if (rowRacks.length > 0) {
+ const maxX = Math.max(...rowRacks.map(r => r.x));
+ x = maxX + gridSize;
+ } else {
+ // No racks in this row yet, start at beginning
+ x = startX;
+ }
+ y = rowY;
+ } else if (position === 'below') {
+ // New row below
+ const maxY = Math.max(...existingRacks.map(r => r.y));
+ x = startX;
+ y = maxY + gridVertical;
+ } else if (position === 'above') {
+ // New row above
+ const minY = Math.min(...existingRacks.map(r => r.y));
+ x = startX;
+ y = minY - gridVertical;
+ }
+
+ // Get starting number for sequential naming
+ const startNum = await this.getNextRackNumber(prefix);
+
+ // Create racks
+ for (let i = 0; i < count; i++) {
+ const num = String(startNum + i).padStart(2, '0');
+ const name = `${prefix}${num}`;
+
+ const rackX = x + (i * gridSize);
+ const rackY = y;
+
+ const rackData = await this.api.createRack(name, rackX, rackY);
+ this.rackManager.createRackShape(rackData);
+ }
+
+ this.layer.batchDraw();
+ }
+
+ showAddDeviceModal(rackId) {
+ const modal = document.getElementById('addDeviceModal');
+ const deviceTypeList = document.getElementById('deviceTypeList');
+ const closeBtn = document.getElementById('addDeviceModalClose');
+
+ // Populate device types
+ deviceTypeList.innerHTML = '';
+ this.deviceManager.deviceTypes.forEach(type => {
+ const card = document.createElement('div');
+ card.className = 'device-type-card';
+ card.innerHTML = `
+ ${type.name}
+ ${type.ports_count} ports
+ `;
+
+ card.addEventListener('click', async () => {
+ const deviceName = prompt(`Enter name for ${type.name}:`, type.name);
+ 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(rackId);
+ await this.deviceManager.addDevice(type.id, rackId, position, deviceName);
+ modal.classList.add('hidden');
+ } catch (err) {
+ alert('Failed to add device: ' + err.message);
+ }
+ }
+ });
+
+ deviceTypeList.appendChild(card);
+ });
+
+ modal.classList.remove('hidden');
+
+ const handleClose = () => {
+ modal.classList.add('hidden');
+ closeBtn.removeEventListener('click', handleClose);
+ };
+
+ closeBtn.addEventListener('click', handleClose);
+ }
+
+ async renameRack(rackId, rackData, rackShape) {
+ const newName = prompt('Enter new rack name:', rackData.name);
+ if (newName && newName !== rackData.name) {
+ try {
+ await this.api.updateRackName(rackId, newName);
+
+ // Update the rack name in the shape
+ const nameLabel = rackShape.findOne('Text');
+ if (nameLabel) {
+ nameLabel.text(newName);
+ this.layer.batchDraw();
+ }
+
+ // Update local data
+ rackData.name = newName;
+
+ // Notify table to sync
+ window.dispatchEvent(new CustomEvent('canvas-data-changed'));
+ } catch (err) {
+ alert('Failed to rename rack: ' + err.message);
+ }
+ }
+ }
+
+ async renameDevice(deviceId, deviceData, deviceShape) {
+ const newName = prompt('Enter new device name:', deviceData.name);
+ if (newName && newName !== deviceData.name) {
+ // Check if name is already taken
+ if (this.deviceManager.isDeviceNameTaken(newName, deviceId)) {
+ alert(`Device name "${newName}" is already in use. Please choose a different name.`);
+ return;
+ }
+
+ try {
+ await this.api.updateDeviceName(deviceId, newName);
+
+ // Update the device name in the shape
+ const nameLabel = deviceShape.findOne('.device-text');
+ if (nameLabel) {
+ nameLabel.text(newName);
+ this.layer.batchDraw();
+ }
+
+ // Update local data
+ deviceData.name = newName;
+
+ // Notify table to sync
+ window.dispatchEvent(new CustomEvent('canvas-data-changed'));
+ } catch (err) {
+ alert('Failed to rename device: ' + err.message);
+ }
+ }
+ }
+
+ setupContextMenu() {
+ // Hide context menu on any click/mousedown anywhere
+ const hideHandler = (e) => {
+ const contextMenu = document.getElementById('contextMenu');
+ // Don't hide if clicking inside the context menu itself
+ if (contextMenu && !contextMenu.contains(e.target)) {
+ this.hideContextMenu();
+ }
+ };
+
+ // Listen on document for clicks outside the canvas
+ document.addEventListener('mousedown', hideHandler);
+ document.addEventListener('click', hideHandler);
+ }
+
+ resetView() {
+ // Reset to default position and zoom
+ this.stage.position({ x: 50, y: 50 });
+ this.stage.scale({ x: 1, y: 1 });
+ this.currentScale = 1;
+ this.updateZoomDisplay(1);
+ this.stage.batchDraw();
+ }
+
+ fitView() {
+ // Get all racks
+ const racks = Array.from(this.rackManager.racks.values());
+
+ if (racks.length === 0) {
+ // No racks, just reset view
+ this.resetView();
+ return;
+ }
+
+ // Calculate bounding box of all racks
+ let minX = Infinity, minY = Infinity;
+ let maxX = -Infinity, maxY = -Infinity;
+
+ racks.forEach(rack => {
+ const x = rack.data.x;
+ const y = rack.data.y;
+ const width = rack.data.width || this.rackManager.rackWidth;
+ const height = rack.data.height || this.rackManager.rackHeight;
+
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y - 30); // Include rack name
+ maxX = Math.max(maxX, x + width);
+ maxY = Math.max(maxY, y + height);
+ });
+
+ // Add padding
+ const padding = 100;
+ minX -= padding;
+ minY -= padding;
+ maxX += padding;
+ maxY += padding;
+
+ const contentWidth = maxX - minX;
+ const contentHeight = maxY - minY;
+
+ // Calculate scale to fit
+ const containerWidth = this.stage.width();
+ const containerHeight = this.stage.height();
+
+ const scaleX = containerWidth / contentWidth;
+ const scaleY = containerHeight / contentHeight;
+ const scale = Math.min(scaleX, scaleY, this.maxScale);
+
+ // Clamp to min/max scale
+ const finalScale = Math.max(this.minScale, Math.min(this.maxScale, scale));
+
+ // Calculate position to center the content
+ const centerX = (minX + maxX) / 2;
+ const centerY = (minY + maxY) / 2;
+
+ const newX = containerWidth / 2 - centerX * finalScale;
+ const newY = containerHeight / 2 - centerY * finalScale;
+
+ // Apply the transformation
+ this.stage.scale({ x: finalScale, y: finalScale });
+ this.stage.position({ x: newX, y: newY });
+ this.currentScale = finalScale;
+ this.updateZoomDisplay(finalScale);
+ this.stage.batchDraw();
+ }
+
+ updateZoomDisplay(scale) {
+ const percentage = Math.round(scale * 100);
+ document.getElementById('zoomInput').value = percentage;
+ }
+
+ setZoom(scale) {
+ // Clamp scale to min/max
+ const newScale = Math.max(this.minScale, Math.min(this.maxScale, scale));
+
+ // Get current center point in world coordinates
+ const containerWidth = this.stage.width();
+ const containerHeight = this.stage.height();
+ const centerX = containerWidth / 2;
+ const centerY = containerHeight / 2;
+
+ // Convert to world coordinates
+ const oldScale = this.stage.scaleX();
+ const worldX = (centerX - this.stage.x()) / oldScale;
+ const worldY = (centerY - this.stage.y()) / oldScale;
+
+ // Apply new scale
+ this.stage.scale({ x: newScale, y: newScale });
+
+ // Recalculate position to keep center point fixed
+ const newPos = {
+ x: centerX - worldX * newScale,
+ y: centerY - worldY * newScale
+ };
+
+ this.stage.position(newPos);
+ this.currentScale = newScale;
+ this.updateZoomDisplay(newScale);
+ this.stage.batchDraw();
+ }
+
+ async switchCanvasView(canvasViewType) {
+ if (canvasViewType !== 'physical' && canvasViewType !== 'logical') {
+ console.error('Invalid canvas view type:', canvasViewType);
+ return;
+ }
+
+ this.currentCanvasView = canvasViewType;
+ localStorage.setItem('currentCanvasView', canvasViewType);
+
+ // Update button states
+ const physicalBtn = document.getElementById('physicalViewBtn');
+ const logicalBtn = document.getElementById('logicalViewBtn');
+
+ physicalBtn.classList.remove('active');
+ logicalBtn.classList.remove('active');
+
+ if (canvasViewType === 'physical') {
+ physicalBtn.classList.add('active');
+ } else {
+ logicalBtn.classList.add('active');
+ }
+
+ // Update device manager's view (changes device width)
+ this.deviceManager.setCurrentView(canvasViewType);
+
+ if (canvasViewType === 'physical') {
+ this.renderPhysicalView();
+ } else {
+ this.renderLogicalView();
+ }
+
+ // Update connection manager's view (reloads connections with view-specific waypoints)
+ await this.connectionManager.setCurrentView(canvasViewType);
+
+ // Sync table if visible
+ if (this.currentTableView) {
+ await this.tableManager.refreshTable();
+ }
+ }
+
+ async toggleTableView(tableViewType) {
+ const racksTableBtn = document.getElementById('racksTableBtn');
+ const devicesTableBtn = document.getElementById('devicesTableBtn');
+ const connectionsTableBtn = document.getElementById('connectionsTableBtn');
+ const tablePane = document.getElementById('tablePane');
+ const resizeHandle = document.getElementById('resizeHandle');
+
+ // If clicking the same table view, close it (toggle off)
+ if (this.currentTableView === tableViewType) {
+ this.currentTableView = null;
+ tablePane.classList.add('hidden');
+ resizeHandle.classList.add('hidden');
+
+ // Remove active state from all table buttons
+ racksTableBtn.classList.remove('active');
+ devicesTableBtn.classList.remove('active');
+ connectionsTableBtn.classList.remove('active');
+
+ this.tableManager.hideTable();
+ this.resizeCanvas();
+ return;
+ }
+
+ // Otherwise, switch to the new table view or open it
+ this.currentTableView = tableViewType;
+
+ // Show table pane and resize handle
+ tablePane.classList.remove('hidden');
+ resizeHandle.classList.remove('hidden');
+
+ // Update button states
+ racksTableBtn.classList.remove('active');
+ devicesTableBtn.classList.remove('active');
+ connectionsTableBtn.classList.remove('active');
+
+ if (tableViewType === 'racks') {
+ racksTableBtn.classList.add('active');
+ } else if (tableViewType === 'devices') {
+ devicesTableBtn.classList.add('active');
+ } else if (tableViewType === 'connections') {
+ connectionsTableBtn.classList.add('active');
+ }
+
+ // Show the table
+ await this.tableManager.showTable(`${tableViewType}-table`);
+ this.resizeCanvas();
+ }
+
+ setupResizeHandle() {
+ const resizeHandle = document.getElementById('resizeHandle');
+ const tablePane = document.getElementById('tablePane');
+ const canvasPane = document.getElementById('canvasPane');
+
+ let isResizing = false;
+ let startY = 0;
+ let startHeight = 0;
+
+ resizeHandle.addEventListener('mousedown', (e) => {
+ isResizing = true;
+ startY = e.clientY;
+ startHeight = tablePane.offsetHeight;
+ document.body.style.cursor = 'ns-resize';
+ document.body.style.userSelect = 'none';
+ });
+
+ document.addEventListener('mousemove', (e) => {
+ if (!isResizing) return;
+
+ const deltaY = startY - e.clientY; // Negative delta = drag down
+ const newHeight = Math.max(150, Math.min(window.innerHeight - 300, startHeight + deltaY));
+
+ tablePane.style.height = `${newHeight}px`;
+ this.resizeCanvas();
+ });
+
+ document.addEventListener('mouseup', () => {
+ if (isResizing) {
+ isResizing = false;
+ document.body.style.cursor = 'default';
+ document.body.style.userSelect = 'auto';
+ }
+ });
+ }
+
+ resizeCanvas() {
+ // Trigger canvas resize
+ const canvasWrapper = document.getElementById('canvasWrapper');
+ if (this.stage) {
+ this.stage.width(canvasWrapper.offsetWidth);
+ this.stage.height(canvasWrapper.offsetHeight);
+ this.stage.batchDraw();
+ }
+
+ // Trigger table grid resize
+ if (this.tableManager.gridApi) {
+ // ag-Grid automatically handles resize, but we can trigger it explicitly
+ setTimeout(() => {
+ if (this.tableManager.gridApi) {
+ this.tableManager.gridApi.sizeColumnsToFit();
+ }
+ }, 100);
+ }
+ }
+
+ renderPhysicalView() {
+ // Show racks
+ this.rackManager.racks.forEach((rack) => {
+ rack.shape.visible(true);
+ });
+
+ // Move devices back into racks and position them relatively
+ this.deviceManager.devices.forEach((device, deviceId) => {
+ const deviceData = device.data;
+ const rackShape = this.rackManager.getRackShape(deviceData.rack_id);
+
+ if (rackShape) {
+ const devicesContainer = rackShape.findOne('.devices-container');
+
+ // Move device back into its rack's container
+ if (device.shape.getParent() !== devicesContainer) {
+ device.shape.moveTo(devicesContainer);
+ }
+
+ // Calculate relative position within rack (using the rack assignment stored in DB)
+ // U1 (slot 1) is at the bottom, U42 (slot 42) is at the top
+ const maxSlots = 42;
+ const visualPosition = maxSlots - deviceData.position;
+ const y = 10 + (visualPosition * (this.deviceManager.deviceHeight + this.deviceManager.deviceSpacing));
+ device.shape.position({ x: 10, y: y });
+
+ // Remove logical view drag handlers
+ device.shape.off('dragstart');
+ device.shape.off('dragmove');
+ device.shape.off('dragmove.connection');
+ device.shape.off('dragend');
+ device.shape.off('dragend.logical');
+
+ // Devices are not draggable in physical view (racks control layout)
+ device.shape.draggable(false);
+
+ // Enable context menu for device deletion (context menu handler is already attached)
+ device.shape.listening(true);
+ }
+ });
+
+ this.layer.batchDraw();
+ this.connectionManager.updateAllConnections();
+ }
+
+ renderLogicalView() {
+ // Hide racks
+ this.rackManager.racks.forEach((rack) => {
+ rack.shape.visible(false);
+ });
+
+ // Move devices to main layer and position them at logical positions
+ this.deviceManager.devices.forEach((device, deviceId) => {
+ const deviceData = device.data;
+
+ // Use logical position if available, otherwise calculate from physical position
+ let logicalX = deviceData.logical_x;
+ let logicalY = deviceData.logical_y;
+
+ if (logicalX === null || logicalX === undefined) {
+ // First time in logical view - calculate position from physical layout
+ const rack = this.rackManager.racks.get(deviceData.rack_id);
+ if (rack) {
+ logicalX = rack.data.x + 100; // Offset from rack position
+ logicalY = rack.data.y + deviceData.position * 40;
+ } else {
+ logicalX = 200;
+ logicalY = 200;
+ }
+ // Save this initial logical position
+ this.api.request(`/api/devices/${deviceId}/logical-position`, {
+ method: 'PUT',
+ body: JSON.stringify({ x: logicalX, y: logicalY })
+ }).catch(err => console.error('Failed to save logical position:', err));
+ }
+
+ // Move device to main layer (out of rack container)
+ if (device.shape.getParent() !== this.layer) {
+ device.shape.moveTo(this.layer);
+ }
+
+ // Position device at logical coordinates (absolute positioning)
+ device.shape.position({ x: logicalX, y: logicalY });
+ device.shape.draggable(true);
+
+ // IMPORTANT: Remove ALL existing drag handlers (including physical view handlers)
+ device.shape.off('dragstart');
+ device.shape.off('dragmove');
+ device.shape.off('dragmove.connection');
+ device.shape.off('dragend');
+ device.shape.off('dragend.logical');
+
+ // Add ONLY logical view drag handler - does NOT change rack assignment
+ device.shape.on('dragend.logical', async () => {
+ const pos = device.shape.position();
+ try {
+ // Update ONLY logical position, never rack_id or position
+ await this.api.request(`/api/devices/${deviceId}/logical-position`, {
+ method: 'PUT',
+ body: JSON.stringify({ x: pos.x, y: pos.y })
+ });
+ // Update local data
+ deviceData.logical_x = pos.x;
+ deviceData.logical_y = pos.y;
+ // Update connections
+ this.connectionManager.updateAllConnections();
+ } catch (err) {
+ console.error('Failed to save logical position:', err);
+ }
+ });
+ });
+
+ this.layer.batchDraw();
+ this.connectionManager.updateAllConnections();
+ }
+
+ async exportProject() {
+ try {
+ // Get current project info
+ const project = await this.api.getProject(this.api.currentProjectId);
+ const racks = await this.api.getRacks();
+ const devices = await this.api.getDevices();
+ const connections = await this.api.getConnections();
+
+ // Create export data
+ const exportData = {
+ version: '1.0',
+ exportDate: new Date().toISOString(),
+ project: {
+ name: project.name,
+ description: project.description
+ },
+ racks: racks,
+ devices: devices,
+ connections: connections,
+ gridSettings: {
+ gridSize: this.rackManager.gridSize,
+ gridVertical: this.rackManager.gridVertical
+ }
+ };
+
+ // Convert to JSON
+ const jsonString = JSON.stringify(exportData, null, 2);
+
+ // Create blob and download
+ const blob = new Blob([jsonString], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${project.name.replace(/[^a-z0-9]/gi, '_')}_${new Date().toISOString().split('T')[0]}.json`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ alert(`Project "${project.name}" exported successfully!`);
+ } catch (err) {
+ console.error('Failed to export project:', err);
+ alert('Failed to export project: ' + err.message);
+ }
+ }
+
+ async importProject(file) {
+ try {
+ const text = await file.text();
+ const importData = JSON.parse(text);
+
+ // Validate import data
+ if (!importData.version || !importData.project) {
+ throw new Error('Invalid project file format');
+ }
+
+ const confirmMsg = `Import project "${importData.project.name}"?\n\nThis will create a new project with:\n- ${importData.racks?.length || 0} racks\n- ${importData.devices?.length || 0} devices\n- ${importData.connections?.length || 0} connections`;
+
+ if (!confirm(confirmMsg)) {
+ return;
+ }
+
+ // Create new project
+ const newProject = await this.api.createProject(
+ importData.project.name + ' (Imported)',
+ importData.project.description || ''
+ );
+
+ // Switch to new project
+ await this.loadProjects();
+ document.getElementById('projectSelect').value = newProject.id;
+ await this.switchProject(newProject.id);
+
+ // Import grid settings if available
+ if (importData.gridSettings) {
+ this.rackManager.gridSize = importData.gridSettings.gridSize || 600;
+ this.rackManager.gridVertical = importData.gridSettings.gridVertical || 1610;
+ this.rackManager.saveSpacing();
+ }
+
+ // Import racks
+ const rackIdMap = new Map(); // Map old IDs to new IDs
+ if (importData.racks) {
+ for (const rack of importData.racks) {
+ const newRack = await this.api.createRack(rack.name, rack.x, rack.y);
+ rackIdMap.set(rack.id, newRack.id);
+ this.rackManager.createRackShape(newRack);
+ }
+ }
+
+ // Import devices
+ const deviceIdMap = new Map(); // Map old IDs to new IDs
+ if (importData.devices) {
+ for (const device of importData.devices) {
+ const newRackId = rackIdMap.get(device.rack_id);
+ if (newRackId) {
+ const newDevice = await this.api.createDevice(
+ device.device_type_id,
+ newRackId,
+ device.position,
+ device.name
+ );
+ deviceIdMap.set(device.id, newDevice.id);
+
+ // Fetch complete device data
+ const devices = await this.api.getDevices();
+ const deviceData = devices.find(d => d.id === newDevice.id);
+ if (deviceData) {
+ // Update rack_units and logical position if available
+ if (device.rack_units) {
+ await this.api.request(`/api/devices/${newDevice.id}/rack-units`, {
+ method: 'PUT',
+ body: JSON.stringify({ rackUnits: device.rack_units })
+ });
+ deviceData.rack_units = device.rack_units;
+ }
+ if (device.logical_x !== null && device.logical_y !== null) {
+ await this.api.request(`/api/devices/${newDevice.id}/logical-position`, {
+ method: 'PUT',
+ body: JSON.stringify({ x: device.logical_x, y: device.logical_y })
+ });
+ }
+ this.deviceManager.createDeviceShape(deviceData);
+ }
+ }
+ }
+ }
+
+ // Import connections
+ if (importData.connections) {
+ for (const conn of importData.connections) {
+ const newSourceId = deviceIdMap.get(conn.source_device_id);
+ const newTargetId = deviceIdMap.get(conn.target_device_id);
+
+ if (newSourceId && newTargetId) {
+ const newConn = await this.api.createConnection(
+ newSourceId,
+ conn.source_port,
+ newTargetId,
+ conn.target_port
+ );
+
+ // Update waypoints if available
+ if (conn.waypoints_physical) {
+ await this.api.updateConnectionWaypoints(
+ newConn.id,
+ typeof conn.waypoints_physical === 'string' ? JSON.parse(conn.waypoints_physical) : conn.waypoints_physical,
+ 'physical'
+ );
+ }
+ if (conn.waypoints_logical) {
+ await this.api.updateConnectionWaypoints(
+ newConn.id,
+ typeof conn.waypoints_logical === 'string' ? JSON.parse(conn.waypoints_logical) : conn.waypoints_logical,
+ 'logical'
+ );
+ }
+ }
+ }
+
+ // Reload connections to display them
+ await this.connectionManager.loadConnections();
+ }
+
+ this.layer.batchDraw();
+ this.connectionManager.getConnectionLayer().batchDraw();
+
+ alert(`Project imported successfully as "${newProject.name}"!`);
+ } catch (err) {
+ console.error('Failed to import project:', err);
+ alert('Failed to import project: ' + err.message);
+ }
+ }
+
+ async exportToExcel() {
+ try {
+ // Get current project data
+ const project = await this.api.getProject(this.api.currentProjectId);
+ const racks = await this.api.getRacks();
+ const devices = await this.api.getDevices();
+ const connections = await this.api.getConnections();
+
+ // Create workbook
+ const wb = XLSX.utils.book_new();
+
+ // Racks sheet
+ const racksData = racks.map(r => ({
+ 'Rack Name': r.name,
+ 'Position X': r.x,
+ 'Position Y': r.y,
+ 'Width': r.width,
+ 'Height': r.height
+ }));
+ const racksWs = XLSX.utils.json_to_sheet(racksData);
+ XLSX.utils.book_append_sheet(wb, racksWs, 'Racks');
+
+ // Devices sheet
+ const racksMap = new Map(racks.map(r => [r.id, r.name]));
+ const devicesData = devices.map(d => ({
+ 'Device Name': d.name,
+ 'Type': d.type_name,
+ 'Rack': racksMap.get(d.rack_id) || 'Unknown',
+ 'Slot': `U${d.position}`,
+ 'Form Factor': `${d.rack_units || 1}U`,
+ 'Ports': d.ports_count,
+ 'Color': d.color
+ }));
+ const devicesWs = XLSX.utils.json_to_sheet(devicesData);
+ XLSX.utils.book_append_sheet(wb, devicesWs, 'Devices');
+
+ // Connections sheet
+ const devicesMap = new Map(devices.map(d => [d.id, d.name]));
+ const connectionsData = connections.map(c => ({
+ 'Source Device': devicesMap.get(c.source_device_id) || 'Unknown',
+ 'Source Port': c.source_port,
+ 'Target Device': devicesMap.get(c.target_device_id) || 'Unknown',
+ 'Target Port': c.target_port
+ }));
+ const connectionsWs = XLSX.utils.json_to_sheet(connectionsData);
+ XLSX.utils.book_append_sheet(wb, connectionsWs, 'Connections');
+
+ // Generate filename
+ const filename = `${project.name.replace(/[^a-z0-9]/gi, '_')}_${new Date().toISOString().split('T')[0]}.xlsx`;
+
+ // Write file
+ XLSX.writeFile(wb, filename);
+
+ alert(`Excel file "${filename}" downloaded successfully!`);
+ } catch (err) {
+ console.error('Failed to export to Excel:', err);
+ alert('Failed to export to Excel: ' + err.message);
+ }
+ }
+}
+
+// Initialize app
+const app = new DatacenterDesigner();
+app.init();
diff --git a/archive/old_public/js/connection-manager.js b/archive/old_public/js/connection-manager.js
new file mode 100644
index 0000000..9148965
--- /dev/null
+++ b/archive/old_public/js/connection-manager.js
@@ -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 = `
+
+
+ ${sourceDevice.name}:${connData.source_port} ↔ ${targetDevice.name}:${connData.target_port}
+
+
+ Delete Connection
+ `;
+
+ 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);
+ }
+}
diff --git a/archive/old_public/js/device-manager.js b/archive/old_public/js/device-manager.js
new file mode 100644
index 0000000..90591a7
--- /dev/null
+++ b/archive/old_public/js/device-manager.js
@@ -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 = `
+ Create Connection
+
+ Delete Device
+ `;
+
+ 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);
+ }
+ });
+ }
+}
diff --git a/archive/old_public/js/rack-manager.js b/archive/old_public/js/rack-manager.js
new file mode 100644
index 0000000..aeb9dcf
--- /dev/null
+++ b/archive/old_public/js/rack-manager.js
@@ -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 = '';
+ if (this.deviceManager && this.deviceManager.deviceTypes) {
+ this.deviceManager.deviceTypes.forEach(type => {
+ deviceTypesHTML += `${type.name}`;
+ });
+ }
+
+ // Build unlock/management options
+ let managementHTML = `${lockText}`;
+
+ // Show delete and spacing controls only when unlocked
+ if (!this.racksLocked) {
+ const horizontalSpacing = this.gridSize - this.rackWidth;
+ const verticalSpacing = this.gridVertical - this.rackHeight;
+
+ managementHTML += `
+ Delete Rack
+
+
+ Horizontal spacing: ${horizontalSpacing}px
+
+
+
+
+
+
+ Vertical spacing: ${verticalSpacing}px
+
+
+
+
+
+ `;
+ }
+
+ contextMenuList.innerHTML = `
+ ${deviceTypesHTML}
+
+ ${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;
+ }
+}
diff --git a/archive/old_public/js/table-manager.js b/archive/old_public/js/table-manager.js
new file mode 100644
index 0000000..123e5a7
--- /dev/null
+++ b/archive/old_public/js/table-manager.js
@@ -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 ``;
+ }
+ },
+ {
+ 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;
+ }
+ }
+}
diff --git a/archive/old_server/db.js b/archive/old_server/db.js
new file mode 100644
index 0000000..84b1336
--- /dev/null
+++ b/archive/old_server/db.js
@@ -0,0 +1,547 @@
+const sqlite3 = require('sqlite3').verbose();
+const path = require('path');
+
+const DB_PATH = path.join(__dirname, '../database/datacenter.db');
+
+class Database {
+ constructor() {
+ this.db = null;
+ }
+
+ init() {
+ return new Promise((resolve, reject) => {
+ this.db = new sqlite3.Database(DB_PATH, (err) => {
+ if (err) {
+ reject(err);
+ } else {
+ console.log('Connected to SQLite database');
+ this.createTables()
+ .then(() => this.seedDeviceTypes())
+ .then(() => this.ensureDefaultProject())
+ .then(resolve)
+ .catch(reject);
+ }
+ });
+ });
+ }
+
+ createTables() {
+ return new Promise((resolve, reject) => {
+ this.db.serialize(() => {
+ // Projects table
+ this.db.run(`
+ CREATE TABLE IF NOT EXISTS projects (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ description TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ `);
+
+ // Racks table
+ this.db.run(`
+ CREATE TABLE IF NOT EXISTS racks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ project_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ x REAL NOT NULL,
+ y REAL NOT NULL,
+ width REAL DEFAULT 520,
+ height REAL DEFAULT 1510,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
+ UNIQUE(project_id, name)
+ )
+ `);
+
+ // Device types table (library of available devices)
+ this.db.run(`
+ CREATE TABLE IF NOT EXISTS device_types (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ ports_count INTEGER NOT NULL DEFAULT 24,
+ color TEXT DEFAULT '#4A90E2'
+ )
+ `);
+
+ // Devices table (instances placed in racks)
+ this.db.run(`
+ CREATE TABLE IF NOT EXISTS devices (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ device_type_id INTEGER NOT NULL,
+ rack_id INTEGER NOT NULL,
+ position INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ logical_x REAL,
+ logical_y REAL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (device_type_id) REFERENCES device_types(id),
+ FOREIGN KEY (rack_id) REFERENCES racks(id) ON DELETE CASCADE
+ )
+ `);
+
+ // Connections table
+ this.db.run(`
+ CREATE TABLE IF NOT EXISTS connections (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ source_device_id INTEGER NOT NULL,
+ source_port INTEGER NOT NULL,
+ target_device_id INTEGER NOT NULL,
+ target_port INTEGER NOT NULL,
+ waypoints TEXT,
+ waypoints_physical TEXT,
+ waypoints_logical TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (source_device_id) REFERENCES devices(id) ON DELETE CASCADE,
+ FOREIGN KEY (target_device_id) REFERENCES devices(id) ON DELETE CASCADE,
+ UNIQUE(source_device_id, source_port),
+ UNIQUE(target_device_id, target_port)
+ )
+ `, (err) => {
+ if (err) {
+ reject(err);
+ } else {
+ // Add waypoints column if it doesn't exist (for existing databases)
+ this.db.run(`
+ ALTER TABLE connections ADD COLUMN waypoints TEXT
+ `, (err) => {
+ // Ignore error if column already exists
+
+ // Add view-specific waypoints columns
+ this.db.run(`
+ ALTER TABLE connections ADD COLUMN waypoints_physical TEXT
+ `, (err) => {
+ // Ignore error if column already exists
+ this.db.run(`
+ ALTER TABLE connections ADD COLUMN waypoints_logical TEXT
+ `, (err) => {
+ // Ignore error if column already exists
+
+ // Add logical view position columns to devices if they don't exist
+ this.db.run(`
+ ALTER TABLE devices ADD COLUMN logical_x REAL
+ `, (err) => {
+ // Ignore error if column already exists
+ this.db.run(`
+ ALTER TABLE devices ADD COLUMN logical_y REAL
+ `, (err) => {
+ // Ignore error if column already exists
+ this.db.run(`
+ ALTER TABLE devices ADD COLUMN rack_units INTEGER DEFAULT 1
+ `, (err) => {
+ // Ignore error if column already exists
+ resolve();
+ });
+ });
+ });
+ });
+ });
+ });
+ }
+ });
+ });
+ });
+ }
+
+ seedDeviceTypes() {
+ return new Promise((resolve, reject) => {
+ const stmt = this.db.prepare('INSERT OR IGNORE INTO device_types (name, ports_count, color) VALUES (?, ?, ?)');
+
+ const deviceTypes = [
+ ['Switch 24-Port', 24, '#4A90E2'],
+ ['Switch 48-Port', 48, '#5CA6E8'],
+ ['Router', 8, '#E27D60'],
+ ['Firewall', 6, '#E8A87C'],
+ ['Server', 4, '#41B3A3'],
+ ['Storage', 8, '#38A169'],
+ ['Patch Panel 24', 24, '#9B59B6'],
+ ['Patch Panel 48', 48, '#A569BD']
+ ];
+
+ deviceTypes.forEach(([name, ports, color]) => {
+ stmt.run(name, ports, color);
+ });
+
+ stmt.finalize((err) => {
+ if (err) reject(err);
+ else {
+ console.log('Device types seeded');
+ resolve();
+ }
+ });
+ });
+ }
+
+ ensureDefaultProject() {
+ return new Promise((resolve, reject) => {
+ this.db.run(
+ 'INSERT OR IGNORE INTO projects (id, name, description) VALUES (1, ?, ?)',
+ ['Default Project', 'Default datacenter project'],
+ (err) => {
+ if (err) reject(err);
+ else {
+ console.log('Default project ensured');
+ resolve();
+ }
+ }
+ );
+ });
+ }
+
+ // Project operations
+ getAllProjects() {
+ return new Promise((resolve, reject) => {
+ this.db.all('SELECT * FROM projects ORDER BY updated_at DESC', (err, rows) => {
+ if (err) reject(err);
+ else resolve(rows);
+ });
+ });
+ }
+
+ getProject(id) {
+ return new Promise((resolve, reject) => {
+ this.db.get('SELECT * FROM projects WHERE id = ?', [id], (err, row) => {
+ if (err) reject(err);
+ else resolve(row);
+ });
+ });
+ }
+
+ createProject(name, description = '') {
+ return new Promise((resolve, reject) => {
+ this.db.run(
+ 'INSERT INTO projects (name, description) VALUES (?, ?)',
+ [name, description],
+ function(err) {
+ if (err) reject(err);
+ else resolve({ id: this.lastID, name, description });
+ }
+ );
+ });
+ }
+
+ updateProject(id, name, description) {
+ return new Promise((resolve, reject) => {
+ this.db.run(
+ 'UPDATE projects SET name = ?, description = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
+ [name, description, id],
+ (err) => {
+ if (err) reject(err);
+ else resolve();
+ }
+ );
+ });
+ }
+
+ deleteProject(id) {
+ return new Promise((resolve, reject) => {
+ // Check if this is the last project
+ this.db.get('SELECT COUNT(*) as count FROM projects', (err, row) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+
+ if (row.count <= 1) {
+ reject(new Error('Cannot delete the last project'));
+ return;
+ }
+
+ // Delete the project (cascade will handle racks, devices, connections)
+ this.db.run('DELETE FROM projects WHERE id = ?', [id], (err) => {
+ if (err) reject(err);
+ else resolve();
+ });
+ });
+ });
+ }
+
+ // Rack operations
+ getAllRacks(projectId) {
+ return new Promise((resolve, reject) => {
+ this.db.all('SELECT * FROM racks WHERE project_id = ? ORDER BY name', [projectId], (err, rows) => {
+ if (err) reject(err);
+ else resolve(rows);
+ });
+ });
+ }
+
+ createRack(projectId, name, x, y) {
+ return new Promise((resolve, reject) => {
+ this.db.run(
+ 'INSERT INTO racks (project_id, name, x, y) VALUES (?, ?, ?, ?)',
+ [projectId, name, x, y],
+ (err) => {
+ if (err) {
+ reject(err);
+ } else {
+ // Fetch the complete rack data with width and height defaults
+ this.db.get(
+ 'SELECT * FROM racks WHERE project_id = ? AND name = ? ORDER BY id DESC LIMIT 1',
+ [projectId, name],
+ (err, row) => {
+ if (err) reject(err);
+ else resolve(row);
+ }
+ );
+ }
+ }
+ );
+ });
+ }
+
+ getNextRackName(projectId, prefix = 'RACK') {
+ return new Promise((resolve, reject) => {
+ this.db.all(
+ `SELECT name FROM racks WHERE project_id = ? AND name LIKE ? ORDER BY name DESC`,
+ [projectId, `${prefix}.%`],
+ (err, rows) => {
+ if (err) {
+ reject(err);
+ } else if (rows.length === 0) {
+ resolve(`${prefix}.01`);
+ } else {
+ const lastNum = parseInt(rows[0].name.split('.').pop());
+ const nextNum = (lastNum + 1).toString().padStart(2, '0');
+ resolve(`${prefix}.${nextNum}`);
+ }
+ }
+ );
+ });
+ }
+
+ updateRackPosition(id, x, y) {
+ return new Promise((resolve, reject) => {
+ this.db.run(
+ 'UPDATE racks SET x = ?, y = ? WHERE id = ?',
+ [x, y, id],
+ (err) => {
+ if (err) reject(err);
+ else resolve();
+ }
+ );
+ });
+ }
+
+ updateRackName(id, name) {
+ return new Promise((resolve, reject) => {
+ this.db.run(
+ 'UPDATE racks SET name = ? WHERE id = ?',
+ [name, id],
+ (err) => {
+ if (err) reject(err);
+ else resolve();
+ }
+ );
+ });
+ }
+
+ deleteRack(id) {
+ return new Promise((resolve, reject) => {
+ this.db.run('DELETE FROM racks WHERE id = ?', [id], (err) => {
+ if (err) reject(err);
+ else resolve();
+ });
+ });
+ }
+
+ // Device type operations
+ getAllDeviceTypes() {
+ return new Promise((resolve, reject) => {
+ this.db.all('SELECT * FROM device_types ORDER BY name', (err, rows) => {
+ if (err) reject(err);
+ else resolve(rows);
+ });
+ });
+ }
+
+ // Device operations
+ getAllDevices(projectId) {
+ return new Promise((resolve, reject) => {
+ this.db.all(`
+ SELECT d.*, dt.name as type_name, dt.ports_count, dt.color
+ FROM devices d
+ JOIN device_types dt ON d.device_type_id = dt.id
+ JOIN racks r ON d.rack_id = r.id
+ WHERE r.project_id = ?
+ ORDER BY d.rack_id, d.position
+ `, [projectId], (err, rows) => {
+ if (err) reject(err);
+ else resolve(rows);
+ });
+ });
+ }
+
+ createDevice(deviceTypeId, rackId, position, name) {
+ return new Promise((resolve, reject) => {
+ this.db.run(
+ 'INSERT INTO devices (device_type_id, rack_id, position, name) VALUES (?, ?, ?, ?)',
+ [deviceTypeId, rackId, position, name],
+ function(err) {
+ if (err) reject(err);
+ else resolve({ id: this.lastID });
+ }
+ );
+ });
+ }
+
+ deleteDevice(id) {
+ return new Promise((resolve, reject) => {
+ this.db.run('DELETE FROM devices WHERE id = ?', [id], (err) => {
+ if (err) reject(err);
+ else resolve();
+ });
+ });
+ }
+
+ updateDeviceRack(id, rackId, position) {
+ return new Promise((resolve, reject) => {
+ this.db.run(
+ 'UPDATE devices SET rack_id = ?, position = ? WHERE id = ?',
+ [rackId, position, id],
+ (err) => {
+ if (err) reject(err);
+ else resolve();
+ }
+ );
+ });
+ }
+
+ updateDeviceLogicalPosition(id, x, y) {
+ return new Promise((resolve, reject) => {
+ this.db.run(
+ 'UPDATE devices SET logical_x = ?, logical_y = ? WHERE id = ?',
+ [x, y, id],
+ (err) => {
+ if (err) reject(err);
+ else resolve();
+ }
+ );
+ });
+ }
+
+ updateDeviceName(id, name) {
+ return new Promise((resolve, reject) => {
+ this.db.run(
+ 'UPDATE devices SET name = ? WHERE id = ?',
+ [name, id],
+ (err) => {
+ if (err) reject(err);
+ else resolve();
+ }
+ );
+ });
+ }
+
+ updateDeviceRackUnits(id, rackUnits) {
+ return new Promise((resolve, reject) => {
+ this.db.run(
+ 'UPDATE devices SET rack_units = ? WHERE id = ?',
+ [rackUnits, id],
+ (err) => {
+ if (err) reject(err);
+ else resolve();
+ }
+ );
+ });
+ }
+
+ updateConnection(id, sourceDeviceId, sourcePort, targetDeviceId, targetPort) {
+ return new Promise((resolve, reject) => {
+ this.db.run(
+ 'UPDATE connections SET source_device_id = ?, source_port = ?, target_device_id = ?, target_port = ? WHERE id = ?',
+ [sourceDeviceId, sourcePort, targetDeviceId, targetPort, id],
+ (err) => {
+ if (err) reject(err);
+ else resolve();
+ }
+ );
+ });
+ }
+
+ // Connection operations
+ getAllConnections(projectId) {
+ return new Promise((resolve, reject) => {
+ this.db.all(`
+ SELECT c.* FROM connections c
+ JOIN devices d ON c.source_device_id = d.id
+ JOIN racks r ON d.rack_id = r.id
+ WHERE r.project_id = ?
+ `, [projectId], (err, rows) => {
+ if (err) reject(err);
+ else resolve(rows);
+ });
+ });
+ }
+
+ createConnection(sourceDeviceId, sourcePort, targetDeviceId, targetPort) {
+ return new Promise((resolve, reject) => {
+ this.db.run(
+ 'INSERT INTO connections (source_device_id, source_port, target_device_id, target_port) VALUES (?, ?, ?, ?)',
+ [sourceDeviceId, sourcePort, targetDeviceId, targetPort],
+ function(err) {
+ if (err) reject(err);
+ else resolve({ id: this.lastID });
+ }
+ );
+ });
+ }
+
+ deleteConnection(id) {
+ return new Promise((resolve, reject) => {
+ this.db.run('DELETE FROM connections WHERE id = ?', [id], (err) => {
+ if (err) reject(err);
+ else resolve();
+ });
+ });
+ }
+
+ updateConnectionWaypoints(id, waypoints, view = null) {
+ return new Promise((resolve, reject) => {
+ const waypointsJson = JSON.stringify(waypoints);
+
+ let query, params;
+ if (view === 'physical') {
+ query = 'UPDATE connections SET waypoints_physical = ? WHERE id = ?';
+ params = [waypointsJson, id];
+ } else if (view === 'logical') {
+ query = 'UPDATE connections SET waypoints_logical = ? WHERE id = ?';
+ params = [waypointsJson, id];
+ } else {
+ // Legacy support - update old waypoints column
+ query = 'UPDATE connections SET waypoints = ? WHERE id = ?';
+ params = [waypointsJson, id];
+ }
+
+ this.db.run(query, params, (err) => {
+ if (err) reject(err);
+ else resolve();
+ });
+ });
+ }
+
+ getUsedPorts(deviceId) {
+ return new Promise((resolve, reject) => {
+ this.db.all(`
+ SELECT source_port as port FROM connections WHERE source_device_id = ?
+ UNION
+ SELECT target_port as port FROM connections WHERE target_device_id = ?
+ `, [deviceId, deviceId], (err, rows) => {
+ if (err) reject(err);
+ else resolve(rows.map(r => r.port));
+ });
+ });
+ }
+
+ close() {
+ return new Promise((resolve, reject) => {
+ this.db.close((err) => {
+ if (err) reject(err);
+ else resolve();
+ });
+ });
+ }
+}
+
+module.exports = new Database();
diff --git a/archive/old_server/server.js b/archive/old_server/server.js
new file mode 100644
index 0000000..61941b2
--- /dev/null
+++ b/archive/old_server/server.js
@@ -0,0 +1,276 @@
+const express = require('express');
+const path = require('path');
+const db = require('./db');
+
+const app = express();
+const PORT = process.env.PORT || 3000;
+
+// Middleware
+app.use(express.json());
+app.use(express.static(path.join(__dirname, '../public')));
+
+// API Routes
+
+// Projects
+app.get('/api/projects', async (req, res) => {
+ try {
+ const projects = await db.getAllProjects();
+ res.json(projects);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.get('/api/projects/:id', async (req, res) => {
+ try {
+ const project = await db.getProject(req.params.id);
+ if (!project) {
+ res.status(404).json({ error: 'Project not found' });
+ } else {
+ res.json(project);
+ }
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.post('/api/projects', async (req, res) => {
+ try {
+ const { name, description } = req.body;
+ const project = await db.createProject(name, description);
+ res.status(201).json(project);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.put('/api/projects/:id', async (req, res) => {
+ try {
+ const { name, description } = req.body;
+ await db.updateProject(req.params.id, name, description);
+ res.json({ success: true });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.delete('/api/projects/:id', async (req, res) => {
+ try {
+ await db.deleteProject(req.params.id);
+ res.json({ success: true });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// Racks
+app.get('/api/racks', async (req, res) => {
+ try {
+ const projectId = req.query.projectId || 1;
+ const racks = await db.getAllRacks(projectId);
+ res.json(racks);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.get('/api/racks/next-name', async (req, res) => {
+ try {
+ const projectId = req.query.projectId || 1;
+ const prefix = req.query.prefix || 'RACK';
+ const name = await db.getNextRackName(projectId, prefix);
+ res.json({ name });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.post('/api/racks', async (req, res) => {
+ try {
+ const { projectId, name, x, y } = req.body;
+ const rack = await db.createRack(projectId || 1, name, x, y);
+ res.status(201).json(rack);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.put('/api/racks/:id/position', async (req, res) => {
+ try {
+ const { x, y } = req.body;
+ await db.updateRackPosition(req.params.id, x, y);
+ res.json({ success: true });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.put('/api/racks/:id/name', async (req, res) => {
+ try {
+ const { name } = req.body;
+ await db.updateRackName(req.params.id, name);
+ res.json({ success: true });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.delete('/api/racks/:id', async (req, res) => {
+ try {
+ await db.deleteRack(req.params.id);
+ res.json({ success: true });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// Device Types
+app.get('/api/device-types', async (req, res) => {
+ try {
+ const types = await db.getAllDeviceTypes();
+ res.json(types);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// Devices
+app.get('/api/devices', async (req, res) => {
+ try {
+ const projectId = req.query.projectId || 1;
+ const devices = await db.getAllDevices(projectId);
+ res.json(devices);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.post('/api/devices', async (req, res) => {
+ try {
+ const { deviceTypeId, rackId, position, name } = req.body;
+ const device = await db.createDevice(deviceTypeId, rackId, position, name);
+ res.status(201).json(device);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.delete('/api/devices/:id', async (req, res) => {
+ try {
+ await db.deleteDevice(req.params.id);
+ res.json({ success: true });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.put('/api/devices/:id/rack', async (req, res) => {
+ try {
+ const { rackId, position } = req.body;
+ await db.updateDeviceRack(req.params.id, rackId, position);
+ res.json({ success: true });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.put('/api/devices/:id/logical-position', async (req, res) => {
+ try {
+ const { x, y } = req.body;
+ await db.updateDeviceLogicalPosition(req.params.id, x, y);
+ res.json({ success: true });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.put('/api/devices/:id/name', async (req, res) => {
+ try {
+ const { name } = req.body;
+ await db.updateDeviceName(req.params.id, name);
+ res.json({ success: true });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.put('/api/devices/:id/rack-units', async (req, res) => {
+ try {
+ const { rackUnits } = req.body;
+ await db.updateDeviceRackUnits(req.params.id, rackUnits);
+ res.json({ success: true });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.get('/api/devices/:id/used-ports', async (req, res) => {
+ try {
+ const ports = await db.getUsedPorts(req.params.id);
+ res.json(ports);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// Connections
+app.get('/api/connections', async (req, res) => {
+ try {
+ const projectId = req.query.projectId || 1;
+ const connections = await db.getAllConnections(projectId);
+ res.json(connections);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.post('/api/connections', async (req, res) => {
+ try {
+ const { sourceDeviceId, sourcePort, targetDeviceId, targetPort } = req.body;
+ const connection = await db.createConnection(sourceDeviceId, sourcePort, targetDeviceId, targetPort);
+ res.status(201).json(connection);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.put('/api/connections/:id/waypoints', async (req, res) => {
+ try {
+ const { waypoints, view } = req.body;
+ await db.updateConnectionWaypoints(req.params.id, waypoints, view);
+ res.json({ success: true });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.put('/api/connections/:id', async (req, res) => {
+ try {
+ const { sourceDeviceId, sourcePort, targetDeviceId, targetPort } = req.body;
+ await db.updateConnection(req.params.id, sourceDeviceId, sourcePort, targetDeviceId, targetPort);
+ res.json({ success: true });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.delete('/api/connections/:id', async (req, res) => {
+ try {
+ await db.deleteConnection(req.params.id);
+ res.json({ success: true });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// Initialize database and start server
+db.init()
+ .then(() => {
+ app.listen(PORT, () => {
+ console.log(`Server running on http://localhost:${PORT}`);
+ });
+ })
+ .catch((err) => {
+ console.error('Failed to initialize database:', err);
+ process.exit(1);
+ });
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..bb90869
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2123 @@
+{
+ "name": "datacenter-designer",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "datacenter-designer",
+ "version": "1.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "better-sqlite3": "^11.7.0",
+ "express": "^4.18.2",
+ "sqlite3": "^5.1.6"
+ }
+ },
+ "node_modules/@gar/promisify": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
+ "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==",
+ "optional": true
+ },
+ "node_modules/@npmcli/fs": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz",
+ "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==",
+ "optional": true,
+ "dependencies": {
+ "@gar/promisify": "^1.0.1",
+ "semver": "^7.3.5"
+ }
+ },
+ "node_modules/@npmcli/move-file": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz",
+ "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==",
+ "deprecated": "This functionality has been moved to @npmcli/fs",
+ "optional": true,
+ "dependencies": {
+ "mkdirp": "^1.0.4",
+ "rimraf": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@tootallnate/once": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
+ "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==",
+ "optional": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/abbrev": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
+ "optional": true
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "optional": true,
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/agent-base/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "optional": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/agent-base/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "optional": true
+ },
+ "node_modules/agentkeepalive": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
+ "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
+ "optional": true,
+ "dependencies": {
+ "humanize-ms": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ }
+ },
+ "node_modules/aggregate-error": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
+ "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
+ "optional": true,
+ "dependencies": {
+ "clean-stack": "^2.0.0",
+ "indent-string": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/aproba": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
+ "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
+ "optional": true
+ },
+ "node_modules/are-we-there-yet": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz",
+ "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==",
+ "deprecated": "This package is no longer supported.",
+ "optional": true,
+ "dependencies": {
+ "delegates": "^1.0.0",
+ "readable-stream": "^3.6.0"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "optional": true
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/better-sqlite3": {
+ "version": "11.7.0",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.7.0.tgz",
+ "integrity": "sha512-mXpa5jnIKKHeoGzBrUJrc65cXFKcILGZpU3FXR0pradUEm9MA7UZz02qfEejaMcm9iXrSOCenwwYMJ/tZ1y5Ig==",
+ "hasInstallScript": true,
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.3",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
+ "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.4.24",
+ "on-finished": "2.4.1",
+ "qs": "6.13.0",
+ "raw-body": "2.5.2",
+ "type-is": "~1.6.18",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "optional": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/cacache": {
+ "version": "15.3.0",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz",
+ "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==",
+ "optional": true,
+ "dependencies": {
+ "@npmcli/fs": "^1.0.0",
+ "@npmcli/move-file": "^1.0.1",
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "glob": "^7.1.4",
+ "infer-owner": "^1.0.4",
+ "lru-cache": "^6.0.0",
+ "minipass": "^3.1.1",
+ "minipass-collect": "^1.0.2",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.2",
+ "mkdirp": "^1.0.3",
+ "p-map": "^4.0.0",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^3.0.2",
+ "ssri": "^8.0.1",
+ "tar": "^6.0.2",
+ "unique-filename": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/clean-stack": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
+ "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
+ "optional": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/color-support": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
+ "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
+ "optional": true,
+ "bin": {
+ "color-support": "bin.js"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "optional": true
+ },
+ "node_modules/console-control-strings": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
+ "optional": true
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
+ "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/delegates": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
+ "optional": true
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "optional": true
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/encoding": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
+ "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
+ "optional": true,
+ "dependencies": {
+ "iconv-lite": "^0.6.2"
+ }
+ },
+ "node_modules/encoding/node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "optional": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/env-paths": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "optional": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/err-code": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz",
+ "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==",
+ "optional": true
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.21.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
+ "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "1.20.3",
+ "content-disposition": "0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "0.7.1",
+ "cookie-signature": "1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "1.3.1",
+ "fresh": "0.5.2",
+ "http-errors": "2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "6.13.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "0.19.0",
+ "serve-static": "1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
+ "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "2.0.1",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="
+ },
+ "node_modules/fs-minipass": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+ "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "optional": true
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gauge": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz",
+ "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==",
+ "deprecated": "This package is no longer supported.",
+ "optional": true,
+ "dependencies": {
+ "aproba": "^1.0.3 || ^2.0.0",
+ "color-support": "^1.1.3",
+ "console-control-strings": "^1.1.0",
+ "has-unicode": "^2.0.1",
+ "signal-exit": "^3.0.7",
+ "string-width": "^4.2.3",
+ "strip-ansi": "^6.0.1",
+ "wide-align": "^1.1.5"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="
+ },
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Glob versions prior to v9 are no longer supported",
+ "optional": true,
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "optional": true
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-unicode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+ "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
+ "optional": true
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-cache-semantics": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
+ "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
+ "optional": true
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
+ "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
+ "dependencies": {
+ "depd": "2.0.0",
+ "inherits": "2.0.4",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "toidentifier": "1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz",
+ "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==",
+ "optional": true,
+ "dependencies": {
+ "@tootallnate/once": "1",
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/http-proxy-agent/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "optional": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/http-proxy-agent/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "optional": true
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "optional": true,
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/https-proxy-agent/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "optional": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/https-proxy-agent/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "optional": true
+ },
+ "node_modules/humanize-ms": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
+ "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
+ "optional": true,
+ "dependencies": {
+ "ms": "^2.0.0"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "optional": true,
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/infer-owner": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz",
+ "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==",
+ "optional": true
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+ "optional": true,
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+ },
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
+ },
+ "node_modules/ip-address": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
+ "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
+ "optional": true,
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-lambda": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz",
+ "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==",
+ "optional": true
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "optional": true
+ },
+ "node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "optional": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/make-fetch-happen": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz",
+ "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==",
+ "optional": true,
+ "dependencies": {
+ "agentkeepalive": "^4.1.3",
+ "cacache": "^15.2.0",
+ "http-cache-semantics": "^4.1.0",
+ "http-proxy-agent": "^4.0.1",
+ "https-proxy-agent": "^5.0.0",
+ "is-lambda": "^1.0.1",
+ "lru-cache": "^6.0.0",
+ "minipass": "^3.1.3",
+ "minipass-collect": "^1.0.2",
+ "minipass-fetch": "^1.3.2",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.4",
+ "negotiator": "^0.6.2",
+ "promise-retry": "^2.0.1",
+ "socks-proxy-agent": "^6.0.0",
+ "ssri": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "optional": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-collect": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz",
+ "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minipass-fetch": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz",
+ "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^3.1.0",
+ "minipass-sized": "^1.0.3",
+ "minizlib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "optionalDependencies": {
+ "encoding": "^0.1.12"
+ }
+ },
+ "node_modules/minipass-flush": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz",
+ "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minipass-pipeline": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz",
+ "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-sized": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz",
+ "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minizlib": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+ "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+ "dependencies": {
+ "minipass": "^3.0.0",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/mkdirp": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
+ },
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/node-abi": {
+ "version": "3.78.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.78.0.tgz",
+ "integrity": "sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ==",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/node-addon-api": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
+ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="
+ },
+ "node_modules/node-gyp": {
+ "version": "8.4.1",
+ "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz",
+ "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==",
+ "optional": true,
+ "dependencies": {
+ "env-paths": "^2.2.0",
+ "glob": "^7.1.4",
+ "graceful-fs": "^4.2.6",
+ "make-fetch-happen": "^9.1.0",
+ "nopt": "^5.0.0",
+ "npmlog": "^6.0.0",
+ "rimraf": "^3.0.2",
+ "semver": "^7.3.5",
+ "tar": "^6.1.2",
+ "which": "^2.0.2"
+ },
+ "bin": {
+ "node-gyp": "bin/node-gyp.js"
+ },
+ "engines": {
+ "node": ">= 10.12.0"
+ }
+ },
+ "node_modules/nopt": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
+ "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
+ "optional": true,
+ "dependencies": {
+ "abbrev": "1"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/npmlog": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz",
+ "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==",
+ "deprecated": "This package is no longer supported.",
+ "optional": true,
+ "dependencies": {
+ "are-we-there-yet": "^3.0.0",
+ "console-control-strings": "^1.1.0",
+ "gauge": "^4.0.3",
+ "set-blocking": "^2.0.0"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/p-map": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
+ "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
+ "optional": true,
+ "dependencies": {
+ "aggregate-error": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.12",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
+ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
+ },
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/promise-inflight": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
+ "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==",
+ "optional": true
+ },
+ "node_modules/promise-retry": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz",
+ "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==",
+ "optional": true,
+ "dependencies": {
+ "err-code": "^2.0.2",
+ "retry": "^0.12.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
+ "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.13.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
+ "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
+ "dependencies": {
+ "side-channel": "^1.0.6"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
+ "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.4.24",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/retry": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
+ "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
+ "optional": true,
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "optional": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+ },
+ "node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.0",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
+ "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "0.5.2",
+ "http-errors": "2.0.0",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "2.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/encodeurl": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
+ "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.2",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
+ "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "0.19.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "optional": true
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "optional": true
+ },
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
+ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+ "optional": true,
+ "engines": {
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks": {
+ "version": "2.8.7",
+ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
+ "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
+ "optional": true,
+ "dependencies": {
+ "ip-address": "^10.0.1",
+ "smart-buffer": "^4.2.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks-proxy-agent": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz",
+ "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==",
+ "optional": true,
+ "dependencies": {
+ "agent-base": "^6.0.2",
+ "debug": "^4.3.3",
+ "socks": "^2.6.2"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/socks-proxy-agent/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "optional": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/socks-proxy-agent/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "optional": true
+ },
+ "node_modules/sqlite3": {
+ "version": "5.1.7",
+ "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz",
+ "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==",
+ "hasInstallScript": true,
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "node-addon-api": "^7.0.0",
+ "prebuild-install": "^7.1.1",
+ "tar": "^6.1.11"
+ },
+ "optionalDependencies": {
+ "node-gyp": "8.x"
+ },
+ "peerDependencies": {
+ "node-gyp": "8.x"
+ },
+ "peerDependenciesMeta": {
+ "node-gyp": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ssri": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz",
+ "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
+ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "optional": true,
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "optional": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tar": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
+ "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
+ "dependencies": {
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "minipass": "^5.0.0",
+ "minizlib": "^2.1.1",
+ "mkdirp": "^1.0.3",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/tar-fs": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
+ "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-fs/node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tar/node_modules/minipass": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
+ "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unique-filename": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz",
+ "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==",
+ "optional": true,
+ "dependencies": {
+ "unique-slug": "^2.0.0"
+ }
+ },
+ "node_modules/unique-slug": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz",
+ "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==",
+ "optional": true,
+ "dependencies": {
+ "imurmurhash": "^0.1.4"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "optional": true,
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/wide-align": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
+ "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
+ "optional": true,
+ "dependencies": {
+ "string-width": "^1.0.2 || 2 || 3 || 4"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
+ },
+ "node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..cfdde5a
--- /dev/null
+++ b/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "datacenter-designer",
+ "version": "0.1.0",
+ "description": "Web application for visual design of racks and interconnected devices",
+ "main": "server/server.js",
+ "scripts": {
+ "start": "node server/server.js",
+ "dev": "node server/server.js"
+ },
+ "keywords": [
+ "datacenter",
+ "rack",
+ "designer",
+ "topology"
+ ],
+ "author": "",
+ "license": "MIT",
+ "dependencies": {
+ "better-sqlite3": "^11.7.0",
+ "express": "^4.18.2",
+ "sqlite3": "^5.1.6"
+ }
+}
diff --git a/public/css/config.css b/public/css/config.css
new file mode 100644
index 0000000..ffcc240
--- /dev/null
+++ b/public/css/config.css
@@ -0,0 +1,157 @@
+/**
+ * CSS Configuration - Theme Variables
+ * Central configuration for all colors, sizes, and design tokens
+ */
+
+:root {
+ /* === Colors === */
+ /* Primary */
+ --color-primary: #4A90E2;
+ --color-primary-dark: #357ABD;
+ --color-primary-light: #e3f2fd;
+ --color-primary-hover: #f0f7ff;
+
+ /* Secondary */
+ --color-secondary: #f5f5f5;
+ --color-secondary-dark: #e0e0e0;
+
+ /* Success, Danger, Warning */
+ --color-success: #4CAF50;
+ --color-success-dark: #45a049;
+ --color-danger: #d32f2f;
+ --color-danger-hover: #b71c1c;
+ --color-warning: #ff9800;
+
+ /* Grays */
+ --color-gray-100: #f9f9f9;
+ --color-gray-200: #f5f5f5;
+ --color-gray-300: #e0e0e0;
+ --color-gray-400: #d0d0d0;
+ --color-gray-500: #999;
+ --color-gray-600: #666;
+ --color-gray-700: #333;
+
+ /* Text */
+ --color-text-primary: #333;
+ --color-text-secondary: #666;
+ --color-text-tertiary: #999;
+ --color-text-inverse: #fff;
+
+ /* Backgrounds */
+ --color-bg-canvas: #f5f5f5;
+ --color-bg-white: #fff;
+ --color-bg-modal-overlay: rgba(0, 0, 0, 0.5);
+ --color-bg-hover: #f5f5f5;
+ --color-bg-selection: #e3f2fd;
+
+ /* Borders */
+ --color-border: #e0e0e0;
+ --color-border-dark: #d0d0d0;
+ --color-border-light: #f0f0f0;
+
+ /* === Spacing === */
+ --spacing-xs: 4px;
+ --spacing-sm: 8px;
+ --spacing-md: 12px;
+ --spacing-lg: 16px;
+ --spacing-xl: 20px;
+ --spacing-2xl: 24px;
+ --spacing-3xl: 32px;
+
+ /* === Typography === */
+ --font-family-primary: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ --font-family-mono: 'Courier New', monospace;
+
+ --font-size-xs: 11px;
+ --font-size-sm: 13px;
+ --font-size-md: 14px;
+ --font-size-lg: 16px;
+ --font-size-xl: 18px;
+ --font-size-2xl: 24px;
+
+ --font-weight-normal: 400;
+ --font-weight-medium: 500;
+ --font-weight-semibold: 600;
+ --font-weight-bold: 700;
+
+ --line-height-tight: 1.2;
+ --line-height-normal: 1.5;
+ --line-height-relaxed: 1.75;
+
+ /* === Border Radius === */
+ --radius-sm: 3px;
+ --radius-md: 4px;
+ --radius-lg: 5px;
+ --radius-xl: 6px;
+ --radius-2xl: 8px;
+ --radius-full: 9999px;
+
+ /* === Shadows === */
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
+ --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.1);
+ --shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.15);
+ --shadow-xl: 0 8px 24px rgba(0, 0, 0, 0.2);
+
+ /* === Transitions === */
+ --transition-fast: 0.15s ease;
+ --transition-normal: 0.2s ease;
+ --transition-slow: 0.3s ease;
+
+ /* === Z-Index === */
+ --z-base: 1;
+ --z-dropdown: 100;
+ --z-sticky: 200;
+ --z-fixed: 300;
+ --z-modal-backdrop: 1000;
+ --z-modal: 2000;
+ --z-popover: 3000;
+ --z-tooltip: 4000;
+
+ /* === Layout === */
+ --toolbar-height: 50px;
+ --table-pane-height: 300px;
+ --table-pane-min-height: 150px;
+ --resize-handle-height: 6px;
+
+ /* === Component Specific === */
+ /* Buttons */
+ --btn-padding-sm: 6px 12px;
+ --btn-padding-md: 10px 20px;
+ --btn-padding-lg: 12px 24px;
+
+ /* Inputs */
+ --input-padding: 10px 12px;
+ --input-border-width: 1px;
+ --input-height: 38px;
+
+ /* Modal */
+ --modal-width: 600px;
+ --modal-width-lg: 800px;
+ --modal-padding: 20px;
+
+ /* Context Menu */
+ --context-menu-width: 200px;
+ --context-menu-item-padding: 10px 20px;
+
+ /* Table */
+ --table-header-height: 40px;
+ --table-row-height: 35px;
+}
+
+/* === Dark Mode Support (Future) === */
+/* @media (prefers-color-scheme: dark) {
+ :root {
+ --color-primary: #64B5F6;
+ --color-bg-canvas: #1a1a1a;
+ --color-bg-white: #2a2a2a;
+ --color-text-primary: #e0e0e0;
+ --color-border: #444;
+ }
+} */
+
+/* === Responsive Breakpoints === */
+:root {
+ --breakpoint-mobile: 768px;
+ --breakpoint-tablet: 1024px;
+ --breakpoint-desktop: 1280px;
+}
diff --git a/public/css/style.css b/public/css/style.css
new file mode 100644
index 0000000..0f9db55
--- /dev/null
+++ b/public/css/style.css
@@ -0,0 +1,642 @@
+/**
+ * Datacenter Designer - Main Styles
+ * Using CSS variables from config.css for maintainability
+ */
+
+@import url('config.css');
+
+/* === Base Styles === */
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: var(--font-family-primary);
+ overflow: hidden;
+ background-color: var(--color-bg-canvas);
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+/* === Toolbar === */
+.toolbar {
+ background-color: var(--color-gray-200);
+ border-bottom: 1px solid var(--color-border);
+ padding: var(--spacing-md) var(--spacing-xl);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-lg);
+ height: var(--toolbar-height);
+ flex-shrink: 0;
+}
+
+.toolbar-spacer {
+ flex: 1;
+}
+
+.toolbar-info, .toolbar-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+}
+
+.project-selector {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ margin-right: var(--spacing-xl);
+}
+
+/* === View Switchers === */
+.view-switcher-group {
+ display: flex;
+ gap: var(--spacing-lg);
+ align-items: center;
+}
+
+.view-switcher {
+ display: flex;
+ gap: 0;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ background-color: var(--color-bg-white);
+}
+
+.btn-view {
+ padding: var(--spacing-xs) var(--spacing-lg);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-medium);
+ background-color: var(--color-bg-white);
+ color: var(--color-text-secondary);
+ border: none;
+ border-right: 1px solid var(--color-border);
+ cursor: pointer;
+ transition: all var(--transition-normal);
+}
+
+.btn-view:last-child {
+ border-right: none;
+}
+
+.btn-view:hover:not(.active) {
+ background-color: var(--color-bg-hover);
+ color: var(--color-text-primary);
+}
+
+.btn-view.active {
+ background-color: var(--color-primary);
+ color: var(--color-text-inverse);
+ font-weight: var(--font-weight-semibold);
+}
+
+/* === Buttons === */
+.btn {
+ padding: var(--btn-padding-md);
+ border: none;
+ border-radius: var(--radius-lg);
+ font-size: var(--font-size-md);
+ font-weight: var(--font-weight-medium);
+ cursor: pointer;
+ transition: all var(--transition-normal);
+}
+
+.btn-primary {
+ background-color: var(--color-primary);
+ color: var(--color-text-inverse);
+}
+
+.btn-primary:hover {
+ background-color: var(--color-primary-dark);
+ box-shadow: var(--shadow-md);
+}
+
+.btn-secondary {
+ background-color: var(--color-gray-200);
+ color: var(--color-text-primary);
+}
+
+.btn-secondary:hover {
+ background-color: var(--color-border);
+}
+
+.btn-sm {
+ padding: var(--btn-padding-sm);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-medium);
+ background-color: var(--color-bg-white);
+ color: var(--color-text-primary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: all var(--transition-normal);
+}
+
+.btn-sm:hover {
+ background-color: var(--color-bg-hover);
+ border-color: var(--color-primary);
+}
+
+.btn-danger {
+ background-color: var(--color-bg-white);
+ color: var(--color-danger);
+ border-color: var(--color-danger);
+}
+
+.btn-danger:hover {
+ background-color: var(--color-danger);
+ color: var(--color-text-inverse);
+}
+
+.btn-success {
+ background-color: var(--color-success);
+ color: var(--color-text-inverse);
+ border-color: var(--color-success);
+}
+
+.btn-success:hover {
+ background-color: var(--color-success-dark);
+}
+
+/* === Forms === */
+.form-select {
+ padding: var(--spacing-xs) var(--spacing-md);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ font-size: var(--font-size-sm);
+ background-color: var(--color-bg-white);
+ cursor: pointer;
+ min-width: 200px;
+}
+
+.form-select:focus {
+ outline: none;
+ border-color: var(--color-primary);
+}
+
+.form-group {
+ margin-bottom: var(--spacing-xl);
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: var(--spacing-sm);
+ font-size: var(--font-size-md);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-primary);
+}
+
+.form-input {
+ width: 100%;
+ padding: var(--input-padding);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ font-size: var(--font-size-md);
+ transition: border-color var(--transition-fast);
+ font-family: inherit;
+}
+
+.form-input:focus {
+ outline: none;
+ border-color: var(--color-primary);
+}
+
+textarea.form-input {
+ resize: vertical;
+ min-height: 80px;
+}
+
+.zoom-input {
+ width: 60px;
+ padding: var(--spacing-xs) var(--spacing-sm);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-medium);
+ background-color: var(--color-bg-white);
+ color: var(--color-text-primary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ text-align: center;
+ transition: all var(--transition-fast);
+}
+
+.zoom-input:focus {
+ outline: none;
+ border-color: var(--color-primary);
+ background-color: var(--color-primary-hover);
+}
+
+.zoom-unit {
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-secondary);
+ margin-left: -8px;
+}
+
+/* === Main Content Layout === */
+.main-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ position: relative;
+}
+
+.canvas-pane {
+ flex: 1;
+ overflow: hidden;
+ position: relative;
+}
+
+#canvasWrapper {
+ width: 100%;
+ height: 100%;
+}
+
+.resize-handle {
+ height: var(--resize-handle-height);
+ background-color: var(--color-border);
+ cursor: ns-resize;
+ position: relative;
+ flex-shrink: 0;
+ transition: background-color var(--transition-fast);
+}
+
+.resize-handle:hover {
+ background-color: var(--color-primary);
+}
+
+.resize-handle.hidden {
+ display: none;
+}
+
+.table-pane {
+ display: flex;
+ flex-direction: column;
+ background-color: var(--color-bg-white);
+ overflow: hidden;
+ height: var(--table-pane-height);
+}
+
+.table-pane.hidden {
+ display: none;
+}
+
+.table-toolbar {
+ padding: var(--spacing-md) var(--spacing-lg);
+ border-bottom: 1px solid var(--color-border);
+ background-color: var(--color-gray-100);
+ display: flex;
+ gap: var(--spacing-md);
+ align-items: center;
+ flex-shrink: 0;
+}
+
+#tableContent {
+ flex: 1;
+ width: 100%;
+ overflow: hidden;
+}
+
+/* === Context Menu === */
+.context-menu {
+ position: fixed;
+ background-color: var(--color-bg-white);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-lg);
+ min-width: var(--context-menu-width);
+ z-index: var(--z-modal-backdrop);
+ overflow: hidden;
+}
+
+.context-menu.hidden {
+ display: none;
+}
+
+.context-menu ul {
+ list-style: none;
+ margin: 0;
+ padding: 4px 0;
+}
+
+.context-menu li {
+ padding: 6px 16px;
+ cursor: pointer;
+ font-size: var(--font-size-sm);
+ color: var(--color-text-primary);
+ transition: background-color var(--transition-fast);
+}
+
+.context-menu li:hover {
+ background-color: var(--color-bg-hover);
+}
+
+.context-menu .menu-header {
+ cursor: default;
+ pointer-events: none;
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-tertiary);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ padding: 8px 16px 4px 16px;
+}
+
+.context-menu .menu-header:hover {
+ background-color: transparent;
+}
+
+.context-menu .divider {
+ height: 1px;
+ background-color: var(--color-border);
+ margin: 4px 0;
+ padding: 0;
+ cursor: default;
+ pointer-events: none;
+}
+
+.context-menu .divider:hover {
+ background-color: var(--color-border);
+}
+
+/* === Modals === */
+.modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: var(--color-bg-modal-overlay);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: var(--z-modal);
+}
+
+.modal.hidden {
+ display: none;
+}
+
+.modal-content {
+ background-color: var(--color-bg-white);
+ border-radius: var(--radius-2xl);
+ min-width: 400px;
+ max-width: var(--modal-width);
+ max-height: 80vh;
+ display: flex;
+ flex-direction: column;
+ box-shadow: var(--shadow-xl);
+}
+
+.modal-content.modal-large {
+ max-width: var(--modal-width-lg);
+ min-width: 600px;
+}
+
+.modal-header {
+ padding: var(--modal-padding);
+ border-bottom: 1px solid var(--color-border);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.modal-header h3 {
+ font-size: var(--font-size-xl);
+ color: var(--color-text-primary);
+ font-weight: var(--font-weight-semibold);
+}
+
+.modal-close {
+ background: none;
+ border: none;
+ font-size: var(--font-size-2xl);
+ color: var(--color-text-tertiary);
+ cursor: pointer;
+ padding: 0;
+ width: 30px;
+ height: 30px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius-md);
+ transition: all var(--transition-fast);
+}
+
+.modal-close:hover {
+ background-color: var(--color-bg-hover);
+ color: var(--color-text-primary);
+}
+
+.modal-body {
+ padding: var(--modal-padding);
+ overflow-y: auto;
+}
+
+.modal-footer {
+ margin-top: var(--spacing-xl);
+ display: flex;
+ gap: var(--spacing-md);
+ justify-content: flex-end;
+}
+
+/* === Scrollbars === */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--color-gray-100);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--color-gray-400);
+ border-radius: var(--radius-md);
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--color-gray-500);
+}
+
+/* === Projects Modal === */
+.projects-toolbar {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+ padding-bottom: var(--spacing-lg);
+ border-bottom: 1px solid var(--color-border);
+ margin-bottom: var(--spacing-lg);
+}
+
+.projects-toolbar .btn {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+}
+
+.projects-toolbar .btn svg {
+ flex-shrink: 0;
+}
+
+.projects-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-md);
+ max-height: 50vh;
+ overflow-y: auto;
+}
+
+.project-card {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--spacing-lg);
+ background-color: var(--color-bg-white);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ transition: all var(--transition-fast);
+ gap: var(--spacing-lg);
+}
+
+.project-card:hover {
+ border-color: var(--color-primary);
+ box-shadow: var(--shadow-sm);
+}
+
+.project-card.active {
+ background-color: var(--color-primary-hover);
+ border-color: var(--color-primary);
+}
+
+.project-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.project-name {
+ font-size: var(--font-size-md);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+ margin-bottom: 4px;
+}
+
+.project-description {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+ margin-bottom: 4px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.project-meta {
+ font-size: var(--font-size-xs);
+ color: var(--color-text-tertiary);
+}
+
+.project-actions {
+ display: flex;
+ gap: var(--spacing-sm);
+ flex-shrink: 0;
+}
+
+.btn-icon {
+ padding: 6px 12px;
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-medium);
+ background-color: var(--color-bg-white);
+ color: var(--color-text-primary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ white-space: nowrap;
+}
+
+.btn-icon:hover:not(:disabled) {
+ background-color: var(--color-bg-hover);
+ border-color: var(--color-primary);
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-sm);
+}
+
+.btn-icon:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ background-color: var(--color-gray-200);
+}
+
+.btn-icon.btn-success {
+ background-color: var(--color-success);
+ color: var(--color-text-inverse);
+ border-color: var(--color-success);
+}
+
+.btn-icon.btn-success:hover {
+ background-color: var(--color-success-dark);
+ border-color: var(--color-success-dark);
+}
+
+.btn-icon.btn-danger {
+ background-color: var(--color-bg-white);
+ color: var(--color-danger);
+ border-color: var(--color-danger);
+}
+
+.btn-icon.btn-danger:hover {
+ background-color: var(--color-danger);
+ color: var(--color-text-inverse);
+}
+
+/* === Utility Classes === */
+.hidden {
+ display: none !important;
+}
+
+/* === Responsive Design === */
+@media (max-width: 768px) {
+ .toolbar {
+ flex-wrap: wrap;
+ height: auto;
+ padding: var(--spacing-sm);
+ }
+
+ .view-switcher-group {
+ width: 100%;
+ justify-content: space-between;
+ }
+
+ .modal-content {
+ min-width: 90%;
+ max-width: 90%;
+ }
+
+ .btn-sm {
+ font-size: var(--font-size-xs);
+ padding: var(--spacing-xs) var(--spacing-sm);
+ }
+}
+
+@media (max-width: 1024px) {
+ .table-pane {
+ height: 250px;
+ }
+}
+
+/* === ag-Grid Customization === */
+.ag-theme-alpine {
+ --ag-header-height: var(--table-header-height);
+ --ag-row-height: var(--table-row-height);
+ --ag-font-size: var(--font-size-sm);
+ --ag-header-foreground-color: var(--color-text-primary);
+ --ag-header-background-color: var(--color-gray-200);
+ --ag-odd-row-background-color: var(--color-gray-100);
+ --ag-row-hover-color: var(--color-primary-hover);
+ --ag-selected-row-background-color: var(--color-bg-selection);
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..bdc6b30
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,206 @@
+
+
+
+
+
+ Datacenter Designer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Preview:
+
RACK01, RACK02, RACK03
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/js/app.js b/public/js/app.js
new file mode 100644
index 0000000..d09308c
--- /dev/null
+++ b/public/js/app.js
@@ -0,0 +1,1839 @@
+import { RackManager } from './managers/rack-manager.js';
+import { DeviceManager } from './managers/device-manager.js';
+import { ConnectionManager } from './managers/connection-manager.js';
+import { TableManager } from './managers/table-manager.js';
+
+class API {
+ constructor() {
+ this.currentProjectId = 1; // Default project
+ }
+
+ setProjectId(projectId) {
+ this.currentProjectId = projectId;
+ }
+
+ async request(url, options = {}) {
+ const response = await fetch(url, {
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ },
+ ...options
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || 'Request failed');
+ }
+
+ return response.json();
+ }
+
+ // Projects
+ getProjects() {
+ return this.request('/api/projects');
+ }
+
+ getProject(id) {
+ return this.request(`/api/projects/${id}`);
+ }
+
+ createProject(name, description) {
+ return this.request('/api/projects', {
+ method: 'POST',
+ body: JSON.stringify({ name, description })
+ });
+ }
+
+ deleteProject(id) {
+ return this.request(`/api/projects/${id}`, { method: 'DELETE' });
+ }
+
+ // Racks
+ getRacks() {
+ return this.request(`/api/racks?projectId=${this.currentProjectId}`);
+ }
+
+ getNextRackName(prefix) {
+ return this.request(`/api/racks/next-name?projectId=${this.currentProjectId}&prefix=${prefix}`).then(r => r.name);
+ }
+
+ createRack(name, x, y) {
+ return this.request('/api/racks', {
+ method: 'POST',
+ body: JSON.stringify({ projectId: this.currentProjectId, name, x, y })
+ });
+ }
+
+ updateRackPosition(id, x, y) {
+ return this.request(`/api/racks/${id}/position`, {
+ method: 'PUT',
+ body: JSON.stringify({ x, y })
+ });
+ }
+
+ updateRackName(id, name) {
+ return this.request(`/api/racks/${id}/name`, {
+ method: 'PUT',
+ body: JSON.stringify({ name })
+ });
+ }
+
+ deleteRack(id) {
+ return this.request(`/api/racks/${id}`, { method: 'DELETE' });
+ }
+
+ // Device Types
+ getDeviceTypes() {
+ return this.request('/api/devices/types');
+ }
+
+ // Devices
+ getDevices() {
+ return this.request(`/api/devices?projectId=${this.currentProjectId}`);
+ }
+
+ createDevice(deviceTypeId, rackId, position, name) {
+ return this.request('/api/devices', {
+ method: 'POST',
+ body: JSON.stringify({ deviceTypeId, rackId, position, name })
+ });
+ }
+
+ deleteDevice(id) {
+ return this.request(`/api/devices/${id}`, { method: 'DELETE' });
+ }
+
+ updateDeviceName(id, name) {
+ return this.request(`/api/devices/${id}/name`, {
+ method: 'PUT',
+ body: JSON.stringify({ name })
+ });
+ }
+
+ getUsedPorts(deviceId) {
+ return this.request(`/api/devices/${deviceId}/used-ports`);
+ }
+
+ // Connections
+ getConnections() {
+ return this.request(`/api/connections?projectId=${this.currentProjectId}`);
+ }
+
+ createConnection(sourceDeviceId, sourcePort, targetDeviceId, targetPort) {
+ return this.request('/api/connections', {
+ method: 'POST',
+ body: JSON.stringify({ sourceDeviceId, sourcePort, targetDeviceId, targetPort })
+ });
+ }
+
+ updateConnectionWaypoints(id, waypoints, view = null) {
+ return this.request(`/api/connections/${id}/waypoints`, {
+ method: 'PUT',
+ body: JSON.stringify({ waypoints, view })
+ });
+ }
+
+ deleteConnection(id) {
+ return this.request(`/api/connections/${id}`, { method: 'DELETE' });
+ }
+}
+
+class DatacenterDesigner {
+ constructor() {
+ this.api = new API();
+ this.stage = null;
+ this.layer = null;
+ this.rackManager = null;
+ this.deviceManager = null;
+ this.connectionManager = null;
+ this.tableManager = null;
+ this.currentScale = 1;
+ this.minScale = 0.1;
+ this.maxScale = 3;
+ this.currentCanvasView = 'physical'; // 'physical' or 'logical'
+ this.currentTableView = null; // null, 'racks', 'devices', or 'connections'
+
+ // Separate view states for physical and logical views
+ this.viewStates = {
+ physical: { x: 50, y: 50, scale: 1 },
+ logical: { x: 50, y: 50, scale: 1 }
+ };
+ }
+
+ async init() {
+ this.setupCanvas();
+ this.setupManagers();
+ await this.loadProjects();
+ // Load spacing after project ID is set
+ this.rackManager.loadSpacing();
+ await this.loadData();
+ this.loadViewStates(); // Load saved view states
+ this.setupEventListeners();
+ this.setupContextMenu();
+ this.setupZoomAndPan();
+ this.setupResizeHandle();
+ }
+
+ async loadProjects() {
+ try {
+ const projects = await this.api.getProjects();
+ const projectSelect = document.getElementById('projectSelect');
+
+ projectSelect.innerHTML = '';
+
+ // Add existing projects
+ projects.forEach(project => {
+ const option = document.createElement('option');
+ option.value = project.id;
+ option.textContent = project.name;
+ projectSelect.appendChild(option);
+ });
+
+ // Add separator
+ const separator = document.createElement('option');
+ separator.disabled = true;
+ separator.textContent = '─────────────────────';
+ projectSelect.appendChild(separator);
+
+ // Add "Create New Project" option
+ const createOption = document.createElement('option');
+ createOption.value = '__create__';
+ createOption.textContent = 'Create New Project';
+ projectSelect.appendChild(createOption);
+
+ // Add "Manage Projects" option
+ const manageOption = document.createElement('option');
+ manageOption.value = '__manage__';
+ manageOption.textContent = 'Manage Projects';
+ projectSelect.appendChild(manageOption);
+
+ // Set current project
+ const currentProjectId = parseInt(localStorage.getItem('currentProjectId') || '1');
+ projectSelect.value = currentProjectId;
+ this.api.setProjectId(currentProjectId);
+ } catch (err) {
+ console.error('Failed to load projects:', err);
+ }
+ }
+
+ async switchProject(projectId) {
+ this.api.setProjectId(projectId);
+ localStorage.setItem('currentProjectId', projectId);
+
+ // Clear canvas
+ this.rackManager.racks.clear();
+ this.deviceManager.devices.clear();
+ this.connectionManager.connections.clear();
+ this.layer.destroyChildren();
+ this.connectionManager.getConnectionLayer().destroyChildren();
+
+ // Reload spacing for this project
+ this.rackManager.loadSpacing();
+
+ // Reset both view states
+ this.viewStates.physical = { x: 50, y: 50, scale: 1 };
+ this.viewStates.logical = { x: 50, y: 50, scale: 1 };
+ this.saveViewStates();
+
+ // Reset view (pan and zoom)
+ this.resetView();
+
+ // Reload data for new project
+ await this.loadData();
+ this.layer.batchDraw();
+ this.connectionManager.getConnectionLayer().batchDraw();
+ }
+
+ loadViewStates() {
+ try {
+ const saved = localStorage.getItem(`viewStates_${this.api.currentProjectId}`);
+ if (saved) {
+ this.viewStates = JSON.parse(saved);
+ }
+ } catch (err) {
+ console.error('Failed to load view states:', err);
+ }
+ }
+
+ saveViewStates() {
+ try {
+ localStorage.setItem(`viewStates_${this.api.currentProjectId}`, JSON.stringify(this.viewStates));
+ } catch (err) {
+ console.error('Failed to save view states:', err);
+ }
+ }
+
+ saveCurrentViewState() {
+ this.viewStates[this.currentCanvasView] = {
+ x: this.stage.x(),
+ y: this.stage.y(),
+ scale: this.stage.scaleX()
+ };
+ this.saveViewStates();
+ }
+
+ restoreViewState(viewType) {
+ const state = this.viewStates[viewType];
+ this.stage.position({ x: state.x, y: state.y });
+ this.stage.scale({ x: state.scale, y: state.scale });
+ this.currentScale = state.scale;
+ this.updateZoomDisplay(state.scale);
+ this.stage.batchDraw();
+ }
+
+ setupCanvas() {
+ const container = document.getElementById('canvasWrapper');
+ const width = container.offsetWidth;
+ const height = container.offsetHeight;
+
+ this.stage = new Konva.Stage({
+ container: 'canvasWrapper',
+ width: width,
+ height: height
+ });
+
+ this.layer = new Konva.Layer();
+ this.stage.add(this.layer);
+
+ // Add initial offset for visual margins (without changing grid coordinates)
+ this.stage.position({ x: 50, y: 50 });
+ }
+
+ setupManagers() {
+ // Create device manager first (needed by rack manager)
+ this.deviceManager = new DeviceManager(this.layer, this.api, null);
+
+ // Create rack manager with device manager reference
+ this.rackManager = new RackManager(this.layer, this.api, this.deviceManager);
+
+ // Set rack manager reference in device manager
+ this.deviceManager.rackManager = this.rackManager;
+
+ this.connectionManager = new ConnectionManager(
+ this.layer,
+ this.api,
+ this.deviceManager,
+ this.rackManager
+ );
+
+ // Set connection manager reference in device manager
+ this.deviceManager.connectionManager = this.connectionManager;
+
+ // Add connection layer on top of main layer so connections are visible
+ this.stage.add(this.connectionManager.getConnectionLayer());
+ this.connectionManager.getConnectionLayer().moveToTop();
+
+ // Create table manager
+ this.tableManager = new TableManager(
+ this.api,
+ this.rackManager,
+ this.deviceManager,
+ this.connectionManager
+ );
+ }
+
+ async loadData() {
+
+ await this.deviceManager.loadDeviceTypes();
+ await this.rackManager.loadRacks();
+ await this.deviceManager.loadDevices();
+ await this.connectionManager.loadConnections();
+
+ }
+
+ setupZoomAndPan() {
+ const container = this.stage.container();
+
+ // Zoom with Ctrl + Wheel
+ container.addEventListener('wheel', (e) => {
+ if (!e.ctrlKey) return;
+
+ e.preventDefault();
+
+ const oldScale = this.stage.scaleX();
+ const pointer = this.stage.getPointerPosition();
+
+ const mousePointTo = {
+ x: (pointer.x - this.stage.x()) / oldScale,
+ y: (pointer.y - this.stage.y()) / oldScale
+ };
+
+ const delta = e.deltaY > 0 ? 0.9 : 1.1;
+ let newScale = oldScale * delta;
+
+ // Clamp scale
+ newScale = Math.max(this.minScale, Math.min(this.maxScale, newScale));
+
+ this.stage.scale({ x: newScale, y: newScale });
+
+ const newPos = {
+ x: pointer.x - mousePointTo.x * newScale,
+ y: pointer.y - mousePointTo.y * newScale
+ };
+
+ this.stage.position(newPos);
+ this.stage.batchDraw();
+
+ this.currentScale = newScale;
+ this.updateZoomDisplay(newScale);
+ this.saveCurrentViewState(); // Save zoom state
+ });
+
+ // Pan with Ctrl + drag
+ let isPanning = false;
+ let startPos = null;
+
+ // Listen to mousedown on container level instead of stage
+ // This way we can control panning without interfering with Konva's drag system
+ container.addEventListener('mousedown', (evt) => {
+ // Ignore right-clicks for panning
+ if (evt.button === 2) {
+ return;
+ }
+
+ // Hide context menu on left click
+ this.hideContextMenu();
+
+ // Only pan when Ctrl is held down
+ if (evt.ctrlKey) {
+ // Check if we're clicking on a Konva element
+ const stage = this.stage;
+ const pos = stage.getPointerPosition();
+
+ if (!pos) return;
+
+ // Get what's under the cursor
+ const shape = stage.getIntersection(pos);
+
+ // Don't pan if clicking on a draggable element
+ if (shape && shape.draggable && shape.draggable()) {
+ console.log('Clicked on draggable element, not panning');
+ return;
+ }
+
+ isPanning = true;
+ startPos = pos;
+ container.style.cursor = 'grabbing';
+ }
+ });
+
+ container.addEventListener('mousemove', (evt) => {
+ if (!isPanning) return;
+
+ const pos = this.stage.getPointerPosition();
+ if (!pos) return;
+
+ const dx = pos.x - startPos.x;
+ const dy = pos.y - startPos.y;
+
+ this.stage.position({
+ x: this.stage.x() + dx,
+ y: this.stage.y() + dy
+ });
+
+ startPos = pos;
+ this.stage.batchDraw();
+ });
+
+ container.addEventListener('mouseup', () => {
+ if (isPanning) {
+ this.saveCurrentViewState(); // Save pan state
+ }
+ isPanning = false;
+ container.style.cursor = 'default';
+ });
+
+ container.addEventListener('mouseleave', () => {
+ isPanning = false;
+ container.style.cursor = 'default';
+ });
+ }
+
+ setupEventListeners() {
+ // Canvas view switcher (Physical / Logical)
+ document.getElementById('physicalViewBtn').addEventListener('click', () => {
+ this.switchCanvasView('physical');
+ });
+
+ document.getElementById('logicalViewBtn').addEventListener('click', () => {
+ this.switchCanvasView('logical');
+ });
+
+ // Table view switcher (Racks / Devices / Connections) - Toggle behavior
+ document.getElementById('racksTableBtn').addEventListener('click', () => {
+ this.toggleTableView('racks');
+ });
+
+ document.getElementById('devicesTableBtn').addEventListener('click', () => {
+ this.toggleTableView('devices');
+ });
+
+ document.getElementById('connectionsTableBtn').addEventListener('click', () => {
+ this.toggleTableView('connections');
+ });
+
+ // Table toolbar buttons
+ document.getElementById('addTableRowBtn').addEventListener('click', () => {
+ this.tableManager.addRow();
+ });
+
+ document.getElementById('deleteTableRowBtn').addEventListener('click', () => {
+ this.tableManager.deleteSelectedRows();
+ });
+
+ // Load saved view preferences
+ const savedCanvasView = localStorage.getItem('currentCanvasView') || 'physical';
+ this.switchCanvasView(savedCanvasView);
+
+ // Project selector
+ document.getElementById('projectSelect').addEventListener('change', async (e) => {
+ const value = e.target.value;
+
+ // Handle special options
+ if (value === '__create__') {
+ // Reset dropdown to current project
+ e.target.value = this.api.currentProjectId;
+ // Show create modal
+ this.showProjectFormModal();
+ return;
+ }
+
+ if (value === '__manage__') {
+ // Reset dropdown to current project
+ e.target.value = this.api.currentProjectId;
+ // Show manage modal
+ this.showManageProjectsModal();
+ return;
+ }
+
+ // Normal project switch
+ const projectId = parseInt(value);
+ await this.switchProject(projectId);
+ });
+
+ // Right-click context menu handler
+ this.stage.on('contextmenu', (e) => {
+ e.evt.preventDefault();
+
+ // Right-click on empty canvas
+ if (e.target === this.stage) {
+ this.showCanvasContextMenu(e);
+ return;
+ }
+
+ // Rack right-clicks are handled by RackManager's own context menu
+ });
+
+ // ESC key to cancel connection
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && this.connectionManager.isConnectionMode()) {
+ this.connectionManager.cancelConnection();
+ }
+ });
+
+ // Device click handler for connections (when racks are locked)
+ this.layer.on('click', (e) => {
+ const target = e.target;
+
+ // Check if clicked on a device (find parent group)
+ let deviceGroup = target;
+ while (deviceGroup && !(deviceGroup.id() && deviceGroup.id().startsWith('device-'))) {
+ deviceGroup = deviceGroup.getParent();
+ if (!deviceGroup || deviceGroup === this.layer) {
+ deviceGroup = null;
+ break;
+ }
+ }
+
+ if (deviceGroup) {
+ const deviceId = parseInt(deviceGroup.id().replace('device-', ''));
+
+ // Only handle clicks when racks are locked
+ if (this.rackManager.racksLocked) {
+ if (this.connectionManager.isConnectionMode()) {
+ // Complete connection
+ this.connectionManager.completeConnection(deviceId, deviceGroup);
+ } else {
+ // Start connection
+ this.connectionManager.startConnection(deviceId, deviceGroup);
+ }
+ }
+ } else {
+ // Clicked on empty space - deselect any selected connection
+ this.connectionManager.deselectConnection();
+ }
+ });
+
+
+ // Rename rack event
+ window.addEventListener('rename-rack', async (e) => {
+ const { rackId, rackData, rackShape } = e.detail;
+ await this.renameRack(rackId, rackData, rackShape);
+ });
+
+ // Rename device event
+ window.addEventListener('rename-device', async (e) => {
+ const { deviceId, deviceData, deviceShape } = e.detail;
+ await this.renameDevice(deviceId, deviceData, deviceShape);
+ });
+
+ // Canvas data changed - sync to table
+ window.addEventListener('canvas-data-changed', async () => {
+ if (this.currentTableView) {
+ await this.tableManager.syncFromCanvas();
+ }
+ });
+
+ // Window resize
+ window.addEventListener('resize', () => {
+ const container = document.getElementById('canvasWrapper');
+ this.stage.width(container.offsetWidth);
+ this.stage.height(container.offsetHeight);
+ });
+
+ // Zoom input field
+ const zoomInput = document.getElementById('zoomInput');
+ zoomInput.addEventListener('change', (e) => {
+ const percentage = parseInt(e.target.value);
+ if (!isNaN(percentage) && percentage >= 10 && percentage <= 300) {
+ this.setZoom(percentage / 100);
+ }
+ });
+
+ // Also handle Enter key
+ zoomInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ const percentage = parseInt(e.target.value);
+ if (!isNaN(percentage) && percentage >= 10 && percentage <= 300) {
+ this.setZoom(percentage / 100);
+ }
+ }
+ });
+
+ // Fit view button
+ document.getElementById('fitViewBtn').addEventListener('click', () => {
+ this.fitView();
+ });
+
+ // Export/Import project buttons
+ document.getElementById('exportProjectBtn').addEventListener('click', () => {
+ this.exportProject();
+ });
+
+ document.getElementById('importProjectBtn').addEventListener('click', () => {
+ document.getElementById('importProjectInput').click();
+ });
+
+ document.getElementById('importProjectInput').addEventListener('change', (e) => {
+ const file = e.target.files[0];
+ if (file) {
+ this.importProject(file);
+ // Reset input so same file can be selected again
+ e.target.value = '';
+ }
+ });
+
+ // Export to Excel button
+ document.getElementById('exportExcelBtn').addEventListener('click', () => {
+ this.exportToExcel();
+ });
+ }
+
+ async showManageProjectsModal() {
+ const modal = document.getElementById('manageProjectsModal');
+ const closeBtn = document.getElementById('manageProjectsModalClose');
+ const newProjectBtn = document.getElementById('newProjectBtnFromManage');
+ const projectsList = document.getElementById('projectsList');
+
+ modal.classList.remove('hidden');
+
+ // Load and display projects
+ await this.renderProjectsList();
+
+ const handleClose = () => {
+ modal.classList.add('hidden');
+ closeBtn.removeEventListener('click', handleClose);
+ newProjectBtn.removeEventListener('click', handleNewProject);
+ };
+
+ const handleNewProject = () => {
+ this.showProjectFormModal();
+ };
+
+ closeBtn.addEventListener('click', handleClose);
+ newProjectBtn.addEventListener('click', handleNewProject);
+ }
+
+ async renderProjectsList() {
+ const projectsList = document.getElementById('projectsList');
+ const projects = await this.api.getProjects();
+ const currentProjectId = this.api.currentProjectId;
+
+ projectsList.innerHTML = '';
+
+ projects.forEach(project => {
+ const card = document.createElement('div');
+ card.className = 'project-card';
+ if (project.id === currentProjectId) {
+ card.classList.add('active');
+ }
+
+ const date = new Date(project.updated_at).toLocaleDateString();
+
+ card.innerHTML = `
+
+
${project.name}
+
${project.description || 'No description'}
+
Last updated: ${date}
+
+
+ ${project.id !== currentProjectId ? `` : ''}
+
+
+
+ `;
+
+ // Add event listeners to action buttons
+ card.querySelectorAll('[data-action]').forEach(btn => {
+ btn.addEventListener('click', async (e) => {
+ const action = e.target.dataset.action;
+ const id = parseInt(e.target.dataset.id);
+
+ if (action === 'switch') {
+ document.getElementById('projectSelect').value = id;
+ await this.switchProject(id);
+ await this.renderProjectsList();
+ } else if (action === 'edit') {
+ this.showProjectFormModal(project);
+ } else if (action === 'delete') {
+ await this.deleteProject(project);
+ }
+ });
+ });
+
+ projectsList.appendChild(card);
+ });
+ }
+
+ showProjectFormModal(project = null) {
+ const modal = document.getElementById('projectFormModal');
+ const title = document.getElementById('projectFormTitle');
+ const saveBtn = document.getElementById('saveProjectBtn');
+ const cancelBtn = document.getElementById('cancelProjectBtn');
+ const closeBtn = document.getElementById('projectFormModalClose');
+ const nameInput = document.getElementById('projectName');
+ const descInput = document.getElementById('projectDescription');
+
+ // Set form mode
+ const isEdit = !!project;
+ title.textContent = isEdit ? 'Edit Project' : 'New Project';
+ nameInput.value = isEdit ? project.name : '';
+ descInput.value = isEdit ? (project.description || '') : '';
+
+ modal.classList.remove('hidden');
+ nameInput.focus();
+
+ const handleSave = async () => {
+ const name = nameInput.value.trim();
+ const description = descInput.value.trim();
+
+ if (!name) {
+ alert('Please enter a project name');
+ return;
+ }
+
+ try {
+ if (isEdit) {
+ await this.api.request(`/api/projects/${project.id}`, {
+ method: 'PUT',
+ body: JSON.stringify({ name, description })
+ });
+ } else {
+ const newProject = await this.api.createProject(name, description);
+
+ // Switch to new project
+ await this.loadProjects();
+ document.getElementById('projectSelect').value = newProject.id;
+ await this.switchProject(newProject.id);
+ }
+
+ modal.classList.add('hidden');
+
+ // Reload projects and refresh manage modal if open
+ await this.loadProjects();
+ const manageModal = document.getElementById('manageProjectsModal');
+ if (!manageModal.classList.contains('hidden')) {
+ await this.renderProjectsList();
+ }
+ } catch (err) {
+ alert('Failed to save project: ' + err.message);
+ }
+
+ cleanup();
+ };
+
+ const handleCancel = () => {
+ modal.classList.add('hidden');
+ cleanup();
+ };
+
+ const cleanup = () => {
+ saveBtn.removeEventListener('click', handleSave);
+ cancelBtn.removeEventListener('click', handleCancel);
+ closeBtn.removeEventListener('click', handleCancel);
+ };
+
+ saveBtn.addEventListener('click', handleSave);
+ cancelBtn.addEventListener('click', handleCancel);
+ closeBtn.addEventListener('click', handleCancel);
+ }
+
+ async deleteProject(project) {
+ const confirmMsg = `Are you sure you want to delete "${project.name}"?\n\nThis will permanently delete:\n- All racks in this project\n- All devices\n- All connections\n\nThis action cannot be undone.`;
+
+ if (!confirm(confirmMsg)) {
+ return;
+ }
+
+ try {
+ await this.api.deleteProject(project.id);
+
+ // Reload projects
+ await this.loadProjects();
+
+ // If we deleted the current project, switch to the first available
+ if (project.id === this.api.currentProjectId) {
+ const projects = await this.api.getProjects();
+ if (projects.length > 0) {
+ document.getElementById('projectSelect').value = projects[0].id;
+ await this.switchProject(projects[0].id);
+ }
+ }
+
+ // Refresh the project list
+ await this.renderProjectsList();
+ } catch (err) {
+ alert('Failed to delete project: ' + err.message);
+ }
+ }
+
+ showCanvasContextMenu(e) {
+ // Don't show context menu in logical view
+ if (this.currentView === 'logical') {
+ return;
+ }
+
+ const contextMenu = document.getElementById('contextMenu');
+ const contextMenuList = document.getElementById('contextMenuList');
+
+ contextMenuList.innerHTML = `
+ Add Rack(s)
+ `;
+
+ contextMenu.style.left = `${e.evt.pageX}px`;
+ contextMenu.style.top = `${e.evt.pageY}px`;
+ contextMenu.classList.remove('hidden');
+
+ // Mark that menu was just shown (prevents immediate hiding)
+ this.contextMenuJustShown = true;
+ setTimeout(() => {
+ this.contextMenuJustShown = false;
+ }, 100);
+
+ // Remove any existing listeners
+ const oldHandler = this.contextMenuHandler;
+ if (oldHandler) {
+ contextMenuList.removeEventListener('click', oldHandler);
+ }
+
+ // Create new handler
+ this.contextMenuHandler = (evt) => {
+ const action = evt.target.dataset.action;
+ if (action === 'add-racks') {
+ this.showAddRackModal();
+ }
+ this.hideContextMenu();
+ };
+
+ contextMenuList.addEventListener('click', this.contextMenuHandler);
+ }
+
+ hideContextMenu() {
+ // Don't hide if menu was just shown
+ if (this.contextMenuJustShown) {
+ return;
+ }
+
+ const contextMenu = document.getElementById('contextMenu');
+ if (contextMenu) {
+ contextMenu.classList.add('hidden');
+ }
+ }
+
+ async getNextRackNumber(prefix) {
+ const racks = await this.api.getRacks();
+ const existingRacks = racks.filter(r => r.name.startsWith(prefix));
+
+ if (existingRacks.length === 0) {
+ return 1;
+ }
+
+ // Find the highest number
+ let maxNum = 0;
+ existingRacks.forEach(rack => {
+ const match = rack.name.match(/\d+$/);
+ if (match) {
+ const num = parseInt(match[0]);
+ if (num > maxNum) maxNum = num;
+ }
+ });
+
+ return maxNum + 1;
+ }
+
+ async updateRackNamesPreview() {
+ const count = parseInt(document.getElementById('rackCount').value) || 1;
+ const prefix = document.getElementById('rackPrefix').value.trim() || 'RACK';
+
+ const startNum = await this.getNextRackNumber(prefix);
+ const previews = [];
+
+ for (let i = 0; i < Math.min(count, 5); i++) {
+ const num = String(startNum + i).padStart(2, '0');
+ previews.push(`${prefix}${num}`);
+ }
+
+ if (count > 5) {
+ previews.push('...');
+ }
+
+ document.getElementById('rackNamePreview').textContent = previews.join(', ');
+ }
+
+ async populateRowDropdown() {
+ const existingRacks = await this.api.getRacks();
+ const rowSelect = document.getElementById('continueRowSelect');
+
+ if (existingRacks.length === 0) {
+ // No racks, just show row 1
+ rowSelect.innerHTML = '';
+ return;
+ }
+
+ // Get unique Y coordinates (rows) and sort them
+ const uniqueRows = [...new Set(existingRacks.map(r => r.y))].sort((a, b) => a - b);
+
+ // Build dropdown options
+ rowSelect.innerHTML = '';
+ uniqueRows.forEach((yCoord, index) => {
+ const option = document.createElement('option');
+ option.value = yCoord;
+ option.textContent = index + 1; // Display as 1-based row numbers
+ rowSelect.appendChild(option);
+ });
+
+ // Select the last row by default
+ rowSelect.value = uniqueRows[uniqueRows.length - 1];
+ }
+
+ async showAddRackModal() {
+ const modal = document.getElementById('addRackModal');
+ const createBtn = document.getElementById('createRacksBtn');
+ const cancelBtn = document.getElementById('cancelRacksBtn');
+ const closeBtn = document.getElementById('addRackModalClose');
+
+ // Populate row dropdown
+ await this.populateRowDropdown();
+
+ modal.classList.remove('hidden');
+ this.updateRackNamesPreview();
+
+ // Add input listeners for live preview
+ const countInput = document.getElementById('rackCount');
+ const prefixInput = document.getElementById('rackPrefix');
+
+ const updatePreview = () => this.updateRackNamesPreview();
+ countInput.addEventListener('input', updatePreview);
+ prefixInput.addEventListener('input', updatePreview);
+
+ const handleCreate = async () => {
+ const count = parseInt(document.getElementById('rackCount').value);
+ const prefix = document.getElementById('rackPrefix').value.trim() || 'RACK';
+ const position = document.querySelector('input[name="rowPosition"]:checked').value;
+ const selectedRow = position === 'continue' ? parseInt(document.getElementById('continueRowSelect').value) : null;
+
+ try {
+ await this.createMultipleRacks(count, prefix, position, selectedRow);
+ modal.classList.add('hidden');
+ } catch (err) {
+ alert('Failed to create racks: ' + err.message);
+ }
+
+ cleanup();
+ };
+
+ const handleCancel = () => {
+ modal.classList.add('hidden');
+ cleanup();
+ };
+
+ const cleanup = () => {
+ createBtn.removeEventListener('click', handleCreate);
+ cancelBtn.removeEventListener('click', handleCancel);
+ closeBtn.removeEventListener('click', handleCancel);
+ countInput.removeEventListener('input', updatePreview);
+ prefixInput.removeEventListener('input', updatePreview);
+ };
+
+ createBtn.addEventListener('click', handleCreate);
+ cancelBtn.addEventListener('click', handleCancel);
+ closeBtn.addEventListener('click', handleCancel);
+ }
+
+ async createMultipleRacks(count, prefix, position, selectedRowY = null) {
+ const existingRacks = await this.api.getRacks();
+
+ // Use current grid dimensions from RackManager
+ const gridSize = this.rackManager.gridSize;
+ const gridVertical = this.rackManager.gridVertical;
+ const startX = 0; // Start at grid origin
+ const startY = 0; // Start at grid origin
+
+ let x, y;
+
+ // Determine starting position based on position type
+ if (existingRacks.length === 0) {
+ // First racks ever
+ x = startX;
+ y = startY;
+ } else if (position === 'continue') {
+ // Continue on the selected row
+ const rowY = selectedRowY;
+ const rowRacks = existingRacks.filter(r => r.y === rowY);
+
+ if (rowRacks.length > 0) {
+ const maxX = Math.max(...rowRacks.map(r => r.x));
+ x = maxX + gridSize;
+ } else {
+ // No racks in this row yet, start at beginning
+ x = startX;
+ }
+ y = rowY;
+ } else if (position === 'below') {
+ // New row below
+ const maxY = Math.max(...existingRacks.map(r => r.y));
+ x = startX;
+ y = maxY + gridVertical;
+ } else if (position === 'above') {
+ // New row above
+ const minY = Math.min(...existingRacks.map(r => r.y));
+ x = startX;
+ y = minY - gridVertical;
+ }
+
+ // Get starting number for sequential naming
+ const startNum = await this.getNextRackNumber(prefix);
+
+ // Create racks
+ for (let i = 0; i < count; i++) {
+ const num = String(startNum + i).padStart(2, '0');
+ const name = `${prefix}${num}`;
+
+ const rackX = x + (i * gridSize);
+ const rackY = y;
+
+ const rackData = await this.api.createRack(name, rackX, rackY);
+ this.rackManager.createRackShape(rackData);
+ }
+
+ this.layer.batchDraw();
+ }
+
+ showAddDeviceModal(rackId) {
+ const modal = document.getElementById('addDeviceModal');
+ const deviceTypeList = document.getElementById('deviceTypeList');
+ const closeBtn = document.getElementById('addDeviceModalClose');
+
+ // Populate device types
+ deviceTypeList.innerHTML = '';
+ this.deviceManager.deviceTypes.forEach(type => {
+ const card = document.createElement('div');
+ card.className = 'device-type-card';
+ card.innerHTML = `
+ ${type.name}
+ ${type.ports_count} ports
+ `;
+
+ card.addEventListener('click', async () => {
+ const deviceName = prompt(`Enter name for ${type.name}:`, type.name);
+ 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(rackId, type.rack_units);
+ await this.deviceManager.addDevice(type.id, rackId, position, deviceName);
+ modal.classList.add('hidden');
+ } catch (err) {
+ alert('Failed to add device: ' + err.message);
+ }
+ }
+ });
+
+ deviceTypeList.appendChild(card);
+ });
+
+ modal.classList.remove('hidden');
+
+ const handleClose = () => {
+ modal.classList.add('hidden');
+ closeBtn.removeEventListener('click', handleClose);
+ };
+
+ closeBtn.addEventListener('click', handleClose);
+ }
+
+ async renameRack(rackId, rackData, rackShape) {
+ const newName = prompt('Enter new rack name:', rackData.name);
+ if (newName && newName !== rackData.name) {
+ try {
+ await this.api.updateRackName(rackId, newName);
+
+ // Update the rack name in the shape
+ const nameLabel = rackShape.findOne('Text');
+ if (nameLabel) {
+ nameLabel.text(newName);
+ this.layer.batchDraw();
+ }
+
+ // Update local data
+ rackData.name = newName;
+
+ // Notify table to sync
+ window.dispatchEvent(new CustomEvent('canvas-data-changed'));
+ } catch (err) {
+ alert('Failed to rename rack: ' + err.message);
+ }
+ }
+ }
+
+ async renameDevice(deviceId, deviceData, deviceShape) {
+ const newName = prompt('Enter new device name:', deviceData.name);
+ if (newName && newName !== deviceData.name) {
+ // Check if name is already taken
+ if (this.deviceManager.isDeviceNameTaken(newName, deviceId)) {
+ alert(`Device name "${newName}" is already in use. Please choose a different name.`);
+ return;
+ }
+
+ try {
+ await this.api.updateDeviceName(deviceId, newName);
+
+ // Update the device name in the shape
+ const nameLabel = deviceShape.findOne('.device-text');
+ if (nameLabel) {
+ nameLabel.text(newName);
+ this.layer.batchDraw();
+ }
+
+ // Update local data
+ deviceData.name = newName;
+
+ // Notify table to sync
+ window.dispatchEvent(new CustomEvent('canvas-data-changed'));
+ } catch (err) {
+ alert('Failed to rename device: ' + err.message);
+ }
+ }
+ }
+
+ setupContextMenu() {
+ // Hide context menu on any click/mousedown anywhere
+ const hideHandler = (e) => {
+ const contextMenu = document.getElementById('contextMenu');
+ // Don't hide if clicking inside the context menu itself
+ if (contextMenu && !contextMenu.contains(e.target)) {
+ this.hideContextMenu();
+ }
+ };
+
+ // Listen on document for clicks outside the canvas
+ document.addEventListener('mousedown', hideHandler);
+ document.addEventListener('click', hideHandler);
+ }
+
+ resetView() {
+ // Reset to default position and zoom
+ this.stage.position({ x: 50, y: 50 });
+ this.stage.scale({ x: 1, y: 1 });
+ this.currentScale = 1;
+ this.updateZoomDisplay(1);
+ this.stage.batchDraw();
+ }
+
+ fitView() {
+ let minX = Infinity, minY = Infinity;
+ let maxX = -Infinity, maxY = -Infinity;
+
+ if (this.currentCanvasView === 'logical') {
+ // In logical view, fit to devices
+ const devices = Array.from(this.deviceManager.devices.values());
+
+ if (devices.length === 0) {
+ this.resetView();
+ return;
+ }
+
+ devices.forEach(device => {
+ const pos = device.shape.position();
+ const x = pos.x;
+ const y = pos.y;
+ const width = this.deviceManager.deviceWidth;
+ const height = device.shape.height();
+
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x + width);
+ maxY = Math.max(maxY, y + height);
+ });
+ } else {
+ // In physical view, fit to racks
+ const racks = Array.from(this.rackManager.racks.values());
+
+ if (racks.length === 0) {
+ this.resetView();
+ return;
+ }
+
+ racks.forEach(rack => {
+ const x = rack.data.x;
+ const y = rack.data.y;
+ const width = rack.data.width || this.rackManager.rackWidth;
+ const height = rack.data.height || this.rackManager.rackHeight;
+
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y - 30); // Include rack name
+ maxX = Math.max(maxX, x + width);
+ maxY = Math.max(maxY, y + height);
+ });
+ }
+
+ // Add padding
+ const padding = 100;
+ minX -= padding;
+ minY -= padding;
+ maxX += padding;
+ maxY += padding;
+
+ const contentWidth = maxX - minX;
+ const contentHeight = maxY - minY;
+
+ // Calculate scale to fit
+ const containerWidth = this.stage.width();
+ const containerHeight = this.stage.height();
+
+ const scaleX = containerWidth / contentWidth;
+ const scaleY = containerHeight / contentHeight;
+ const scale = Math.min(scaleX, scaleY, this.maxScale);
+
+ // Clamp to min/max scale
+ const finalScale = Math.max(this.minScale, Math.min(this.maxScale, scale));
+
+ // Calculate position to center the content
+ const centerX = (minX + maxX) / 2;
+ const centerY = (minY + maxY) / 2;
+
+ const newX = containerWidth / 2 - centerX * finalScale;
+ const newY = containerHeight / 2 - centerY * finalScale;
+
+ // Apply the transformation
+ this.stage.scale({ x: finalScale, y: finalScale });
+ this.stage.position({ x: newX, y: newY });
+ this.currentScale = finalScale;
+ this.updateZoomDisplay(finalScale);
+ this.stage.batchDraw();
+ this.saveCurrentViewState(); // Save state after fit
+ }
+
+ updateZoomDisplay(scale) {
+ const percentage = Math.round(scale * 100);
+ document.getElementById('zoomInput').value = percentage;
+ }
+
+ setZoom(scale) {
+ // Clamp scale to min/max
+ const newScale = Math.max(this.minScale, Math.min(this.maxScale, scale));
+
+ // Get current center point in world coordinates
+ const containerWidth = this.stage.width();
+ const containerHeight = this.stage.height();
+ const centerX = containerWidth / 2;
+ const centerY = containerHeight / 2;
+
+ // Convert to world coordinates
+ const oldScale = this.stage.scaleX();
+ const worldX = (centerX - this.stage.x()) / oldScale;
+ const worldY = (centerY - this.stage.y()) / oldScale;
+
+ // Apply new scale
+ this.stage.scale({ x: newScale, y: newScale });
+
+ // Recalculate position to keep center point fixed
+ const newPos = {
+ x: centerX - worldX * newScale,
+ y: centerY - worldY * newScale
+ };
+
+ this.stage.position(newPos);
+ this.currentScale = newScale;
+ this.updateZoomDisplay(newScale);
+ this.stage.batchDraw();
+ this.saveCurrentViewState(); // Save state after zoom change
+ }
+
+ async switchCanvasView(canvasViewType) {
+ if (canvasViewType !== 'physical' && canvasViewType !== 'logical') {
+ console.error('Invalid canvas view type:', canvasViewType);
+ return;
+ }
+
+ // Save current view state before switching
+ this.saveCurrentViewState();
+
+ this.currentCanvasView = canvasViewType;
+ localStorage.setItem('currentCanvasView', canvasViewType);
+
+ // Update button states
+ const physicalBtn = document.getElementById('physicalViewBtn');
+ const logicalBtn = document.getElementById('logicalViewBtn');
+
+ physicalBtn.classList.remove('active');
+ logicalBtn.classList.remove('active');
+
+ if (canvasViewType === 'physical') {
+ physicalBtn.classList.add('active');
+ } else {
+ logicalBtn.classList.add('active');
+ }
+
+ // Update device manager's view (changes device width)
+ this.deviceManager.setCurrentView(canvasViewType);
+
+ if (canvasViewType === 'physical') {
+ this.renderPhysicalView();
+ } else {
+ this.renderLogicalView();
+ }
+
+ // Update connection manager's view (reloads connections with view-specific waypoints)
+ await this.connectionManager.setCurrentView(canvasViewType);
+
+ // Restore the target view's saved state
+ this.restoreViewState(canvasViewType);
+
+ // Sync table if visible
+ if (this.currentTableView) {
+ await this.tableManager.refreshTable();
+ }
+ }
+
+ async toggleTableView(tableViewType) {
+ const racksTableBtn = document.getElementById('racksTableBtn');
+ const devicesTableBtn = document.getElementById('devicesTableBtn');
+ const connectionsTableBtn = document.getElementById('connectionsTableBtn');
+ const tablePane = document.getElementById('tablePane');
+ const resizeHandle = document.getElementById('resizeHandle');
+
+ // If clicking the same table view, close it (toggle off)
+ if (this.currentTableView === tableViewType) {
+ this.currentTableView = null;
+ tablePane.classList.add('hidden');
+ resizeHandle.classList.add('hidden');
+
+ // Remove active state from all table buttons
+ racksTableBtn.classList.remove('active');
+ devicesTableBtn.classList.remove('active');
+ connectionsTableBtn.classList.remove('active');
+
+ this.tableManager.hideTable();
+ this.resizeCanvas();
+ return;
+ }
+
+ // Otherwise, switch to the new table view or open it
+ this.currentTableView = tableViewType;
+
+ // Show table pane and resize handle
+ tablePane.classList.remove('hidden');
+ resizeHandle.classList.remove('hidden');
+
+ // Update button states
+ racksTableBtn.classList.remove('active');
+ devicesTableBtn.classList.remove('active');
+ connectionsTableBtn.classList.remove('active');
+
+ if (tableViewType === 'racks') {
+ racksTableBtn.classList.add('active');
+ } else if (tableViewType === 'devices') {
+ devicesTableBtn.classList.add('active');
+ } else if (tableViewType === 'connections') {
+ connectionsTableBtn.classList.add('active');
+ }
+
+ // Show the table
+ await this.tableManager.showTable(`${tableViewType}-table`);
+ this.resizeCanvas();
+ }
+
+ setupResizeHandle() {
+ const resizeHandle = document.getElementById('resizeHandle');
+ const tablePane = document.getElementById('tablePane');
+ const canvasPane = document.getElementById('canvasPane');
+
+ let isResizing = false;
+ let startY = 0;
+ let startHeight = 0;
+
+ resizeHandle.addEventListener('mousedown', (e) => {
+ isResizing = true;
+ startY = e.clientY;
+ startHeight = tablePane.offsetHeight;
+ document.body.style.cursor = 'ns-resize';
+ document.body.style.userSelect = 'none';
+ });
+
+ document.addEventListener('mousemove', (e) => {
+ if (!isResizing) return;
+
+ const deltaY = startY - e.clientY; // Negative delta = drag down
+
+ // Get actual available height (main-content area)
+ const mainContent = document.querySelector('.main-content');
+ const availableHeight = mainContent.offsetHeight;
+ const resizeHandleHeight = resizeHandle.offsetHeight || 4;
+
+ const minHeight = 0; // Allow collapsing completely
+ const maxHeight = availableHeight - resizeHandleHeight; // Up to fill entire main-content
+ const newHeight = Math.max(minHeight, Math.min(maxHeight, startHeight + deltaY));
+
+ tablePane.style.height = `${newHeight}px`;
+ this.resizeCanvas();
+ });
+
+ document.addEventListener('mouseup', () => {
+ if (isResizing) {
+ isResizing = false;
+ document.body.style.cursor = 'default';
+ document.body.style.userSelect = 'auto';
+ }
+ });
+ }
+
+ resizeCanvas() {
+ // Use requestAnimationFrame to ensure DOM has completed reflow
+ requestAnimationFrame(() => {
+ const canvasWrapper = document.getElementById('canvasWrapper');
+ if (this.stage && canvasWrapper) {
+ const width = canvasWrapper.offsetWidth;
+ const height = canvasWrapper.offsetHeight;
+
+ // Only resize if dimensions are valid (non-zero)
+ if (width > 0 && height > 0) {
+ this.stage.width(width);
+ this.stage.height(height);
+ this.stage.batchDraw();
+ }
+ }
+
+ // Trigger table grid resize
+ if (this.tableManager.gridApi) {
+ // ag-Grid automatically handles resize, but we can trigger it explicitly
+ setTimeout(() => {
+ if (this.tableManager.gridApi) {
+ this.tableManager.gridApi.sizeColumnsToFit();
+ }
+ }, 100);
+ }
+ });
+ }
+
+ renderPhysicalView() {
+ // Show racks
+ this.rackManager.racks.forEach((rack) => {
+ rack.shape.visible(true);
+ });
+
+ // Move devices back into racks and position them relatively
+ this.deviceManager.devices.forEach((device, deviceId) => {
+ const deviceData = device.data;
+ const rackShape = this.rackManager.getRackShape(deviceData.rack_id);
+
+ if (rackShape) {
+ const devicesContainer = rackShape.findOne('.devices-container');
+
+ // Move device back into its rack's container
+ if (device.shape.getParent() !== devicesContainer) {
+ device.shape.moveTo(devicesContainer);
+ }
+
+ // Calculate relative position within rack (using the rack assignment stored in DB)
+ // U1 (slot 1) is at the bottom, U42 (slot 42) is at the top
+ const maxSlots = 42;
+ const visualPosition = maxSlots - deviceData.position;
+ const y = 10 + (visualPosition * (this.deviceManager.deviceHeight + this.deviceManager.deviceSpacing));
+ device.shape.position({ x: 10, y: y });
+
+ // Remove logical view drag handlers
+ device.shape.off('dragstart');
+ device.shape.off('dragmove');
+ device.shape.off('dragmove.connection');
+ device.shape.off('dragend');
+ device.shape.off('dragend.logical');
+
+ // Re-add physical view drag handlers
+ device.shape.on('dragstart', () => {
+ // Store original parent and position
+ device.shape.setAttr('originalParent', device.shape.getParent());
+ device.shape.setAttr('originalPosition', device.shape.position());
+
+ // Move to main layer to be on top of everything
+ const absolutePos = device.shape.getAbsolutePosition();
+ device.shape.moveTo(this.layer);
+ device.shape.setAbsolutePosition(absolutePos);
+ device.shape.moveToTop();
+ device.shape.opacity(0.7);
+ });
+
+ device.shape.on('dragend', async () => {
+ device.shape.opacity(1);
+ await this.deviceManager.handleDeviceDrop(deviceData.id, device.shape);
+ });
+
+ // Devices are always draggable in physical view
+ device.shape.draggable(true);
+
+ // Enable context menu for device deletion (context menu handler is already attached)
+ device.shape.listening(true);
+ }
+ });
+
+ this.layer.batchDraw();
+ this.connectionManager.updateAllConnections();
+ }
+
+ renderLogicalView() {
+ // Hide racks
+ this.rackManager.racks.forEach((rack) => {
+ rack.shape.visible(false);
+ });
+
+ // Move devices to main layer and position them at logical positions
+ this.deviceManager.devices.forEach((device, deviceId) => {
+ const deviceData = device.data;
+
+ // Use logical position if available, otherwise calculate from physical position
+ let logicalX = deviceData.logical_x;
+ let logicalY = deviceData.logical_y;
+
+ if (logicalX === null || logicalX === undefined) {
+ // First time in logical view - calculate position from physical layout
+ const rack = this.rackManager.racks.get(deviceData.rack_id);
+ if (rack) {
+ logicalX = rack.data.x + 100; // Offset from rack position
+ logicalY = rack.data.y + deviceData.position * 40;
+ } else {
+ logicalX = 200;
+ logicalY = 200;
+ }
+ // Save this initial logical position
+ this.api.request(`/api/devices/${deviceId}/logical-position`, {
+ method: 'PUT',
+ body: JSON.stringify({ x: logicalX, y: logicalY })
+ }).catch(err => console.error('Failed to save logical position:', err));
+ }
+
+ // Move device to main layer (out of rack container)
+ if (device.shape.getParent() !== this.layer) {
+ device.shape.moveTo(this.layer);
+ }
+
+ // Position device at logical coordinates (absolute positioning)
+ device.shape.position({ x: logicalX, y: logicalY });
+ device.shape.draggable(true);
+
+ // IMPORTANT: Remove ALL existing drag handlers (including physical view handlers)
+ device.shape.off('dragstart');
+ device.shape.off('dragmove');
+ device.shape.off('dragmove.connection');
+ device.shape.off('dragend');
+ device.shape.off('dragend.logical');
+
+ // Add ONLY logical view drag handler - does NOT change rack assignment
+ device.shape.on('dragend.logical', async () => {
+ const pos = device.shape.position();
+ try {
+ // Update ONLY logical position, never rack_id or position
+ await this.api.request(`/api/devices/${deviceId}/logical-position`, {
+ method: 'PUT',
+ body: JSON.stringify({ x: pos.x, y: pos.y })
+ });
+ // Update local data
+ deviceData.logical_x = pos.x;
+ deviceData.logical_y = pos.y;
+ // Update connections
+ this.connectionManager.updateAllConnections();
+ } catch (err) {
+ console.error('Failed to save logical position:', err);
+ }
+ });
+ });
+
+ this.layer.batchDraw();
+ this.connectionManager.updateAllConnections();
+ }
+
+ async exportProject() {
+ try {
+ // Get current project info
+ const project = await this.api.getProject(this.api.currentProjectId);
+ const racks = await this.api.getRacks();
+ const devices = await this.api.getDevices();
+ const connections = await this.api.getConnections();
+
+ // Create export data
+ const exportData = {
+ version: '1.0',
+ exportDate: new Date().toISOString(),
+ project: {
+ name: project.name,
+ description: project.description
+ },
+ racks: racks,
+ devices: devices,
+ connections: connections,
+ gridSettings: {
+ gridSize: this.rackManager.gridSize,
+ gridVertical: this.rackManager.gridVertical
+ }
+ };
+
+ // Convert to JSON
+ const jsonString = JSON.stringify(exportData, null, 2);
+
+ // Create blob and download
+ const blob = new Blob([jsonString], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${project.name.replace(/[^a-z0-9]/gi, '_')}_${new Date().toISOString().split('T')[0]}.json`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ alert(`Project "${project.name}" exported successfully!`);
+ } catch (err) {
+ console.error('Failed to export project:', err);
+ alert('Failed to export project: ' + err.message);
+ }
+ }
+
+ async importProject(file) {
+ try {
+ const text = await file.text();
+ const importData = JSON.parse(text);
+
+ // Validate import data
+ if (!importData.version || !importData.project) {
+ throw new Error('Invalid project file format');
+ }
+
+ const confirmMsg = `Import project "${importData.project.name}"?\n\nThis will create a new project with:\n- ${importData.racks?.length || 0} racks\n- ${importData.devices?.length || 0} devices\n- ${importData.connections?.length || 0} connections`;
+
+ if (!confirm(confirmMsg)) {
+ return;
+ }
+
+ // Create new project
+ const newProject = await this.api.createProject(
+ importData.project.name + ' (Imported)',
+ importData.project.description || ''
+ );
+
+ // Switch to new project
+ await this.loadProjects();
+ document.getElementById('projectSelect').value = newProject.id;
+ await this.switchProject(newProject.id);
+
+ // Import grid settings if available
+ if (importData.gridSettings) {
+ this.rackManager.gridSize = importData.gridSettings.gridSize || 600;
+ this.rackManager.gridVertical = importData.gridSettings.gridVertical || 1610;
+ this.rackManager.saveSpacing();
+ }
+
+ // Import racks
+ const rackIdMap = new Map(); // Map old IDs to new IDs
+ if (importData.racks) {
+ for (const rack of importData.racks) {
+ const newRack = await this.api.createRack(rack.name, rack.x, rack.y);
+ rackIdMap.set(rack.id, newRack.id);
+ this.rackManager.createRackShape(newRack);
+ }
+ }
+
+ // Import devices
+ const deviceIdMap = new Map(); // Map old IDs to new IDs
+ if (importData.devices) {
+ for (const device of importData.devices) {
+ const newRackId = rackIdMap.get(device.rack_id);
+ if (newRackId) {
+ const newDevice = await this.api.createDevice(
+ device.device_type_id,
+ newRackId,
+ device.position,
+ device.name
+ );
+ deviceIdMap.set(device.id, newDevice.id);
+
+ // Fetch complete device data
+ const devices = await this.api.getDevices();
+ const deviceData = devices.find(d => d.id === newDevice.id);
+ if (deviceData) {
+ // Update rack_units and logical position if available
+ if (device.rack_units) {
+ await this.api.request(`/api/devices/${newDevice.id}/rack-units`, {
+ method: 'PUT',
+ body: JSON.stringify({ rackUnits: device.rack_units })
+ });
+ deviceData.rack_units = device.rack_units;
+ }
+ if (device.logical_x !== null && device.logical_y !== null) {
+ await this.api.request(`/api/devices/${newDevice.id}/logical-position`, {
+ method: 'PUT',
+ body: JSON.stringify({ x: device.logical_x, y: device.logical_y })
+ });
+ }
+ this.deviceManager.createDeviceShape(deviceData);
+ }
+ }
+ }
+ }
+
+ // Import connections
+ if (importData.connections) {
+ for (const conn of importData.connections) {
+ const newSourceId = deviceIdMap.get(conn.source_device_id);
+ const newTargetId = deviceIdMap.get(conn.target_device_id);
+
+ if (newSourceId && newTargetId) {
+ const newConn = await this.api.createConnection(
+ newSourceId,
+ conn.source_port,
+ newTargetId,
+ conn.target_port
+ );
+
+ // Update waypoints if available
+ if (conn.waypoints_physical) {
+ await this.api.updateConnectionWaypoints(
+ newConn.id,
+ typeof conn.waypoints_physical === 'string' ? JSON.parse(conn.waypoints_physical) : conn.waypoints_physical,
+ 'physical'
+ );
+ }
+ if (conn.waypoints_logical) {
+ await this.api.updateConnectionWaypoints(
+ newConn.id,
+ typeof conn.waypoints_logical === 'string' ? JSON.parse(conn.waypoints_logical) : conn.waypoints_logical,
+ 'logical'
+ );
+ }
+ }
+ }
+
+ // Reload connections to display them
+ await this.connectionManager.loadConnections();
+ }
+
+ this.layer.batchDraw();
+ this.connectionManager.getConnectionLayer().batchDraw();
+
+ alert(`Project imported successfully as "${newProject.name}"!`);
+ } catch (err) {
+ console.error('Failed to import project:', err);
+ alert('Failed to import project: ' + err.message);
+ }
+ }
+
+ async exportToExcel() {
+ try {
+ // Get current project data
+ const project = await this.api.getProject(this.api.currentProjectId);
+ const racks = await this.api.getRacks();
+ const devices = await this.api.getDevices();
+ const connections = await this.api.getConnections();
+
+ // Create workbook
+ const wb = XLSX.utils.book_new();
+
+ // Racks sheet
+ const racksData = racks.map(r => ({
+ 'Rack Name': r.name,
+ 'Position X': r.x,
+ 'Position Y': r.y,
+ 'Width': r.width,
+ 'Height': r.height
+ }));
+ const racksWs = XLSX.utils.json_to_sheet(racksData);
+ XLSX.utils.book_append_sheet(wb, racksWs, 'Racks');
+
+ // Devices sheet
+ const racksMap = new Map(racks.map(r => [r.id, r.name]));
+ const devicesData = devices.map(d => ({
+ 'Device Name': d.name,
+ 'Type': d.type_name,
+ 'Rack': racksMap.get(d.rack_id) || 'Unknown',
+ 'Slot': `U${d.position}`,
+ 'Form Factor': `${d.rack_units || 1}U`,
+ 'Ports': d.ports_count,
+ 'Color': d.color
+ }));
+ const devicesWs = XLSX.utils.json_to_sheet(devicesData);
+ XLSX.utils.book_append_sheet(wb, devicesWs, 'Devices');
+
+ // Connections sheet
+ const devicesMap = new Map(devices.map(d => [d.id, d.name]));
+ const connectionsData = connections.map(c => ({
+ 'Source Device': devicesMap.get(c.source_device_id) || 'Unknown',
+ 'Source Port': c.source_port,
+ 'Target Device': devicesMap.get(c.target_device_id) || 'Unknown',
+ 'Target Port': c.target_port
+ }));
+ const connectionsWs = XLSX.utils.json_to_sheet(connectionsData);
+ XLSX.utils.book_append_sheet(wb, connectionsWs, 'Connections');
+
+ // Generate filename
+ const filename = `${project.name.replace(/[^a-z0-9]/gi, '_')}_${new Date().toISOString().split('T')[0]}.xlsx`;
+
+ // Write file
+ XLSX.writeFile(wb, filename);
+
+ alert(`Excel file "${filename}" downloaded successfully!`);
+ } catch (err) {
+ console.error('Failed to export to Excel:', err);
+ alert('Failed to export to Excel: ' + err.message);
+ }
+ }
+}
+
+// Initialize app
+const app = new DatacenterDesigner();
+app.init();
diff --git a/public/js/config.js b/public/js/config.js
new file mode 100644
index 0000000..c025de9
--- /dev/null
+++ b/public/js/config.js
@@ -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;
diff --git a/public/js/lib/api.js b/public/js/lib/api.js
new file mode 100644
index 0000000..dddfdb3
--- /dev/null
+++ b/public/js/lib/api.js
@@ -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();
diff --git a/public/js/lib/ui.js b/public/js/lib/ui.js
new file mode 100644
index 0000000..5a55c36
--- /dev/null
+++ b/public/js/lib/ui.js
@@ -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 = `
+
+ ${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} 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 = `
+
+ `;
+
+ 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} 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 = `
+
+ `;
+
+ 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();
diff --git a/public/js/managers/connection-manager.js b/public/js/managers/connection-manager.js
new file mode 100644
index 0000000..82ade21
--- /dev/null
+++ b/public/js/managers/connection-manager.js
@@ -0,0 +1,1006 @@
+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
+ this.contextMenuHandler = null; // Store the current context menu handler
+
+ // 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 ONLY for waypoints + edge points
+ // Don't create handles for auto-generated corner points
+ const handles = [];
+
+ // Get waypoints from connection data
+ let savedWaypoints = [];
+ if (this.currentView === 'physical' && connData.waypoints_physical) {
+ const parsed = typeof connData.waypoints_physical === 'string'
+ ? JSON.parse(connData.waypoints_physical)
+ : connData.waypoints_physical;
+ savedWaypoints = parsed.filter(p => !p.isEdge);
+ } else if (this.currentView === 'logical' && connData.waypoints_logical) {
+ const parsed = typeof connData.waypoints_logical === 'string'
+ ? JSON.parse(connData.waypoints_logical)
+ : connData.waypoints_logical;
+ savedWaypoints = parsed.filter(p => !p.isEdge);
+ }
+
+ // Create handles: start point, user waypoints, end point
+ const handlePoints = [
+ { x: points[0], y: points[1], isEdge: true },
+ ...savedWaypoints.map(wp => ({ x: wp.x, y: wp.y, isEdge: false })),
+ { x: points[points.length - 2], y: points[points.length - 1], isEdge: true }
+ ];
+
+ handlePoints.forEach((pt, i) => {
+ 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}`,
+ isEdge: pt.isEdge
+ });
+
+ 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();
+ }
+ });
+
+ // Handle event listeners
+ handles.forEach((handle, idx) => {
+ const isEdgeHandle = handle.getAttr('isEdge');
+
+ 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);
+
+ // Double-click on waypoint handle to DELETE it
+ handle.on('dblclick', (e) => {
+ e.cancelBubble = true;
+ if (!isEdgeHandle) {
+ // Delete this waypoint
+ this.deleteWaypoint(line, handles, idx, connData);
+ }
+ });
+
+ 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);
+ const updatedHandles = conn.handles;
+ updatedHandles.forEach(h => h.opacity(1));
+
+ // Save
+ this.saveWaypoints(connData.id, updatedHandles);
+ this.connectionLayer.batchDraw();
+ }
+
+ deleteWaypoint(line, handles, handleIndex, 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() }));
+
+ // Remove the waypoint at the specified index (adjust for edge handle at index 0)
+ const waypointIndex = handleIndex - 1;
+ if (waypointIndex >= 0 && waypointIndex < waypoints.length) {
+ waypoints.splice(waypointIndex, 1);
+ }
+
+ // Recreate handles
+ this.recreateHandles(line, handles, startPoint, waypoints, endPoint, connData);
+
+ // Show handles and highlight line
+ line.stroke('#4A90E2');
+ line.strokeWidth(2);
+ const updatedHandles = conn.handles;
+ updatedHandles.forEach(h => h.opacity(1));
+
+ // Save
+ this.saveWaypoints(connData.id, updatedHandles);
+ 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: start point, user waypoints, end point
+ const handlePoints = [
+ { ...startPoint, isEdge: true },
+ ...waypoints.map(wp => ({ ...wp, isEdge: false })),
+ { ...endPoint, isEdge: true }
+ ];
+
+ handlePoints.forEach((pt, i) => {
+ const isEdgeHandle = pt.isEdge;
+
+ 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}`,
+ isEdge: pt.isEdge
+ });
+
+ // 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);
+
+ // Double-click on waypoint handle to DELETE it
+ handle.on('dblclick', (e) => {
+ e.cancelBubble = true;
+ if (!isEdgeHandle) {
+ // Delete this waypoint
+ this.deleteWaypoint(line, handles, i, connData);
+ }
+ });
+
+ 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 {
+ // Only save user waypoints (middle handles), not edge points
+ // Edge points are recalculated based on device positions
+ const waypoints = handles.slice(1, -1).map(h => ({
+ x: h.x(),
+ y: h.y()
+ }));
+
+ // Save to view-specific column
+ await this.api.updateConnectionWaypoints(connectionId, waypoints, 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, suppressEvent = false) {
+ try {
+ await this.api.deleteConnection(connId);
+
+ // Handle case where line and handles might not be provided (called from table)
+ if (line) {
+ line.destroy();
+ }
+ if (handles) {
+ handles.forEach(h => h.destroy());
+ }
+
+ // If line/handles not provided, find and destroy them
+ const conn = this.connections.get(connId);
+ if (conn) {
+ if (!line && conn.shape) {
+ conn.shape.destroy();
+ }
+ if (!handles && conn.handles) {
+ conn.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 (unless suppressed for bulk operations)
+ if (!suppressEvent) {
+ 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 = `
+
+
+ ${sourceDevice.name}:${connData.source_port} ↔ ${targetDevice.name}:${connData.target_port}
+
+
+ Delete Connection
+ `;
+
+ 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 = (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);
+ this.contextMenuHandler = null;
+ };
+
+ // Store and add the new handler
+ this.contextMenuHandler = handleAction;
+ contextMenuList.addEventListener('click', handleAction);
+ }
+}
diff --git a/public/js/managers/device-manager.js b/public/js/managers/device-manager.js
new file mode 100644
index 0000000..81487be
--- /dev/null
+++ b/public/js/managers/device-manager.js
@@ -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 = `
+ Create Connection
+
+ Delete Device
+ `;
+
+ 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);
+ }
+ });
+ }
+}
diff --git a/public/js/managers/rack-manager.js b/public/js/managers/rack-manager.js
new file mode 100644
index 0000000..6bab7d7
--- /dev/null
+++ b/public/js/managers/rack-manager.js
@@ -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 = '';
+ if (this.deviceManager && this.deviceManager.deviceTypes) {
+ this.deviceManager.deviceTypes.forEach(type => {
+ deviceTypesHTML += `${type.name}`;
+ });
+ }
+
+ // Build unlock/management options
+ let managementHTML = `${lockText}`;
+
+ // Show delete and spacing controls only when unlocked
+ if (!this.racksLocked) {
+ const horizontalSpacing = this.gridSize - this.rackWidth;
+ const verticalSpacing = this.gridVertical - this.rackHeight;
+
+ managementHTML += `
+ Delete Rack
+
+
+ Horizontal spacing: ${horizontalSpacing}px
+
+
+
+
+
+
+ Vertical spacing: ${verticalSpacing}px
+
+
+
+
+
+ `;
+ }
+
+ contextMenuList.innerHTML = `
+ ${deviceTypesHTML}
+
+ ${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;
+ }
+}
diff --git a/public/js/managers/table-manager.js b/public/js/managers/table-manager.js
new file mode 100644
index 0000000..68e6a0e
--- /dev/null
+++ b/public/js/managers/table-manager.js
@@ -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: 'No racks found'
+ };
+
+ 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 ``;
+ }
+ },
+ {
+ 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: 'No devices found'
+ };
+
+ 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: 'No connections found'
+ };
+
+ 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;
+ }
+ }
+}
diff --git a/server/config.js b/server/config.js
new file mode 100644
index 0000000..3bf143d
--- /dev/null
+++ b/server/config.js
@@ -0,0 +1,116 @@
+/**
+ * Server Configuration
+ * Central configuration for all backend constants and settings
+ */
+
+const path = require('path');
+
+const config = {
+ // Server Settings
+ server: {
+ port: process.env.PORT || 3000,
+ host: process.env.HOST || '0.0.0.0', // Bind to all interfaces
+ env: process.env.NODE_ENV || 'development'
+ },
+
+ // Database Settings
+ database: {
+ path: path.join(__dirname, '../database/datacenter.db'),
+ // Enable WAL mode for better concurrency
+ walMode: true,
+ // Enable foreign keys
+ foreignKeys: true,
+ // Busy timeout in ms
+ busyTimeout: 5000
+ },
+
+ // Rack Configuration
+ rack: {
+ // Default dimensions in pixels
+ defaultWidth: 520,
+ defaultHeight: 1485,
+ // Number of U slots
+ slots: 42,
+ // Grid spacing
+ gridHorizontal: 600,
+ gridVertical: 1585
+ },
+
+ // Device Configuration
+ device: {
+ // Default device dimensions
+ defaultHeight: 32,
+ defaultSpacing: 2,
+ // Margins within rack
+ margin: {
+ top: 10,
+ right: 10,
+ bottom: 10,
+ left: 10
+ },
+ // Physical view width
+ physicalWidth: 500,
+ // Logical view width
+ logicalWidth: 120
+ },
+
+ // Validation Rules
+ validation: {
+ project: {
+ nameMinLength: 1,
+ nameMaxLength: 100,
+ descriptionMaxLength: 500
+ },
+ rack: {
+ nameMinLength: 1,
+ nameMaxLength: 50,
+ maxPerProject: 1000
+ },
+ device: {
+ nameMinLength: 1,
+ nameMaxLength: 50,
+ minRackUnits: 1,
+ maxRackUnits: 42
+ },
+ connection: {
+ maxWaypoints: 20
+ }
+ },
+
+ // API Settings
+ api: {
+ // Request size limits
+ jsonLimit: '10mb',
+ // Enable CORS (set to true if frontend is on different domain)
+ cors: false,
+ // Rate limiting (requests per minute per IP)
+ rateLimit: {
+ enabled: false,
+ windowMs: 60000, // 1 minute
+ max: 100 // 100 requests per minute
+ }
+ },
+
+ // Logging
+ logging: {
+ enabled: true,
+ level: process.env.LOG_LEVEL || 'info', // debug, info, warn, error
+ format: 'combined' // Morgan format
+ },
+
+ // Default Device Types (seed data)
+ deviceTypes: [
+ { name: 'Switch 24-Port', portsCount: 24, color: '#4A90E2', rackUnits: 1 },
+ { name: 'Switch 48-Port', portsCount: 48, color: '#5CA6E8', rackUnits: 1 },
+ { name: 'Router', portsCount: 8, color: '#E27D60', rackUnits: 1 },
+ { name: 'Firewall', portsCount: 6, color: '#E8A87C', rackUnits: 1 },
+ { name: 'Server 1U', portsCount: 4, color: '#41B3A3', rackUnits: 1 },
+ { name: 'Server 2U', portsCount: 4, color: '#41B3A3', rackUnits: 2 },
+ { name: 'Server 4U', portsCount: 8, color: '#38A169', rackUnits: 4 },
+ { name: 'Storage', portsCount: 8, color: '#38A169', rackUnits: 2 },
+ { name: 'Patch Panel 24', portsCount: 24, color: '#9B59B6', rackUnits: 1 },
+ { name: 'Patch Panel 48', portsCount: 48, color: '#A569BD', rackUnits: 1 }
+ ]
+};
+
+module.exports = config;
diff --git a/server/db.js b/server/db.js
new file mode 100644
index 0000000..3b604e6
--- /dev/null
+++ b/server/db.js
@@ -0,0 +1,408 @@
+/**
+ * Database Layer using better-sqlite3
+ * Synchronous, simpler, and faster than callback-based sqlite3
+ */
+
+const Database = require('better-sqlite3');
+const config = require('./config');
+
+class DatacenterDB {
+ constructor() {
+ this.db = null;
+ }
+
+ /**
+ * Initialize database connection and schema
+ */
+ init() {
+ // Open database connection
+ this.db = new Database(config.database.path);
+
+ // Configure database
+ if (config.database.walMode) {
+ this.db.pragma('journal_mode = WAL');
+ }
+ if (config.database.foreignKeys) {
+ this.db.pragma('foreign_keys = ON');
+ }
+ if (config.database.busyTimeout) {
+ this.db.pragma(`busy_timeout = ${config.database.busyTimeout}`);
+ }
+
+ console.log('Connected to SQLite database with better-sqlite3');
+
+ // Create schema
+ this.createTables();
+ this.seedDeviceTypes();
+ this.ensureDefaultProject();
+
+ return this;
+ }
+
+ /**
+ * Create all database tables
+ */
+ createTables() {
+ this.db.exec(`
+ CREATE TABLE IF NOT EXISTS projects (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ description TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+
+ CREATE TABLE IF NOT EXISTS racks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ project_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ x REAL NOT NULL,
+ y REAL NOT NULL,
+ width REAL DEFAULT ${config.rack.defaultWidth},
+ height REAL DEFAULT ${config.rack.defaultHeight},
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
+ UNIQUE(project_id, name)
+ );
+
+ CREATE TABLE IF NOT EXISTS device_types (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ ports_count INTEGER NOT NULL DEFAULT 24,
+ color TEXT DEFAULT '#4A90E2',
+ rack_units INTEGER DEFAULT 1
+ );
+
+ CREATE TABLE IF NOT EXISTS devices (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ device_type_id INTEGER NOT NULL,
+ rack_id INTEGER NOT NULL,
+ project_id INTEGER NOT NULL,
+ position INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ rack_units INTEGER DEFAULT 1,
+ logical_x REAL,
+ logical_y REAL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (device_type_id) REFERENCES device_types(id),
+ FOREIGN KEY (rack_id) REFERENCES racks(id) ON DELETE CASCADE,
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
+ UNIQUE(project_id, name)
+ );
+
+ CREATE TABLE IF NOT EXISTS connections (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ source_device_id INTEGER NOT NULL,
+ source_port INTEGER NOT NULL,
+ target_device_id INTEGER NOT NULL,
+ target_port INTEGER NOT NULL,
+ waypoints_physical TEXT,
+ waypoints_logical TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (source_device_id) REFERENCES devices(id) ON DELETE CASCADE,
+ FOREIGN KEY (target_device_id) REFERENCES devices(id) ON DELETE CASCADE,
+ UNIQUE(source_device_id, source_port),
+ UNIQUE(target_device_id, target_port)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_racks_project ON racks(project_id);
+ CREATE INDEX IF NOT EXISTS idx_devices_rack ON devices(rack_id);
+ CREATE INDEX IF NOT EXISTS idx_devices_project ON devices(project_id);
+ CREATE INDEX IF NOT EXISTS idx_devices_type ON devices(device_type_id);
+ CREATE INDEX IF NOT EXISTS idx_connections_source ON connections(source_device_id);
+ CREATE INDEX IF NOT EXISTS idx_connections_target ON connections(target_device_id);
+ `);
+
+ console.log('Database schema created');
+ }
+
+ /**
+ * Seed device types with defaults
+ */
+ seedDeviceTypes() {
+ const insert = this.db.prepare(`
+ INSERT OR IGNORE INTO device_types (name, ports_count, color, rack_units)
+ VALUES (?, ?, ?, ?)
+ `);
+
+ const insertMany = this.db.transaction((types) => {
+ for (const type of types) {
+ insert.run(type.name, type.portsCount, type.color, type.rackUnits);
+ }
+ });
+
+ insertMany(config.deviceTypes);
+ console.log('Device types seeded');
+ }
+
+ /**
+ * Ensure default project exists
+ */
+ ensureDefaultProject() {
+ const insert = this.db.prepare(`
+ INSERT OR IGNORE INTO projects (id, name, description)
+ VALUES (1, ?, ?)
+ `);
+
+ insert.run('Default Project', 'Default datacenter project');
+ console.log('Default project ensured');
+ }
+
+ // ==================== PROJECT OPERATIONS ====================
+
+ getAllProjects() {
+ return this.db.prepare('SELECT * FROM projects ORDER BY updated_at DESC').all();
+ }
+
+ getProject(id) {
+ return this.db.prepare('SELECT * FROM projects WHERE id = ?').get(id);
+ }
+
+ createProject(name, description = '') {
+ const stmt = this.db.prepare('INSERT INTO projects (name, description) VALUES (?, ?)');
+ const info = stmt.run(name, description);
+ return { id: info.lastInsertRowid, name, description };
+ }
+
+ updateProject(id, name, description) {
+ const stmt = this.db.prepare(`
+ UPDATE projects
+ SET name = ?, description = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ `);
+ stmt.run(name, description, id);
+ }
+
+ deleteProject(id) {
+ // Check if this is the last project
+ const count = this.db.prepare('SELECT COUNT(*) as count FROM projects').get();
+ if (count.count <= 1) {
+ throw new Error('Cannot delete the last project');
+ }
+
+ const stmt = this.db.prepare('DELETE FROM projects WHERE id = ?');
+ stmt.run(id);
+ }
+
+ // ==================== RACK OPERATIONS ====================
+
+ getAllRacks(projectId) {
+ return this.db.prepare('SELECT * FROM racks WHERE project_id = ? ORDER BY name').all(projectId);
+ }
+
+ createRack(projectId, name, x, y) {
+ const stmt = this.db.prepare(`
+ INSERT INTO racks (project_id, name, x, y)
+ VALUES (?, ?, ?, ?)
+ `);
+ const info = stmt.run(projectId, name, x, y);
+
+ // Fetch and return the complete rack data
+ return this.db.prepare('SELECT * FROM racks WHERE id = ?').get(info.lastInsertRowid);
+ }
+
+ updateRackPosition(id, x, y) {
+ const stmt = this.db.prepare('UPDATE racks SET x = ?, y = ? WHERE id = ?');
+ stmt.run(x, y, id);
+ }
+
+ updateRackName(id, name) {
+ const stmt = this.db.prepare('UPDATE racks SET name = ? WHERE id = ?');
+ stmt.run(name, id);
+ }
+
+ deleteRack(id) {
+ const stmt = this.db.prepare('DELETE FROM racks WHERE id = ?');
+ stmt.run(id);
+ }
+
+ getNextRackName(projectId, prefix = 'RACK') {
+ const racks = this.db.prepare(`
+ SELECT name FROM racks
+ WHERE project_id = ? AND name LIKE ?
+ ORDER BY name DESC
+ LIMIT 1
+ `).all(projectId, `${prefix}%`);
+
+ if (racks.length === 0) {
+ return `${prefix}01`;
+ }
+
+ // Extract number from last rack name
+ const lastNum = parseInt(racks[0].name.replace(prefix, '')) || 0;
+ const nextNum = (lastNum + 1).toString().padStart(2, '0');
+ return `${prefix}${nextNum}`;
+ }
+
+ // ==================== DEVICE TYPE OPERATIONS ====================
+
+ getAllDeviceTypes() {
+ return this.db.prepare('SELECT * FROM device_types ORDER BY name').all();
+ }
+
+ // ==================== DEVICE OPERATIONS ====================
+
+ getAllDevices(projectId) {
+ return this.db.prepare(`
+ SELECT d.*, dt.name as type_name, dt.ports_count, dt.color
+ FROM devices d
+ JOIN device_types dt ON d.device_type_id = dt.id
+ JOIN racks r ON d.rack_id = r.id
+ WHERE r.project_id = ?
+ ORDER BY d.rack_id, d.position
+ `).all(projectId);
+ }
+
+ createDevice(deviceTypeId, rackId, projectId, position, name) {
+ // Get rack_units from device_type
+ const deviceType = this.db.prepare('SELECT rack_units FROM device_types WHERE id = ?').get(deviceTypeId);
+ const rackUnits = deviceType ? deviceType.rack_units : 1;
+
+ const stmt = this.db.prepare(`
+ INSERT INTO devices (device_type_id, rack_id, project_id, position, name, rack_units)
+ VALUES (?, ?, ?, ?, ?, ?)
+ `);
+ const info = stmt.run(deviceTypeId, rackId, projectId, position, name, rackUnits);
+ return { id: info.lastInsertRowid };
+ }
+
+ deleteDevice(id) {
+ const stmt = this.db.prepare('DELETE FROM devices WHERE id = ?');
+ stmt.run(id);
+ }
+
+ updateDeviceRack(id, rackId, position) {
+ // Get project_id from the new rack
+ const rack = this.db.prepare('SELECT project_id FROM racks WHERE id = ?').get(rackId);
+ if (!rack) {
+ throw new Error('Rack not found');
+ }
+
+ const stmt = this.db.prepare(`
+ UPDATE devices SET rack_id = ?, project_id = ?, position = ? WHERE id = ?
+ `);
+ stmt.run(rackId, rack.project_id, position, id);
+ }
+
+ updateDeviceLogicalPosition(id, x, y) {
+ const stmt = this.db.prepare(`
+ UPDATE devices SET logical_x = ?, logical_y = ? WHERE id = ?
+ `);
+ stmt.run(x, y, id);
+ }
+
+ updateDeviceName(id, name) {
+ const stmt = this.db.prepare('UPDATE devices SET name = ? WHERE id = ?');
+ stmt.run(name, id);
+ }
+
+ updateDeviceRackUnits(id, rackUnits) {
+ const stmt = this.db.prepare('UPDATE devices SET rack_units = ? WHERE id = ?');
+ stmt.run(rackUnits, id);
+ }
+
+ getUsedPorts(deviceId) {
+ const ports = this.db.prepare(`
+ SELECT source_port as port FROM connections WHERE source_device_id = ?
+ UNION
+ SELECT target_port as port FROM connections WHERE target_device_id = ?
+ `).all(deviceId, deviceId);
+
+ return ports.map(p => p.port);
+ }
+
+ // ==================== CONNECTION OPERATIONS ====================
+
+ getAllConnections(projectId) {
+ return this.db.prepare(`
+ SELECT c.* FROM connections c
+ JOIN devices d ON c.source_device_id = d.id
+ JOIN racks r ON d.rack_id = r.id
+ WHERE r.project_id = ?
+ `).all(projectId);
+ }
+
+ createConnection(sourceDeviceId, sourcePort, targetDeviceId, targetPort) {
+ const stmt = this.db.prepare(`
+ INSERT INTO connections (source_device_id, source_port, target_device_id, target_port)
+ VALUES (?, ?, ?, ?)
+ `);
+ const info = stmt.run(sourceDeviceId, sourcePort, targetDeviceId, targetPort);
+ return { id: info.lastInsertRowid };
+ }
+
+ updateConnection(id, sourceDeviceId, sourcePort, targetDeviceId, targetPort) {
+ const stmt = this.db.prepare(`
+ UPDATE connections
+ SET source_device_id = ?, source_port = ?, target_device_id = ?, target_port = ?
+ WHERE id = ?
+ `);
+ stmt.run(sourceDeviceId, sourcePort, targetDeviceId, targetPort, id);
+ }
+
+ updateConnectionWaypoints(id, waypoints, view = null) {
+ const waypointsJson = JSON.stringify(waypoints);
+
+ let query;
+ if (view === 'physical') {
+ query = 'UPDATE connections SET waypoints_physical = ? WHERE id = ?';
+ } else if (view === 'logical') {
+ query = 'UPDATE connections SET waypoints_logical = ? WHERE id = ?';
+ } else {
+ // For backwards compatibility
+ query = 'UPDATE connections SET waypoints_physical = ?, waypoints_logical = ? WHERE id = ?';
+ const stmt = this.db.prepare(query);
+ stmt.run(waypointsJson, waypointsJson, id);
+ return;
+ }
+
+ const stmt = this.db.prepare(query);
+ stmt.run(waypointsJson, id);
+ }
+
+ deleteConnection(id) {
+ const stmt = this.db.prepare('DELETE FROM connections WHERE id = ?');
+ stmt.run(id);
+ }
+
+ // ==================== UTILITY METHODS ====================
+
+ /**
+ * Execute a transaction
+ * @param {Function} fn - Function containing database operations
+ * @returns {*} - Return value of the transaction function
+ */
+ transaction(fn) {
+ return this.db.transaction(fn)();
+ }
+
+ /**
+ * Close database connection
+ */
+ close() {
+ if (this.db) {
+ this.db.close();
+ console.log('Database connection closed');
+ }
+ }
+
+ /**
+ * Get database statistics
+ */
+ getStats() {
+ const projects = this.db.prepare('SELECT COUNT(*) as count FROM projects').get();
+ const racks = this.db.prepare('SELECT COUNT(*) as count FROM racks').get();
+ const devices = this.db.prepare('SELECT COUNT(*) as count FROM devices').get();
+ const connections = this.db.prepare('SELECT COUNT(*) as count FROM connections').get();
+
+ return {
+ projects: projects.count,
+ racks: racks.count,
+ devices: devices.count,
+ connections: connections.count
+ };
+ }
+}
+
+// Export singleton instance
+module.exports = new DatacenterDB();
diff --git a/server/lib/errorHandler.js b/server/lib/errorHandler.js
new file mode 100644
index 0000000..f268d2d
--- /dev/null
+++ b/server/lib/errorHandler.js
@@ -0,0 +1,66 @@
+/**
+ * Error Handling Middleware
+ * Centralized error handling for consistent API responses
+ */
+
+/**
+ * Error handler middleware
+ * Catches errors from routes and formats response
+ */
+function errorHandler(err, req, res, next) {
+ // Log error for debugging
+ console.error('Error:', err);
+
+ // Default error response
+ let statusCode = 500;
+ let message = 'Internal server error';
+
+ // Handle specific error types
+ if (err.message) {
+ message = err.message;
+
+ // SQLite constraint errors
+ if (err.message.includes('UNIQUE constraint')) {
+ statusCode = 409; // Conflict
+ message = 'A record with that value already exists';
+ } else if (err.message.includes('FOREIGN KEY constraint')) {
+ statusCode = 400; // Bad Request
+ message = 'Invalid reference to related record';
+ } else if (err.message.includes('NOT NULL constraint')) {
+ statusCode = 400;
+ message = 'Required field is missing';
+ } else if (err.message.includes('CHECK constraint')) {
+ statusCode = 400;
+ message = 'Value does not meet validation requirements';
+ }
+
+ // Custom application errors
+ else if (err.message.includes('not found')) {
+ statusCode = 404;
+ } else if (err.message.includes('Cannot delete')) {
+ statusCode = 400;
+ }
+ }
+
+ // Send error response
+ res.status(statusCode).json({
+ error: message,
+ ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
+ });
+}
+
+/**
+ * 404 Not Found handler
+ * Catches requests to undefined routes
+ */
+function notFoundHandler(req, res) {
+ res.status(404).json({
+ error: 'Route not found',
+ path: req.path
+ });
+}
+
+module.exports = {
+ errorHandler,
+ notFoundHandler
+};
diff --git a/server/routes/connections.js b/server/routes/connections.js
new file mode 100644
index 0000000..958ac7a
--- /dev/null
+++ b/server/routes/connections.js
@@ -0,0 +1,147 @@
+/**
+ * Connections Routes
+ * All routes related to connection management
+ */
+
+const express = require('express');
+const router = express.Router();
+const db = require('../db');
+
+/**
+ * GET /api/connections
+ * Get all connections for a project
+ */
+router.get('/', (req, res, next) => {
+ try {
+ const projectId = req.query.projectId || 1;
+ const connections = db.getAllConnections(projectId);
+ res.json(connections);
+ } catch (err) {
+ next(err);
+ }
+});
+
+/**
+ * POST /api/connections
+ * Create a new connection
+ */
+router.post('/', (req, res, next) => {
+ try {
+ const { sourceDeviceId, sourcePort, targetDeviceId, targetPort } = req.body;
+
+ // Validation
+ if (!sourceDeviceId) {
+ return res.status(400).json({ error: 'Source device ID is required' });
+ }
+ if (typeof sourcePort !== 'number' || sourcePort < 1) {
+ return res.status(400).json({ error: 'Valid source port number is required' });
+ }
+ if (!targetDeviceId) {
+ return res.status(400).json({ error: 'Target device ID is required' });
+ }
+ if (typeof targetPort !== 'number' || targetPort < 1) {
+ return res.status(400).json({ error: 'Valid target port number is required' });
+ }
+ if (sourceDeviceId === targetDeviceId) {
+ return res.status(400).json({ error: 'Cannot connect device to itself' });
+ }
+
+ const connection = db.createConnection(
+ sourceDeviceId,
+ sourcePort,
+ targetDeviceId,
+ targetPort
+ );
+ res.status(201).json(connection);
+ } catch (err) {
+ // Handle unique constraint violations (port already in use)
+ if (err.message && err.message.includes('UNIQUE constraint')) {
+ return res.status(400).json({ error: 'One or both ports are already in use' });
+ }
+ next(err);
+ }
+});
+
+/**
+ * PUT /api/connections/:id
+ * Update a connection
+ */
+router.put('/:id', (req, res, next) => {
+ try {
+ const { sourceDeviceId, sourcePort, targetDeviceId, targetPort } = req.body;
+
+ // Validation
+ if (!sourceDeviceId) {
+ return res.status(400).json({ error: 'Source device ID is required' });
+ }
+ if (typeof sourcePort !== 'number' || sourcePort < 1) {
+ return res.status(400).json({ error: 'Valid source port number is required' });
+ }
+ if (!targetDeviceId) {
+ return res.status(400).json({ error: 'Target device ID is required' });
+ }
+ if (typeof targetPort !== 'number' || targetPort < 1) {
+ return res.status(400).json({ error: 'Valid target port number is required' });
+ }
+ if (sourceDeviceId === targetDeviceId) {
+ return res.status(400).json({ error: 'Cannot connect device to itself' });
+ }
+
+ db.updateConnection(
+ req.params.id,
+ sourceDeviceId,
+ sourcePort,
+ targetDeviceId,
+ targetPort
+ );
+ res.json({ success: true });
+ } catch (err) {
+ if (err.message && err.message.includes('UNIQUE constraint')) {
+ return res.status(400).json({ error: 'One or both ports are already in use' });
+ }
+ next(err);
+ }
+});
+
+/**
+ * PUT /api/connections/:id/waypoints
+ * Update connection waypoints
+ */
+router.put('/:id/waypoints', (req, res, next) => {
+ try {
+ const { waypoints, view } = req.body;
+
+ if (!waypoints || !Array.isArray(waypoints)) {
+ return res.status(400).json({ error: 'Waypoints must be an array' });
+ }
+
+ // Validate waypoint structure
+ for (const waypoint of waypoints) {
+ if (typeof waypoint.x !== 'number' || typeof waypoint.y !== 'number') {
+ return res.status(400).json({
+ error: 'Each waypoint must have valid x and y coordinates'
+ });
+ }
+ }
+
+ db.updateConnectionWaypoints(req.params.id, waypoints, view);
+ res.json({ success: true });
+ } catch (err) {
+ next(err);
+ }
+});
+
+/**
+ * DELETE /api/connections/:id
+ * Delete a connection
+ */
+router.delete('/:id', (req, res, next) => {
+ try {
+ db.deleteConnection(req.params.id);
+ res.json({ success: true });
+ } catch (err) {
+ next(err);
+ }
+});
+
+module.exports = router;
diff --git a/server/routes/devices.js b/server/routes/devices.js
new file mode 100644
index 0000000..0331831
--- /dev/null
+++ b/server/routes/devices.js
@@ -0,0 +1,186 @@
+/**
+ * Devices Routes
+ * All routes related to device management
+ */
+
+const express = require('express');
+const router = express.Router();
+const db = require('../db');
+const config = require('../config');
+
+/**
+ * GET /api/device-types
+ * Get all device types
+ */
+router.get('/types', (req, res, next) => {
+ try {
+ const types = db.getAllDeviceTypes();
+ res.json(types);
+ } catch (err) {
+ next(err);
+ }
+});
+
+/**
+ * GET /api/devices
+ * Get all devices for a project
+ */
+router.get('/', (req, res, next) => {
+ try {
+ const projectId = req.query.projectId || 1;
+ const devices = db.getAllDevices(projectId);
+ res.json(devices);
+ } catch (err) {
+ next(err);
+ }
+});
+
+/**
+ * GET /api/devices/:id/used-ports
+ * Get used ports for a device
+ */
+router.get('/:id/used-ports', (req, res, next) => {
+ try {
+ const ports = db.getUsedPorts(req.params.id);
+ res.json(ports);
+ } catch (err) {
+ next(err);
+ }
+});
+
+/**
+ * POST /api/devices
+ * Create a new device
+ */
+router.post('/', (req, res, next) => {
+ try {
+ const { deviceTypeId, rackId, position, name } = req.body;
+
+ // Validation
+ if (!deviceTypeId) {
+ return res.status(400).json({ error: 'Device type ID is required' });
+ }
+ if (!rackId) {
+ return res.status(400).json({ error: 'Rack ID is required' });
+ }
+ if (typeof position !== 'number' || position < 1 || position > config.rack.slots) {
+ return res.status(400).json({
+ error: `Position must be between 1 and ${config.rack.slots}`
+ });
+ }
+ if (!name || name.trim().length === 0) {
+ return res.status(400).json({ error: 'Device name is required' });
+ }
+
+ // Get project_id from the rack
+ const rack = db.db.prepare('SELECT project_id FROM racks WHERE id = ?').get(rackId);
+ if (!rack) {
+ return res.status(404).json({ error: 'Rack not found' });
+ }
+
+ const device = db.createDevice(deviceTypeId, rackId, rack.project_id, position, name.trim());
+ res.status(201).json(device);
+ } catch (err) {
+ next(err);
+ }
+});
+
+/**
+ * PUT /api/devices/:id/rack
+ * Update device rack and position
+ */
+router.put('/:id/rack', (req, res, next) => {
+ try {
+ const { rackId, position } = req.body;
+
+ if (!rackId) {
+ return res.status(400).json({ error: 'Rack ID is required' });
+ }
+ if (typeof position !== 'number' || position < 1 || position > config.rack.slots) {
+ return res.status(400).json({
+ error: `Position must be between 1 and ${config.rack.slots}`
+ });
+ }
+
+ db.updateDeviceRack(req.params.id, rackId, position);
+ res.json({ success: true });
+ } catch (err) {
+ next(err);
+ }
+});
+
+/**
+ * PUT /api/devices/:id/logical-position
+ * Update device logical view position
+ */
+router.put('/:id/logical-position', (req, res, next) => {
+ try {
+ const { x, y } = req.body;
+
+ if (typeof x !== 'number' || typeof y !== 'number') {
+ return res.status(400).json({ error: 'Valid x and y coordinates are required' });
+ }
+
+ db.updateDeviceLogicalPosition(req.params.id, x, y);
+ res.json({ success: true });
+ } catch (err) {
+ next(err);
+ }
+});
+
+/**
+ * PUT /api/devices/:id/name
+ * Update device name
+ */
+router.put('/:id/name', (req, res, next) => {
+ try {
+ const { name } = req.body;
+
+ if (!name || name.trim().length === 0) {
+ return res.status(400).json({ error: 'Device name is required' });
+ }
+
+ db.updateDeviceName(req.params.id, name.trim());
+ res.json({ success: true });
+ } catch (err) {
+ next(err);
+ }
+});
+
+/**
+ * PUT /api/devices/:id/rack-units
+ * Update device rack units (form factor)
+ */
+router.put('/:id/rack-units', (req, res, next) => {
+ try {
+ const { rackUnits } = req.body;
+ const min = config.device.minRackUnits || 1;
+ const max = config.device.maxRackUnits || config.rack.slots;
+
+ if (typeof rackUnits !== 'number' || rackUnits < min || rackUnits > max) {
+ return res.status(400).json({
+ error: `Rack units must be between ${min} and ${max}`
+ });
+ }
+
+ db.updateDeviceRackUnits(req.params.id, rackUnits);
+ res.json({ success: true });
+ } catch (err) {
+ next(err);
+ }
+});
+
+/**
+ * DELETE /api/devices/:id
+ * Delete a device
+ */
+router.delete('/:id', (req, res, next) => {
+ try {
+ db.deleteDevice(req.params.id);
+ res.json({ success: true });
+ } catch (err) {
+ next(err);
+ }
+});
+
+module.exports = router;
diff --git a/server/routes/projects.js b/server/routes/projects.js
new file mode 100644
index 0000000..0002d68
--- /dev/null
+++ b/server/routes/projects.js
@@ -0,0 +1,90 @@
+/**
+ * Projects Routes
+ * All routes related to project management
+ */
+
+const express = require('express');
+const router = express.Router();
+const db = require('../db');
+
+/**
+ * GET /api/projects
+ * Get all projects
+ */
+router.get('/', (req, res, next) => {
+ try {
+ const projects = db.getAllProjects();
+ res.json(projects);
+ } catch (err) {
+ next(err);
+ }
+});
+
+/**
+ * GET /api/projects/:id
+ * Get a specific project
+ */
+router.get('/:id', (req, res, next) => {
+ try {
+ const project = db.getProject(req.params.id);
+ if (!project) {
+ return res.status(404).json({ error: 'Project not found' });
+ }
+ res.json(project);
+ } catch (err) {
+ next(err);
+ }
+});
+
+/**
+ * POST /api/projects
+ * Create a new project
+ */
+router.post('/', (req, res, next) => {
+ try {
+ const { name, description } = req.body;
+
+ if (!name || name.trim().length === 0) {
+ return res.status(400).json({ error: 'Project name is required' });
+ }
+
+ const project = db.createProject(name.trim(), description || '');
+ res.status(201).json(project);
+ } catch (err) {
+ next(err);
+ }
+});
+
+/**
+ * PUT /api/projects/:id
+ * Update a project
+ */
+router.put('/:id', (req, res, next) => {
+ try {
+ const { name, description } = req.body;
+
+ if (!name || name.trim().length === 0) {
+ return res.status(400).json({ error: 'Project name is required' });
+ }
+
+ db.updateProject(req.params.id, name.trim(), description || '');
+ res.json({ success: true });
+ } catch (err) {
+ next(err);
+ }
+});
+
+/**
+ * DELETE /api/projects/:id
+ * Delete a project
+ */
+router.delete('/:id', (req, res, next) => {
+ try {
+ db.deleteProject(req.params.id);
+ res.json({ success: true });
+ } catch (err) {
+ next(err);
+ }
+});
+
+module.exports = router;
diff --git a/server/routes/racks.js b/server/routes/racks.js
new file mode 100644
index 0000000..d1dc5e8
--- /dev/null
+++ b/server/routes/racks.js
@@ -0,0 +1,113 @@
+/**
+ * Racks Routes
+ * All routes related to rack management
+ */
+
+const express = require('express');
+const router = express.Router();
+const db = require('../db');
+
+/**
+ * GET /api/racks
+ * Get all racks for a project
+ */
+router.get('/', (req, res, next) => {
+ try {
+ const projectId = req.query.projectId || 1;
+ const racks = db.getAllRacks(projectId);
+ res.json(racks);
+ } catch (err) {
+ next(err);
+ }
+});
+
+/**
+ * GET /api/racks/next-name
+ * Get next available rack name for a prefix
+ */
+router.get('/next-name', (req, res, next) => {
+ try {
+ const projectId = req.query.projectId || 1;
+ const prefix = req.query.prefix || 'RACK';
+ const name = db.getNextRackName(projectId, prefix);
+ res.json({ name });
+ } catch (err) {
+ next(err);
+ }
+});
+
+/**
+ * POST /api/racks
+ * Create a new rack
+ */
+router.post('/', (req, res, next) => {
+ try {
+ const { projectId, name, x, y } = req.body;
+
+ // Validation
+ if (!name || name.trim().length === 0) {
+ return res.status(400).json({ error: 'Rack name is required' });
+ }
+ if (typeof x !== 'number' || typeof y !== 'number') {
+ return res.status(400).json({ error: 'Valid x and y coordinates are required' });
+ }
+
+ const rack = db.createRack(projectId || 1, name.trim(), x, y);
+ res.status(201).json(rack);
+ } catch (err) {
+ next(err);
+ }
+});
+
+/**
+ * PUT /api/racks/:id/position
+ * Update rack position
+ */
+router.put('/:id/position', (req, res, next) => {
+ try {
+ const { x, y } = req.body;
+
+ if (typeof x !== 'number' || typeof y !== 'number') {
+ return res.status(400).json({ error: 'Valid x and y coordinates are required' });
+ }
+
+ db.updateRackPosition(req.params.id, x, y);
+ res.json({ success: true });
+ } catch (err) {
+ next(err);
+ }
+});
+
+/**
+ * PUT /api/racks/:id/name
+ * Update rack name
+ */
+router.put('/:id/name', (req, res, next) => {
+ try {
+ const { name } = req.body;
+
+ if (!name || name.trim().length === 0) {
+ return res.status(400).json({ error: 'Rack name is required' });
+ }
+
+ db.updateRackName(req.params.id, name.trim());
+ res.json({ success: true });
+ } catch (err) {
+ next(err);
+ }
+});
+
+/**
+ * DELETE /api/racks/:id
+ * Delete a rack
+ */
+router.delete('/:id', (req, res, next) => {
+ try {
+ db.deleteRack(req.params.id);
+ res.json({ success: true });
+ } catch (err) {
+ next(err);
+ }
+});
+
+module.exports = router;
diff --git a/server/server.js b/server/server.js
new file mode 100644
index 0000000..3b9a6d2
--- /dev/null
+++ b/server/server.js
@@ -0,0 +1,83 @@
+/**
+ * Datacenter Designer Server
+ * Express application with modular routes
+ */
+
+const express = require('express');
+const path = require('path');
+const config = require('./config');
+const db = require('./db');
+const { errorHandler, notFoundHandler } = require('./lib/errorHandler');
+
+// Import route modules
+const projectsRouter = require('./routes/projects');
+const racksRouter = require('./routes/racks');
+const devicesRouter = require('./routes/devices');
+const connectionsRouter = require('./routes/connections');
+
+// Initialize Express app
+const app = express();
+
+// Middleware
+app.use(express.json({ limit: config.api.jsonLimit }));
+app.use(express.static(path.join(__dirname, '../public')));
+
+// Optional: Request logging (development)
+if (config.logging.enabled && config.server.env === 'development') {
+ app.use((req, res, next) => {
+ console.log(`${req.method} ${req.path}`);
+ next();
+ });
+}
+
+// API Routes
+app.use('/api/projects', projectsRouter);
+app.use('/api/racks', racksRouter);
+app.use('/api/devices', devicesRouter);
+app.use('/api/device-types', devicesRouter); // Mounted under /devices/types
+app.use('/api/connections', connectionsRouter);
+
+// Health check endpoint
+app.get('/api/health', (req, res) => {
+ const stats = db.getStats();
+ res.json({
+ status: 'ok',
+ version: '1.0.0',
+ database: stats
+ });
+});
+
+// 404 handler - must be after all other routes
+app.use(notFoundHandler);
+
+// Error handler - must be last
+app.use(errorHandler);
+
+// Initialize database and start server
+db.init();
+
+app.listen(config.server.port, () => {
+ console.log(`
+╔═══════════════════════════════════════════════════════════╗
+║ ║
+║ Datacenter Designer Server ║
+║ ║
+║ Running on: http://${config.server.host}:${config.server.port} ║
+║ Environment: ${config.server.env} ║
+║ ║
+╚═══════════════════════════════════════════════════════════╝
+ `);
+});
+
+// Graceful shutdown
+process.on('SIGTERM', () => {
+ console.log('SIGTERM signal received: closing HTTP server');
+ db.close();
+ process.exit(0);
+});
+
+process.on('SIGINT', () => {
+ console.log('SIGINT signal received: closing HTTP server');
+ db.close();
+ process.exit(0);
+});