Initial scaffold: full-stack RackMapper application
Complete project scaffold with working auth, REST API, Prisma/SQLite schema, Docker config, and React frontend for both Rack Planner and Service Mapper modules. Both server and client pass TypeScript strict mode with zero errors. Initial migration applied. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
63
server/index.ts
Normal file
63
server/index.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
|
||||
import { authRouter } from './routes/auth';
|
||||
import { racksRouter } from './routes/racks';
|
||||
import { modulesRouter } from './routes/modules';
|
||||
import { portsRouter } from './routes/ports';
|
||||
import { vlansRouter } from './routes/vlans';
|
||||
import { serviceMapRouter } from './routes/serviceMap';
|
||||
import { nodesRouter } from './routes/nodes';
|
||||
import { edgesRouter } from './routes/edges';
|
||||
import { authMiddleware } from './middleware/authMiddleware';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT ?? 3001;
|
||||
|
||||
// ---- Core middleware ----
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
|
||||
// CORS only needed in local dev (Vite :5173 → Node :3001)
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
app.use(
|
||||
cors({
|
||||
origin: 'http://localhost:5173',
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Auth routes (no JWT required) ----
|
||||
app.use('/api/auth', authRouter);
|
||||
|
||||
// ---- Protected API routes ----
|
||||
app.use('/api', authMiddleware);
|
||||
app.use('/api/racks', racksRouter);
|
||||
app.use('/api/modules', modulesRouter);
|
||||
app.use('/api/ports', portsRouter);
|
||||
app.use('/api/vlans', vlansRouter);
|
||||
app.use('/api/maps', serviceMapRouter);
|
||||
app.use('/api/nodes', nodesRouter);
|
||||
app.use('/api/edges', edgesRouter);
|
||||
|
||||
// ---- Serve Vite build in production ----
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const clientDist = path.join(process.cwd(), 'client', 'dist');
|
||||
app.use(express.static(clientDist));
|
||||
// SPA fallback — always serve index.html for non-API routes
|
||||
app.get(/^(?!\/api).*/, (_req, res) => {
|
||||
res.sendFile(path.join(clientDist, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Error handler (must be last) ----
|
||||
app.use(errorHandler);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`RackMapper running on port ${PORT} [${process.env.NODE_ENV ?? 'development'}]`);
|
||||
});
|
||||
44
server/lib/constants.ts
Normal file
44
server/lib/constants.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// SQLite doesn't support Prisma enums — use string literals throughout the server.
|
||||
// These types mirror client/src/types/index.ts
|
||||
|
||||
export type ModuleType =
|
||||
| 'SWITCH' | 'AGGREGATE_SWITCH' | 'MODEM' | 'ROUTER' | 'NAS'
|
||||
| 'PDU' | 'PATCH_PANEL' | 'SERVER' | 'FIREWALL' | 'AP' | 'BLANK' | 'OTHER';
|
||||
|
||||
export type PortType = 'ETHERNET' | 'SFP' | 'SFP_PLUS' | 'QSFP' | 'CONSOLE' | 'UPLINK';
|
||||
|
||||
export type VlanMode = 'ACCESS' | 'TRUNK' | 'HYBRID';
|
||||
|
||||
export type NodeType =
|
||||
| 'SERVICE' | 'DATABASE' | 'API' | 'DEVICE' | 'EXTERNAL'
|
||||
| 'USER' | 'VLAN' | 'FIREWALL' | 'LOAD_BALANCER' | 'NOTE';
|
||||
|
||||
export const MODULE_PORT_DEFAULTS: Record<ModuleType, number> = {
|
||||
SWITCH: 24,
|
||||
AGGREGATE_SWITCH: 8,
|
||||
ROUTER: 4,
|
||||
FIREWALL: 8,
|
||||
PATCH_PANEL: 24,
|
||||
AP: 1,
|
||||
MODEM: 2,
|
||||
SERVER: 2,
|
||||
NAS: 1,
|
||||
PDU: 12,
|
||||
BLANK: 0,
|
||||
OTHER: 0,
|
||||
};
|
||||
|
||||
export const MODULE_U_DEFAULTS: Record<ModuleType, number> = {
|
||||
SWITCH: 1,
|
||||
AGGREGATE_SWITCH: 2,
|
||||
ROUTER: 1,
|
||||
FIREWALL: 1,
|
||||
PATCH_PANEL: 1,
|
||||
AP: 1,
|
||||
MODEM: 1,
|
||||
SERVER: 2,
|
||||
NAS: 4,
|
||||
PDU: 1,
|
||||
BLANK: 1,
|
||||
OTHER: 1,
|
||||
};
|
||||
14
server/lib/prisma.ts
Normal file
14
server/lib/prisma.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Singleton pattern prevents multiple PrismaClient instances in dev (hot reload)
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
26
server/middleware/authMiddleware.ts
Normal file
26
server/middleware/authMiddleware.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { AppError, AuthenticatedRequest } from '../types/index';
|
||||
|
||||
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
const token = (req.cookies as Record<string, string | undefined>)?.token;
|
||||
|
||||
if (!token) {
|
||||
next(new AppError('Unauthorized', 401, 'NO_TOKEN'));
|
||||
return;
|
||||
}
|
||||
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
next(new AppError('Server misconfiguration: JWT_SECRET not set', 500, 'CONFIG_ERROR'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(token, secret) as { sub: string };
|
||||
(req as AuthenticatedRequest).user = { sub: payload.sub };
|
||||
next();
|
||||
} catch {
|
||||
next(new AppError('Invalid or expired session', 401, 'INVALID_TOKEN'));
|
||||
}
|
||||
}
|
||||
22
server/middleware/errorHandler.ts
Normal file
22
server/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { AppError, err } from '../types/index';
|
||||
|
||||
export function errorHandler(
|
||||
error: Error,
|
||||
_req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction
|
||||
): void {
|
||||
const statusCode = error instanceof AppError ? error.statusCode : 500;
|
||||
const code = error instanceof AppError ? error.code : 'INTERNAL_ERROR';
|
||||
const message =
|
||||
process.env.NODE_ENV === 'production' && statusCode === 500
|
||||
? 'Internal server error'
|
||||
: error.message;
|
||||
|
||||
if (statusCode === 500 && process.env.NODE_ENV !== 'production') {
|
||||
console.error('[ErrorHandler]', error);
|
||||
}
|
||||
|
||||
res.status(statusCode).json(err(message, code ? { code } : undefined));
|
||||
}
|
||||
60
server/routes/auth.ts
Normal file
60
server/routes/auth.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { AppError, ok } from '../types/index';
|
||||
import { authMiddleware } from '../middleware/authMiddleware';
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
const COOKIE_OPTS = {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict' as const,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
};
|
||||
|
||||
authRouter.post('/login', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { username, password } = req.body as { username?: string; password?: string };
|
||||
|
||||
if (!username || !password) {
|
||||
throw new AppError('Username and password are required', 400, 'MISSING_FIELDS');
|
||||
}
|
||||
|
||||
const adminUsername = process.env.ADMIN_USERNAME;
|
||||
const adminHash = process.env.ADMIN_PASSWORD_HASH;
|
||||
|
||||
if (!adminUsername || !adminHash) {
|
||||
throw new AppError('Server not configured: admin credentials missing', 500, 'CONFIG_ERROR');
|
||||
}
|
||||
|
||||
const usernameMatch = username === adminUsername;
|
||||
// Always run bcrypt to prevent timing attacks even if username is wrong
|
||||
const passwordMatch = await bcrypt.compare(password, adminHash);
|
||||
|
||||
if (!usernameMatch || !passwordMatch) {
|
||||
throw new AppError('Invalid username or password', 401, 'INVALID_CREDENTIALS');
|
||||
}
|
||||
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) throw new AppError('Server not configured: JWT_SECRET missing', 500, 'CONFIG_ERROR');
|
||||
|
||||
const token = jwt.sign({ sub: 'admin' }, secret, {
|
||||
expiresIn: (process.env.JWT_EXPIRY ?? '8h') as jwt.SignOptions['expiresIn'],
|
||||
});
|
||||
|
||||
res.cookie('token', token, COOKIE_OPTS);
|
||||
res.json(ok({ success: true }));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
authRouter.post('/logout', (_req: Request, res: Response) => {
|
||||
res.clearCookie('token', COOKIE_OPTS);
|
||||
res.json(ok({ success: true }));
|
||||
});
|
||||
|
||||
authRouter.get('/me', authMiddleware, (_req: Request, res: Response) => {
|
||||
res.json(ok({ authenticated: true }));
|
||||
});
|
||||
28
server/routes/edges.ts
Normal file
28
server/routes/edges.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import * as mapService from '../services/mapService';
|
||||
import { ok } from '../types/index';
|
||||
|
||||
export const edgesRouter = Router();
|
||||
|
||||
edgesRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { label, edgeType, animated, metadata } = req.body as {
|
||||
label?: string;
|
||||
edgeType?: string;
|
||||
animated?: boolean;
|
||||
metadata?: string;
|
||||
};
|
||||
res.json(ok(await mapService.updateEdge(req.params.id, { label, edgeType, animated, metadata })));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
edgesRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await mapService.deleteEdge(req.params.id);
|
||||
res.json(ok(null));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
51
server/routes/modules.ts
Normal file
51
server/routes/modules.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import * as moduleService from '../services/moduleService';
|
||||
import { ok } from '../types/index';
|
||||
|
||||
export const modulesRouter = Router();
|
||||
|
||||
modulesRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { name, uPosition, uSize, manufacturer, model, ipAddress, notes } = req.body as {
|
||||
name?: string;
|
||||
uPosition?: number;
|
||||
uSize?: number;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
ipAddress?: string;
|
||||
notes?: string;
|
||||
};
|
||||
res.json(
|
||||
ok(
|
||||
await moduleService.updateModule(req.params.id, {
|
||||
name,
|
||||
uPosition,
|
||||
uSize,
|
||||
manufacturer,
|
||||
model,
|
||||
ipAddress,
|
||||
notes,
|
||||
})
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
modulesRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await moduleService.deleteModule(req.params.id);
|
||||
res.json(ok(null));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
modulesRouter.get('/:id/ports', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.json(ok(await moduleService.getModulePorts(req.params.id)));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
33
server/routes/nodes.ts
Normal file
33
server/routes/nodes.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import * as mapService from '../services/mapService';
|
||||
import { ok } from '../types/index';
|
||||
|
||||
export const nodesRouter = Router();
|
||||
|
||||
nodesRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { label, positionX, positionY, metadata, color, icon, moduleId } = req.body as {
|
||||
label?: string;
|
||||
positionX?: number;
|
||||
positionY?: number;
|
||||
metadata?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
moduleId?: string | null;
|
||||
};
|
||||
res.json(
|
||||
ok(await mapService.updateNode(req.params.id, { label, positionX, positionY, metadata, color, icon, moduleId }))
|
||||
);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
nodesRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await mapService.deleteNode(req.params.id);
|
||||
res.json(ok(null));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
21
server/routes/ports.ts
Normal file
21
server/routes/ports.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import * as portService from '../services/portService';
|
||||
import { ok } from '../types/index';
|
||||
import type { VlanMode } from '../lib/constants';
|
||||
|
||||
export const portsRouter = Router();
|
||||
|
||||
portsRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { label, mode, nativeVlan, notes, vlans } = req.body as {
|
||||
label?: string;
|
||||
mode?: VlanMode;
|
||||
nativeVlan?: number | null;
|
||||
notes?: string;
|
||||
vlans?: Array<{ vlanId: string; tagged: boolean }>;
|
||||
};
|
||||
res.json(ok(await portService.updatePort(req.params.id, { label, mode, nativeVlan, notes, vlans })));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
83
server/routes/racks.ts
Normal file
83
server/routes/racks.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import * as rackService from '../services/rackService';
|
||||
import * as moduleService from '../services/moduleService';
|
||||
import { ok } from '../types/index';
|
||||
import type { ModuleType, PortType } from '../lib/constants';
|
||||
|
||||
export const racksRouter = Router();
|
||||
|
||||
racksRouter.get('/', async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.json(ok(await rackService.listRacks()));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
racksRouter.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { name, totalU, location, displayOrder } = req.body as {
|
||||
name: string;
|
||||
totalU?: number;
|
||||
location?: string;
|
||||
displayOrder?: number;
|
||||
};
|
||||
res.status(201).json(ok(await rackService.createRack({ name, totalU, location, displayOrder })));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
racksRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.json(ok(await rackService.getRack(req.params.id)));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
racksRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { name, totalU, location, displayOrder } = req.body as {
|
||||
name?: string;
|
||||
totalU?: number;
|
||||
location?: string;
|
||||
displayOrder?: number;
|
||||
};
|
||||
res.json(ok(await rackService.updateRack(req.params.id, { name, totalU, location, displayOrder })));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
racksRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await rackService.deleteRack(req.params.id);
|
||||
res.json(ok(null));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
racksRouter.post('/:id/modules', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { name, type, uPosition, uSize, manufacturer, model, ipAddress, notes, portCount, portType } =
|
||||
req.body as {
|
||||
name: string;
|
||||
type: ModuleType;
|
||||
uPosition: number;
|
||||
uSize?: number;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
ipAddress?: string;
|
||||
notes?: string;
|
||||
portCount?: number;
|
||||
portType?: PortType;
|
||||
};
|
||||
res.status(201).json(
|
||||
ok(await moduleService.createModule(req.params.id, { name, type, uPosition, uSize, manufacturer, model, ipAddress, notes, portCount, portType }))
|
||||
);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
97
server/routes/serviceMap.ts
Normal file
97
server/routes/serviceMap.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import * as mapService from '../services/mapService';
|
||||
import { ok } from '../types/index';
|
||||
import type { NodeType } from '../lib/constants';
|
||||
|
||||
export const serviceMapRouter = Router();
|
||||
|
||||
serviceMapRouter.get('/', async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.json(ok(await mapService.listMaps()));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
serviceMapRouter.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { name, description } = req.body as { name: string; description?: string };
|
||||
res.status(201).json(ok(await mapService.createMap({ name, description })));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
serviceMapRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.json(ok(await mapService.getMap(req.params.id)));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
serviceMapRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { name, description } = req.body as { name?: string; description?: string };
|
||||
res.json(ok(await mapService.updateMap(req.params.id, { name, description })));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
serviceMapRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await mapService.deleteMap(req.params.id);
|
||||
res.json(ok(null));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
serviceMapRouter.post('/:id/nodes', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { label, nodeType, positionX, positionY, metadata, color, icon, moduleId } = req.body as {
|
||||
label: string;
|
||||
nodeType: NodeType;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
metadata?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
moduleId?: string;
|
||||
};
|
||||
res.status(201).json(
|
||||
ok(await mapService.addNode(req.params.id, { label, nodeType, positionX, positionY, metadata, color, icon, moduleId }))
|
||||
);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
serviceMapRouter.post('/:id/populate', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.json(ok(await mapService.populateFromRack(req.params.id)));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
serviceMapRouter.post('/:id/edges', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { sourceId, targetId, label, edgeType, animated, metadata } = req.body as {
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
label?: string;
|
||||
edgeType?: string;
|
||||
animated?: boolean;
|
||||
metadata?: string;
|
||||
};
|
||||
res.status(201).json(
|
||||
ok(await mapService.addEdge(req.params.id, { sourceId, targetId, label, edgeType, animated, metadata }))
|
||||
);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
export { mapService };
|
||||
49
server/routes/vlans.ts
Normal file
49
server/routes/vlans.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import * as vlanService from '../services/vlanService';
|
||||
import { ok } from '../types/index';
|
||||
|
||||
export const vlansRouter = Router();
|
||||
|
||||
vlansRouter.get('/', async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.json(ok(await vlanService.listVlans()));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
vlansRouter.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { vlanId, name, description, color } = req.body as {
|
||||
vlanId: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
};
|
||||
res.status(201).json(ok(await vlanService.createVlan({ vlanId, name, description, color })));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
vlansRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { name, description, color } = req.body as {
|
||||
name?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
};
|
||||
res.json(ok(await vlanService.updateVlan(req.params.id, { name, description, color })));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
vlansRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await vlanService.deleteVlan(req.params.id);
|
||||
res.json(ok(null));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
170
server/services/mapService.ts
Normal file
170
server/services/mapService.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { AppError } from '../types/index';
|
||||
import type { NodeType } from '../lib/constants';
|
||||
|
||||
const mapInclude = {
|
||||
nodes: {
|
||||
include: { module: true },
|
||||
},
|
||||
edges: true,
|
||||
};
|
||||
|
||||
export async function listMaps() {
|
||||
return prisma.serviceMap.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true, name: true, description: true, createdAt: true, updatedAt: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMap(id: string) {
|
||||
const map = await prisma.serviceMap.findUnique({ where: { id }, include: mapInclude });
|
||||
if (!map) throw new AppError('Map not found', 404, 'NOT_FOUND');
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function createMap(data: { name: string; description?: string }) {
|
||||
return prisma.serviceMap.create({ data, include: mapInclude });
|
||||
}
|
||||
|
||||
export async function updateMap(id: string, data: Partial<{ name: string; description: string }>) {
|
||||
await getMap(id);
|
||||
return prisma.serviceMap.update({ where: { id }, data, include: mapInclude });
|
||||
}
|
||||
|
||||
export async function deleteMap(id: string) {
|
||||
await getMap(id);
|
||||
return prisma.serviceMap.delete({ where: { id } });
|
||||
}
|
||||
|
||||
// ---- Nodes ----
|
||||
|
||||
export async function addNode(
|
||||
mapId: string,
|
||||
data: {
|
||||
label: string;
|
||||
nodeType: NodeType;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
metadata?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
moduleId?: string;
|
||||
}
|
||||
) {
|
||||
await getMap(mapId);
|
||||
return prisma.serviceNode.create({
|
||||
data: { mapId, ...data },
|
||||
include: { module: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function populateFromRack(mapId: string) {
|
||||
await getMap(mapId);
|
||||
|
||||
const modules = await prisma.module.findMany({
|
||||
orderBy: [{ rack: { displayOrder: 'asc' } }, { uPosition: 'asc' }],
|
||||
include: { rack: true },
|
||||
});
|
||||
|
||||
const existing = await prisma.serviceNode.findMany({
|
||||
where: { mapId, moduleId: { not: null } },
|
||||
select: { moduleId: true },
|
||||
});
|
||||
const existingModuleIds = new Set(existing.map((n) => n.moduleId as string));
|
||||
|
||||
const newModules = modules.filter((m) => !existingModuleIds.has(m.id));
|
||||
if (newModules.length === 0) return getMap(mapId);
|
||||
|
||||
const byRack = new Map<string, typeof modules>();
|
||||
for (const mod of newModules) {
|
||||
if (!byRack.has(mod.rackId)) byRack.set(mod.rackId, []);
|
||||
byRack.get(mod.rackId)!.push(mod);
|
||||
}
|
||||
|
||||
const NODE_W = 200;
|
||||
const NODE_H = 80;
|
||||
const COL_GAP = 260;
|
||||
const ROW_GAP = 110;
|
||||
|
||||
const nodesToCreate: Array<{
|
||||
mapId: string;
|
||||
label: string;
|
||||
nodeType: NodeType;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
moduleId: string;
|
||||
}> = [];
|
||||
|
||||
let colIdx = 0;
|
||||
for (const rackModules of byRack.values()) {
|
||||
rackModules.forEach((mod, rowIdx) => {
|
||||
nodesToCreate.push({
|
||||
mapId,
|
||||
label: mod.name,
|
||||
nodeType: 'DEVICE' as NodeType,
|
||||
positionX: colIdx * (NODE_W + COL_GAP),
|
||||
positionY: rowIdx * (NODE_H + ROW_GAP),
|
||||
moduleId: mod.id,
|
||||
});
|
||||
});
|
||||
colIdx++;
|
||||
}
|
||||
|
||||
await prisma.serviceNode.createMany({ data: nodesToCreate });
|
||||
return getMap(mapId);
|
||||
}
|
||||
|
||||
export async function updateNode(
|
||||
id: string,
|
||||
data: Partial<{
|
||||
label: string;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
metadata: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
moduleId: string | null;
|
||||
}>
|
||||
) {
|
||||
const existing = await prisma.serviceNode.findUnique({ where: { id } });
|
||||
if (!existing) throw new AppError('Node not found', 404, 'NOT_FOUND');
|
||||
return prisma.serviceNode.update({ where: { id }, data, include: { module: true } });
|
||||
}
|
||||
|
||||
export async function deleteNode(id: string) {
|
||||
const existing = await prisma.serviceNode.findUnique({ where: { id } });
|
||||
if (!existing) throw new AppError('Node not found', 404, 'NOT_FOUND');
|
||||
return prisma.serviceNode.delete({ where: { id } });
|
||||
}
|
||||
|
||||
// ---- Edges ----
|
||||
|
||||
export async function addEdge(
|
||||
mapId: string,
|
||||
data: {
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
label?: string;
|
||||
edgeType?: string;
|
||||
animated?: boolean;
|
||||
metadata?: string;
|
||||
}
|
||||
) {
|
||||
await getMap(mapId);
|
||||
return prisma.serviceEdge.create({ data: { mapId, ...data } });
|
||||
}
|
||||
|
||||
export async function updateEdge(
|
||||
id: string,
|
||||
data: Partial<{ label: string; edgeType: string; animated: boolean; metadata: string }>
|
||||
) {
|
||||
const existing = await prisma.serviceEdge.findUnique({ where: { id } });
|
||||
if (!existing) throw new AppError('Edge not found', 404, 'NOT_FOUND');
|
||||
return prisma.serviceEdge.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
export async function deleteEdge(id: string) {
|
||||
const existing = await prisma.serviceEdge.findUnique({ where: { id } });
|
||||
if (!existing) throw new AppError('Edge not found', 404, 'NOT_FOUND');
|
||||
return prisma.serviceEdge.delete({ where: { id } });
|
||||
}
|
||||
147
server/services/moduleService.ts
Normal file
147
server/services/moduleService.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { AppError } from '../types/index';
|
||||
import { MODULE_PORT_DEFAULTS, MODULE_U_DEFAULTS, type ModuleType, type PortType } from '../lib/constants';
|
||||
|
||||
const moduleInclude = {
|
||||
ports: {
|
||||
orderBy: { portNumber: 'asc' as const },
|
||||
include: {
|
||||
vlans: { include: { vlan: true } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Check whether a U-range is occupied in a rack, optionally excluding one module (for moves). */
|
||||
async function hasCollision(
|
||||
rackId: string,
|
||||
uPosition: number,
|
||||
uSize: number,
|
||||
excludeModuleId?: string
|
||||
): Promise<boolean> {
|
||||
const modules = await prisma.module.findMany({
|
||||
where: { rackId, ...(excludeModuleId ? { id: { not: excludeModuleId } } : {}) },
|
||||
select: { uPosition: true, uSize: true },
|
||||
});
|
||||
|
||||
const occupied = new Set<number>();
|
||||
for (const m of modules) {
|
||||
for (let u = m.uPosition; u < m.uPosition + m.uSize; u++) occupied.add(u);
|
||||
}
|
||||
|
||||
for (let u = uPosition; u < uPosition + uSize; u++) {
|
||||
if (occupied.has(u)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function createModule(
|
||||
rackId: string,
|
||||
data: {
|
||||
name: string;
|
||||
type: ModuleType;
|
||||
uPosition: number;
|
||||
uSize?: number;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
ipAddress?: string;
|
||||
notes?: string;
|
||||
portCount?: number;
|
||||
portType?: PortType;
|
||||
}
|
||||
) {
|
||||
const rack = await prisma.rack.findUnique({ where: { id: rackId } });
|
||||
if (!rack) throw new AppError('Rack not found', 404, 'NOT_FOUND');
|
||||
|
||||
const uSize = data.uSize ?? MODULE_U_DEFAULTS[data.type] ?? 1;
|
||||
|
||||
if (data.uPosition < 1 || data.uPosition + uSize - 1 > rack.totalU) {
|
||||
throw new AppError(
|
||||
`Module does not fit within rack (U1–U${rack.totalU})`,
|
||||
400,
|
||||
'OUT_OF_BOUNDS'
|
||||
);
|
||||
}
|
||||
|
||||
if (await hasCollision(rackId, data.uPosition, uSize)) {
|
||||
throw new AppError('U-slot collision: another module occupies that space', 409, 'COLLISION');
|
||||
}
|
||||
|
||||
const portCount = data.portCount ?? MODULE_PORT_DEFAULTS[data.type] ?? 0;
|
||||
const portType: PortType = data.portType ?? 'ETHERNET';
|
||||
|
||||
return prisma.module.create({
|
||||
data: {
|
||||
rackId,
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
uPosition: data.uPosition,
|
||||
uSize,
|
||||
manufacturer: data.manufacturer,
|
||||
model: data.model,
|
||||
ipAddress: data.ipAddress,
|
||||
notes: data.notes,
|
||||
ports: {
|
||||
create: Array.from({ length: portCount }, (_, i) => ({
|
||||
portNumber: i + 1,
|
||||
portType,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: moduleInclude,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateModule(
|
||||
id: string,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
uPosition: number;
|
||||
uSize: number;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
ipAddress: string;
|
||||
notes: string;
|
||||
}>
|
||||
) {
|
||||
const existing = await prisma.module.findUnique({ where: { id } });
|
||||
if (!existing) throw new AppError('Module not found', 404, 'NOT_FOUND');
|
||||
|
||||
const newPosition = data.uPosition ?? existing.uPosition;
|
||||
const newSize = data.uSize ?? existing.uSize;
|
||||
|
||||
if (data.uPosition !== undefined || data.uSize !== undefined) {
|
||||
const rack = await prisma.rack.findUnique({ where: { id: existing.rackId } });
|
||||
if (!rack) throw new AppError('Rack not found', 404, 'NOT_FOUND');
|
||||
|
||||
if (newPosition < 1 || newPosition + newSize - 1 > rack.totalU) {
|
||||
throw new AppError(
|
||||
`Module does not fit within rack (U1–U${rack.totalU})`,
|
||||
400,
|
||||
'OUT_OF_BOUNDS'
|
||||
);
|
||||
}
|
||||
|
||||
if (await hasCollision(existing.rackId, newPosition, newSize, id)) {
|
||||
throw new AppError('U-slot collision', 409, 'COLLISION');
|
||||
}
|
||||
}
|
||||
|
||||
return prisma.module.update({ where: { id }, data, include: moduleInclude });
|
||||
}
|
||||
|
||||
export async function deleteModule(id: string) {
|
||||
const existing = await prisma.module.findUnique({ where: { id } });
|
||||
if (!existing) throw new AppError('Module not found', 404, 'NOT_FOUND');
|
||||
return prisma.module.delete({ where: { id } });
|
||||
}
|
||||
|
||||
export async function getModulePorts(id: string) {
|
||||
const existing = await prisma.module.findUnique({ where: { id } });
|
||||
if (!existing) throw new AppError('Module not found', 404, 'NOT_FOUND');
|
||||
|
||||
return prisma.port.findMany({
|
||||
where: { moduleId: id },
|
||||
orderBy: { portNumber: 'asc' },
|
||||
include: { vlans: { include: { vlan: true } } },
|
||||
});
|
||||
}
|
||||
47
server/services/portService.ts
Normal file
47
server/services/portService.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { AppError } from '../types/index';
|
||||
import type { VlanMode } from '../lib/constants';
|
||||
|
||||
const portInclude = {
|
||||
vlans: { include: { vlan: true } },
|
||||
};
|
||||
|
||||
export async function updatePort(
|
||||
id: string,
|
||||
data: {
|
||||
label?: string;
|
||||
mode?: VlanMode;
|
||||
nativeVlan?: number | null;
|
||||
notes?: string;
|
||||
vlans?: Array<{ vlanId: string; tagged: boolean }>;
|
||||
}
|
||||
) {
|
||||
const existing = await prisma.port.findUnique({ where: { id } });
|
||||
if (!existing) throw new AppError('Port not found', 404, 'NOT_FOUND');
|
||||
|
||||
const { vlans: vlanAssignments, ...portData } = data;
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await tx.port.update({ where: { id }, data: portData });
|
||||
|
||||
if (vlanAssignments !== undefined) {
|
||||
if (vlanAssignments.length > 0) {
|
||||
const vlanIds = vlanAssignments.map((v) => v.vlanId);
|
||||
const found = await tx.vlan.findMany({ where: { id: { in: vlanIds } } });
|
||||
if (found.length !== vlanIds.length) {
|
||||
throw new AppError('One or more VLANs not found', 404, 'VLAN_NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
||||
await tx.portVlan.deleteMany({ where: { portId: id } });
|
||||
|
||||
if (vlanAssignments.length > 0) {
|
||||
await tx.portVlan.createMany({
|
||||
data: vlanAssignments.map(({ vlanId, tagged }) => ({ portId: id, vlanId, tagged })),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tx.port.findUnique({ where: { id }, include: portInclude });
|
||||
});
|
||||
}
|
||||
59
server/services/rackService.ts
Normal file
59
server/services/rackService.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { AppError } from '../types/index';
|
||||
|
||||
// Full include shape used across all rack queries
|
||||
const rackInclude = {
|
||||
modules: {
|
||||
orderBy: { uPosition: 'asc' as const },
|
||||
include: {
|
||||
ports: {
|
||||
orderBy: { portNumber: 'asc' as const },
|
||||
include: {
|
||||
vlans: {
|
||||
include: { vlan: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export async function listRacks() {
|
||||
return prisma.rack.findMany({
|
||||
orderBy: { displayOrder: 'asc' },
|
||||
include: rackInclude,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRack(id: string) {
|
||||
const rack = await prisma.rack.findUnique({ where: { id }, include: rackInclude });
|
||||
if (!rack) throw new AppError('Rack not found', 404, 'NOT_FOUND');
|
||||
return rack;
|
||||
}
|
||||
|
||||
export async function createRack(data: {
|
||||
name: string;
|
||||
totalU?: number;
|
||||
location?: string;
|
||||
displayOrder?: number;
|
||||
}) {
|
||||
// Auto-assign displayOrder to end of list if not provided
|
||||
if (data.displayOrder === undefined) {
|
||||
const last = await prisma.rack.findFirst({ orderBy: { displayOrder: 'desc' } });
|
||||
data.displayOrder = last ? last.displayOrder + 1 : 0;
|
||||
}
|
||||
return prisma.rack.create({ data, include: rackInclude });
|
||||
}
|
||||
|
||||
export async function updateRack(
|
||||
id: string,
|
||||
data: Partial<{ name: string; totalU: number; location: string; displayOrder: number }>
|
||||
) {
|
||||
await getRack(id); // throws 404 if missing
|
||||
return prisma.rack.update({ where: { id }, data, include: rackInclude });
|
||||
}
|
||||
|
||||
export async function deleteRack(id: string) {
|
||||
await getRack(id);
|
||||
return prisma.rack.delete({ where: { id } });
|
||||
}
|
||||
37
server/services/vlanService.ts
Normal file
37
server/services/vlanService.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { AppError } from '../types/index';
|
||||
|
||||
export async function listVlans() {
|
||||
return prisma.vlan.findMany({ orderBy: { vlanId: 'asc' } });
|
||||
}
|
||||
|
||||
export async function createVlan(data: {
|
||||
vlanId: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
}) {
|
||||
const existing = await prisma.vlan.findUnique({ where: { vlanId: data.vlanId } });
|
||||
if (existing) throw new AppError(`VLAN ID ${data.vlanId} already exists`, 409, 'DUPLICATE');
|
||||
|
||||
if (data.vlanId < 1 || data.vlanId > 4094) {
|
||||
throw new AppError('VLAN ID must be between 1 and 4094', 400, 'INVALID_VLAN_ID');
|
||||
}
|
||||
|
||||
return prisma.vlan.create({ data });
|
||||
}
|
||||
|
||||
export async function updateVlan(
|
||||
id: string,
|
||||
data: Partial<{ name: string; description: string; color: string }>
|
||||
) {
|
||||
const existing = await prisma.vlan.findUnique({ where: { id } });
|
||||
if (!existing) throw new AppError('VLAN not found', 404, 'NOT_FOUND');
|
||||
return prisma.vlan.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
export async function deleteVlan(id: string) {
|
||||
const existing = await prisma.vlan.findUnique({ where: { id } });
|
||||
if (!existing) throw new AppError('VLAN not found', 404, 'NOT_FOUND');
|
||||
return prisma.vlan.delete({ where: { id } });
|
||||
}
|
||||
37
server/types/index.ts
Normal file
37
server/types/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Request } from 'express';
|
||||
|
||||
// ---- Error handling ----
|
||||
|
||||
export class AppError extends Error {
|
||||
statusCode: number;
|
||||
code?: string;
|
||||
|
||||
constructor(message: string, statusCode: number, code?: string) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
this.statusCode = statusCode;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- API response shape ----
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
data: T | null;
|
||||
error: string | null;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function ok<T>(data: T, meta?: Record<string, unknown>): ApiResponse<T> {
|
||||
return { data, error: null, ...(meta ? { meta } : {}) };
|
||||
}
|
||||
|
||||
export function err(message: string, meta?: Record<string, unknown>): ApiResponse<null> {
|
||||
return { data: null, error: message, ...(meta ? { meta } : {}) };
|
||||
}
|
||||
|
||||
// ---- Augmented request ----
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user: { sub: string };
|
||||
}
|
||||
Reference in New Issue
Block a user