feat(rack-planner): add support for WAN and SFP ports with right-justified layout and distinct styling
This commit is contained in:
@@ -81,6 +81,8 @@ const racks = {
|
||||
notes?: string;
|
||||
portCount?: number;
|
||||
portType?: PortType;
|
||||
sfpCount?: number;
|
||||
wanCount?: number;
|
||||
}
|
||||
) => post<Module>(`/racks/${rackId}/modules`, data),
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-slate-300">Size (U)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
type="number" min={1} max={20}
|
||||
value={uSize}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-slate-300">Port count</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={128}
|
||||
value={portCount}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-slate-300">IP Address</label>
|
||||
<input
|
||||
@@ -202,6 +196,41 @@ export function AddModuleModal({ open, onClose, rackId, uPosition, initialType }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-slate-300">Ethernet</label>
|
||||
<input
|
||||
type="number" min={0} max={128}
|
||||
value={portCount}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-slate-300">SFP</label>
|
||||
<input
|
||||
type="number" min={0} max={128}
|
||||
value={sfpCount}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-slate-300">WAN</label>
|
||||
<input
|
||||
type="number" min={0} max={128}
|
||||
value={wanCount}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-slate-300">Manufacturer</label>
|
||||
|
||||
@@ -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 ? (
|
||||
<div className="flex flex-col gap-[3px] px-2 pt-[5px]">
|
||||
{visibleRows.map((row, rowIdx) => (
|
||||
<div key={rowIdx} className="flex gap-[3px]">
|
||||
{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;
|
||||
<div key={rowIdx} className="flex justify-between items-center w-full">
|
||||
{/* Standard ports */}
|
||||
<div className="flex gap-[3px]">
|
||||
{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 (
|
||||
<button
|
||||
key={port.id}
|
||||
data-port-id={port.id}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => handlePortClick(e, port.id)}
|
||||
aria-label={`Port ${port.portNumber}`}
|
||||
title={`Port ${port.portNumber}${port.label ? ` · ${port.label}` : ''}${
|
||||
hasVlan ? ` (VLAN ${port.vlans.map((v) => v.vlan?.vlanId).filter(Boolean).join(',')})` : ''
|
||||
}\nShift+Click for settings`}
|
||||
style={{ backgroundColor: vlanColor, borderColor: 'rgba(0,0,0,0.2)' }}
|
||||
className={cn(
|
||||
'w-2.5 h-2.5 rounded-sm border transition-all shrink-0 hover:scale-110 hover:brightness-125',
|
||||
isCablingSource &&
|
||||
'ring-2 ring-blue-400 ring-offset-1 ring-offset-slate-900 animate-pulse'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<button
|
||||
key={port.id}
|
||||
data-port-id={port.id}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => handlePortClick(e, port.id)}
|
||||
aria-label={`Port ${port.portNumber}`}
|
||||
title={`Port ${port.portNumber}${port.label ? ` · ${port.label}` : ''}${
|
||||
hasVlan ? ` (VLAN ${port.vlans.map((v) => v.vlan?.vlanId).filter(Boolean).join(',')})` : ''
|
||||
}\nShift+Click for settings`}
|
||||
style={{ backgroundColor: vlanColor, borderColor: 'rgba(0,0,0,0.2)' }}
|
||||
className={cn(
|
||||
'w-2.5 h-2.5 rounded-sm border transition-all shrink-0 hover:scale-110 hover:brightness-125',
|
||||
isCablingSource && 'ring-2 ring-blue-400 ring-offset-1 ring-offset-slate-900 animate-pulse'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* SFP/WAN group (on first row) */}
|
||||
{rowIdx === 0 && sidePorts.length > 0 && (
|
||||
<div className="flex gap-[3px] ml-auto">
|
||||
{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 (
|
||||
<button
|
||||
key={port.id}
|
||||
data-port-id={port.id}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => handlePortClick(e, port.id)}
|
||||
aria-label={`${port.portType} Port ${port.portNumber}`}
|
||||
title={`${port.portType} Port ${port.portNumber}${port.label ? ` · ${port.label}` : ''}${
|
||||
hasVlan ? ` (VLAN ${port.vlans.map((v) => v.vlan?.vlanId).filter(Boolean).join(',')})` : ''
|
||||
}\nShift+Click for settings`}
|
||||
style={{ backgroundColor: vlanColor, borderColor: isSfp ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)' }}
|
||||
className={cn(
|
||||
'w-2.5 h-2.5 border transition-all shrink-0 hover:scale-110 hover:brightness-125',
|
||||
isSfp ? 'rounded-none rotate-45 scale-[0.85]' : 'rounded-full',
|
||||
isCablingSource && 'ring-2 ring-blue-400 ring-offset-1 ring-offset-slate-900 animate-pulse',
|
||||
isWan && 'ring-1 ring-blue-400/50'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{hiddenPortCount > 0 && (
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user