From becb55d57c9f68629796b332214d3f352a8b54ea Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 22 Mar 2026 14:55:33 -0500 Subject: [PATCH] feat(rack-planner): implement port-to-port connections (patch cables) with dynamic SVG visualization layer --- client/src/api/client.ts | 11 +- .../src/components/modals/PortConfigModal.tsx | 113 ++++++++++--- .../src/components/rack/ConnectionLayer.tsx | 158 ++++++++++++++++++ client/src/components/rack/ModuleBlock.tsx | 51 +++++- client/src/components/rack/RackPlanner.tsx | 4 +- client/src/store/useRackStore.ts | 22 +++ client/src/types/index.ts | 12 ++ .../migration.sql | 14 ++ prisma/schema.prisma | 17 ++ server/index.ts | 2 + server/routes/connections.ts | 37 ++++ server/services/connectionService.ts | 34 ++++ server/services/rackService.ts | 2 + 13 files changed, 449 insertions(+), 28 deletions(-) create mode 100644 client/src/components/rack/ConnectionLayer.tsx create mode 100644 prisma/migrations/20260322195150_add_port_connections/migration.sql create mode 100644 server/routes/connections.ts create mode 100644 server/services/connectionService.ts diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 7a2673d..09bbdc4 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -195,4 +195,13 @@ const edges = { delete: (id: string) => del(`/edges/${id}`), }; -export const apiClient = { auth, racks, modules, ports, vlans, maps, nodes, edges }; +// ---- Connections ---- + +const connections = { + create: (data: { fromPortId: string; toPortId: string; color?: string; label?: string }) => + post<{ id: string }>('/connections', data), + delete: (id: string) => del(`/connections/${id}`), + deleteByPorts: (p1: string, p2: string) => del(`/connections/ports/${p1}/${p2}`), +}; + +export const apiClient = { auth, racks, modules, ports, vlans, maps, nodes, edges, connections }; diff --git a/client/src/components/modals/PortConfigModal.tsx b/client/src/components/modals/PortConfigModal.tsx index 3626059..f86b730 100644 --- a/client/src/components/modals/PortConfigModal.tsx +++ b/client/src/components/modals/PortConfigModal.tsx @@ -27,6 +27,7 @@ export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps) // Quick-create VLAN const [newVlanId, setNewVlanId] = useState(''); const [newVlanName, setNewVlanName] = useState(''); + const [newVlanColor, setNewVlanColor] = useState('#3b82f6'); const [creatingVlan, setCreatingVlan] = useState(false); // Find the port from store @@ -99,10 +100,15 @@ export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps) if (!id || !newVlanName.trim()) return; setCreatingVlan(true); try { - const created = await apiClient.vlans.create({ vlanId: id, name: newVlanName.trim() }); + const created = await apiClient.vlans.create({ + vlanId: id, + name: newVlanName.trim(), + color: newVlanColor, + }); setVlans((v) => [...v, created].sort((a, b) => a.vlanId - b.vlanId)); setNewVlanId(''); setNewVlanName(''); + setNewVlanColor('#3b82f6'); toast.success(`VLAN ${id} created`); } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to create VLAN'); @@ -119,6 +125,22 @@ export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps) if (!port) return null; + const { deleteConnection } = useRackStore(); + const connections = [...(port.sourceConnections || []), ...(port.targetConnections || [])]; + + async function handleDisconnect(connId: string) { + if (!confirm('Remove this patch cable?')) return; + try { + setLoading(true); + await deleteConnection(connId); + toast.success('Disconnected'); + } catch (err) { + toast.error('Failed to disconnect'); + } finally { + setLoading(false); + } + } + return (
@@ -140,6 +162,35 @@ export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps) /> + {/* Existing Connections */} + {connections.length > 0 && ( +
+ +
+ {connections.map((c) => ( +
+
+
+ + Cable {c.label || `#${c.id.slice(-4)}`} + +
+ +
+ ))} +
+
+ )} + {/* Mode */}
@@ -164,19 +215,29 @@ export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps) {/* Native VLAN */}
- +
+ + {nativeVlanId && ( +
v.vlanId === Number(nativeVlanId))?.color ?? '#3b82f6', + }} + /> + )} +
{/* Tagged VLANs — Trunk/Hybrid only */} @@ -192,12 +253,17 @@ export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps) key={v.id} type="button" onClick={() => toggleTaggedVlan(v.id)} - className={`px-2 py-0.5 rounded text-xs border transition-colors ${ - taggedVlanIds.includes(v.id) - ? 'bg-blue-700 border-blue-500 text-white' - : 'bg-slate-800 border-slate-600 text-slate-400 hover:border-slate-400' - }`} + style={{ + backgroundColor: taggedVlanIds.includes(v.id) ? v.color ?? '#3b82f6' : 'transparent', + borderColor: taggedVlanIds.includes(v.id) ? 'transparent' : v.color ?? '#475569', + color: taggedVlanIds.includes(v.id) ? '#fff' : v.color ?? '#94a3b8', + }} + className={`px-2 py-0.5 rounded text-[11px] border font-medium transition-all hover:brightness-110 flex items-center gap-1`} > +
{v.vlanId} {v.name} ))} @@ -222,7 +288,14 @@ export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps) value={newVlanName} onChange={(e) => setNewVlanName(e.target.value)} placeholder="Name" - className="flex-1 bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-xs text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-1 focus:ring-blue-500" + className="flex-1 min-w-0 bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-xs text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + setNewVlanColor(e.target.value)} + className="w-8 h-8 rounded shrink-0 bg-transparent border border-slate-600 p-0.5 cursor-pointer" + title="VLAN Color" />