From eed5f39a10ca5627324519ee8c991d5b28134775 Mon Sep 17 00:00:00 2001 From: scheianu Date: Sat, 9 May 2026 01:16:30 +0300 Subject: [PATCH] Initial commit --- .gitignore | 5 + README.md | 117 + backend/package.json | 19 + backend/src/db.js | 79 + backend/src/index.js | 51 + backend/src/routes/components.js | 233 + backend/src/routes/racks.js | 95 + backend/src/routes/rooms.js | 77 + backend/src/routes/sites.js | 70 + data/.gitkeep | 0 frontend/index.html | 13 + frontend/package.json | 25 + frontend/src/App.tsx | 26 + frontend/src/api.ts | 70 + frontend/src/components/AddItemModal.tsx | 293 + frontend/src/components/MarkdownEditor.tsx | 80 + frontend/src/components/RackGraphicView.tsx | 466 ++ frontend/src/components/RackVisual.tsx | 186 + frontend/src/components/Sidebar.tsx | 229 + frontend/src/index.css | 1440 +++++ frontend/src/main.tsx | 10 + frontend/src/pages/ComponentPage.tsx | 479 ++ frontend/src/pages/HomePage.tsx | 63 + frontend/src/pages/RackPage.tsx | 205 + frontend/src/pages/RoomPage.tsx | 150 + frontend/src/pages/SitePage.tsx | 139 + frontend/src/types.ts | 128 + frontend/tsconfig.json | 21 + frontend/tsconfig.node.json | 12 + frontend/vite.config.ts | 18 + package-lock.json | 5452 +++++++++++++++++++ package.json | 16 + 32 files changed, 10267 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/package.json create mode 100644 backend/src/db.js create mode 100644 backend/src/index.js create mode 100644 backend/src/routes/components.js create mode 100644 backend/src/routes/racks.js create mode 100644 backend/src/routes/rooms.js create mode 100644 backend/src/routes/sites.js create mode 100644 data/.gitkeep create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/components/AddItemModal.tsx create mode 100644 frontend/src/components/MarkdownEditor.tsx create mode 100644 frontend/src/components/RackGraphicView.tsx create mode 100644 frontend/src/components/RackVisual.tsx create mode 100644 frontend/src/components/Sidebar.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/ComponentPage.tsx create mode 100644 frontend/src/pages/HomePage.tsx create mode 100644 frontend/src/pages/RackPage.tsx create mode 100644 frontend/src/pages/RoomPage.tsx create mode 100644 frontend/src/pages/SitePage.tsx create mode 100644 frontend/src/types.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d42567 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +*.db +!data/.gitkeep diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b2db46 --- /dev/null +++ b/README.md @@ -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 | diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..f267795 --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/src/db.js b/backend/src/db.js new file mode 100644 index 0000000..22b365b --- /dev/null +++ b/backend/src/db.js @@ -0,0 +1,79 @@ +const Database = require('better-sqlite3'); +const path = require('path'); +const fs = require('fs'); + +const DB_PATH = path.join(__dirname, '../../../data/networkview.db'); +fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); +const db = new Database(DB_PATH); + +// Enable WAL mode for better performance +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +db.exec(` + CREATE TABLE IF NOT EXISTS sites ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + location TEXT, + notes TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS rooms ( + id TEXT PRIMARY KEY, + site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE, + name TEXT NOT NULL, + notes TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS racks ( + id TEXT PRIMARY KEY, + room_id TEXT REFERENCES rooms(id) ON DELETE CASCADE, + name TEXT NOT NULL, + total_units INTEGER DEFAULT 42, + manufacturer TEXT, + model TEXT, + notes TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS components ( + id TEXT PRIMARY KEY, + rack_id TEXT REFERENCES racks(id) ON DELETE CASCADE, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'other', + position INTEGER, + height_units INTEGER DEFAULT 1, + manufacturer TEXT, + model TEXT, + serial_number TEXT, + asset_tag TEXT, + ip_address TEXT, + mac_address TEXT, + status TEXT DEFAULT 'active', + notes TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS ports ( + id TEXT PRIMARY KEY, + component_id TEXT NOT NULL REFERENCES components(id) ON DELETE CASCADE, + port_number INTEGER NOT NULL, + label TEXT, + port_type TEXT DEFAULT 'RJ45', + connected_to_port_id TEXT REFERENCES ports(id) ON DELETE SET NULL, + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); +`); + +// Migrate: add port_count column if it doesn't exist yet +try { db.exec('ALTER TABLE components ADD COLUMN port_count INTEGER DEFAULT NULL'); } catch (_) {} +try { db.exec('ALTER TABLE components ADD COLUMN sfp_count INTEGER DEFAULT NULL'); } catch (_) {} + +module.exports = db; diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..665e48a --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,51 @@ +const express = require('express'); +const cors = require('cors'); +const morgan = require('morgan'); +const path = require('path'); + +const app = express(); +const PORT = process.env.PORT || 3001; + +app.use(cors()); +app.use(express.json()); +app.use(morgan('dev')); + +// API routes +app.use('/api/sites', require('./routes/sites')); +app.use('/api/rooms', require('./routes/rooms')); +app.use('/api/racks', require('./routes/racks')); +app.use('/api/components', require('./routes/components')); + +// Search across all entities +const db = require('./db'); +app.get('/api/search', (req, res) => { + const { q } = req.query; + if (!q || q.trim().length < 2) return res.json({ sites: [], rooms: [], racks: [], components: [] }); + + const term = `%${q.trim()}%`; + const sites = db.prepare('SELECT id, name, location FROM sites WHERE name LIKE ? OR location LIKE ? LIMIT 10').all(term, term); + const rooms = db.prepare('SELECT id, name, site_id FROM rooms WHERE name LIKE ? LIMIT 10').all(term); + const racks = db.prepare('SELECT id, name, room_id FROM racks WHERE name LIKE ? OR model LIKE ? LIMIT 10').all(term, term); + const components = db.prepare(` + SELECT id, name, type, rack_id, model, ip_address FROM components + WHERE name LIKE ? OR model LIKE ? OR ip_address LIKE ? OR serial_number LIKE ? LIMIT 20 + `).all(term, term, term, term); + + res.json({ sites, rooms, racks, components }); +}); + +// Health check +app.get('/api/health', (_req, res) => res.json({ status: 'ok', time: new Date().toISOString() })); + +// Serve built frontend in production +const FRONTEND_DIST = path.join(__dirname, '../../frontend/dist'); +app.use(express.static(FRONTEND_DIST)); +app.get('*', (_req, res) => { + res.sendFile(path.join(FRONTEND_DIST, 'index.html'), err => { + if (err) res.status(200).json({ message: 'NetworkView API running. Start frontend separately in dev mode.' }); + }); +}); + +app.listen(PORT, () => { + console.log(`NetworkView backend running on http://localhost:${PORT}`); +}); diff --git a/backend/src/routes/components.js b/backend/src/routes/components.js new file mode 100644 index 0000000..5c9fd5a --- /dev/null +++ b/backend/src/routes/components.js @@ -0,0 +1,233 @@ +const express = require('express'); +const { v4: uuidv4 } = require('uuid'); +const db = require('../db'); +const router = express.Router(); + +function checkPositionOverlap(rackId, position, heightUnits, excludeId = null) { + const end = position + heightUnits - 1; + const existing = db.prepare(` + SELECT id, name, position, height_units FROM components + WHERE rack_id = ? + AND id != ? + AND position IS NOT NULL + AND position <= ? AND (position + height_units - 1) >= ? + `).all(rackId, excludeId || '', end, position); + return existing; +} + +// GET /api/components?rackId= +router.get('/', (req, res) => { + const { rackId } = req.query; + if (!rackId) return res.status(400).json({ error: 'rackId query param required' }); + + const components = db.prepare( + 'SELECT * FROM components WHERE rack_id = ? ORDER BY position ASC NULLS LAST' + ).all(rackId); + res.json(components); +}); + +// GET /api/components/:id +router.get('/:id', (req, res) => { + const component = db.prepare('SELECT * FROM components WHERE id = ?').get(req.params.id); + if (!component) return res.status(404).json({ error: 'Component not found' }); + + const rawPorts = db.prepare('SELECT * FROM ports WHERE component_id = ? ORDER BY port_number').all(req.params.id); + const ports = rawPorts.map(p => { + if (!p.connected_to_port_id) return p; + const lp = db.prepare('SELECT * FROM ports WHERE id = ?').get(p.connected_to_port_id); + if (!lp) return p; + const lc = db.prepare('SELECT id, name, type FROM components WHERE id = ?').get(lp.component_id); + return { ...p, linked_port: lc ? { id: lp.id, port_number: lp.port_number, label: lp.label, component_id: lc.id, component_name: lc.name, component_type: lc.type } : null }; + }); + + let rack = null, room = null, site = null; + if (component.rack_id) { + rack = db.prepare('SELECT id, name, total_units, room_id FROM racks WHERE id = ?').get(component.rack_id); + if (rack?.room_id) { + room = db.prepare('SELECT id, name, site_id FROM rooms WHERE id = ?').get(rack.room_id); + if (room?.site_id) site = db.prepare('SELECT id, name FROM sites WHERE id = ?').get(room.site_id); + } + } + + res.json({ ...component, ports, rack, room, site }); +}); + +// POST /api/components +router.post('/', (req, res) => { + const { + rack_id, name, type, position, height_units, + manufacturer, model, serial_number, asset_tag, + ip_address, mac_address, status, notes + } = req.body; + + if (!name) return res.status(400).json({ error: 'name is required' }); + + if (rack_id) { + const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(rack_id); + if (!rack) return res.status(404).json({ error: 'Rack not found' }); + + if (position != null) { + const h = height_units || 1; + if (position < 1 || position + h - 1 > rack.total_units) { + return res.status(400).json({ error: `Position ${position} with ${h}U height exceeds rack size (${rack.total_units}U)` }); + } + const overlapping = checkPositionOverlap(rack_id, position, h); + if (overlapping.length > 0) { + return res.status(400).json({ + error: `Position overlaps with existing component: ${overlapping[0].name}` + }); + } + } + } + + const { port_count, sfp_count } = req.body; + const id = uuidv4(); + db.prepare(` + INSERT INTO components + (id, rack_id, name, type, position, height_units, manufacturer, model, + serial_number, asset_tag, ip_address, mac_address, status, notes, port_count, sfp_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, rack_id || null, name, type || 'other', + position ?? null, height_units || 1, + manufacturer || null, model || null, + serial_number || null, asset_tag || null, + ip_address || null, mac_address || null, + status || 'active', notes || '', + port_count ?? null, sfp_count ?? null + ); + + res.status(201).json(db.prepare('SELECT * FROM components WHERE id = ?').get(id)); +}); + +// PUT /api/components/:id +router.put('/:id', (req, res) => { + const component = db.prepare('SELECT * FROM components WHERE id = ?').get(req.params.id); + if (!component) return res.status(404).json({ error: 'Component not found' }); + + const { + name, type, position, height_units, manufacturer, model, + serial_number, asset_tag, ip_address, mac_address, status, notes, port_count, sfp_count + } = req.body; + + const newPos = position ?? component.position; + const newH = height_units ?? component.height_units; + + if (component.rack_id && newPos != null) { + const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(component.rack_id); + if (newPos < 1 || newPos + newH - 1 > rack.total_units) { + return res.status(400).json({ error: `Position exceeds rack size (${rack.total_units}U)` }); + } + const overlapping = checkPositionOverlap(component.rack_id, newPos, newH, req.params.id); + if (overlapping.length > 0) { + return res.status(400).json({ error: `Position overlaps with: ${overlapping[0].name}` }); + } + } + + db.prepare(` + UPDATE components SET + name = ?, type = ?, position = ?, height_units = ?, + manufacturer = ?, model = ?, serial_number = ?, asset_tag = ?, + ip_address = ?, mac_address = ?, status = ?, notes = ?, + port_count = ?, sfp_count = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run( + name ?? component.name, + type ?? component.type, + newPos, + newH, + manufacturer ?? component.manufacturer, + model ?? component.model, + serial_number ?? component.serial_number, + asset_tag ?? component.asset_tag, + ip_address ?? component.ip_address, + mac_address ?? component.mac_address, + status ?? component.status, + notes ?? component.notes, + port_count !== undefined ? port_count : component.port_count, + sfp_count !== undefined ? sfp_count : component.sfp_count, + req.params.id + ); + + res.json(db.prepare('SELECT * FROM components WHERE id = ?').get(req.params.id)); +}); + +// DELETE /api/components/:id +router.delete('/:id', (req, res) => { + const component = db.prepare('SELECT id FROM components WHERE id = ?').get(req.params.id); + if (!component) return res.status(404).json({ error: 'Component not found' }); + + db.prepare('DELETE FROM components WHERE id = ?').run(req.params.id); + res.json({ ok: true }); +}); + +// --- Ports sub-resource --- + +// GET /api/components/:id/ports +router.get('/:id/ports', (req, res) => { + const ports = db.prepare('SELECT * FROM ports WHERE component_id = ? ORDER BY port_number').all(req.params.id); + res.json(ports); +}); + +// POST /api/components/:id/ports +router.post('/:id/ports', (req, res) => { + const component = db.prepare('SELECT id FROM components WHERE id = ?').get(req.params.id); + if (!component) return res.status(404).json({ error: 'Component not found' }); + + const { port_number, label, port_type, connected_to_port_id, notes } = req.body; + if (port_number == null) return res.status(400).json({ error: 'port_number is required' }); + + const id = uuidv4(); + db.prepare(` + INSERT INTO ports (id, component_id, port_number, label, port_type, connected_to_port_id, notes) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(id, req.params.id, port_number, label || null, port_type || 'RJ45', connected_to_port_id || null, notes || null); + + // Mirror link on the other side + if (connected_to_port_id) { + db.prepare('UPDATE ports SET connected_to_port_id = ? WHERE id = ?').run(id, connected_to_port_id); + } + + res.status(201).json(db.prepare('SELECT * FROM ports WHERE id = ?').get(id)); +}); + +// PUT /api/components/:componentId/ports/:portId +router.put('/:componentId/ports/:portId', (req, res) => { + const port = db.prepare('SELECT * FROM ports WHERE id = ? AND component_id = ?').get(req.params.portId, req.params.componentId); + if (!port) return res.status(404).json({ error: 'Port not found' }); + + const { label, port_type, connected_to_port_id, notes } = req.body; + const newLinkedId = connected_to_port_id !== undefined ? (connected_to_port_id || null) : port.connected_to_port_id; + const oldLinkedId = port.connected_to_port_id; + + // Clear old reverse link if it changed + if (oldLinkedId && oldLinkedId !== newLinkedId) { + db.prepare('UPDATE ports SET connected_to_port_id = NULL WHERE id = ? AND connected_to_port_id = ?').run(oldLinkedId, port.id); + } + + db.prepare(` + UPDATE ports SET label = ?, port_type = ?, connected_to_port_id = ?, notes = ? WHERE id = ? + `).run( + label ?? port.label, + port_type ?? port.port_type, + newLinkedId, + notes ?? port.notes, + req.params.portId + ); + + // Set new reverse link + if (newLinkedId && newLinkedId !== oldLinkedId) { + db.prepare('UPDATE ports SET connected_to_port_id = ? WHERE id = ?').run(port.id, newLinkedId); + } + + res.json(db.prepare('SELECT * FROM ports WHERE id = ?').get(req.params.portId)); +}); + +// DELETE /api/components/:componentId/ports/:portId +router.delete('/:componentId/ports/:portId', (req, res) => { + db.prepare('DELETE FROM ports WHERE id = ? AND component_id = ?').run(req.params.portId, req.params.componentId); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/backend/src/routes/racks.js b/backend/src/routes/racks.js new file mode 100644 index 0000000..8961a25 --- /dev/null +++ b/backend/src/routes/racks.js @@ -0,0 +1,95 @@ +const express = require('express'); +const { v4: uuidv4 } = require('uuid'); +const db = require('../db'); +const router = express.Router(); + +// GET /api/racks?roomId= +router.get('/', (req, res) => { + const { roomId } = req.query; + if (!roomId) return res.status(400).json({ error: 'roomId query param required' }); + + const racks = db.prepare(` + SELECT ra.*, COUNT(c.id) as component_count + FROM racks ra + LEFT JOIN components c ON c.rack_id = ra.id + WHERE ra.room_id = ? + GROUP BY ra.id + ORDER BY ra.name + `).all(roomId); + res.json(racks); +}); + +// GET /api/racks/:id +router.get('/:id', (req, res) => { + const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(req.params.id); + if (!rack) return res.status(404).json({ error: 'Rack not found' }); + + const components = db.prepare(` + SELECT * FROM components WHERE rack_id = ? ORDER BY position ASC NULLS LAST, name ASC + `).all(req.params.id); + + // Attach ports to each component so the graphic view can show active ports + const componentsWithPorts = components.map(c => { + const ports = db.prepare('SELECT * FROM ports WHERE component_id = ? ORDER BY port_number').all(c.id); + return { ...c, ports }; + }); + + let room = null, site = null; + if (rack.room_id) { + room = db.prepare('SELECT id, name, site_id FROM rooms WHERE id = ?').get(rack.room_id); + if (room) site = db.prepare('SELECT id, name FROM sites WHERE id = ?').get(room.site_id); + } + + res.json({ ...rack, components: componentsWithPorts, room, site }); +}); + +// POST /api/racks +router.post('/', (req, res) => { + const { room_id, name, total_units, manufacturer, model, notes } = req.body; + if (!name) return res.status(400).json({ error: 'name is required' }); + + if (room_id) { + const room = db.prepare('SELECT id FROM rooms WHERE id = ?').get(room_id); + if (!room) return res.status(404).json({ error: 'Room not found' }); + } + + const id = uuidv4(); + db.prepare(` + INSERT INTO racks (id, room_id, name, total_units, manufacturer, model, notes) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(id, room_id || null, name, total_units || 42, manufacturer || null, model || null, notes || ''); + + res.status(201).json(db.prepare('SELECT * FROM racks WHERE id = ?').get(id)); +}); + +// PUT /api/racks/:id +router.put('/:id', (req, res) => { + const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(req.params.id); + if (!rack) return res.status(404).json({ error: 'Rack not found' }); + + const { name, total_units, manufacturer, model, notes } = req.body; + db.prepare(` + UPDATE racks SET name = ?, total_units = ?, manufacturer = ?, model = ?, notes = ?, + updated_at = CURRENT_TIMESTAMP WHERE id = ? + `).run( + name ?? rack.name, + total_units ?? rack.total_units, + manufacturer ?? rack.manufacturer, + model ?? rack.model, + notes ?? rack.notes, + req.params.id + ); + + res.json(db.prepare('SELECT * FROM racks WHERE id = ?').get(req.params.id)); +}); + +// DELETE /api/racks/:id +router.delete('/:id', (req, res) => { + const rack = db.prepare('SELECT id FROM racks WHERE id = ?').get(req.params.id); + if (!rack) return res.status(404).json({ error: 'Rack not found' }); + + db.prepare('DELETE FROM racks WHERE id = ?').run(req.params.id); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/backend/src/routes/rooms.js b/backend/src/routes/rooms.js new file mode 100644 index 0000000..34e262f --- /dev/null +++ b/backend/src/routes/rooms.js @@ -0,0 +1,77 @@ +const express = require('express'); +const { v4: uuidv4 } = require('uuid'); +const db = require('../db'); +const router = express.Router(); + +// GET /api/rooms?siteId= +router.get('/', (req, res) => { + const { siteId } = req.query; + if (!siteId) return res.status(400).json({ error: 'siteId query param required' }); + + const rooms = db.prepare(` + SELECT r.*, COUNT(ra.id) as rack_count + FROM rooms r + LEFT JOIN racks ra ON ra.room_id = r.id + WHERE r.site_id = ? + GROUP BY r.id + ORDER BY r.name + `).all(siteId); + res.json(rooms); +}); + +// GET /api/rooms/:id +router.get('/:id', (req, res) => { + const room = db.prepare('SELECT * FROM rooms WHERE id = ?').get(req.params.id); + if (!room) return res.status(404).json({ error: 'Room not found' }); + + const racks = db.prepare(` + SELECT ra.*, COUNT(c.id) as component_count + FROM racks ra + LEFT JOIN components c ON c.rack_id = ra.id + WHERE ra.room_id = ? + GROUP BY ra.id + ORDER BY ra.name + `).all(req.params.id); + + const site = db.prepare('SELECT id, name FROM sites WHERE id = ?').get(room.site_id); + res.json({ ...room, racks, site }); +}); + +// POST /api/rooms +router.post('/', (req, res) => { + const { site_id, name, notes } = req.body; + if (!site_id || !name) return res.status(400).json({ error: 'site_id and name are required' }); + + const site = db.prepare('SELECT id FROM sites WHERE id = ?').get(site_id); + if (!site) return res.status(404).json({ error: 'Site not found' }); + + const id = uuidv4(); + db.prepare('INSERT INTO rooms (id, site_id, name, notes) VALUES (?, ?, ?, ?)').run( + id, site_id, name, notes || '' + ); + res.status(201).json(db.prepare('SELECT * FROM rooms WHERE id = ?').get(id)); +}); + +// PUT /api/rooms/:id +router.put('/:id', (req, res) => { + const room = db.prepare('SELECT * FROM rooms WHERE id = ?').get(req.params.id); + if (!room) return res.status(404).json({ error: 'Room not found' }); + + const { name, notes } = req.body; + db.prepare(` + UPDATE rooms SET name = ?, notes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? + `).run(name ?? room.name, notes ?? room.notes, req.params.id); + + res.json(db.prepare('SELECT * FROM rooms WHERE id = ?').get(req.params.id)); +}); + +// DELETE /api/rooms/:id +router.delete('/:id', (req, res) => { + const room = db.prepare('SELECT id FROM rooms WHERE id = ?').get(req.params.id); + if (!room) return res.status(404).json({ error: 'Room not found' }); + + db.prepare('DELETE FROM rooms WHERE id = ?').run(req.params.id); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/backend/src/routes/sites.js b/backend/src/routes/sites.js new file mode 100644 index 0000000..9781c6a --- /dev/null +++ b/backend/src/routes/sites.js @@ -0,0 +1,70 @@ +const express = require('express'); +const { v4: uuidv4 } = require('uuid'); +const db = require('../db'); +const router = express.Router(); + +// GET /api/sites +router.get('/', (req, res) => { + const sites = db.prepare(` + SELECT s.*, COUNT(r.id) as room_count + FROM sites s + LEFT JOIN rooms r ON r.site_id = s.id + GROUP BY s.id + ORDER BY s.name + `).all(); + res.json(sites); +}); + +// GET /api/sites/:id +router.get('/:id', (req, res) => { + const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); + if (!site) return res.status(404).json({ error: 'Site not found' }); + + const rooms = db.prepare(` + SELECT r.*, COUNT(ra.id) as rack_count + FROM rooms r + LEFT JOIN racks ra ON ra.room_id = r.id + WHERE r.site_id = ? + GROUP BY r.id + ORDER BY r.name + `).all(req.params.id); + + res.json({ ...site, rooms }); +}); + +// POST /api/sites +router.post('/', (req, res) => { + const { name, location, notes } = req.body; + if (!name) return res.status(400).json({ error: 'Name is required' }); + + const id = uuidv4(); + db.prepare('INSERT INTO sites (id, name, location, notes) VALUES (?, ?, ?, ?)').run( + id, name, location || null, notes || '' + ); + res.status(201).json(db.prepare('SELECT * FROM sites WHERE id = ?').get(id)); +}); + +// PUT /api/sites/:id +router.put('/:id', (req, res) => { + const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); + if (!site) return res.status(404).json({ error: 'Site not found' }); + + const { name, location, notes } = req.body; + db.prepare(` + UPDATE sites SET name = ?, location = ?, notes = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(name ?? site.name, location ?? site.location, notes ?? site.notes, req.params.id); + + res.json(db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id)); +}); + +// DELETE /api/sites/:id +router.delete('/:id', (req, res) => { + const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); + if (!site) return res.status(404).json({ error: 'Site not found' }); + + db.prepare('DELETE FROM sites WHERE id = ?').run(req.params.id); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b433c59 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + NetworkView — Lab Documentation + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..701bb69 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..fccfe60 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,26 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import Sidebar from './components/Sidebar'; +import HomePage from './pages/HomePage'; +import SitePage from './pages/SitePage'; +import RoomPage from './pages/RoomPage'; +import RackPage from './pages/RackPage'; +import ComponentPage from './pages/ComponentPage'; + +export default function App() { + return ( + +
+ +
+ + } /> + } /> + } /> + } /> + } /> + +
+
+
+ ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..4d4abed --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,70 @@ +import type { + Site, Room, Rack, Component, Port, SearchResults +} from './types'; + +const BASE = '/api'; + +async function request(path: string, options?: RequestInit): Promise { + 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; +} + +// --- Sites --- +export const getSites = () => request('/sites'); +export const getSite = (id: string) => request(`/sites/${id}`); +export const createSite = (data: Partial) => + request('/sites', { method: 'POST', body: JSON.stringify(data) }); +export const updateSite = (id: string, data: Partial) => + request(`/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(`/rooms?siteId=${siteId}`); +export const getRoom = (id: string) => request(`/rooms/${id}`); +export const createRoom = (data: Partial) => + request('/rooms', { method: 'POST', body: JSON.stringify(data) }); +export const updateRoom = (id: string, data: Partial) => + request(`/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(`/racks?roomId=${roomId}`); +export const getRack = (id: string) => request(`/racks/${id}`); +export const createRack = (data: Partial) => + request('/racks', { method: 'POST', body: JSON.stringify(data) }); +export const updateRack = (id: string, data: Partial) => + request(`/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(`/components?rackId=${rackId}`); +export const getComponent = (id: string) => request(`/components/${id}`); +export const createComponent = (data: Partial) => + request('/components', { method: 'POST', body: JSON.stringify(data) }); +export const updateComponent = (id: string, data: Partial) => + request(`/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(`/components/${componentId}/ports`); +export const createPort = (componentId: string, data: Partial) => + request(`/components/${componentId}/ports`, { method: 'POST', body: JSON.stringify(data) }); +export const updatePort = (componentId: string, portId: string, data: Partial) => + request(`/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(`/search?q=${encodeURIComponent(q)}`); diff --git a/frontend/src/components/AddItemModal.tsx b/frontend/src/components/AddItemModal.tsx new file mode 100644 index 0000000..0cc306a --- /dev/null +++ b/frontend/src/components/AddItemModal.tsx @@ -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; + 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({ + 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 ( +
e.target === e.currentTarget && onClose()}> +
+
+

Add Component

+ +
+ +
+
+
+ + set('name', e.target.value)} + placeholder="e.g. Core Switch SW-01" + className="form-input" + /> +
+
+ + +
+
+ +
+
+ + set('position', e.target.value ? Number(e.target.value) : null)} + placeholder="auto" + className="form-input" + /> + Leave empty to leave unpositioned +
+
+ + set('height_units', Number(e.target.value))} + className="form-input" + /> +
+
+ + +
+
+ + {(form.type === 'switch' || form.type === 'patch_panel') && ( +
+
+ + set('port_count', Number(e.target.value))} + className="form-input" + placeholder="24" + /> + + {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)'} + +
+ {form.type === 'switch' && ( +
+ + set('sfp_count', Number(e.target.value))} + className="form-input" + placeholder="0" + /> + SFP / fiber uplink slots (0–16) +
+ )} +
+ )} + +
+
+ + set('manufacturer', e.target.value)} placeholder="e.g. Cisco" className="form-input" /> +
+
+ + set('model', e.target.value)} placeholder="e.g. Catalyst 2960" className="form-input" /> +
+
+ +
+
+ + set('ip_address', e.target.value)} placeholder="192.168.1.1" className="form-input" /> +
+
+ + set('mac_address', e.target.value)} placeholder="AA:BB:CC:DD:EE:FF" className="form-input" /> +
+
+ +
+
+ + set('serial_number', e.target.value)} className="form-input" /> +
+
+ + set('asset_tag', e.target.value)} className="form-input" /> +
+
+ + {error &&
{error}
} + +
+ + +
+
+
+
+ ); +} + +// 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) => Promise; + onClose: () => void; +} + +export function SimpleCreateModal({ title, fields, onSave, onClose }: SimpleCreateProps) { + const [values, setValues] = useState>( + 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 ( +
e.target === e.currentTarget && onClose()}> +
+
+

{title}

+ +
+
+ {fields.map(f => ( +
+ + setValues(v => ({ ...v, [f.key]: f.type === 'number' ? Number(e.target.value) : e.target.value }))} + placeholder={f.placeholder} + className="form-input" + /> +
+ ))} + {error &&
{error}
} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/MarkdownEditor.tsx b/frontend/src/components/MarkdownEditor.tsx new file mode 100644 index 0000000..e6e7916 --- /dev/null +++ b/frontend/src/components/MarkdownEditor.tsx @@ -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('split'); + const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved'); + const saveTimer = useRef | 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 ( +
+
+
+ {(['edit', 'split', 'preview'] as EditorMode[]).map(m => ( + + ))} +
+
+ {onSave && ( + + {saveStatus === 'saving' ? '⟳ Saving…' : saveStatus === 'unsaved' ? '● Unsaved' : '✓ Saved'} + + )} + Markdown supported · GFM tables, checkboxes +
+
+ +
+ {mode !== 'preview' && ( +