Initial commit: enterprise digital platform with portal SSO, DigiServer, IT Assets, NetworkView, Server Monitor

This commit is contained in:
ske087
2026-05-10 21:07:50 +03:00
commit 8d9df56b0b
364 changed files with 73655 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.env
*.db
!data/.gitkeep
+117
View File
@@ -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 |
+15
View File
@@ -0,0 +1,15 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production
ENV PORT=3001
EXPOSE 3001
CMD ["node", "src/index.js"]
File diff suppressed because it is too large Load Diff
+19
View File
@@ -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"
}
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Audit logging helper.
*
* Every mutation route calls `logAudit(req, { action, entityType, entityId, entityName, changes })`
* where `req` already has `req.actor` attached by the actor middleware (see index.js).
*/
const { v4: uuidv4 } = require('uuid');
const db = require('./db');
const insert = db.prepare(`
INSERT INTO audit_log (id, user_id, username, action, entity_type, entity_id, entity_name, changes, ip_address)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
/**
* @param {import('express').Request} req
* @param {{ action: string, entityType: string, entityId?: string, entityName?: string, changes?: object }} opts
*/
function logAudit(req, { action, entityType, entityId, entityName, changes }) {
const actor = req.actor ?? {};
const ip = (req.headers['x-forwarded-for'] ?? req.socket.remoteAddress ?? '').toString().split(',')[0].trim();
try {
insert.run(
uuidv4(),
actor.id ?? null,
actor.username ?? 'anonymous',
action,
entityType,
entityId ?? null,
entityName ?? null,
changes ? JSON.stringify(changes) : null,
ip || null,
);
} catch (e) {
// Never crash a request because audit failed
console.error('[audit] Failed to write log entry:', e.message);
}
}
module.exports = { logAudit };
+107
View File
@@ -0,0 +1,107 @@
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 (_) {}
// ── Users & Audit tables ─────────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT UNIQUE,
role TEXT NOT NULL DEFAULT 'viewer',
is_active INTEGER NOT NULL DEFAULT 1,
api_key TEXT UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
user_id TEXT,
username TEXT,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT,
entity_name TEXT,
changes TEXT,
ip_address TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
`);
module.exports = db;
+72
View File
@@ -0,0 +1,72 @@
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'));
// ── Actor middleware ──────────────────────────────────────────────────────────
// The external main app sets x-user-id + x-username after token verification.
// Direct calls can use x-api-key (validated against users.api_key).
const db = require('./db');
app.use((req, _res, next) => {
const userId = req.headers['x-user-id'];
const username = req.headers['x-username'];
const apiKey = req.headers['x-api-key'];
if (userId && username) {
req.actor = { id: userId, username: String(username) };
} else if (apiKey) {
const user = db.prepare('SELECT id, username FROM users WHERE api_key = ? AND is_active = 1').get(apiKey);
req.actor = user ? { id: user.id, username: user.username } : { id: null, username: 'api-key-invalid' };
} else {
req.actor = { id: null, username: 'anonymous' };
}
next();
});
// 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'));
app.use('/api/graph', require('./routes/graph'));
app.use('/api/users', require('./routes/users'));
app.use('/api/audit', require('./routes/audit'));
app.use('/api/settings', require('./routes/settings'));
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}`);
});
+46
View File
@@ -0,0 +1,46 @@
/**
* /api/audit
* Read-only audit log with filtering and pagination.
*/
const express = require('express');
const db = require('../db');
const router = express.Router();
// GET /api/audit?entity_type=&entity_id=&user_id=&action=&limit=50&offset=0
router.get('/', (req, res) => {
const { entity_type, entity_id, user_id, action, limit = 50, offset = 0 } = req.query;
const conditions = [];
const params = [];
if (entity_type) { conditions.push('entity_type = ?'); params.push(entity_type); }
if (entity_id) { conditions.push('entity_id = ?'); params.push(entity_id); }
if (user_id) { conditions.push('user_id = ?'); params.push(user_id); }
if (action) { conditions.push('action = ?'); params.push(action); }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const lim = Math.min(Number(limit) || 50, 500);
const off = Number(offset) || 0;
const rows = db.prepare(`
SELECT * FROM audit_log ${where}
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`).all(...params, lim, off);
const total = db.prepare(`
SELECT COUNT(*) AS n FROM audit_log ${where}
`).get(...params).n;
res.json({ total, limit: lim, offset: off, entries: rows });
});
// GET /api/audit/:id
router.get('/:id', (req, res) => {
const entry = db.prepare('SELECT * FROM audit_log WHERE id = ?').get(req.params.id);
if (!entry) return res.status(404).json({ error: 'Audit entry not found' });
res.json(entry);
});
module.exports = router;
@@ -0,0 +1,251 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const db = require('../db');
const { logAudit } = require('../audit');
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));
logAudit(req, { action: 'create', entityType: 'component', entityId: id, entityName: name });
});
// 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
);
logAudit(req, { action: 'update', entityType: 'component', entityId: component.id, entityName: name ?? component.name });
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 * 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);
logAudit(req, { action: 'delete', entityType: 'component', entityId: component.id, entityName: component.name });
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, type, port_count 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' });
// Patch-panel specific: no duplicate port numbers, enforce port_count capacity
if (component.type === 'patch_panel') {
const existing = db.prepare('SELECT id FROM ports WHERE component_id = ? AND port_number = ?').get(req.params.id, port_number);
if (existing) return res.status(409).json({ error: `Port ${port_number} already exists on this patch panel.` });
if (component.port_count != null) {
const count = db.prepare('SELECT COUNT(*) as n FROM ports WHERE component_id = ?').get(req.params.id).n;
if (count >= component.port_count)
return res.status(409).json({ error: `Patch panel is full (${component.port_count} ports maximum).` });
if (port_number < 1 || port_number > component.port_count)
return res.status(400).json({ error: `Port number must be between 1 and ${component.port_count}.` });
}
}
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;
+124
View File
@@ -0,0 +1,124 @@
const express = require('express');
const db = require('../db');
const router = express.Router();
/**
* GET /api/graph/:level/:id
* level: site | room | rack
* Returns { nodes, edges } for a force-directed graph view.
*/
router.get('/:level/:id', (req, res) => {
const { level, id } = req.params;
const nodes = [];
const edges = [];
const compIds = [];
if (level === 'site') {
const site = db.prepare('SELECT id, name FROM sites WHERE id = ?').get(id);
if (!site) return res.status(404).json({ error: 'Site not found' });
nodes.push({ id: site.id, label: site.name, type: 'site', url: `/sites/${site.id}` });
const rooms = db.prepare('SELECT id, name FROM rooms WHERE site_id = ?').all(id);
for (const room of rooms) {
nodes.push({ id: room.id, label: room.name, type: 'room', url: `/rooms/${room.id}` });
edges.push({ source: site.id, target: room.id, type: 'hierarchy' });
const racks = db.prepare('SELECT id, name FROM racks WHERE room_id = ?').all(room.id);
for (const rack of racks) {
nodes.push({ id: rack.id, label: rack.name, type: 'rack', url: `/racks/${rack.id}` });
edges.push({ source: room.id, target: rack.id, type: 'hierarchy' });
const components = db.prepare('SELECT id, name, type FROM components WHERE rack_id = ?').all(rack.id);
for (const comp of components) {
nodes.push({ id: comp.id, label: comp.name, type: comp.type, url: `/components/${comp.id}` });
edges.push({ source: rack.id, target: comp.id, type: 'hierarchy' });
compIds.push(comp.id);
}
}
}
} else if (level === 'room') {
const room = db.prepare('SELECT id, name FROM rooms WHERE id = ?').get(id);
if (!room) return res.status(404).json({ error: 'Room not found' });
nodes.push({ id: room.id, label: room.name, type: 'room', url: `/rooms/${room.id}` });
const racks = db.prepare('SELECT id, name FROM racks WHERE room_id = ?').all(id);
for (const rack of racks) {
nodes.push({ id: rack.id, label: rack.name, type: 'rack', url: `/racks/${rack.id}` });
edges.push({ source: room.id, target: rack.id, type: 'hierarchy' });
const components = db.prepare('SELECT id, name, type FROM components WHERE rack_id = ?').all(rack.id);
for (const comp of components) {
nodes.push({ id: comp.id, label: comp.name, type: comp.type, url: `/components/${comp.id}` });
edges.push({ source: rack.id, target: comp.id, type: 'hierarchy' });
compIds.push(comp.id);
}
}
} else if (level === 'rack') {
const rack = db.prepare('SELECT id, name FROM racks WHERE id = ?').get(id);
if (!rack) return res.status(404).json({ error: 'Rack not found' });
nodes.push({ id: rack.id, label: rack.name, type: 'rack', url: `/racks/${rack.id}` });
const components = db.prepare('SELECT id, name, type FROM components WHERE rack_id = ?').all(id);
for (const comp of components) {
nodes.push({ id: comp.id, label: comp.name, type: comp.type, url: `/components/${comp.id}` });
edges.push({ source: rack.id, target: comp.id, type: 'hierarchy' });
compIds.push(comp.id);
}
} else {
return res.status(400).json({ error: 'Invalid level. Use site, room, or rack.' });
}
// Port-to-port connections + port-level nodes (leaf level)
if (compIds.length > 1) {
const ph = compIds.map(() => '?').join(',');
const portLinks = db.prepare(`
SELECT
p1.id AS pid1, p1.port_number AS num1, p1.label AS lbl1, p1.component_id AS src,
p2.id AS pid2, p2.port_number AS num2, p2.label AS lbl2, p2.component_id AS tgt
FROM ports p1
JOIN ports p2 ON p1.connected_to_port_id = p2.id
WHERE p1.component_id IN (${ph})
AND p2.component_id IN (${ph})
AND p1.component_id != p2.component_id
`).all(...compIds, ...compIds);
const compEdgeSeen = new Set();
const portNodeSeen = new Set();
const portEdgeSeen = new Set();
for (const link of portLinks) {
// Component-to-component connection (high-level, deduplicated)
const compKey = [link.src, link.tgt].sort().join('|');
if (!compEdgeSeen.has(compKey)) {
compEdgeSeen.add(compKey);
edges.push({ source: link.src, target: link.tgt, type: 'connection' });
}
// Port nodes + hierarchy edges
const pid1 = `port:${link.pid1}`;
const pid2 = `port:${link.pid2}`;
if (!portNodeSeen.has(pid1)) {
portNodeSeen.add(pid1);
nodes.push({ id: pid1, label: link.lbl1 || `P${link.num1}`, type: 'port', url: `/components/${link.src}` });
edges.push({ source: link.src, target: pid1, type: 'hierarchy' });
}
if (!portNodeSeen.has(pid2)) {
portNodeSeen.add(pid2);
nodes.push({ id: pid2, label: link.lbl2 || `P${link.num2}`, type: 'port', url: `/components/${link.tgt}` });
edges.push({ source: link.tgt, target: pid2, type: 'hierarchy' });
}
// Port-to-port connection edge (deepest level)
const portEdgeKey = [pid1, pid2].sort().join('|');
if (!portEdgeSeen.has(portEdgeKey)) {
portEdgeSeen.add(portEdgeKey);
edges.push({ source: pid1, target: pid2, type: 'connection' });
}
}
}
res.json({ nodes, edges });
});
module.exports = router;
+105
View File
@@ -0,0 +1,105 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const db = require('../db');
const { logAudit } = require('../audit');
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 || '');
const created = db.prepare('SELECT * FROM racks WHERE id = ?').get(id);
logAudit(req, { action: 'create', entityType: 'rack', entityId: id, entityName: name });
res.status(201).json(created);
});
// 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;
const changes = {};
if (name != null && name !== rack.name) changes.name = { from: rack.name, to: name };
if (total_units != null && total_units !== rack.total_units) changes.total_units = { from: rack.total_units, to: total_units };
if (manufacturer != null && manufacturer !== rack.manufacturer) changes.manufacturer = { from: rack.manufacturer, to: manufacturer };
if (model != null && model !== rack.model) changes.model = { from: rack.model, to: model };
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
);
logAudit(req, { action: 'update', entityType: 'rack', entityId: rack.id, entityName: name ?? rack.name, changes });
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 * 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);
logAudit(req, { action: 'delete', entityType: 'rack', entityId: rack.id, entityName: rack.name });
res.json({ ok: true });
});
module.exports = router;
+84
View File
@@ -0,0 +1,84 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const db = require('../db');
const { logAudit } = require('../audit');
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 || ''
);
const created = db.prepare('SELECT * FROM rooms WHERE id = ?').get(id);
logAudit(req, { action: 'create', entityType: 'room', entityId: id, entityName: name });
res.status(201).json(created);
});
// 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;
const changes = {};
if (name != null && name !== room.name) changes.name = { from: room.name, to: name };
db.prepare(`
UPDATE rooms SET name = ?, notes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
`).run(name ?? room.name, notes ?? room.notes, req.params.id);
logAudit(req, { action: 'update', entityType: 'room', entityId: room.id, entityName: name ?? room.name, changes });
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 * 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);
logAudit(req, { action: 'delete', entityType: 'room', entityId: room.id, entityName: room.name });
res.json({ ok: true });
});
module.exports = router;
@@ -0,0 +1,95 @@
/**
* /api/settings
* Administrative / danger-zone operations.
* All destructive actions require the header X-Confirm: yes
*/
const express = require('express');
const db = require('../db');
const { logAudit } = require('../audit');
const router = express.Router();
// Guard: require explicit confirmation header for destructive ops
function requireConfirm(req, res, next) {
if ((req.headers['x-confirm'] ?? '').toLowerCase() !== 'yes') {
return res.status(400).json({ error: 'Send header X-Confirm: yes to confirm this destructive operation.' });
}
next();
}
// ── GET /api/settings/stats ───────────────────────────────────────────────────
// Overview counts — useful for the settings page dashboard
router.get('/stats', (req, res) => {
const stats = {
sites: db.prepare('SELECT COUNT(*) AS n FROM sites').get().n,
rooms: db.prepare('SELECT COUNT(*) AS n FROM rooms').get().n,
racks: db.prepare('SELECT COUNT(*) AS n FROM racks').get().n,
components: db.prepare('SELECT COUNT(*) AS n FROM components').get().n,
ports: db.prepare('SELECT COUNT(*) AS n FROM ports').get().n,
users: db.prepare('SELECT COUNT(*) AS n FROM users').get().n,
audit_entries: db.prepare('SELECT COUNT(*) AS n FROM audit_log').get().n,
};
res.json(stats);
});
// ── DELETE /api/settings/data/all ────────────────────────────────────────────
// Wipe ALL network data (sites, rooms, racks, components, ports, audit_log).
// Users are kept. Requires X-Confirm: yes.
router.delete('/data/all', requireConfirm, (req, res) => {
db.transaction(() => {
db.exec('DELETE FROM ports');
db.exec('DELETE FROM components');
db.exec('DELETE FROM racks');
db.exec('DELETE FROM rooms');
db.exec('DELETE FROM sites');
db.exec('DELETE FROM audit_log');
})();
logAudit(req, { action: 'delete_all', entityType: 'database', entityName: 'all network data' });
res.json({ ok: true, message: 'All network data deleted.' });
});
// ── DELETE /api/settings/data/audit ──────────────────────────────────────────
// Clear only the audit log.
router.delete('/data/audit', requireConfirm, (req, res) => {
db.exec('DELETE FROM audit_log');
logAudit(req, { action: 'delete_all', entityType: 'audit_log', entityName: 'audit log' });
res.json({ ok: true, message: 'Audit log cleared.' });
});
// ── DELETE /api/settings/sites/:id ───────────────────────────────────────────
router.delete('/sites/:id', requireConfirm, (req, res) => {
const row = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Site not found' });
db.prepare('DELETE FROM sites WHERE id = ?').run(row.id);
logAudit(req, { action: 'delete', entityType: 'site', entityId: row.id, entityName: row.name });
res.json({ ok: true });
});
// ── DELETE /api/settings/rooms/:id ───────────────────────────────────────────
router.delete('/rooms/:id', requireConfirm, (req, res) => {
const row = db.prepare('SELECT * FROM rooms WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Room not found' });
db.prepare('DELETE FROM rooms WHERE id = ?').run(row.id);
logAudit(req, { action: 'delete', entityType: 'room', entityId: row.id, entityName: row.name });
res.json({ ok: true });
});
// ── DELETE /api/settings/racks/:id ───────────────────────────────────────────
router.delete('/racks/:id', requireConfirm, (req, res) => {
const row = db.prepare('SELECT * FROM racks WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Rack not found' });
db.prepare('DELETE FROM racks WHERE id = ?').run(row.id);
logAudit(req, { action: 'delete', entityType: 'rack', entityId: row.id, entityName: row.name });
res.json({ ok: true });
});
// ── DELETE /api/settings/components/:id ──────────────────────────────────────
router.delete('/components/:id', requireConfirm, (req, res) => {
const row = db.prepare('SELECT * FROM components WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Component not found' });
db.prepare('DELETE FROM components WHERE id = ?').run(row.id);
logAudit(req, { action: 'delete', entityType: 'component', entityId: row.id, entityName: row.name });
res.json({ ok: true });
});
module.exports = router;
+78
View File
@@ -0,0 +1,78 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const db = require('../db');
const { logAudit } = require('../audit');
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 || ''
);
const created = db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
logAudit(req, { action: 'create', entityType: 'site', entityId: id, entityName: name });
res.status(201).json(created);
});
// 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;
const changes = {};
if (name != null && name !== site.name) changes.name = { from: site.name, to: name };
if (location != null && location !== site.location) changes.location = { from: site.location, to: location };
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);
logAudit(req, { action: 'update', entityType: 'site', entityId: site.id, entityName: name ?? site.name, changes });
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);
logAudit(req, { action: 'delete', entityType: 'site', entityId: site.id, entityName: site.name });
res.json({ ok: true });
});
module.exports = router;
+138
View File
@@ -0,0 +1,138 @@
/**
* /api/users
*
* Manages NetworkView users. Authentication is expected to be handled by
* the external main app; this API accepts an `x-user-id` / `x-username`
* header (set by the gateway after verifying the bearer token) and an
* `x-api-key` header for direct service-to-service calls.
*
* Roles: admin | editor | viewer
*/
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const crypto = require('crypto');
const db = require('../db');
const { logAudit } = require('../audit');
const router = express.Router();
// ── Helpers ──────────────────────────────────────────────────────────────────
const safeUser = u => ({
id: u.id,
username: u.username,
email: u.email,
role: u.role,
is_active: u.is_active,
has_api_key: !!u.api_key,
created_at: u.created_at,
updated_at: u.updated_at,
});
// ── GET /api/users ────────────────────────────────────────────────────────────
router.get('/', (req, res) => {
const users = db.prepare('SELECT * FROM users ORDER BY username').all();
res.json(users.map(safeUser));
});
// ── GET /api/users/:id ────────────────────────────────────────────────────────
router.get('/:id', (req, res) => {
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(safeUser(user));
});
// ── POST /api/users ───────────────────────────────────────────────────────────
router.post('/', (req, res) => {
const { username, email, role = 'viewer' } = req.body;
if (!username) return res.status(400).json({ error: 'username is required' });
if (!['admin', 'editor', 'viewer'].includes(role)) {
return res.status(400).json({ error: 'role must be admin | editor | viewer' });
}
if (db.prepare('SELECT id FROM users WHERE username = ?').get(username)) {
return res.status(409).json({ error: 'Username already exists' });
}
if (email && db.prepare('SELECT id FROM users WHERE email = ?').get(email)) {
return res.status(409).json({ error: 'Email already in use' });
}
const id = uuidv4();
db.prepare(`
INSERT INTO users (id, username, email, role) VALUES (?, ?, ?, ?)
`).run(id, username, email ?? null, role);
const created = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
logAudit(req, { action: 'create', entityType: 'user', entityId: id, entityName: username });
res.status(201).json(safeUser(created));
});
// ── PUT /api/users/:id ────────────────────────────────────────────────────────
router.put('/:id', (req, res) => {
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
const { username, email, role, is_active } = req.body;
if (role && !['admin', 'editor', 'viewer'].includes(role)) {
return res.status(400).json({ error: 'role must be admin | editor | viewer' });
}
if (username && username !== user.username) {
if (db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, user.id)) {
return res.status(409).json({ error: 'Username already exists' });
}
}
const changes = {};
if (username != null && username !== user.username) changes.username = { from: user.username, to: username };
if (email != null && email !== user.email) changes.email = { from: user.email, to: email };
if (role != null && role !== user.role) changes.role = { from: user.role, to: role };
if (is_active != null && Number(is_active) !== user.is_active) changes.is_active = { from: user.is_active, to: Number(is_active) };
db.prepare(`
UPDATE users
SET username = ?, email = ?, role = ?, is_active = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
username ?? user.username,
email ?? user.email,
role ?? user.role,
is_active != null ? Number(is_active) : user.is_active,
user.id,
);
logAudit(req, { action: 'update', entityType: 'user', entityId: user.id, entityName: username ?? user.username, changes });
res.json(safeUser(db.prepare('SELECT * FROM users WHERE id = ?').get(user.id)));
});
// ── DELETE /api/users/:id ─────────────────────────────────────────────────────
router.delete('/:id', (req, res) => {
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
db.prepare('DELETE FROM users WHERE id = ?').run(user.id);
logAudit(req, { action: 'delete', entityType: 'user', entityId: user.id, entityName: user.username });
res.json({ ok: true });
});
// ── POST /api/users/:id/api-key (generate / rotate) ─────────────────────────
router.post('/:id/api-key', (req, res) => {
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
const apiKey = `nvk_${crypto.randomBytes(24).toString('hex')}`;
db.prepare('UPDATE users SET api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(apiKey, user.id);
logAudit(req, { action: 'api_key_rotate', entityType: 'user', entityId: user.id, entityName: user.username });
// Return the key once — after this it is never exposed again
res.json({ api_key: apiKey });
});
// ── DELETE /api/users/:id/api-key (revoke) ──────────────────────────────────
router.delete('/:id/api-key', (req, res) => {
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
db.prepare('UPDATE users SET api_key = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
logAudit(req, { action: 'api_key_revoke', entityType: 'user', entityId: user.id, entityName: user.username });
res.json({ ok: true });
});
module.exports = router;
+24
View File
@@ -0,0 +1,24 @@
# Stage 1: build the React/Vite app
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
# Build with /networkview/ as the base path so all asset URLs are prefixed
ENV VITE_BASE_PATH=/networkview/
ENV VITE_API_BASE=/networkview/api
RUN npm run build
# Stage 2: serve with nginx
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+13
View File
@@ -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>
+18
View File
@@ -0,0 +1,18 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Serve static assets with long cache
location ~* \.(js|css|png|jpg|svg|ico|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# SPA fallback: return index.html for any path
# React Router (with basename=/networkview) handles routing client-side
location / {
try_files $uri /index.html;
}
}
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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"
}
}
+34
View File
@@ -0,0 +1,34 @@
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';
import GraphPage from './pages/GraphPage';
import SettingsPage from './pages/SettingsPage';
// import.meta.env.BASE_URL is set by Vite to the configured `base` value.
// Strip the trailing slash so React Router receives e.g. '/networkview'.
const routerBase = import.meta.env.BASE_URL.replace(/\/$/, '');
export default function App() {
return (
<BrowserRouter basename={routerBase}>
<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 />} />
<Route path="/graph/:level/:id" element={<GraphPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</main>
</div>
</BrowserRouter>
);
}
+109
View File
@@ -0,0 +1,109 @@
import type {
Site, Room, Rack, Component, Port, SearchResults, User, AuditEntry, DbStats
} from './types';
// In production the API lives at /networkview/api (set via VITE_API_BASE at build time).
// In development it defaults to /api, proxied by the Vite dev server.
const BASE = import.meta.env.VITE_API_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)}`);
// --- Users ---
export const getUsers = () => request<User[]>('/users');
export const getUser = (id: string) => request<User>(`/users/${id}`);
export const createUser = (data: { username: string; email?: string; role?: string }) =>
request<User>('/users', { method: 'POST', body: JSON.stringify(data) });
export const updateUser = (id: string, data: Partial<Pick<User, 'username' | 'email' | 'role' | 'is_active'>>) =>
request<User>(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const deleteUser = (id: string) =>
request(`/users/${id}`, { method: 'DELETE' });
export const rotateApiKey = (id: string) =>
request<{ api_key: string }>(`/users/${id}/api-key`, { method: 'POST' });
export const revokeApiKey = (id: string) =>
request(`/users/${id}/api-key`, { method: 'DELETE' });
// --- Audit ---
export const getAuditLog = (params: {
entity_type?: string; action?: string; user_id?: string;
limit?: number; offset?: number;
}) => {
const qs = new URLSearchParams(
Object.entries(params)
.filter(([, v]) => v !== undefined && v !== '')
.map(([k, v]) => [k, String(v)])
).toString();
return request<{ total: number; limit: number; offset: number; entries: AuditEntry[] }>(
`/audit${qs ? `?${qs}` : ''}`
);
};
export const clearAuditLog = () =>
request('/settings/data/audit', { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'X-Confirm': 'yes' } });
// --- Settings ---
export const getDbStats = () => request<DbStats>('/settings/stats');
export const deleteAllData = () =>
request('/settings/data/all', { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'X-Confirm': 'yes' } });
@@ -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 (016)</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…&#10;&#10;# Heading&#10;**bold**, *italic*, `code`&#10;&#10;- [ ] Checklist item&#10;&#10;| Port | Device |&#10;|------|--------|&#10;| 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,235 @@
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>
<div className="sidebar-footer">
<Link to="/settings" className={`sidebar-footer-link ${isActive('/settings') ? 'active' : ''}`}>
Settings
</Link>
</div>
</aside>
);
}
File diff suppressed because it is too large Load Diff
+10
View File
@@ -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,647 @@
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 [switches, setSwitches] = useState<Component[]>([]);
const [swLink, setSwLink] = useState<{ switchId: string; portNumber: string; portId: string | null } | null>(null);
const [portError, setPortError] = useState<string | null>(null);
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'));
}
if (c.type === 'patch_panel' && c.rack?.id) {
const all = await api.getComponents(c.rack.id);
setSwitches(all.filter(comp => comp.type === 'switch'));
}
}, [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) => {
setPortError(null);
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);
if (port?.linked_port?.component_type === 'switch') {
setSwLink({ switchId: port.linked_port.component_id, portNumber: String(port.linked_port.port_number), portId: port.linked_port.id });
} else {
setSwLink(null);
}
};
const closePortModal = () => { setPortModal(null); setSwLink(null); setPortError(null); };
const handleSavePort = async (e: React.FormEvent) => {
e.preventDefault();
if (!componentId || !portForm.port_number) return;
setPortError(null);
// Patch-panel only: validate no duplicate port number and capacity limit
if (component?.type === 'patch_panel' && !portModal?.editPort) {
const existingPorts: Port[] = component.ports ?? [];
const portNum = Number(portForm.port_number);
const maxPorts = component.port_count ?? Infinity;
if (existingPorts.some(p => p.port_number === portNum)) {
setPortError(`Port ${portNum} is already added. Each port number can only appear once.`);
return;
}
if (existingPorts.length >= maxPorts) {
setPortError(`This patch panel is full (${maxPorts} ports). Delete an existing port to add a new one.`);
return;
}
if (portNum < 1 || portNum > maxPorts) {
setPortError(`Port number must be between 1 and ${maxPorts}.`);
return;
}
}
// Resolve connected_to_port_id for patch panels via swLink
let connectedToPortId: string | null | undefined = portForm.connected_to_port_id || null;
if (component?.type === 'patch_panel') {
if (swLink?.switchId && swLink?.portNumber) {
if (swLink.portId) {
connectedToPortId = swLink.portId;
} else {
// Switch port record doesn't exist yet create it first
const newSwPort = await api.createPort(swLink.switchId, {
port_number: Number(swLink.portNumber),
port_type: 'RJ45',
});
connectedToPortId = newSwPort.id;
}
} else {
connectedToPortId = null;
}
}
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: connectedToPortId,
});
} 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: connectedToPortId ?? undefined,
});
}
setPortModal(null);
setSwLink(null);
setPortError(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"
min={1}
max={component.type === 'patch_panel' && component.port_count ? component.port_count : undefined}
value={portForm.port_number}
onChange={e => setPortForm(f => ({ ...f, port_number: e.target.value }))}
required
disabled={!!portModal.editPort}
/>
{component.type === 'patch_panel' && component.port_count && !portModal.editPort && (
<span className="form-hint">Port 1{component.port_count}</span>
)}
</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 }))}
/>
)}
{component.type === 'patch_panel' && (
<SwLinkPicker
key={portModal.editPort?.id ?? 'add'}
switches={switches}
initialSwitchId={swLink?.switchId}
initialPortNumber={swLink?.portNumber}
currentLinkedPortId={swLink?.portId}
onChange={setSwLink}
/>
)}
{portError && (
<div style={{ color: '#f87171', fontSize: 13, background: '#450a0a', border: '1px solid #7f1d1d', borderRadius: 6, padding: '8px 12px' }}>
{portError}
</div>
)}
<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>
);
}
function SwLinkPicker({
switches,
initialSwitchId,
initialPortNumber,
currentLinkedPortId,
onChange,
}: {
switches: Component[];
initialSwitchId?: string;
initialPortNumber?: string;
currentLinkedPortId?: string | null;
onChange: (link: { switchId: string; portNumber: string; portId: string | null } | null) => void;
}) {
const [selectedSwitchId, setSelectedSwitchId] = useState(initialSwitchId ?? '');
const [switchData, setSwitchData] = useState<Component | null>(null);
const [loadingSwitch, setLoadingSwitch] = useState(false);
const [selectedPortNumber, setSelectedPortNumber] = useState(initialPortNumber ?? '');
useEffect(() => {
if (!selectedSwitchId) { setSwitchData(null); return; }
setLoadingSwitch(true);
api.getComponent(selectedSwitchId).then(sw => {
setSwitchData(sw);
setLoadingSwitch(false);
});
}, [selectedSwitchId]);
const portCount = switchData?.port_count ?? 24;
const handleSwitchChange = (swId: string) => {
setSelectedSwitchId(swId);
setSelectedPortNumber('');
onChange(null);
};
const handlePortChange = (portNumberStr: string) => {
setSelectedPortNumber(portNumberStr);
if (!portNumberStr || !selectedSwitchId) { onChange(null); return; }
const existingPort = (switchData?.ports ?? []).find(p => p.port_number === Number(portNumberStr));
onChange({ switchId: selectedSwitchId, portNumber: portNumberStr, portId: existingPort?.id ?? null });
};
return (
<div className="pp-link-picker">
<span className="pp-link-label">🔗 Link to Switch Port <span style={{ color: 'var(--text3)', fontWeight: 400 }}>(optional)</span></span>
<div className="pp-link-row">
<select
className="form-input form-input-sm"
value={selectedSwitchId}
onChange={e => handleSwitchChange(e.target.value)}
>
<option value=""> no switch </option>
{switches.map(sw => (
<option key={sw.id} value={sw.id}>{sw.name}</option>
))}
</select>
{selectedSwitchId && !loadingSwitch && switchData && (
<select
className="form-input form-input-sm"
value={selectedPortNumber}
onChange={e => handlePortChange(e.target.value)}
>
<option value=""> select port </option>
{Array.from({ length: portCount }, (_, i) => i + 1).map(n => {
const existingPort = (switchData.ports ?? []).find(p => p.port_number === n);
const isOccupied = existingPort?.connected_to_port_id && existingPort.id !== currentLinkedPortId;
return (
<option key={n} value={String(n)} disabled={!!isOccupied}>
Port {n}{existingPort?.label ? `${existingPort.label}` : ''}{isOccupied ? ' (in use)' : ''}
</option>
);
})}
</select>
)}
{loadingSwitch && <span style={{ color: 'var(--text3)', fontSize: 12 }}>Loading</span>}
{switches.length === 0 && (
<span style={{ color: 'var(--text3)', fontSize: 12 }}>No switches in this rack</span>
)}
</div>
{selectedSwitchId && selectedPortNumber && (
<button
type="button"
className="pp-link-clear"
onClick={() => { setSelectedSwitchId(''); setSelectedPortNumber(''); onChange(null); }}
>
Clear link
</button>
)}
</div>
);
}
@@ -0,0 +1,444 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { COMPONENT_META } from '../types';
// ── Types ────────────────────────────────────────────────────────────────────
interface GNode {
id: string;
label: string;
type: string;
url: string;
x: number; y: number;
vx: number; vy: number;
tx: number; ty: number;
pinned: boolean;
}
interface GEdge {
source: string;
target: string;
type: 'hierarchy' | 'connection';
}
type VB = { x: number; y: number; w: number; h: number };
// ── Constants ────────────────────────────────────────────────────────────────
const HOME_K = 0.14;
const REPULSION = 900;
const DAMPING = 0.78;
const JITTER = 0.18;
const V_GAP = 170;
const NODE_R: Record<string, number> = { site: 36, room: 30, rack: 27, port: 12 };
const DEFAULT_R = 22;
const ICONS: Record<string, string> = {
site: '🏢', room: '🚪', rack: '🗄️',
server: '💻', switch: '🔀', router: '🌐',
firewall: '🛡️', patch_panel: '🔌', ups: '🔋',
pdu: '⚡', kvm: '🖥️', storage: '💾', other: '📦',
port: '◉',
};
const TYPE_LABELS: Record<string, string> = {
site: 'Site', room: 'Room', rack: 'Rack',
server: 'Server', switch: 'Switch', router: 'Router',
firewall: 'Firewall', patch_panel: 'Patch Panel', ups: 'UPS',
pdu: 'PDU', kvm: 'KVM', storage: 'Storage', other: 'Other',
port: 'Port',
};
function nodeColors(type: string) {
if (type === 'site') return { fill: '#1c2d1e', stroke: '#3fb950', text: '#3fb950' };
if (type === 'room') return { fill: '#1a2744', stroke: '#58a6ff', text: '#58a6ff' };
if (type === 'rack') return { fill: '#21262d', stroke: '#8b949e', text: '#8b949e' };
if (type === 'port') return { fill: '#1a1f2e', stroke: '#f59e0b', text: '#f59e0b' };
const m = COMPONENT_META[type as keyof typeof COMPONENT_META];
return m
? { fill: m.bg, stroke: m.color, text: m.color }
: { fill: '#21262d', stroke: '#6e7681', text: '#6e7681' };
}
// ── Tree layout (BFS top-down) ───────────────────────────────────────────────
function computeTreeLayout(
nodes: { id: string }[],
edges: GEdge[],
): Map<string, { tx: number; ty: number }> {
if (nodes.length === 0) return new Map();
const hierEdges = edges.filter(e => e.type === 'hierarchy');
const children: Record<string, string[]> = {};
const hasParent = new Set<string>();
for (const e of hierEdges) {
(children[e.source] ??= []).push(e.target);
hasParent.add(e.target);
}
const root = nodes.find(n => !hasParent.has(n.id))?.id ?? nodes[0].id;
const depth: Record<string, number> = { [root]: 0 };
const queue = [root];
while (queue.length) {
const cur = queue.shift()!;
for (const child of (children[cur] ?? [])) {
if (depth[child] == null) {
depth[child] = depth[cur] + 1;
queue.push(child);
}
}
}
const byLevel: Record<number, string[]> = {};
for (const n of nodes) {
const lv = depth[n.id] ?? 0;
(byLevel[lv] ??= []).push(n.id);
}
const sortedByLevel: Record<number, string[]> = Object.fromEntries(
Object.entries(byLevel).map(([lv, ids]) => [
lv,
ids.slice().sort((a, b) => {
const pa = hierEdges.find(e => e.target === a)?.source;
const pb = hierEdges.find(e => e.target === b)?.source;
if (!pa || !pb || pa === pb) return 0;
const lpa = depth[pa] ?? 0;
const levelNodes = byLevel[lpa] ?? [];
return levelNodes.indexOf(pa) - levelNodes.indexOf(pb);
}),
])
);
const maxLevel = Math.max(0, ...Object.keys(sortedByLevel).map(Number));
const positions = new Map<string, { tx: number; ty: number }>();
for (const [lvStr, ids] of Object.entries(sortedByLevel)) {
const lv = Number(lvStr);
const count = ids.length;
const spacing = count === 1 ? 0 : Math.max(115, 800 / count);
const totalW = (count - 1) * spacing;
const baseY = lv * V_GAP - (maxLevel * V_GAP) / 2;
ids.forEach((nid, i) => {
positions.set(nid, {
tx: -totalW / 2 + i * spacing,
ty: baseY,
});
});
}
return positions;
}
// ── Component ─────────────────────────────────────────────────────────────────
export default function GraphPage() {
const { level, id } = useParams<{ level: string; id: string }>();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [errMsg, setErrMsg] = useState<string | null>(null);
const [pageTitle, setPageTitle] = useState('Graph View');
const [renderNodes, setRenderNodes] = useState<GNode[]>([]);
const [edges, setEdges] = useState<GEdge[]>([]);
const nodesRef = useRef<GNode[]>([]);
const edgesRef = useRef<GEdge[]>([]);
const rafRef = useRef<number>(0);
const alphaRef = useRef(0.5);
const hasMoved = useRef(false);
const dragging = useRef<{ id: string; ox: number; oy: number } | null>(null);
const panning = useRef<{ x0: number; y0: number; vb0: VB } | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
const initVbRef = useRef<VB>({ x: -500, y: -350, w: 1000, h: 700 });
const [vb, setVb] = useState<VB>(initVbRef.current);
useEffect(() => {
if (!level || !id) return;
setLoading(true);
setErrMsg(null);
fetch(`/api/graph/${level}/${id}`)
.then(r => r.ok ? r.json() : r.json().then((b: { error: string }) => Promise.reject(b.error)))
.then((data: { nodes: Omit<GNode, 'x' | 'y' | 'vx' | 'vy' | 'tx' | 'ty' | 'pinned'>[]; edges: GEdge[] }) => {
const positions = computeTreeLayout(data.nodes, data.edges);
const placed: GNode[] = data.nodes.map(node => {
const pos = positions.get(node.id) ?? { tx: 0, ty: 0 };
return { ...node, x: pos.tx, y: pos.ty, vx: 0, vy: 0, tx: pos.tx, ty: pos.ty, pinned: false };
});
nodesRef.current = placed;
edgesRef.current = data.edges;
setEdges(data.edges);
setRenderNodes([...placed]);
alphaRef.current = 0.45;
if (data.nodes.length > 0) {
const root = data.nodes[0];
setPageTitle(`${ICONS[root.type] ?? ''} ${root.label} — Graph`);
}
if (placed.length > 0) {
const xs = placed.map(n => n.x);
const ys = placed.map(n => n.y);
const pad = 100;
const fitVb = {
x: Math.min(...xs) - pad,
y: Math.min(...ys) - pad,
w: Math.max(Math.max(...xs) - Math.min(...xs) + pad * 2, 600),
h: Math.max(Math.max(...ys) - Math.min(...ys) + pad * 2, 400),
};
initVbRef.current = fitVb;
setVb(fitVb);
}
setLoading(false);
})
.catch((e: unknown) => { setErrMsg(String(e)); setLoading(false); });
}, [level, id]);
useEffect(() => {
if (loading || nodesRef.current.length === 0) return;
const tick = () => {
const ns = nodesRef.current;
const a = alphaRef.current;
for (const n of ns) {
if (n.pinned) continue;
n.vx += (n.tx - n.x) * HOME_K * a;
n.vy += (n.ty - n.y) * HOME_K * a;
}
for (let i = 0; i < ns.length; i++) {
for (let j = i + 1; j < ns.length; j++) {
const dx = ns[j].x - ns[i].x || 0.01;
const dy = ns[j].y - ns[i].y || 0.01;
const d2 = dx * dx + dy * dy;
if (d2 > 32000) continue;
const d = Math.sqrt(d2);
const f = (REPULSION * a) / d2;
const fx = (dx / d) * f, fy = (dy / d) * f;
ns[i].vx -= fx; ns[i].vy -= fy;
ns[j].vx += fx; ns[j].vy += fy;
}
}
for (const n of ns) {
if (n.pinned) { n.vx = 0; n.vy = 0; continue; }
n.vx *= DAMPING;
n.vy *= DAMPING;
if (a <= 0.06) {
n.vx += (Math.random() - 0.5) * JITTER;
n.vy += (Math.random() - 0.5) * JITTER;
}
n.x += n.vx;
n.y += n.vy;
}
alphaRef.current = Math.max(a * 0.994, 0.05);
setRenderNodes([...ns]);
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafRef.current);
}, [loading, edges]);
const toSvg = useCallback((cx: number, cy: number) => {
const svg = svgRef.current;
if (!svg) return { x: cx, y: cy };
const pt = svg.createSVGPoint();
pt.x = cx; pt.y = cy;
return pt.matrixTransform(svg.getScreenCTM()!.inverse());
}, []);
const onNodeDown = useCallback((e: React.MouseEvent, nid: string) => {
e.stopPropagation();
hasMoved.current = false;
const sp = toSvg(e.clientX, e.clientY);
const node = nodesRef.current.find(n => n.id === nid);
if (!node) return;
dragging.current = { id: nid, ox: sp.x - node.x, oy: sp.y - node.y };
nodesRef.current = nodesRef.current.map(n => n.id === nid ? { ...n, pinned: true } : n);
alphaRef.current = Math.max(alphaRef.current, 0.25);
}, [toSvg]);
const onBgDown = useCallback((e: React.MouseEvent) => {
panning.current = { x0: e.clientX, y0: e.clientY, vb0: { ...vb } };
}, [vb]);
const onMouseMove = useCallback((e: React.MouseEvent) => {
if (dragging.current) {
hasMoved.current = true;
const sp = toSvg(e.clientX, e.clientY);
const { id: nid, ox, oy } = dragging.current;
nodesRef.current = nodesRef.current.map(n =>
n.id === nid ? { ...n, x: sp.x - ox, y: sp.y - oy } : n
);
return;
}
if (panning.current) {
const { x0, y0, vb0 } = panning.current;
const scale = vb0.w / (svgRef.current?.clientWidth || 900);
setVb(v => ({ ...v, x: vb0.x - (e.clientX - x0) * scale, y: vb0.y - (e.clientY - y0) * scale }));
}
}, [toSvg]);
const onMouseUp = useCallback(() => {
if (dragging.current) {
const nid = dragging.current.id;
nodesRef.current = nodesRef.current.map(n =>
n.id === nid ? { ...n, pinned: false, tx: n.x, ty: n.y } : n
);
dragging.current = null;
alphaRef.current = Math.max(alphaRef.current, 0.15);
}
panning.current = null;
}, []);
const onWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const sp = toSvg(e.clientX, e.clientY);
const f = e.deltaY > 0 ? 1.12 : 0.89;
setVb(v => ({
x: sp.x - (sp.x - v.x) * f,
y: sp.y - (sp.y - v.y) * f,
w: v.w * f,
h: v.h * f,
}));
}, [toSvg]);
if (loading) return <div className="page-loading">Building graph</div>;
if (errMsg) return <div className="page-loading" style={{ color: 'var(--danger)' }}>Error: {errMsg}</div>;
return (
<div className="graph-page">
<div className="graph-toolbar">
<button className="btn-back btn-sm" onClick={() => navigate(-1)}> Back</button>
<span className="graph-title">{pageTitle}</span>
<div className="graph-legend">
<span className="graph-legend-item">
<svg width="26" height="10" style={{ flexShrink: 0 }}>
<line x1="0" y1="5" x2="26" y2="5" stroke="#444d58" strokeWidth="2.5" />
</svg>
Hierarchy
</span>
<span className="graph-legend-item">
<svg width="26" height="10" style={{ flexShrink: 0 }}>
<line x1="0" y1="5" x2="26" y2="5" stroke="#58a6ff" strokeWidth="2" strokeDasharray="5,3" />
</svg>
Port connection
</span>
</div>
<button
className="btn-secondary btn-sm"
style={{ marginLeft: 'auto' }}
onClick={() => setVb(initVbRef.current)}
>
Fit View
</button>
<span className="graph-hint">Scroll = zoom · Drag node · Drag bg = pan · Click = open</span>
</div>
<svg
ref={svgRef}
className="graph-canvas"
viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseUp}
onMouseDown={onBgDown}
onWheel={onWheel}
>
{/* Level guide bands */}
<g opacity="0.07">
{Array.from(new Set(renderNodes.map(n => Math.round(n.ty)))).map(ty => (
<line key={ty} x1={vb.x - 200} y1={ty} x2={vb.x + vb.w + 200} y2={ty}
stroke="#8b949e" strokeWidth="40" />
))}
</g>
{/* Edges */}
<g>
{edges.map((e, i) => {
const src = renderNodes.find(n => n.id === e.source);
const tgt = renderNodes.find(n => n.id === e.target);
if (!src || !tgt) return null;
if (e.type === 'connection') {
const mx = (src.x + tgt.x) / 2;
const my = (src.y + tgt.y) / 2 - 50;
return (
<path key={i}
d={`M${src.x.toFixed(1)},${src.y.toFixed(1)} Q${mx.toFixed(1)},${my.toFixed(1)} ${tgt.x.toFixed(1)},${tgt.y.toFixed(1)}`}
fill="none" stroke="#58a6ff" strokeWidth="1.5"
strokeDasharray="5,3" strokeOpacity="0.8"
/>
);
}
const midY = (src.y + tgt.y) / 2;
return (
<path key={i}
d={`M${src.x.toFixed(1)},${src.y.toFixed(1)} L${src.x.toFixed(1)},${midY.toFixed(1)} L${tgt.x.toFixed(1)},${midY.toFixed(1)} L${tgt.x.toFixed(1)},${tgt.y.toFixed(1)}`}
fill="none" stroke="#3d4650" strokeWidth="1.5" strokeOpacity="0.85"
/>
);
})}
</g>
{/* Nodes */}
<g>
{renderNodes.map(node => {
const r = NODE_R[node.type] ?? DEFAULT_R;
const c = nodeColors(node.type);
const isStr = ['site', 'room', 'rack'].includes(node.type);
const isPort = node.type === 'port';
const label = node.label.length > 14 ? node.label.slice(0, 12) + '…' : node.label;
return (
<g key={node.id} className="graph-node"
transform={`translate(${node.x.toFixed(1)},${node.y.toFixed(1)})`}
onMouseDown={ev => onNodeDown(ev, node.id)}
onClick={ev => { ev.stopPropagation(); if (!hasMoved.current) navigate(node.url); }}
style={{ cursor: 'pointer' }}
>
{isStr && (
<circle r={r + 6} fill={c.fill} fillOpacity="0.3"
stroke={c.stroke} strokeWidth="1" strokeOpacity="0.25" />
)}
<circle r={r} fill={c.fill} stroke={c.stroke} strokeWidth={isStr ? 2.5 : isPort ? 1 : 1.5} />
{!isPort && (
<text textAnchor="middle" dominantBaseline="central"
fontSize={isStr ? 18 : 14}
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{ICONS[node.type] ?? '📦'}
</text>
)}
<text y={r + (isPort ? 10 : 14)} textAnchor="middle" fontSize={isStr ? 12 : isPort ? 9 : 10}
fill={c.text} fontWeight={isStr ? '600' : '400'}
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{label}
</text>
{isStr && (
<text y={r + 26} textAnchor="middle" fontSize={9} fill="#6e7681"
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{TYPE_LABELS[node.type] ?? node.type}
</text>
)}
</g>
);
})}
</g>
</svg>
{renderNodes.length === 0 && !loading && (
<div className="graph-empty">
Nothing to show yet add rooms, racks and components first.
</div>
)}
</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>
);
}
+307
View File
@@ -0,0 +1,307 @@
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';
// ── Rack Layout Table ─────────────────────────────────────────────────────────
function RackLayoutView({ rack, components, onAddAtSlot }: {
rack: Rack;
components: Component[];
onAddAtSlot?: (position: number) => void;
}) {
const navigate = useNavigate();
// Map each unit number → component (for multi-U components, repeated)
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);
}
}
}
// Build rows for the table
interface RowInfo { unit: number; comp?: Component; isFirst: boolean; span: number }
const rows: RowInfo[] = [];
const seen = new Set<string>();
for (let u = 1; u <= rack.total_units; u++) {
const comp = byPos.get(u);
if (comp) {
if (!seen.has(comp.id)) {
seen.add(comp.id);
rows.push({ unit: u, comp, isFirst: true, span: comp.height_units });
} else {
rows.push({ unit: u, comp, isFirst: false, span: 0 });
}
} else {
rows.push({ unit: u, comp: undefined, isFirst: true, span: 1 });
}
}
return (
<div className="rack-layout">
<table className="rack-layout-table">
<tbody>
{rows.map(row => (
<tr key={row.unit} className="rack-layout-row">
<td className="rack-layout-u">{row.unit}</td>
{row.isFirst && (
row.comp ? (
<td
rowSpan={row.span}
className="rack-layout-comp"
style={{
background: COMPONENT_META[row.comp.type].bg,
borderLeft: `4px solid ${COMPONENT_META[row.comp.type].color}`,
}}
onClick={() => navigate(`/components/${row.comp!.id}`)}
>
<span className="rack-layout-comp-name">{row.comp.name}</span>
<span className="rack-layout-comp-type" style={{ color: COMPONENT_META[row.comp.type].color }}>
{COMPONENT_META[row.comp.type].label}
</span>
{row.comp.model && <span className="rack-layout-comp-model">{row.comp.model}</span>}
{row.comp.ip_address && <span className="rack-layout-comp-ip">{row.comp.ip_address}</span>}
{row.span > 1 && <span className="rack-layout-comp-height">{row.span}U</span>}
</td>
) : (
<td
className="rack-layout-empty"
onClick={() => onAddAtSlot?.(row.unit)}
>
<span className="rack-layout-empty-txt">· empty · click to add</span>
</td>
)
)}
</tr>
))}
</tbody>
</table>
</div>
);
}
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 [compView, setCompView] = useState<'table' | 'layout'>('table');
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-graph btn-sm" onClick={() => navigate(`/graph/rack/${rackId}`)}> Graph View</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>
<div className="section-view-toggle">
<button
className={`btn-sm ${compView === 'table' ? 'btn-secondary' : 'btn-ghost'}`}
title="Table view"
onClick={() => setCompView('table')}
> Table</button>
<button
className={`btn-sm ${compView === 'layout' ? 'btn-secondary' : 'btn-ghost'}`}
title="Rack layout view"
onClick={() => setCompView('layout')}
> Rack Map</button>
</div>
<button className="btn-primary btn-sm" onClick={() => { setAddAtPosition(undefined); setShowAddComponent(true); }}>
+ Add Component
</button>
</div>
{compView === 'layout' ? (
components.length === 0 ? (
<div className="empty-state"><p>No components yet.</p></div>
) : (
<RackLayoutView rack={rack} components={components} onAddAtSlot={handleAddAtSlot} />
)
) : (
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>
);
}
+151
View File
@@ -0,0 +1,151 @@
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-graph btn-sm" onClick={() => navigate(`/graph/room/${roomId}`)}> Graph View</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,415 @@
import { useEffect, useState, useCallback } from 'react';
import * as api from '../api';
import type { AuditEntry, User, DbStats } from '../types';
// ── Helpers ───────────────────────────────────────────────────────────────────
const ACTION_COLORS: Record<string, string> = {
create: '#3fb950',
update: '#58a6ff',
delete: '#f87171',
delete_all: '#f87171',
api_key_rotate: '#f59e0b',
api_key_revoke: '#f59e0b',
};
const ROLE_COLORS: Record<string, string> = {
admin: '#f87171',
editor: '#f59e0b',
viewer: '#8b949e',
};
function formatTs(ts: string) {
return new Date(ts).toLocaleString();
}
// ── Sub-section: DB Stats ─────────────────────────────────────────────────────
function StatsPanel({ stats }: { stats: DbStats }) {
return (
<div className="settings-stats">
{Object.entries(stats).map(([k, v]) => (
<div key={k} className="settings-stat-card">
<span className="settings-stat-val">{v}</span>
<span className="settings-stat-key">{k.replace(/_/g, ' ')}</span>
</div>
))}
</div>
);
}
// ── Sub-section: Users ────────────────────────────────────────────────────────
function UsersPanel() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const [form, setForm] = useState({ username: '', email: '', role: 'viewer' });
const [err, setErr] = useState<string | null>(null);
const [apiKeyResult, setApiKeyResult] = useState<{ userId: string; key: string } | null>(null);
const load = useCallback(async () => {
setLoading(true);
setUsers(await api.getUsers());
setLoading(false);
}, []);
useEffect(() => { load(); }, [load]);
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
setErr(null);
try {
await api.createUser(form);
setForm({ username: '', email: '', role: 'viewer' });
setShowAdd(false);
await load();
} catch (ex: unknown) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
};
const handleDelete = async (u: User) => {
if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return;
await api.deleteUser(u.id);
await load();
};
const handleToggleActive = async (u: User) => {
await api.updateUser(u.id, { is_active: !u.is_active });
await load();
};
const handleRotateKey = async (u: User) => {
if (!confirm(`Rotate API key for "${u.username}"? The old key will stop working immediately.`)) return;
const result = await api.rotateApiKey(u.id);
setApiKeyResult({ userId: u.id, key: result.api_key });
};
const handleRevokeKey = async (u: User) => {
if (!confirm(`Revoke API key for "${u.username}"?`)) return;
await api.revokeApiKey(u.id);
await load();
};
return (
<div className="settings-section">
<div className="settings-section-header">
<h2 className="settings-section-title">Users</h2>
<button className="btn-primary btn-sm" onClick={() => setShowAdd(v => !v)}>+ Add User</button>
</div>
<p className="settings-desc">
User accounts for this NetworkView instance. Authentication is delegated to your central app
set <code>X-User-Id</code> + <code>X-Username</code> headers on API requests, or use per-user API keys.
</p>
{showAdd && (
<form className="settings-user-form" onSubmit={handleAdd}>
<input
className="modal-input" placeholder="Username *" required
value={form.username} onChange={e => setForm(f => ({ ...f, username: e.target.value }))}
/>
<input
className="modal-input" placeholder="Email" type="email"
value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
/>
<select
className="modal-select"
value={form.role} onChange={e => setForm(f => ({ ...f, role: e.target.value }))}
>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
{err && <div className="settings-error">{err}</div>}
<div className="form-actions">
<button type="submit" className="btn-primary btn-sm">Create</button>
<button type="button" className="btn-secondary btn-sm" onClick={() => setShowAdd(false)}>Cancel</button>
</div>
</form>
)}
{apiKeyResult && (
<div className="settings-api-key-reveal">
<strong>New API Key (copy now shown only once):</strong>
<code className="settings-api-key-code">{apiKeyResult.key}</code>
<button className="btn-secondary btn-sm" onClick={() => { navigator.clipboard.writeText(apiKeyResult.key); }}>
📋 Copy
</button>
<button className="btn-ghost btn-sm" onClick={() => setApiKeyResult(null)}>Dismiss</button>
</div>
)}
{loading ? <div className="page-loading">Loading</div> : (
<table className="settings-user-table">
<thead>
<tr>
<th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>API Key</th><th>Actions</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id} style={{ opacity: u.is_active ? 1 : 0.5 }}>
<td><strong>{u.username}</strong></td>
<td style={{ color: 'var(--text3)', fontSize: 12 }}>{u.email ?? '—'}</td>
<td>
<span className="status-badge" style={{ color: ROLE_COLORS[u.role] ?? '#8b949e', borderColor: ROLE_COLORS[u.role] ?? '#8b949e' }}>
{u.role}
</span>
</td>
<td>
<span className={`status-badge status-${u.is_active ? 'active' : 'decommissioned'}`}>
{u.is_active ? 'active' : 'inactive'}
</span>
</td>
<td style={{ fontSize: 11, color: 'var(--text3)' }}>
{u.has_api_key ? '●●●●●●●●' : '—'}
</td>
<td className="settings-user-actions">
<button className="btn-ghost btn-sm" onClick={() => handleToggleActive(u)}
title={u.is_active ? 'Deactivate' : 'Activate'}>
{u.is_active ? '⏸' : '▶'}
</button>
<button className="btn-ghost btn-sm" onClick={() => handleRotateKey(u)} title="Rotate API key">🔑</button>
{u.has_api_key && (
<button className="btn-ghost btn-sm" onClick={() => handleRevokeKey(u)} title="Revoke API key">🚫</button>
)}
<button className="btn-danger btn-sm" onClick={() => handleDelete(u)}>🗑</button>
</td>
</tr>
))}
{users.length === 0 && (
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--text3)', padding: 20 }}>No users yet</td></tr>
)}
</tbody>
</table>
)}
</div>
);
}
// ── Sub-section: Audit Log ────────────────────────────────────────────────────
function AuditPanel() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState({ entity_type: '', action: '' });
const [offset, setOffset] = useState(0);
const LIMIT = 50;
const load = useCallback(async () => {
setLoading(true);
const result = await api.getAuditLog({ ...filter, limit: LIMIT, offset });
setEntries(result.entries);
setTotal(result.total);
setLoading(false);
}, [filter, offset]);
useEffect(() => { load(); }, [load]);
const handleClearAudit = async () => {
if (!confirm('Clear the entire audit log? This cannot be undone.')) return;
await api.clearAuditLog();
setOffset(0);
await load();
};
return (
<div className="settings-section">
<div className="settings-section-header">
<h2 className="settings-section-title">Audit Log</h2>
<button className="btn-danger btn-sm" onClick={handleClearAudit}>🗑 Clear Log</button>
</div>
<div className="settings-audit-filters">
<select className="modal-select" style={{ width: 'auto' }}
value={filter.entity_type}
onChange={e => { setFilter(f => ({ ...f, entity_type: e.target.value })); setOffset(0); }}>
<option value="">All types</option>
{['site','room','rack','component','port','user','database','audit_log'].map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
<select className="modal-select" style={{ width: 'auto' }}
value={filter.action}
onChange={e => { setFilter(f => ({ ...f, action: e.target.value })); setOffset(0); }}>
<option value="">All actions</option>
{['create','update','delete','delete_all','api_key_rotate','api_key_revoke'].map(a => (
<option key={a} value={a}>{a}</option>
))}
</select>
<span style={{ color: 'var(--text3)', fontSize: 12 }}>{total} entries</span>
</div>
{loading ? <div className="page-loading" style={{ height: 80 }}>Loading</div> : (
<>
<table className="settings-audit-table">
<thead>
<tr><th>Time</th><th>User</th><th>Action</th><th>Type</th><th>Entity</th><th>Changes</th></tr>
</thead>
<tbody>
{entries.map(e => (
<tr key={e.id}>
<td style={{ whiteSpace: 'nowrap', fontSize: 11, color: 'var(--text3)' }}>{formatTs(e.created_at)}</td>
<td style={{ fontSize: 12 }}>{e.username ?? '—'}</td>
<td>
<span className="audit-action-badge" style={{ color: ACTION_COLORS[e.action] ?? '#8b949e' }}>
{e.action}
</span>
</td>
<td style={{ fontSize: 12, color: 'var(--text3)' }}>{e.entity_type}</td>
<td style={{ fontSize: 12 }}>{e.entity_name ?? e.entity_id ?? '—'}</td>
<td style={{ fontSize: 11, color: 'var(--text3)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{e.changes ?? '—'}
</td>
</tr>
))}
{entries.length === 0 && (
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--text3)', padding: 20 }}>No entries</td></tr>
)}
</tbody>
</table>
<div className="settings-audit-pager">
<button className="btn-secondary btn-sm" disabled={offset === 0}
onClick={() => setOffset(o => Math.max(0, o - LIMIT))}> Previous</button>
<span style={{ color: 'var(--text3)', fontSize: 12 }}>
{offset + 1}{Math.min(offset + LIMIT, total)} of {total}
</span>
<button className="btn-secondary btn-sm" disabled={offset + LIMIT >= total}
onClick={() => setOffset(o => o + LIMIT)}>Next </button>
</div>
</>
)}
</div>
);
}
// ── Sub-section: Danger Zone ──────────────────────────────────────────────────
function DangerZone({ onAction }: { onAction: () => void }) {
const [confirmText, setConfirmText] = useState('');
const [busy, setBusy] = useState(false);
const deleteAll = async () => {
if (confirmText !== 'DELETE ALL') {
alert('Type DELETE ALL exactly to confirm.');
return;
}
setBusy(true);
try {
await api.deleteAllData();
setConfirmText('');
onAction();
alert('All network data deleted.');
} catch (e: unknown) {
alert(e instanceof Error ? e.message : String(e));
}
setBusy(false);
};
return (
<div className="settings-section settings-danger-zone">
<h2 className="settings-section-title" style={{ color: '#f87171' }}> Danger Zone</h2>
<p className="settings-desc">
These operations are irreversible. All cascading data (rooms, racks, components, ports) will be permanently deleted.
</p>
<div className="danger-action">
<div>
<strong>Delete all network data</strong>
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '4px 0 0' }}>
Removes all sites, rooms, racks, components, ports and audit entries. User accounts are kept.
</p>
</div>
<div className="danger-confirm-row">
<input
className="modal-input danger-input"
placeholder='Type "DELETE ALL" to confirm'
value={confirmText}
onChange={e => setConfirmText(e.target.value)}
/>
<button
className="btn-danger btn-sm"
disabled={busy || confirmText !== 'DELETE ALL'}
onClick={deleteAll}
>
{busy ? '…' : '🗑 Delete All'}
</button>
</div>
</div>
</div>
);
}
// ── Main Page ─────────────────────────────────────────────────────────────────
type Tab = 'overview' | 'users' | 'audit' | 'danger';
export default function SettingsPage() {
const [tab, setTab] = useState<Tab>('overview');
const [stats, setStats] = useState<DbStats | null>(null);
const loadStats = useCallback(async () => {
setStats(await api.getDbStats());
}, []);
useEffect(() => { loadStats(); }, [loadStats]);
const tabs: { key: Tab; label: string }[] = [
{ key: 'overview', label: '📊 Overview' },
{ key: 'users', label: '👤 Users' },
{ key: 'audit', label: '📋 Audit Log' },
{ key: 'danger', label: '⚠ Danger' },
];
return (
<div className="page page-settings">
<div className="page-header">
<h1 className="entity-title" style={{ fontSize: 22 }}> Settings</h1>
</div>
<div className="settings-tabs">
{tabs.map(t => (
<button
key={t.key}
className={`settings-tab ${tab === t.key ? 'settings-tab-active' : ''}`}
onClick={() => setTab(t.key)}
>
{t.label}
</button>
))}
</div>
<div className="settings-body">
{tab === 'overview' && (
<div className="settings-section">
<h2 className="settings-section-title">Database Overview</h2>
{stats ? <StatsPanel stats={stats} /> : <div className="page-loading">Loading</div>}
<div className="settings-api-info">
<h3 style={{ marginBottom: 8, color: 'var(--text2)' }}>API Integration</h3>
<p className="settings-desc">
This app exposes a REST API designed to be consumed by a central management app.
Use the following headers on every request:
</p>
<table className="settings-api-table">
<thead><tr><th>Header</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>X-User-Id</code></td><td>UUID of the authenticated user (set by your auth gateway)</td></tr>
<tr><td><code>X-Username</code></td><td>Display name of the authenticated user</td></tr>
<tr><td><code>X-Api-Key</code></td><td>Per-user API key (alternative to gateway headers)</td></tr>
<tr><td><code>X-Confirm: yes</code></td><td>Required for destructive operations</td></tr>
</tbody>
</table>
<p className="settings-desc" style={{ marginTop: 12 }}>
<strong>Key endpoints:</strong>{' '}
<code>GET /api/users</code> · <code>GET /api/audit</code> ·
<code>GET /api/settings/stats</code> · <code>DELETE /api/settings/data/all</code>
</p>
</div>
</div>
)}
{tab === 'users' && <UsersPanel />}
{tab === 'audit' && <AuditPanel />}
{tab === 'danger' && <DangerZone onAction={loadStats} />}
</div>
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
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-graph btn-sm" onClick={() => navigate(`/graph/site/${siteId}`)}> Graph View</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>
);
}
+164
View File
@@ -0,0 +1,164 @@
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',
];
// ── Users & Audit types (for Settings page) ──────────────────────────────────
export interface User {
id: string;
username: string;
email?: string;
role: 'admin' | 'editor' | 'viewer';
is_active: boolean;
has_api_key: boolean;
created_at: string;
updated_at: string;
}
export interface AuditEntry {
id: string;
user_id?: string;
username?: string;
action: string;
entity_type: string;
entity_id?: string;
entity_name?: string;
changes?: string;
ip_address?: string;
created_at: string;
}
export interface DbStats {
sites: number;
rooms: number;
racks: number;
components: number;
ports: number;
users: number;
audit_entries: number;
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"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" }]
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": false
},
"include": ["vite.config.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// In production (Docker build) VITE_BASE_PATH=/networkview/ is injected.
// In development it falls back to '/' so the dev server works as-is.
const basePath = process.env.VITE_BASE_PATH ?? '/';
export default defineConfig({
plugins: [react()],
base: basePath,
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
},
});
+5452
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -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"
}
}