diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 09bbdc4..eff3629 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -81,6 +81,8 @@ const racks = { notes?: string; portCount?: number; portType?: PortType; + sfpCount?: number; + wanCount?: number; } ) => post(`/racks/${rackId}/modules`, data), }; diff --git a/client/src/components/modals/AddModuleModal.tsx b/client/src/components/modals/AddModuleModal.tsx index 6a1c655..4e1cdd4 100644 --- a/client/src/components/modals/AddModuleModal.tsx +++ b/client/src/components/modals/AddModuleModal.tsx @@ -35,6 +35,8 @@ export function AddModuleModal({ open, onClose, rackId, uPosition, initialType } const [ipAddress, setIpAddress] = useState(''); const [manufacturer, setManufacturer] = useState(''); const [model, setModel] = useState(''); + const [sfpCount, setSfpCount] = useState(0); + const [wanCount, setWanCount] = useState(0); const [loading, setLoading] = useState(false); // Sync state when modal opens with a new initialType (e.g. drag-drop reuse) @@ -54,6 +56,8 @@ export function AddModuleModal({ open, onClose, rackId, uPosition, initialType } setName(MODULE_TYPE_LABELS[type]); setUSize(MODULE_U_DEFAULTS[type]); setPortCount(MODULE_PORT_DEFAULTS[type]); + setSfpCount(0); + setWanCount(0); } function reset() { @@ -64,6 +68,8 @@ export function AddModuleModal({ open, onClose, rackId, uPosition, initialType } setIpAddress(''); setManufacturer(''); setModel(''); + setSfpCount(0); + setWanCount(0); } async function handleSubmit(e: FormEvent) { @@ -77,6 +83,8 @@ export function AddModuleModal({ open, onClose, rackId, uPosition, initialType } uPosition, uSize, portCount, + sfpCount, + wanCount, ipAddress: ipAddress.trim() || undefined, manufacturer: manufacturer.trim() || undefined, model: model.trim() || undefined, @@ -165,31 +173,17 @@ export function AddModuleModal({ open, onClose, rackId, uPosition, initialType } /> -
+
setUSize(Number(e.target.value))} disabled={loading} className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50" />
-
- - setPortCount(Number(e.target.value))} - disabled={loading} - className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50" - /> -
+
+
+ + setPortCount(Number(e.target.value))} + disabled={loading} + className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50" + /> +
+
+ + setSfpCount(Number(e.target.value))} + disabled={loading} + className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50" + /> +
+
+ + setWanCount(Number(e.target.value))} + disabled={loading} + className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50" + /> +
+
+ + +
diff --git a/client/src/components/rack/ModuleBlock.tsx b/client/src/components/rack/ModuleBlock.tsx index cad419d..7dbfd69 100644 --- a/client/src/components/rack/ModuleBlock.tsx +++ b/client/src/components/rack/ModuleBlock.tsx @@ -43,17 +43,25 @@ export function ModuleBlock({ module }: ModuleBlockProps) { const height = displayUSize * U_HEIGHT_PX; const hasPorts = module.ports.length > 0; - // Split ports into rows of PORTS_PER_ROW + // Categorize ports for layout: separate WAN/SFP to the right + const mainPorts = module.ports.filter(p => !['SFP', 'SFP_PLUS', 'QSFP', 'WAN'].includes(p.portType)); + const sidePorts = module.ports.filter(p => ['SFP', 'SFP_PLUS', 'QSFP', 'WAN'].includes(p.portType)); + + // Split Main ports into rows const portRows: (typeof module.ports)[] = []; - for (let i = 0; i < module.ports.length; i += PORTS_PER_ROW) { - portRows.push(module.ports.slice(i, i + PORTS_PER_ROW)); + for (let i = 0; i < mainPorts.length; i += PORTS_PER_ROW) { + portRows.push(mainPorts.slice(i, i + PORTS_PER_ROW)); } - // Only show as many rows as fit within the current height - // Each row needs ~14px (10px dot + 4px gap/padding) - const availableForPorts = height - 16; // subtract top padding + resize handle + + // Calculate vertical space + const availableForPorts = height - 16; const maxRows = Math.max(1, Math.floor(availableForPorts / 14)); const visibleRows = portRows.slice(0, maxRows); - const hiddenPortCount = module.ports.length - visibleRows.flat().length; + const hiddenPortCount = mainPorts.length - visibleRows.flat().length; + + // SFP/WAN ports often sit on the far right of the module + // We'll show them on the first row if possible + // Compute the maximum allowed uSize for this module (rack bounds + collision) const maxResizeU = useCallback((): number => { @@ -163,35 +171,76 @@ export function ModuleBlock({ module }: ModuleBlockProps) { {hasPorts && previewUSize === null ? (
{visibleRows.map((row, rowIdx) => ( -
- {row.map((port) => { - const hasVlan = port.vlans.length > 0; - const vlanColor = hasVlan - ? port.mode === 'ACCESS' - ? port.vlans[0]?.vlan?.color || '#10b981' - : '#8b5cf6' - : '#475569'; - const isCablingSource = cablingFromPortId === port.id; +
+ {/* Standard ports */} +
+ {row.map((port) => { + const hasVlan = port.vlans.length > 0; + const vlanColor = hasVlan + ? port.mode === 'ACCESS' + ? port.vlans[0]?.vlan?.color || '#10b981' + : '#8b5cf6' + : '#475569'; + const isCablingSource = cablingFromPortId === port.id; - return ( -
+ + {/* SFP/WAN group (on first row) */} + {rowIdx === 0 && sidePorts.length > 0 && ( +
+ {sidePorts.map((port) => { + const hasVlan = port.vlans.length > 0; + const isSfp = ['SFP', 'SFP_PLUS', 'QSFP'].includes(port.portType); + const isWan = port.portType === 'WAN'; + + const vlanColor = hasVlan + ? port.mode === 'ACCESS' + ? port.vlans[0]?.vlan?.color || '#10b981' + : '#8b5cf6' + : isWan ? '#3b82f6' : '#64748b'; + + const isCablingSource = cablingFromPortId === port.id; + + return ( +
+ )}
))} {hiddenPortCount > 0 && ( diff --git a/client/src/types/index.ts b/client/src/types/index.ts index ee6a01d..6fd19ae 100644 --- a/client/src/types/index.ts +++ b/client/src/types/index.ts @@ -14,7 +14,7 @@ export type ModuleType = | 'BLANK' | 'OTHER'; -export type PortType = 'ETHERNET' | 'SFP' | 'SFP_PLUS' | 'QSFP' | 'CONSOLE' | 'UPLINK'; +export type PortType = 'ETHERNET' | 'SFP' | 'SFP_PLUS' | 'QSFP' | 'CONSOLE' | 'UPLINK' | 'WAN'; export type VlanMode = 'ACCESS' | 'TRUNK' | 'HYBRID'; diff --git a/server/lib/constants.ts b/server/lib/constants.ts index 7932e1c..c747600 100644 --- a/server/lib/constants.ts +++ b/server/lib/constants.ts @@ -5,7 +5,7 @@ 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 PortType = 'ETHERNET' | 'SFP' | 'SFP_PLUS' | 'QSFP' | 'CONSOLE' | 'UPLINK' | 'WAN'; export type VlanMode = 'ACCESS' | 'TRUNK' | 'HYBRID'; diff --git a/server/services/moduleService.ts b/server/services/moduleService.ts index 7cdd545..c94cec6 100644 --- a/server/services/moduleService.ts +++ b/server/services/moduleService.ts @@ -47,6 +47,8 @@ export async function createModule( notes?: string; portCount?: number; portType?: PortType; + sfpCount?: number; + wanCount?: number; } ) { const rack = await prisma.rack.findUnique({ where: { id: rackId } }); @@ -66,8 +68,32 @@ export async function createModule( 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'; + const sfpCount = data.sfpCount ?? (data.type === 'AGGREGATE_SWITCH' ? data.portCount ?? MODULE_PORT_DEFAULTS[data.type] : 0); + const wanCount = data.wanCount ?? 0; + // If aggregate switch is chosen, it usually uses its portCount as SFP ports, but it can be overridden. + // Standard ethernet port count is either the provided portCount or the default, adjusted if it's an aggregate switch (where default are SFP) + const ethernetCount = data.type === 'AGGREGATE_SWITCH' + ? (data.portCount ? data.portCount : 0) // if user manually set portCount for Aggr, we treat it as ethernet (unlikely but possible) + : (data.portCount ?? MODULE_PORT_DEFAULTS[data.type] ?? 0); + + const portsToCreate = []; + let currentNum = 1; + + // 1. WAN/Uplink ports (often on the left or special) + for (let i = 0; i < wanCount; i++) { + portsToCreate.push({ portNumber: currentNum++, portType: 'WAN' as PortType }); + } + + // 2. Standard Ethernet ports + for (let i = 0; i < ethernetCount; i++) { + if (data.type === 'AGGREGATE_SWITCH' && !data.portCount) break; // skip if it's aggr and we handle them as SFPs below + portsToCreate.push({ portNumber: currentNum++, portType: 'ETHERNET' as PortType }); + } + + // 3. SFP ports + for (let i = 0; i < sfpCount; i++) { + portsToCreate.push({ portNumber: currentNum++, portType: 'SFP' as PortType }); + } return prisma.module.create({ data: { @@ -81,10 +107,7 @@ export async function createModule( ipAddress: data.ipAddress, notes: data.notes, ports: { - create: Array.from({ length: portCount }, (_, i) => ({ - portNumber: i + 1, - portType, - })), + create: portsToCreate, }, }, include: moduleInclude,