First commit
This commit is contained in:
148
.gitignore
vendored
Normal file
148
.gitignore
vendored
Normal 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
827
CLAUDE.md
Normal 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
306
README.md
Normal 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.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## 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**
|
||||
741
archive/old_public/css/style.css
Normal file
741
archive/old_public/css/style.css
Normal 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;
|
||||
}
|
||||
3
archive/old_public/favicon.svg
Normal file
3
archive/old_public/favicon.svg
Normal 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 |
193
archive/old_public/index.html
Normal file
193
archive/old_public/index.html
Normal 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">×</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">×</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">×</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">×</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">×</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
1719
archive/old_public/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
901
archive/old_public/js/connection-manager.js
Normal file
901
archive/old_public/js/connection-manager.js
Normal 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);
|
||||
}
|
||||
}
|
||||
513
archive/old_public/js/device-manager.js
Normal file
513
archive/old_public/js/device-manager.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
488
archive/old_public/js/rack-manager.js
Normal file
488
archive/old_public/js/rack-manager.js
Normal 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;
|
||||
}
|
||||
}
|
||||
792
archive/old_public/js/table-manager.js
Normal file
792
archive/old_public/js/table-manager.js
Normal 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
547
archive/old_server/db.js
Normal 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();
|
||||
276
archive/old_server/server.js
Normal file
276
archive/old_server/server.js
Normal 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
2123
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal 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
157
public/css/config.css
Normal 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
642
public/css/style.css
Normal 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
206
public/index.html
Normal 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">×</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">×</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">×</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">×</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">×</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
1839
public/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
245
public/js/config.js
Normal file
245
public/js/config.js
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Frontend Configuration
|
||||
* Central configuration for all frontend constants and settings
|
||||
*/
|
||||
|
||||
export const CONFIG = {
|
||||
// Rack Configuration
|
||||
RACK: {
|
||||
WIDTH: 520,
|
||||
HEIGHT: 1510,
|
||||
SLOTS: 42,
|
||||
NAME_PREFIX_DEFAULT: 'RACK',
|
||||
// Grid snapping
|
||||
GRID: {
|
||||
HORIZONTAL: 600,
|
||||
VERTICAL: 1610
|
||||
},
|
||||
// Visual styling
|
||||
FILL_COLOR: '#f8f8f8',
|
||||
STROKE_COLOR: '#333',
|
||||
STROKE_WIDTH: 2,
|
||||
// Name label
|
||||
NAME_OFFSET_Y: -25,
|
||||
NAME_FONT_SIZE: 16,
|
||||
NAME_FONT_FAMILY: 'Arial',
|
||||
NAME_COLOR: '#333'
|
||||
},
|
||||
|
||||
// Device Configuration
|
||||
DEVICE: {
|
||||
HEIGHT: 32,
|
||||
SPACING: 2,
|
||||
// Width varies by view
|
||||
WIDTH: {
|
||||
PHYSICAL: 500,
|
||||
LOGICAL: 120
|
||||
},
|
||||
// Margins within rack
|
||||
MARGIN: {
|
||||
TOP: 10,
|
||||
RIGHT: 10,
|
||||
BOTTOM: 10,
|
||||
LEFT: 10
|
||||
},
|
||||
// Visual styling
|
||||
STROKE_WIDTH: 1,
|
||||
CORNER_RADIUS: 4,
|
||||
// Text
|
||||
FONT_SIZE: 13,
|
||||
FONT_FAMILY: 'Arial',
|
||||
TEXT_COLOR: '#fff',
|
||||
// Form factor
|
||||
MIN_RACK_UNITS: 1,
|
||||
MAX_RACK_UNITS: 42
|
||||
},
|
||||
|
||||
// Connection Configuration
|
||||
CONNECTION: {
|
||||
STROKE_WIDTH: 2,
|
||||
STROKE_COLOR: '#4A90E2',
|
||||
STROKE_COLOR_HOVER: '#357ABD',
|
||||
STROKE_COLOR_SELECTED: '#FF6B6B',
|
||||
SELECTED_WIDTH: 3,
|
||||
// Waypoint handles
|
||||
HANDLE_RADIUS: 6,
|
||||
HANDLE_FILL: '#4A90E2',
|
||||
HANDLE_STROKE: '#fff',
|
||||
HANDLE_STROKE_WIDTH: 2,
|
||||
// Hit detection
|
||||
HIT_STROKE_WIDTH: 10
|
||||
},
|
||||
|
||||
// Canvas/Stage Configuration
|
||||
CANVAS: {
|
||||
// Initial position offset
|
||||
INITIAL_OFFSET: { x: 50, y: 50 },
|
||||
// Zoom limits
|
||||
MIN_SCALE: 0.1,
|
||||
MAX_SCALE: 3.0,
|
||||
// Zoom step
|
||||
ZOOM_STEP: 0.1,
|
||||
// Default scale
|
||||
DEFAULT_SCALE: 1.0,
|
||||
// Pan cursor
|
||||
PAN_CURSOR: 'grabbing'
|
||||
},
|
||||
|
||||
// View Configuration
|
||||
VIEWS: {
|
||||
CANVAS: {
|
||||
PHYSICAL: 'physical',
|
||||
LOGICAL: 'logical'
|
||||
},
|
||||
TABLE: {
|
||||
RACKS: 'racks',
|
||||
DEVICES: 'devices',
|
||||
CONNECTIONS: 'connections'
|
||||
}
|
||||
},
|
||||
|
||||
// UI Configuration
|
||||
UI: {
|
||||
// Toolbar height
|
||||
TOOLBAR_HEIGHT: 50,
|
||||
// Table pane
|
||||
TABLE_PANE: {
|
||||
MIN_HEIGHT: 150,
|
||||
DEFAULT_HEIGHT: 300,
|
||||
MAX_HEIGHT_RATIO: 0.7 // 70% of viewport
|
||||
},
|
||||
// Resize handle
|
||||
RESIZE_HANDLE_HEIGHT: 6,
|
||||
// Context menu
|
||||
CONTEXT_MENU: {
|
||||
MIN_WIDTH: 200,
|
||||
ANIMATION_DELAY: 100
|
||||
},
|
||||
// Modals
|
||||
MODAL: {
|
||||
MAX_WIDTH: 600,
|
||||
MAX_WIDTH_LARGE: 800,
|
||||
MAX_HEIGHT_RATIO: 0.8
|
||||
}
|
||||
},
|
||||
|
||||
// Animation Configuration
|
||||
ANIMATION: {
|
||||
DURATION: 200, // ms
|
||||
EASING: 'ease-in-out'
|
||||
},
|
||||
|
||||
// Colors (Theme)
|
||||
COLORS: {
|
||||
PRIMARY: '#4A90E2',
|
||||
PRIMARY_DARK: '#357ABD',
|
||||
SECONDARY: '#f5f5f5',
|
||||
SUCCESS: '#4CAF50',
|
||||
DANGER: '#d32f2f',
|
||||
WARNING: '#ff9800',
|
||||
// Grays
|
||||
GRAY_100: '#f9f9f9',
|
||||
GRAY_200: '#f5f5f5',
|
||||
GRAY_300: '#e0e0e0',
|
||||
GRAY_400: '#d0d0d0',
|
||||
GRAY_500: '#999',
|
||||
GRAY_600: '#666',
|
||||
GRAY_700: '#333',
|
||||
// Backgrounds
|
||||
BG_CANVAS: '#f5f5f5',
|
||||
BG_RACK: '#f8f8f8',
|
||||
BG_MODAL: 'rgba(0, 0, 0, 0.5)',
|
||||
// Selection
|
||||
SELECTION_BG: '#e3f2fd',
|
||||
HOVER_BG: '#f0f7ff'
|
||||
},
|
||||
|
||||
// Keyboard Shortcuts
|
||||
KEYS: {
|
||||
DELETE: ['Delete', 'Backspace'],
|
||||
ESCAPE: 'Escape',
|
||||
CTRL: 'Control',
|
||||
// Future shortcuts
|
||||
UNDO: 'z', // Ctrl+Z
|
||||
REDO: 'y', // Ctrl+Y
|
||||
SAVE: 's', // Ctrl+S
|
||||
SELECT_ALL: 'a', // Ctrl+A
|
||||
COPY: 'c', // Ctrl+C
|
||||
PASTE: 'v', // Ctrl+V
|
||||
FIT_VIEW: 'f' // F key
|
||||
},
|
||||
|
||||
// API Configuration
|
||||
API: {
|
||||
BASE_URL: '', // Same origin
|
||||
TIMEOUT: 30000, // 30 seconds
|
||||
RETRY_ATTEMPTS: 3,
|
||||
RETRY_DELAY: 1000 // 1 second
|
||||
},
|
||||
|
||||
// Local Storage Keys
|
||||
STORAGE: {
|
||||
CURRENT_PROJECT_ID: 'currentProjectId',
|
||||
CURRENT_CANVAS_VIEW: 'currentCanvasView',
|
||||
GRID_SIZE: 'gridSize',
|
||||
GRID_VERTICAL: 'gridVertical',
|
||||
THEME: 'theme', // 'light' or 'dark'
|
||||
ZOOM_LEVEL: 'zoomLevel'
|
||||
},
|
||||
|
||||
// Export/Import
|
||||
EXPORT: {
|
||||
VERSION: '1.0',
|
||||
JSON_INDENT: 2,
|
||||
EXCEL_FORMATS: {
|
||||
DATE: 'yyyy-mm-dd',
|
||||
DATETIME: 'yyyy-mm-dd hh:mm:ss'
|
||||
}
|
||||
},
|
||||
|
||||
// Validation
|
||||
VALIDATION: {
|
||||
PROJECT: {
|
||||
NAME_MIN: 1,
|
||||
NAME_MAX: 100,
|
||||
DESC_MAX: 500
|
||||
},
|
||||
RACK: {
|
||||
NAME_MIN: 1,
|
||||
NAME_MAX: 50,
|
||||
COUNT_MIN: 1,
|
||||
COUNT_MAX: 20
|
||||
},
|
||||
DEVICE: {
|
||||
NAME_MIN: 1,
|
||||
NAME_MAX: 50
|
||||
}
|
||||
},
|
||||
|
||||
// Debug
|
||||
DEBUG: {
|
||||
ENABLED: false, // Set to true for development
|
||||
LOG_API_CALLS: false,
|
||||
LOG_STATE_CHANGES: false,
|
||||
SHOW_FPS: false,
|
||||
KONVA_WARNINGS: true
|
||||
}
|
||||
};
|
||||
|
||||
// Freeze config to prevent modifications
|
||||
Object.freeze(CONFIG);
|
||||
Object.freeze(CONFIG.RACK);
|
||||
Object.freeze(CONFIG.DEVICE);
|
||||
Object.freeze(CONFIG.CONNECTION);
|
||||
Object.freeze(CONFIG.CANVAS);
|
||||
Object.freeze(CONFIG.VIEWS);
|
||||
Object.freeze(CONFIG.UI);
|
||||
Object.freeze(CONFIG.COLORS);
|
||||
Object.freeze(CONFIG.KEYS);
|
||||
Object.freeze(CONFIG.API);
|
||||
Object.freeze(CONFIG.STORAGE);
|
||||
Object.freeze(CONFIG.EXPORT);
|
||||
Object.freeze(CONFIG.VALIDATION);
|
||||
Object.freeze(CONFIG.DEBUG);
|
||||
|
||||
export default CONFIG;
|
||||
217
public/js/lib/api.js
Normal file
217
public/js/lib/api.js
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* API Client
|
||||
* Centralized HTTP client for backend communication
|
||||
*/
|
||||
|
||||
import CONFIG from '../config.js';
|
||||
|
||||
class APIClient {
|
||||
constructor() {
|
||||
this.baseURL = CONFIG.API.BASE_URL;
|
||||
this.timeout = CONFIG.API.TIMEOUT;
|
||||
this.currentProjectId = 1; // Default project
|
||||
}
|
||||
|
||||
/**
|
||||
* Set current project ID
|
||||
*/
|
||||
setProjectId(projectId) {
|
||||
this.currentProjectId = projectId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic HTTP request with error handling
|
||||
* @private
|
||||
*/
|
||||
async request(url, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(this.baseURL + url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
},
|
||||
signal: controller.signal,
|
||||
...options
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error('Request timeout - server is not responding');
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== PROJECTS ====================
|
||||
|
||||
async getProjects() {
|
||||
return this.request('/api/projects');
|
||||
}
|
||||
|
||||
async getProject(id) {
|
||||
return this.request(`/api/projects/${id}`);
|
||||
}
|
||||
|
||||
async createProject(name, description = '') {
|
||||
return this.request('/api/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description })
|
||||
});
|
||||
}
|
||||
|
||||
async updateProject(id, name, description) {
|
||||
return this.request(`/api/projects/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name, description })
|
||||
});
|
||||
}
|
||||
|
||||
async deleteProject(id) {
|
||||
return this.request(`/api/projects/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ==================== RACKS ====================
|
||||
|
||||
async getRacks(projectId = this.currentProjectId) {
|
||||
return this.request(`/api/racks?projectId=${projectId}`);
|
||||
}
|
||||
|
||||
async getNextRackName(prefix = 'RACK', projectId = this.currentProjectId) {
|
||||
const data = await this.request(`/api/racks/next-name?projectId=${projectId}&prefix=${prefix}`);
|
||||
return data.name;
|
||||
}
|
||||
|
||||
async createRack(name, x, y, projectId = this.currentProjectId) {
|
||||
return this.request('/api/racks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ projectId, name, x, y })
|
||||
});
|
||||
}
|
||||
|
||||
async updateRackPosition(id, x, y) {
|
||||
return this.request(`/api/racks/${id}/position`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ x, y })
|
||||
});
|
||||
}
|
||||
|
||||
async updateRackName(id, name) {
|
||||
return this.request(`/api/racks/${id}/name`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
}
|
||||
|
||||
async deleteRack(id) {
|
||||
return this.request(`/api/racks/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ==================== DEVICE TYPES ====================
|
||||
|
||||
async getDeviceTypes() {
|
||||
return this.request('/api/devices/types');
|
||||
}
|
||||
|
||||
// ==================== DEVICES ====================
|
||||
|
||||
async getDevices(projectId = this.currentProjectId) {
|
||||
return this.request(`/api/devices?projectId=${projectId}`);
|
||||
}
|
||||
|
||||
async createDevice(deviceTypeId, rackId, position, name) {
|
||||
return this.request('/api/devices', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ deviceTypeId, rackId, position, name })
|
||||
});
|
||||
}
|
||||
|
||||
async updateDeviceRack(id, rackId, position) {
|
||||
return this.request(`/api/devices/${id}/rack`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ rackId, position })
|
||||
});
|
||||
}
|
||||
|
||||
async updateDeviceLogicalPosition(id, x, y) {
|
||||
return this.request(`/api/devices/${id}/logical-position`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ x, y })
|
||||
});
|
||||
}
|
||||
|
||||
async updateDeviceName(id, name) {
|
||||
return this.request(`/api/devices/${id}/name`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
}
|
||||
|
||||
async updateDeviceRackUnits(id, rackUnits) {
|
||||
return this.request(`/api/devices/${id}/rack-units`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ rackUnits })
|
||||
});
|
||||
}
|
||||
|
||||
async getUsedPorts(deviceId) {
|
||||
return this.request(`/api/devices/${deviceId}/used-ports`);
|
||||
}
|
||||
|
||||
async deleteDevice(id) {
|
||||
return this.request(`/api/devices/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ==================== CONNECTIONS ====================
|
||||
|
||||
async getConnections(projectId = this.currentProjectId) {
|
||||
return this.request(`/api/connections?projectId=${projectId}`);
|
||||
}
|
||||
|
||||
async createConnection(sourceDeviceId, sourcePort, targetDeviceId, targetPort) {
|
||||
return this.request('/api/connections', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sourceDeviceId, sourcePort, targetDeviceId, targetPort })
|
||||
});
|
||||
}
|
||||
|
||||
async updateConnection(id, sourceDeviceId, sourcePort, targetDeviceId, targetPort) {
|
||||
return this.request(`/api/connections/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ sourceDeviceId, sourcePort, targetDeviceId, targetPort })
|
||||
});
|
||||
}
|
||||
|
||||
async updateConnectionWaypoints(id, waypoints, view = null) {
|
||||
return this.request(`/api/connections/${id}/waypoints`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ waypoints, view })
|
||||
});
|
||||
}
|
||||
|
||||
async deleteConnection(id) {
|
||||
return this.request(`/api/connections/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ==================== HEALTH CHECK ====================
|
||||
|
||||
async healthCheck() {
|
||||
return this.request('/api/health');
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export default new APIClient();
|
||||
451
public/js/lib/ui.js
Normal file
451
public/js/lib/ui.js
Normal file
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* UI Utilities
|
||||
* Reusable UI components and helpers
|
||||
*/
|
||||
|
||||
import CONFIG from '../config.js';
|
||||
|
||||
/**
|
||||
* Modal Manager
|
||||
* Simplifies modal open/close operations
|
||||
*/
|
||||
export class ModalManager {
|
||||
/**
|
||||
* Show a modal
|
||||
* @param {string} modalId - ID of modal element
|
||||
* @param {Function} onOpen - Optional callback when modal opens
|
||||
*/
|
||||
static show(modalId, onOpen = null) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (!modal) {
|
||||
console.error(`Modal #${modalId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
if (onOpen) {
|
||||
onOpen(modal);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide a modal
|
||||
* @param {string} modalId - ID of modal element
|
||||
* @param {Function} onClose - Optional callback when modal closes
|
||||
*/
|
||||
static hide(modalId, onClose = null) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (!modal) {
|
||||
console.error(`Modal #${modalId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
modal.classList.add('hidden');
|
||||
|
||||
if (onClose) {
|
||||
onClose(modal);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup modal with close handlers
|
||||
* @param {string} modalId - ID of modal element
|
||||
* @param {string} closeButtonId - ID of close button
|
||||
* @param {Function} onClose - Optional cleanup callback
|
||||
* @returns {Function} Cleanup function
|
||||
*/
|
||||
static setup(modalId, closeButtonId, onClose = null) {
|
||||
const modal = document.getElementById(modalId);
|
||||
const closeBtn = document.getElementById(closeButtonId);
|
||||
|
||||
if (!modal || !closeBtn) {
|
||||
console.error(`Modal #${modalId} or close button #${closeButtonId} not found`);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
this.hide(modalId, onClose);
|
||||
};
|
||||
|
||||
// Close on button click
|
||||
closeBtn.addEventListener('click', handleClose);
|
||||
|
||||
// Close on outside click
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
handleClose();
|
||||
}
|
||||
});
|
||||
|
||||
// Close on ESC key
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
closeBtn.removeEventListener('click', handleClose);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast Notification System
|
||||
* Better than window.alert()
|
||||
*/
|
||||
export class Toast {
|
||||
static container = null;
|
||||
|
||||
/**
|
||||
* Initialize toast container
|
||||
*/
|
||||
static init() {
|
||||
if (this.container) return;
|
||||
|
||||
this.container = document.createElement('div');
|
||||
this.container.id = 'toast-container';
|
||||
this.container.style.cssText = `
|
||||
position: fixed;
|
||||
top: ${CONFIG.UI.TOOLBAR_HEIGHT + 20}px;
|
||||
right: 20px;
|
||||
z-index: ${CONFIG.COLORS.PRIMARY};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
`;
|
||||
document.body.appendChild(this.container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a toast notification
|
||||
* @param {string} message - Message to display
|
||||
* @param {string} type - 'success', 'error', 'warning', 'info'
|
||||
* @param {number} duration - Display duration in ms (0 = persist)
|
||||
*/
|
||||
static show(message, type = 'info', duration = 3000) {
|
||||
this.init();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
|
||||
const colors = {
|
||||
success: CONFIG.COLORS.SUCCESS,
|
||||
error: CONFIG.COLORS.DANGER,
|
||||
warning: CONFIG.COLORS.WARNING,
|
||||
info: CONFIG.COLORS.PRIMARY
|
||||
};
|
||||
|
||||
toast.style.cssText = `
|
||||
background-color: ${colors[type] || colors.info};
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
max-width: 400px;
|
||||
word-wrap: break-word;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
animation: slideIn 0.3s ease;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
toast.textContent = message;
|
||||
|
||||
// Click to dismiss
|
||||
toast.addEventListener('click', () => {
|
||||
this.remove(toast);
|
||||
});
|
||||
|
||||
this.container.appendChild(toast);
|
||||
|
||||
// Auto-remove after duration
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
this.remove(toast);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return toast;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a toast
|
||||
*/
|
||||
static remove(toast) {
|
||||
toast.style.animation = 'slideOut 0.3s ease';
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
static success(message, duration) {
|
||||
return this.show(message, 'success', duration);
|
||||
}
|
||||
|
||||
static error(message, duration) {
|
||||
return this.show(message, 'error', duration);
|
||||
}
|
||||
|
||||
static warning(message, duration) {
|
||||
return this.show(message, 'warning', duration);
|
||||
}
|
||||
|
||||
static info(message, duration) {
|
||||
return this.show(message, 'info', duration);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading Indicator
|
||||
*/
|
||||
export class LoadingIndicator {
|
||||
static overlay = null;
|
||||
|
||||
static show(message = 'Loading...') {
|
||||
if (this.overlay) return;
|
||||
|
||||
this.overlay = document.createElement('div');
|
||||
this.overlay.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
`;
|
||||
|
||||
const spinner = document.createElement('div');
|
||||
spinner.style.cssText = `
|
||||
background-color: white;
|
||||
padding: 30px 40px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
`;
|
||||
spinner.innerHTML = `
|
||||
<div style="margin-bottom: 15px;">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
${message}
|
||||
`;
|
||||
|
||||
this.overlay.appendChild(spinner);
|
||||
document.body.appendChild(this.overlay);
|
||||
}
|
||||
|
||||
static hide() {
|
||||
if (this.overlay && this.overlay.parentNode) {
|
||||
this.overlay.parentNode.removeChild(this.overlay);
|
||||
this.overlay = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmation Dialog
|
||||
* Better than window.confirm()
|
||||
*/
|
||||
export class Confirm {
|
||||
/**
|
||||
* Show confirmation dialog
|
||||
* @param {string} message - Confirmation message
|
||||
* @param {Object} options - Options {title, confirmText, cancelText, onConfirm, onCancel}
|
||||
* @returns {Promise<boolean>} Resolves to true if confirmed
|
||||
*/
|
||||
static async show(message, options = {}) {
|
||||
const {
|
||||
title = 'Confirm',
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
danger = false
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Create modal
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content" style="max-width: 400px;">
|
||||
<div class="modal-header">
|
||||
<h3>${title}</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="margin: 0; font-size: 14px; color: #666;">${message}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary cancel-btn">${cancelText}</button>
|
||||
<button class="btn ${danger ? 'btn-danger' : 'btn-primary'} confirm-btn">${confirmText}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const confirmBtn = modal.querySelector('.confirm-btn');
|
||||
const cancelBtn = modal.querySelector('.cancel-btn');
|
||||
|
||||
const cleanup = () => {
|
||||
if (modal.parentNode) {
|
||||
modal.parentNode.removeChild(modal);
|
||||
}
|
||||
};
|
||||
|
||||
confirmBtn.addEventListener('click', () => {
|
||||
cleanup();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
cleanup();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
cleanup();
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt Dialog
|
||||
* Better than window.prompt()
|
||||
*/
|
||||
export class Prompt {
|
||||
/**
|
||||
* Show prompt dialog
|
||||
* @param {string} message - Prompt message
|
||||
* @param {Object} options - Options {title, defaultValue, placeholder, okText, cancelText}
|
||||
* @returns {Promise<string|null>} Resolves to input value or null if cancelled
|
||||
*/
|
||||
static async show(message, options = {}) {
|
||||
const {
|
||||
title = 'Input Required',
|
||||
defaultValue = '',
|
||||
placeholder = '',
|
||||
okText = 'OK',
|
||||
cancelText = 'Cancel'
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content" style="max-width: 400px;">
|
||||
<div class="modal-header">
|
||||
<h3>${title}</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label style="display: block; margin-bottom: 8px; font-size: 14px; color: #666;">${message}</label>
|
||||
<input type="text" class="form-input prompt-input" value="${defaultValue}" placeholder="${placeholder}" />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary cancel-btn">${cancelText}</button>
|
||||
<button class="btn btn-primary ok-btn">${okText}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const input = modal.querySelector('.prompt-input');
|
||||
const okBtn = modal.querySelector('.ok-btn');
|
||||
const cancelBtn = modal.querySelector('.cancel-btn');
|
||||
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
const cleanup = () => {
|
||||
if (modal.parentNode) {
|
||||
modal.parentNode.removeChild(modal);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const value = input.value.trim();
|
||||
cleanup();
|
||||
resolve(value || null);
|
||||
};
|
||||
|
||||
okBtn.addEventListener('click', submit);
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
submit();
|
||||
} else if (e.key === 'Escape') {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add CSS animations for toasts
|
||||
*/
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOut {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid ${CONFIG.COLORS.PRIMARY};
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Initialize toast system
|
||||
Toast.init();
|
||||
1006
public/js/managers/connection-manager.js
Normal file
1006
public/js/managers/connection-manager.js
Normal file
File diff suppressed because it is too large
Load Diff
610
public/js/managers/device-manager.js
Normal file
610
public/js/managers/device-manager.js
Normal file
@@ -0,0 +1,610 @@
|
||||
export class DeviceManager {
|
||||
constructor(layer, api, rackManager) {
|
||||
this.layer = layer;
|
||||
this.api = api;
|
||||
this.rackManager = rackManager;
|
||||
this.devices = new Map();
|
||||
this.deviceTypes = [];
|
||||
this.deviceHeight = 30;
|
||||
this.deviceSpacing = 5;
|
||||
this.deviceWidth = 500; // Physical view width
|
||||
this.currentView = 'physical'; // Track current view
|
||||
this.contextMenuHandler = null; // Store the current context menu handler
|
||||
}
|
||||
|
||||
async loadDeviceTypes() {
|
||||
try {
|
||||
this.deviceTypes = await this.api.getDeviceTypes();
|
||||
} catch (err) {
|
||||
console.error('Failed to load device types:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async loadDevices() {
|
||||
try {
|
||||
const devices = await this.api.getDevices();
|
||||
devices.forEach(deviceData => {
|
||||
this.createDeviceShape(deviceData);
|
||||
});
|
||||
this.layer.batchDraw();
|
||||
} catch (err) {
|
||||
console.error('Failed to load devices:', err);
|
||||
}
|
||||
}
|
||||
|
||||
createDeviceShape(deviceData) {
|
||||
const rackShape = this.rackManager.getRackShape(deviceData.rack_id);
|
||||
if (!rackShape) {
|
||||
console.error('Rack not found for device:', deviceData);
|
||||
return;
|
||||
}
|
||||
|
||||
const devicesContainer = rackShape.findOne('.devices-container');
|
||||
|
||||
// Convert slot position (1-42) to visual Y position
|
||||
// Slot 1 (U1) is at the bottom, slot 42 (U42) is at the top
|
||||
const rackData = this.rackManager.getRackData(deviceData.rack_id);
|
||||
const rackHeight = rackData?.height || this.rackManager.rackHeight;
|
||||
const maxSlots = 42;
|
||||
|
||||
// Calculate device height based on rack_units
|
||||
const rackUnits = deviceData.rack_units || 1;
|
||||
const deviceHeight = (this.deviceHeight * rackUnits) + (this.deviceSpacing * (rackUnits - 1));
|
||||
|
||||
// Calculate Y position using helper method
|
||||
const y = this.calculateDeviceY(deviceData.position, rackUnits, rackHeight);
|
||||
|
||||
const group = new Konva.Group({
|
||||
x: 10,
|
||||
y: y,
|
||||
draggable: true, // Always draggable
|
||||
id: `device-${deviceData.id}`
|
||||
});
|
||||
|
||||
// Device rectangle
|
||||
const rect = new Konva.Rect({
|
||||
width: this.deviceWidth,
|
||||
height: deviceHeight,
|
||||
fill: deviceData.color || '#4A90E2',
|
||||
stroke: '#333',
|
||||
strokeWidth: 1,
|
||||
cornerRadius: 4,
|
||||
name: 'device-rect'
|
||||
});
|
||||
|
||||
// Device name - set listening to false to let events pass through to group
|
||||
const text = new Konva.Text({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: this.deviceWidth,
|
||||
height: deviceHeight,
|
||||
text: deviceData.name,
|
||||
fontSize: 14,
|
||||
fontStyle: 'bold',
|
||||
fill: '#fff',
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
padding: 5,
|
||||
name: 'device-text',
|
||||
listening: false // Don't intercept events, let them pass to group
|
||||
});
|
||||
|
||||
group.add(rect);
|
||||
group.add(text);
|
||||
|
||||
// Double-click anywhere on device to rename
|
||||
group.on('dblclick', (e) => {
|
||||
e.cancelBubble = true;
|
||||
window.dispatchEvent(new CustomEvent('rename-device', {
|
||||
detail: { deviceId: deviceData.id, deviceData, deviceShape: group }
|
||||
}));
|
||||
});
|
||||
|
||||
// Drag and drop between racks
|
||||
group.on('dragstart', () => {
|
||||
// Store original parent and position
|
||||
group.setAttr('originalParent', group.getParent());
|
||||
group.setAttr('originalPosition', group.position());
|
||||
group.setAttr('originalRackId', deviceData.rack_id);
|
||||
|
||||
// Move to main layer to be on top of everything
|
||||
const absolutePos = group.getAbsolutePosition();
|
||||
group.moveTo(this.layer);
|
||||
group.setAbsolutePosition(absolutePos);
|
||||
group.moveToTop();
|
||||
group.opacity(0.7);
|
||||
});
|
||||
|
||||
group.on('dragend', async (e) => {
|
||||
group.opacity(1);
|
||||
// Pass the event to get pointer position
|
||||
await this.handleDeviceDrop(deviceData.id, group, e);
|
||||
});
|
||||
|
||||
// Right-click context menu
|
||||
group.on('contextmenu', (e) => {
|
||||
e.evt.preventDefault();
|
||||
e.cancelBubble = true; // Stop propagation to prevent rack menu
|
||||
this.showDeviceContextMenu(e, deviceData, group);
|
||||
});
|
||||
|
||||
devicesContainer.add(group);
|
||||
|
||||
// Ensure devices-container is always on top of the rack
|
||||
devicesContainer.moveToTop();
|
||||
|
||||
this.devices.set(deviceData.id, { data: deviceData, shape: group });
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
async addDevice(deviceTypeId, rackId, position, name) {
|
||||
try {
|
||||
// Generate unique name if needed
|
||||
const uniqueName = this.generateUniqueName(name);
|
||||
|
||||
const response = await this.api.createDevice(deviceTypeId, rackId, position, uniqueName);
|
||||
|
||||
// Reload devices to get full data
|
||||
const devices = await this.api.getDevices();
|
||||
const newDevice = devices.find(d => d.id === response.id);
|
||||
|
||||
if (newDevice) {
|
||||
this.createDeviceShape(newDevice);
|
||||
this.layer.batchDraw();
|
||||
}
|
||||
|
||||
// Notify table to sync
|
||||
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
|
||||
|
||||
return newDevice;
|
||||
} catch (err) {
|
||||
console.error('Failed to add device:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteDevice(deviceId, group, suppressEvent = false) {
|
||||
try {
|
||||
await this.api.deleteDevice(deviceId);
|
||||
group.destroy();
|
||||
this.devices.delete(deviceId);
|
||||
this.layer.batchDraw();
|
||||
|
||||
// Notify table to sync (unless suppressed for bulk operations)
|
||||
if (!suppressEvent) {
|
||||
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete device:', err);
|
||||
}
|
||||
}
|
||||
|
||||
showDeviceContextMenu(e, deviceData, group) {
|
||||
const contextMenu = document.getElementById('contextMenu');
|
||||
const contextMenuList = document.getElementById('contextMenuList');
|
||||
|
||||
contextMenuList.innerHTML = `
|
||||
<li data-action="connect">Create Connection</li>
|
||||
<li class="divider"></li>
|
||||
<li data-action="delete">Delete Device</li>
|
||||
`;
|
||||
|
||||
contextMenu.style.left = `${e.evt.pageX}px`;
|
||||
contextMenu.style.top = `${e.evt.pageY}px`;
|
||||
contextMenu.classList.remove('hidden');
|
||||
|
||||
// Remove previous event listener if exists
|
||||
if (this.contextMenuHandler) {
|
||||
contextMenuList.removeEventListener('click', this.contextMenuHandler);
|
||||
}
|
||||
|
||||
const handleAction = async (evt) => {
|
||||
const action = evt.target.dataset.action;
|
||||
if (action === 'delete') {
|
||||
if (confirm(`Delete device ${deviceData.name}?`)) {
|
||||
this.deleteDevice(deviceData.id, group);
|
||||
}
|
||||
} else if (action === 'connect') {
|
||||
// Trigger connection creation
|
||||
window.dispatchEvent(new CustomEvent('create-connection', {
|
||||
detail: { deviceData, deviceShape: group }
|
||||
}));
|
||||
}
|
||||
contextMenu.classList.add('hidden');
|
||||
contextMenuList.removeEventListener('click', handleAction);
|
||||
this.contextMenuHandler = null;
|
||||
};
|
||||
|
||||
// Store and add the new handler
|
||||
this.contextMenuHandler = handleAction;
|
||||
contextMenuList.addEventListener('click', handleAction);
|
||||
}
|
||||
|
||||
getNextDevicePosition(rackId, requiredRackUnits = 1) {
|
||||
// Find the lowest available slot (1-42) that can fit a device with requiredRackUnits
|
||||
// U1 is at the bottom, so we fill from bottom to top
|
||||
const usedSlots = new Set();
|
||||
|
||||
// Mark ALL slots occupied by each device (accounting for rack_units)
|
||||
this.devices.forEach(device => {
|
||||
if (device.data.rack_id === rackId) {
|
||||
const rackUnits = device.data.rack_units || 1;
|
||||
// Mark all slots this device occupies
|
||||
for (let i = 0; i < rackUnits; i++) {
|
||||
usedSlots.add(device.data.position + i);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Find first available slot starting from U1 (bottom) that has enough consecutive space
|
||||
for (let slot = 1; slot <= 42; slot++) {
|
||||
// Check if this slot and the next (requiredRackUnits - 1) slots are all free
|
||||
let hasSpace = true;
|
||||
for (let i = 0; i < requiredRackUnits; i++) {
|
||||
if (usedSlots.has(slot + i) || (slot + i) > 42) {
|
||||
hasSpace = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasSpace) {
|
||||
return slot;
|
||||
}
|
||||
}
|
||||
|
||||
// If no space found, return next slot after maximum (will overflow)
|
||||
return 43;
|
||||
}
|
||||
|
||||
getDeviceShape(deviceId) {
|
||||
const device = this.devices.get(deviceId);
|
||||
return device ? device.shape : null;
|
||||
}
|
||||
|
||||
getDeviceData(deviceId) {
|
||||
const device = this.devices.get(deviceId);
|
||||
return device ? device.data : null;
|
||||
}
|
||||
|
||||
getAllDevices() {
|
||||
return Array.from(this.devices.values()).map(d => d.data);
|
||||
}
|
||||
|
||||
// Calculate Y position for a device at a given slot with given rack units
|
||||
calculateDeviceY(position, rackUnits = 1, rackHeight = null) {
|
||||
const maxSlots = 42;
|
||||
|
||||
// Use same margin as left/right (10px)
|
||||
const topMargin = 10;
|
||||
|
||||
// Device at position X with N rack units occupies slots X (bottom) to X+N-1 (top)
|
||||
const topSlot = position + (rackUnits - 1);
|
||||
const visualPosition = maxSlots - topSlot;
|
||||
|
||||
return topMargin + (visualPosition * (this.deviceHeight + this.deviceSpacing));
|
||||
}
|
||||
|
||||
// Check if a device at a given position with given rack_units conflicts with other devices
|
||||
// Returns null if no conflict, or a descriptive error message if there is a conflict
|
||||
checkSlotConflict(rackId, position, rackUnits, excludeDeviceId = null) {
|
||||
const slotsOccupied = [];
|
||||
for (let i = 0; i < rackUnits; i++) {
|
||||
slotsOccupied.push(position + i);
|
||||
}
|
||||
|
||||
// Check all devices in the same rack
|
||||
const devicesInRack = Array.from(this.devices.values())
|
||||
.filter(d => d.data.rack_id === rackId && d.data.id !== excludeDeviceId);
|
||||
|
||||
for (const device of devicesInRack) {
|
||||
const deviceRackUnits = device.data.rack_units || 1;
|
||||
const deviceSlotsOccupied = [];
|
||||
for (let i = 0; i < deviceRackUnits; i++) {
|
||||
deviceSlotsOccupied.push(device.data.position + i);
|
||||
}
|
||||
|
||||
// Check for overlap
|
||||
const overlap = slotsOccupied.some(slot => deviceSlotsOccupied.includes(slot));
|
||||
if (overlap) {
|
||||
const conflictSlots = slotsOccupied.filter(slot => deviceSlotsOccupied.includes(slot));
|
||||
return `Device "${device.data.name}" already occupies slot(s) U${conflictSlots.join(', U')}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null; // No conflict
|
||||
}
|
||||
|
||||
// Check if a device name already exists (case-insensitive)
|
||||
isDeviceNameTaken(name, excludeDeviceId = null) {
|
||||
const nameLower = name.toLowerCase();
|
||||
return Array.from(this.devices.values()).some(device => {
|
||||
if (excludeDeviceId && device.data.id === excludeDeviceId) {
|
||||
return false; // Exclude the device being renamed
|
||||
}
|
||||
return device.data.name.toLowerCase() === nameLower;
|
||||
});
|
||||
}
|
||||
|
||||
// Generate a unique device name by adding _XX suffix
|
||||
generateUniqueName(baseName) {
|
||||
// Remove any existing _XX suffix from the base name
|
||||
const cleanBaseName = baseName.replace(/_\d+$/, '');
|
||||
|
||||
// If the clean name is available, use it
|
||||
if (!this.isDeviceNameTaken(cleanBaseName)) {
|
||||
return cleanBaseName;
|
||||
}
|
||||
|
||||
// Find the highest existing number suffix
|
||||
let maxNumber = 0;
|
||||
const pattern = new RegExp(`^${cleanBaseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}_?(\\d+)$`, 'i');
|
||||
|
||||
Array.from(this.devices.values()).forEach(device => {
|
||||
const match = device.data.name.match(pattern);
|
||||
if (match) {
|
||||
const num = parseInt(match[1]) || 0;
|
||||
if (num > maxNumber) {
|
||||
maxNumber = num;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Generate next number with padding
|
||||
const nextNumber = (maxNumber + 1).toString().padStart(2, '0');
|
||||
return `${cleanBaseName}_${nextNumber}`;
|
||||
}
|
||||
|
||||
async handleDeviceDrop(deviceId, deviceShape, event) {
|
||||
const device = this.devices.get(deviceId);
|
||||
if (!device) return;
|
||||
|
||||
// Get the stage and mouse pointer position
|
||||
const stage = this.layer.getStage();
|
||||
const pointerPos = stage.getPointerPosition();
|
||||
|
||||
if (!pointerPos) {
|
||||
const originalParent = deviceShape.getAttr('originalParent');
|
||||
const originalPosition = deviceShape.getAttr('originalPosition');
|
||||
if (originalParent) {
|
||||
deviceShape.moveTo(originalParent);
|
||||
deviceShape.position(originalPosition);
|
||||
}
|
||||
this.layer.batchDraw();
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert pointer position from screen coordinates to world coordinates
|
||||
// Account for stage position (pan) and scale (zoom)
|
||||
const scale = stage.scaleX(); // Assumes uniform scaling (scaleX === scaleY)
|
||||
const stagePos = stage.position();
|
||||
|
||||
const worldX = (pointerPos.x - stagePos.x) / scale;
|
||||
const worldY = (pointerPos.y - stagePos.y) / scale;
|
||||
|
||||
const rackUnits = device.data.rack_units || 1;
|
||||
|
||||
// Find which rack the pointer is over
|
||||
let targetRack = null;
|
||||
let targetRackId = null;
|
||||
|
||||
// Convert Map to array to use find() instead of forEach
|
||||
const racksArray = Array.from(this.rackManager.racks.entries());
|
||||
|
||||
for (const [rackId, rack] of racksArray) {
|
||||
const rackX = rack.data.x;
|
||||
const rackY = rack.data.y;
|
||||
const rackWidth = rack.data.width || this.rackManager.rackWidth;
|
||||
const rackHeight = rack.data.height || this.rackManager.rackHeight;
|
||||
|
||||
// Check if world-space pointer is within rack bounds
|
||||
if (worldX >= rackX && worldX <= rackX + rackWidth &&
|
||||
worldY >= rackY && worldY <= rackY + rackHeight) {
|
||||
targetRack = rack;
|
||||
targetRackId = rackId;
|
||||
break; // Use first matching rack
|
||||
}
|
||||
}
|
||||
|
||||
// If not over any rack, return device to original position
|
||||
if (!targetRack) {
|
||||
const originalParent = deviceShape.getAttr('originalParent');
|
||||
const originalPosition = deviceShape.getAttr('originalPosition');
|
||||
|
||||
if (originalParent) {
|
||||
deviceShape.moveTo(originalParent);
|
||||
deviceShape.position(originalPosition);
|
||||
}
|
||||
this.layer.batchDraw();
|
||||
return;
|
||||
}
|
||||
|
||||
const originalRackId = deviceShape.getAttr('originalRackId') || device.data.rack_id;
|
||||
|
||||
// Get the rack shape for later use
|
||||
const rackShape = targetRack.shape;
|
||||
|
||||
// Calculate position within target rack using world coordinates
|
||||
const rackY = targetRack.data.y;
|
||||
|
||||
// Use the world Y position for slot detection
|
||||
const relativeY = worldY - rackY;
|
||||
|
||||
// Convert visual Y to slot position (1-42, where U1 is at bottom)
|
||||
const maxSlots = 42;
|
||||
const slotHeight = this.deviceHeight + this.deviceSpacing;
|
||||
const topMargin = 10;
|
||||
|
||||
// Calculate which slot the pointer is in
|
||||
const visualSlotFromTop = Math.floor((relativeY - topMargin) / slotHeight);
|
||||
let newPosition = maxSlots - visualSlotFromTop; // Invert: bottom (high Y) = low slot number
|
||||
newPosition = Math.max(1, Math.min(42, newPosition)); // Clamp to 1-42
|
||||
|
||||
// Check for conflicts with existing devices in this rack
|
||||
// Note: rackUnits already declared at the beginning of this function
|
||||
const conflict = this.checkSlotConflict(targetRackId, newPosition, rackUnits, deviceId);
|
||||
|
||||
if (conflict) {
|
||||
// Position is occupied, revert to original position
|
||||
const originalParent = deviceShape.getAttr('originalParent');
|
||||
const originalPosition = deviceShape.getAttr('originalPosition');
|
||||
|
||||
if (originalParent) {
|
||||
deviceShape.moveTo(originalParent);
|
||||
deviceShape.position(originalPosition);
|
||||
}
|
||||
this.layer.batchDraw();
|
||||
return;
|
||||
}
|
||||
|
||||
const finalPosition = newPosition;
|
||||
|
||||
// Check if device actually moved
|
||||
if (originalRackId === targetRackId && device.data.position === finalPosition) {
|
||||
// Device didn't move, but snap it back to proper slot position
|
||||
const devicesContainer = rackShape.findOne('.devices-container');
|
||||
deviceShape.moveTo(devicesContainer);
|
||||
|
||||
// Recalculate proper Y position to snap to slot
|
||||
const rackData = this.rackManager.getRackData(targetRackId);
|
||||
const rackHeight = rackData?.height || this.rackManager.rackHeight;
|
||||
const correctY = this.calculateDeviceY(finalPosition, rackUnits, rackHeight);
|
||||
deviceShape.position({ x: 10, y: correctY });
|
||||
|
||||
this.layer.batchDraw();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Update device in database
|
||||
await this.api.request(`/api/devices/${deviceId}/rack`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ rackId: targetRackId, position: finalPosition })
|
||||
});
|
||||
|
||||
// Update local data
|
||||
device.data.rack_id = targetRackId;
|
||||
device.data.position = finalPosition;
|
||||
|
||||
// Move device to new rack's devices-container
|
||||
const newDevicesContainer = rackShape.findOne('.devices-container');
|
||||
deviceShape.moveTo(newDevicesContainer);
|
||||
|
||||
// Ensure devices-container is on top within the rack
|
||||
newDevicesContainer.moveToTop();
|
||||
|
||||
// Reposition device using helper method
|
||||
// Note: rackUnits already declared above
|
||||
const rackData = this.rackManager.getRackData(targetRackId);
|
||||
const rackHeight = rackData?.height || this.rackManager.rackHeight;
|
||||
const newY = this.calculateDeviceY(finalPosition, rackUnits, rackHeight);
|
||||
deviceShape.position({ x: 10, y: newY });
|
||||
|
||||
// NOTE: Removed auto-compacting - it was moving other devices unexpectedly
|
||||
// Users can manually adjust device positions as needed
|
||||
|
||||
this.layer.batchDraw();
|
||||
|
||||
// Update connections after device movement
|
||||
if (this.connectionManager) {
|
||||
this.connectionManager.updateAllConnections();
|
||||
}
|
||||
|
||||
// Notify table to sync
|
||||
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to move device:', err);
|
||||
// Revert to original position
|
||||
const originalParent = deviceShape.getAttr('originalParent');
|
||||
const originalPosition = deviceShape.getAttr('originalPosition');
|
||||
|
||||
if (originalParent) {
|
||||
deviceShape.moveTo(originalParent);
|
||||
deviceShape.position(originalPosition);
|
||||
}
|
||||
this.layer.batchDraw();
|
||||
}
|
||||
}
|
||||
|
||||
async compactRackDevices(rackId) {
|
||||
// Get all devices in this rack, sorted by position (1-42)
|
||||
const devicesInRack = Array.from(this.devices.values())
|
||||
.filter(d => d.data.rack_id === rackId)
|
||||
.sort((a, b) => a.data.position - b.data.position);
|
||||
|
||||
// Reassign positions to be sequential starting from 1 (U1 = bottom)
|
||||
const updatePromises = [];
|
||||
const maxSlots = 42;
|
||||
|
||||
devicesInRack.forEach((device, index) => {
|
||||
const newSlot = index + 1; // Slots start at 1
|
||||
|
||||
if (device.data.position !== newSlot) {
|
||||
device.data.position = newSlot;
|
||||
|
||||
// Update visual position using helper method
|
||||
const rackUnits = device.data.rack_units || 1;
|
||||
const rackData = this.rackManager.getRackData(rackId);
|
||||
const rackHeight = rackData?.height || this.rackManager.rackHeight;
|
||||
const newY = this.calculateDeviceY(newSlot, rackUnits, rackHeight);
|
||||
device.shape.position({ x: 10, y: newY });
|
||||
|
||||
// Update database
|
||||
updatePromises.push(
|
||||
this.api.request(`/api/devices/${device.data.id}/rack`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ rackId: rackId, position: newSlot })
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
this.layer.batchDraw();
|
||||
}
|
||||
|
||||
updateDevicesDraggability(draggable) {
|
||||
// Devices are now always draggable, regardless of rack lock state
|
||||
// This method is kept for compatibility but doesn't change draggability
|
||||
this.devices.forEach(device => {
|
||||
device.shape.draggable(true);
|
||||
});
|
||||
}
|
||||
|
||||
setCurrentView(viewType) {
|
||||
this.currentView = viewType;
|
||||
|
||||
// Set device width based on view
|
||||
if (viewType === 'logical') {
|
||||
this.deviceWidth = 200; // Narrower in logical view
|
||||
} else {
|
||||
this.deviceWidth = 500; // Normal width in physical view
|
||||
}
|
||||
|
||||
// Resize all existing devices
|
||||
this.devices.forEach(device => {
|
||||
const rect = device.shape.findOne('.device-rect');
|
||||
const text = device.shape.findOne('.device-text');
|
||||
|
||||
// In logical view: all devices same size (1U)
|
||||
// In physical view: size based on rack units
|
||||
let deviceHeight;
|
||||
if (viewType === 'logical') {
|
||||
deviceHeight = this.deviceHeight; // All devices are 1U height in logical view
|
||||
} else {
|
||||
const rackUnits = device.data.rack_units || 1;
|
||||
deviceHeight = (this.deviceHeight * rackUnits) + (this.deviceSpacing * (rackUnits - 1));
|
||||
}
|
||||
|
||||
if (rect) {
|
||||
rect.width(this.deviceWidth);
|
||||
rect.height(deviceHeight);
|
||||
}
|
||||
if (text) {
|
||||
text.width(this.deviceWidth);
|
||||
text.height(deviceHeight);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
487
public/js/managers/rack-manager.js
Normal file
487
public/js/managers/rack-manager.js
Normal file
@@ -0,0 +1,487 @@
|
||||
export class RackManager {
|
||||
constructor(layer, api, deviceManager) {
|
||||
this.layer = layer;
|
||||
this.api = api;
|
||||
this.deviceManager = deviceManager;
|
||||
this.racks = new Map();
|
||||
this.rackPrefix = 'RACK';
|
||||
this.rackWidth = 520; // Fits 500px wide devices with margins
|
||||
this.rackHeight = 1485; // Fits 42 devices (42 * 30px + 41 * 5px spacing + 20px margins)
|
||||
this.rackSpacing = 80;
|
||||
this.gridSize = 600; // Default: rack width + spacing
|
||||
this.gridVertical = 1585; // Default: rack height + spacing (1485 + 100)
|
||||
this.racksLocked = true; // Start with racks locked
|
||||
this.nextX = 0; // Start at grid origin
|
||||
this.nextY = 0; // Start at grid origin
|
||||
this.contextMenuHandler = null; // Store the current context menu handler
|
||||
// Note: loadSpacing() will be called after project ID is set
|
||||
}
|
||||
|
||||
loadSpacing() {
|
||||
const projectId = this.api.currentProjectId;
|
||||
const savedGridSize = localStorage.getItem(`gridSize_${projectId}`);
|
||||
const savedGridVertical = localStorage.getItem(`gridVertical_${projectId}`);
|
||||
|
||||
if (savedGridSize) {
|
||||
this.gridSize = parseInt(savedGridSize);
|
||||
this.rackSpacing = this.gridSize - this.rackWidth;
|
||||
} else {
|
||||
this.gridSize = 600; // Default: rack width + spacing
|
||||
}
|
||||
|
||||
if (savedGridVertical) {
|
||||
this.gridVertical = parseInt(savedGridVertical);
|
||||
} else {
|
||||
this.gridVertical = 1585; // Default: rack height + spacing (fits 42 devices)
|
||||
}
|
||||
}
|
||||
|
||||
saveSpacing() {
|
||||
const projectId = this.api.currentProjectId;
|
||||
localStorage.setItem(`gridSize_${projectId}`, this.gridSize.toString());
|
||||
localStorage.setItem(`gridVertical_${projectId}`, this.gridVertical.toString());
|
||||
}
|
||||
|
||||
async toggleRacksLock() {
|
||||
this.racksLocked = !this.racksLocked;
|
||||
this.racks.forEach(rack => {
|
||||
rack.shape.draggable(!this.racksLocked);
|
||||
});
|
||||
|
||||
// Update device draggability
|
||||
if (this.deviceManager) {
|
||||
this.deviceManager.updateDevicesDraggability(!this.racksLocked);
|
||||
}
|
||||
|
||||
// If locking, compact the grid (remove empty columns from the left)
|
||||
if (this.racksLocked) {
|
||||
await this.compactGrid();
|
||||
}
|
||||
|
||||
return this.racksLocked;
|
||||
}
|
||||
|
||||
async compactGrid() {
|
||||
if (this.racks.size === 0) return;
|
||||
|
||||
// Get all rack positions and calculate their grid coordinates
|
||||
const rackPositions = [];
|
||||
this.racks.forEach((rack, id) => {
|
||||
const gridX = Math.round(rack.data.x / this.gridSize);
|
||||
const gridY = Math.round(rack.data.y / this.gridVertical);
|
||||
rackPositions.push({ id, rack, gridX, gridY });
|
||||
});
|
||||
|
||||
// Find the minimum grid X (leftmost column that has racks)
|
||||
const minGridX = Math.min(...rackPositions.map(r => r.gridX));
|
||||
|
||||
// If minGridX is 0, grid is already compact
|
||||
if (minGridX === 0) return;
|
||||
|
||||
// Shift all racks left by minGridX columns
|
||||
const updatePromises = [];
|
||||
|
||||
for (const rackPos of rackPositions) {
|
||||
const newGridX = rackPos.gridX - minGridX;
|
||||
const newX = newGridX * this.gridSize;
|
||||
const newY = rackPos.gridY * this.gridVertical;
|
||||
|
||||
// Update visual position
|
||||
rackPos.rack.shape.position({ x: newX, y: newY });
|
||||
|
||||
// Update data
|
||||
rackPos.rack.data.x = newX;
|
||||
rackPos.rack.data.y = newY;
|
||||
|
||||
// Queue database update
|
||||
updatePromises.push(this.api.updateRackPosition(rackPos.id, newX, newY));
|
||||
}
|
||||
|
||||
// Redraw once
|
||||
this.layer.batchDraw();
|
||||
|
||||
// Wait for all updates
|
||||
await Promise.all(updatePromises);
|
||||
}
|
||||
|
||||
snapToGrid(value, gridSize) {
|
||||
return Math.round(value / gridSize) * gridSize;
|
||||
}
|
||||
|
||||
async loadRacks() {
|
||||
try {
|
||||
const racks = await this.api.getRacks();
|
||||
racks.forEach(rackData => {
|
||||
this.createRackShape(rackData);
|
||||
});
|
||||
this.layer.batchDraw();
|
||||
} catch (err) {
|
||||
console.error('Failed to load racks:', err);
|
||||
}
|
||||
}
|
||||
|
||||
createRackShape(rackData) {
|
||||
const group = new Konva.Group({
|
||||
x: rackData.x,
|
||||
y: rackData.y,
|
||||
draggable: !this.racksLocked, // Locked by default
|
||||
id: `rack-${rackData.id}`
|
||||
});
|
||||
|
||||
// Rack background
|
||||
const rect = new Konva.Rect({
|
||||
width: rackData.width || this.rackWidth,
|
||||
height: rackData.height || this.rackHeight,
|
||||
fill: '#ffffff',
|
||||
stroke: '#333',
|
||||
strokeWidth: 2,
|
||||
shadowColor: 'black',
|
||||
shadowBlur: 5,
|
||||
shadowOpacity: 0.1,
|
||||
shadowOffset: { x: 2, y: 2 }
|
||||
});
|
||||
|
||||
// Rack name label
|
||||
const nameLabel = new Konva.Text({
|
||||
x: 0,
|
||||
y: -30,
|
||||
width: rackData.width || this.rackWidth,
|
||||
text: rackData.name,
|
||||
fontSize: 16,
|
||||
fontStyle: 'bold',
|
||||
fill: '#333',
|
||||
align: 'center',
|
||||
name: 'rack-name'
|
||||
});
|
||||
|
||||
// Double-click to rename (consistent with device behavior)
|
||||
nameLabel.on('dblclick', () => {
|
||||
window.dispatchEvent(new CustomEvent('rename-rack', {
|
||||
detail: { rackId: rackData.id, rackData, rackShape: group }
|
||||
}));
|
||||
});
|
||||
|
||||
// Container for devices
|
||||
const devicesLayer = new Konva.Group({
|
||||
name: 'devices-container'
|
||||
});
|
||||
|
||||
group.add(rect);
|
||||
group.add(nameLabel);
|
||||
group.add(devicesLayer);
|
||||
|
||||
// Grid snapping during drag
|
||||
group.on('dragmove', () => {
|
||||
const x = this.snapToGrid(group.x(), this.gridSize);
|
||||
const y = this.snapToGrid(group.y(), this.gridVertical);
|
||||
group.position({ x, y });
|
||||
});
|
||||
|
||||
// Drag end - update position in DB with smart positioning
|
||||
group.on('dragend', async () => {
|
||||
try {
|
||||
const newX = this.snapToGrid(group.x(), this.gridSize);
|
||||
const newY = this.snapToGrid(group.y(), this.gridVertical);
|
||||
|
||||
// Check if position is occupied by another rack
|
||||
await this.handleRackPlacement(rackData.id, newX, newY);
|
||||
} catch (err) {
|
||||
console.error('Failed to update rack position:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// Right-click context menu
|
||||
group.on('contextmenu', (e) => {
|
||||
e.evt.preventDefault();
|
||||
this.showContextMenu(e, rackData, group);
|
||||
});
|
||||
|
||||
this.layer.add(group);
|
||||
this.racks.set(rackData.id, { data: rackData, shape: group });
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
async addRack() {
|
||||
try {
|
||||
const nextName = await this.api.getNextRackName(this.rackPrefix);
|
||||
|
||||
const rackData = await this.api.createRack(nextName, this.nextX, this.nextY);
|
||||
|
||||
this.createRackShape(rackData);
|
||||
this.layer.batchDraw();
|
||||
|
||||
// Update next position (using grid sizes)
|
||||
this.nextX += this.gridSize;
|
||||
if (this.nextX > 1200) {
|
||||
this.nextX = 0;
|
||||
this.nextY += this.gridVertical;
|
||||
}
|
||||
|
||||
// Notify table to sync
|
||||
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
|
||||
|
||||
return rackData;
|
||||
} catch (err) {
|
||||
console.error('Failed to add rack:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteRack(rackId, group, suppressEvent = false) {
|
||||
try {
|
||||
await this.api.deleteRack(rackId);
|
||||
group.destroy();
|
||||
this.racks.delete(rackId);
|
||||
this.layer.batchDraw();
|
||||
|
||||
// Notify table to sync (unless suppressed for bulk operations)
|
||||
if (!suppressEvent) {
|
||||
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete rack:', err);
|
||||
}
|
||||
}
|
||||
|
||||
showContextMenu(e, rackData, group) {
|
||||
const contextMenu = document.getElementById('contextMenu');
|
||||
const contextMenuList = document.getElementById('contextMenuList');
|
||||
|
||||
const lockText = this.racksLocked ? 'Unlock All Racks' : 'Lock All Racks';
|
||||
|
||||
// Build device types list with header
|
||||
let deviceTypesHTML = '<li class="menu-header">Add device:</li>';
|
||||
if (this.deviceManager && this.deviceManager.deviceTypes) {
|
||||
this.deviceManager.deviceTypes.forEach(type => {
|
||||
deviceTypesHTML += `<li data-action="add-device" data-device-type-id="${type.id}" data-device-type-name="${type.name}">${type.name}</li>`;
|
||||
});
|
||||
}
|
||||
|
||||
// Build unlock/management options
|
||||
let managementHTML = `<li data-action="toggle-lock">${lockText}</li>`;
|
||||
|
||||
// Show delete and spacing controls only when unlocked
|
||||
if (!this.racksLocked) {
|
||||
const horizontalSpacing = this.gridSize - this.rackWidth;
|
||||
const verticalSpacing = this.gridVertical - this.rackHeight;
|
||||
|
||||
managementHTML += `
|
||||
<li data-action="delete">Delete Rack</li>
|
||||
<li class="divider"></li>
|
||||
<li class="spacing-control">
|
||||
<span class="spacing-label">Horizontal spacing: ${horizontalSpacing}px</span>
|
||||
<div class="spacing-buttons">
|
||||
<button class="spacing-btn" data-action="h-spacing-decrease">−</button>
|
||||
<button class="spacing-btn" data-action="h-spacing-increase">+</button>
|
||||
</div>
|
||||
</li>
|
||||
<li class="spacing-control">
|
||||
<span class="spacing-label">Vertical spacing: ${verticalSpacing}px</span>
|
||||
<div class="spacing-buttons">
|
||||
<button class="spacing-btn" data-action="v-spacing-decrease">−</button>
|
||||
<button class="spacing-btn" data-action="v-spacing-increase">+</button>
|
||||
</div>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
|
||||
contextMenuList.innerHTML = `
|
||||
${deviceTypesHTML}
|
||||
<li class="divider"></li>
|
||||
${managementHTML}
|
||||
`;
|
||||
|
||||
contextMenu.style.left = `${e.evt.pageX}px`;
|
||||
contextMenu.style.top = `${e.evt.pageY}px`;
|
||||
contextMenu.classList.remove('hidden');
|
||||
|
||||
// Remove previous event listener if exists
|
||||
if (this.contextMenuHandler) {
|
||||
contextMenuList.removeEventListener('click', this.contextMenuHandler);
|
||||
}
|
||||
|
||||
const handleAction = async (evt) => {
|
||||
const action = evt.target.dataset.action;
|
||||
|
||||
// For spacing buttons, prevent default and stop propagation
|
||||
if (action && action.includes('spacing')) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
}
|
||||
|
||||
if (action === 'add-device') {
|
||||
const deviceTypeId = parseInt(evt.target.dataset.deviceTypeId);
|
||||
const deviceTypeName = evt.target.dataset.deviceTypeName;
|
||||
const deviceName = prompt(`Enter name for ${deviceTypeName}:`, deviceTypeName);
|
||||
|
||||
if (deviceName) {
|
||||
try {
|
||||
// Check if name will be auto-numbered
|
||||
const uniqueName = this.deviceManager.generateUniqueName(deviceName);
|
||||
if (uniqueName !== deviceName) {
|
||||
const proceed = confirm(`Device name "${deviceName}" is already in use.\n\nDevice will be named "${uniqueName}" instead.\n\nContinue?`);
|
||||
if (!proceed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const position = this.deviceManager.getNextDevicePosition(rackData.id);
|
||||
await this.deviceManager.addDevice(deviceTypeId, rackData.id, position, deviceName);
|
||||
} catch (err) {
|
||||
alert('Failed to add device: ' + err.message);
|
||||
}
|
||||
}
|
||||
} else if (action === 'delete') {
|
||||
if (confirm(`Delete rack ${rackData.name}?`)) {
|
||||
this.deleteRack(rackData.id, group);
|
||||
}
|
||||
} else if (action === 'toggle-lock') {
|
||||
const isLocked = await this.toggleRacksLock();
|
||||
const statusText = isLocked ? 'Racks locked (grid compacted)' : 'Racks unlocked';
|
||||
// Close and reopen menu to refresh the lock state
|
||||
contextMenu.classList.add('hidden');
|
||||
setTimeout(() => {
|
||||
this.showContextMenu(e, rackData, group);
|
||||
}, 10);
|
||||
return; // Don't close menu handler
|
||||
} else if (action === 'h-spacing-increase') {
|
||||
await this.adjustSpacing('horizontal', 10);
|
||||
return; // Don't close menu
|
||||
} else if (action === 'h-spacing-decrease') {
|
||||
await this.adjustSpacing('horizontal', -10);
|
||||
return; // Don't close menu
|
||||
} else if (action === 'v-spacing-increase') {
|
||||
await this.adjustSpacing('vertical', 50);
|
||||
return; // Don't close menu
|
||||
} else if (action === 'v-spacing-decrease') {
|
||||
await this.adjustSpacing('vertical', -50);
|
||||
return; // Don't close menu
|
||||
}
|
||||
|
||||
contextMenu.classList.add('hidden');
|
||||
contextMenuList.removeEventListener('click', handleAction);
|
||||
this.contextMenuHandler = null;
|
||||
};
|
||||
|
||||
// Store and add the new handler
|
||||
this.contextMenuHandler = handleAction;
|
||||
contextMenuList.addEventListener('click', handleAction);
|
||||
}
|
||||
|
||||
getRackShape(rackId) {
|
||||
const rack = this.racks.get(rackId);
|
||||
return rack ? rack.shape : null;
|
||||
}
|
||||
|
||||
getRackData(rackId) {
|
||||
const rack = this.racks.get(rackId);
|
||||
return rack ? rack.data : null;
|
||||
}
|
||||
|
||||
async handleRackPlacement(movedRackId, newX, newY) {
|
||||
// Get all racks in the same row (same Y coordinate)
|
||||
const racksInRow = [];
|
||||
this.racks.forEach((rack, id) => {
|
||||
if (id !== movedRackId && rack.data.y === newY) {
|
||||
racksInRow.push({ id, rack, x: rack.data.x });
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by X position
|
||||
racksInRow.sort((a, b) => a.x - b.x);
|
||||
|
||||
// Check if new position is occupied
|
||||
const occupiedRack = racksInRow.find(r => r.x === newX);
|
||||
|
||||
if (occupiedRack) {
|
||||
// Position is occupied - shift all racks at and to the right of this position
|
||||
const racksToShift = racksInRow.filter(r => r.x >= newX);
|
||||
|
||||
// Shift each rack one grid position to the right
|
||||
for (const rackInfo of racksToShift) {
|
||||
const newRackX = rackInfo.x + this.gridSize;
|
||||
|
||||
// Update visual position
|
||||
rackInfo.rack.shape.position({ x: newRackX, y: newY });
|
||||
|
||||
// Update data
|
||||
rackInfo.rack.data.x = newRackX;
|
||||
rackInfo.rack.data.y = newY;
|
||||
|
||||
// Update in database
|
||||
await this.api.updateRackPosition(rackInfo.id, newRackX, newY);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the moved rack
|
||||
const movedRack = this.racks.get(movedRackId);
|
||||
if (movedRack) {
|
||||
movedRack.shape.position({ x: newX, y: newY });
|
||||
movedRack.data.x = newX;
|
||||
movedRack.data.y = newY;
|
||||
await this.api.updateRackPosition(movedRackId, newX, newY);
|
||||
}
|
||||
|
||||
// Redraw
|
||||
this.layer.batchDraw();
|
||||
}
|
||||
|
||||
async adjustSpacing(direction, delta) {
|
||||
// Calculate grid coordinates for all racks BEFORE changing spacing
|
||||
const rackGridPositions = new Map();
|
||||
|
||||
this.racks.forEach((rack, id) => {
|
||||
const gridX = Math.round(rack.data.x / this.gridSize);
|
||||
const gridY = Math.round(rack.data.y / this.gridVertical);
|
||||
rackGridPositions.set(id, { gridX, gridY });
|
||||
});
|
||||
|
||||
// Adjust spacing (this updates the grid references)
|
||||
if (direction === 'horizontal') {
|
||||
const newSpacing = (this.gridSize - this.rackWidth) + delta;
|
||||
if (newSpacing < 10) return; // Minimum spacing
|
||||
this.gridSize = this.rackWidth + newSpacing;
|
||||
this.rackSpacing = newSpacing; // Update the spacing value
|
||||
} else {
|
||||
const newSpacing = (this.gridVertical - this.rackHeight) + delta;
|
||||
if (newSpacing < 10) return; // Minimum spacing
|
||||
this.gridVertical = this.rackHeight + newSpacing;
|
||||
}
|
||||
|
||||
// Batch all position updates
|
||||
const updatePromises = [];
|
||||
|
||||
// Recalculate all rack positions at once
|
||||
for (const [id, gridPos] of rackGridPositions) {
|
||||
const rack = this.racks.get(id);
|
||||
if (!rack) continue;
|
||||
|
||||
const newX = gridPos.gridX * this.gridSize;
|
||||
const newY = gridPos.gridY * this.gridVertical;
|
||||
|
||||
// Update visual position
|
||||
rack.shape.position({ x: newX, y: newY });
|
||||
|
||||
// Update data
|
||||
rack.data.x = newX;
|
||||
rack.data.y = newY;
|
||||
|
||||
// Queue database update (don't await yet)
|
||||
updatePromises.push(this.api.updateRackPosition(id, newX, newY));
|
||||
}
|
||||
|
||||
// Redraw once for all changes
|
||||
this.layer.batchDraw();
|
||||
|
||||
// Wait for all database updates to complete
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
// Save spacing to localStorage
|
||||
this.saveSpacing();
|
||||
|
||||
// Update status
|
||||
const horizontalSpacing = this.gridSize - this.rackWidth;
|
||||
const verticalSpacing = this.gridVertical - this.rackHeight;
|
||||
}
|
||||
}
|
||||
805
public/js/managers/table-manager.js
Normal file
805
public/js/managers/table-manager.js
Normal file
@@ -0,0 +1,805 @@
|
||||
export class TableManager {
|
||||
constructor(api, rackManager, deviceManager, connectionManager) {
|
||||
this.api = api;
|
||||
this.rackManager = rackManager;
|
||||
this.deviceManager = deviceManager;
|
||||
this.connectionManager = connectionManager;
|
||||
|
||||
this.currentTable = null; // 'racks', 'devices', 'connections'
|
||||
this.gridApi = null;
|
||||
this.gridColumnApi = null;
|
||||
this.tableContainer = document.getElementById('tableContent');
|
||||
}
|
||||
|
||||
isTableVisible() {
|
||||
return this.currentTable !== null;
|
||||
}
|
||||
|
||||
getCurrentTableType() {
|
||||
return this.currentTable;
|
||||
}
|
||||
|
||||
// Show specific table view
|
||||
async showTable(tableType) {
|
||||
// tableType can be: 'racks-table', 'devices-table', 'connections-table'
|
||||
const tableMap = {
|
||||
'racks-table': 'racks',
|
||||
'devices-table': 'devices',
|
||||
'connections-table': 'connections'
|
||||
};
|
||||
|
||||
this.currentTable = tableMap[tableType];
|
||||
|
||||
// Clear existing grid
|
||||
if (this.gridApi) {
|
||||
this.gridApi.destroy();
|
||||
this.gridApi = null;
|
||||
}
|
||||
|
||||
// Clear container to ensure no stale DOM elements
|
||||
this.tableContainer.innerHTML = '';
|
||||
|
||||
// Render appropriate table
|
||||
switch (this.currentTable) {
|
||||
case 'racks':
|
||||
await this.showRacksTable();
|
||||
break;
|
||||
case 'devices':
|
||||
await this.showDevicesTable();
|
||||
break;
|
||||
case 'connections':
|
||||
await this.showConnectionsTable();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
hideTable() {
|
||||
if (this.gridApi) {
|
||||
this.gridApi.destroy();
|
||||
this.gridApi = null;
|
||||
}
|
||||
this.currentTable = null;
|
||||
this.tableContainer.innerHTML = '';
|
||||
}
|
||||
|
||||
// ===== RACKS TABLE =====
|
||||
async showRacksTable() {
|
||||
const racks = await this.api.getRacks();
|
||||
|
||||
// Sort alphabetically by name
|
||||
const sortedRacks = racks.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const columnDefs = [
|
||||
{
|
||||
headerName: 'Rack Name',
|
||||
field: 'name',
|
||||
editable: true,
|
||||
sortable: true,
|
||||
filter: true,
|
||||
checkboxSelection: true,
|
||||
headerCheckboxSelection: true
|
||||
},
|
||||
{
|
||||
headerName: 'Position X',
|
||||
field: 'x',
|
||||
editable: false,
|
||||
sortable: true,
|
||||
valueFormatter: params => `${Math.round(params.value)}px`
|
||||
},
|
||||
{
|
||||
headerName: 'Position Y',
|
||||
field: 'y',
|
||||
editable: false,
|
||||
sortable: true,
|
||||
valueFormatter: params => `${Math.round(params.value)}px`
|
||||
},
|
||||
{
|
||||
headerName: 'Width',
|
||||
field: 'width',
|
||||
editable: false,
|
||||
sortable: true,
|
||||
valueFormatter: params => `${params.value}px`
|
||||
},
|
||||
{
|
||||
headerName: 'Height',
|
||||
field: 'height',
|
||||
editable: false,
|
||||
sortable: true,
|
||||
valueFormatter: params => `${params.value}px`
|
||||
},
|
||||
{
|
||||
headerName: 'Device Count',
|
||||
field: 'deviceCount',
|
||||
editable: false,
|
||||
sortable: true,
|
||||
valueGetter: params => {
|
||||
// Count devices in this rack
|
||||
const devices = this.deviceManager.getAllDevices();
|
||||
return devices.filter(d => d.rack_id === params.data.id).length;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const gridOptions = {
|
||||
columnDefs: columnDefs,
|
||||
rowData: sortedRacks,
|
||||
rowSelection: 'multiple',
|
||||
animateRows: true,
|
||||
enableCellTextSelection: true,
|
||||
defaultColDef: {
|
||||
flex: 1,
|
||||
minWidth: 100,
|
||||
resizable: true
|
||||
},
|
||||
onCellValueChanged: (params) => this.onRackCellValueChanged(params),
|
||||
onSelectionChanged: () => this.updateToolbarButtons(),
|
||||
overlayNoRowsTemplate: '<span style="padding: 10px; color: #999;">No racks found</span>'
|
||||
};
|
||||
|
||||
this.gridApi = agGrid.createGrid(this.tableContainer, gridOptions);
|
||||
}
|
||||
|
||||
async onRackCellValueChanged(params) {
|
||||
const rackId = params.data.id;
|
||||
const field = params.colDef.field;
|
||||
const newValue = params.newValue;
|
||||
|
||||
try {
|
||||
if (field === 'name') {
|
||||
await this.api.updateRackName(rackId, newValue);
|
||||
|
||||
// Update canvas
|
||||
const rackShape = this.rackManager.getRackShape(rackId);
|
||||
if (rackShape) {
|
||||
const nameLabel = rackShape.findOne('.rack-name');
|
||||
if (nameLabel) {
|
||||
nameLabel.text(newValue);
|
||||
this.rackManager.layer.batchDraw();
|
||||
}
|
||||
}
|
||||
|
||||
// Update local data
|
||||
const rackData = this.rackManager.getRackData(rackId);
|
||||
if (rackData) {
|
||||
rackData.name = newValue;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update rack:', err);
|
||||
alert('Failed to update rack: ' + err.message);
|
||||
// Revert the change
|
||||
params.data[field] = params.oldValue;
|
||||
this.gridApi.refreshCells();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== DEVICES TABLE =====
|
||||
async showDevicesTable() {
|
||||
const devices = await this.api.getDevices();
|
||||
const racks = await this.api.getRacks();
|
||||
const deviceTypes = await this.api.getDeviceTypes();
|
||||
|
||||
const columnDefs = [
|
||||
{
|
||||
headerName: 'Device Name',
|
||||
field: 'name',
|
||||
editable: true,
|
||||
sortable: true,
|
||||
filter: true,
|
||||
checkboxSelection: true,
|
||||
headerCheckboxSelection: true
|
||||
},
|
||||
{
|
||||
headerName: 'Type',
|
||||
field: 'type_name',
|
||||
editable: true,
|
||||
sortable: true,
|
||||
filter: true,
|
||||
cellEditor: 'agSelectCellEditor',
|
||||
cellEditorParams: {
|
||||
values: deviceTypes.map(t => t.name)
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: 'Rack',
|
||||
field: 'rack_name',
|
||||
editable: true,
|
||||
sortable: true,
|
||||
filter: true,
|
||||
cellEditor: 'agSelectCellEditor',
|
||||
cellEditorParams: {
|
||||
values: racks.map(r => r.name)
|
||||
},
|
||||
valueGetter: params => {
|
||||
const rack = racks.find(r => r.id === params.data.rack_id);
|
||||
return rack ? rack.name : '';
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: 'Slot/Position',
|
||||
field: 'position',
|
||||
editable: true,
|
||||
sortable: true,
|
||||
filter: 'agNumberColumnFilter',
|
||||
valueFormatter: params => `U${params.value}`,
|
||||
cellEditor: 'agNumberCellEditor',
|
||||
cellEditorParams: {
|
||||
min: 1,
|
||||
max: 42,
|
||||
precision: 0
|
||||
},
|
||||
valueSetter: params => {
|
||||
const newValue = parseInt(params.newValue);
|
||||
if (newValue >= 1 && newValue <= 42) {
|
||||
params.data.position = newValue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: 'Form Factor',
|
||||
field: 'rack_units',
|
||||
editable: true,
|
||||
sortable: true,
|
||||
filter: 'agNumberColumnFilter',
|
||||
valueFormatter: params => `${params.value || 1}U`,
|
||||
cellEditor: 'agNumberCellEditor',
|
||||
cellEditorParams: {
|
||||
min: 1,
|
||||
max: 42,
|
||||
precision: 0
|
||||
},
|
||||
valueSetter: params => {
|
||||
const newValue = parseInt(params.newValue);
|
||||
if (newValue >= 1 && newValue <= 42) {
|
||||
params.data.rack_units = newValue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: 'Ports',
|
||||
field: 'ports_count',
|
||||
editable: false,
|
||||
sortable: true,
|
||||
filter: 'agNumberColumnFilter'
|
||||
},
|
||||
{
|
||||
headerName: 'Color',
|
||||
field: 'color',
|
||||
editable: false,
|
||||
sortable: false,
|
||||
cellRenderer: params => {
|
||||
return `<div style="width: 100%; height: 100%; background-color: ${params.value}; border-radius: 3px;"></div>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: 'Connections',
|
||||
field: 'connectionCount',
|
||||
editable: false,
|
||||
sortable: true,
|
||||
valueGetter: params => {
|
||||
// Count connections for this device
|
||||
const connections = Array.from(this.connectionManager.connections.values());
|
||||
return connections.filter(c =>
|
||||
c.data.source_device_id === params.data.id ||
|
||||
c.data.target_device_id === params.data.id
|
||||
).length;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const gridOptions = {
|
||||
columnDefs: columnDefs,
|
||||
rowData: devices,
|
||||
rowSelection: 'multiple',
|
||||
animateRows: true,
|
||||
enableCellTextSelection: true,
|
||||
defaultColDef: {
|
||||
flex: 1,
|
||||
minWidth: 100,
|
||||
resizable: true
|
||||
},
|
||||
onCellValueChanged: (params) => this.onDeviceCellValueChanged(params, racks, deviceTypes),
|
||||
onSelectionChanged: () => this.updateToolbarButtons(),
|
||||
overlayNoRowsTemplate: '<span style="padding: 10px; color: #999;">No devices found</span>'
|
||||
};
|
||||
|
||||
this.gridApi = agGrid.createGrid(this.tableContainer, gridOptions);
|
||||
}
|
||||
|
||||
async onDeviceCellValueChanged(params, racks, deviceTypes) {
|
||||
const deviceId = params.data.id;
|
||||
const field = params.colDef.field;
|
||||
const newValue = params.newValue;
|
||||
|
||||
try {
|
||||
if (field === 'name') {
|
||||
// Check if name is already taken
|
||||
if (this.deviceManager.isDeviceNameTaken(newValue, deviceId)) {
|
||||
alert(`Device name "${newValue}" is already in use. Please choose a different name.`);
|
||||
params.data[field] = params.oldValue;
|
||||
this.gridApi.refreshCells();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.api.updateDeviceName(deviceId, newValue);
|
||||
|
||||
// Update canvas
|
||||
const deviceShape = this.deviceManager.getDeviceShape(deviceId);
|
||||
if (deviceShape) {
|
||||
const nameLabel = deviceShape.findOne('.device-text');
|
||||
if (nameLabel) {
|
||||
nameLabel.text(newValue);
|
||||
this.deviceManager.layer.batchDraw();
|
||||
}
|
||||
}
|
||||
|
||||
// Update local data
|
||||
const deviceData = this.deviceManager.getDeviceData(deviceId);
|
||||
if (deviceData) {
|
||||
deviceData.name = newValue;
|
||||
}
|
||||
} else if (field === 'rack_name') {
|
||||
// Find the rack by name
|
||||
const rack = racks.find(r => r.name === newValue);
|
||||
if (rack) {
|
||||
const newPosition = this.deviceManager.getNextDevicePosition(rack.id);
|
||||
await this.api.request(`/api/devices/${deviceId}/rack`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ rackId: rack.id, position: newPosition })
|
||||
});
|
||||
|
||||
// Update device on canvas
|
||||
const deviceShape = this.deviceManager.getDeviceShape(deviceId);
|
||||
const deviceData = this.deviceManager.getDeviceData(deviceId);
|
||||
if (deviceShape && deviceData) {
|
||||
const oldRackId = deviceData.rack_id;
|
||||
deviceData.rack_id = rack.id;
|
||||
deviceData.position = newPosition;
|
||||
|
||||
// Move to new rack's container
|
||||
const newRackShape = this.rackManager.getRackShape(rack.id);
|
||||
if (newRackShape) {
|
||||
const newDevicesContainer = newRackShape.findOne('.devices-container');
|
||||
deviceShape.moveTo(newDevicesContainer);
|
||||
|
||||
// Calculate visual position using helper method
|
||||
const rackUnits = deviceData.rack_units || 1;
|
||||
const rackData = this.rackManager.getRackData(rack.id);
|
||||
const rackHeight = rackData?.height || this.rackManager.rackHeight;
|
||||
const newY = this.deviceManager.calculateDeviceY(newPosition, rackUnits, rackHeight);
|
||||
deviceShape.position({ x: 10, y: newY });
|
||||
|
||||
// Compact old rack
|
||||
if (oldRackId !== rack.id) {
|
||||
this.deviceManager.compactRackDevices(oldRackId);
|
||||
}
|
||||
|
||||
this.deviceManager.layer.batchDraw();
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh table to show updated position
|
||||
this.refreshTable();
|
||||
}
|
||||
} else if (field === 'position') {
|
||||
const rackId = params.data.rack_id;
|
||||
const newSlot = parseInt(newValue);
|
||||
const rackUnits = params.data.rack_units || 1;
|
||||
|
||||
// Validate slot range (1-42)
|
||||
if (newSlot < 1 || newSlot > 42) {
|
||||
alert('Slot position must be between U1 and U42');
|
||||
params.data[field] = params.oldValue;
|
||||
this.gridApi.refreshCells();
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that device with its rack_units fits in the rack
|
||||
if (newSlot + rackUnits - 1 > 42) {
|
||||
alert(`Device with ${rackUnits}U form factor cannot fit at position U${newSlot}. Maximum position is U${43 - rackUnits}.`);
|
||||
params.data[field] = params.oldValue;
|
||||
this.gridApi.refreshCells();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for slot conflicts with other devices
|
||||
const conflict = this.deviceManager.checkSlotConflict(rackId, newSlot, rackUnits, deviceId);
|
||||
if (conflict) {
|
||||
alert(`Slot conflict detected: ${conflict}`);
|
||||
params.data[field] = params.oldValue;
|
||||
this.gridApi.refreshCells();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.api.request(`/api/devices/${deviceId}/rack`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ rackId: rackId, position: newSlot })
|
||||
});
|
||||
|
||||
// Update device position on canvas using helper method
|
||||
const deviceShape = this.deviceManager.getDeviceShape(deviceId);
|
||||
const deviceData = this.deviceManager.getDeviceData(deviceId);
|
||||
if (deviceShape && deviceData) {
|
||||
deviceData.position = newSlot;
|
||||
const rackUnits = deviceData.rack_units || 1;
|
||||
const rackData = this.rackManager.getRackData(rackId);
|
||||
const rackHeight = rackData?.height || this.rackManager.rackHeight;
|
||||
const newY = this.deviceManager.calculateDeviceY(newSlot, rackUnits, rackHeight);
|
||||
deviceShape.position({ x: 10, y: newY });
|
||||
this.deviceManager.layer.batchDraw();
|
||||
}
|
||||
} else if (field === 'rack_units') {
|
||||
const rackId = params.data.rack_id;
|
||||
const position = params.data.position;
|
||||
const newRackUnits = parseInt(newValue);
|
||||
|
||||
// Validate that device with its new rack_units fits in the rack
|
||||
if (position + newRackUnits - 1 > 42) {
|
||||
alert(`Device with ${newRackUnits}U form factor cannot fit at position U${position}. Maximum form factor at this position is ${43 - position}U.`);
|
||||
params.data[field] = params.oldValue;
|
||||
this.gridApi.refreshCells();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for slot conflicts with other devices
|
||||
const conflict = this.deviceManager.checkSlotConflict(rackId, position, newRackUnits, deviceId);
|
||||
if (conflict) {
|
||||
alert(`Slot conflict detected: ${conflict}`);
|
||||
params.data[field] = params.oldValue;
|
||||
this.gridApi.refreshCells();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.api.request(`/api/devices/${deviceId}/rack-units`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ rackUnits: newRackUnits })
|
||||
});
|
||||
|
||||
// Update device rendering on canvas
|
||||
const deviceShape = this.deviceManager.getDeviceShape(deviceId);
|
||||
const deviceData = this.deviceManager.getDeviceData(deviceId);
|
||||
if (deviceShape && deviceData) {
|
||||
deviceData.rack_units = newRackUnits;
|
||||
|
||||
// Update device height
|
||||
const newHeight = (this.deviceManager.deviceHeight * newRackUnits) + (this.deviceManager.deviceSpacing * (newRackUnits - 1));
|
||||
const rect = deviceShape.findOne('Rect');
|
||||
const text = deviceShape.findOne('.device-text');
|
||||
if (rect) {
|
||||
rect.height(newHeight);
|
||||
}
|
||||
if (text) {
|
||||
text.height(newHeight);
|
||||
}
|
||||
|
||||
// Reposition device since height changed using helper method
|
||||
const rackData = this.rackManager.getRackData(rackId);
|
||||
const rackHeight = rackData?.height || this.rackManager.rackHeight;
|
||||
const newY = this.deviceManager.calculateDeviceY(position, newRackUnits, rackHeight);
|
||||
deviceShape.position({ x: 10, y: newY });
|
||||
|
||||
this.deviceManager.layer.batchDraw();
|
||||
}
|
||||
|
||||
// Notify canvas that data changed
|
||||
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
|
||||
} else if (field === 'type_name') {
|
||||
// Find device type by name
|
||||
const deviceType = deviceTypes.find(dt => dt.name === newValue);
|
||||
if (deviceType) {
|
||||
// Note: We would need an API endpoint to update device type
|
||||
// For now, just show a message
|
||||
alert('Changing device type requires updating the device_type_id in the database. This feature needs backend support.');
|
||||
params.data[field] = params.oldValue;
|
||||
this.gridApi.refreshCells();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update device:', err);
|
||||
alert('Failed to update device: ' + err.message);
|
||||
// Revert the change
|
||||
params.data[field] = params.oldValue;
|
||||
this.gridApi.refreshCells();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== CONNECTIONS TABLE =====
|
||||
async showConnectionsTable() {
|
||||
const connections = await this.api.getConnections();
|
||||
const devices = await this.api.getDevices();
|
||||
|
||||
// Enrich connection data with device names
|
||||
const enrichedConnections = connections.map(conn => {
|
||||
const sourceDevice = devices.find(d => d.id === conn.source_device_id);
|
||||
const targetDevice = devices.find(d => d.id === conn.target_device_id);
|
||||
|
||||
return {
|
||||
...conn,
|
||||
source_device_name: sourceDevice ? sourceDevice.name : 'Unknown',
|
||||
target_device_name: targetDevice ? targetDevice.name : 'Unknown',
|
||||
source_device_type: sourceDevice ? sourceDevice.type_name : '',
|
||||
target_device_type: targetDevice ? targetDevice.type_name : ''
|
||||
};
|
||||
});
|
||||
|
||||
const columnDefs = [
|
||||
{
|
||||
headerName: 'Source Device',
|
||||
field: 'source_device_name',
|
||||
editable: true,
|
||||
sortable: true,
|
||||
filter: true,
|
||||
checkboxSelection: true,
|
||||
headerCheckboxSelection: true,
|
||||
cellEditor: 'agSelectCellEditor',
|
||||
cellEditorParams: {
|
||||
values: devices.map(d => d.name)
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: 'Source Port',
|
||||
field: 'source_port',
|
||||
editable: true,
|
||||
sortable: true,
|
||||
filter: 'agNumberColumnFilter',
|
||||
valueFormatter: params => `Port ${params.value}`
|
||||
},
|
||||
{
|
||||
headerName: 'Dest Device',
|
||||
field: 'target_device_name',
|
||||
editable: true,
|
||||
sortable: true,
|
||||
filter: true,
|
||||
cellEditor: 'agSelectCellEditor',
|
||||
cellEditorParams: {
|
||||
values: devices.map(d => d.name)
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: 'Dest Port',
|
||||
field: 'target_port',
|
||||
editable: true,
|
||||
sortable: true,
|
||||
filter: 'agNumberColumnFilter',
|
||||
valueFormatter: params => `Port ${params.value}`
|
||||
},
|
||||
{
|
||||
headerName: 'Status',
|
||||
field: 'status',
|
||||
editable: false,
|
||||
sortable: true,
|
||||
valueGetter: params => {
|
||||
// Validate connection
|
||||
const sourceDevice = devices.find(d => d.id === params.data.source_device_id);
|
||||
const targetDevice = devices.find(d => d.id === params.data.target_device_id);
|
||||
|
||||
if (!sourceDevice || !targetDevice) return 'Invalid';
|
||||
if (params.data.source_port >= sourceDevice.ports_count) return 'Invalid Port';
|
||||
if (params.data.target_port >= targetDevice.ports_count) return 'Invalid Port';
|
||||
|
||||
return 'Valid';
|
||||
},
|
||||
cellStyle: params => {
|
||||
if (params.value === 'Valid') {
|
||||
return { color: '#4CAF50', fontWeight: 'bold' };
|
||||
} else {
|
||||
return { color: '#d32f2f', fontWeight: 'bold' };
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const gridOptions = {
|
||||
columnDefs: columnDefs,
|
||||
rowData: enrichedConnections,
|
||||
rowSelection: 'multiple',
|
||||
animateRows: true,
|
||||
enableCellTextSelection: true,
|
||||
defaultColDef: {
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
resizable: true
|
||||
},
|
||||
onCellValueChanged: (params) => this.onConnectionCellValueChanged(params, devices),
|
||||
onSelectionChanged: () => this.updateToolbarButtons(),
|
||||
overlayNoRowsTemplate: '<span style="padding: 10px; color: #999;">No connections found</span>'
|
||||
};
|
||||
|
||||
this.gridApi = agGrid.createGrid(this.tableContainer, gridOptions);
|
||||
}
|
||||
|
||||
async onConnectionCellValueChanged(params, devices) {
|
||||
const connectionId = params.data.id;
|
||||
const field = params.colDef.field;
|
||||
const newValue = params.newValue;
|
||||
|
||||
try {
|
||||
let sourceDeviceId = params.data.source_device_id;
|
||||
let sourcePort = params.data.source_port;
|
||||
let targetDeviceId = params.data.target_device_id;
|
||||
let targetPort = params.data.target_port;
|
||||
|
||||
// Update the field that was changed
|
||||
if (field === 'source_device_name') {
|
||||
const device = devices.find(d => d.name === newValue);
|
||||
if (!device) {
|
||||
alert(`Device "${newValue}" not found.`);
|
||||
params.data[field] = params.oldValue;
|
||||
this.gridApi.refreshCells();
|
||||
return;
|
||||
}
|
||||
sourceDeviceId = device.id;
|
||||
params.data.source_device_id = device.id;
|
||||
params.data.source_device_type = device.type_name;
|
||||
} else if (field === 'source_port') {
|
||||
sourcePort = parseInt(newValue);
|
||||
const sourceDevice = devices.find(d => d.id === sourceDeviceId);
|
||||
if (sourcePort < 0 || sourcePort >= sourceDevice.ports_count) {
|
||||
alert(`Invalid source port. Device "${sourceDevice.name}" has ports 0-${sourceDevice.ports_count - 1}.`);
|
||||
params.data[field] = params.oldValue;
|
||||
this.gridApi.refreshCells();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if port is already in use by another connection
|
||||
const connections = await this.api.getConnections();
|
||||
const portInUse = connections.some(c =>
|
||||
c.id !== connectionId &&
|
||||
((c.source_device_id === sourceDeviceId && c.source_port === sourcePort) ||
|
||||
(c.target_device_id === sourceDeviceId && c.target_port === sourcePort))
|
||||
);
|
||||
if (portInUse) {
|
||||
alert(`Port ${sourcePort} is already in use on device "${sourceDevice.name}".`);
|
||||
params.data[field] = params.oldValue;
|
||||
this.gridApi.refreshCells();
|
||||
return;
|
||||
}
|
||||
} else if (field === 'target_device_name') {
|
||||
const device = devices.find(d => d.name === newValue);
|
||||
if (!device) {
|
||||
alert(`Device "${newValue}" not found.`);
|
||||
params.data[field] = params.oldValue;
|
||||
this.gridApi.refreshCells();
|
||||
return;
|
||||
}
|
||||
targetDeviceId = device.id;
|
||||
params.data.target_device_id = device.id;
|
||||
params.data.target_device_type = device.type_name;
|
||||
} else if (field === 'target_port') {
|
||||
targetPort = parseInt(newValue);
|
||||
const targetDevice = devices.find(d => d.id === targetDeviceId);
|
||||
if (targetPort < 0 || targetPort >= targetDevice.ports_count) {
|
||||
alert(`Invalid target port. Device "${targetDevice.name}" has ports 0-${targetDevice.ports_count - 1}.`);
|
||||
params.data[field] = params.oldValue;
|
||||
this.gridApi.refreshCells();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if port is already in use by another connection
|
||||
const connections = await this.api.getConnections();
|
||||
const portInUse = connections.some(c =>
|
||||
c.id !== connectionId &&
|
||||
((c.source_device_id === targetDeviceId && c.source_port === targetPort) ||
|
||||
(c.target_device_id === targetDeviceId && c.target_port === targetPort))
|
||||
);
|
||||
if (portInUse) {
|
||||
alert(`Port ${targetPort} is already in use on device "${targetDevice.name}".`);
|
||||
params.data[field] = params.oldValue;
|
||||
this.gridApi.refreshCells();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Update connection in database
|
||||
await this.api.request(`/api/connections/${connectionId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
sourceDeviceId,
|
||||
sourcePort,
|
||||
targetDeviceId,
|
||||
targetPort
|
||||
})
|
||||
});
|
||||
|
||||
// Update canvas - delete and recreate the connection
|
||||
await this.connectionManager.deleteConnection(connectionId);
|
||||
const newConnection = await this.api.getConnections();
|
||||
const updatedConnection = newConnection.find(c => c.id === connectionId);
|
||||
if (updatedConnection) {
|
||||
this.connectionManager.createConnectionShape(updatedConnection);
|
||||
this.connectionManager.layer.batchDraw();
|
||||
}
|
||||
|
||||
// Refresh table to show updated data
|
||||
this.refreshTable();
|
||||
} catch (err) {
|
||||
console.error('Failed to update connection:', err);
|
||||
alert('Failed to update connection: ' + err.message);
|
||||
params.data[field] = params.oldValue;
|
||||
this.gridApi.refreshCells();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== REFRESH & SYNC =====
|
||||
async refreshTable() {
|
||||
if (!this.currentTable) return;
|
||||
|
||||
const tableType = `${this.currentTable}-table`;
|
||||
await this.showTable(tableType);
|
||||
}
|
||||
|
||||
async syncFromCanvas() {
|
||||
// Called when canvas data changes - refresh the table
|
||||
if (this.isTableVisible()) {
|
||||
await this.refreshTable();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== CRUD OPERATIONS =====
|
||||
async addRow() {
|
||||
try {
|
||||
if (this.currentTable === 'racks') {
|
||||
await this.rackManager.addRack();
|
||||
await this.refreshTable();
|
||||
} else if (this.currentTable === 'devices') {
|
||||
alert('To add a device, please use the canvas view (right-click on a rack).');
|
||||
} else if (this.currentTable === 'connections') {
|
||||
alert('To add a connection, please use the canvas view (right-click on a device).');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add row:', err);
|
||||
alert('Failed to add row: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSelectedRows() {
|
||||
const selectedRows = this.gridApi.getSelectedRows();
|
||||
|
||||
if (selectedRows.length === 0) {
|
||||
alert('Please select rows to delete.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Delete ${selectedRows.length} row(s)?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete all rows with suppressed events to avoid race conditions
|
||||
for (const row of selectedRows) {
|
||||
if (this.currentTable === 'racks') {
|
||||
const rackShape = this.rackManager.getRackShape(row.id);
|
||||
await this.rackManager.deleteRack(row.id, rackShape, true); // suppress event
|
||||
} else if (this.currentTable === 'devices') {
|
||||
const deviceShape = this.deviceManager.getDeviceShape(row.id);
|
||||
await this.deviceManager.deleteDevice(row.id, deviceShape, true); // suppress event
|
||||
} else if (this.currentTable === 'connections') {
|
||||
const conn = this.connectionManager.connections.get(row.id);
|
||||
const line = conn ? conn.shape : null;
|
||||
const handles = conn ? conn.handles : null;
|
||||
await this.connectionManager.deleteConnection(row.id, line, handles, true); // suppress event
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch single event after all deletions complete
|
||||
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
|
||||
|
||||
await this.refreshTable();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete rows:', err);
|
||||
alert('Failed to delete rows: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
updateToolbarButtons() {
|
||||
const deleteBtn = document.getElementById('deleteTableRowBtn');
|
||||
if (deleteBtn && this.gridApi) {
|
||||
const selectedRows = this.gridApi.getSelectedRows();
|
||||
deleteBtn.disabled = selectedRows.length === 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
116
server/config.js
Normal file
116
server/config.js
Normal 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
408
server/db.js
Normal 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();
|
||||
66
server/lib/errorHandler.js
Normal file
66
server/lib/errorHandler.js
Normal 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
|
||||
};
|
||||
147
server/routes/connections.js
Normal file
147
server/routes/connections.js
Normal 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
186
server/routes/devices.js
Normal 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
90
server/routes/projects.js
Normal 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
113
server/routes/racks.js
Normal 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
83
server/server.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user