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:
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 } });
|
||||
}
|
||||
Reference in New Issue
Block a user