feat(rack-planner): add support for WAN and SFP ports with right-justified layout and distinct styling

This commit is contained in:
2026-03-22 15:16:54 -05:00
parent b26f88a89e
commit 1f360cdb2a
6 changed files with 162 additions and 59 deletions

View File

@@ -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 && (