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. + +![Version](https://img.shields.io/badge/version-0.1.0-blue.svg) +![License](https://img.shields.io/badge/license-MIT-green.svg) +![Node](https://img.shields.io/badge/node-%3E%3D14.0.0-brightgreen.svg) + +## 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 + + + + + + + + +
+
+ +
+
+
+ + +
+
+ + + +
+
+
+
+ + + +
+
+ + % + +
+
+ + +
+ +
+
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + + + + +
    +
    + +
    +
    +
    + + +
    +
    + + + +
    +
    +
    +
    + + % + +
    +
    + + +
    + +
    +
    +
    + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + 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); +});