Initial commit: enterprise digital platform with portal SSO, DigiServer, IT Assets, NetworkView, Server Monitor
This commit is contained in:
@@ -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"]
|
||||
Generated
+1728
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "networkview-backend",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon src/index.js",
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"morgan": "^1.10.0",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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 };
|
||||
@@ -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;
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user