Initial commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.db
|
||||
!data/.gitkeep
|
||||
@@ -0,0 +1,117 @@
|
||||
# NetworkView
|
||||
|
||||
A powerful, dark-themed network & homelab documentation application. Document sites, server rooms, racks and every device inside them — with a visual rack diagram, Markdown notes, port/connection tracking and full-text search.
|
||||
|
||||
## Features
|
||||
|
||||
- **Visual Rack Diagram** — graphical U-by-U rack view with color-coded device types. Click any empty slot to add a device there.
|
||||
- **Hierarchical Structure** — Sites → Rooms → Racks → Components
|
||||
- **10 Component Types** — Server, Switch, Router, Firewall, Patch Panel, UPS, PDU, KVM, Storage, Other
|
||||
- **Port Documentation** — document each port on a device (RJ45, SFP, SFP+, LC, SC, etc.) with labels and patch-through connections
|
||||
- **Markdown Notes** — every entity has a full Markdown editor with live split-pane preview (GFM: tables, checkboxes, code blocks)
|
||||
- **Auto-save Notes** — notes save automatically as you type (1.5s debounce)
|
||||
- **Full-text Search** — sidebar search across all sites, rooms, racks and components (name, model, IP, serial)
|
||||
- **Dark Theme** — GitHub-style dark UI optimised for long documentation sessions
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Frontend | React 18 + TypeScript + Vite |
|
||||
| Backend | Express.js (Node 20) |
|
||||
| Database | SQLite via `better-sqlite3` (single file, zero config) |
|
||||
| Markdown | `react-markdown` + `remark-gfm` |
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- npm 10+
|
||||
|
||||
### Install & Run (Development)
|
||||
|
||||
```bash
|
||||
# From the project root
|
||||
npm install
|
||||
|
||||
# Start both backend (port 3001) and frontend (port 5173) with hot-reload
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open **http://localhost:5173** in your browser.
|
||||
|
||||
### Production Build
|
||||
|
||||
```bash
|
||||
npm run build # builds frontend into frontend/dist/
|
||||
npm start # serves API + built frontend on port 3001
|
||||
```
|
||||
|
||||
Open **http://localhost:3001**
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
NetworkView/
|
||||
├── backend/
|
||||
│ └── src/
|
||||
│ ├── index.js # Express server + search endpoint
|
||||
│ ├── db.js # SQLite init + schema
|
||||
│ └── routes/
|
||||
│ ├── sites.js
|
||||
│ ├── rooms.js
|
||||
│ ├── racks.js
|
||||
│ └── components.js # includes ports sub-resource
|
||||
├── frontend/
|
||||
│ └── src/
|
||||
│ ├── App.tsx # Router
|
||||
│ ├── api.ts # Fetch wrapper for all endpoints
|
||||
│ ├── types.ts # TypeScript types + component metadata
|
||||
│ ├── components/
|
||||
│ │ ├── Sidebar.tsx # Tree navigation + search
|
||||
│ │ ├── RackVisual.tsx # Graphical rack diagram
|
||||
│ │ ├── MarkdownEditor.tsx
|
||||
│ │ └── AddItemModal.tsx
|
||||
│ └── pages/
|
||||
│ ├── HomePage.tsx
|
||||
│ ├── SitePage.tsx
|
||||
│ ├── RoomPage.tsx
|
||||
│ ├── RackPage.tsx
|
||||
│ └── ComponentPage.tsx
|
||||
└── data/
|
||||
└── networkview.db # Created automatically on first run
|
||||
```
|
||||
|
||||
## Data Model
|
||||
|
||||
```
|
||||
Site
|
||||
└── Room(s)
|
||||
└── Rack(s)
|
||||
└── Component(s)
|
||||
└── Port(s)
|
||||
```
|
||||
|
||||
Every level has its own page with Markdown notes.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/sites` | List all sites |
|
||||
| POST | `/api/sites` | Create site |
|
||||
| GET/PUT/DELETE | `/api/sites/:id` | Site CRUD |
|
||||
| GET | `/api/rooms?siteId=` | Rooms in a site |
|
||||
| POST | `/api/rooms` | Create room |
|
||||
| GET/PUT/DELETE | `/api/rooms/:id` | Room CRUD |
|
||||
| GET | `/api/racks?roomId=` | Racks in a room |
|
||||
| POST | `/api/racks` | Create rack |
|
||||
| GET/PUT/DELETE | `/api/racks/:id` | Rack CRUD |
|
||||
| GET | `/api/components?rackId=` | Components in rack |
|
||||
| POST | `/api/components` | Create component |
|
||||
| GET/PUT/DELETE | `/api/components/:id` | Component CRUD |
|
||||
| GET | `/api/components/:id/ports` | Ports on component |
|
||||
| POST | `/api/components/:id/ports` | Add port |
|
||||
| PUT/DELETE | `/api/components/:id/ports/:portId` | Port CRUD |
|
||||
| GET | `/api/search?q=` | Full-text search |
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "networkview-backend",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon src/index.js",
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"morgan": "^1.10.0",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const DB_PATH = path.join(__dirname, '../../../data/networkview.db');
|
||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
// Enable WAL mode for better performance
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
location TEXT,
|
||||
notes TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rooms (
|
||||
id TEXT PRIMARY KEY,
|
||||
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
notes TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS racks (
|
||||
id TEXT PRIMARY KEY,
|
||||
room_id TEXT REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
total_units INTEGER DEFAULT 42,
|
||||
manufacturer TEXT,
|
||||
model TEXT,
|
||||
notes TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS components (
|
||||
id TEXT PRIMARY KEY,
|
||||
rack_id TEXT REFERENCES racks(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'other',
|
||||
position INTEGER,
|
||||
height_units INTEGER DEFAULT 1,
|
||||
manufacturer TEXT,
|
||||
model TEXT,
|
||||
serial_number TEXT,
|
||||
asset_tag TEXT,
|
||||
ip_address TEXT,
|
||||
mac_address TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
notes TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ports (
|
||||
id TEXT PRIMARY KEY,
|
||||
component_id TEXT NOT NULL REFERENCES components(id) ON DELETE CASCADE,
|
||||
port_number INTEGER NOT NULL,
|
||||
label TEXT,
|
||||
port_type TEXT DEFAULT 'RJ45',
|
||||
connected_to_port_id TEXT REFERENCES ports(id) ON DELETE SET NULL,
|
||||
notes TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
// Migrate: add port_count column if it doesn't exist yet
|
||||
try { db.exec('ALTER TABLE components ADD COLUMN port_count INTEGER DEFAULT NULL'); } catch (_) {}
|
||||
try { db.exec('ALTER TABLE components ADD COLUMN sfp_count INTEGER DEFAULT NULL'); } catch (_) {}
|
||||
|
||||
module.exports = db;
|
||||
@@ -0,0 +1,51 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const morgan = require('morgan');
|
||||
const path = require('path');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(morgan('dev'));
|
||||
|
||||
// API routes
|
||||
app.use('/api/sites', require('./routes/sites'));
|
||||
app.use('/api/rooms', require('./routes/rooms'));
|
||||
app.use('/api/racks', require('./routes/racks'));
|
||||
app.use('/api/components', require('./routes/components'));
|
||||
|
||||
// Search across all entities
|
||||
const db = require('./db');
|
||||
app.get('/api/search', (req, res) => {
|
||||
const { q } = req.query;
|
||||
if (!q || q.trim().length < 2) return res.json({ sites: [], rooms: [], racks: [], components: [] });
|
||||
|
||||
const term = `%${q.trim()}%`;
|
||||
const sites = db.prepare('SELECT id, name, location FROM sites WHERE name LIKE ? OR location LIKE ? LIMIT 10').all(term, term);
|
||||
const rooms = db.prepare('SELECT id, name, site_id FROM rooms WHERE name LIKE ? LIMIT 10').all(term);
|
||||
const racks = db.prepare('SELECT id, name, room_id FROM racks WHERE name LIKE ? OR model LIKE ? LIMIT 10').all(term, term);
|
||||
const components = db.prepare(`
|
||||
SELECT id, name, type, rack_id, model, ip_address FROM components
|
||||
WHERE name LIKE ? OR model LIKE ? OR ip_address LIKE ? OR serial_number LIKE ? LIMIT 20
|
||||
`).all(term, term, term, term);
|
||||
|
||||
res.json({ sites, rooms, racks, components });
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (_req, res) => res.json({ status: 'ok', time: new Date().toISOString() }));
|
||||
|
||||
// Serve built frontend in production
|
||||
const FRONTEND_DIST = path.join(__dirname, '../../frontend/dist');
|
||||
app.use(express.static(FRONTEND_DIST));
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(FRONTEND_DIST, 'index.html'), err => {
|
||||
if (err) res.status(200).json({ message: 'NetworkView API running. Start frontend separately in dev mode.' });
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`NetworkView backend running on http://localhost:${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
const express = require('express');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const db = require('../db');
|
||||
const router = express.Router();
|
||||
|
||||
function checkPositionOverlap(rackId, position, heightUnits, excludeId = null) {
|
||||
const end = position + heightUnits - 1;
|
||||
const existing = db.prepare(`
|
||||
SELECT id, name, position, height_units FROM components
|
||||
WHERE rack_id = ?
|
||||
AND id != ?
|
||||
AND position IS NOT NULL
|
||||
AND position <= ? AND (position + height_units - 1) >= ?
|
||||
`).all(rackId, excludeId || '', end, position);
|
||||
return existing;
|
||||
}
|
||||
|
||||
// GET /api/components?rackId=
|
||||
router.get('/', (req, res) => {
|
||||
const { rackId } = req.query;
|
||||
if (!rackId) return res.status(400).json({ error: 'rackId query param required' });
|
||||
|
||||
const components = db.prepare(
|
||||
'SELECT * FROM components WHERE rack_id = ? ORDER BY position ASC NULLS LAST'
|
||||
).all(rackId);
|
||||
res.json(components);
|
||||
});
|
||||
|
||||
// GET /api/components/:id
|
||||
router.get('/:id', (req, res) => {
|
||||
const component = db.prepare('SELECT * FROM components WHERE id = ?').get(req.params.id);
|
||||
if (!component) return res.status(404).json({ error: 'Component not found' });
|
||||
|
||||
const rawPorts = db.prepare('SELECT * FROM ports WHERE component_id = ? ORDER BY port_number').all(req.params.id);
|
||||
const ports = rawPorts.map(p => {
|
||||
if (!p.connected_to_port_id) return p;
|
||||
const lp = db.prepare('SELECT * FROM ports WHERE id = ?').get(p.connected_to_port_id);
|
||||
if (!lp) return p;
|
||||
const lc = db.prepare('SELECT id, name, type FROM components WHERE id = ?').get(lp.component_id);
|
||||
return { ...p, linked_port: lc ? { id: lp.id, port_number: lp.port_number, label: lp.label, component_id: lc.id, component_name: lc.name, component_type: lc.type } : null };
|
||||
});
|
||||
|
||||
let rack = null, room = null, site = null;
|
||||
if (component.rack_id) {
|
||||
rack = db.prepare('SELECT id, name, total_units, room_id FROM racks WHERE id = ?').get(component.rack_id);
|
||||
if (rack?.room_id) {
|
||||
room = db.prepare('SELECT id, name, site_id FROM rooms WHERE id = ?').get(rack.room_id);
|
||||
if (room?.site_id) site = db.prepare('SELECT id, name FROM sites WHERE id = ?').get(room.site_id);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ...component, ports, rack, room, site });
|
||||
});
|
||||
|
||||
// POST /api/components
|
||||
router.post('/', (req, res) => {
|
||||
const {
|
||||
rack_id, name, type, position, height_units,
|
||||
manufacturer, model, serial_number, asset_tag,
|
||||
ip_address, mac_address, status, notes
|
||||
} = req.body;
|
||||
|
||||
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||
|
||||
if (rack_id) {
|
||||
const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(rack_id);
|
||||
if (!rack) return res.status(404).json({ error: 'Rack not found' });
|
||||
|
||||
if (position != null) {
|
||||
const h = height_units || 1;
|
||||
if (position < 1 || position + h - 1 > rack.total_units) {
|
||||
return res.status(400).json({ error: `Position ${position} with ${h}U height exceeds rack size (${rack.total_units}U)` });
|
||||
}
|
||||
const overlapping = checkPositionOverlap(rack_id, position, h);
|
||||
if (overlapping.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: `Position overlaps with existing component: ${overlapping[0].name}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { port_count, sfp_count } = req.body;
|
||||
const id = uuidv4();
|
||||
db.prepare(`
|
||||
INSERT INTO components
|
||||
(id, rack_id, name, type, position, height_units, manufacturer, model,
|
||||
serial_number, asset_tag, ip_address, mac_address, status, notes, port_count, sfp_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id, rack_id || null, name, type || 'other',
|
||||
position ?? null, height_units || 1,
|
||||
manufacturer || null, model || null,
|
||||
serial_number || null, asset_tag || null,
|
||||
ip_address || null, mac_address || null,
|
||||
status || 'active', notes || '',
|
||||
port_count ?? null, sfp_count ?? null
|
||||
);
|
||||
|
||||
res.status(201).json(db.prepare('SELECT * FROM components WHERE id = ?').get(id));
|
||||
});
|
||||
|
||||
// PUT /api/components/:id
|
||||
router.put('/:id', (req, res) => {
|
||||
const component = db.prepare('SELECT * FROM components WHERE id = ?').get(req.params.id);
|
||||
if (!component) return res.status(404).json({ error: 'Component not found' });
|
||||
|
||||
const {
|
||||
name, type, position, height_units, manufacturer, model,
|
||||
serial_number, asset_tag, ip_address, mac_address, status, notes, port_count, sfp_count
|
||||
} = req.body;
|
||||
|
||||
const newPos = position ?? component.position;
|
||||
const newH = height_units ?? component.height_units;
|
||||
|
||||
if (component.rack_id && newPos != null) {
|
||||
const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(component.rack_id);
|
||||
if (newPos < 1 || newPos + newH - 1 > rack.total_units) {
|
||||
return res.status(400).json({ error: `Position exceeds rack size (${rack.total_units}U)` });
|
||||
}
|
||||
const overlapping = checkPositionOverlap(component.rack_id, newPos, newH, req.params.id);
|
||||
if (overlapping.length > 0) {
|
||||
return res.status(400).json({ error: `Position overlaps with: ${overlapping[0].name}` });
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE components SET
|
||||
name = ?, type = ?, position = ?, height_units = ?,
|
||||
manufacturer = ?, model = ?, serial_number = ?, asset_tag = ?,
|
||||
ip_address = ?, mac_address = ?, status = ?, notes = ?,
|
||||
port_count = ?, sfp_count = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name ?? component.name,
|
||||
type ?? component.type,
|
||||
newPos,
|
||||
newH,
|
||||
manufacturer ?? component.manufacturer,
|
||||
model ?? component.model,
|
||||
serial_number ?? component.serial_number,
|
||||
asset_tag ?? component.asset_tag,
|
||||
ip_address ?? component.ip_address,
|
||||
mac_address ?? component.mac_address,
|
||||
status ?? component.status,
|
||||
notes ?? component.notes,
|
||||
port_count !== undefined ? port_count : component.port_count,
|
||||
sfp_count !== undefined ? sfp_count : component.sfp_count,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
res.json(db.prepare('SELECT * FROM components WHERE id = ?').get(req.params.id));
|
||||
});
|
||||
|
||||
// DELETE /api/components/:id
|
||||
router.delete('/:id', (req, res) => {
|
||||
const component = db.prepare('SELECT id FROM components WHERE id = ?').get(req.params.id);
|
||||
if (!component) return res.status(404).json({ error: 'Component not found' });
|
||||
|
||||
db.prepare('DELETE FROM components WHERE id = ?').run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- Ports sub-resource ---
|
||||
|
||||
// GET /api/components/:id/ports
|
||||
router.get('/:id/ports', (req, res) => {
|
||||
const ports = db.prepare('SELECT * FROM ports WHERE component_id = ? ORDER BY port_number').all(req.params.id);
|
||||
res.json(ports);
|
||||
});
|
||||
|
||||
// POST /api/components/:id/ports
|
||||
router.post('/:id/ports', (req, res) => {
|
||||
const component = db.prepare('SELECT id FROM components WHERE id = ?').get(req.params.id);
|
||||
if (!component) return res.status(404).json({ error: 'Component not found' });
|
||||
|
||||
const { port_number, label, port_type, connected_to_port_id, notes } = req.body;
|
||||
if (port_number == null) return res.status(400).json({ error: 'port_number is required' });
|
||||
|
||||
const id = uuidv4();
|
||||
db.prepare(`
|
||||
INSERT INTO ports (id, component_id, port_number, label, port_type, connected_to_port_id, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, req.params.id, port_number, label || null, port_type || 'RJ45', connected_to_port_id || null, notes || null);
|
||||
|
||||
// Mirror link on the other side
|
||||
if (connected_to_port_id) {
|
||||
db.prepare('UPDATE ports SET connected_to_port_id = ? WHERE id = ?').run(id, connected_to_port_id);
|
||||
}
|
||||
|
||||
res.status(201).json(db.prepare('SELECT * FROM ports WHERE id = ?').get(id));
|
||||
});
|
||||
|
||||
// PUT /api/components/:componentId/ports/:portId
|
||||
router.put('/:componentId/ports/:portId', (req, res) => {
|
||||
const port = db.prepare('SELECT * FROM ports WHERE id = ? AND component_id = ?').get(req.params.portId, req.params.componentId);
|
||||
if (!port) return res.status(404).json({ error: 'Port not found' });
|
||||
|
||||
const { label, port_type, connected_to_port_id, notes } = req.body;
|
||||
const newLinkedId = connected_to_port_id !== undefined ? (connected_to_port_id || null) : port.connected_to_port_id;
|
||||
const oldLinkedId = port.connected_to_port_id;
|
||||
|
||||
// Clear old reverse link if it changed
|
||||
if (oldLinkedId && oldLinkedId !== newLinkedId) {
|
||||
db.prepare('UPDATE ports SET connected_to_port_id = NULL WHERE id = ? AND connected_to_port_id = ?').run(oldLinkedId, port.id);
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE ports SET label = ?, port_type = ?, connected_to_port_id = ?, notes = ? WHERE id = ?
|
||||
`).run(
|
||||
label ?? port.label,
|
||||
port_type ?? port.port_type,
|
||||
newLinkedId,
|
||||
notes ?? port.notes,
|
||||
req.params.portId
|
||||
);
|
||||
|
||||
// Set new reverse link
|
||||
if (newLinkedId && newLinkedId !== oldLinkedId) {
|
||||
db.prepare('UPDATE ports SET connected_to_port_id = ? WHERE id = ?').run(port.id, newLinkedId);
|
||||
}
|
||||
|
||||
res.json(db.prepare('SELECT * FROM ports WHERE id = ?').get(req.params.portId));
|
||||
});
|
||||
|
||||
// DELETE /api/components/:componentId/ports/:portId
|
||||
router.delete('/:componentId/ports/:portId', (req, res) => {
|
||||
db.prepare('DELETE FROM ports WHERE id = ? AND component_id = ?').run(req.params.portId, req.params.componentId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,95 @@
|
||||
const express = require('express');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const db = require('../db');
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/racks?roomId=
|
||||
router.get('/', (req, res) => {
|
||||
const { roomId } = req.query;
|
||||
if (!roomId) return res.status(400).json({ error: 'roomId query param required' });
|
||||
|
||||
const racks = db.prepare(`
|
||||
SELECT ra.*, COUNT(c.id) as component_count
|
||||
FROM racks ra
|
||||
LEFT JOIN components c ON c.rack_id = ra.id
|
||||
WHERE ra.room_id = ?
|
||||
GROUP BY ra.id
|
||||
ORDER BY ra.name
|
||||
`).all(roomId);
|
||||
res.json(racks);
|
||||
});
|
||||
|
||||
// GET /api/racks/:id
|
||||
router.get('/:id', (req, res) => {
|
||||
const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(req.params.id);
|
||||
if (!rack) return res.status(404).json({ error: 'Rack not found' });
|
||||
|
||||
const components = db.prepare(`
|
||||
SELECT * FROM components WHERE rack_id = ? ORDER BY position ASC NULLS LAST, name ASC
|
||||
`).all(req.params.id);
|
||||
|
||||
// Attach ports to each component so the graphic view can show active ports
|
||||
const componentsWithPorts = components.map(c => {
|
||||
const ports = db.prepare('SELECT * FROM ports WHERE component_id = ? ORDER BY port_number').all(c.id);
|
||||
return { ...c, ports };
|
||||
});
|
||||
|
||||
let room = null, site = null;
|
||||
if (rack.room_id) {
|
||||
room = db.prepare('SELECT id, name, site_id FROM rooms WHERE id = ?').get(rack.room_id);
|
||||
if (room) site = db.prepare('SELECT id, name FROM sites WHERE id = ?').get(room.site_id);
|
||||
}
|
||||
|
||||
res.json({ ...rack, components: componentsWithPorts, room, site });
|
||||
});
|
||||
|
||||
// POST /api/racks
|
||||
router.post('/', (req, res) => {
|
||||
const { room_id, name, total_units, manufacturer, model, notes } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||
|
||||
if (room_id) {
|
||||
const room = db.prepare('SELECT id FROM rooms WHERE id = ?').get(room_id);
|
||||
if (!room) return res.status(404).json({ error: 'Room not found' });
|
||||
}
|
||||
|
||||
const id = uuidv4();
|
||||
db.prepare(`
|
||||
INSERT INTO racks (id, room_id, name, total_units, manufacturer, model, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, room_id || null, name, total_units || 42, manufacturer || null, model || null, notes || '');
|
||||
|
||||
res.status(201).json(db.prepare('SELECT * FROM racks WHERE id = ?').get(id));
|
||||
});
|
||||
|
||||
// PUT /api/racks/:id
|
||||
router.put('/:id', (req, res) => {
|
||||
const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(req.params.id);
|
||||
if (!rack) return res.status(404).json({ error: 'Rack not found' });
|
||||
|
||||
const { name, total_units, manufacturer, model, notes } = req.body;
|
||||
db.prepare(`
|
||||
UPDATE racks SET name = ?, total_units = ?, manufacturer = ?, model = ?, notes = ?,
|
||||
updated_at = CURRENT_TIMESTAMP WHERE id = ?
|
||||
`).run(
|
||||
name ?? rack.name,
|
||||
total_units ?? rack.total_units,
|
||||
manufacturer ?? rack.manufacturer,
|
||||
model ?? rack.model,
|
||||
notes ?? rack.notes,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
res.json(db.prepare('SELECT * FROM racks WHERE id = ?').get(req.params.id));
|
||||
});
|
||||
|
||||
// DELETE /api/racks/:id
|
||||
router.delete('/:id', (req, res) => {
|
||||
const rack = db.prepare('SELECT id FROM racks WHERE id = ?').get(req.params.id);
|
||||
if (!rack) return res.status(404).json({ error: 'Rack not found' });
|
||||
|
||||
db.prepare('DELETE FROM racks WHERE id = ?').run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,77 @@
|
||||
const express = require('express');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const db = require('../db');
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/rooms?siteId=
|
||||
router.get('/', (req, res) => {
|
||||
const { siteId } = req.query;
|
||||
if (!siteId) return res.status(400).json({ error: 'siteId query param required' });
|
||||
|
||||
const rooms = db.prepare(`
|
||||
SELECT r.*, COUNT(ra.id) as rack_count
|
||||
FROM rooms r
|
||||
LEFT JOIN racks ra ON ra.room_id = r.id
|
||||
WHERE r.site_id = ?
|
||||
GROUP BY r.id
|
||||
ORDER BY r.name
|
||||
`).all(siteId);
|
||||
res.json(rooms);
|
||||
});
|
||||
|
||||
// GET /api/rooms/:id
|
||||
router.get('/:id', (req, res) => {
|
||||
const room = db.prepare('SELECT * FROM rooms WHERE id = ?').get(req.params.id);
|
||||
if (!room) return res.status(404).json({ error: 'Room not found' });
|
||||
|
||||
const racks = db.prepare(`
|
||||
SELECT ra.*, COUNT(c.id) as component_count
|
||||
FROM racks ra
|
||||
LEFT JOIN components c ON c.rack_id = ra.id
|
||||
WHERE ra.room_id = ?
|
||||
GROUP BY ra.id
|
||||
ORDER BY ra.name
|
||||
`).all(req.params.id);
|
||||
|
||||
const site = db.prepare('SELECT id, name FROM sites WHERE id = ?').get(room.site_id);
|
||||
res.json({ ...room, racks, site });
|
||||
});
|
||||
|
||||
// POST /api/rooms
|
||||
router.post('/', (req, res) => {
|
||||
const { site_id, name, notes } = req.body;
|
||||
if (!site_id || !name) return res.status(400).json({ error: 'site_id and name are required' });
|
||||
|
||||
const site = db.prepare('SELECT id FROM sites WHERE id = ?').get(site_id);
|
||||
if (!site) return res.status(404).json({ error: 'Site not found' });
|
||||
|
||||
const id = uuidv4();
|
||||
db.prepare('INSERT INTO rooms (id, site_id, name, notes) VALUES (?, ?, ?, ?)').run(
|
||||
id, site_id, name, notes || ''
|
||||
);
|
||||
res.status(201).json(db.prepare('SELECT * FROM rooms WHERE id = ?').get(id));
|
||||
});
|
||||
|
||||
// PUT /api/rooms/:id
|
||||
router.put('/:id', (req, res) => {
|
||||
const room = db.prepare('SELECT * FROM rooms WHERE id = ?').get(req.params.id);
|
||||
if (!room) return res.status(404).json({ error: 'Room not found' });
|
||||
|
||||
const { name, notes } = req.body;
|
||||
db.prepare(`
|
||||
UPDATE rooms SET name = ?, notes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
|
||||
`).run(name ?? room.name, notes ?? room.notes, req.params.id);
|
||||
|
||||
res.json(db.prepare('SELECT * FROM rooms WHERE id = ?').get(req.params.id));
|
||||
});
|
||||
|
||||
// DELETE /api/rooms/:id
|
||||
router.delete('/:id', (req, res) => {
|
||||
const room = db.prepare('SELECT id FROM rooms WHERE id = ?').get(req.params.id);
|
||||
if (!room) return res.status(404).json({ error: 'Room not found' });
|
||||
|
||||
db.prepare('DELETE FROM rooms WHERE id = ?').run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,70 @@
|
||||
const express = require('express');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const db = require('../db');
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/sites
|
||||
router.get('/', (req, res) => {
|
||||
const sites = db.prepare(`
|
||||
SELECT s.*, COUNT(r.id) as room_count
|
||||
FROM sites s
|
||||
LEFT JOIN rooms r ON r.site_id = s.id
|
||||
GROUP BY s.id
|
||||
ORDER BY s.name
|
||||
`).all();
|
||||
res.json(sites);
|
||||
});
|
||||
|
||||
// GET /api/sites/:id
|
||||
router.get('/:id', (req, res) => {
|
||||
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
||||
if (!site) return res.status(404).json({ error: 'Site not found' });
|
||||
|
||||
const rooms = db.prepare(`
|
||||
SELECT r.*, COUNT(ra.id) as rack_count
|
||||
FROM rooms r
|
||||
LEFT JOIN racks ra ON ra.room_id = r.id
|
||||
WHERE r.site_id = ?
|
||||
GROUP BY r.id
|
||||
ORDER BY r.name
|
||||
`).all(req.params.id);
|
||||
|
||||
res.json({ ...site, rooms });
|
||||
});
|
||||
|
||||
// POST /api/sites
|
||||
router.post('/', (req, res) => {
|
||||
const { name, location, notes } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||
|
||||
const id = uuidv4();
|
||||
db.prepare('INSERT INTO sites (id, name, location, notes) VALUES (?, ?, ?, ?)').run(
|
||||
id, name, location || null, notes || ''
|
||||
);
|
||||
res.status(201).json(db.prepare('SELECT * FROM sites WHERE id = ?').get(id));
|
||||
});
|
||||
|
||||
// PUT /api/sites/:id
|
||||
router.put('/:id', (req, res) => {
|
||||
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
||||
if (!site) return res.status(404).json({ error: 'Site not found' });
|
||||
|
||||
const { name, location, notes } = req.body;
|
||||
db.prepare(`
|
||||
UPDATE sites SET name = ?, location = ?, notes = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(name ?? site.name, location ?? site.location, notes ?? site.notes, req.params.id);
|
||||
|
||||
res.json(db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id));
|
||||
});
|
||||
|
||||
// DELETE /api/sites/:id
|
||||
router.delete('/:id', (req, res) => {
|
||||
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
||||
if (!site) return res.status(404).json({ error: 'Site not found' });
|
||||
|
||||
db.prepare('DELETE FROM sites WHERE id = ?').run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/rack-icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NetworkView — Lab Documentation</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "networkview-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"rehype-highlight": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import HomePage from './pages/HomePage';
|
||||
import SitePage from './pages/SitePage';
|
||||
import RoomPage from './pages/RoomPage';
|
||||
import RackPage from './pages/RackPage';
|
||||
import ComponentPage from './pages/ComponentPage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="app-layout">
|
||||
<Sidebar />
|
||||
<main className="main-content">
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/sites/:siteId" element={<SitePage />} />
|
||||
<Route path="/rooms/:roomId" element={<RoomPage />} />
|
||||
<Route path="/racks/:rackId" element={<RackPage />} />
|
||||
<Route path="/components/:componentId" element={<ComponentPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
Site, Room, Rack, Component, Port, SearchResults
|
||||
} from './types';
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// --- Sites ---
|
||||
export const getSites = () => request<Site[]>('/sites');
|
||||
export const getSite = (id: string) => request<Site & { rooms: Room[] }>(`/sites/${id}`);
|
||||
export const createSite = (data: Partial<Site>) =>
|
||||
request<Site>('/sites', { method: 'POST', body: JSON.stringify(data) });
|
||||
export const updateSite = (id: string, data: Partial<Site>) =>
|
||||
request<Site>(`/sites/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
export const deleteSite = (id: string) =>
|
||||
request(`/sites/${id}`, { method: 'DELETE' });
|
||||
|
||||
// --- Rooms ---
|
||||
export const getRooms = (siteId: string) => request<Room[]>(`/rooms?siteId=${siteId}`);
|
||||
export const getRoom = (id: string) => request<Room>(`/rooms/${id}`);
|
||||
export const createRoom = (data: Partial<Room>) =>
|
||||
request<Room>('/rooms', { method: 'POST', body: JSON.stringify(data) });
|
||||
export const updateRoom = (id: string, data: Partial<Room>) =>
|
||||
request<Room>(`/rooms/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
export const deleteRoom = (id: string) =>
|
||||
request(`/rooms/${id}`, { method: 'DELETE' });
|
||||
|
||||
// --- Racks ---
|
||||
export const getRacks = (roomId: string) => request<Rack[]>(`/racks?roomId=${roomId}`);
|
||||
export const getRack = (id: string) => request<Rack>(`/racks/${id}`);
|
||||
export const createRack = (data: Partial<Rack>) =>
|
||||
request<Rack>('/racks', { method: 'POST', body: JSON.stringify(data) });
|
||||
export const updateRack = (id: string, data: Partial<Rack>) =>
|
||||
request<Rack>(`/racks/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
export const deleteRack = (id: string) =>
|
||||
request(`/racks/${id}`, { method: 'DELETE' });
|
||||
|
||||
// --- Components ---
|
||||
export const getComponents = (rackId: string) => request<Component[]>(`/components?rackId=${rackId}`);
|
||||
export const getComponent = (id: string) => request<Component>(`/components/${id}`);
|
||||
export const createComponent = (data: Partial<Component>) =>
|
||||
request<Component>('/components', { method: 'POST', body: JSON.stringify(data) });
|
||||
export const updateComponent = (id: string, data: Partial<Component>) =>
|
||||
request<Component>(`/components/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
export const deleteComponent = (id: string) =>
|
||||
request(`/components/${id}`, { method: 'DELETE' });
|
||||
|
||||
// --- Ports ---
|
||||
export const getPorts = (componentId: string) =>
|
||||
request<Port[]>(`/components/${componentId}/ports`);
|
||||
export const createPort = (componentId: string, data: Partial<Port>) =>
|
||||
request<Port>(`/components/${componentId}/ports`, { method: 'POST', body: JSON.stringify(data) });
|
||||
export const updatePort = (componentId: string, portId: string, data: Partial<Port>) =>
|
||||
request<Port>(`/components/${componentId}/ports/${portId}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
export const deletePort = (componentId: string, portId: string) =>
|
||||
request(`/components/${componentId}/ports/${portId}`, { method: 'DELETE' });
|
||||
|
||||
// --- Search ---
|
||||
export const search = (q: string) => request<SearchResults>(`/search?q=${encodeURIComponent(q)}`);
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { COMPONENT_META, COMPONENT_TYPES } from '../types';
|
||||
import type { ComponentType, ComponentStatus } from '../types';
|
||||
|
||||
interface AddComponentProps {
|
||||
rackId: string;
|
||||
totalUnits: number;
|
||||
initialPosition?: number;
|
||||
onSave: (data: ComponentFormData) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface ComponentFormData {
|
||||
rack_id: string;
|
||||
name: string;
|
||||
type: ComponentType;
|
||||
position?: number | null;
|
||||
height_units: number;
|
||||
port_count?: number | null;
|
||||
sfp_count?: number | null;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serial_number?: string;
|
||||
asset_tag?: string;
|
||||
ip_address?: string;
|
||||
mac_address?: string;
|
||||
status: ComponentStatus;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export function AddComponentModal({ rackId, totalUnits, initialPosition, onSave, onClose }: AddComponentProps) {
|
||||
const [form, setForm] = useState<ComponentFormData>({
|
||||
rack_id: rackId,
|
||||
name: '',
|
||||
type: 'server',
|
||||
position: initialPosition ?? null,
|
||||
height_units: 1,
|
||||
port_count: null,
|
||||
sfp_count: null,
|
||||
manufacturer: '',
|
||||
model: '',
|
||||
serial_number: '',
|
||||
asset_tag: '',
|
||||
ip_address: '',
|
||||
mac_address: '',
|
||||
status: 'active',
|
||||
notes: '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function set(key: keyof ComponentFormData, value: unknown) {
|
||||
setForm(f => ({ ...f, [key]: value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim()) { setError('Name is required'); return; }
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await onSave({
|
||||
...form,
|
||||
position: form.position ? Number(form.position) : null,
|
||||
height_units: Number(form.height_units),
|
||||
port_count: (form.type === 'switch' || form.type === 'patch_panel') && form.port_count
|
||||
? Number(form.port_count) : null,
|
||||
sfp_count: form.type === 'switch' && form.sfp_count ? Number(form.sfp_count) : null,
|
||||
manufacturer: form.manufacturer || undefined,
|
||||
model: form.model || undefined,
|
||||
serial_number: form.serial_number || undefined,
|
||||
asset_tag: form.asset_tag || undefined,
|
||||
ip_address: form.ip_address || undefined,
|
||||
mac_address: form.mac_address || undefined,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">Add Component</h2>
|
||||
<button className="modal-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="modal-form">
|
||||
<div className="form-row">
|
||||
<div className="form-group form-group-lg">
|
||||
<label>Name *</label>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => set('name', e.target.value)}
|
||||
placeholder="e.g. Core Switch SW-01"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Type</label>
|
||||
<select value={form.type} onChange={e => set('type', e.target.value)} className="form-input">
|
||||
{COMPONENT_TYPES.map(t => (
|
||||
<option key={t} value={t}>{COMPONENT_META[t].label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Rack Position (U)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalUnits}
|
||||
value={form.position ?? ''}
|
||||
onChange={e => set('position', e.target.value ? Number(e.target.value) : null)}
|
||||
placeholder="auto"
|
||||
className="form-input"
|
||||
/>
|
||||
<span className="form-hint">Leave empty to leave unpositioned</span>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Height (U)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalUnits}
|
||||
value={form.height_units}
|
||||
onChange={e => set('height_units', Number(e.target.value))}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Status</label>
|
||||
<select value={form.status} onChange={e => set('status', e.target.value)} className="form-input">
|
||||
<option value="active">Active</option>
|
||||
<option value="maintenance">Maintenance</option>
|
||||
<option value="decommissioned">Decommissioned</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(form.type === 'switch' || form.type === 'patch_panel') && (
|
||||
<div className="form-row port-count-row">
|
||||
<div className="form-group form-group-lg">
|
||||
<label>
|
||||
{form.type === 'patch_panel' ? '🔌 Available Patches (port count)' : '🔌 Number of Switch Ports'}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={96}
|
||||
value={form.port_count ?? 24}
|
||||
onChange={e => set('port_count', Number(e.target.value))}
|
||||
className="form-input"
|
||||
placeholder="24"
|
||||
/>
|
||||
<span className="form-hint">
|
||||
{form.type === 'patch_panel'
|
||||
? 'Number of RJ45 jacks on the front panel (e.g. 12, 24, 48)'
|
||||
: 'Total ethernet ports shown on the rack diagram (e.g. 8, 16, 24, 48)'}
|
||||
</span>
|
||||
</div>
|
||||
{form.type === 'switch' && (
|
||||
<div className="form-group">
|
||||
<label>🔶 Fiber / SFP Ports</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={16}
|
||||
value={form.sfp_count ?? 0}
|
||||
onChange={e => set('sfp_count', Number(e.target.value))}
|
||||
className="form-input"
|
||||
placeholder="0"
|
||||
/>
|
||||
<span className="form-hint">SFP / fiber uplink slots (0–16)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Manufacturer</label>
|
||||
<input type="text" value={form.manufacturer} onChange={e => set('manufacturer', e.target.value)} placeholder="e.g. Cisco" className="form-input" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Model</label>
|
||||
<input type="text" value={form.model} onChange={e => set('model', e.target.value)} placeholder="e.g. Catalyst 2960" className="form-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>IP Address</label>
|
||||
<input type="text" value={form.ip_address} onChange={e => set('ip_address', e.target.value)} placeholder="192.168.1.1" className="form-input" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>MAC Address</label>
|
||||
<input type="text" value={form.mac_address} onChange={e => set('mac_address', e.target.value)} placeholder="AA:BB:CC:DD:EE:FF" className="form-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Serial Number</label>
|
||||
<input type="text" value={form.serial_number} onChange={e => set('serial_number', e.target.value)} className="form-input" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Asset Tag</label>
|
||||
<input type="text" value={form.asset_tag} onChange={e => set('asset_tag', e.target.value)} className="form-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Add Component'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Generic modal for creating sites/rooms/racks
|
||||
interface SimpleCreateProps {
|
||||
title: string;
|
||||
fields: { key: string; label: string; placeholder?: string; type?: string; defaultValue?: string | number }[];
|
||||
onSave: (data: Record<string, string | number>) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SimpleCreateModal({ title, fields, onSave, onClose }: SimpleCreateProps) {
|
||||
const [values, setValues] = useState<Record<string, string | number>>(
|
||||
Object.fromEntries(fields.map(f => [f.key, f.defaultValue ?? '']))
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await onSave(values);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal modal-sm">
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">{title}</h2>
|
||||
<button className="modal-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="modal-form">
|
||||
{fields.map(f => (
|
||||
<div className="form-group" key={f.key}>
|
||||
<label>{f.label}</label>
|
||||
<input
|
||||
autoFocus={fields[0].key === f.key}
|
||||
type={f.type ?? 'text'}
|
||||
value={values[f.key] as string}
|
||||
onChange={e => setValues(v => ({ ...v, [f.key]: f.type === 'number' ? Number(e.target.value) : e.target.value }))}
|
||||
placeholder={f.placeholder}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
onSave?: (val: string) => void;
|
||||
autoSaveDelay?: number;
|
||||
}
|
||||
|
||||
type EditorMode = 'edit' | 'split' | 'preview';
|
||||
|
||||
export default function MarkdownEditor({ value, onChange, onSave, autoSaveDelay = 1500 }: Props) {
|
||||
const [mode, setMode] = useState<EditorMode>('split');
|
||||
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved');
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastSaved = useRef(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onSave) return;
|
||||
if (value === lastSaved.current) { setSaveStatus('saved'); return; }
|
||||
setSaveStatus('unsaved');
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(async () => {
|
||||
setSaveStatus('saving');
|
||||
await onSave(value);
|
||||
lastSaved.current = value;
|
||||
setSaveStatus('saved');
|
||||
}, autoSaveDelay);
|
||||
return () => { if (saveTimer.current) clearTimeout(saveTimer.current); };
|
||||
}, [value, onSave, autoSaveDelay]);
|
||||
|
||||
return (
|
||||
<div className="md-editor">
|
||||
<div className="md-editor-toolbar">
|
||||
<div className="md-mode-tabs">
|
||||
{(['edit', 'split', 'preview'] as EditorMode[]).map(m => (
|
||||
<button
|
||||
key={m}
|
||||
className={`md-mode-btn ${mode === m ? 'active' : ''}`}
|
||||
onClick={() => setMode(m)}
|
||||
>
|
||||
{m.charAt(0).toUpperCase() + m.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="md-toolbar-actions">
|
||||
{onSave && (
|
||||
<span className={`save-status save-status-${saveStatus}`}>
|
||||
{saveStatus === 'saving' ? '⟳ Saving…' : saveStatus === 'unsaved' ? '● Unsaved' : '✓ Saved'}
|
||||
</span>
|
||||
)}
|
||||
<span className="md-help-hint">Markdown supported · GFM tables, checkboxes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`md-editor-body md-mode-${mode}`}>
|
||||
{mode !== 'preview' && (
|
||||
<textarea
|
||||
className="md-textarea"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder="Write notes in Markdown… # Heading **bold**, *italic*, `code` - [ ] Checklist item | Port | Device | |------|--------| | 1 | PC-01 |"
|
||||
spellCheck={false}
|
||||
/>
|
||||
)}
|
||||
{mode !== 'edit' && (
|
||||
<div className="md-preview">
|
||||
{value.trim() ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{value}</ReactMarkdown>
|
||||
) : (
|
||||
<div className="md-preview-empty">Nothing to preview yet. Start writing in the editor.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { Rack, Component } from '../types';
|
||||
import { COMPONENT_META } from '../types';
|
||||
|
||||
const UNIT_PX = 44;
|
||||
|
||||
interface SlotItem {
|
||||
type: 'component' | 'empty';
|
||||
position: number;
|
||||
heightUnits: number;
|
||||
component?: Component;
|
||||
}
|
||||
|
||||
function buildSlots(totalUnits: number, components: Component[]): SlotItem[] {
|
||||
const byPos = new Map<number, Component>();
|
||||
for (const c of components) {
|
||||
if (c.position != null) {
|
||||
for (let u = c.position; u < c.position + c.height_units; u++) {
|
||||
byPos.set(u, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
const slots: SlotItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
let u = 1;
|
||||
while (u <= totalUnits) {
|
||||
const comp = byPos.get(u);
|
||||
if (comp && !seen.has(comp.id)) {
|
||||
seen.add(comp.id);
|
||||
slots.push({ type: 'component', position: u, heightUnits: comp.height_units, component: comp });
|
||||
u += comp.height_units;
|
||||
} else if (!comp) {
|
||||
slots.push({ type: 'empty', position: u, heightUnits: 1 });
|
||||
u++;
|
||||
} else {
|
||||
u++;
|
||||
}
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
// ─── Patch Panel ────────────────────────────────────────────────────────────
|
||||
function PatchPanelFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
|
||||
const portCount = comp.port_count ?? 24;
|
||||
const showNums = portCount <= 48;
|
||||
const color = COMPONENT_META.patch_panel.color;
|
||||
|
||||
return (
|
||||
<div className="hw-face hw-pp" style={{ height: heightPx }}>
|
||||
<div className="hw-ports-main hw-pp-fullrow">
|
||||
<div className="hw-pp-row">
|
||||
{Array.from({ length: portCount }, (_, i) => (
|
||||
<div key={i} className="hw-rj45-wrap" title={`Port ${i + 1}`}>
|
||||
<div className="hw-rj45-jack" />
|
||||
{showNums && <span className="hw-rj45-num">{i + 1}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hw-face-labels">
|
||||
<span className="hw-lbl-type" style={{ color }}>PATCH</span>
|
||||
<span className="hw-lbl-name">{comp.name}</span>
|
||||
<span className="hw-lbl-count" style={{ color }}>{portCount}P</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Switch ──────────────────────────────────────────────────────────────────
|
||||
function SwitchFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
|
||||
const portCount = comp.port_count ?? 24;
|
||||
const sfpCount = comp.sfp_count ?? 0;
|
||||
const color = COMPONENT_META.switch.color;
|
||||
const configuredNums = new Set((comp.ports ?? []).map(p => p.port_number));
|
||||
const pairCount = Math.ceil(portCount / 2);
|
||||
|
||||
return (
|
||||
<div className="hw-face hw-sw" style={{ height: heightPx }}>
|
||||
{/* Main body: RJ45 pairs + optional SFP column */}
|
||||
<div className="hw-sw-body">
|
||||
{/* RJ45 area — fills all space left of the SFP column */}
|
||||
<div className="hw-sw-rj45-area">
|
||||
<div className="hw-sw-pairs-row">
|
||||
{Array.from({ length: pairCount }, (_, col) => {
|
||||
const top = col * 2 + 1;
|
||||
const bot = col * 2 + 2;
|
||||
const topActive = configuredNums.has(top);
|
||||
const botActive = bot <= portCount && configuredNums.has(bot);
|
||||
return (
|
||||
<div key={col} className="hw-sw-pair">
|
||||
<div className={`hw-sw-jack2${topActive ? ' hw-sw-jack2-active' : ''}`} title={`Port ${top}`}>
|
||||
<div className={`hw-sw-led2 ${topActive ? 'hw-led-green' : 'hw-led-off'}`} />
|
||||
</div>
|
||||
{bot <= portCount ? (
|
||||
<div className={`hw-sw-jack2${botActive ? ' hw-sw-jack2-active' : ''}`} title={`Port ${bot}`}>
|
||||
<div className={`hw-sw-led2 ${botActive ? 'hw-led-green' : 'hw-led-off'}`} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="hw-sw-jack2 hw-sw-jack2-empty" />
|
||||
)}
|
||||
<span className="hw-sw-num2">{top}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SFP column — only shown when sfp_count > 0 */}
|
||||
{sfpCount > 0 && (
|
||||
<div className="hw-sw-sfp-col" title={`${sfpCount} SFP/fiber uplink slots`}>
|
||||
<span className="hw-sw-sfp-title">SFP</span>
|
||||
{Array.from({ length: sfpCount }, (_, i) => (
|
||||
<div key={i} className="hw-sw-sfp-slot" title={`SFP ${i + 1}`}>
|
||||
<div className="hw-led hw-led-off" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hw-face-labels">
|
||||
<span className="hw-lbl-type" style={{ color }}>SWITCH</span>
|
||||
<span className="hw-lbl-name">{comp.name}</span>
|
||||
{comp.ip_address && <span className="hw-lbl-ip">{comp.ip_address}</span>}
|
||||
<span className="hw-lbl-count" style={{ color }}>{portCount}P{sfpCount > 0 ? ` +${sfpCount}F` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Server ──────────────────────────────────────────────────────────────────
|
||||
function ServerFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
|
||||
const color = COMPONENT_META.server.color;
|
||||
const driveCount = Math.min(8, comp.height_units * 4);
|
||||
|
||||
return (
|
||||
<div className="hw-face hw-server" style={{ height: heightPx }}>
|
||||
<div className="hw-ports-main hw-server-body">
|
||||
<div className="hw-drive-bays">
|
||||
{Array.from({ length: driveCount }, (_, i) => (
|
||||
<div key={i} className="hw-drive-bay" title={`Drive ${i + 1}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="hw-server-panel">
|
||||
<div className="hw-power-btn" title="Power" />
|
||||
<div className="hw-led hw-led-green" title="Online" />
|
||||
<div className="hw-led hw-led-amber" title="Activity" />
|
||||
<div className="hw-usb" title="USB" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="hw-face-labels">
|
||||
<span className="hw-lbl-type" style={{ color }}>SERVER</span>
|
||||
<span className="hw-lbl-name">{comp.name}</span>
|
||||
{comp.model && <span className="hw-lbl-model">{comp.model}</span>}
|
||||
{comp.ip_address && <span className="hw-lbl-ip">{comp.ip_address}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Firewall ────────────────────────────────────────────────────────────────
|
||||
function FirewallFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
|
||||
const color = COMPONENT_META.firewall.color;
|
||||
|
||||
return (
|
||||
<div className="hw-face hw-fw" style={{ height: heightPx }}>
|
||||
<div className="hw-ports-main">
|
||||
<div className="hw-sw-row">
|
||||
<div className="hw-sw-port-wrap" title="WAN">
|
||||
<div className="hw-led hw-led-amber" />
|
||||
<div className="hw-eth-port hw-eth-wan" />
|
||||
</div>
|
||||
<div className="hw-sw-gap" />
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<div key={i} className="hw-sw-port-wrap" title={`LAN ${i + 1}`}>
|
||||
<div className="hw-led hw-led-green" />
|
||||
<div className="hw-eth-port" />
|
||||
</div>
|
||||
))}
|
||||
<div className="hw-sw-gap" />
|
||||
<div className="hw-sw-port-wrap" title="DMZ">
|
||||
<div className="hw-led hw-led-off" />
|
||||
<div className="hw-eth-port" style={{ borderColor: '#6b7280' }} />
|
||||
</div>
|
||||
<div className="hw-sw-gap" />
|
||||
<div className="hw-usb" title="Console" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="hw-face-labels">
|
||||
<span className="hw-lbl-type" style={{ color }}>FIREWALL</span>
|
||||
<span className="hw-lbl-name">{comp.name}</span>
|
||||
{comp.ip_address && <span className="hw-lbl-ip">{comp.ip_address}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Router ──────────────────────────────────────────────────────────────────
|
||||
function RouterFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
|
||||
const color = COMPONENT_META.router.color;
|
||||
|
||||
return (
|
||||
<div className="hw-face hw-router" style={{ height: heightPx }}>
|
||||
<div className="hw-ports-main">
|
||||
<div className="hw-sw-row">
|
||||
<div className="hw-sw-port-wrap" title="WAN">
|
||||
<div className="hw-led hw-led-amber" />
|
||||
<div className="hw-eth-port hw-eth-wan" />
|
||||
</div>
|
||||
<div className="hw-sw-gap" />
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<div key={i} className="hw-sw-port-wrap" title={`LAN ${i + 1}`}>
|
||||
<div className="hw-led hw-led-green" />
|
||||
<div className="hw-eth-port" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hw-face-labels">
|
||||
<span className="hw-lbl-type" style={{ color }}>ROUTER</span>
|
||||
<span className="hw-lbl-name">{comp.name}</span>
|
||||
{comp.ip_address && <span className="hw-lbl-ip">{comp.ip_address}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── UPS ─────────────────────────────────────────────────────────────────────
|
||||
function UpsFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
|
||||
const color = COMPONENT_META.ups.color;
|
||||
|
||||
return (
|
||||
<div className="hw-face hw-ups" style={{ height: heightPx }}>
|
||||
<div className="hw-ports-main hw-ups-body">
|
||||
<div className="hw-ups-screen">
|
||||
<div className="hw-ups-bar-outer">
|
||||
<div className="hw-ups-bar-inner" style={{ width: '80%' }} />
|
||||
</div>
|
||||
<span className="hw-ups-pct">100%</span>
|
||||
</div>
|
||||
<div className="hw-ups-indicators">
|
||||
<div className="hw-led hw-led-green" title="Online" />
|
||||
<div className="hw-led hw-led-amber" title="Battery" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="hw-face-labels">
|
||||
<span className="hw-lbl-type" style={{ color }}>UPS</span>
|
||||
<span className="hw-lbl-name">{comp.name}</span>
|
||||
{comp.model && <span className="hw-lbl-model">{comp.model}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── PDU ─────────────────────────────────────────────────────────────────────
|
||||
function PduFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
|
||||
const color = COMPONENT_META.pdu.color;
|
||||
|
||||
return (
|
||||
<div className="hw-face hw-pdu" style={{ height: heightPx }}>
|
||||
<div className="hw-ports-main">
|
||||
<div className="hw-sw-row">
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<div key={i} className="hw-pdu-outlet" title={`Outlet ${i + 1}`}>
|
||||
<div className="hw-led hw-led-green" />
|
||||
<div className="hw-outlet-socket" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hw-face-labels">
|
||||
<span className="hw-lbl-type" style={{ color }}>PDU</span>
|
||||
<span className="hw-lbl-name">{comp.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── KVM ─────────────────────────────────────────────────────────────────────
|
||||
function KvmFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
|
||||
const color = COMPONENT_META.kvm.color;
|
||||
|
||||
return (
|
||||
<div className="hw-face hw-kvm" style={{ height: heightPx }}>
|
||||
<div className="hw-ports-main hw-server-body">
|
||||
<div className="hw-kvm-ports">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<div key={i} className="hw-sw-port-wrap" title={`KVM ${i + 1}`}>
|
||||
<div className="hw-led hw-led-off" />
|
||||
<div className="hw-eth-port" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hw-server-panel">
|
||||
<div className="hw-led hw-led-green" />
|
||||
<div className="hw-usb" />
|
||||
<div className="hw-usb" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="hw-face-labels">
|
||||
<span className="hw-lbl-type" style={{ color }}>KVM</span>
|
||||
<span className="hw-lbl-name">{comp.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Storage ─────────────────────────────────────────────────────────────────
|
||||
function StorageFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
|
||||
const color = COMPONENT_META.storage.color;
|
||||
const driveCount = Math.min(12, comp.height_units * 4);
|
||||
|
||||
return (
|
||||
<div className="hw-face hw-storage" style={{ height: heightPx }}>
|
||||
<div className="hw-ports-main hw-server-body">
|
||||
<div className="hw-drive-bays">
|
||||
{Array.from({ length: driveCount }, (_, i) => (
|
||||
<div key={i} className="hw-drive-bay hw-drive-bay-storage" title={`Disk ${i + 1}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="hw-server-panel">
|
||||
<div className="hw-power-btn" />
|
||||
<div className="hw-led hw-led-green" />
|
||||
<div className="hw-led hw-led-amber" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="hw-face-labels">
|
||||
<span className="hw-lbl-type" style={{ color }}>STORAGE</span>
|
||||
<span className="hw-lbl-name">{comp.name}</span>
|
||||
{comp.model && <span className="hw-lbl-model">{comp.model}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Default ─────────────────────────────────────────────────────────────────
|
||||
function DefaultFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
|
||||
const meta = COMPONENT_META[comp.type];
|
||||
return (
|
||||
<div className="hw-face" style={{ height: heightPx, background: meta.bg, borderLeft: `4px solid ${meta.color}` }}>
|
||||
<div className="hw-ports-main hw-default-body">
|
||||
<div className="hw-indicators-col">
|
||||
<div className="hw-led hw-led-green" />
|
||||
{comp.height_units > 1 && <div className="hw-led hw-led-amber" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hw-face-labels">
|
||||
<span className="hw-lbl-type" style={{ color: meta.color }}>{meta.label.toUpperCase()}</span>
|
||||
<span className="hw-lbl-name">{comp.name}</span>
|
||||
{comp.ip_address && <span className="hw-lbl-ip">{comp.ip_address}</span>}
|
||||
{comp.model && <span className="hw-lbl-model">{comp.model}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFace(comp: Component, heightPx: number): React.ReactNode {
|
||||
switch (comp.type) {
|
||||
case 'patch_panel': return <PatchPanelFace comp={comp} heightPx={heightPx} />;
|
||||
case 'switch': return <SwitchFace comp={comp} heightPx={heightPx} />;
|
||||
case 'server': return <ServerFace comp={comp} heightPx={heightPx} />;
|
||||
case 'firewall': return <FirewallFace comp={comp} heightPx={heightPx} />;
|
||||
case 'router': return <RouterFace comp={comp} heightPx={heightPx} />;
|
||||
case 'ups': return <UpsFace comp={comp} heightPx={heightPx} />;
|
||||
case 'pdu': return <PduFace comp={comp} heightPx={heightPx} />;
|
||||
case 'kvm': return <KvmFace comp={comp} heightPx={heightPx} />;
|
||||
case 'storage': return <StorageFace comp={comp} heightPx={heightPx} />;
|
||||
default: return <DefaultFace comp={comp} heightPx={heightPx} />;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||
interface Props {
|
||||
rack: Rack;
|
||||
components: Component[];
|
||||
onAddAtSlot?: (position: number) => void;
|
||||
}
|
||||
|
||||
export default function RackGraphicView({ rack, components, onAddAtSlot }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const slots = buildSlots(rack.total_units, components);
|
||||
|
||||
return (
|
||||
<div className="rack-gfx">
|
||||
<div className="rack-gfx-cabinet">
|
||||
{/* Top strip */}
|
||||
<div className="rack-gfx-top">
|
||||
<span className="rack-gfx-title">{rack.name}</span>
|
||||
{rack.manufacturer && (
|
||||
<span className="rack-gfx-sub">{rack.manufacturer} {rack.model}</span>
|
||||
)}
|
||||
<span className="rack-gfx-units-badge">{rack.total_units}U</span>
|
||||
</div>
|
||||
|
||||
{/* Slots */}
|
||||
<div className="rack-gfx-body">
|
||||
{slots.map(slot => {
|
||||
const heightPx = slot.heightUnits * UNIT_PX;
|
||||
|
||||
if (slot.type === 'empty') {
|
||||
return (
|
||||
<div
|
||||
key={`e-${slot.position}`}
|
||||
className="rack-gfx-slot rack-gfx-empty"
|
||||
style={{ height: heightPx }}
|
||||
onClick={() => onAddAtSlot?.(slot.position)}
|
||||
>
|
||||
<span className="rack-gfx-u">{slot.position}</span>
|
||||
<span className="rack-gfx-empty-txt">· empty ·</span>
|
||||
<span className="rack-gfx-add-hint">+ add</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const comp = slot.component!;
|
||||
return (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="rack-gfx-slot rack-gfx-occupied"
|
||||
style={{ height: heightPx }}
|
||||
onClick={() => navigate(`/components/${comp.id}`)}
|
||||
title={`${comp.name} — click to open`}
|
||||
>
|
||||
<span className="rack-gfx-u">{slot.position}</span>
|
||||
<div className="rack-gfx-face">
|
||||
{renderFace(comp, heightPx)}
|
||||
</div>
|
||||
<div className="rack-gfx-screws">
|
||||
<div className="rack-gfx-screw" />
|
||||
{slot.heightUnits > 1 && <div className="rack-gfx-screw" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bottom strip */}
|
||||
<div className="rack-gfx-bottom" />
|
||||
</div>
|
||||
|
||||
{/* Unpositioned */}
|
||||
{components.filter(c => c.position == null).length > 0 && (
|
||||
<div className="rack-unpositioned" style={{ marginTop: 16 }}>
|
||||
<div className="rack-unpositioned-title">Unpositioned Components</div>
|
||||
<div className="rack-unpositioned-list">
|
||||
{components.filter(c => c.position == null).map(comp => {
|
||||
const meta = COMPONENT_META[comp.type];
|
||||
return (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="rack-unpositioned-item"
|
||||
style={{ borderLeft: `3px solid ${meta.color}` }}
|
||||
onClick={() => navigate(`/components/${comp.id}`)}
|
||||
>
|
||||
<span style={{ color: meta.color, fontSize: 11 }}>{meta.label}</span>
|
||||
<span className="rack-unpositioned-name">{comp.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { Rack, Component } from '../types';
|
||||
import { COMPONENT_META } from '../types';
|
||||
|
||||
interface Props {
|
||||
rack: Rack;
|
||||
components: Component[];
|
||||
onAddAtSlot?: (position: number) => void;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
interface SlotItem {
|
||||
type: 'component' | 'empty';
|
||||
component?: Component;
|
||||
position: number;
|
||||
heightUnits: number;
|
||||
}
|
||||
|
||||
function buildSlots(totalUnits: number, components: Component[]): SlotItem[] {
|
||||
// Map each U position to the component occupying it
|
||||
const byPosition = new Map<number, Component>();
|
||||
for (const c of components) {
|
||||
if (c.position != null) {
|
||||
for (let u = c.position; u < c.position + c.height_units; u++) {
|
||||
byPosition.set(u, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const items: SlotItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
let u = 1;
|
||||
|
||||
while (u <= totalUnits) {
|
||||
const comp = byPosition.get(u);
|
||||
if (comp && !seen.has(comp.id)) {
|
||||
seen.add(comp.id);
|
||||
items.push({ type: 'component', component: comp, position: comp.position!, heightUnits: comp.height_units });
|
||||
u += comp.height_units;
|
||||
} else if (!comp) {
|
||||
items.push({ type: 'empty', position: u, heightUnits: 1 });
|
||||
u++;
|
||||
} else {
|
||||
// occupied by a comp already rendered
|
||||
u++;
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
const UNIT_HEIGHT = 30; // px per U
|
||||
|
||||
export default function RackVisual({ rack, components, onAddAtSlot }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const slots = buildSlots(rack.total_units, components);
|
||||
|
||||
const usedUnits = components.reduce((sum, c) => sum + c.height_units, 0);
|
||||
const freeUnits = rack.total_units - usedUnits;
|
||||
|
||||
return (
|
||||
<div className="rack-wrapper">
|
||||
<div className="rack-stats-bar">
|
||||
<span className="rack-stat"><strong>{rack.total_units}U</strong> total</span>
|
||||
<span className="rack-stat rack-stat-used"><strong>{usedUnits}U</strong> used</span>
|
||||
<span className="rack-stat rack-stat-free"><strong>{freeUnits}U</strong> free</span>
|
||||
<span className="rack-stat">{components.length} devices</span>
|
||||
</div>
|
||||
|
||||
<div className="rack-cabinet">
|
||||
{/* Rack top strip */}
|
||||
<div className="rack-top-strip">
|
||||
<span className="rack-label-text">{rack.name}</span>
|
||||
{rack.manufacturer && <span className="rack-label-sub">{rack.manufacturer} {rack.model}</span>}
|
||||
</div>
|
||||
|
||||
{/* Rack body */}
|
||||
<div className="rack-body">
|
||||
{slots.map(slot => {
|
||||
const heightPx = slot.heightUnits * UNIT_HEIGHT;
|
||||
|
||||
if (slot.type === 'empty') {
|
||||
return (
|
||||
<div
|
||||
key={`empty-${slot.position}`}
|
||||
className="rack-slot rack-slot-empty"
|
||||
style={{ height: `${heightPx}px` }}
|
||||
title={`Slot ${slot.position}U — click to add component`}
|
||||
onClick={() => onAddAtSlot?.(slot.position)}
|
||||
>
|
||||
<span className="rack-unit-num">{slot.position}</span>
|
||||
<span className="rack-empty-label">· empty ·</span>
|
||||
<span className="rack-add-hint">+ add</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const comp = slot.component!;
|
||||
const meta = COMPONENT_META[comp.type];
|
||||
const statusDot = comp.status === 'active' ? '🟢' : comp.status === 'maintenance' ? '🟡' : '🔴';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="rack-slot rack-slot-component"
|
||||
style={{
|
||||
height: `${heightPx}px`,
|
||||
backgroundColor: meta.bg,
|
||||
borderLeft: `4px solid ${meta.color}`,
|
||||
borderBottom: `1px solid ${meta.border}`,
|
||||
}}
|
||||
title={`${comp.name} — ${meta.label} (${comp.height_units}U) — Click to view`}
|
||||
onClick={() => navigate(`/components/${comp.id}`)}
|
||||
>
|
||||
<span className="rack-unit-num" style={{ color: meta.color }}>
|
||||
{slot.position}
|
||||
</span>
|
||||
<div className="rack-component-body">
|
||||
<div className="rack-component-top">
|
||||
<span className="rack-component-type-badge" style={{ color: meta.color }}>
|
||||
{meta.label}
|
||||
</span>
|
||||
<span className="rack-component-name" style={{ color: '#f1f5f9' }}>
|
||||
{comp.name}
|
||||
</span>
|
||||
<span className="rack-component-status">{statusDot}</span>
|
||||
</div>
|
||||
{comp.height_units > 1 && (
|
||||
<div className="rack-component-sub">
|
||||
{comp.model && <span className="rack-component-model">{comp.model}</span>}
|
||||
{comp.ip_address && <span className="rack-component-ip">{comp.ip_address}</span>}
|
||||
<span className="rack-component-units" style={{ color: meta.color }}>{comp.height_units}U</span>
|
||||
</div>
|
||||
)}
|
||||
{comp.height_units === 1 && (
|
||||
<>
|
||||
{comp.model && (
|
||||
<span className="rack-component-model-inline"> · {comp.model}</span>
|
||||
)}
|
||||
{comp.ip_address && (
|
||||
<span className="rack-component-ip-inline"> · {comp.ip_address}</span>
|
||||
)}
|
||||
<span className="rack-component-units-inline" style={{ color: meta.color }}>
|
||||
{' '}{comp.height_units}U
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Rack screw holes */}
|
||||
<div className="rack-screw-col">
|
||||
<div className="rack-screw" />
|
||||
{slot.heightUnits > 1 && <div className="rack-screw" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Rack bottom strip */}
|
||||
<div className="rack-bottom-strip" />
|
||||
</div>
|
||||
|
||||
{/* Unpositioned components */}
|
||||
{components.filter(c => c.position == null).length > 0 && (
|
||||
<div className="rack-unpositioned">
|
||||
<h4 className="rack-unpositioned-title">Unpositioned Components</h4>
|
||||
<div className="rack-unpositioned-list">
|
||||
{components.filter(c => c.position == null).map(comp => {
|
||||
const meta = COMPONENT_META[comp.type];
|
||||
return (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="rack-unpositioned-item"
|
||||
style={{ borderLeft: `3px solid ${meta.color}` }}
|
||||
onClick={() => navigate(`/components/${comp.id}`)}
|
||||
>
|
||||
<span style={{ color: meta.color }}>{meta.label}</span>
|
||||
<span className="rack-unpositioned-name">{comp.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import * as api from '../api';
|
||||
import type { Site, Room, Rack } from '../types';
|
||||
|
||||
interface TreeRoom extends Room {
|
||||
racks: Rack[];
|
||||
expanded: boolean;
|
||||
}
|
||||
interface TreeSite extends Site {
|
||||
rooms: TreeRoom[];
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
const [tree, setTree] = useState<TreeSite[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<Awaited<ReturnType<typeof api.search>> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [addSiteName, setAddSiteName] = useState('');
|
||||
const [showAddSite, setShowAddSite] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const loadTree = useCallback(async () => {
|
||||
const sites = await api.getSites();
|
||||
setTree(sites.map(s => ({ ...s, rooms: [], expanded: false })));
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadTree(); }, [loadTree]);
|
||||
|
||||
const expandSite = async (siteId: string) => {
|
||||
setTree(prev => prev.map(s => {
|
||||
if (s.id !== siteId) return s;
|
||||
if (s.expanded) return { ...s, expanded: false };
|
||||
return { ...s, expanded: true };
|
||||
}));
|
||||
// Load rooms if not loaded
|
||||
setTree(prev => {
|
||||
const site = prev.find(s => s.id === siteId);
|
||||
if (site && site.rooms.length === 0) {
|
||||
api.getSite(siteId).then(full => {
|
||||
setTree(p => p.map(s => s.id === siteId
|
||||
? { ...s, rooms: (full.rooms ?? []).map(r => ({ ...r, racks: [], expanded: false })) }
|
||||
: s
|
||||
));
|
||||
});
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
const expandRoom = async (siteId: string, roomId: string) => {
|
||||
setTree(prev => prev.map(s => s.id !== siteId ? s : {
|
||||
...s,
|
||||
rooms: s.rooms.map(r => {
|
||||
if (r.id !== roomId) return r;
|
||||
if (r.expanded) return { ...r, expanded: false };
|
||||
// Load racks
|
||||
if (r.racks.length === 0) {
|
||||
api.getRacks(roomId).then(racks => {
|
||||
setTree(p => p.map(ss => ss.id !== siteId ? ss : {
|
||||
...ss,
|
||||
rooms: ss.rooms.map(rr => rr.id === roomId ? { ...rr, racks } : rr),
|
||||
}));
|
||||
});
|
||||
}
|
||||
return { ...r, expanded: true };
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddSite = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!addSiteName.trim()) return;
|
||||
const site = await api.createSite({ name: addSiteName.trim() });
|
||||
setAddSiteName('');
|
||||
setShowAddSite(false);
|
||||
await loadTree();
|
||||
navigate(`/sites/${site.id}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!search.trim()) { setSearchResults(null); return; }
|
||||
const t = setTimeout(async () => {
|
||||
const r = await api.search(search);
|
||||
setSearchResults(r);
|
||||
}, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
const isActive = (path: string) => location.pathname === path;
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<Link to="/" className="sidebar-logo">
|
||||
<span className="logo-icon">⬛</span>
|
||||
<span className="logo-text">NetworkView</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
{searchResults && (
|
||||
<div className="search-results">
|
||||
{searchResults.sites.map(s => (
|
||||
<Link key={s.id} to={`/sites/${s.id}`} className="search-result-item" onClick={() => setSearch('')}>
|
||||
<span className="search-result-icon">🏢</span> {s.name}
|
||||
</Link>
|
||||
))}
|
||||
{searchResults.rooms.map(r => (
|
||||
<Link key={r.id} to={`/rooms/${r.id}`} className="search-result-item" onClick={() => setSearch('')}>
|
||||
<span className="search-result-icon">🚪</span> {r.name}
|
||||
</Link>
|
||||
))}
|
||||
{searchResults.racks.map(r => (
|
||||
<Link key={r.id} to={`/racks/${r.id}`} className="search-result-item" onClick={() => setSearch('')}>
|
||||
<span className="search-result-icon">🗄️</span> {r.name}
|
||||
</Link>
|
||||
))}
|
||||
{searchResults.components.map(c => (
|
||||
<Link key={c.id} to={`/components/${c.id}`} className="search-result-item" onClick={() => setSearch('')}>
|
||||
<span className="search-result-icon">🔧</span> {c.name}
|
||||
{c.ip_address && <span className="search-result-sub"> — {c.ip_address}</span>}
|
||||
</Link>
|
||||
))}
|
||||
{!searchResults.sites.length && !searchResults.rooms.length &&
|
||||
!searchResults.racks.length && !searchResults.components.length && (
|
||||
<div className="search-no-results">No results found</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
<div className="sidebar-section-header">
|
||||
<span>Sites</span>
|
||||
<button className="icon-btn" title="Add site" onClick={() => setShowAddSite(v => !v)}>+</button>
|
||||
</div>
|
||||
|
||||
{showAddSite && (
|
||||
<form onSubmit={handleAddSite} className="inline-add-form">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="Site name..."
|
||||
value={addSiteName}
|
||||
onChange={e => setAddSiteName(e.target.value)}
|
||||
className="inline-input"
|
||||
/>
|
||||
<button type="submit" className="inline-btn">Add</button>
|
||||
<button type="button" className="inline-btn secondary" onClick={() => setShowAddSite(false)}>✕</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{loading && <div className="sidebar-loading">Loading...</div>}
|
||||
|
||||
{tree.map(site => (
|
||||
<div key={site.id} className="tree-node">
|
||||
<div className={`tree-item tree-site ${isActive(`/sites/${site.id}`) ? 'active' : ''}`}>
|
||||
<button className="tree-expand" onClick={() => expandSite(site.id)}>
|
||||
{site.expanded ? '▾' : '▸'}
|
||||
</button>
|
||||
<Link to={`/sites/${site.id}`} className="tree-label">
|
||||
<span className="tree-icon">🏢</span>
|
||||
<span className="tree-name">{site.name}</span>
|
||||
{site.room_count != null && (
|
||||
<span className="tree-count">{site.room_count}</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{site.expanded && (
|
||||
<div className="tree-children">
|
||||
{site.rooms.map(room => (
|
||||
<div key={room.id} className="tree-node">
|
||||
<div className={`tree-item tree-room ${isActive(`/rooms/${room.id}`) ? 'active' : ''}`}>
|
||||
<button className="tree-expand" onClick={() => expandRoom(site.id, room.id)}>
|
||||
{room.expanded ? '▾' : '▸'}
|
||||
</button>
|
||||
<Link to={`/rooms/${room.id}`} className="tree-label">
|
||||
<span className="tree-icon">🚪</span>
|
||||
<span className="tree-name">{room.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{room.expanded && (
|
||||
<div className="tree-children">
|
||||
{room.racks.map(rack => (
|
||||
<div key={rack.id} className={`tree-item tree-rack ${isActive(`/racks/${rack.id}`) ? 'active' : ''}`}>
|
||||
<Link to={`/racks/${rack.id}`} className="tree-label">
|
||||
<span className="tree-icon">🗄️</span>
|
||||
<span className="tree-name">{rack.name}</span>
|
||||
<span className="tree-count">{rack.total_units}U</span>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{site.rooms.length === 0 && (
|
||||
<div className="tree-empty">No rooms yet</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!loading && tree.length === 0 && (
|
||||
<div className="sidebar-empty">
|
||||
<p>No sites yet.</p>
|
||||
<button className="btn-primary btn-sm" onClick={() => setShowAddSite(true)}>
|
||||
+ Add your first site
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,479 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import * as api from '../api';
|
||||
import type { Component, Port } from '../types';
|
||||
import { COMPONENT_META, COMPONENT_TYPES } from '../types';
|
||||
import MarkdownEditor from '../components/MarkdownEditor';
|
||||
|
||||
const STATUS_OPTIONS = ['active', 'maintenance', 'decommissioned'] as const;
|
||||
|
||||
export default function ComponentPage() {
|
||||
const { componentId } = useParams<{ componentId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [component, setComponent] = useState<Component | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [form, setForm] = useState<Partial<Component>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
type PortModalState = { editPort: Port | null };
|
||||
const BLANK_PORT_FORM = { port_number: '', label: '', port_type: 'RJ45', notes: '', connected_to_port_id: '' };
|
||||
const [portModal, setPortModal] = useState<PortModalState | null>(null);
|
||||
const [portForm, setPortForm] = useState(BLANK_PORT_FORM);
|
||||
const [patchPanels, setPatchPanels] = useState<Component[]>([]);
|
||||
|
||||
const loadComponent = useCallback(async () => {
|
||||
if (!componentId) return;
|
||||
const c = await api.getComponent(componentId);
|
||||
setComponent(c);
|
||||
setNotes(c.notes ?? '');
|
||||
setForm({
|
||||
name: c.name, type: c.type, position: c.position, height_units: c.height_units,
|
||||
manufacturer: c.manufacturer, model: c.model, serial_number: c.serial_number,
|
||||
asset_tag: c.asset_tag, ip_address: c.ip_address, mac_address: c.mac_address, status: c.status,
|
||||
port_count: c.port_count,
|
||||
sfp_count: c.sfp_count,
|
||||
});
|
||||
setLoading(false);
|
||||
if (c.type === 'switch' && c.rack?.id) {
|
||||
const all = await api.getComponents(c.rack.id);
|
||||
setPatchPanels(all.filter(comp => comp.type === 'patch_panel'));
|
||||
}
|
||||
}, [componentId]);
|
||||
|
||||
useEffect(() => { loadComponent(); }, [loadComponent]);
|
||||
|
||||
const saveNotes = async (val: string) => {
|
||||
if (!componentId) return;
|
||||
await api.updateComponent(componentId, { notes: val });
|
||||
};
|
||||
|
||||
const saveMeta = async () => {
|
||||
if (!componentId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.updateComponent(componentId, form);
|
||||
await loadComponent();
|
||||
setEditing(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!componentId || !component || !confirm(`Delete component "${component.name}"?`)) return;
|
||||
await api.deleteComponent(componentId);
|
||||
navigate(component.rack ? `/racks/${component.rack.id}` : '/');
|
||||
};
|
||||
|
||||
const handleDeletePort = async (portId: string) => {
|
||||
if (!componentId || !confirm('Delete this port?')) return;
|
||||
await api.deletePort(componentId, portId);
|
||||
await loadComponent();
|
||||
};
|
||||
|
||||
const openPortModal = (port?: Port) => {
|
||||
setPortModal({ editPort: port ?? null });
|
||||
setPortForm(port ? {
|
||||
port_number: String(port.port_number),
|
||||
label: port.label ?? '',
|
||||
port_type: port.port_type,
|
||||
notes: port.notes ?? '',
|
||||
connected_to_port_id: port.connected_to_port_id ?? '',
|
||||
} : BLANK_PORT_FORM);
|
||||
};
|
||||
const closePortModal = () => setPortModal(null);
|
||||
|
||||
const handleSavePort = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!componentId || !portForm.port_number) return;
|
||||
if (portModal?.editPort) {
|
||||
await api.updatePort(componentId, portModal.editPort.id, {
|
||||
label: portForm.label || undefined,
|
||||
port_type: portForm.port_type,
|
||||
notes: portForm.notes || undefined,
|
||||
connected_to_port_id: portForm.connected_to_port_id || null,
|
||||
});
|
||||
} else {
|
||||
await api.createPort(componentId, {
|
||||
port_number: Number(portForm.port_number),
|
||||
label: portForm.label || undefined,
|
||||
port_type: portForm.port_type,
|
||||
notes: portForm.notes || undefined,
|
||||
connected_to_port_id: portForm.connected_to_port_id || undefined,
|
||||
});
|
||||
}
|
||||
setPortModal(null);
|
||||
await loadComponent();
|
||||
};
|
||||
|
||||
const setField = (key: keyof Component, val: unknown) =>
|
||||
setForm(f => ({ ...f, [key]: val }));
|
||||
|
||||
if (loading || !component) return <div className="page-loading">Loading…</div>;
|
||||
|
||||
const meta = COMPONENT_META[component.type];
|
||||
const ports: Port[] = component.ports ?? [];
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<div className="breadcrumb">
|
||||
<Link to="/">Dashboard</Link>
|
||||
{component.site && <><span> / </span><Link to={`/sites/${component.site.id}`}>{component.site.name}</Link></>}
|
||||
{component.room && <><span> / </span><Link to={`/rooms/${component.room.id}`}>{component.room.name}</Link></>}
|
||||
{component.rack && <><span> / </span><Link to={`/racks/${component.rack.id}`}>{component.rack.name}</Link></>}
|
||||
<span> / </span>
|
||||
<span>{component.name}</span>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
{component.rack && (
|
||||
<button className="btn-back btn-sm" onClick={() => navigate(`/racks/${component.rack!.id}`)}>← Back to Rack</button>
|
||||
)}
|
||||
<button className="btn-secondary btn-sm" onClick={() => setEditing(v => !v)}>
|
||||
{editing ? 'Cancel' : '✎ Edit'}
|
||||
</button>
|
||||
<button className="btn-danger btn-sm" onClick={handleDelete}>🗑 Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Component Header */}
|
||||
<div className="component-header" style={{ borderLeft: `6px solid ${meta.color}` }}>
|
||||
<div className="component-type-badge" style={{ background: meta.bg, color: meta.color }}>
|
||||
{meta.label}
|
||||
</div>
|
||||
<h1 className="entity-title">{component.name}</h1>
|
||||
<span className={`status-badge status-${component.status}`}>{component.status}</span>
|
||||
</div>
|
||||
|
||||
{/* Meta fields */}
|
||||
<section className="content-section">
|
||||
{editing ? (
|
||||
<div className="edit-meta-form">
|
||||
<div className="form-row">
|
||||
<div className="form-group form-group-lg">
|
||||
<label>Name</label>
|
||||
<input className="form-input" value={form.name ?? ''} onChange={e => setField('name', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Type</label>
|
||||
<select className="form-input" value={form.type ?? 'other'} onChange={e => setField('type', e.target.value)}>
|
||||
{COMPONENT_TYPES.map(t => <option key={t} value={t}>{COMPONENT_META[t].label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Status</label>
|
||||
<select className="form-input" value={form.status ?? 'active'} onChange={e => setField('status', e.target.value)}>
|
||||
{STATUS_OPTIONS.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Position (U)</label>
|
||||
<input type="number" className="form-input" value={form.position ?? ''} onChange={e => setField('position', e.target.value ? Number(e.target.value) : null)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Height (U)</label>
|
||||
<input type="number" className="form-input" value={form.height_units ?? 1} onChange={e => setField('height_units', Number(e.target.value))} />
|
||||
</div>
|
||||
{(form.type === 'switch' || form.type === 'patch_panel') && (
|
||||
<div className="form-group">
|
||||
<label>{form.type === 'patch_panel' ? 'Available Patches' : 'Switch Ports'}</label>
|
||||
<input
|
||||
type="number" min={1} max={96} className="form-input"
|
||||
value={form.port_count ?? 24}
|
||||
onChange={e => setField('port_count', Number(e.target.value))}
|
||||
/>
|
||||
<span className="form-hint">
|
||||
{form.type === 'patch_panel' ? 'RJ45 jacks on front panel' : 'Ethernet port count'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{form.type === 'switch' && (
|
||||
<div className="form-group">
|
||||
<label>Fiber / SFP Ports</label>
|
||||
<input
|
||||
type="number" min={0} max={16} className="form-input"
|
||||
value={form.sfp_count ?? 0}
|
||||
onChange={e => setField('sfp_count', Number(e.target.value))}
|
||||
/>
|
||||
<span className="form-hint">SFP / fiber uplink slots</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Manufacturer</label>
|
||||
<input className="form-input" value={form.manufacturer ?? ''} onChange={e => setField('manufacturer', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Model</label>
|
||||
<input className="form-input" value={form.model ?? ''} onChange={e => setField('model', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>IP Address</label>
|
||||
<input className="form-input" value={form.ip_address ?? ''} onChange={e => setField('ip_address', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>MAC Address</label>
|
||||
<input className="form-input" value={form.mac_address ?? ''} onChange={e => setField('mac_address', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Serial Number</label>
|
||||
<input className="form-input" value={form.serial_number ?? ''} onChange={e => setField('serial_number', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Asset Tag</label>
|
||||
<input className="form-input" value={form.asset_tag ?? ''} onChange={e => setField('asset_tag', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button className="btn-primary btn-sm" onClick={saveMeta} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
<button className="btn-secondary btn-sm" onClick={() => setEditing(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="component-meta-grid">
|
||||
<MetaField label="Type" value={meta.label} />
|
||||
<MetaField label="Status" value={component.status} />
|
||||
<MetaField label="Rack Position" value={component.position != null ? `${component.position}U` : '—'} />
|
||||
<MetaField label="Height" value={`${component.height_units}U`} />
|
||||
<MetaField label="Manufacturer" value={component.manufacturer} />
|
||||
<MetaField label="Model" value={component.model} />
|
||||
<MetaField label="IP Address" value={component.ip_address} />
|
||||
<MetaField label="MAC Address" value={component.mac_address} />
|
||||
<MetaField label="Serial Number" value={component.serial_number} />
|
||||
<MetaField label="Asset Tag" value={component.asset_tag} />
|
||||
{(component.type === 'switch' || component.type === 'patch_panel') && (
|
||||
<MetaField
|
||||
label={component.type === 'patch_panel' ? 'Available Patches' : 'Port Count'}
|
||||
value={component.port_count != null ? `${component.port_count} ports` : null}
|
||||
/>
|
||||
)}
|
||||
{component.type === 'switch' && (
|
||||
<MetaField
|
||||
label="Fiber / SFP Ports"
|
||||
value={component.sfp_count != null ? `${component.sfp_count} SFP slots` : null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Ports */}
|
||||
<section className="content-section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">Ports / Connections</h2>
|
||||
<button className="btn-primary btn-sm" onClick={() => openPortModal()}>
|
||||
+ {component.type === 'patch_panel' ? 'Add PP Port' : 'Add SW Port'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{ports.length === 0 ? (
|
||||
<div className="empty-state-sm">No ports documented yet.</div>
|
||||
) : (
|
||||
<div className="ports-grid">
|
||||
{ports.map(port => (
|
||||
<div key={port.id} className="port-item">
|
||||
<div className="port-number">{port.port_number}</div>
|
||||
<div className="port-info">
|
||||
<div className="port-label">{port.label || '—'}</div>
|
||||
{component.type !== 'patch_panel' && (
|
||||
<div className="port-type">{port.port_type}</div>
|
||||
)}
|
||||
{port.notes && <div className="port-notes">{port.notes}</div>}
|
||||
{port.linked_port && (
|
||||
<div className="port-link-chain">
|
||||
{component.type === 'switch'
|
||||
? `↔ ${port.linked_port.component_name} : P${port.linked_port.port_number}${port.linked_port.label ? ` → ${port.linked_port.label}` : ''}`
|
||||
: `↔ ${port.linked_port.component_name} : P${port.linked_port.port_number}`
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="port-edit-btn" onClick={() => openPortModal(port)} title="Edit port">✎</button>
|
||||
<button className="port-delete" onClick={() => handleDeletePort(port.id)} title="Delete port">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Port Modal */}
|
||||
{portModal !== null && (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && closePortModal()}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<span className="modal-title">
|
||||
{portModal.editPort
|
||||
? `Edit ${component.type === 'patch_panel' ? 'PP' : 'SW'} Port ${portModal.editPort.port_number}`
|
||||
: `Add ${component.type === 'patch_panel' ? 'PP Port' : 'SW Port'}`}
|
||||
</span>
|
||||
<button className="modal-close" onClick={closePortModal}>✕</button>
|
||||
</div>
|
||||
<form onSubmit={handleSavePort} style={{ padding: '20px', display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>{component.type === 'patch_panel' ? 'PP Port #' : 'SW Port #'}</label>
|
||||
<input
|
||||
type="number" className="form-input"
|
||||
value={portForm.port_number}
|
||||
onChange={e => setPortForm(f => ({ ...f, port_number: e.target.value }))}
|
||||
required
|
||||
disabled={!!portModal.editPort}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group form-group-lg">
|
||||
<label>{component.type === 'patch_panel' ? 'End Device' : 'Label'}</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={portForm.label}
|
||||
onChange={e => setPortForm(f => ({ ...f, label: e.target.value }))}
|
||||
placeholder={component.type === 'patch_panel' ? 'e.g. Desktop 1, Printer, IP Camera' : 'e.g. Uplink-01'}
|
||||
autoFocus={!portModal.editPort}
|
||||
/>
|
||||
</div>
|
||||
{component.type !== 'patch_panel' && (
|
||||
<div className="form-group">
|
||||
<label>Type</label>
|
||||
<select className="form-input" value={portForm.port_type}
|
||||
onChange={e => setPortForm(f => ({ ...f, port_type: e.target.value }))}>
|
||||
{['RJ45', 'SFP', 'SFP+', 'QSFP', 'LC', 'SC', 'Serial', 'USB', 'HDMI'].map(t =>
|
||||
<option key={t} value={t}>{t}</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label>Notes</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={portForm.notes}
|
||||
onChange={e => setPortForm(f => ({ ...f, notes: e.target.value }))}
|
||||
placeholder={component.type === 'patch_panel' ? 'e.g. Room A, Floor 2' : 'e.g. VLAN 10'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{component.type === 'switch' && patchPanels.length > 0 && (
|
||||
<PpLinkPicker
|
||||
key={portModal.editPort?.id ?? 'add'}
|
||||
patchPanels={patchPanels}
|
||||
initialPpId={
|
||||
portModal.editPort?.linked_port?.component_type === 'patch_panel'
|
||||
? portModal.editPort.linked_port.component_id
|
||||
: undefined
|
||||
}
|
||||
value={portForm.connected_to_port_id}
|
||||
onChange={id => setPortForm(f => ({ ...f, connected_to_port_id: id }))}
|
||||
/>
|
||||
)}
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn-primary btn-sm">
|
||||
{portModal.editPort ? 'Save Changes' : (component.type === 'patch_panel' ? 'Add PP Port' : 'Add SW Port')}
|
||||
</button>
|
||||
<button type="button" className="btn-secondary btn-sm" onClick={closePortModal}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<section className="content-section">
|
||||
<h2 className="section-title">Documentation</h2>
|
||||
<MarkdownEditor value={notes} onChange={setNotes} onSave={saveNotes} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaField({ label, value }: { label: string; value?: string | number | null }) {
|
||||
if (!value && value !== 0) return null;
|
||||
return (
|
||||
<div className="meta-field">
|
||||
<span className="meta-field-label">{label}</span>
|
||||
<span className="meta-field-value">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PpLinkPicker({
|
||||
patchPanels,
|
||||
value,
|
||||
initialPpId,
|
||||
onChange,
|
||||
}: {
|
||||
patchPanels: Component[];
|
||||
value: string;
|
||||
initialPpId?: string;
|
||||
onChange: (portId: string) => void;
|
||||
}) {
|
||||
const [selectedPpId, setSelectedPpId] = useState(initialPpId ?? '');
|
||||
const [ppPorts, setPpPorts] = useState<Port[]>([]);
|
||||
const [loadingPp, setLoadingPp] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPpId) { setPpPorts([]); return; }
|
||||
setLoadingPp(true);
|
||||
api.getComponent(selectedPpId).then(pp => {
|
||||
setPpPorts(pp.ports ?? []);
|
||||
setLoadingPp(false);
|
||||
});
|
||||
}, [selectedPpId]);
|
||||
|
||||
const selectedPort = ppPorts.find(p => p.id === value);
|
||||
|
||||
return (
|
||||
<div className="pp-link-picker">
|
||||
<span className="pp-link-label">🔗 Link to Patch Panel</span>
|
||||
<div className="pp-link-row">
|
||||
<select
|
||||
className="form-input form-input-sm"
|
||||
value={selectedPpId}
|
||||
onChange={e => { setSelectedPpId(e.target.value); onChange(''); }}
|
||||
>
|
||||
<option value="">— no patch panel —</option>
|
||||
{patchPanels.map(pp => (
|
||||
<option key={pp.id} value={pp.id}>{pp.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedPpId && (
|
||||
<select
|
||||
className="form-input form-input-sm"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
disabled={loadingPp}
|
||||
>
|
||||
<option value="">— select port —</option>
|
||||
{ppPorts.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
Port {p.port_number}{p.label ? ` — ${p.label}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{selectedPort?.label && (
|
||||
<div className="pp-link-device-hint">
|
||||
↳ End device: <strong>{selectedPort.label}</strong>
|
||||
</div>
|
||||
)}
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
className="pp-link-clear"
|
||||
onClick={() => { onChange(''); setSelectedPpId(''); }}
|
||||
>
|
||||
✕ Clear link
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import * as api from '../api';
|
||||
import type { Site } from '../types';
|
||||
|
||||
export default function HomePage() {
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSites().then(s => { setSites(s); setLoading(false); });
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="page-loading">Loading…</div>;
|
||||
|
||||
if (sites.length === 0) {
|
||||
return (
|
||||
<div className="welcome-screen">
|
||||
<div className="welcome-icon">🗄️</div>
|
||||
<h1 className="welcome-title">Welcome to NetworkView</h1>
|
||||
<p className="welcome-sub">
|
||||
Document your home lab or enterprise network.<br />
|
||||
Create sites, add server rooms, build rack layouts, and keep Markdown notes on every device.
|
||||
</p>
|
||||
<div className="welcome-steps">
|
||||
<div className="step">
|
||||
<div className="step-num">1</div>
|
||||
<div>Click <strong>+</strong> next to "Sites" in the sidebar to create your first site.</div>
|
||||
</div>
|
||||
<div className="step">
|
||||
<div className="step-num">2</div>
|
||||
<div>Add a <strong>Room</strong> to the site (e.g. "Server Closet").</div>
|
||||
</div>
|
||||
<div className="step">
|
||||
<div className="step-num">3</div>
|
||||
<div>Create a <strong>Rack</strong> in the room and start adding devices.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Dashboard</h1>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
{sites.map(site => (
|
||||
<Link key={site.id} to={`/sites/${site.id}`} className="dashboard-card">
|
||||
<div className="dashboard-card-icon">🏢</div>
|
||||
<div className="dashboard-card-body">
|
||||
<div className="dashboard-card-name">{site.name}</div>
|
||||
{site.location && <div className="dashboard-card-sub">{site.location}</div>}
|
||||
<div className="dashboard-card-meta">{site.room_count ?? 0} rooms</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import * as api from '../api';
|
||||
import type { Rack, Component } from '../types';
|
||||
import { COMPONENT_META } from '../types';
|
||||
import RackGraphicView from '../components/RackGraphicView';
|
||||
import MarkdownEditor from '../components/MarkdownEditor';
|
||||
import { AddComponentModal, SimpleCreateModal } from '../components/AddItemModal';
|
||||
import type { ComponentFormData } from '../components/AddItemModal';
|
||||
|
||||
export default function RackPage() {
|
||||
const { rackId } = useParams<{ rackId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [rack, setRack] = useState<Rack | null>(null);
|
||||
const [components, setComponents] = useState<Component[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [showAddComponent, setShowAddComponent] = useState(false);
|
||||
const [addAtPosition, setAddAtPosition] = useState<number | undefined>(undefined);
|
||||
const [showEditRack, setShowEditRack] = useState(false);
|
||||
|
||||
const loadRack = useCallback(async () => {
|
||||
if (!rackId) return;
|
||||
const data = await api.getRack(rackId);
|
||||
setRack(data);
|
||||
setComponents(data.components ?? []);
|
||||
setNotes(data.notes ?? '');
|
||||
setLoading(false);
|
||||
}, [rackId]);
|
||||
|
||||
useEffect(() => { loadRack(); }, [loadRack]);
|
||||
|
||||
const saveNotes = async (val: string) => {
|
||||
if (!rackId) return;
|
||||
await api.updateRack(rackId, { notes: val });
|
||||
};
|
||||
|
||||
const handleAddComponent = async (formData: ComponentFormData) => {
|
||||
await api.createComponent(formData);
|
||||
setShowAddComponent(false);
|
||||
await loadRack();
|
||||
};
|
||||
|
||||
const handleAddAtSlot = (position: number) => {
|
||||
setAddAtPosition(position);
|
||||
setShowAddComponent(true);
|
||||
};
|
||||
|
||||
const handleEditRack = async (data: Record<string, string | number>) => {
|
||||
if (!rackId) return;
|
||||
await api.updateRack(rackId, {
|
||||
name: data.name as string,
|
||||
total_units: Number(data.total_units),
|
||||
manufacturer: data.manufacturer as string,
|
||||
model: data.model as string,
|
||||
});
|
||||
setShowEditRack(false);
|
||||
await loadRack();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!rackId || !rack || !confirm(`Delete rack "${rack.name}" and all its components?`)) return;
|
||||
await api.deleteRack(rackId);
|
||||
navigate(rack.room ? `/rooms/${rack.room.id}` : '/');
|
||||
};
|
||||
|
||||
if (loading || !rack) return <div className="page-loading">Loading…</div>;
|
||||
|
||||
const usedUnits = components.reduce((s, c) => s + (c.position != null ? c.height_units : 0), 0);
|
||||
const freeUnits = rack.total_units - usedUnits;
|
||||
|
||||
return (
|
||||
<div className="page page-rack">
|
||||
<div className="page-header">
|
||||
<div className="breadcrumb">
|
||||
<Link to="/">Dashboard</Link>
|
||||
{rack.site && <><span> / </span><Link to={`/sites/${rack.site.id}`}>{rack.site.name}</Link></>}
|
||||
{rack.room && <><span> / </span><Link to={`/rooms/${rack.room.id}`}>{rack.room.name}</Link></>}
|
||||
<span> / </span>
|
||||
<span>{rack.name}</span>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
{rack.room && (
|
||||
<button className="btn-back btn-sm" onClick={() => navigate(`/rooms/${rack.room!.id}`)}>← Back to Room</button>
|
||||
)}
|
||||
<button className="btn-secondary btn-sm" onClick={() => setShowEditRack(true)}>✎ Edit</button>
|
||||
<button className="btn-primary btn-sm" onClick={() => { setAddAtPosition(undefined); setShowAddComponent(true); }}>
|
||||
+ Add Component
|
||||
</button>
|
||||
<button className="btn-danger btn-sm" onClick={handleDelete}>🗑 Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="entity-header">
|
||||
<h1 className="entity-title">
|
||||
<span className="entity-icon">🗄️</span> {rack.name}
|
||||
</h1>
|
||||
<div className="entity-meta-row">
|
||||
<span className="entity-meta-chip">{rack.total_units}U total</span>
|
||||
<span className="entity-meta-chip" style={{ color: '#f59e0b' }}>{usedUnits}U used</span>
|
||||
<span className="entity-meta-chip" style={{ color: '#34d399' }}>{freeUnits}U free</span>
|
||||
{rack.manufacturer && <span className="entity-meta-chip">{rack.manufacturer}</span>}
|
||||
{rack.model && <span className="entity-meta-chip">{rack.model}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-column rack layout */}
|
||||
<div className="rack-page-columns">
|
||||
{/* LEFT: Component management */}
|
||||
<div className="rack-page-left">
|
||||
<section className="content-section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">Components</h2>
|
||||
<button className="btn-primary btn-sm" onClick={() => { setAddAtPosition(undefined); setShowAddComponent(true); }}>
|
||||
+ Add Component
|
||||
</button>
|
||||
</div>
|
||||
{components.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No components yet.</p>
|
||||
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 4 }}>
|
||||
Click <strong>+ Add Component</strong> or click an empty slot in the rack diagram →
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="component-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>U</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Model</th>
|
||||
<th>IP</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...components]
|
||||
.sort((a, b) => (a.position ?? 9999) - (b.position ?? 9999))
|
||||
.map(c => {
|
||||
const meta = COMPONENT_META[c.type];
|
||||
return (
|
||||
<tr key={c.id} className="component-table-row" onClick={() => navigate(`/components/${c.id}`)} style={{ borderLeft: `3px solid ${meta.color}` }}>
|
||||
<td className="td-position">{c.position ?? '—'}</td>
|
||||
<td className="td-name">{c.name}</td>
|
||||
<td className="td-type" style={{ color: meta.color }}>{meta.label}</td>
|
||||
<td className="td-model">{c.model ?? '—'}</td>
|
||||
<td className="td-ip">{c.ip_address ?? '—'}</td>
|
||||
<td className="td-status"><span className={`status-badge status-${c.status}`}>{c.status}</span></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="content-section">
|
||||
<h2 className="section-title">Rack Notes</h2>
|
||||
<MarkdownEditor value={notes} onChange={setNotes} onSave={saveNotes} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Graphical front panel */}
|
||||
<div className="rack-page-right">
|
||||
<div className="rack-page-right-scroll">
|
||||
<div className="rack-page-right-header">
|
||||
<span className="rack-page-right-title">Front Panel View</span>
|
||||
<span className="rack-page-right-hint">Click slot to add · Click device to open</span>
|
||||
</div>
|
||||
<RackGraphicView
|
||||
rack={rack}
|
||||
components={components}
|
||||
onAddAtSlot={handleAddAtSlot}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAddComponent && rackId && (
|
||||
<AddComponentModal
|
||||
rackId={rackId}
|
||||
totalUnits={rack.total_units}
|
||||
initialPosition={addAtPosition}
|
||||
onSave={handleAddComponent}
|
||||
onClose={() => setShowAddComponent(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showEditRack && (
|
||||
<SimpleCreateModal
|
||||
title="Edit Rack"
|
||||
fields={[
|
||||
{ key: 'name', label: 'Rack Name', defaultValue: rack.name },
|
||||
{ key: 'total_units', label: 'Size (U)', type: 'number', defaultValue: rack.total_units },
|
||||
{ key: 'manufacturer', label: 'Manufacturer', defaultValue: rack.manufacturer ?? '' },
|
||||
{ key: 'model', label: 'Model', defaultValue: rack.model ?? '' },
|
||||
]}
|
||||
onSave={handleEditRack}
|
||||
onClose={() => setShowEditRack(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import * as api from '../api';
|
||||
import type { Room, Rack } from '../types';
|
||||
import MarkdownEditor from '../components/MarkdownEditor';
|
||||
import { SimpleCreateModal } from '../components/AddItemModal';
|
||||
|
||||
export default function RoomPage() {
|
||||
const { roomId } = useParams<{ roomId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [room, setRoom] = useState<Room | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [showAddRack, setShowAddRack] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomId) return;
|
||||
api.getRoom(roomId).then(r => {
|
||||
setRoom(r);
|
||||
setNotes(r.notes ?? '');
|
||||
setEditName(r.name);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [roomId]);
|
||||
|
||||
const saveNotes = async (val: string) => {
|
||||
if (!roomId) return;
|
||||
await api.updateRoom(roomId, { notes: val });
|
||||
};
|
||||
|
||||
const saveMeta = async () => {
|
||||
if (!roomId) return;
|
||||
await api.updateRoom(roomId, { name: editName });
|
||||
setRoom(r => r ? { ...r, name: editName } : r);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!roomId || !confirm(`Delete room "${room?.name}" and all its racks?`)) return;
|
||||
await api.deleteRoom(roomId);
|
||||
navigate(room?.site?.id ? `/sites/${room.site.id}` : '/');
|
||||
};
|
||||
|
||||
const handleAddRack = async (data: Record<string, string | number>) => {
|
||||
if (!roomId) return;
|
||||
const rack = await api.createRack({
|
||||
room_id: roomId,
|
||||
name: data.name as string,
|
||||
total_units: Number(data.total_units) || 42,
|
||||
});
|
||||
setShowAddRack(false);
|
||||
navigate(`/racks/${rack.id}`);
|
||||
};
|
||||
|
||||
if (loading || !room) return <div className="page-loading">Loading…</div>;
|
||||
|
||||
const racks = room.racks ?? [];
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<div className="breadcrumb">
|
||||
<Link to="/">Dashboard</Link>
|
||||
{room.site && <><span> / </span><Link to={`/sites/${room.site.id}`}>{room.site.name}</Link></>}
|
||||
<span> / </span>
|
||||
<span>{room.name}</span>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
{room.site && (
|
||||
<button className="btn-back btn-sm" onClick={() => navigate(`/sites/${room.site!.id}`)}>← Back to Site</button>
|
||||
)}
|
||||
<button className="btn-secondary btn-sm" onClick={() => setEditing(v => !v)}>
|
||||
{editing ? 'Cancel' : '✎ Edit'}
|
||||
</button>
|
||||
<button className="btn-danger btn-sm" onClick={handleDelete}>🗑 Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="edit-meta-form">
|
||||
<div className="form-group">
|
||||
<label>Room Name</label>
|
||||
<input className="form-input" value={editName} onChange={e => setEditName(e.target.value)} />
|
||||
</div>
|
||||
<button className="btn-primary btn-sm" onClick={saveMeta}>Save</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="entity-header">
|
||||
<h1 className="entity-title">
|
||||
<span className="entity-icon">🚪</span> {room.name}
|
||||
</h1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Racks */}
|
||||
<section className="content-section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">Racks</h2>
|
||||
<button className="btn-primary btn-sm" onClick={() => setShowAddRack(true)}>+ Add Rack</button>
|
||||
</div>
|
||||
|
||||
{racks.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No racks yet. Add a rack to start building your layout.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="racks-grid">
|
||||
{racks.map((rack: Rack) => (
|
||||
<Link key={rack.id} to={`/racks/${rack.id}`} className="rack-card">
|
||||
<div className="rack-card-visual">
|
||||
{Array.from({ length: Math.min(rack.total_units, 8) }).map((_, i) => (
|
||||
<div key={i} className="rack-card-unit" />
|
||||
))}
|
||||
</div>
|
||||
<div className="rack-card-info">
|
||||
<div className="rack-card-name">{rack.name}</div>
|
||||
<div className="rack-card-meta">
|
||||
{rack.total_units}U
|
||||
{rack.manufacturer && ` · ${rack.manufacturer}`}
|
||||
{rack.model && ` ${rack.model}`}
|
||||
</div>
|
||||
<div className="rack-card-count">{rack.component_count ?? 0} devices</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="content-section">
|
||||
<h2 className="section-title">Notes</h2>
|
||||
<MarkdownEditor value={notes} onChange={setNotes} onSave={saveNotes} />
|
||||
</section>
|
||||
|
||||
{showAddRack && (
|
||||
<SimpleCreateModal
|
||||
title="Add Rack"
|
||||
fields={[
|
||||
{ key: 'name', label: 'Rack Name', placeholder: 'e.g. RACK-01' },
|
||||
{ key: 'total_units', label: 'Size (U)', type: 'number', defaultValue: 42, placeholder: '42' },
|
||||
]}
|
||||
onSave={handleAddRack}
|
||||
onClose={() => setShowAddRack(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import * as api from '../api';
|
||||
import type { Site, Room } from '../types';
|
||||
import MarkdownEditor from '../components/MarkdownEditor';
|
||||
import { SimpleCreateModal } from '../components/AddItemModal';
|
||||
|
||||
export default function SitePage() {
|
||||
const { siteId } = useParams<{ siteId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [site, setSite] = useState<Site & { rooms: Room[] } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editLocation, setEditLocation] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [showAddRoom, setShowAddRoom] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteId) return;
|
||||
api.getSite(siteId).then(s => {
|
||||
setSite(s);
|
||||
setNotes(s.notes ?? '');
|
||||
setEditName(s.name);
|
||||
setEditLocation(s.location ?? '');
|
||||
setLoading(false);
|
||||
});
|
||||
}, [siteId]);
|
||||
|
||||
const saveNotes = async (val: string) => {
|
||||
if (!siteId) return;
|
||||
await api.updateSite(siteId, { notes: val });
|
||||
};
|
||||
|
||||
const saveMeta = async () => {
|
||||
if (!siteId) return;
|
||||
await api.updateSite(siteId, { name: editName, location: editLocation });
|
||||
setSite(s => s ? { ...s, name: editName, location: editLocation } : s);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!siteId || !confirm(`Delete site "${site?.name}" and all its contents?`)) return;
|
||||
await api.deleteSite(siteId);
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const handleAddRoom = async (data: Record<string, string | number>) => {
|
||||
if (!siteId) return;
|
||||
const room = await api.createRoom({ site_id: siteId, name: data.name as string });
|
||||
setShowAddRoom(false);
|
||||
navigate(`/rooms/${room.id}`);
|
||||
};
|
||||
|
||||
if (loading || !site) return <div className="page-loading">Loading…</div>;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<div className="breadcrumb">
|
||||
<Link to="/">Dashboard</Link>
|
||||
<span> / </span>
|
||||
<span>{site.name}</span>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<button className="btn-back btn-sm" onClick={() => navigate('/')}>← Dashboard</button>
|
||||
<button className="btn-secondary btn-sm" onClick={() => setEditing(v => !v)}>
|
||||
{editing ? 'Cancel' : '✎ Edit'}
|
||||
</button>
|
||||
<button className="btn-danger btn-sm" onClick={handleDelete}>🗑 Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="edit-meta-form">
|
||||
<div className="form-row">
|
||||
<div className="form-group form-group-lg">
|
||||
<label>Site Name</label>
|
||||
<input className="form-input" value={editName} onChange={e => setEditName(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Location</label>
|
||||
<input className="form-input" value={editLocation} onChange={e => setEditLocation(e.target.value)} placeholder="e.g. Building A" />
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn-primary btn-sm" onClick={saveMeta}>Save</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="entity-header">
|
||||
<h1 className="entity-title">
|
||||
<span className="entity-icon">🏢</span> {site.name}
|
||||
</h1>
|
||||
{site.location && <div className="entity-location">📍 {site.location}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rooms list */}
|
||||
<section className="content-section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">Rooms</h2>
|
||||
<button className="btn-primary btn-sm" onClick={() => setShowAddRoom(true)}>+ Add Room</button>
|
||||
</div>
|
||||
|
||||
{site.rooms.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No rooms yet. Add a room to start documenting your racks.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cards-grid">
|
||||
{site.rooms.map(room => (
|
||||
<Link key={room.id} to={`/rooms/${room.id}`} className="entity-card">
|
||||
<span className="entity-card-icon">🚪</span>
|
||||
<div className="entity-card-body">
|
||||
<div className="entity-card-name">{room.name}</div>
|
||||
<div className="entity-card-meta">{room.rack_count ?? 0} racks</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Notes */}
|
||||
<section className="content-section">
|
||||
<h2 className="section-title">Notes</h2>
|
||||
<MarkdownEditor value={notes} onChange={setNotes} onSave={saveNotes} />
|
||||
</section>
|
||||
|
||||
{showAddRoom && (
|
||||
<SimpleCreateModal
|
||||
title="Add Room"
|
||||
fields={[{ key: 'name', label: 'Room Name', placeholder: 'e.g. Server Room A' }]}
|
||||
onSave={handleAddRoom}
|
||||
onClose={() => setShowAddRoom(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
export type ComponentType =
|
||||
| 'server'
|
||||
| 'switch'
|
||||
| 'router'
|
||||
| 'firewall'
|
||||
| 'patch_panel'
|
||||
| 'ups'
|
||||
| 'pdu'
|
||||
| 'kvm'
|
||||
| 'storage'
|
||||
| 'other';
|
||||
|
||||
export type ComponentStatus = 'active' | 'maintenance' | 'decommissioned';
|
||||
|
||||
export interface Site {
|
||||
id: string;
|
||||
name: string;
|
||||
location?: string;
|
||||
notes: string;
|
||||
room_count?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
site_id: string;
|
||||
name: string;
|
||||
notes: string;
|
||||
rack_count?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
site?: { id: string; name: string };
|
||||
racks?: Rack[];
|
||||
}
|
||||
|
||||
export interface Rack {
|
||||
id: string;
|
||||
room_id?: string;
|
||||
name: string;
|
||||
total_units: number;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
notes: string;
|
||||
component_count?: number;
|
||||
components?: Component[];
|
||||
room?: { id: string; name: string; site_id: string };
|
||||
site?: { id: string; name: string };
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Component {
|
||||
id: string;
|
||||
rack_id?: string;
|
||||
name: string;
|
||||
type: ComponentType;
|
||||
position?: number | null;
|
||||
height_units: number;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serial_number?: string;
|
||||
asset_tag?: string;
|
||||
ip_address?: string;
|
||||
mac_address?: string;
|
||||
status: ComponentStatus;
|
||||
notes: string;
|
||||
port_count?: number | null;
|
||||
sfp_count?: number | null;
|
||||
ports?: Port[];
|
||||
rack?: { id: string; name: string; total_units: number; room_id?: string };
|
||||
room?: { id: string; name: string; site_id: string };
|
||||
site?: { id: string; name: string };
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Port {
|
||||
id: string;
|
||||
component_id: string;
|
||||
port_number: number;
|
||||
label?: string;
|
||||
port_type: string;
|
||||
connected_to_port_id?: string | null;
|
||||
notes?: string;
|
||||
linked_port?: {
|
||||
id: string;
|
||||
port_number: number;
|
||||
label?: string;
|
||||
component_id: string;
|
||||
component_name: string;
|
||||
component_type: ComponentType;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
sites: Pick<Site, 'id' | 'name'>[];
|
||||
rooms: Pick<Room, 'id' | 'name' | 'site_id'>[];
|
||||
racks: Pick<Rack, 'id' | 'name' | 'room_id'>[];
|
||||
components: Pick<Component, 'id' | 'name' | 'type' | 'rack_id' | 'model' | 'ip_address'>[];
|
||||
}
|
||||
|
||||
export interface TreeNode {
|
||||
type: 'site' | 'room' | 'rack';
|
||||
id: string;
|
||||
name: string;
|
||||
children?: TreeNode[];
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
// Color & label metadata for component types
|
||||
export const COMPONENT_META: Record<ComponentType, { label: string; color: string; bg: string; border: string }> = {
|
||||
server: { label: 'Server', color: '#34d399', bg: '#064e3b', border: '#065f46' },
|
||||
switch: { label: 'Switch', color: '#60a5fa', bg: '#1e3a8a', border: '#1e40af' },
|
||||
router: { label: 'Router', color: '#c084fc', bg: '#3b0764', border: '#4c1d95' },
|
||||
firewall: { label: 'Firewall', color: '#f87171', bg: '#450a0a', border: '#7f1d1d' },
|
||||
patch_panel: { label: 'Patch Panel', color: '#d1d5db', bg: '#1f2937', border: '#374151' },
|
||||
ups: { label: 'UPS', color: '#fb923c', bg: '#451a03', border: '#7c2d12' },
|
||||
pdu: { label: 'PDU', color: '#fb7185', bg: '#500724', border: '#881337' },
|
||||
kvm: { label: 'KVM', color: '#2dd4bf', bg: '#042f2e', border: '#134e4a' },
|
||||
storage: { label: 'Storage', color: '#fdba74', bg: '#431407', border: '#7c2d12' },
|
||||
other: { label: 'Other', color: '#94a3b8', bg: '#0f172a', border: '#1e293b' },
|
||||
};
|
||||
|
||||
export const COMPONENT_TYPES: ComponentType[] = [
|
||||
'server', 'switch', 'router', 'firewall',
|
||||
'patch_panel', 'ups', 'pdu', 'kvm', 'storage', 'other',
|
||||
];
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
});
|
||||
Generated
+5452
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "networkview",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"workspaces": ["backend", "frontend"],
|
||||
"scripts": {
|
||||
"dev": "concurrently -n backend,frontend -c cyan,magenta \"npm run dev --workspace=backend\" \"npm run dev --workspace=frontend\"",
|
||||
"dev:backend": "npm run dev --workspace=backend",
|
||||
"dev:frontend": "npm run dev --workspace=frontend",
|
||||
"build": "npm run build --workspace=frontend",
|
||||
"start": "npm start --workspace=backend"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user