feat(rack-planner): implement port-to-port connections (patch cables) with dynamic SVG visualization layer
This commit is contained in:
@@ -195,4 +195,13 @@ const edges = {
|
|||||||
delete: (id: string) => del<null>(`/edges/${id}`),
|
delete: (id: string) => del<null>(`/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<null>(`/connections/${id}`),
|
||||||
|
deleteByPorts: (p1: string, p2: string) => del<null>(`/connections/ports/${p1}/${p2}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiClient = { auth, racks, modules, ports, vlans, maps, nodes, edges, connections };
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps)
|
|||||||
// Quick-create VLAN
|
// Quick-create VLAN
|
||||||
const [newVlanId, setNewVlanId] = useState('');
|
const [newVlanId, setNewVlanId] = useState('');
|
||||||
const [newVlanName, setNewVlanName] = useState('');
|
const [newVlanName, setNewVlanName] = useState('');
|
||||||
|
const [newVlanColor, setNewVlanColor] = useState('#3b82f6');
|
||||||
const [creatingVlan, setCreatingVlan] = useState(false);
|
const [creatingVlan, setCreatingVlan] = useState(false);
|
||||||
|
|
||||||
// Find the port from store
|
// Find the port from store
|
||||||
@@ -99,10 +100,15 @@ export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps)
|
|||||||
if (!id || !newVlanName.trim()) return;
|
if (!id || !newVlanName.trim()) return;
|
||||||
setCreatingVlan(true);
|
setCreatingVlan(true);
|
||||||
try {
|
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));
|
setVlans((v) => [...v, created].sort((a, b) => a.vlanId - b.vlanId));
|
||||||
setNewVlanId('');
|
setNewVlanId('');
|
||||||
setNewVlanName('');
|
setNewVlanName('');
|
||||||
|
setNewVlanColor('#3b82f6');
|
||||||
toast.success(`VLAN ${id} created`);
|
toast.success(`VLAN ${id} created`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Failed to create VLAN');
|
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;
|
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 (
|
return (
|
||||||
<Modal open={open} onClose={onClose} title={`Port ${port.portNumber} Configuration`} size="md">
|
<Modal open={open} onClose={onClose} title={`Port ${port.portNumber} Configuration`} size="md">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
@@ -140,6 +162,35 @@ export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Existing Connections */}
|
||||||
|
{connections.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm text-slate-300">Patch Cables</label>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{connections.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className="p-2 bg-slate-800 border border-slate-700 rounded-lg flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: c.color || '#3b82f6' }} />
|
||||||
|
<span className="text-xs text-slate-200">
|
||||||
|
Cable {c.label || `#${c.id.slice(-4)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDisconnect(c.id)}
|
||||||
|
className="text-[10px] uppercase font-bold text-red-400 hover:text-red-300 px-2 py-1 rounded hover:bg-red-950 transition-colors"
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Mode */}
|
{/* Mode */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="block text-sm text-slate-300">Mode</label>
|
<label className="block text-sm text-slate-300">Mode</label>
|
||||||
@@ -164,19 +215,29 @@ export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps)
|
|||||||
{/* Native VLAN */}
|
{/* Native VLAN */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="block text-sm text-slate-300">Native VLAN</label>
|
<label className="block text-sm text-slate-300">Native VLAN</label>
|
||||||
<select
|
<div className="flex items-center gap-2">
|
||||||
value={nativeVlanId}
|
<select
|
||||||
onChange={(e) => setNativeVlanId(e.target.value)}
|
value={nativeVlanId}
|
||||||
disabled={loading || fetching}
|
onChange={(e) => setNativeVlanId(e.target.value)}
|
||||||
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"
|
disabled={loading || fetching}
|
||||||
>
|
className="flex-1 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"
|
||||||
<option value="">— Untagged —</option>
|
>
|
||||||
{vlans.map((v) => (
|
<option value="">— Untagged —</option>
|
||||||
<option key={v.id} value={v.vlanId.toString()}>
|
{vlans.map((v) => (
|
||||||
VLAN {v.vlanId} — {v.name}
|
<option key={v.id} value={v.vlanId.toString()}>
|
||||||
</option>
|
VLAN {v.vlanId} — {v.name}
|
||||||
))}
|
</option>
|
||||||
</select>
|
))}
|
||||||
|
</select>
|
||||||
|
{nativeVlanId && (
|
||||||
|
<div
|
||||||
|
className="w-5 h-5 rounded-full border border-slate-600 shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: vlans.find((v) => v.vlanId === Number(nativeVlanId))?.color ?? '#3b82f6',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tagged VLANs — Trunk/Hybrid only */}
|
{/* Tagged VLANs — Trunk/Hybrid only */}
|
||||||
@@ -192,12 +253,17 @@ export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps)
|
|||||||
key={v.id}
|
key={v.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleTaggedVlan(v.id)}
|
onClick={() => toggleTaggedVlan(v.id)}
|
||||||
className={`px-2 py-0.5 rounded text-xs border transition-colors ${
|
style={{
|
||||||
taggedVlanIds.includes(v.id)
|
backgroundColor: taggedVlanIds.includes(v.id) ? v.color ?? '#3b82f6' : 'transparent',
|
||||||
? 'bg-blue-700 border-blue-500 text-white'
|
borderColor: taggedVlanIds.includes(v.id) ? 'transparent' : v.color ?? '#475569',
|
||||||
: 'bg-slate-800 border-slate-600 text-slate-400 hover:border-slate-400'
|
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`}
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: taggedVlanIds.includes(v.id) ? '#fff' : v.color ?? '#3b82f6' }}
|
||||||
|
/>
|
||||||
{v.vlanId} {v.name}
|
{v.vlanId} {v.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -222,7 +288,14 @@ export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps)
|
|||||||
value={newVlanName}
|
value={newVlanName}
|
||||||
onChange={(e) => setNewVlanName(e.target.value)}
|
onChange={(e) => setNewVlanName(e.target.value)}
|
||||||
placeholder="Name"
|
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"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={newVlanColor}
|
||||||
|
onChange={(e) => 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"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
158
client/src/components/rack/ConnectionLayer.tsx
Normal file
158
client/src/components/rack/ConnectionLayer.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||||
|
import { useRackStore } from '../../store/useRackStore';
|
||||||
|
|
||||||
|
export function ConnectionLayer() {
|
||||||
|
const { racks, cablingFromPortId } = useRackStore();
|
||||||
|
const [coords, setCoords] = useState<Record<string, { x: number; y: number }>>({});
|
||||||
|
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
// Update port coordinates
|
||||||
|
const updateCoords = useCallback(() => {
|
||||||
|
const newCoords: Record<string, { x: number; y: number }> = {};
|
||||||
|
const dots = document.querySelectorAll('[data-port-id]');
|
||||||
|
|
||||||
|
// Find the closest scrollable parent that defines our coordinate system
|
||||||
|
// RackPlanner has overflow-auto on the canvas wrapper
|
||||||
|
const canvas = document.querySelector('.rack-planner-canvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const canvasRect = canvas.getBoundingClientRect();
|
||||||
|
|
||||||
|
dots.forEach((dot) => {
|
||||||
|
const portId = (dot as HTMLElement).dataset.portId;
|
||||||
|
if (!portId) return;
|
||||||
|
const rect = dot.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Coordinate is relative to the canvas origin, including its scroll position
|
||||||
|
newCoords[portId] = {
|
||||||
|
x: rect.left + rect.width / 2 - canvasRect.left + canvas.scrollLeft,
|
||||||
|
y: rect.top + rect.height / 2 - canvasRect.top + canvas.scrollTop,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setCoords(newCoords);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateCoords();
|
||||||
|
// Re-calculate on window resize or when racks change (modules move)
|
||||||
|
window.addEventListener('resize', updateCoords);
|
||||||
|
|
||||||
|
// Also re-calculate if the user scrolls (though ideally lines are pinned to the canvas)
|
||||||
|
// Actually, if SVG is INSIDE the scrollable container, we don't need scroll adjustment.
|
||||||
|
|
||||||
|
// We'll use a MutationObserver to detect DOM changes (like modules being added/moved)
|
||||||
|
const observer = new MutationObserver(updateCoords);
|
||||||
|
const canvas = document.querySelector('.rack-planner-canvas');
|
||||||
|
if (canvas) {
|
||||||
|
observer.observe(canvas, { childList: true, subtree: true, attributes: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', updateCoords);
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [racks, updateCoords]);
|
||||||
|
|
||||||
|
// Track mouse for "draft" connection (only while actively cabling)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cablingFromPortId) return;
|
||||||
|
|
||||||
|
const onMouseMove = (e: MouseEvent) => {
|
||||||
|
const canvas = document.querySelector('.rack-planner-canvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
setMousePos({
|
||||||
|
x: e.clientX - rect.left + canvas.scrollLeft,
|
||||||
|
y: e.clientY - rect.top + canvas.scrollTop,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', onMouseMove);
|
||||||
|
return () => window.removeEventListener('mousemove', onMouseMove);
|
||||||
|
}, [cablingFromPortId]);
|
||||||
|
|
||||||
|
const connections = useMemo(() => {
|
||||||
|
const conns: { id: string; from: string; to: string; color?: string; fromRackId: string; toRackId: string }[] = [];
|
||||||
|
racks.forEach((rack) => {
|
||||||
|
rack.modules.forEach((mod) => {
|
||||||
|
mod.ports.forEach((port) => {
|
||||||
|
port.sourceConnections?.forEach((c) => {
|
||||||
|
conns.push({
|
||||||
|
id: c.id,
|
||||||
|
from: c.fromPortId,
|
||||||
|
to: c.toPortId,
|
||||||
|
color: c.color,
|
||||||
|
fromRackId: rack.id,
|
||||||
|
toRackId: '' // We don't easily know the destination rack without searching
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return conns;
|
||||||
|
}, [racks]);
|
||||||
|
|
||||||
|
// Decide if we should show draft line
|
||||||
|
const draftStart = cablingFromPortId ? coords[cablingFromPortId] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="absolute top-0 left-0 pointer-events-none z-20 overflow-visible"
|
||||||
|
style={{ width: '1px', height: '1px' }} // SVG origin is top-left of canvas
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<marker id="arrow" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="4" markerHeight="4" orient="auto-start-reverse">
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Existing connections */}
|
||||||
|
{connections.map((conn) => {
|
||||||
|
const start = coords[conn.from];
|
||||||
|
const end = coords[conn.to];
|
||||||
|
if (!start || !end) return null;
|
||||||
|
|
||||||
|
// Calculate a slight curve. If ports are close, use a tighter curve.
|
||||||
|
const dx = Math.abs(end.x - start.x);
|
||||||
|
const dy = Math.abs(end.y - start.y);
|
||||||
|
const distance = Math.sqrt(dx*dx + dy*dy);
|
||||||
|
const curvature = Math.min(100, distance / 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={conn.id} className="connection-group">
|
||||||
|
<path
|
||||||
|
d={`M ${start.x} ${start.y} C ${start.x + curvature} ${start.y}, ${end.x - curvature} ${end.y}, ${end.x} ${end.y}`}
|
||||||
|
stroke={conn.color || '#3b82f6'}
|
||||||
|
strokeWidth="2.5"
|
||||||
|
fill="none"
|
||||||
|
opacity="0.8"
|
||||||
|
className="drop-shadow-sm transition-opacity hover:opacity-100"
|
||||||
|
/>
|
||||||
|
{/* Thicker transparent helper for easier identification if we ever add hover interactions */}
|
||||||
|
<path
|
||||||
|
d={`M ${start.x} ${start.y} C ${start.x + curvature} ${start.y}, ${end.x - curvature} ${end.y}, ${end.x} ${end.y}`}
|
||||||
|
stroke="transparent"
|
||||||
|
strokeWidth="10"
|
||||||
|
fill="none"
|
||||||
|
className="pointer-events-auto cursor-help"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Draft connection line (dashed) */}
|
||||||
|
{draftStart && (
|
||||||
|
<line
|
||||||
|
x1={draftStart.x}
|
||||||
|
y1={draftStart.y}
|
||||||
|
x2={mousePos.x}
|
||||||
|
y2={mousePos.y}
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray="5 3"
|
||||||
|
opacity="0.6"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -109,6 +109,35 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
|
|||||||
setPortModalOpen(true);
|
setPortModalOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { cablingFromPortId, setCablingFromPortId, createConnection } = useRackStore();
|
||||||
|
|
||||||
|
async function handlePortClick(e: React.MouseEvent, portId: string) {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// If shift key is pressed, open config modal as before
|
||||||
|
if (e.shiftKey) {
|
||||||
|
openPort(portId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle cabling mode
|
||||||
|
if (!cablingFromPortId) {
|
||||||
|
setCablingFromPortId(portId);
|
||||||
|
} else if (cablingFromPortId === portId) {
|
||||||
|
setCablingFromPortId(null);
|
||||||
|
} else {
|
||||||
|
// Connect!
|
||||||
|
try {
|
||||||
|
await createConnection(cablingFromPortId, portId);
|
||||||
|
setCablingFromPortId(null);
|
||||||
|
toast.success('Patch cable connected');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Connection failed');
|
||||||
|
setCablingFromPortId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -140,18 +169,28 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
|
|||||||
<div key={rowIdx} className="flex gap-[3px]">
|
<div key={rowIdx} className="flex gap-[3px]">
|
||||||
{row.map((port) => {
|
{row.map((port) => {
|
||||||
const hasVlan = port.vlans.length > 0;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={port.id}
|
key={port.id}
|
||||||
|
data-port-id={port.id}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => { e.stopPropagation(); openPort(port.id); }}
|
onClick={(e) => handlePortClick(e, port.id)}
|
||||||
aria-label={`Port ${port.portNumber}`}
|
aria-label={`Port ${port.portNumber}`}
|
||||||
title={`Port ${port.portNumber}${port.label ? ` · ${port.label}` : ''}`}
|
title={`Port ${port.portNumber}${port.label ? ` · ${port.label}` : ''}${
|
||||||
|
hasVlan ? ` (VLAN ${port.vlans.map((v) => v.vlan.vlanId).join(',')})` : ''
|
||||||
|
}\nShift+Click for settings`}
|
||||||
|
style={{ backgroundColor: vlanColor, borderColor: 'rgba(0,0,0,0.2)' }}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-2.5 h-2.5 rounded-sm border transition-colors shrink-0',
|
'w-2.5 h-2.5 rounded-sm border transition-all shrink-0 hover:scale-110 hover:brightness-125',
|
||||||
hasVlan
|
isCablingSource &&
|
||||||
? 'bg-green-400 border-green-500 hover:bg-green-300'
|
'ring-2 ring-blue-400 ring-offset-1 ring-offset-slate-900 animate-pulse'
|
||||||
: 'bg-slate-600 border-slate-500 hover:bg-slate-400'
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { apiClient } from '../../api/client';
|
|||||||
import { RackToolbar } from './RackToolbar';
|
import { RackToolbar } from './RackToolbar';
|
||||||
import { RackColumn } from './RackColumn';
|
import { RackColumn } from './RackColumn';
|
||||||
import { DevicePalette } from './DevicePalette';
|
import { DevicePalette } from './DevicePalette';
|
||||||
|
import { ConnectionLayer } from './ConnectionLayer';
|
||||||
import { AddModuleModal } from '../modals/AddModuleModal';
|
import { AddModuleModal } from '../modals/AddModuleModal';
|
||||||
import { RackSkeleton } from '../ui/Skeleton';
|
import { RackSkeleton } from '../ui/Skeleton';
|
||||||
import type { ModuleType } from '../../types';
|
import type { ModuleType } from '../../types';
|
||||||
@@ -237,7 +238,7 @@ export function RackPlanner() {
|
|||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<DevicePalette />
|
<DevicePalette />
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto relative rack-planner-canvas">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<RackSkeleton />
|
<RackSkeleton />
|
||||||
) : racks.length === 0 ? (
|
) : racks.length === 0 ? (
|
||||||
@@ -270,6 +271,7 @@ export function RackPlanner() {
|
|||||||
hoverSlot={hoverSlot}
|
hoverSlot={hoverSlot}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
<ConnectionLayer />
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ interface RackState {
|
|||||||
removeModuleLocal: (moduleId: string) => void;
|
removeModuleLocal: (moduleId: string) => void;
|
||||||
// Selection
|
// Selection
|
||||||
setSelectedModule: (id: string | null) => void;
|
setSelectedModule: (id: string | null) => void;
|
||||||
|
// Cabling
|
||||||
|
cablingFromPortId: string | null;
|
||||||
|
setCablingFromPortId: (id: string | null) => void;
|
||||||
|
createConnection: (fromPortId: string, toPortId: string) => Promise<void>;
|
||||||
|
deleteConnection: (id: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useRackStore = create<RackState>((set, get) => ({
|
export const useRackStore = create<RackState>((set, get) => ({
|
||||||
@@ -106,4 +111,21 @@ export const useRackStore = create<RackState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
setSelectedModule: (id) => set({ selectedModuleId: id }),
|
setSelectedModule: (id) => set({ selectedModuleId: id }),
|
||||||
|
|
||||||
|
// Cabling
|
||||||
|
cablingFromPortId: null,
|
||||||
|
setCablingFromPortId: (id) => set({ cablingFromPortId: id }),
|
||||||
|
|
||||||
|
createConnection: async (fromPortId, toPortId) => {
|
||||||
|
await apiClient.connections.create({ fromPortId, toPortId });
|
||||||
|
// Refresh racks to get updated nested connections
|
||||||
|
const racks = await apiClient.racks.list();
|
||||||
|
set({ racks });
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteConnection: async (id) => {
|
||||||
|
await apiClient.connections.delete(id);
|
||||||
|
const racks = await apiClient.racks.list();
|
||||||
|
set({ racks });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -56,6 +56,18 @@ export interface Port {
|
|||||||
nativeVlan?: number;
|
nativeVlan?: number;
|
||||||
vlans: PortVlanAssignment[];
|
vlans: PortVlanAssignment[];
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
// Physically connected links (patch cables)
|
||||||
|
sourceConnections?: Connection[];
|
||||||
|
targetConnections?: Connection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Connection {
|
||||||
|
id: string;
|
||||||
|
fromPortId: string;
|
||||||
|
toPortId: string;
|
||||||
|
color?: string;
|
||||||
|
label?: string;
|
||||||
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Module {
|
export interface Module {
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Connection" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"fromPortId" TEXT NOT NULL,
|
||||||
|
"toPortId" TEXT NOT NULL,
|
||||||
|
"color" TEXT,
|
||||||
|
"label" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "Connection_fromPortId_fkey" FOREIGN KEY ("fromPortId") REFERENCES "Port" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "Connection_toPortId_fkey" FOREIGN KEY ("toPortId") REFERENCES "Port" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Connection_fromPortId_toPortId_key" ON "Connection"("fromPortId", "toPortId");
|
||||||
@@ -53,6 +53,23 @@ model Port {
|
|||||||
nativeVlan Int?
|
nativeVlan Int?
|
||||||
vlans PortVlan[]
|
vlans PortVlan[]
|
||||||
notes String?
|
notes String?
|
||||||
|
|
||||||
|
// Connections — port can be source or target of a patch cable
|
||||||
|
sourceConnections Connection[] @relation("SourcePort")
|
||||||
|
targetConnections Connection[] @relation("TargetPort")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Connection {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
fromPortId String
|
||||||
|
fromPort Port @relation("SourcePort", fields: [fromPortId], references: [id], onDelete: Cascade)
|
||||||
|
toPortId String
|
||||||
|
toPort Port @relation("TargetPort", fields: [toPortId], references: [id], onDelete: Cascade)
|
||||||
|
color String? // Optional custom cable color
|
||||||
|
label String? // Optional cable label (e.g. "Cable #104")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([fromPortId, toPortId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Vlan {
|
model Vlan {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { vlansRouter } from './routes/vlans';
|
|||||||
import { serviceMapRouter } from './routes/serviceMap';
|
import { serviceMapRouter } from './routes/serviceMap';
|
||||||
import { nodesRouter } from './routes/nodes';
|
import { nodesRouter } from './routes/nodes';
|
||||||
import { edgesRouter } from './routes/edges';
|
import { edgesRouter } from './routes/edges';
|
||||||
|
import connectionsRouter from './routes/connections';
|
||||||
import { authMiddleware } from './middleware/authMiddleware';
|
import { authMiddleware } from './middleware/authMiddleware';
|
||||||
import { errorHandler } from './middleware/errorHandler';
|
import { errorHandler } from './middleware/errorHandler';
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ app.use('/api/vlans', vlansRouter);
|
|||||||
app.use('/api/maps', serviceMapRouter);
|
app.use('/api/maps', serviceMapRouter);
|
||||||
app.use('/api/nodes', nodesRouter);
|
app.use('/api/nodes', nodesRouter);
|
||||||
app.use('/api/edges', edgesRouter);
|
app.use('/api/edges', edgesRouter);
|
||||||
|
app.use('/api/connections', connectionsRouter);
|
||||||
|
|
||||||
// ---- Serve Vite build in production ----
|
// ---- Serve Vite build in production ----
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
|||||||
37
server/routes/connections.ts
Normal file
37
server/routes/connections.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import * as connService from '../services/connectionService';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// POST /api/connections
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { fromPortId, toPortId, color, label } = req.body;
|
||||||
|
const conn = await connService.createConnection({ fromPortId, toPortId, color, label });
|
||||||
|
res.status(201).json(conn);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/connections/:id
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await connService.deleteConnection(req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/connections/ports/:p1/:p2 (remove link between two specific ports)
|
||||||
|
router.delete('/ports/:p1/:p2', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await connService.deleteByPorts(req.params.p1, req.params.p2);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
34
server/services/connectionService.ts
Normal file
34
server/services/connectionService.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { prisma } from '../lib/prisma';
|
||||||
|
import { AppError } from '../types/index';
|
||||||
|
|
||||||
|
export async function createConnection(data: { fromPortId: string; toPortId: string; color?: string; label?: string }) {
|
||||||
|
// Check if both ports exist
|
||||||
|
const [from, to] = await Promise.all([
|
||||||
|
prisma.port.findUnique({ where: { id: data.fromPortId } }),
|
||||||
|
prisma.port.findUnique({ where: { id: data.toPortId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!from || !to) throw new AppError('One or both ports not found', 404, 'NOT_FOUND');
|
||||||
|
if (from.id === to.id) throw new AppError('Cannot connect a port to itself', 400, 'BAD_REQUEST');
|
||||||
|
|
||||||
|
// Check if ports are already occupied?
|
||||||
|
// (In real life, a port can only have one cable, but we might allow one source and one target per port if we want to be flexible, but better to prevent simple loops)
|
||||||
|
|
||||||
|
// Create connection (if it already exists, use upsert or just throw error; @@unique already handles it)
|
||||||
|
return prisma.connection.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteConnection(id: string) {
|
||||||
|
return prisma.connection.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteByPorts(portId1: string, portId2: string) {
|
||||||
|
return prisma.connection.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ fromPortId: portId1, toPortId: portId2 },
|
||||||
|
{ fromPortId: portId2, toPortId: portId1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ const rackInclude = {
|
|||||||
vlans: {
|
vlans: {
|
||||||
include: { vlan: true },
|
include: { vlan: true },
|
||||||
},
|
},
|
||||||
|
sourceConnections: true,
|
||||||
|
targetConnections: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user