First commit

This commit is contained in:
Stefano Manfredi
2025-10-27 11:57:38 +00:00
commit 3431a121a9
34 changed files with 17474 additions and 0 deletions

148
.gitignore vendored Normal file
View File

@@ -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

827
CLAUDE.md Normal file
View File

@@ -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

306
README.md Normal file
View File

@@ -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**

View File

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

View File

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

After

Width:  |  Height:  |  Size: 115 B

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,513 @@
export class DeviceManager {
constructor(layer, api, rackManager) {
this.layer = layer;
this.api = api;
this.rackManager = rackManager;
this.devices = new Map();
this.deviceTypes = [];
this.deviceHeight = 30;
this.deviceSpacing = 5;
this.deviceWidth = 500; // Physical view width
this.currentView = 'physical'; // Track current view
}
async loadDeviceTypes() {
try {
this.deviceTypes = await this.api.getDeviceTypes();
} catch (err) {
console.error('Failed to load device types:', err);
}
}
async loadDevices() {
try {
const devices = await this.api.getDevices();
devices.forEach(deviceData => {
this.createDeviceShape(deviceData);
});
this.layer.batchDraw();
} catch (err) {
console.error('Failed to load devices:', err);
}
}
createDeviceShape(deviceData) {
const rackShape = this.rackManager.getRackShape(deviceData.rack_id);
if (!rackShape) {
console.error('Rack not found for device:', deviceData);
return;
}
const devicesContainer = rackShape.findOne('.devices-container');
// Convert slot position (1-42) to visual Y position
// Slot 1 (U1) is at the bottom, slot 42 (U42) is at the top
const rackData = this.rackManager.getRackData(deviceData.rack_id);
const rackHeight = rackData?.height || this.rackManager.rackHeight;
const maxSlots = 42;
// Calculate device height based on rack_units
const rackUnits = deviceData.rack_units || 1;
const deviceHeight = (this.deviceHeight * rackUnits) + (this.deviceSpacing * (rackUnits - 1));
// Calculate Y position using helper method
const y = this.calculateDeviceY(deviceData.position, rackUnits, rackHeight);
const group = new Konva.Group({
x: 10,
y: y,
draggable: !this.rackManager.racksLocked, // Draggable when racks are unlocked
id: `device-${deviceData.id}`
});
// Device rectangle
const rect = new Konva.Rect({
width: this.deviceWidth,
height: deviceHeight,
fill: deviceData.color || '#4A90E2',
stroke: '#333',
strokeWidth: 1,
cornerRadius: 4,
name: 'device-rect'
});
// Device name
const text = new Konva.Text({
x: 0,
y: 0,
width: this.deviceWidth,
height: deviceHeight,
text: deviceData.name,
fontSize: 14,
fontStyle: 'bold',
fill: '#fff',
align: 'center',
verticalAlign: 'middle',
padding: 5,
name: 'device-text'
});
// Make name clickable for renaming
text.on('click', (e) => {
e.cancelBubble = true; // Prevent group drag
window.dispatchEvent(new CustomEvent('rename-device', {
detail: { deviceId: deviceData.id, deviceData, deviceShape: group }
}));
});
text.on('mouseenter', () => {
document.body.style.cursor = 'text';
text.fontStyle('bold italic');
this.layer.batchDraw();
});
text.on('mouseleave', () => {
document.body.style.cursor = 'default';
text.fontStyle('bold');
this.layer.batchDraw();
});
group.add(rect);
group.add(text);
// Drag and drop between racks
group.on('dragstart', () => {
// Store original parent and position
group.setAttr('originalParent', group.getParent());
group.setAttr('originalPosition', group.position());
// Move to main layer to be on top of everything
const absolutePos = group.getAbsolutePosition();
group.moveTo(this.layer);
group.setAbsolutePosition(absolutePos);
group.moveToTop();
group.opacity(0.7);
});
group.on('dragend', async () => {
group.opacity(1);
await this.handleDeviceDrop(deviceData.id, group);
});
// Right-click context menu
group.on('contextmenu', (e) => {
e.evt.preventDefault();
this.showDeviceContextMenu(e, deviceData, group);
});
devicesContainer.add(group);
this.devices.set(deviceData.id, { data: deviceData, shape: group });
return group;
}
async addDevice(deviceTypeId, rackId, position, name) {
try {
// Generate unique name if needed
const uniqueName = this.generateUniqueName(name);
const response = await this.api.createDevice(deviceTypeId, rackId, position, uniqueName);
// Reload devices to get full data
const devices = await this.api.getDevices();
const newDevice = devices.find(d => d.id === response.id);
if (newDevice) {
this.createDeviceShape(newDevice);
this.layer.batchDraw();
}
// Notify table to sync
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
return newDevice;
} catch (err) {
console.error('Failed to add device:', err);
throw err;
}
}
async deleteDevice(deviceId, group) {
try {
await this.api.deleteDevice(deviceId);
group.destroy();
this.devices.delete(deviceId);
this.layer.batchDraw();
// Notify table to sync
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
} catch (err) {
console.error('Failed to delete device:', err);
}
}
showDeviceContextMenu(e, deviceData, group) {
const contextMenu = document.getElementById('contextMenu');
const contextMenuList = document.getElementById('contextMenuList');
contextMenuList.innerHTML = `
<li data-action="connect">Create Connection</li>
<li class="divider"></li>
<li data-action="delete">Delete Device</li>
`;
contextMenu.style.left = `${e.evt.pageX}px`;
contextMenu.style.top = `${e.evt.pageY}px`;
contextMenu.classList.remove('hidden');
const handleAction = async (evt) => {
const action = evt.target.dataset.action;
if (action === 'delete') {
if (confirm(`Delete device ${deviceData.name}?`)) {
this.deleteDevice(deviceData.id, group);
}
} else if (action === 'connect') {
// Trigger connection creation
window.dispatchEvent(new CustomEvent('create-connection', {
detail: { deviceData, deviceShape: group }
}));
}
contextMenu.classList.add('hidden');
contextMenuList.removeEventListener('click', handleAction);
};
contextMenuList.addEventListener('click', handleAction);
}
getNextDevicePosition(rackId) {
// Find the lowest available slot (1-42)
// U1 is at the bottom, so we fill from bottom to top
const usedSlots = new Set();
this.devices.forEach(device => {
if (device.data.rack_id === rackId) {
usedSlots.add(device.data.position);
}
});
// Find first available slot starting from U1 (bottom)
for (let slot = 1; slot <= 42; slot++) {
if (!usedSlots.has(slot)) {
return slot;
}
}
// If all slots are full, return next slot (will overflow)
return 43;
}
getDeviceShape(deviceId) {
const device = this.devices.get(deviceId);
return device ? device.shape : null;
}
getDeviceData(deviceId) {
const device = this.devices.get(deviceId);
return device ? device.data : null;
}
getAllDevices() {
return Array.from(this.devices.values()).map(d => d.data);
}
// Calculate Y position for a device at a given slot with given rack units
calculateDeviceY(position, rackUnits = 1, rackHeight = null) {
const maxSlots = 42;
// Use same margin as left/right (10px)
const topMargin = 10;
// Device at position X with N rack units occupies slots X (bottom) to X+N-1 (top)
const topSlot = position + (rackUnits - 1);
const visualPosition = maxSlots - topSlot;
return topMargin + (visualPosition * (this.deviceHeight + this.deviceSpacing));
}
// Check if a device at a given position with given rack_units conflicts with other devices
// Returns null if no conflict, or a descriptive error message if there is a conflict
checkSlotConflict(rackId, position, rackUnits, excludeDeviceId = null) {
const slotsOccupied = [];
for (let i = 0; i < rackUnits; i++) {
slotsOccupied.push(position + i);
}
// Check all devices in the same rack
const devicesInRack = Array.from(this.devices.values())
.filter(d => d.data.rack_id === rackId && d.data.id !== excludeDeviceId);
for (const device of devicesInRack) {
const deviceRackUnits = device.data.rack_units || 1;
const deviceSlotsOccupied = [];
for (let i = 0; i < deviceRackUnits; i++) {
deviceSlotsOccupied.push(device.data.position + i);
}
// Check for overlap
const overlap = slotsOccupied.some(slot => deviceSlotsOccupied.includes(slot));
if (overlap) {
const conflictSlots = slotsOccupied.filter(slot => deviceSlotsOccupied.includes(slot));
return `Device "${device.data.name}" already occupies slot(s) U${conflictSlots.join(', U')}`;
}
}
return null; // No conflict
}
// Check if a device name already exists (case-insensitive)
isDeviceNameTaken(name, excludeDeviceId = null) {
const nameLower = name.toLowerCase();
return Array.from(this.devices.values()).some(device => {
if (excludeDeviceId && device.data.id === excludeDeviceId) {
return false; // Exclude the device being renamed
}
return device.data.name.toLowerCase() === nameLower;
});
}
// Generate a unique device name by adding _XX suffix
generateUniqueName(baseName) {
// Remove any existing _XX suffix from the base name
const cleanBaseName = baseName.replace(/_\d+$/, '');
// If the clean name is available, use it
if (!this.isDeviceNameTaken(cleanBaseName)) {
return cleanBaseName;
}
// Find the highest existing number suffix
let maxNumber = 0;
const pattern = new RegExp(`^${cleanBaseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}_?(\\d+)$`, 'i');
Array.from(this.devices.values()).forEach(device => {
const match = device.data.name.match(pattern);
if (match) {
const num = parseInt(match[1]) || 0;
if (num > maxNumber) {
maxNumber = num;
}
}
});
// Generate next number with padding
const nextNumber = (maxNumber + 1).toString().padStart(2, '0');
return `${cleanBaseName}_${nextNumber}`;
}
async handleDeviceDrop(deviceId, deviceShape) {
const device = this.devices.get(deviceId);
if (!device) return;
// Get device's center point for more accurate drop detection
const absolutePos = deviceShape.getAbsolutePosition();
const deviceCenterX = absolutePos.x + (this.deviceWidth / 2);
const deviceCenterY = absolutePos.y + (this.deviceHeight / 2);
// Find which rack the device is over
let targetRack = null;
let targetRackId = null;
this.rackManager.racks.forEach((rack, rackId) => {
const rackPos = rack.shape.getAbsolutePosition();
const rackWidth = rack.data.width || this.rackManager.rackWidth;
const rackHeight = rack.data.height || this.rackManager.rackHeight;
// Check if device center is within rack bounds
if (deviceCenterX >= rackPos.x && deviceCenterX <= rackPos.x + rackWidth &&
deviceCenterY >= rackPos.y && deviceCenterY <= rackPos.y + rackHeight) {
targetRack = rack;
targetRackId = rackId;
}
});
// If not over any rack, return device to original position
if (!targetRack) {
const originalParent = deviceShape.getAttr('originalParent');
const originalPosition = deviceShape.getAttr('originalPosition');
if (originalParent) {
deviceShape.moveTo(originalParent);
deviceShape.position(originalPosition);
}
this.layer.batchDraw();
return;
}
const originalRackId = device.data.rack_id;
// Calculate position within target rack
const rackShape = targetRack.shape;
const rackAbsolutePos = rackShape.getAbsolutePosition();
const relativeY = absolutePos.y - rackAbsolutePos.y;
// Convert visual Y to slot position (1-42, where U1 is at bottom)
const maxSlots = 42;
const visualPosition = Math.round((relativeY - 10) / (this.deviceHeight + this.deviceSpacing));
let newPosition = maxSlots - visualPosition; // Invert: bottom (high Y) = low slot number
newPosition = Math.max(1, Math.min(42, newPosition)); // Clamp to 1-42
// Get devices in target rack and check for conflicts
const devicesInTargetRack = Array.from(this.devices.values())
.filter(d => d.data.rack_id === targetRackId && d.data.id !== deviceId)
.sort((a, b) => a.data.position - b.data.position);
// Find available position
let finalPosition = newPosition;
const occupiedPositions = new Set(devicesInTargetRack.map(d => d.data.position));
while (occupiedPositions.has(finalPosition)) {
finalPosition++;
}
try {
// Update device in database
await this.api.request(`/api/devices/${deviceId}/rack`, {
method: 'PUT',
body: JSON.stringify({ rackId: targetRackId, position: finalPosition })
});
// Update local data
device.data.rack_id = targetRackId;
device.data.position = finalPosition;
// Move device to new rack's devices-container
const newDevicesContainer = rackShape.findOne('.devices-container');
deviceShape.moveTo(newDevicesContainer);
// Reposition device using helper method
const rackUnits = device.data.rack_units || 1;
const rackData = this.rackManager.getRackData(targetRackId);
const rackHeight = rackData?.height || this.rackManager.rackHeight;
const newY = this.calculateDeviceY(finalPosition, rackUnits, rackHeight);
deviceShape.position({ x: 10, y: newY });
// Compact positions in original rack if different
if (originalRackId !== targetRackId) {
this.compactRackDevices(originalRackId);
}
this.layer.batchDraw();
// Notify table to sync
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
} catch (err) {
console.error('Failed to move device:', err);
// Revert to original position
const originalParent = deviceShape.getAttr('originalParent');
const originalPosition = deviceShape.getAttr('originalPosition');
if (originalParent) {
deviceShape.moveTo(originalParent);
deviceShape.position(originalPosition);
}
this.layer.batchDraw();
}
}
async compactRackDevices(rackId) {
// Get all devices in this rack, sorted by position (1-42)
const devicesInRack = Array.from(this.devices.values())
.filter(d => d.data.rack_id === rackId)
.sort((a, b) => a.data.position - b.data.position);
// Reassign positions to be sequential starting from 1 (U1 = bottom)
const updatePromises = [];
const maxSlots = 42;
devicesInRack.forEach((device, index) => {
const newSlot = index + 1; // Slots start at 1
if (device.data.position !== newSlot) {
device.data.position = newSlot;
// Update visual position using helper method
const rackUnits = device.data.rack_units || 1;
const rackData = this.rackManager.getRackData(rackId);
const rackHeight = rackData?.height || this.rackManager.rackHeight;
const newY = this.calculateDeviceY(newSlot, rackUnits, rackHeight);
device.shape.position({ x: 10, y: newY });
// Update database
updatePromises.push(
this.api.request(`/api/devices/${device.data.id}/rack`, {
method: 'PUT',
body: JSON.stringify({ rackId: rackId, position: newSlot })
})
);
}
});
await Promise.all(updatePromises);
this.layer.batchDraw();
}
updateDevicesDraggability(draggable) {
this.devices.forEach(device => {
device.shape.draggable(draggable);
});
}
setCurrentView(viewType) {
this.currentView = viewType;
// Set device width based on view
if (viewType === 'logical') {
this.deviceWidth = 200; // Narrower in logical view
} else {
this.deviceWidth = 500; // Normal width in physical view
}
// Resize all existing devices
this.devices.forEach(device => {
const rect = device.shape.findOne('.device-rect');
const text = device.shape.findOne('.device-text');
if (rect) {
rect.width(this.deviceWidth);
}
if (text) {
text.width(this.deviceWidth);
}
});
}
}

View File

@@ -0,0 +1,488 @@
export class RackManager {
constructor(layer, api, deviceManager) {
this.layer = layer;
this.api = api;
this.deviceManager = deviceManager;
this.racks = new Map();
this.rackPrefix = 'RACK';
this.rackWidth = 520; // Fits 500px wide devices with margins
this.rackHeight = 1510; // Fits 42 devices (42 * 35px + margins)
this.rackSpacing = 80;
this.gridSize = 600; // Default: rack width + spacing
this.gridVertical = 1610; // Default: rack height + spacing (1510 + 100)
this.racksLocked = true; // Start with racks locked
this.nextX = 0; // Start at grid origin
this.nextY = 0; // Start at grid origin
// Note: loadSpacing() will be called after project ID is set
}
loadSpacing() {
const projectId = this.api.currentProjectId;
const savedGridSize = localStorage.getItem(`gridSize_${projectId}`);
const savedGridVertical = localStorage.getItem(`gridVertical_${projectId}`);
if (savedGridSize) {
this.gridSize = parseInt(savedGridSize);
this.rackSpacing = this.gridSize - this.rackWidth;
} else {
this.gridSize = 600; // Default: rack width + spacing
}
if (savedGridVertical) {
this.gridVertical = parseInt(savedGridVertical);
} else {
this.gridVertical = 1610; // Default: rack height + spacing (fits 42 devices)
}
}
saveSpacing() {
const projectId = this.api.currentProjectId;
localStorage.setItem(`gridSize_${projectId}`, this.gridSize.toString());
localStorage.setItem(`gridVertical_${projectId}`, this.gridVertical.toString());
}
async toggleRacksLock() {
this.racksLocked = !this.racksLocked;
this.racks.forEach(rack => {
rack.shape.draggable(!this.racksLocked);
});
// Update device draggability
if (this.deviceManager) {
this.deviceManager.updateDevicesDraggability(!this.racksLocked);
}
// If locking, compact the grid (remove empty columns from the left)
if (this.racksLocked) {
await this.compactGrid();
}
return this.racksLocked;
}
async compactGrid() {
if (this.racks.size === 0) return;
// Get all rack positions and calculate their grid coordinates
const rackPositions = [];
this.racks.forEach((rack, id) => {
const gridX = Math.round(rack.data.x / this.gridSize);
const gridY = Math.round(rack.data.y / this.gridVertical);
rackPositions.push({ id, rack, gridX, gridY });
});
// Find the minimum grid X (leftmost column that has racks)
const minGridX = Math.min(...rackPositions.map(r => r.gridX));
// If minGridX is 0, grid is already compact
if (minGridX === 0) return;
// Shift all racks left by minGridX columns
const updatePromises = [];
for (const rackPos of rackPositions) {
const newGridX = rackPos.gridX - minGridX;
const newX = newGridX * this.gridSize;
const newY = rackPos.gridY * this.gridVertical;
// Update visual position
rackPos.rack.shape.position({ x: newX, y: newY });
// Update data
rackPos.rack.data.x = newX;
rackPos.rack.data.y = newY;
// Queue database update
updatePromises.push(this.api.updateRackPosition(rackPos.id, newX, newY));
}
// Redraw once
this.layer.batchDraw();
// Wait for all updates
await Promise.all(updatePromises);
}
snapToGrid(value, gridSize) {
return Math.round(value / gridSize) * gridSize;
}
async loadRacks() {
try {
const racks = await this.api.getRacks();
racks.forEach(rackData => {
this.createRackShape(rackData);
});
this.layer.batchDraw();
} catch (err) {
console.error('Failed to load racks:', err);
}
}
createRackShape(rackData) {
const group = new Konva.Group({
x: rackData.x,
y: rackData.y,
draggable: !this.racksLocked, // Locked by default
id: `rack-${rackData.id}`
});
// Rack background
const rect = new Konva.Rect({
width: rackData.width || this.rackWidth,
height: rackData.height || this.rackHeight,
fill: '#ffffff',
stroke: '#333',
strokeWidth: 2,
shadowColor: 'black',
shadowBlur: 5,
shadowOpacity: 0.1,
shadowOffset: { x: 2, y: 2 }
});
// Rack name label (clickable)
const nameLabel = new Konva.Text({
x: 0,
y: -30,
width: rackData.width || this.rackWidth,
text: rackData.name,
fontSize: 16,
fontStyle: 'bold',
fill: '#333',
align: 'center',
name: 'rack-name'
});
// Make name clickable
nameLabel.on('click', () => {
window.dispatchEvent(new CustomEvent('rename-rack', {
detail: { rackId: rackData.id, rackData, rackShape: group }
}));
});
nameLabel.on('mouseenter', () => {
document.body.style.cursor = 'pointer';
nameLabel.fill('#4A90E2');
this.layer.batchDraw();
});
nameLabel.on('mouseleave', () => {
document.body.style.cursor = 'default';
nameLabel.fill('#333');
this.layer.batchDraw();
});
// Container for devices
const devicesLayer = new Konva.Group({
name: 'devices-container'
});
group.add(rect);
group.add(nameLabel);
group.add(devicesLayer);
// Grid snapping during drag
group.on('dragmove', () => {
const x = this.snapToGrid(group.x(), this.gridSize);
const y = this.snapToGrid(group.y(), this.gridVertical);
group.position({ x, y });
});
// Drag end - update position in DB with smart positioning
group.on('dragend', async () => {
try {
const newX = this.snapToGrid(group.x(), this.gridSize);
const newY = this.snapToGrid(group.y(), this.gridVertical);
// Check if position is occupied by another rack
await this.handleRackPlacement(rackData.id, newX, newY);
} catch (err) {
console.error('Failed to update rack position:', err);
}
});
// Right-click context menu
group.on('contextmenu', (e) => {
e.evt.preventDefault();
this.showContextMenu(e, rackData, group);
});
this.layer.add(group);
this.racks.set(rackData.id, { data: rackData, shape: group });
return group;
}
async addRack() {
try {
const nextName = await this.api.getNextRackName(this.rackPrefix);
const rackData = await this.api.createRack(nextName, this.nextX, this.nextY);
this.createRackShape(rackData);
this.layer.batchDraw();
// Update next position (using grid sizes)
this.nextX += this.gridSize;
if (this.nextX > 1200) {
this.nextX = 0;
this.nextY += this.gridVertical;
}
// Notify table to sync
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
return rackData;
} catch (err) {
console.error('Failed to add rack:', err);
throw err;
}
}
async deleteRack(rackId, group) {
try {
await this.api.deleteRack(rackId);
group.destroy();
this.racks.delete(rackId);
this.layer.batchDraw();
// Notify table to sync
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
} catch (err) {
console.error('Failed to delete rack:', err);
}
}
showContextMenu(e, rackData, group) {
const contextMenu = document.getElementById('contextMenu');
const contextMenuList = document.getElementById('contextMenuList');
const lockText = this.racksLocked ? 'Unlock All Racks' : 'Lock All Racks';
// Build device types list with header
let deviceTypesHTML = '<li class="menu-header">Add device:</li>';
if (this.deviceManager && this.deviceManager.deviceTypes) {
this.deviceManager.deviceTypes.forEach(type => {
deviceTypesHTML += `<li data-action="add-device" data-device-type-id="${type.id}" data-device-type-name="${type.name}">${type.name}</li>`;
});
}
// Build unlock/management options
let managementHTML = `<li data-action="toggle-lock">${lockText}</li>`;
// Show delete and spacing controls only when unlocked
if (!this.racksLocked) {
const horizontalSpacing = this.gridSize - this.rackWidth;
const verticalSpacing = this.gridVertical - this.rackHeight;
managementHTML += `
<li data-action="delete">Delete Rack</li>
<li class="divider"></li>
<li class="spacing-control">
<span class="spacing-label">Horizontal spacing: ${horizontalSpacing}px</span>
<div class="spacing-buttons">
<button class="spacing-btn" data-action="h-spacing-decrease"></button>
<button class="spacing-btn" data-action="h-spacing-increase">+</button>
</div>
</li>
<li class="spacing-control">
<span class="spacing-label">Vertical spacing: ${verticalSpacing}px</span>
<div class="spacing-buttons">
<button class="spacing-btn" data-action="v-spacing-decrease"></button>
<button class="spacing-btn" data-action="v-spacing-increase">+</button>
</div>
</li>
`;
}
contextMenuList.innerHTML = `
${deviceTypesHTML}
<li class="divider"></li>
${managementHTML}
`;
contextMenu.style.left = `${e.evt.pageX}px`;
contextMenu.style.top = `${e.evt.pageY}px`;
contextMenu.classList.remove('hidden');
const handleAction = async (evt) => {
const action = evt.target.dataset.action;
// For spacing buttons, prevent default and stop propagation
if (action && action.includes('spacing')) {
evt.preventDefault();
evt.stopPropagation();
}
if (action === 'add-device') {
const deviceTypeId = parseInt(evt.target.dataset.deviceTypeId);
const deviceTypeName = evt.target.dataset.deviceTypeName;
const deviceName = prompt(`Enter name for ${deviceTypeName}:`, deviceTypeName);
if (deviceName) {
try {
// Check if name will be auto-numbered
const uniqueName = this.deviceManager.generateUniqueName(deviceName);
if (uniqueName !== deviceName) {
const proceed = confirm(`Device name "${deviceName}" is already in use.\n\nDevice will be named "${uniqueName}" instead.\n\nContinue?`);
if (!proceed) {
return;
}
}
const position = this.deviceManager.getNextDevicePosition(rackData.id);
await this.deviceManager.addDevice(deviceTypeId, rackData.id, position, deviceName);
} catch (err) {
alert('Failed to add device: ' + err.message);
}
}
} else if (action === 'delete') {
if (confirm(`Delete rack ${rackData.name}?`)) {
this.deleteRack(rackData.id, group);
}
} else if (action === 'toggle-lock') {
const isLocked = await this.toggleRacksLock();
const statusText = isLocked ? 'Racks locked (grid compacted)' : 'Racks unlocked';
// Close and reopen menu to refresh the lock state
contextMenu.classList.add('hidden');
setTimeout(() => {
this.showContextMenu(e, rackData, group);
}, 10);
return; // Don't close menu handler
} else if (action === 'h-spacing-increase') {
await this.adjustSpacing('horizontal', 10);
return; // Don't close menu
} else if (action === 'h-spacing-decrease') {
await this.adjustSpacing('horizontal', -10);
return; // Don't close menu
} else if (action === 'v-spacing-increase') {
await this.adjustSpacing('vertical', 50);
return; // Don't close menu
} else if (action === 'v-spacing-decrease') {
await this.adjustSpacing('vertical', -50);
return; // Don't close menu
}
contextMenu.classList.add('hidden');
contextMenuList.removeEventListener('click', handleAction);
};
contextMenuList.addEventListener('click', handleAction);
}
getRackShape(rackId) {
const rack = this.racks.get(rackId);
return rack ? rack.shape : null;
}
getRackData(rackId) {
const rack = this.racks.get(rackId);
return rack ? rack.data : null;
}
async handleRackPlacement(movedRackId, newX, newY) {
// Get all racks in the same row (same Y coordinate)
const racksInRow = [];
this.racks.forEach((rack, id) => {
if (id !== movedRackId && rack.data.y === newY) {
racksInRow.push({ id, rack, x: rack.data.x });
}
});
// Sort by X position
racksInRow.sort((a, b) => a.x - b.x);
// Check if new position is occupied
const occupiedRack = racksInRow.find(r => r.x === newX);
if (occupiedRack) {
// Position is occupied - shift all racks at and to the right of this position
const racksToShift = racksInRow.filter(r => r.x >= newX);
// Shift each rack one grid position to the right
for (const rackInfo of racksToShift) {
const newRackX = rackInfo.x + this.gridSize;
// Update visual position
rackInfo.rack.shape.position({ x: newRackX, y: newY });
// Update data
rackInfo.rack.data.x = newRackX;
rackInfo.rack.data.y = newY;
// Update in database
await this.api.updateRackPosition(rackInfo.id, newRackX, newY);
}
}
// Update the moved rack
const movedRack = this.racks.get(movedRackId);
if (movedRack) {
movedRack.shape.position({ x: newX, y: newY });
movedRack.data.x = newX;
movedRack.data.y = newY;
await this.api.updateRackPosition(movedRackId, newX, newY);
}
// Redraw
this.layer.batchDraw();
}
async adjustSpacing(direction, delta) {
// Calculate grid coordinates for all racks BEFORE changing spacing
const rackGridPositions = new Map();
this.racks.forEach((rack, id) => {
const gridX = Math.round(rack.data.x / this.gridSize);
const gridY = Math.round(rack.data.y / this.gridVertical);
rackGridPositions.set(id, { gridX, gridY });
});
// Adjust spacing (this updates the grid references)
if (direction === 'horizontal') {
const newSpacing = (this.gridSize - this.rackWidth) + delta;
if (newSpacing < 10) return; // Minimum spacing
this.gridSize = this.rackWidth + newSpacing;
this.rackSpacing = newSpacing; // Update the spacing value
} else {
const newSpacing = (this.gridVertical - this.rackHeight) + delta;
if (newSpacing < 10) return; // Minimum spacing
this.gridVertical = this.rackHeight + newSpacing;
}
// Batch all position updates
const updatePromises = [];
// Recalculate all rack positions at once
for (const [id, gridPos] of rackGridPositions) {
const rack = this.racks.get(id);
if (!rack) continue;
const newX = gridPos.gridX * this.gridSize;
const newY = gridPos.gridY * this.gridVertical;
// Update visual position
rack.shape.position({ x: newX, y: newY });
// Update data
rack.data.x = newX;
rack.data.y = newY;
// Queue database update (don't await yet)
updatePromises.push(this.api.updateRackPosition(id, newX, newY));
}
// Redraw once for all changes
this.layer.batchDraw();
// Wait for all database updates to complete
await Promise.all(updatePromises);
// Save spacing to localStorage
this.saveSpacing();
// Update status
const horizontalSpacing = this.gridSize - this.rackWidth;
const verticalSpacing = this.gridVertical - this.rackHeight;
}
}

View File

@@ -0,0 +1,792 @@
export class TableManager {
constructor(api, rackManager, deviceManager, connectionManager) {
this.api = api;
this.rackManager = rackManager;
this.deviceManager = deviceManager;
this.connectionManager = connectionManager;
this.currentTable = null; // 'racks', 'devices', 'connections'
this.gridApi = null;
this.gridColumnApi = null;
this.tableContainer = document.getElementById('tableContent');
}
isTableVisible() {
return this.currentTable !== null;
}
getCurrentTableType() {
return this.currentTable;
}
// Show specific table view
async showTable(tableType) {
// tableType can be: 'racks-table', 'devices-table', 'connections-table'
const tableMap = {
'racks-table': 'racks',
'devices-table': 'devices',
'connections-table': 'connections'
};
this.currentTable = tableMap[tableType];
// Clear existing grid
if (this.gridApi) {
this.gridApi.destroy();
this.gridApi = null;
}
// Render appropriate table
switch (this.currentTable) {
case 'racks':
await this.showRacksTable();
break;
case 'devices':
await this.showDevicesTable();
break;
case 'connections':
await this.showConnectionsTable();
break;
}
}
hideTable() {
if (this.gridApi) {
this.gridApi.destroy();
this.gridApi = null;
}
this.currentTable = null;
this.tableContainer.innerHTML = '';
}
// ===== RACKS TABLE =====
async showRacksTable() {
const racks = await this.api.getRacks();
// Sort alphabetically by name
const sortedRacks = racks.sort((a, b) => a.name.localeCompare(b.name));
const columnDefs = [
{
headerName: 'Rack Name',
field: 'name',
editable: true,
sortable: true,
filter: true,
checkboxSelection: true,
headerCheckboxSelection: true
},
{
headerName: 'Position X',
field: 'x',
editable: false,
sortable: true,
valueFormatter: params => `${Math.round(params.value)}px`
},
{
headerName: 'Position Y',
field: 'y',
editable: false,
sortable: true,
valueFormatter: params => `${Math.round(params.value)}px`
},
{
headerName: 'Width',
field: 'width',
editable: false,
sortable: true,
valueFormatter: params => `${params.value}px`
},
{
headerName: 'Height',
field: 'height',
editable: false,
sortable: true,
valueFormatter: params => `${params.value}px`
},
{
headerName: 'Device Count',
field: 'deviceCount',
editable: false,
sortable: true,
valueGetter: params => {
// Count devices in this rack
const devices = this.deviceManager.getAllDevices();
return devices.filter(d => d.rack_id === params.data.id).length;
}
}
];
const gridOptions = {
columnDefs: columnDefs,
rowData: sortedRacks,
rowSelection: 'multiple',
animateRows: true,
enableCellTextSelection: true,
defaultColDef: {
flex: 1,
minWidth: 100,
resizable: true
},
onCellValueChanged: (params) => this.onRackCellValueChanged(params),
onSelectionChanged: () => this.updateToolbarButtons()
};
this.gridApi = agGrid.createGrid(this.tableContainer, gridOptions);
}
async onRackCellValueChanged(params) {
const rackId = params.data.id;
const field = params.colDef.field;
const newValue = params.newValue;
try {
if (field === 'name') {
await this.api.updateRackName(rackId, newValue);
// Update canvas
const rackShape = this.rackManager.getRackShape(rackId);
if (rackShape) {
const nameLabel = rackShape.findOne('.rack-name');
if (nameLabel) {
nameLabel.text(newValue);
this.rackManager.layer.batchDraw();
}
}
// Update local data
const rackData = this.rackManager.getRackData(rackId);
if (rackData) {
rackData.name = newValue;
}
}
} catch (err) {
console.error('Failed to update rack:', err);
alert('Failed to update rack: ' + err.message);
// Revert the change
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
}
}
// ===== DEVICES TABLE =====
async showDevicesTable() {
const devices = await this.api.getDevices();
const racks = await this.api.getRacks();
const deviceTypes = await this.api.getDeviceTypes();
const columnDefs = [
{
headerName: 'Device Name',
field: 'name',
editable: true,
sortable: true,
filter: true,
checkboxSelection: true,
headerCheckboxSelection: true
},
{
headerName: 'Type',
field: 'type_name',
editable: true,
sortable: true,
filter: true,
cellEditor: 'agSelectCellEditor',
cellEditorParams: {
values: deviceTypes.map(t => t.name)
}
},
{
headerName: 'Rack',
field: 'rack_name',
editable: true,
sortable: true,
filter: true,
cellEditor: 'agSelectCellEditor',
cellEditorParams: {
values: racks.map(r => r.name)
},
valueGetter: params => {
const rack = racks.find(r => r.id === params.data.rack_id);
return rack ? rack.name : '';
}
},
{
headerName: 'Slot/Position',
field: 'position',
editable: true,
sortable: true,
filter: 'agNumberColumnFilter',
valueFormatter: params => `U${params.value}`,
cellEditor: 'agNumberCellEditor',
cellEditorParams: {
min: 1,
max: 42,
precision: 0
},
valueSetter: params => {
const newValue = parseInt(params.newValue);
if (newValue >= 1 && newValue <= 42) {
params.data.position = newValue;
return true;
}
return false;
}
},
{
headerName: 'Form Factor',
field: 'rack_units',
editable: true,
sortable: true,
filter: 'agNumberColumnFilter',
valueFormatter: params => `${params.value || 1}U`,
cellEditor: 'agNumberCellEditor',
cellEditorParams: {
min: 1,
max: 42,
precision: 0
},
valueSetter: params => {
const newValue = parseInt(params.newValue);
if (newValue >= 1 && newValue <= 42) {
params.data.rack_units = newValue;
return true;
}
return false;
}
},
{
headerName: 'Ports',
field: 'ports_count',
editable: false,
sortable: true,
filter: 'agNumberColumnFilter'
},
{
headerName: 'Color',
field: 'color',
editable: false,
sortable: false,
cellRenderer: params => {
return `<div style="width: 100%; height: 100%; background-color: ${params.value}; border-radius: 3px;"></div>`;
}
},
{
headerName: 'Connections',
field: 'connectionCount',
editable: false,
sortable: true,
valueGetter: params => {
// Count connections for this device
const connections = Array.from(this.connectionManager.connections.values());
return connections.filter(c =>
c.data.source_device_id === params.data.id ||
c.data.target_device_id === params.data.id
).length;
}
}
];
const gridOptions = {
columnDefs: columnDefs,
rowData: devices,
rowSelection: 'multiple',
animateRows: true,
enableCellTextSelection: true,
defaultColDef: {
flex: 1,
minWidth: 100,
resizable: true
},
onCellValueChanged: (params) => this.onDeviceCellValueChanged(params, racks, deviceTypes),
onSelectionChanged: () => this.updateToolbarButtons()
};
this.gridApi = agGrid.createGrid(this.tableContainer, gridOptions);
}
async onDeviceCellValueChanged(params, racks, deviceTypes) {
const deviceId = params.data.id;
const field = params.colDef.field;
const newValue = params.newValue;
try {
if (field === 'name') {
// Check if name is already taken
if (this.deviceManager.isDeviceNameTaken(newValue, deviceId)) {
alert(`Device name "${newValue}" is already in use. Please choose a different name.`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
await this.api.updateDeviceName(deviceId, newValue);
// Update canvas
const deviceShape = this.deviceManager.getDeviceShape(deviceId);
if (deviceShape) {
const nameLabel = deviceShape.findOne('.device-text');
if (nameLabel) {
nameLabel.text(newValue);
this.deviceManager.layer.batchDraw();
}
}
// Update local data
const deviceData = this.deviceManager.getDeviceData(deviceId);
if (deviceData) {
deviceData.name = newValue;
}
} else if (field === 'rack_name') {
// Find the rack by name
const rack = racks.find(r => r.name === newValue);
if (rack) {
const newPosition = this.deviceManager.getNextDevicePosition(rack.id);
await this.api.request(`/api/devices/${deviceId}/rack`, {
method: 'PUT',
body: JSON.stringify({ rackId: rack.id, position: newPosition })
});
// Update device on canvas
const deviceShape = this.deviceManager.getDeviceShape(deviceId);
const deviceData = this.deviceManager.getDeviceData(deviceId);
if (deviceShape && deviceData) {
const oldRackId = deviceData.rack_id;
deviceData.rack_id = rack.id;
deviceData.position = newPosition;
// Move to new rack's container
const newRackShape = this.rackManager.getRackShape(rack.id);
if (newRackShape) {
const newDevicesContainer = newRackShape.findOne('.devices-container');
deviceShape.moveTo(newDevicesContainer);
// Calculate visual position using helper method
const rackUnits = deviceData.rack_units || 1;
const rackData = this.rackManager.getRackData(rack.id);
const rackHeight = rackData?.height || this.rackManager.rackHeight;
const newY = this.deviceManager.calculateDeviceY(newPosition, rackUnits, rackHeight);
deviceShape.position({ x: 10, y: newY });
// Compact old rack
if (oldRackId !== rack.id) {
this.deviceManager.compactRackDevices(oldRackId);
}
this.deviceManager.layer.batchDraw();
}
}
// Refresh table to show updated position
this.refreshTable();
}
} else if (field === 'position') {
const rackId = params.data.rack_id;
const newSlot = parseInt(newValue);
const rackUnits = params.data.rack_units || 1;
// Validate slot range (1-42)
if (newSlot < 1 || newSlot > 42) {
alert('Slot position must be between U1 and U42');
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
// Validate that device with its rack_units fits in the rack
if (newSlot + rackUnits - 1 > 42) {
alert(`Device with ${rackUnits}U form factor cannot fit at position U${newSlot}. Maximum position is U${43 - rackUnits}.`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
// Check for slot conflicts with other devices
const conflict = this.deviceManager.checkSlotConflict(rackId, newSlot, rackUnits, deviceId);
if (conflict) {
alert(`Slot conflict detected: ${conflict}`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
await this.api.request(`/api/devices/${deviceId}/rack`, {
method: 'PUT',
body: JSON.stringify({ rackId: rackId, position: newSlot })
});
// Update device position on canvas using helper method
const deviceShape = this.deviceManager.getDeviceShape(deviceId);
const deviceData = this.deviceManager.getDeviceData(deviceId);
if (deviceShape && deviceData) {
deviceData.position = newSlot;
const rackUnits = deviceData.rack_units || 1;
const rackData = this.rackManager.getRackData(rackId);
const rackHeight = rackData?.height || this.rackManager.rackHeight;
const newY = this.deviceManager.calculateDeviceY(newSlot, rackUnits, rackHeight);
deviceShape.position({ x: 10, y: newY });
this.deviceManager.layer.batchDraw();
}
} else if (field === 'rack_units') {
const rackId = params.data.rack_id;
const position = params.data.position;
const newRackUnits = parseInt(newValue);
// Validate that device with its new rack_units fits in the rack
if (position + newRackUnits - 1 > 42) {
alert(`Device with ${newRackUnits}U form factor cannot fit at position U${position}. Maximum form factor at this position is ${43 - position}U.`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
// Check for slot conflicts with other devices
const conflict = this.deviceManager.checkSlotConflict(rackId, position, newRackUnits, deviceId);
if (conflict) {
alert(`Slot conflict detected: ${conflict}`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
await this.api.request(`/api/devices/${deviceId}/rack-units`, {
method: 'PUT',
body: JSON.stringify({ rackUnits: newRackUnits })
});
// Update device rendering on canvas
const deviceShape = this.deviceManager.getDeviceShape(deviceId);
const deviceData = this.deviceManager.getDeviceData(deviceId);
if (deviceShape && deviceData) {
deviceData.rack_units = newRackUnits;
// Update device height
const newHeight = (this.deviceManager.deviceHeight * newRackUnits) + (this.deviceManager.deviceSpacing * (newRackUnits - 1));
const rect = deviceShape.findOne('Rect');
const text = deviceShape.findOne('.device-text');
if (rect) {
rect.height(newHeight);
}
if (text) {
text.height(newHeight);
}
// Reposition device since height changed using helper method
const rackData = this.rackManager.getRackData(rackId);
const rackHeight = rackData?.height || this.rackManager.rackHeight;
const newY = this.deviceManager.calculateDeviceY(position, newRackUnits, rackHeight);
deviceShape.position({ x: 10, y: newY });
this.deviceManager.layer.batchDraw();
}
// Notify canvas that data changed
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
} else if (field === 'type_name') {
// Find device type by name
const deviceType = deviceTypes.find(dt => dt.name === newValue);
if (deviceType) {
// Note: We would need an API endpoint to update device type
// For now, just show a message
alert('Changing device type requires updating the device_type_id in the database. This feature needs backend support.');
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
}
}
} catch (err) {
console.error('Failed to update device:', err);
alert('Failed to update device: ' + err.message);
// Revert the change
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
}
}
// ===== CONNECTIONS TABLE =====
async showConnectionsTable() {
const connections = await this.api.getConnections();
const devices = await this.api.getDevices();
// Enrich connection data with device names
const enrichedConnections = connections.map(conn => {
const sourceDevice = devices.find(d => d.id === conn.source_device_id);
const targetDevice = devices.find(d => d.id === conn.target_device_id);
return {
...conn,
source_device_name: sourceDevice ? sourceDevice.name : 'Unknown',
target_device_name: targetDevice ? targetDevice.name : 'Unknown',
source_device_type: sourceDevice ? sourceDevice.type_name : '',
target_device_type: targetDevice ? targetDevice.type_name : ''
};
});
const columnDefs = [
{
headerName: 'Source Device',
field: 'source_device_name',
editable: true,
sortable: true,
filter: true,
checkboxSelection: true,
headerCheckboxSelection: true,
cellEditor: 'agSelectCellEditor',
cellEditorParams: {
values: devices.map(d => d.name)
}
},
{
headerName: 'Source Port',
field: 'source_port',
editable: true,
sortable: true,
filter: 'agNumberColumnFilter',
valueFormatter: params => `Port ${params.value}`
},
{
headerName: 'Dest Device',
field: 'target_device_name',
editable: true,
sortable: true,
filter: true,
cellEditor: 'agSelectCellEditor',
cellEditorParams: {
values: devices.map(d => d.name)
}
},
{
headerName: 'Dest Port',
field: 'target_port',
editable: true,
sortable: true,
filter: 'agNumberColumnFilter',
valueFormatter: params => `Port ${params.value}`
},
{
headerName: 'Status',
field: 'status',
editable: false,
sortable: true,
valueGetter: params => {
// Validate connection
const sourceDevice = devices.find(d => d.id === params.data.source_device_id);
const targetDevice = devices.find(d => d.id === params.data.target_device_id);
if (!sourceDevice || !targetDevice) return 'Invalid';
if (params.data.source_port >= sourceDevice.ports_count) return 'Invalid Port';
if (params.data.target_port >= targetDevice.ports_count) return 'Invalid Port';
return 'Valid';
},
cellStyle: params => {
if (params.value === 'Valid') {
return { color: '#4CAF50', fontWeight: 'bold' };
} else {
return { color: '#d32f2f', fontWeight: 'bold' };
}
}
}
];
const gridOptions = {
columnDefs: columnDefs,
rowData: enrichedConnections,
rowSelection: 'multiple',
animateRows: true,
enableCellTextSelection: true,
defaultColDef: {
flex: 1,
minWidth: 120,
resizable: true
},
onCellValueChanged: (params) => this.onConnectionCellValueChanged(params, devices),
onSelectionChanged: () => this.updateToolbarButtons()
};
this.gridApi = agGrid.createGrid(this.tableContainer, gridOptions);
}
async onConnectionCellValueChanged(params, devices) {
const connectionId = params.data.id;
const field = params.colDef.field;
const newValue = params.newValue;
try {
let sourceDeviceId = params.data.source_device_id;
let sourcePort = params.data.source_port;
let targetDeviceId = params.data.target_device_id;
let targetPort = params.data.target_port;
// Update the field that was changed
if (field === 'source_device_name') {
const device = devices.find(d => d.name === newValue);
if (!device) {
alert(`Device "${newValue}" not found.`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
sourceDeviceId = device.id;
params.data.source_device_id = device.id;
params.data.source_device_type = device.type_name;
} else if (field === 'source_port') {
sourcePort = parseInt(newValue);
const sourceDevice = devices.find(d => d.id === sourceDeviceId);
if (sourcePort < 0 || sourcePort >= sourceDevice.ports_count) {
alert(`Invalid source port. Device "${sourceDevice.name}" has ports 0-${sourceDevice.ports_count - 1}.`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
// Check if port is already in use by another connection
const connections = await this.api.getConnections();
const portInUse = connections.some(c =>
c.id !== connectionId &&
((c.source_device_id === sourceDeviceId && c.source_port === sourcePort) ||
(c.target_device_id === sourceDeviceId && c.target_port === sourcePort))
);
if (portInUse) {
alert(`Port ${sourcePort} is already in use on device "${sourceDevice.name}".`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
} else if (field === 'target_device_name') {
const device = devices.find(d => d.name === newValue);
if (!device) {
alert(`Device "${newValue}" not found.`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
targetDeviceId = device.id;
params.data.target_device_id = device.id;
params.data.target_device_type = device.type_name;
} else if (field === 'target_port') {
targetPort = parseInt(newValue);
const targetDevice = devices.find(d => d.id === targetDeviceId);
if (targetPort < 0 || targetPort >= targetDevice.ports_count) {
alert(`Invalid target port. Device "${targetDevice.name}" has ports 0-${targetDevice.ports_count - 1}.`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
// Check if port is already in use by another connection
const connections = await this.api.getConnections();
const portInUse = connections.some(c =>
c.id !== connectionId &&
((c.source_device_id === targetDeviceId && c.source_port === targetPort) ||
(c.target_device_id === targetDeviceId && c.target_port === targetPort))
);
if (portInUse) {
alert(`Port ${targetPort} is already in use on device "${targetDevice.name}".`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
}
// Update connection in database
await this.api.request(`/api/connections/${connectionId}`, {
method: 'PUT',
body: JSON.stringify({
sourceDeviceId,
sourcePort,
targetDeviceId,
targetPort
})
});
// Update canvas - delete and recreate the connection
await this.connectionManager.deleteConnection(connectionId);
const newConnection = await this.api.getConnections();
const updatedConnection = newConnection.find(c => c.id === connectionId);
if (updatedConnection) {
this.connectionManager.createConnectionShape(updatedConnection);
this.connectionManager.layer.batchDraw();
}
// Refresh table to show updated data
this.refreshTable();
} catch (err) {
console.error('Failed to update connection:', err);
alert('Failed to update connection: ' + err.message);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
}
}
// ===== REFRESH & SYNC =====
async refreshTable() {
if (!this.currentTable) return;
const tableType = `${this.currentTable}-table`;
await this.showTable(tableType);
}
async syncFromCanvas() {
// Called when canvas data changes - refresh the table
if (this.isTableVisible()) {
await this.refreshTable();
}
}
// ===== CRUD OPERATIONS =====
async addRow() {
try {
if (this.currentTable === 'racks') {
await this.rackManager.addRack();
await this.refreshTable();
} else if (this.currentTable === 'devices') {
alert('To add a device, please use the canvas view (right-click on a rack).');
} else if (this.currentTable === 'connections') {
alert('To add a connection, please use the canvas view (right-click on a device).');
}
} catch (err) {
console.error('Failed to add row:', err);
alert('Failed to add row: ' + err.message);
}
}
async deleteSelectedRows() {
const selectedRows = this.gridApi.getSelectedRows();
if (selectedRows.length === 0) {
alert('Please select rows to delete.');
return;
}
if (!confirm(`Delete ${selectedRows.length} row(s)?`)) {
return;
}
try {
for (const row of selectedRows) {
if (this.currentTable === 'racks') {
const rackShape = this.rackManager.getRackShape(row.id);
await this.rackManager.deleteRack(row.id, rackShape);
} else if (this.currentTable === 'devices') {
const deviceShape = this.deviceManager.getDeviceShape(row.id);
await this.deviceManager.deleteDevice(row.id, deviceShape);
} else if (this.currentTable === 'connections') {
await this.connectionManager.deleteConnection(row.id);
}
}
await this.refreshTable();
} catch (err) {
console.error('Failed to delete rows:', err);
alert('Failed to delete rows: ' + err.message);
}
}
updateToolbarButtons() {
const deleteBtn = document.getElementById('deleteTableRowBtn');
if (deleteBtn && this.gridApi) {
const selectedRows = this.gridApi.getSelectedRows();
deleteBtn.disabled = selectedRows.length === 0;
}
}
}

547
archive/old_server/db.js Normal file
View File

@@ -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();

View File

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

2123
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@@ -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"
}
}

157
public/css/config.css Normal file
View File

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

642
public/css/style.css Normal file
View File

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

206
public/index.html Normal file
View File

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

1839
public/js/app.js Normal file

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,610 @@
export class DeviceManager {
constructor(layer, api, rackManager) {
this.layer = layer;
this.api = api;
this.rackManager = rackManager;
this.devices = new Map();
this.deviceTypes = [];
this.deviceHeight = 30;
this.deviceSpacing = 5;
this.deviceWidth = 500; // Physical view width
this.currentView = 'physical'; // Track current view
this.contextMenuHandler = null; // Store the current context menu handler
}
async loadDeviceTypes() {
try {
this.deviceTypes = await this.api.getDeviceTypes();
} catch (err) {
console.error('Failed to load device types:', err);
}
}
async loadDevices() {
try {
const devices = await this.api.getDevices();
devices.forEach(deviceData => {
this.createDeviceShape(deviceData);
});
this.layer.batchDraw();
} catch (err) {
console.error('Failed to load devices:', err);
}
}
createDeviceShape(deviceData) {
const rackShape = this.rackManager.getRackShape(deviceData.rack_id);
if (!rackShape) {
console.error('Rack not found for device:', deviceData);
return;
}
const devicesContainer = rackShape.findOne('.devices-container');
// Convert slot position (1-42) to visual Y position
// Slot 1 (U1) is at the bottom, slot 42 (U42) is at the top
const rackData = this.rackManager.getRackData(deviceData.rack_id);
const rackHeight = rackData?.height || this.rackManager.rackHeight;
const maxSlots = 42;
// Calculate device height based on rack_units
const rackUnits = deviceData.rack_units || 1;
const deviceHeight = (this.deviceHeight * rackUnits) + (this.deviceSpacing * (rackUnits - 1));
// Calculate Y position using helper method
const y = this.calculateDeviceY(deviceData.position, rackUnits, rackHeight);
const group = new Konva.Group({
x: 10,
y: y,
draggable: true, // Always draggable
id: `device-${deviceData.id}`
});
// Device rectangle
const rect = new Konva.Rect({
width: this.deviceWidth,
height: deviceHeight,
fill: deviceData.color || '#4A90E2',
stroke: '#333',
strokeWidth: 1,
cornerRadius: 4,
name: 'device-rect'
});
// Device name - set listening to false to let events pass through to group
const text = new Konva.Text({
x: 0,
y: 0,
width: this.deviceWidth,
height: deviceHeight,
text: deviceData.name,
fontSize: 14,
fontStyle: 'bold',
fill: '#fff',
align: 'center',
verticalAlign: 'middle',
padding: 5,
name: 'device-text',
listening: false // Don't intercept events, let them pass to group
});
group.add(rect);
group.add(text);
// Double-click anywhere on device to rename
group.on('dblclick', (e) => {
e.cancelBubble = true;
window.dispatchEvent(new CustomEvent('rename-device', {
detail: { deviceId: deviceData.id, deviceData, deviceShape: group }
}));
});
// Drag and drop between racks
group.on('dragstart', () => {
// Store original parent and position
group.setAttr('originalParent', group.getParent());
group.setAttr('originalPosition', group.position());
group.setAttr('originalRackId', deviceData.rack_id);
// Move to main layer to be on top of everything
const absolutePos = group.getAbsolutePosition();
group.moveTo(this.layer);
group.setAbsolutePosition(absolutePos);
group.moveToTop();
group.opacity(0.7);
});
group.on('dragend', async (e) => {
group.opacity(1);
// Pass the event to get pointer position
await this.handleDeviceDrop(deviceData.id, group, e);
});
// Right-click context menu
group.on('contextmenu', (e) => {
e.evt.preventDefault();
e.cancelBubble = true; // Stop propagation to prevent rack menu
this.showDeviceContextMenu(e, deviceData, group);
});
devicesContainer.add(group);
// Ensure devices-container is always on top of the rack
devicesContainer.moveToTop();
this.devices.set(deviceData.id, { data: deviceData, shape: group });
return group;
}
async addDevice(deviceTypeId, rackId, position, name) {
try {
// Generate unique name if needed
const uniqueName = this.generateUniqueName(name);
const response = await this.api.createDevice(deviceTypeId, rackId, position, uniqueName);
// Reload devices to get full data
const devices = await this.api.getDevices();
const newDevice = devices.find(d => d.id === response.id);
if (newDevice) {
this.createDeviceShape(newDevice);
this.layer.batchDraw();
}
// Notify table to sync
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
return newDevice;
} catch (err) {
console.error('Failed to add device:', err);
throw err;
}
}
async deleteDevice(deviceId, group, suppressEvent = false) {
try {
await this.api.deleteDevice(deviceId);
group.destroy();
this.devices.delete(deviceId);
this.layer.batchDraw();
// Notify table to sync (unless suppressed for bulk operations)
if (!suppressEvent) {
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
}
} catch (err) {
console.error('Failed to delete device:', err);
}
}
showDeviceContextMenu(e, deviceData, group) {
const contextMenu = document.getElementById('contextMenu');
const contextMenuList = document.getElementById('contextMenuList');
contextMenuList.innerHTML = `
<li data-action="connect">Create Connection</li>
<li class="divider"></li>
<li data-action="delete">Delete Device</li>
`;
contextMenu.style.left = `${e.evt.pageX}px`;
contextMenu.style.top = `${e.evt.pageY}px`;
contextMenu.classList.remove('hidden');
// Remove previous event listener if exists
if (this.contextMenuHandler) {
contextMenuList.removeEventListener('click', this.contextMenuHandler);
}
const handleAction = async (evt) => {
const action = evt.target.dataset.action;
if (action === 'delete') {
if (confirm(`Delete device ${deviceData.name}?`)) {
this.deleteDevice(deviceData.id, group);
}
} else if (action === 'connect') {
// Trigger connection creation
window.dispatchEvent(new CustomEvent('create-connection', {
detail: { deviceData, deviceShape: group }
}));
}
contextMenu.classList.add('hidden');
contextMenuList.removeEventListener('click', handleAction);
this.contextMenuHandler = null;
};
// Store and add the new handler
this.contextMenuHandler = handleAction;
contextMenuList.addEventListener('click', handleAction);
}
getNextDevicePosition(rackId, requiredRackUnits = 1) {
// Find the lowest available slot (1-42) that can fit a device with requiredRackUnits
// U1 is at the bottom, so we fill from bottom to top
const usedSlots = new Set();
// Mark ALL slots occupied by each device (accounting for rack_units)
this.devices.forEach(device => {
if (device.data.rack_id === rackId) {
const rackUnits = device.data.rack_units || 1;
// Mark all slots this device occupies
for (let i = 0; i < rackUnits; i++) {
usedSlots.add(device.data.position + i);
}
}
});
// Find first available slot starting from U1 (bottom) that has enough consecutive space
for (let slot = 1; slot <= 42; slot++) {
// Check if this slot and the next (requiredRackUnits - 1) slots are all free
let hasSpace = true;
for (let i = 0; i < requiredRackUnits; i++) {
if (usedSlots.has(slot + i) || (slot + i) > 42) {
hasSpace = false;
break;
}
}
if (hasSpace) {
return slot;
}
}
// If no space found, return next slot after maximum (will overflow)
return 43;
}
getDeviceShape(deviceId) {
const device = this.devices.get(deviceId);
return device ? device.shape : null;
}
getDeviceData(deviceId) {
const device = this.devices.get(deviceId);
return device ? device.data : null;
}
getAllDevices() {
return Array.from(this.devices.values()).map(d => d.data);
}
// Calculate Y position for a device at a given slot with given rack units
calculateDeviceY(position, rackUnits = 1, rackHeight = null) {
const maxSlots = 42;
// Use same margin as left/right (10px)
const topMargin = 10;
// Device at position X with N rack units occupies slots X (bottom) to X+N-1 (top)
const topSlot = position + (rackUnits - 1);
const visualPosition = maxSlots - topSlot;
return topMargin + (visualPosition * (this.deviceHeight + this.deviceSpacing));
}
// Check if a device at a given position with given rack_units conflicts with other devices
// Returns null if no conflict, or a descriptive error message if there is a conflict
checkSlotConflict(rackId, position, rackUnits, excludeDeviceId = null) {
const slotsOccupied = [];
for (let i = 0; i < rackUnits; i++) {
slotsOccupied.push(position + i);
}
// Check all devices in the same rack
const devicesInRack = Array.from(this.devices.values())
.filter(d => d.data.rack_id === rackId && d.data.id !== excludeDeviceId);
for (const device of devicesInRack) {
const deviceRackUnits = device.data.rack_units || 1;
const deviceSlotsOccupied = [];
for (let i = 0; i < deviceRackUnits; i++) {
deviceSlotsOccupied.push(device.data.position + i);
}
// Check for overlap
const overlap = slotsOccupied.some(slot => deviceSlotsOccupied.includes(slot));
if (overlap) {
const conflictSlots = slotsOccupied.filter(slot => deviceSlotsOccupied.includes(slot));
return `Device "${device.data.name}" already occupies slot(s) U${conflictSlots.join(', U')}`;
}
}
return null; // No conflict
}
// Check if a device name already exists (case-insensitive)
isDeviceNameTaken(name, excludeDeviceId = null) {
const nameLower = name.toLowerCase();
return Array.from(this.devices.values()).some(device => {
if (excludeDeviceId && device.data.id === excludeDeviceId) {
return false; // Exclude the device being renamed
}
return device.data.name.toLowerCase() === nameLower;
});
}
// Generate a unique device name by adding _XX suffix
generateUniqueName(baseName) {
// Remove any existing _XX suffix from the base name
const cleanBaseName = baseName.replace(/_\d+$/, '');
// If the clean name is available, use it
if (!this.isDeviceNameTaken(cleanBaseName)) {
return cleanBaseName;
}
// Find the highest existing number suffix
let maxNumber = 0;
const pattern = new RegExp(`^${cleanBaseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}_?(\\d+)$`, 'i');
Array.from(this.devices.values()).forEach(device => {
const match = device.data.name.match(pattern);
if (match) {
const num = parseInt(match[1]) || 0;
if (num > maxNumber) {
maxNumber = num;
}
}
});
// Generate next number with padding
const nextNumber = (maxNumber + 1).toString().padStart(2, '0');
return `${cleanBaseName}_${nextNumber}`;
}
async handleDeviceDrop(deviceId, deviceShape, event) {
const device = this.devices.get(deviceId);
if (!device) return;
// Get the stage and mouse pointer position
const stage = this.layer.getStage();
const pointerPos = stage.getPointerPosition();
if (!pointerPos) {
const originalParent = deviceShape.getAttr('originalParent');
const originalPosition = deviceShape.getAttr('originalPosition');
if (originalParent) {
deviceShape.moveTo(originalParent);
deviceShape.position(originalPosition);
}
this.layer.batchDraw();
return;
}
// Convert pointer position from screen coordinates to world coordinates
// Account for stage position (pan) and scale (zoom)
const scale = stage.scaleX(); // Assumes uniform scaling (scaleX === scaleY)
const stagePos = stage.position();
const worldX = (pointerPos.x - stagePos.x) / scale;
const worldY = (pointerPos.y - stagePos.y) / scale;
const rackUnits = device.data.rack_units || 1;
// Find which rack the pointer is over
let targetRack = null;
let targetRackId = null;
// Convert Map to array to use find() instead of forEach
const racksArray = Array.from(this.rackManager.racks.entries());
for (const [rackId, rack] of racksArray) {
const rackX = rack.data.x;
const rackY = rack.data.y;
const rackWidth = rack.data.width || this.rackManager.rackWidth;
const rackHeight = rack.data.height || this.rackManager.rackHeight;
// Check if world-space pointer is within rack bounds
if (worldX >= rackX && worldX <= rackX + rackWidth &&
worldY >= rackY && worldY <= rackY + rackHeight) {
targetRack = rack;
targetRackId = rackId;
break; // Use first matching rack
}
}
// If not over any rack, return device to original position
if (!targetRack) {
const originalParent = deviceShape.getAttr('originalParent');
const originalPosition = deviceShape.getAttr('originalPosition');
if (originalParent) {
deviceShape.moveTo(originalParent);
deviceShape.position(originalPosition);
}
this.layer.batchDraw();
return;
}
const originalRackId = deviceShape.getAttr('originalRackId') || device.data.rack_id;
// Get the rack shape for later use
const rackShape = targetRack.shape;
// Calculate position within target rack using world coordinates
const rackY = targetRack.data.y;
// Use the world Y position for slot detection
const relativeY = worldY - rackY;
// Convert visual Y to slot position (1-42, where U1 is at bottom)
const maxSlots = 42;
const slotHeight = this.deviceHeight + this.deviceSpacing;
const topMargin = 10;
// Calculate which slot the pointer is in
const visualSlotFromTop = Math.floor((relativeY - topMargin) / slotHeight);
let newPosition = maxSlots - visualSlotFromTop; // Invert: bottom (high Y) = low slot number
newPosition = Math.max(1, Math.min(42, newPosition)); // Clamp to 1-42
// Check for conflicts with existing devices in this rack
// Note: rackUnits already declared at the beginning of this function
const conflict = this.checkSlotConflict(targetRackId, newPosition, rackUnits, deviceId);
if (conflict) {
// Position is occupied, revert to original position
const originalParent = deviceShape.getAttr('originalParent');
const originalPosition = deviceShape.getAttr('originalPosition');
if (originalParent) {
deviceShape.moveTo(originalParent);
deviceShape.position(originalPosition);
}
this.layer.batchDraw();
return;
}
const finalPosition = newPosition;
// Check if device actually moved
if (originalRackId === targetRackId && device.data.position === finalPosition) {
// Device didn't move, but snap it back to proper slot position
const devicesContainer = rackShape.findOne('.devices-container');
deviceShape.moveTo(devicesContainer);
// Recalculate proper Y position to snap to slot
const rackData = this.rackManager.getRackData(targetRackId);
const rackHeight = rackData?.height || this.rackManager.rackHeight;
const correctY = this.calculateDeviceY(finalPosition, rackUnits, rackHeight);
deviceShape.position({ x: 10, y: correctY });
this.layer.batchDraw();
return;
}
try {
// Update device in database
await this.api.request(`/api/devices/${deviceId}/rack`, {
method: 'PUT',
body: JSON.stringify({ rackId: targetRackId, position: finalPosition })
});
// Update local data
device.data.rack_id = targetRackId;
device.data.position = finalPosition;
// Move device to new rack's devices-container
const newDevicesContainer = rackShape.findOne('.devices-container');
deviceShape.moveTo(newDevicesContainer);
// Ensure devices-container is on top within the rack
newDevicesContainer.moveToTop();
// Reposition device using helper method
// Note: rackUnits already declared above
const rackData = this.rackManager.getRackData(targetRackId);
const rackHeight = rackData?.height || this.rackManager.rackHeight;
const newY = this.calculateDeviceY(finalPosition, rackUnits, rackHeight);
deviceShape.position({ x: 10, y: newY });
// NOTE: Removed auto-compacting - it was moving other devices unexpectedly
// Users can manually adjust device positions as needed
this.layer.batchDraw();
// Update connections after device movement
if (this.connectionManager) {
this.connectionManager.updateAllConnections();
}
// Notify table to sync
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
} catch (err) {
console.error('Failed to move device:', err);
// Revert to original position
const originalParent = deviceShape.getAttr('originalParent');
const originalPosition = deviceShape.getAttr('originalPosition');
if (originalParent) {
deviceShape.moveTo(originalParent);
deviceShape.position(originalPosition);
}
this.layer.batchDraw();
}
}
async compactRackDevices(rackId) {
// Get all devices in this rack, sorted by position (1-42)
const devicesInRack = Array.from(this.devices.values())
.filter(d => d.data.rack_id === rackId)
.sort((a, b) => a.data.position - b.data.position);
// Reassign positions to be sequential starting from 1 (U1 = bottom)
const updatePromises = [];
const maxSlots = 42;
devicesInRack.forEach((device, index) => {
const newSlot = index + 1; // Slots start at 1
if (device.data.position !== newSlot) {
device.data.position = newSlot;
// Update visual position using helper method
const rackUnits = device.data.rack_units || 1;
const rackData = this.rackManager.getRackData(rackId);
const rackHeight = rackData?.height || this.rackManager.rackHeight;
const newY = this.calculateDeviceY(newSlot, rackUnits, rackHeight);
device.shape.position({ x: 10, y: newY });
// Update database
updatePromises.push(
this.api.request(`/api/devices/${device.data.id}/rack`, {
method: 'PUT',
body: JSON.stringify({ rackId: rackId, position: newSlot })
})
);
}
});
await Promise.all(updatePromises);
this.layer.batchDraw();
}
updateDevicesDraggability(draggable) {
// Devices are now always draggable, regardless of rack lock state
// This method is kept for compatibility but doesn't change draggability
this.devices.forEach(device => {
device.shape.draggable(true);
});
}
setCurrentView(viewType) {
this.currentView = viewType;
// Set device width based on view
if (viewType === 'logical') {
this.deviceWidth = 200; // Narrower in logical view
} else {
this.deviceWidth = 500; // Normal width in physical view
}
// Resize all existing devices
this.devices.forEach(device => {
const rect = device.shape.findOne('.device-rect');
const text = device.shape.findOne('.device-text');
// In logical view: all devices same size (1U)
// In physical view: size based on rack units
let deviceHeight;
if (viewType === 'logical') {
deviceHeight = this.deviceHeight; // All devices are 1U height in logical view
} else {
const rackUnits = device.data.rack_units || 1;
deviceHeight = (this.deviceHeight * rackUnits) + (this.deviceSpacing * (rackUnits - 1));
}
if (rect) {
rect.width(this.deviceWidth);
rect.height(deviceHeight);
}
if (text) {
text.width(this.deviceWidth);
text.height(deviceHeight);
}
});
}
}

View File

@@ -0,0 +1,487 @@
export class RackManager {
constructor(layer, api, deviceManager) {
this.layer = layer;
this.api = api;
this.deviceManager = deviceManager;
this.racks = new Map();
this.rackPrefix = 'RACK';
this.rackWidth = 520; // Fits 500px wide devices with margins
this.rackHeight = 1485; // Fits 42 devices (42 * 30px + 41 * 5px spacing + 20px margins)
this.rackSpacing = 80;
this.gridSize = 600; // Default: rack width + spacing
this.gridVertical = 1585; // Default: rack height + spacing (1485 + 100)
this.racksLocked = true; // Start with racks locked
this.nextX = 0; // Start at grid origin
this.nextY = 0; // Start at grid origin
this.contextMenuHandler = null; // Store the current context menu handler
// Note: loadSpacing() will be called after project ID is set
}
loadSpacing() {
const projectId = this.api.currentProjectId;
const savedGridSize = localStorage.getItem(`gridSize_${projectId}`);
const savedGridVertical = localStorage.getItem(`gridVertical_${projectId}`);
if (savedGridSize) {
this.gridSize = parseInt(savedGridSize);
this.rackSpacing = this.gridSize - this.rackWidth;
} else {
this.gridSize = 600; // Default: rack width + spacing
}
if (savedGridVertical) {
this.gridVertical = parseInt(savedGridVertical);
} else {
this.gridVertical = 1585; // Default: rack height + spacing (fits 42 devices)
}
}
saveSpacing() {
const projectId = this.api.currentProjectId;
localStorage.setItem(`gridSize_${projectId}`, this.gridSize.toString());
localStorage.setItem(`gridVertical_${projectId}`, this.gridVertical.toString());
}
async toggleRacksLock() {
this.racksLocked = !this.racksLocked;
this.racks.forEach(rack => {
rack.shape.draggable(!this.racksLocked);
});
// Update device draggability
if (this.deviceManager) {
this.deviceManager.updateDevicesDraggability(!this.racksLocked);
}
// If locking, compact the grid (remove empty columns from the left)
if (this.racksLocked) {
await this.compactGrid();
}
return this.racksLocked;
}
async compactGrid() {
if (this.racks.size === 0) return;
// Get all rack positions and calculate their grid coordinates
const rackPositions = [];
this.racks.forEach((rack, id) => {
const gridX = Math.round(rack.data.x / this.gridSize);
const gridY = Math.round(rack.data.y / this.gridVertical);
rackPositions.push({ id, rack, gridX, gridY });
});
// Find the minimum grid X (leftmost column that has racks)
const minGridX = Math.min(...rackPositions.map(r => r.gridX));
// If minGridX is 0, grid is already compact
if (minGridX === 0) return;
// Shift all racks left by minGridX columns
const updatePromises = [];
for (const rackPos of rackPositions) {
const newGridX = rackPos.gridX - minGridX;
const newX = newGridX * this.gridSize;
const newY = rackPos.gridY * this.gridVertical;
// Update visual position
rackPos.rack.shape.position({ x: newX, y: newY });
// Update data
rackPos.rack.data.x = newX;
rackPos.rack.data.y = newY;
// Queue database update
updatePromises.push(this.api.updateRackPosition(rackPos.id, newX, newY));
}
// Redraw once
this.layer.batchDraw();
// Wait for all updates
await Promise.all(updatePromises);
}
snapToGrid(value, gridSize) {
return Math.round(value / gridSize) * gridSize;
}
async loadRacks() {
try {
const racks = await this.api.getRacks();
racks.forEach(rackData => {
this.createRackShape(rackData);
});
this.layer.batchDraw();
} catch (err) {
console.error('Failed to load racks:', err);
}
}
createRackShape(rackData) {
const group = new Konva.Group({
x: rackData.x,
y: rackData.y,
draggable: !this.racksLocked, // Locked by default
id: `rack-${rackData.id}`
});
// Rack background
const rect = new Konva.Rect({
width: rackData.width || this.rackWidth,
height: rackData.height || this.rackHeight,
fill: '#ffffff',
stroke: '#333',
strokeWidth: 2,
shadowColor: 'black',
shadowBlur: 5,
shadowOpacity: 0.1,
shadowOffset: { x: 2, y: 2 }
});
// Rack name label
const nameLabel = new Konva.Text({
x: 0,
y: -30,
width: rackData.width || this.rackWidth,
text: rackData.name,
fontSize: 16,
fontStyle: 'bold',
fill: '#333',
align: 'center',
name: 'rack-name'
});
// Double-click to rename (consistent with device behavior)
nameLabel.on('dblclick', () => {
window.dispatchEvent(new CustomEvent('rename-rack', {
detail: { rackId: rackData.id, rackData, rackShape: group }
}));
});
// Container for devices
const devicesLayer = new Konva.Group({
name: 'devices-container'
});
group.add(rect);
group.add(nameLabel);
group.add(devicesLayer);
// Grid snapping during drag
group.on('dragmove', () => {
const x = this.snapToGrid(group.x(), this.gridSize);
const y = this.snapToGrid(group.y(), this.gridVertical);
group.position({ x, y });
});
// Drag end - update position in DB with smart positioning
group.on('dragend', async () => {
try {
const newX = this.snapToGrid(group.x(), this.gridSize);
const newY = this.snapToGrid(group.y(), this.gridVertical);
// Check if position is occupied by another rack
await this.handleRackPlacement(rackData.id, newX, newY);
} catch (err) {
console.error('Failed to update rack position:', err);
}
});
// Right-click context menu
group.on('contextmenu', (e) => {
e.evt.preventDefault();
this.showContextMenu(e, rackData, group);
});
this.layer.add(group);
this.racks.set(rackData.id, { data: rackData, shape: group });
return group;
}
async addRack() {
try {
const nextName = await this.api.getNextRackName(this.rackPrefix);
const rackData = await this.api.createRack(nextName, this.nextX, this.nextY);
this.createRackShape(rackData);
this.layer.batchDraw();
// Update next position (using grid sizes)
this.nextX += this.gridSize;
if (this.nextX > 1200) {
this.nextX = 0;
this.nextY += this.gridVertical;
}
// Notify table to sync
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
return rackData;
} catch (err) {
console.error('Failed to add rack:', err);
throw err;
}
}
async deleteRack(rackId, group, suppressEvent = false) {
try {
await this.api.deleteRack(rackId);
group.destroy();
this.racks.delete(rackId);
this.layer.batchDraw();
// Notify table to sync (unless suppressed for bulk operations)
if (!suppressEvent) {
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
}
} catch (err) {
console.error('Failed to delete rack:', err);
}
}
showContextMenu(e, rackData, group) {
const contextMenu = document.getElementById('contextMenu');
const contextMenuList = document.getElementById('contextMenuList');
const lockText = this.racksLocked ? 'Unlock All Racks' : 'Lock All Racks';
// Build device types list with header
let deviceTypesHTML = '<li class="menu-header">Add device:</li>';
if (this.deviceManager && this.deviceManager.deviceTypes) {
this.deviceManager.deviceTypes.forEach(type => {
deviceTypesHTML += `<li data-action="add-device" data-device-type-id="${type.id}" data-device-type-name="${type.name}">${type.name}</li>`;
});
}
// Build unlock/management options
let managementHTML = `<li data-action="toggle-lock">${lockText}</li>`;
// Show delete and spacing controls only when unlocked
if (!this.racksLocked) {
const horizontalSpacing = this.gridSize - this.rackWidth;
const verticalSpacing = this.gridVertical - this.rackHeight;
managementHTML += `
<li data-action="delete">Delete Rack</li>
<li class="divider"></li>
<li class="spacing-control">
<span class="spacing-label">Horizontal spacing: ${horizontalSpacing}px</span>
<div class="spacing-buttons">
<button class="spacing-btn" data-action="h-spacing-decrease"></button>
<button class="spacing-btn" data-action="h-spacing-increase">+</button>
</div>
</li>
<li class="spacing-control">
<span class="spacing-label">Vertical spacing: ${verticalSpacing}px</span>
<div class="spacing-buttons">
<button class="spacing-btn" data-action="v-spacing-decrease"></button>
<button class="spacing-btn" data-action="v-spacing-increase">+</button>
</div>
</li>
`;
}
contextMenuList.innerHTML = `
${deviceTypesHTML}
<li class="divider"></li>
${managementHTML}
`;
contextMenu.style.left = `${e.evt.pageX}px`;
contextMenu.style.top = `${e.evt.pageY}px`;
contextMenu.classList.remove('hidden');
// Remove previous event listener if exists
if (this.contextMenuHandler) {
contextMenuList.removeEventListener('click', this.contextMenuHandler);
}
const handleAction = async (evt) => {
const action = evt.target.dataset.action;
// For spacing buttons, prevent default and stop propagation
if (action && action.includes('spacing')) {
evt.preventDefault();
evt.stopPropagation();
}
if (action === 'add-device') {
const deviceTypeId = parseInt(evt.target.dataset.deviceTypeId);
const deviceTypeName = evt.target.dataset.deviceTypeName;
const deviceName = prompt(`Enter name for ${deviceTypeName}:`, deviceTypeName);
if (deviceName) {
try {
// Check if name will be auto-numbered
const uniqueName = this.deviceManager.generateUniqueName(deviceName);
if (uniqueName !== deviceName) {
const proceed = confirm(`Device name "${deviceName}" is already in use.\n\nDevice will be named "${uniqueName}" instead.\n\nContinue?`);
if (!proceed) {
return;
}
}
const position = this.deviceManager.getNextDevicePosition(rackData.id);
await this.deviceManager.addDevice(deviceTypeId, rackData.id, position, deviceName);
} catch (err) {
alert('Failed to add device: ' + err.message);
}
}
} else if (action === 'delete') {
if (confirm(`Delete rack ${rackData.name}?`)) {
this.deleteRack(rackData.id, group);
}
} else if (action === 'toggle-lock') {
const isLocked = await this.toggleRacksLock();
const statusText = isLocked ? 'Racks locked (grid compacted)' : 'Racks unlocked';
// Close and reopen menu to refresh the lock state
contextMenu.classList.add('hidden');
setTimeout(() => {
this.showContextMenu(e, rackData, group);
}, 10);
return; // Don't close menu handler
} else if (action === 'h-spacing-increase') {
await this.adjustSpacing('horizontal', 10);
return; // Don't close menu
} else if (action === 'h-spacing-decrease') {
await this.adjustSpacing('horizontal', -10);
return; // Don't close menu
} else if (action === 'v-spacing-increase') {
await this.adjustSpacing('vertical', 50);
return; // Don't close menu
} else if (action === 'v-spacing-decrease') {
await this.adjustSpacing('vertical', -50);
return; // Don't close menu
}
contextMenu.classList.add('hidden');
contextMenuList.removeEventListener('click', handleAction);
this.contextMenuHandler = null;
};
// Store and add the new handler
this.contextMenuHandler = handleAction;
contextMenuList.addEventListener('click', handleAction);
}
getRackShape(rackId) {
const rack = this.racks.get(rackId);
return rack ? rack.shape : null;
}
getRackData(rackId) {
const rack = this.racks.get(rackId);
return rack ? rack.data : null;
}
async handleRackPlacement(movedRackId, newX, newY) {
// Get all racks in the same row (same Y coordinate)
const racksInRow = [];
this.racks.forEach((rack, id) => {
if (id !== movedRackId && rack.data.y === newY) {
racksInRow.push({ id, rack, x: rack.data.x });
}
});
// Sort by X position
racksInRow.sort((a, b) => a.x - b.x);
// Check if new position is occupied
const occupiedRack = racksInRow.find(r => r.x === newX);
if (occupiedRack) {
// Position is occupied - shift all racks at and to the right of this position
const racksToShift = racksInRow.filter(r => r.x >= newX);
// Shift each rack one grid position to the right
for (const rackInfo of racksToShift) {
const newRackX = rackInfo.x + this.gridSize;
// Update visual position
rackInfo.rack.shape.position({ x: newRackX, y: newY });
// Update data
rackInfo.rack.data.x = newRackX;
rackInfo.rack.data.y = newY;
// Update in database
await this.api.updateRackPosition(rackInfo.id, newRackX, newY);
}
}
// Update the moved rack
const movedRack = this.racks.get(movedRackId);
if (movedRack) {
movedRack.shape.position({ x: newX, y: newY });
movedRack.data.x = newX;
movedRack.data.y = newY;
await this.api.updateRackPosition(movedRackId, newX, newY);
}
// Redraw
this.layer.batchDraw();
}
async adjustSpacing(direction, delta) {
// Calculate grid coordinates for all racks BEFORE changing spacing
const rackGridPositions = new Map();
this.racks.forEach((rack, id) => {
const gridX = Math.round(rack.data.x / this.gridSize);
const gridY = Math.round(rack.data.y / this.gridVertical);
rackGridPositions.set(id, { gridX, gridY });
});
// Adjust spacing (this updates the grid references)
if (direction === 'horizontal') {
const newSpacing = (this.gridSize - this.rackWidth) + delta;
if (newSpacing < 10) return; // Minimum spacing
this.gridSize = this.rackWidth + newSpacing;
this.rackSpacing = newSpacing; // Update the spacing value
} else {
const newSpacing = (this.gridVertical - this.rackHeight) + delta;
if (newSpacing < 10) return; // Minimum spacing
this.gridVertical = this.rackHeight + newSpacing;
}
// Batch all position updates
const updatePromises = [];
// Recalculate all rack positions at once
for (const [id, gridPos] of rackGridPositions) {
const rack = this.racks.get(id);
if (!rack) continue;
const newX = gridPos.gridX * this.gridSize;
const newY = gridPos.gridY * this.gridVertical;
// Update visual position
rack.shape.position({ x: newX, y: newY });
// Update data
rack.data.x = newX;
rack.data.y = newY;
// Queue database update (don't await yet)
updatePromises.push(this.api.updateRackPosition(id, newX, newY));
}
// Redraw once for all changes
this.layer.batchDraw();
// Wait for all database updates to complete
await Promise.all(updatePromises);
// Save spacing to localStorage
this.saveSpacing();
// Update status
const horizontalSpacing = this.gridSize - this.rackWidth;
const verticalSpacing = this.gridVertical - this.rackHeight;
}
}

View File

@@ -0,0 +1,805 @@
export class TableManager {
constructor(api, rackManager, deviceManager, connectionManager) {
this.api = api;
this.rackManager = rackManager;
this.deviceManager = deviceManager;
this.connectionManager = connectionManager;
this.currentTable = null; // 'racks', 'devices', 'connections'
this.gridApi = null;
this.gridColumnApi = null;
this.tableContainer = document.getElementById('tableContent');
}
isTableVisible() {
return this.currentTable !== null;
}
getCurrentTableType() {
return this.currentTable;
}
// Show specific table view
async showTable(tableType) {
// tableType can be: 'racks-table', 'devices-table', 'connections-table'
const tableMap = {
'racks-table': 'racks',
'devices-table': 'devices',
'connections-table': 'connections'
};
this.currentTable = tableMap[tableType];
// Clear existing grid
if (this.gridApi) {
this.gridApi.destroy();
this.gridApi = null;
}
// Clear container to ensure no stale DOM elements
this.tableContainer.innerHTML = '';
// Render appropriate table
switch (this.currentTable) {
case 'racks':
await this.showRacksTable();
break;
case 'devices':
await this.showDevicesTable();
break;
case 'connections':
await this.showConnectionsTable();
break;
}
}
hideTable() {
if (this.gridApi) {
this.gridApi.destroy();
this.gridApi = null;
}
this.currentTable = null;
this.tableContainer.innerHTML = '';
}
// ===== RACKS TABLE =====
async showRacksTable() {
const racks = await this.api.getRacks();
// Sort alphabetically by name
const sortedRacks = racks.sort((a, b) => a.name.localeCompare(b.name));
const columnDefs = [
{
headerName: 'Rack Name',
field: 'name',
editable: true,
sortable: true,
filter: true,
checkboxSelection: true,
headerCheckboxSelection: true
},
{
headerName: 'Position X',
field: 'x',
editable: false,
sortable: true,
valueFormatter: params => `${Math.round(params.value)}px`
},
{
headerName: 'Position Y',
field: 'y',
editable: false,
sortable: true,
valueFormatter: params => `${Math.round(params.value)}px`
},
{
headerName: 'Width',
field: 'width',
editable: false,
sortable: true,
valueFormatter: params => `${params.value}px`
},
{
headerName: 'Height',
field: 'height',
editable: false,
sortable: true,
valueFormatter: params => `${params.value}px`
},
{
headerName: 'Device Count',
field: 'deviceCount',
editable: false,
sortable: true,
valueGetter: params => {
// Count devices in this rack
const devices = this.deviceManager.getAllDevices();
return devices.filter(d => d.rack_id === params.data.id).length;
}
}
];
const gridOptions = {
columnDefs: columnDefs,
rowData: sortedRacks,
rowSelection: 'multiple',
animateRows: true,
enableCellTextSelection: true,
defaultColDef: {
flex: 1,
minWidth: 100,
resizable: true
},
onCellValueChanged: (params) => this.onRackCellValueChanged(params),
onSelectionChanged: () => this.updateToolbarButtons(),
overlayNoRowsTemplate: '<span style="padding: 10px; color: #999;">No racks found</span>'
};
this.gridApi = agGrid.createGrid(this.tableContainer, gridOptions);
}
async onRackCellValueChanged(params) {
const rackId = params.data.id;
const field = params.colDef.field;
const newValue = params.newValue;
try {
if (field === 'name') {
await this.api.updateRackName(rackId, newValue);
// Update canvas
const rackShape = this.rackManager.getRackShape(rackId);
if (rackShape) {
const nameLabel = rackShape.findOne('.rack-name');
if (nameLabel) {
nameLabel.text(newValue);
this.rackManager.layer.batchDraw();
}
}
// Update local data
const rackData = this.rackManager.getRackData(rackId);
if (rackData) {
rackData.name = newValue;
}
}
} catch (err) {
console.error('Failed to update rack:', err);
alert('Failed to update rack: ' + err.message);
// Revert the change
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
}
}
// ===== DEVICES TABLE =====
async showDevicesTable() {
const devices = await this.api.getDevices();
const racks = await this.api.getRacks();
const deviceTypes = await this.api.getDeviceTypes();
const columnDefs = [
{
headerName: 'Device Name',
field: 'name',
editable: true,
sortable: true,
filter: true,
checkboxSelection: true,
headerCheckboxSelection: true
},
{
headerName: 'Type',
field: 'type_name',
editable: true,
sortable: true,
filter: true,
cellEditor: 'agSelectCellEditor',
cellEditorParams: {
values: deviceTypes.map(t => t.name)
}
},
{
headerName: 'Rack',
field: 'rack_name',
editable: true,
sortable: true,
filter: true,
cellEditor: 'agSelectCellEditor',
cellEditorParams: {
values: racks.map(r => r.name)
},
valueGetter: params => {
const rack = racks.find(r => r.id === params.data.rack_id);
return rack ? rack.name : '';
}
},
{
headerName: 'Slot/Position',
field: 'position',
editable: true,
sortable: true,
filter: 'agNumberColumnFilter',
valueFormatter: params => `U${params.value}`,
cellEditor: 'agNumberCellEditor',
cellEditorParams: {
min: 1,
max: 42,
precision: 0
},
valueSetter: params => {
const newValue = parseInt(params.newValue);
if (newValue >= 1 && newValue <= 42) {
params.data.position = newValue;
return true;
}
return false;
}
},
{
headerName: 'Form Factor',
field: 'rack_units',
editable: true,
sortable: true,
filter: 'agNumberColumnFilter',
valueFormatter: params => `${params.value || 1}U`,
cellEditor: 'agNumberCellEditor',
cellEditorParams: {
min: 1,
max: 42,
precision: 0
},
valueSetter: params => {
const newValue = parseInt(params.newValue);
if (newValue >= 1 && newValue <= 42) {
params.data.rack_units = newValue;
return true;
}
return false;
}
},
{
headerName: 'Ports',
field: 'ports_count',
editable: false,
sortable: true,
filter: 'agNumberColumnFilter'
},
{
headerName: 'Color',
field: 'color',
editable: false,
sortable: false,
cellRenderer: params => {
return `<div style="width: 100%; height: 100%; background-color: ${params.value}; border-radius: 3px;"></div>`;
}
},
{
headerName: 'Connections',
field: 'connectionCount',
editable: false,
sortable: true,
valueGetter: params => {
// Count connections for this device
const connections = Array.from(this.connectionManager.connections.values());
return connections.filter(c =>
c.data.source_device_id === params.data.id ||
c.data.target_device_id === params.data.id
).length;
}
}
];
const gridOptions = {
columnDefs: columnDefs,
rowData: devices,
rowSelection: 'multiple',
animateRows: true,
enableCellTextSelection: true,
defaultColDef: {
flex: 1,
minWidth: 100,
resizable: true
},
onCellValueChanged: (params) => this.onDeviceCellValueChanged(params, racks, deviceTypes),
onSelectionChanged: () => this.updateToolbarButtons(),
overlayNoRowsTemplate: '<span style="padding: 10px; color: #999;">No devices found</span>'
};
this.gridApi = agGrid.createGrid(this.tableContainer, gridOptions);
}
async onDeviceCellValueChanged(params, racks, deviceTypes) {
const deviceId = params.data.id;
const field = params.colDef.field;
const newValue = params.newValue;
try {
if (field === 'name') {
// Check if name is already taken
if (this.deviceManager.isDeviceNameTaken(newValue, deviceId)) {
alert(`Device name "${newValue}" is already in use. Please choose a different name.`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
await this.api.updateDeviceName(deviceId, newValue);
// Update canvas
const deviceShape = this.deviceManager.getDeviceShape(deviceId);
if (deviceShape) {
const nameLabel = deviceShape.findOne('.device-text');
if (nameLabel) {
nameLabel.text(newValue);
this.deviceManager.layer.batchDraw();
}
}
// Update local data
const deviceData = this.deviceManager.getDeviceData(deviceId);
if (deviceData) {
deviceData.name = newValue;
}
} else if (field === 'rack_name') {
// Find the rack by name
const rack = racks.find(r => r.name === newValue);
if (rack) {
const newPosition = this.deviceManager.getNextDevicePosition(rack.id);
await this.api.request(`/api/devices/${deviceId}/rack`, {
method: 'PUT',
body: JSON.stringify({ rackId: rack.id, position: newPosition })
});
// Update device on canvas
const deviceShape = this.deviceManager.getDeviceShape(deviceId);
const deviceData = this.deviceManager.getDeviceData(deviceId);
if (deviceShape && deviceData) {
const oldRackId = deviceData.rack_id;
deviceData.rack_id = rack.id;
deviceData.position = newPosition;
// Move to new rack's container
const newRackShape = this.rackManager.getRackShape(rack.id);
if (newRackShape) {
const newDevicesContainer = newRackShape.findOne('.devices-container');
deviceShape.moveTo(newDevicesContainer);
// Calculate visual position using helper method
const rackUnits = deviceData.rack_units || 1;
const rackData = this.rackManager.getRackData(rack.id);
const rackHeight = rackData?.height || this.rackManager.rackHeight;
const newY = this.deviceManager.calculateDeviceY(newPosition, rackUnits, rackHeight);
deviceShape.position({ x: 10, y: newY });
// Compact old rack
if (oldRackId !== rack.id) {
this.deviceManager.compactRackDevices(oldRackId);
}
this.deviceManager.layer.batchDraw();
}
}
// Refresh table to show updated position
this.refreshTable();
}
} else if (field === 'position') {
const rackId = params.data.rack_id;
const newSlot = parseInt(newValue);
const rackUnits = params.data.rack_units || 1;
// Validate slot range (1-42)
if (newSlot < 1 || newSlot > 42) {
alert('Slot position must be between U1 and U42');
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
// Validate that device with its rack_units fits in the rack
if (newSlot + rackUnits - 1 > 42) {
alert(`Device with ${rackUnits}U form factor cannot fit at position U${newSlot}. Maximum position is U${43 - rackUnits}.`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
// Check for slot conflicts with other devices
const conflict = this.deviceManager.checkSlotConflict(rackId, newSlot, rackUnits, deviceId);
if (conflict) {
alert(`Slot conflict detected: ${conflict}`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
await this.api.request(`/api/devices/${deviceId}/rack`, {
method: 'PUT',
body: JSON.stringify({ rackId: rackId, position: newSlot })
});
// Update device position on canvas using helper method
const deviceShape = this.deviceManager.getDeviceShape(deviceId);
const deviceData = this.deviceManager.getDeviceData(deviceId);
if (deviceShape && deviceData) {
deviceData.position = newSlot;
const rackUnits = deviceData.rack_units || 1;
const rackData = this.rackManager.getRackData(rackId);
const rackHeight = rackData?.height || this.rackManager.rackHeight;
const newY = this.deviceManager.calculateDeviceY(newSlot, rackUnits, rackHeight);
deviceShape.position({ x: 10, y: newY });
this.deviceManager.layer.batchDraw();
}
} else if (field === 'rack_units') {
const rackId = params.data.rack_id;
const position = params.data.position;
const newRackUnits = parseInt(newValue);
// Validate that device with its new rack_units fits in the rack
if (position + newRackUnits - 1 > 42) {
alert(`Device with ${newRackUnits}U form factor cannot fit at position U${position}. Maximum form factor at this position is ${43 - position}U.`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
// Check for slot conflicts with other devices
const conflict = this.deviceManager.checkSlotConflict(rackId, position, newRackUnits, deviceId);
if (conflict) {
alert(`Slot conflict detected: ${conflict}`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
await this.api.request(`/api/devices/${deviceId}/rack-units`, {
method: 'PUT',
body: JSON.stringify({ rackUnits: newRackUnits })
});
// Update device rendering on canvas
const deviceShape = this.deviceManager.getDeviceShape(deviceId);
const deviceData = this.deviceManager.getDeviceData(deviceId);
if (deviceShape && deviceData) {
deviceData.rack_units = newRackUnits;
// Update device height
const newHeight = (this.deviceManager.deviceHeight * newRackUnits) + (this.deviceManager.deviceSpacing * (newRackUnits - 1));
const rect = deviceShape.findOne('Rect');
const text = deviceShape.findOne('.device-text');
if (rect) {
rect.height(newHeight);
}
if (text) {
text.height(newHeight);
}
// Reposition device since height changed using helper method
const rackData = this.rackManager.getRackData(rackId);
const rackHeight = rackData?.height || this.rackManager.rackHeight;
const newY = this.deviceManager.calculateDeviceY(position, newRackUnits, rackHeight);
deviceShape.position({ x: 10, y: newY });
this.deviceManager.layer.batchDraw();
}
// Notify canvas that data changed
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
} else if (field === 'type_name') {
// Find device type by name
const deviceType = deviceTypes.find(dt => dt.name === newValue);
if (deviceType) {
// Note: We would need an API endpoint to update device type
// For now, just show a message
alert('Changing device type requires updating the device_type_id in the database. This feature needs backend support.');
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
}
}
} catch (err) {
console.error('Failed to update device:', err);
alert('Failed to update device: ' + err.message);
// Revert the change
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
}
}
// ===== CONNECTIONS TABLE =====
async showConnectionsTable() {
const connections = await this.api.getConnections();
const devices = await this.api.getDevices();
// Enrich connection data with device names
const enrichedConnections = connections.map(conn => {
const sourceDevice = devices.find(d => d.id === conn.source_device_id);
const targetDevice = devices.find(d => d.id === conn.target_device_id);
return {
...conn,
source_device_name: sourceDevice ? sourceDevice.name : 'Unknown',
target_device_name: targetDevice ? targetDevice.name : 'Unknown',
source_device_type: sourceDevice ? sourceDevice.type_name : '',
target_device_type: targetDevice ? targetDevice.type_name : ''
};
});
const columnDefs = [
{
headerName: 'Source Device',
field: 'source_device_name',
editable: true,
sortable: true,
filter: true,
checkboxSelection: true,
headerCheckboxSelection: true,
cellEditor: 'agSelectCellEditor',
cellEditorParams: {
values: devices.map(d => d.name)
}
},
{
headerName: 'Source Port',
field: 'source_port',
editable: true,
sortable: true,
filter: 'agNumberColumnFilter',
valueFormatter: params => `Port ${params.value}`
},
{
headerName: 'Dest Device',
field: 'target_device_name',
editable: true,
sortable: true,
filter: true,
cellEditor: 'agSelectCellEditor',
cellEditorParams: {
values: devices.map(d => d.name)
}
},
{
headerName: 'Dest Port',
field: 'target_port',
editable: true,
sortable: true,
filter: 'agNumberColumnFilter',
valueFormatter: params => `Port ${params.value}`
},
{
headerName: 'Status',
field: 'status',
editable: false,
sortable: true,
valueGetter: params => {
// Validate connection
const sourceDevice = devices.find(d => d.id === params.data.source_device_id);
const targetDevice = devices.find(d => d.id === params.data.target_device_id);
if (!sourceDevice || !targetDevice) return 'Invalid';
if (params.data.source_port >= sourceDevice.ports_count) return 'Invalid Port';
if (params.data.target_port >= targetDevice.ports_count) return 'Invalid Port';
return 'Valid';
},
cellStyle: params => {
if (params.value === 'Valid') {
return { color: '#4CAF50', fontWeight: 'bold' };
} else {
return { color: '#d32f2f', fontWeight: 'bold' };
}
}
}
];
const gridOptions = {
columnDefs: columnDefs,
rowData: enrichedConnections,
rowSelection: 'multiple',
animateRows: true,
enableCellTextSelection: true,
defaultColDef: {
flex: 1,
minWidth: 120,
resizable: true
},
onCellValueChanged: (params) => this.onConnectionCellValueChanged(params, devices),
onSelectionChanged: () => this.updateToolbarButtons(),
overlayNoRowsTemplate: '<span style="padding: 10px; color: #999;">No connections found</span>'
};
this.gridApi = agGrid.createGrid(this.tableContainer, gridOptions);
}
async onConnectionCellValueChanged(params, devices) {
const connectionId = params.data.id;
const field = params.colDef.field;
const newValue = params.newValue;
try {
let sourceDeviceId = params.data.source_device_id;
let sourcePort = params.data.source_port;
let targetDeviceId = params.data.target_device_id;
let targetPort = params.data.target_port;
// Update the field that was changed
if (field === 'source_device_name') {
const device = devices.find(d => d.name === newValue);
if (!device) {
alert(`Device "${newValue}" not found.`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
sourceDeviceId = device.id;
params.data.source_device_id = device.id;
params.data.source_device_type = device.type_name;
} else if (field === 'source_port') {
sourcePort = parseInt(newValue);
const sourceDevice = devices.find(d => d.id === sourceDeviceId);
if (sourcePort < 0 || sourcePort >= sourceDevice.ports_count) {
alert(`Invalid source port. Device "${sourceDevice.name}" has ports 0-${sourceDevice.ports_count - 1}.`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
// Check if port is already in use by another connection
const connections = await this.api.getConnections();
const portInUse = connections.some(c =>
c.id !== connectionId &&
((c.source_device_id === sourceDeviceId && c.source_port === sourcePort) ||
(c.target_device_id === sourceDeviceId && c.target_port === sourcePort))
);
if (portInUse) {
alert(`Port ${sourcePort} is already in use on device "${sourceDevice.name}".`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
} else if (field === 'target_device_name') {
const device = devices.find(d => d.name === newValue);
if (!device) {
alert(`Device "${newValue}" not found.`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
targetDeviceId = device.id;
params.data.target_device_id = device.id;
params.data.target_device_type = device.type_name;
} else if (field === 'target_port') {
targetPort = parseInt(newValue);
const targetDevice = devices.find(d => d.id === targetDeviceId);
if (targetPort < 0 || targetPort >= targetDevice.ports_count) {
alert(`Invalid target port. Device "${targetDevice.name}" has ports 0-${targetDevice.ports_count - 1}.`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
// Check if port is already in use by another connection
const connections = await this.api.getConnections();
const portInUse = connections.some(c =>
c.id !== connectionId &&
((c.source_device_id === targetDeviceId && c.source_port === targetPort) ||
(c.target_device_id === targetDeviceId && c.target_port === targetPort))
);
if (portInUse) {
alert(`Port ${targetPort} is already in use on device "${targetDevice.name}".`);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
return;
}
}
// Update connection in database
await this.api.request(`/api/connections/${connectionId}`, {
method: 'PUT',
body: JSON.stringify({
sourceDeviceId,
sourcePort,
targetDeviceId,
targetPort
})
});
// Update canvas - delete and recreate the connection
await this.connectionManager.deleteConnection(connectionId);
const newConnection = await this.api.getConnections();
const updatedConnection = newConnection.find(c => c.id === connectionId);
if (updatedConnection) {
this.connectionManager.createConnectionShape(updatedConnection);
this.connectionManager.layer.batchDraw();
}
// Refresh table to show updated data
this.refreshTable();
} catch (err) {
console.error('Failed to update connection:', err);
alert('Failed to update connection: ' + err.message);
params.data[field] = params.oldValue;
this.gridApi.refreshCells();
}
}
// ===== REFRESH & SYNC =====
async refreshTable() {
if (!this.currentTable) return;
const tableType = `${this.currentTable}-table`;
await this.showTable(tableType);
}
async syncFromCanvas() {
// Called when canvas data changes - refresh the table
if (this.isTableVisible()) {
await this.refreshTable();
}
}
// ===== CRUD OPERATIONS =====
async addRow() {
try {
if (this.currentTable === 'racks') {
await this.rackManager.addRack();
await this.refreshTable();
} else if (this.currentTable === 'devices') {
alert('To add a device, please use the canvas view (right-click on a rack).');
} else if (this.currentTable === 'connections') {
alert('To add a connection, please use the canvas view (right-click on a device).');
}
} catch (err) {
console.error('Failed to add row:', err);
alert('Failed to add row: ' + err.message);
}
}
async deleteSelectedRows() {
const selectedRows = this.gridApi.getSelectedRows();
if (selectedRows.length === 0) {
alert('Please select rows to delete.');
return;
}
if (!confirm(`Delete ${selectedRows.length} row(s)?`)) {
return;
}
try {
// Delete all rows with suppressed events to avoid race conditions
for (const row of selectedRows) {
if (this.currentTable === 'racks') {
const rackShape = this.rackManager.getRackShape(row.id);
await this.rackManager.deleteRack(row.id, rackShape, true); // suppress event
} else if (this.currentTable === 'devices') {
const deviceShape = this.deviceManager.getDeviceShape(row.id);
await this.deviceManager.deleteDevice(row.id, deviceShape, true); // suppress event
} else if (this.currentTable === 'connections') {
const conn = this.connectionManager.connections.get(row.id);
const line = conn ? conn.shape : null;
const handles = conn ? conn.handles : null;
await this.connectionManager.deleteConnection(row.id, line, handles, true); // suppress event
}
}
// Dispatch single event after all deletions complete
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
await this.refreshTable();
} catch (err) {
console.error('Failed to delete rows:', err);
alert('Failed to delete rows: ' + err.message);
}
}
updateToolbarButtons() {
const deleteBtn = document.getElementById('deleteTableRowBtn');
if (deleteBtn && this.gridApi) {
const selectedRows = this.gridApi.getSelectedRows();
deleteBtn.disabled = selectedRows.length === 0;
}
}
}

116
server/config.js Normal file
View File

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

408
server/db.js Normal file
View File

@@ -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();

View File

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

View File

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

186
server/routes/devices.js Normal file
View File

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

90
server/routes/projects.js Normal file
View File

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

113
server/routes/racks.js Normal file
View File

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

83
server/server.js Normal file
View File

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