Compare commits
31 Commits
7ea358e66a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d9170cec9 | |||
| 308a4c5641 | |||
| 3eac74f28c | |||
| f1c1efd8d3 | |||
| 72918bd87a | |||
| 96adb1e130 | |||
| f6b6f49379 | |||
| 1f360cdb2a | |||
| b26f88a89e | |||
| 5de001c630 | |||
| e2c5cad8a3 | |||
| becb55d57c | |||
| 444d694a06 | |||
| 0dcf5b3c8c | |||
| a13c52d3e3 | |||
| df04bb2c78 | |||
| 2e2b182844 | |||
| 1a99e22bfb | |||
| 55ee1dea93 | |||
| c9aed96400 | |||
| d381f8b720 | |||
| 25e78b4754 | |||
| a11634070f | |||
| 172896b85f | |||
| 7c04c4633f | |||
| 95d26ec805 | |||
| 3d72f429bc | |||
| 128b43e43d | |||
| b5df2e6721 | |||
| 69b7262535 | |||
| 2c95d01e7a |
@@ -8,7 +8,8 @@
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(npm uninstall:*)",
|
||||
"Bash(git add:*)"
|
||||
"Bash(git add:*)",
|
||||
"Bash(engine response\" error at migrate/startup time:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -3,14 +3,18 @@
|
||||
|
||||
# Admin credentials
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD_HASH= # Generate with: npx ts-node scripts/hashPassword.ts yourpassword
|
||||
ADMIN_PASSWORD= # Plain-text password
|
||||
|
||||
# JWT
|
||||
JWT_SECRET= # Min 32 random chars — generate with: openssl rand -hex 32
|
||||
JWT_EXPIRY=8h
|
||||
|
||||
# Database (relative path inside container; bind-mounted to ./data/)
|
||||
DATABASE_URL=file:./data/rackmapper.db
|
||||
# Cookie security — set to true only if behind an HTTPS reverse proxy
|
||||
COOKIE_SECURE=false
|
||||
|
||||
# Database — absolute path avoids Prisma CLI vs runtime resolution mismatch
|
||||
# In Docker this maps to the bind-mounted /app/data volume
|
||||
DATABASE_URL=file:/app/data/rackmapper.db
|
||||
|
||||
# Server
|
||||
PORT=3001
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Build and Push Docker Image
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.alwisp.com
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and Push
|
||||
run: |
|
||||
docker build -t registry.alwisp.com/${{ gitea.repository_owner }}/${{ gitea.repository }}:latest .
|
||||
docker push registry.alwisp.com/${{ gitea.repository_owner }}/${{ gitea.repository }}:latest
|
||||
@@ -440,14 +440,14 @@ Each `NodeType` has a custom React Flow node component in `client/src/components
|
||||
|
||||
| Node Type | Component | Visual Style |
|
||||
|---|---|---|
|
||||
| `DEVICE` | `DeviceNode.tsx` | Rack icon; accent border color by ModuleType; shows IP if linked |
|
||||
| `SERVICE` | `ServiceNode.tsx` | Rounded card, colored left border, icon + label |
|
||||
| `DATABASE` | `DatabaseNode.tsx` | Cylinder icon, dark teal accent |
|
||||
| `API` | `ApiNode.tsx` | Badge style with optional method tag (REST/gRPC/WS) |
|
||||
| `EXTERNAL` | `ExternalNode.tsx` | Dashed border, cloud icon |
|
||||
| `DEVICE` | `DeviceNode.tsx` | Rack icon; accent border color by ModuleType; shows IP if linked or overridden by metadata |
|
||||
| `SERVICE` | `ServiceNode.tsx` | Rounded card, colored left border, icon + label; shows IP/Port if set in metadata |
|
||||
| `DATABASE` | `DatabaseNode.tsx` | Cylinder icon, dark teal accent; shows IP/Port if set in metadata |
|
||||
| `API` | `ApiNode.tsx` | Badge style with optional method tag (REST/gRPC/WS); shows IP/Port if set in metadata |
|
||||
| `EXTERNAL` | `ExternalNode.tsx` | Dashed border, cloud icon; shows IP/Port if set in metadata |
|
||||
| `VLAN` | `VlanNode.tsx` | Colored square matching VLAN color swatch |
|
||||
| `FIREWALL` | `FirewallNode.tsx` | Shield icon, red accent |
|
||||
| `LOAD_BALANCER` | `LBNode.tsx` | Scale/balance icon |
|
||||
| `FIREWALL` | `FirewallNode.tsx` | Shield icon, red accent; shows IP/Port if set in metadata |
|
||||
| `LOAD_BALANCER` | `LBNode.tsx` | Scale/balance icon; shows IP/Port if set in metadata |
|
||||
| `USER` | `UserNode.tsx` | Person icon, neutral gray |
|
||||
| `NOTE` | `NoteNode.tsx` | Sticky note style, no handles, free text |
|
||||
|
||||
@@ -463,7 +463,7 @@ Each `NodeType` has a custom React Flow node component in `client/src/components
|
||||
- Right-click canvas → context menu: Add Node (all 10 types placed at cursor position)
|
||||
- Right-click node → Edit (label/colour/module link), Duplicate, Delete
|
||||
- Right-click edge → Toggle Animation, set edge type (bezier/smooth/step/straight), Delete
|
||||
- Double-click node → `NodeEditModal` (label, accent colour swatch + custom picker, rack module link)
|
||||
- Double-click node → `NodeEditModal` (label, accent colour swatch + custom picker, logical IP/Port metadata, rack module link)
|
||||
|
||||
### Persistence
|
||||
|
||||
@@ -608,6 +608,8 @@ The Dockerfile should run `npx prisma migrate deploy && node dist/index.js` as t
|
||||
8. **Ports auto-generated on Module creation** — use `MODULE_PORT_DEFAULTS`. Do not require manual port addition.
|
||||
9. **DeviceNode position is canvas-independent** — linking a node to a Module does not constrain its canvas position.
|
||||
10. **VLAN seed is blank** — the user creates all VLANs manually. Do not pre-seed VLAN records.
|
||||
11. **Logical Address Metadata** — IP and Port for mapper nodes are stored as a JSON string in the `metadata` field of the `ServiceNode` table. This avoids schema migrations for functional logical address tracking.
|
||||
12. **Fixed Drag & Drop** — Rack Planner drag-and-drop utilizes `@dnd-kit` with optimized hit-testing via `document.elementFromPoint` and `pointer-events: none` on the `DragOverlay`. Dragged modules remain mounted to maintain library state.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+19
-10
@@ -2,16 +2,22 @@ FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build tools needed for better-sqlite3 native bindings
|
||||
RUN apk add --no-cache python3 make g++
|
||||
# Install build tools + OpenSSL (required by Prisma query engine on Alpine)
|
||||
RUN apk add --no-cache python3 make g++ openssl openssl-dev
|
||||
|
||||
# Copy package manifests
|
||||
COPY package*.json ./
|
||||
COPY client/package*.json ./client/
|
||||
# Configure npm: disable package-lock update, cap network retries/timeout
|
||||
RUN npm config set fetch-retry-mintimeout 5000 \
|
||||
&& npm config set fetch-retry-maxtimeout 30000 \
|
||||
&& npm config set fetch-retries 3 \
|
||||
&& npm config set prefer-offline false
|
||||
|
||||
# Install all dependencies (dev deps needed for prisma CLI + tsc build)
|
||||
RUN npm install
|
||||
RUN cd client && npm install
|
||||
# Copy lockfiles + manifests together so npm ci can validate the lockfile
|
||||
COPY package.json package-lock.json ./
|
||||
COPY client/package.json client/package-lock.json ./client/
|
||||
|
||||
# Use npm ci (lockfile-exact, no resolution step, much faster than npm install)
|
||||
RUN npm ci
|
||||
RUN cd client && npm ci
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
@@ -25,7 +31,10 @@ RUN npm run build
|
||||
# Ensure data directory exists for SQLite bind mount
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Copy and make entrypoint executable
|
||||
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
|
||||
RUN chmod +x /app/docker-entrypoint.sh
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
# Apply pending migrations then start
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/server/index.js"]
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
|
||||
@@ -19,7 +19,9 @@ A self-hosted, dark-mode web app for visualising and managing network rack infra
|
||||
- Right-click canvas → add any node type at cursor position
|
||||
- Right-click node → Edit, Duplicate, Delete
|
||||
- Right-click edge → Toggle animation, change edge type, Delete
|
||||
- Double-click a node → edit label, accent colour, and rack module link
|
||||
- Double-click a node → edit label, accent colour, logical IP/Port, and rack module link
|
||||
- Logical Address mapping — assign IP and Port to any node type via metadata (stored as JSON)
|
||||
- Persistent storage — all node details and logical addresses are saved to the SQLite database
|
||||
- Auto-populate nodes from all rack modules ("Import Rack" button)
|
||||
- Connect nodes by dragging from handles; Delete key removes selected items
|
||||
- Minimap, zoom controls, snap-to-grid (15px), PNG export
|
||||
@@ -109,17 +109,20 @@ Click **Add another Path, Port, Variable, Label or Device** → select **Variabl
|
||||
|---|---|---|
|
||||
| Node Environment | `NODE_ENV` | `production` |
|
||||
| Port | `PORT` | `3001` |
|
||||
| Database URL | `DATABASE_URL` | `file:./data/rackmapper.db` |
|
||||
| Database URL | `DATABASE_URL` | `file:/app/data/rackmapper.db` |
|
||||
| Admin Username | `ADMIN_USERNAME` | `admin` *(or your preferred username)* |
|
||||
| Admin Password | `ADMIN_PASSWORD` | `yourpassword` |
|
||||
| JWT Secret | `JWT_SECRET` | `a-long-random-string-min-32-chars` |
|
||||
| JWT Expiry | `JWT_EXPIRY` | `8h` *(optional — defaults to 8h)* |
|
||||
| Secure Cookie | `COOKIE_SECURE` | `false` *(set to `true` only if behind HTTPS reverse proxy)* |
|
||||
|
||||
> **JWT_SECRET** should be a random string of at least 32 characters. You can generate one in the Unraid terminal:
|
||||
> ```bash
|
||||
> cat /proc/sys/kernel/random/uuid | tr -d '-' && cat /proc/sys/kernel/random/uuid | tr -d '-'
|
||||
> ```
|
||||
|
||||
> **COOKIE_SECURE** — Leave this unset or `false` for direct HTTP access (the default for Unraid). Only set it to `true` if you are terminating HTTPS at a reverse proxy (e.g. Nginx Proxy Manager, Traefik) in front of RackMapper, otherwise login will succeed but every subsequent API call will return 401 Unauthorized because the browser will refuse to send the session cookie over plain HTTP.
|
||||
|
||||
---
|
||||
|
||||
### Step 3 — Apply
|
||||
@@ -148,11 +151,12 @@ docker run -d \
|
||||
-v /mnt/user/appdata/rackmapper/data:/app/data \
|
||||
-e NODE_ENV=production \
|
||||
-e PORT=3001 \
|
||||
-e DATABASE_URL="file:./data/rackmapper.db" \
|
||||
-e DATABASE_URL="file:/app/data/rackmapper.db" \
|
||||
-e ADMIN_USERNAME=admin \
|
||||
-e ADMIN_PASSWORD=yourpassword \
|
||||
-e JWT_SECRET=a-long-random-string-min-32-chars \
|
||||
-e JWT_EXPIRY=8h \
|
||||
-e COOKIE_SECURE=false \
|
||||
rackmapper:latest
|
||||
```
|
||||
|
||||
|
||||
@@ -81,6 +81,8 @@ const racks = {
|
||||
notes?: string;
|
||||
portCount?: number;
|
||||
portType?: PortType;
|
||||
sfpCount?: number;
|
||||
wanCount?: number;
|
||||
}
|
||||
) => post<Module>(`/racks/${rackId}/modules`, data),
|
||||
};
|
||||
@@ -195,4 +197,15 @@ const edges = {
|
||||
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; edgeType?: string }) =>
|
||||
post<{ id: string }>('/connections', data),
|
||||
update: (id: string, data: Partial<{ color: string; label: string; edgeType: string }>) =>
|
||||
put<{ id: string }>(`/connections/${id}`, 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 };
|
||||
|
||||
@@ -25,7 +25,8 @@ export interface NodeEditModalProps {
|
||||
initialLabel: string;
|
||||
initialColor?: string;
|
||||
initialModuleId?: string | null;
|
||||
onSaved: (updated: { label: string; color: string; moduleId: string | null }) => void;
|
||||
initialMetadata?: string | null;
|
||||
onSaved: (updated: { label: string; color: string; moduleId: string | null; metadata?: string }) => void;
|
||||
}
|
||||
|
||||
export function NodeEditModal({
|
||||
@@ -35,12 +36,15 @@ export function NodeEditModal({
|
||||
initialLabel,
|
||||
initialColor,
|
||||
initialModuleId,
|
||||
initialMetadata,
|
||||
onSaved,
|
||||
}: NodeEditModalProps) {
|
||||
const { racks } = useRackStore();
|
||||
const [label, setLabel] = useState(initialLabel);
|
||||
const [color, setColor] = useState(initialColor ?? '#3b82f6');
|
||||
const [moduleId, setModuleId] = useState<string>(initialModuleId ?? '');
|
||||
const [ipAddress, setIpAddress] = useState('');
|
||||
const [port, setPort] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -48,20 +52,50 @@ export function NodeEditModal({
|
||||
setLabel(initialLabel);
|
||||
setColor(initialColor ?? '#3b82f6');
|
||||
setModuleId(initialModuleId ?? '');
|
||||
|
||||
if (initialMetadata) {
|
||||
try {
|
||||
const parsed = JSON.parse(initialMetadata);
|
||||
setIpAddress(parsed.ipAddress || '');
|
||||
setPort(parsed.port || '');
|
||||
} catch {
|
||||
setIpAddress('');
|
||||
setPort('');
|
||||
}
|
||||
} else {
|
||||
setIpAddress('');
|
||||
setPort('');
|
||||
}
|
||||
}
|
||||
}, [open, initialLabel, initialColor, initialModuleId]);
|
||||
}, [open, initialLabel, initialColor, initialModuleId, initialMetadata]);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!label.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
let metaObj: Record<string, unknown> = {};
|
||||
if (initialMetadata) {
|
||||
try {
|
||||
metaObj = JSON.parse(initialMetadata);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (ipAddress.trim()) metaObj.ipAddress = ipAddress.trim();
|
||||
else delete metaObj.ipAddress;
|
||||
|
||||
if (port.trim()) metaObj.port = port.trim();
|
||||
else delete metaObj.port;
|
||||
|
||||
const metadataString = Object.keys(metaObj).length > 0 ? JSON.stringify(metaObj) : '';
|
||||
|
||||
await apiClient.nodes.update(nodeId, {
|
||||
label: label.trim(),
|
||||
color,
|
||||
moduleId: moduleId || null,
|
||||
metadata: metadataString,
|
||||
});
|
||||
onSaved({ label: label.trim(), color, moduleId: moduleId || null });
|
||||
onSaved({ label: label.trim(), color, moduleId: moduleId || null, metadata: metadataString });
|
||||
toast.success('Node updated');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -139,6 +173,30 @@ export function NodeEditModal({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Logical Address */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-slate-300">IP Address</label>
|
||||
<input
|
||||
value={ipAddress}
|
||||
onChange={(e) => setIpAddress(e.target.value)}
|
||||
disabled={loading}
|
||||
placeholder="e.g. 10.0.0.5"
|
||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-slate-300">Port</label>
|
||||
<input
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
disabled={loading}
|
||||
placeholder="e.g. 443"
|
||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-1">
|
||||
<Button variant="secondary" size="sm" type="button" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
|
||||
@@ -136,6 +136,7 @@ type NodeEditState = {
|
||||
label: string;
|
||||
color?: string;
|
||||
moduleId?: string | null;
|
||||
metadata?: string | null;
|
||||
} | null;
|
||||
|
||||
function ServiceMapperInner() {
|
||||
@@ -148,48 +149,58 @@ function ServiceMapperInner() {
|
||||
|
||||
const [ctxMenu, setCtxMenu] = useState<CtxMenu>(null);
|
||||
const [nodeEditState, setNodeEditState] = useState<NodeEditState>(null);
|
||||
// Block position saves briefly after map load to prevent fitView from
|
||||
// firing spurious position changes that overwrite stored positions
|
||||
const blockSaveRef = useRef(false);
|
||||
|
||||
// Load maps list on mount
|
||||
useEffect(() => {
|
||||
fetchMaps().catch(() => toast.error('Failed to load maps'));
|
||||
}, [fetchMaps]);
|
||||
|
||||
// When active map changes, update flow state
|
||||
// When active map changes, update flow state and block position saves briefly
|
||||
useEffect(() => {
|
||||
if (activeMap) {
|
||||
blockSaveRef.current = true;
|
||||
setNodes(toFlowNodes(activeMap));
|
||||
setEdges(toFlowEdges(activeMap));
|
||||
// Unblock after React Flow has settled its initial layout
|
||||
const t = setTimeout(() => { blockSaveRef.current = false; }, 800);
|
||||
return () => clearTimeout(t);
|
||||
} else {
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
}
|
||||
}, [activeMap, setNodes, setEdges]);
|
||||
|
||||
// Debounced node position save (500ms after drag end)
|
||||
// Debounced node position save (500ms after drag ends)
|
||||
const handleNodesChange = useCallback(
|
||||
(changes: NodeChange<Node>[]) => {
|
||||
onNodesChange(changes);
|
||||
|
||||
const positionChanges = changes.filter(
|
||||
(c): c is NodeChange<Node> & { type: 'position'; dragging: false } =>
|
||||
c.type === 'position' && !(c as { dragging?: boolean }).dragging
|
||||
);
|
||||
// Don't persist positions during initial load / fitView settle period
|
||||
if (blockSaveRef.current) return;
|
||||
|
||||
const positionChanges = changes.filter((c) => {
|
||||
if (c.type !== 'position') return false;
|
||||
const pc = c as { type: 'position'; id: string; position?: { x: number; y: number }; dragging?: boolean };
|
||||
// Only save when drag has fully ended (dragging === false) and position is present
|
||||
return pc.dragging === false && pc.position != null;
|
||||
});
|
||||
|
||||
if (positionChanges.length === 0) return;
|
||||
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(async () => {
|
||||
for (const change of positionChanges) {
|
||||
const nodeId = (change as { id: string }).id;
|
||||
const position = (change as { position?: { x: number; y: number } }).position;
|
||||
if (!position) continue;
|
||||
const pc = change as { id: string; position: { x: number; y: number } };
|
||||
try {
|
||||
await apiClient.nodes.update(nodeId, {
|
||||
positionX: position.x,
|
||||
positionY: position.y,
|
||||
await apiClient.nodes.update(pc.id, {
|
||||
positionX: pc.position.x,
|
||||
positionY: pc.position.y,
|
||||
});
|
||||
} catch {
|
||||
// Silent — position drift on failure is acceptable
|
||||
// Silent — minor position drift on failure is acceptable
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
@@ -267,7 +278,7 @@ function ServiceMapperInner() {
|
||||
|
||||
const onNodeContextMenu = useCallback((event: React.MouseEvent, node: Node) => {
|
||||
event.preventDefault();
|
||||
const d = node.data as { label?: string; color?: string; module?: { id: string } };
|
||||
const d = node.data as { label?: string; color?: string; module?: { id: string }; metadata?: string };
|
||||
setCtxMenu({
|
||||
kind: 'node',
|
||||
x: event.clientX,
|
||||
@@ -292,12 +303,13 @@ function ServiceMapperInner() {
|
||||
}, []);
|
||||
|
||||
const onNodeDoubleClick = useCallback((_event: React.MouseEvent, node: Node) => {
|
||||
const d = node.data as { label?: string; color?: string; module?: { id: string } };
|
||||
const d = node.data as { label?: string; color?: string; module?: { id: string }; metadata?: string };
|
||||
setNodeEditState({
|
||||
nodeId: node.id,
|
||||
label: d.label ?? '',
|
||||
color: d.color,
|
||||
moduleId: d.module?.id ?? null,
|
||||
metadata: d.metadata ?? null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -420,12 +432,12 @@ function ServiceMapperInner() {
|
||||
|
||||
// ---- Node edit modal save ----
|
||||
|
||||
function handleNodeEditSaved(updated: { label: string; color: string; moduleId: string | null }) {
|
||||
function handleNodeEditSaved(updated: { label: string; color: string; moduleId: string | null; metadata?: string }) {
|
||||
if (!nodeEditState) return;
|
||||
setNodes((nds) =>
|
||||
nds.map((n) =>
|
||||
n.id === nodeEditState.nodeId
|
||||
? { ...n, data: { ...n.data, label: updated.label, color: updated.color } }
|
||||
? { ...n, data: { ...n.data, label: updated.label, color: updated.color, metadata: updated.metadata } }
|
||||
: n
|
||||
)
|
||||
);
|
||||
@@ -447,11 +459,14 @@ function ServiceMapperInner() {
|
||||
|
||||
if (ctxMenu.kind === 'node') {
|
||||
const { nodeId, label, color, moduleId } = ctxMenu;
|
||||
const node = nodes.find(n => n.id === nodeId);
|
||||
const metadata = node ? (node.data as any).metadata : null;
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Edit node',
|
||||
icon: <Edit2 size={12} />,
|
||||
onClick: () => setNodeEditState({ nodeId, label, color, moduleId }),
|
||||
onClick: () => setNodeEditState({ nodeId, label, color, moduleId, metadata }),
|
||||
},
|
||||
{
|
||||
label: 'Duplicate',
|
||||
@@ -605,6 +620,7 @@ function ServiceMapperInner() {
|
||||
initialLabel={nodeEditState.label}
|
||||
initialColor={nodeEditState.color}
|
||||
initialModuleId={nodeEditState.moduleId}
|
||||
initialMetadata={nodeEditState.metadata}
|
||||
onSaved={handleNodeEditSaved}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import { Zap } from 'lucide-react';
|
||||
|
||||
export const ApiNode = memo(({ data, selected }: NodeProps) => {
|
||||
const label = (data as { label?: string }).label ?? 'API';
|
||||
|
||||
const meta = useMemo(() => {
|
||||
try {
|
||||
return (data as any).metadata ? JSON.parse((data as any).metadata) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const hasAddress = meta.ipAddress || meta.port;
|
||||
const method = (data as { method?: string }).method;
|
||||
return (
|
||||
<div className={`min-w-[140px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-yellow-500 border-yellow-500' : 'border-yellow-700'}`}>
|
||||
<Handle type="target" position={Position.Top} className="!bg-yellow-400 !border-yellow-600" />
|
||||
<div className="px-3 py-2 flex items-center gap-2">
|
||||
<Zap size={13} className="text-yellow-400 shrink-0" />
|
||||
<span className="text-xs font-medium text-slate-100 truncate flex-1">{label}</span>
|
||||
{method && (
|
||||
<span className="text-[10px] px-1 py-0.5 rounded bg-yellow-900/60 text-yellow-300 border border-yellow-700/50 font-mono shrink-0">
|
||||
{method}
|
||||
</span>
|
||||
<div className="px-3 py-2 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap size={13} className="text-yellow-400 shrink-0" />
|
||||
<span className="text-xs font-medium text-slate-100 truncate flex-1">{label}</span>
|
||||
{method && (
|
||||
<span className="text-[10px] px-1 py-0.5 rounded bg-yellow-900/60 text-yellow-300 border border-yellow-700/50 font-mono shrink-0">
|
||||
{method}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasAddress && (
|
||||
<div className="text-[10px] text-slate-400 font-mono pl-[21px] truncate">
|
||||
{meta.ipAddress}
|
||||
{meta.ipAddress && meta.port && ':'}
|
||||
{meta.port}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} className="!bg-yellow-400 !border-yellow-600" />
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import { Database } from 'lucide-react';
|
||||
|
||||
export const DatabaseNode = memo(({ data, selected }: NodeProps) => {
|
||||
const label = (data as { label?: string }).label ?? 'Database';
|
||||
|
||||
const meta = useMemo(() => {
|
||||
try {
|
||||
return (data as any).metadata ? JSON.parse((data as any).metadata) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const hasAddress = meta.ipAddress || meta.port;
|
||||
return (
|
||||
<div className={`min-w-[140px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-teal-500 border-teal-500' : 'border-teal-700'}`}>
|
||||
<Handle type="target" position={Position.Top} className="!bg-teal-400 !border-teal-600" />
|
||||
<div className="px-3 py-2 flex items-center gap-2">
|
||||
<Database size={13} className="text-teal-400 shrink-0" />
|
||||
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
|
||||
<div className="px-3 py-2 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database size={13} className="text-teal-400 shrink-0" />
|
||||
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
|
||||
</div>
|
||||
{hasAddress && (
|
||||
<div className="text-[10px] text-slate-400 font-mono pl-[21px] truncate">
|
||||
{meta.ipAddress}
|
||||
{meta.ipAddress && meta.port && ':'}
|
||||
{meta.port}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} className="!bg-teal-400 !border-teal-600" />
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import type { Module } from '../../../types';
|
||||
import { MODULE_TYPE_COLORS, MODULE_TYPE_LABELS } from '../../../lib/constants';
|
||||
@@ -16,6 +16,17 @@ export const DeviceNode = memo(({ data, selected }: NodeProps) => {
|
||||
|
||||
const colors = mod ? MODULE_TYPE_COLORS[mod.type] : null;
|
||||
|
||||
const meta = useMemo(() => {
|
||||
try {
|
||||
return nodeData.metadata ? JSON.parse(nodeData.metadata as string) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, [nodeData.metadata]);
|
||||
|
||||
const ipToDisplay = meta.ipAddress || mod?.ipAddress;
|
||||
const hasAddress = ipToDisplay || meta.port;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[160px] max-w-[200px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden transition-all ${
|
||||
@@ -37,15 +48,24 @@ export const DeviceNode = memo(({ data, selected }: NodeProps) => {
|
||||
<span className="text-xs font-semibold text-slate-100 truncate">{nodeData.label}</span>
|
||||
</div>
|
||||
{mod && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
<Badge variant="slate" className="text-[10px]">{MODULE_TYPE_LABELS[mod.type]}</Badge>
|
||||
{mod.ipAddress && (
|
||||
<span className="text-[10px] text-slate-400 font-mono">{mod.ipAddress}</span>
|
||||
{hasAddress && (
|
||||
<span className="text-[10px] text-slate-400 font-mono ml-1 mt-0.5">
|
||||
{ipToDisplay}{meta.port ? `:${meta.port}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!mod && (
|
||||
<span className="text-[10px] text-slate-500">Unlinked device</span>
|
||||
<div className="flex flex-col mt-1">
|
||||
<span className="text-[10px] text-slate-500">Unlinked device</span>
|
||||
{hasAddress && (
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
{ipToDisplay}{meta.port ? `:${meta.port}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import { Cloud } from 'lucide-react';
|
||||
|
||||
export const ExternalNode = memo(({ data, selected }: NodeProps) => {
|
||||
const label = (data as { label?: string }).label ?? 'External';
|
||||
|
||||
const meta = useMemo(() => {
|
||||
try {
|
||||
return (data as any).metadata ? JSON.parse((data as any).metadata) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const hasAddress = meta.ipAddress || meta.port;
|
||||
return (
|
||||
<div className={`min-w-[140px] bg-slate-800 border-2 border-dashed rounded-lg shadow-lg ${selected ? 'ring-2 ring-slate-400 border-slate-400' : 'border-slate-500'}`}>
|
||||
<Handle type="target" position={Position.Top} className="!bg-slate-400 !border-slate-600" />
|
||||
<div className="px-3 py-2 flex items-center gap-2">
|
||||
<Cloud size={13} className="text-slate-400 shrink-0" />
|
||||
<span className="text-xs font-medium text-slate-300 truncate">{label}</span>
|
||||
<div className="px-3 py-2 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cloud size={13} className="text-slate-400 shrink-0" />
|
||||
<span className="text-xs font-medium text-slate-300 truncate">{label}</span>
|
||||
</div>
|
||||
{hasAddress && (
|
||||
<div className="text-[10px] text-slate-400 font-mono pl-[21px] truncate">
|
||||
{meta.ipAddress}
|
||||
{meta.ipAddress && meta.port && ':'}
|
||||
{meta.port}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import { Shield } from 'lucide-react';
|
||||
|
||||
export const FirewallNode = memo(({ data, selected }: NodeProps) => {
|
||||
const label = (data as { label?: string }).label ?? 'Firewall';
|
||||
|
||||
const meta = useMemo(() => {
|
||||
try {
|
||||
return (data as any).metadata ? JSON.parse((data as any).metadata) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const hasAddress = meta.ipAddress || meta.port;
|
||||
return (
|
||||
<div className={`min-w-[140px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-red-500 border-red-500' : 'border-red-700'}`}>
|
||||
<Handle type="target" position={Position.Top} className="!bg-red-400 !border-red-600" />
|
||||
<div className="px-3 py-2 flex items-center gap-2">
|
||||
<Shield size={13} className="text-red-400 shrink-0" />
|
||||
<span className="text-xs font-semibold text-slate-100 truncate">{label}</span>
|
||||
<div className="px-3 py-2 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield size={13} className="text-red-400 shrink-0" />
|
||||
<span className="text-xs font-semibold text-slate-100 truncate">{label}</span>
|
||||
</div>
|
||||
{hasAddress && (
|
||||
<div className="text-[10px] text-slate-400 font-mono pl-[21px] truncate">
|
||||
{meta.ipAddress}
|
||||
{meta.ipAddress && meta.port && ':'}
|
||||
{meta.port}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} className="!bg-red-400 !border-red-600" />
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import { Scale } from 'lucide-react';
|
||||
|
||||
export const LBNode = memo(({ data, selected }: NodeProps) => {
|
||||
const label = (data as { label?: string }).label ?? 'Load Balancer';
|
||||
|
||||
const meta = useMemo(() => {
|
||||
try {
|
||||
return (data as any).metadata ? JSON.parse((data as any).metadata) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const hasAddress = meta.ipAddress || meta.port;
|
||||
return (
|
||||
<div className={`min-w-[140px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-orange-500 border-orange-500' : 'border-orange-700'}`}>
|
||||
<Handle type="target" position={Position.Top} className="!bg-orange-400 !border-orange-600" />
|
||||
<div className="px-3 py-2 flex items-center gap-2">
|
||||
<Scale size={13} className="text-orange-400 shrink-0" />
|
||||
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
|
||||
<div className="px-3 py-2 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Scale size={13} className="text-orange-400 shrink-0" />
|
||||
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
|
||||
</div>
|
||||
{hasAddress && (
|
||||
<div className="text-[10px] text-slate-400 font-mono pl-[21px] truncate">
|
||||
{meta.ipAddress}
|
||||
{meta.ipAddress && meta.port && ':'}
|
||||
{meta.port}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} className="!bg-orange-400 !border-orange-600" />
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import { Layers } from 'lucide-react';
|
||||
|
||||
@@ -6,6 +6,16 @@ export const ServiceNode = memo(({ data, selected }: NodeProps) => {
|
||||
const label = (data as { label?: string }).label ?? 'Service';
|
||||
const color = (data as { color?: string }).color ?? '#3b82f6';
|
||||
|
||||
const meta = useMemo(() => {
|
||||
try {
|
||||
return (data as any).metadata ? JSON.parse((data as any).metadata) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const hasAddress = meta.ipAddress || meta.port;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[140px] bg-slate-800 rounded-lg shadow-lg border overflow-hidden ${
|
||||
@@ -14,9 +24,18 @@ export const ServiceNode = memo(({ data, selected }: NodeProps) => {
|
||||
style={{ borderLeftColor: color, borderLeftWidth: 3 }}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} className="!bg-slate-400 !border-slate-600" />
|
||||
<div className="px-3 py-2 flex items-center gap-2">
|
||||
<Layers size={13} style={{ color }} className="shrink-0" />
|
||||
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
|
||||
<div className="px-3 py-2 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers size={13} style={{ color }} className="shrink-0" />
|
||||
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
|
||||
</div>
|
||||
{hasAddress && (
|
||||
<div className="text-[10px] text-slate-400 font-mono pl-[21px] truncate">
|
||||
{meta.ipAddress}
|
||||
{meta.ipAddress && meta.port && ':'}
|
||||
{meta.port}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useState, useEffect, useMemo, type FormEvent } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { Button } from '../ui/Button';
|
||||
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
||||
import { useRackStore } from '../../store/useRackStore';
|
||||
|
||||
interface ConnectionConfigModalProps {
|
||||
connectionId: string | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const EDGE_STYLES = [
|
||||
{ value: 'bezier', label: 'Curved (Bezier)' },
|
||||
{ value: 'straight', label: 'Straight Line' },
|
||||
{ value: 'step', label: 'Stepped / Orthogonal' },
|
||||
];
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#3b82f6', // Blue
|
||||
'#10b981', // Emerald
|
||||
'#8b5cf6', // Violet
|
||||
'#ef4444', // Red
|
||||
'#f59e0b', // Amber
|
||||
'#ec4899', // Pink
|
||||
'#64748b', // Slate
|
||||
'#ffffff', // White
|
||||
];
|
||||
|
||||
export function ConnectionConfigModal({ connectionId, open, onClose }: ConnectionConfigModalProps) {
|
||||
const { racks, updateConnection, deleteConnection } = useRackStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
|
||||
// Synchronously find the connection from the global store
|
||||
const connection = useMemo(() => {
|
||||
if (!connectionId) return null;
|
||||
for (const rack of racks) {
|
||||
for (const mod of rack.modules) {
|
||||
for (const port of mod.ports) {
|
||||
const found = port.sourceConnections?.find((c) => c.id === connectionId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [racks, connectionId]);
|
||||
|
||||
// Form state
|
||||
const [color, setColor] = useState('#3b82f6');
|
||||
const [edgeType, setEdgeType] = useState('bezier');
|
||||
const [label, setLabel] = useState('');
|
||||
|
||||
// Reset form state when connection is found or changed
|
||||
useEffect(() => {
|
||||
if (connection && open) {
|
||||
setColor(connection.color || '#3b82f6');
|
||||
setEdgeType(connection.edgeType || 'bezier');
|
||||
setLabel(connection.label || '');
|
||||
}
|
||||
}, [connection, open]);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!connectionId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateConnection(connectionId, { color, edgeType, label });
|
||||
toast.success('Connection updated');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Update failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!connectionId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteConnection(connectionId);
|
||||
toast.success('Connection removed');
|
||||
setConfirmDeleteOpen(false);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open || !connection) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onClose={() => !loading && onClose()} title="Edit Connection">
|
||||
<div className="flex flex-col gap-6">
|
||||
<p className="text-sm text-slate-400">
|
||||
Customize the cable style and color.
|
||||
</p>
|
||||
|
||||
<form id="connection-form" onSubmit={handleSubmit} className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="connection-label" className="text-sm font-semibold text-slate-300">Cable Label (Optional)</label>
|
||||
<input
|
||||
id="connection-label"
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="e.g. Uplink to Core"
|
||||
className="px-3 py-2 rounded-md bg-slate-900 border border-slate-700 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-slate-300">Cable Color</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
style={{ backgroundColor: c }}
|
||||
className={`w-8 h-8 rounded-full border-2 transition-all hover:scale-110 ${
|
||||
color === c ? 'border-white scale-110 shadow-lg' : 'border-transparent'
|
||||
}`}
|
||||
aria-label={`Select color ${c}`}
|
||||
/>
|
||||
))}
|
||||
<div className="relative w-8 h-8 rounded-full overflow-hidden border-2 border-slate-700">
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="absolute -top-2 -left-2 w-12 h-12 cursor-pointer"
|
||||
title="Custom color"
|
||||
aria-label="Select custom cable color"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-slate-300">Curve Style</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{EDGE_STYLES.map((style) => (
|
||||
<button
|
||||
key={style.value}
|
||||
type="button"
|
||||
onClick={() => setEdgeType(style.value)}
|
||||
className={`px-3 py-2 rounded-md border text-xs font-medium transition-all ${
|
||||
edgeType === style.value
|
||||
? 'bg-blue-500/10 border-blue-500 text-blue-400'
|
||||
: 'bg-slate-900 border-slate-700 text-slate-400 hover:border-slate-500 hover:text-slate-200'
|
||||
}`}
|
||||
aria-pressed={edgeType === style.value}
|
||||
>
|
||||
{style.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t border-slate-800">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-red-400 hover:text-red-300"
|
||||
onClick={() => setConfirmDeleteOpen(true)}
|
||||
disabled={loading}
|
||||
>
|
||||
Delete Connection
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form="connection-form" variant="primary" loading={loading}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDeleteOpen}
|
||||
onClose={() => !loading && setConfirmDeleteOpen(false)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Connection"
|
||||
message="Are you sure you want to remove this connection?"
|
||||
confirmLabel="Delete Connection"
|
||||
loading={loading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, type FormEvent } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Module } from '../../types';
|
||||
import { Modal } from '../ui/Modal';
|
||||
@@ -15,7 +16,7 @@ interface ModuleEditPanelProps {
|
||||
}
|
||||
|
||||
export function ModuleEditPanel({ module, open, onClose }: ModuleEditPanelProps) {
|
||||
const { updateModuleLocal } = useRackStore();
|
||||
const { updateModuleLocal, removeModuleLocal } = useRackStore();
|
||||
const [name, setName] = useState(module.name);
|
||||
const [ipAddress, setIpAddress] = useState(module.ipAddress ?? '');
|
||||
const [manufacturer, setManufacturer] = useState(module.manufacturer ?? '');
|
||||
@@ -23,9 +24,12 @@ export function ModuleEditPanel({ module, open, onClose }: ModuleEditPanelProps)
|
||||
const [notes, setNotes] = useState(module.notes ?? '');
|
||||
const [uSize, setUSize] = useState(module.uSize);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setConfirmingDelete(false);
|
||||
setName(module.name);
|
||||
setIpAddress(module.ipAddress ?? '');
|
||||
setManufacturer(module.manufacturer ?? '');
|
||||
@@ -35,6 +39,21 @@ export function ModuleEditPanel({ module, open, onClose }: ModuleEditPanelProps)
|
||||
}
|
||||
}, [open, module]);
|
||||
|
||||
async function handleDelete() {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await apiClient.modules.delete(module.id);
|
||||
removeModuleLocal(module.id);
|
||||
toast.success(`${module.name} removed`);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Delete failed');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setConfirmingDelete(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
@@ -132,13 +151,51 @@ export function ModuleEditPanel({ module, open, onClose }: ModuleEditPanelProps)
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-1">
|
||||
<Button variant="secondary" size="sm" type="button" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" type="submit" loading={loading} disabled={!name.trim()}>
|
||||
Save Changes
|
||||
</Button>
|
||||
<div className="flex items-center justify-between gap-3 pt-1 border-t border-slate-700 mt-1">
|
||||
{/* Delete — left side with inline confirm */}
|
||||
{confirmingDelete ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-red-400">Remove this module?</span>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
type="button"
|
||||
loading={deleting}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => setConfirmingDelete(false)}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 text-xs text-slate-500 hover:text-red-400 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
Delete module
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Save / Cancel — right side */}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" type="button" onClick={onClose} disabled={loading || deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" type="submit" loading={loading} disabled={!name.trim() || deleting}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, type FormEvent } from 'react';
|
||||
import { useState, useEffect, useMemo, type FormEvent } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Port, Vlan, VlanMode } from '../../types';
|
||||
import { Modal } from '../ui/Modal';
|
||||
@@ -14,41 +14,45 @@ interface PortConfigModalProps {
|
||||
}
|
||||
|
||||
export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps) {
|
||||
const { racks, fetchRacks } = useRackStore();
|
||||
const [port, setPort] = useState<Port | null>(null);
|
||||
const { racks, fetchRacks, deleteConnection } = useRackStore();
|
||||
const [vlans, setVlans] = useState<Vlan[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
|
||||
// Synchronously find the port from the global store
|
||||
const port = useMemo(() => {
|
||||
for (const rack of racks) {
|
||||
for (const mod of rack.modules) {
|
||||
const found = mod.ports.find((p) => p.id === portId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [racks, portId]);
|
||||
|
||||
// Form state
|
||||
const [label, setLabel] = useState('');
|
||||
const [mode, setMode] = useState<VlanMode>('ACCESS');
|
||||
const [nativeVlanId, setNativeVlanId] = useState<string>('');
|
||||
const [taggedVlanIds, setTaggedVlanIds] = useState<string[]>([]);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
|
||||
// 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
|
||||
// Reset form state when port is found or changed
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let found: Port | undefined;
|
||||
for (const rack of racks) {
|
||||
for (const mod of rack.modules) {
|
||||
found = mod.ports.find((p) => p.id === portId);
|
||||
if (found) break;
|
||||
}
|
||||
if (found) break;
|
||||
if (port && open) {
|
||||
setLabel(port.label ?? '');
|
||||
setMode(port.mode);
|
||||
setNativeVlanId(port.nativeVlan?.toString() ?? '');
|
||||
setTaggedVlanIds(port.vlans.filter((v) => v.tagged).map((v) => v.vlanId));
|
||||
setNotes(port.notes ?? '');
|
||||
}
|
||||
if (found) {
|
||||
setPort(found);
|
||||
setLabel(found.label ?? '');
|
||||
setMode(found.mode);
|
||||
setNativeVlanId(found.nativeVlan?.toString() ?? '');
|
||||
setTaggedVlanIds(found.vlans.filter((v) => v.tagged).map((v) => v.vlanId));
|
||||
setNotes(found.notes ?? '');
|
||||
}
|
||||
}, [open, portId, racks]);
|
||||
}, [port, open]);
|
||||
|
||||
// Load VLAN list
|
||||
useEffect(() => {
|
||||
@@ -99,10 +103,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 +128,21 @@ export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps)
|
||||
|
||||
if (!port) return null;
|
||||
|
||||
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 (
|
||||
<Modal open={open} onClose={onClose} title={`Port ${port.portNumber} Configuration`} size="md">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
@@ -140,6 +164,35 @@ export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps)
|
||||
/>
|
||||
</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 */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-slate-300">Mode</label>
|
||||
@@ -164,19 +217,29 @@ export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps)
|
||||
{/* Native VLAN */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-slate-300">Native VLAN</label>
|
||||
<select
|
||||
value={nativeVlanId}
|
||||
onChange={(e) => setNativeVlanId(e.target.value)}
|
||||
disabled={loading || fetching}
|
||||
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"
|
||||
>
|
||||
<option value="">— Untagged —</option>
|
||||
{vlans.map((v) => (
|
||||
<option key={v.id} value={v.vlanId.toString()}>
|
||||
VLAN {v.vlanId} — {v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={nativeVlanId}
|
||||
onChange={(e) => setNativeVlanId(e.target.value)}
|
||||
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 key={v.id} value={v.vlanId.toString()}>
|
||||
VLAN {v.vlanId} — {v.name}
|
||||
</option>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* Tagged VLANs — Trunk/Hybrid only */}
|
||||
@@ -192,12 +255,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`}
|
||||
>
|
||||
<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}
|
||||
</button>
|
||||
))}
|
||||
@@ -222,7 +290,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"
|
||||
/>
|
||||
<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
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { useRackStore } from '../../store/useRackStore';
|
||||
|
||||
export function ConnectionLayer() {
|
||||
const { racks, cablingFromPortId, setActiveConfigConnectionId } = useRackStore();
|
||||
const [coords, setCoords] = useState<Record<string, { x: number; y: number }>>({});
|
||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||
const [isShiftPressed, setIsShiftPressed] = useState(false);
|
||||
|
||||
// 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.
|
||||
|
||||
// Use a MutationObserver to detect DOM changes (like modules being added/moved)
|
||||
const observer = new MutationObserver(() => {
|
||||
// Small debounce or check if it was our OWN SVG that changed
|
||||
updateCoords();
|
||||
});
|
||||
|
||||
const canvas = document.querySelector('.rack-planner-canvas');
|
||||
if (canvas) {
|
||||
// DO NOT observe the entire subtree with attributes if it includes the ConnectionLayer
|
||||
// Instead, just watch for module layout changes
|
||||
observer.observe(canvas, { childList: true, subtree: 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]);
|
||||
|
||||
useEffect(() => {
|
||||
const syncShiftState = (event: KeyboardEvent) => {
|
||||
setIsShiftPressed(event.shiftKey);
|
||||
};
|
||||
|
||||
const clearShiftState = () => {
|
||||
setIsShiftPressed(false);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', syncShiftState);
|
||||
window.addEventListener('keyup', syncShiftState);
|
||||
window.addEventListener('blur', clearShiftState);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', syncShiftState);
|
||||
window.removeEventListener('keyup', syncShiftState);
|
||||
window.removeEventListener('blur', clearShiftState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const connections = useMemo(() => {
|
||||
const conns: { id: string; from: string; to: string; color?: string; edgeType?: 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,
|
||||
edgeType: c.edgeType,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
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;
|
||||
|
||||
let d = '';
|
||||
if (conn.edgeType === 'straight') {
|
||||
d = `M ${start.x} ${start.y} L ${end.x} ${end.y}`;
|
||||
} else if (conn.edgeType === 'step') {
|
||||
const midX = start.x + (end.x - start.x) / 2;
|
||||
d = `M ${start.x} ${start.y} L ${midX} ${start.y} L ${midX} ${end.y} L ${end.x} ${end.y}`;
|
||||
} else {
|
||||
// default bezier
|
||||
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);
|
||||
d = `M ${start.x} ${start.y} C ${start.x + curvature} ${start.y}, ${end.x - curvature} ${end.y}, ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<g key={conn.id} className="connection-group">
|
||||
<path
|
||||
d={d}
|
||||
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={d}
|
||||
stroke="transparent"
|
||||
strokeWidth="10"
|
||||
fill="none"
|
||||
className={isShiftPressed ? 'pointer-events-auto cursor-pointer' : 'pointer-events-none'}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label="Edit connection"
|
||||
onClick={(e) => {
|
||||
if (e.shiftKey) {
|
||||
e.stopPropagation();
|
||||
setActiveConfigConnectionId(conn.id);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setActiveConfigConnectionId(conn.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ const ALL_TYPES: ModuleType[] = [
|
||||
function PaletteItem({ type }: { type: ModuleType }) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `palette-${type}`,
|
||||
data: { type },
|
||||
data: { dragType: 'palette', type },
|
||||
});
|
||||
|
||||
const colors = MODULE_TYPE_COLORS[type];
|
||||
@@ -27,7 +27,7 @@ function PaletteItem({ type }: { type: ModuleType }) {
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded border text-left w-full cursor-grab active:cursor-grabbing transition-all select-none',
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded border text-left w-full cursor-grab active:cursor-grabbing transition-all select-none touch-none',
|
||||
colors.bg,
|
||||
colors.border,
|
||||
isDragging ? 'opacity-40' : 'hover:brightness-125'
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { Trash2, GripVertical, GripHorizontal } from 'lucide-react';
|
||||
import { GripHorizontal } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Module } from '../../types';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { MODULE_TYPE_COLORS, MODULE_TYPE_LABELS, U_HEIGHT_PX } from '../../lib/constants';
|
||||
import { Badge } from '../ui/Badge';
|
||||
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
||||
import { MODULE_TYPE_COLORS, U_HEIGHT_PX, PORTS_PER_ROW } from '../../lib/constants';
|
||||
import { ModuleEditPanel } from '../modals/ModuleEditPanel';
|
||||
import { PortConfigModal } from '../modals/PortConfigModal';
|
||||
import { useRackStore } from '../../store/useRackStore';
|
||||
@@ -17,13 +15,9 @@ interface ModuleBlockProps {
|
||||
}
|
||||
|
||||
export function ModuleBlock({ module }: ModuleBlockProps) {
|
||||
const { racks, removeModuleLocal, updateModuleLocal } = useRackStore();
|
||||
const { racks, updateModuleLocal, setActiveConfigPortId } = useRackStore();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deletingLoading, setDeletingLoading] = useState(false);
|
||||
const [portModalOpen, setPortModalOpen] = useState(false);
|
||||
const [selectedPortId, setSelectedPortId] = useState<string | null>(null);
|
||||
|
||||
// Resize state
|
||||
const [previewUSize, setPreviewUSize] = useState<number | null>(null);
|
||||
@@ -49,6 +43,29 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
|
||||
const height = displayUSize * U_HEIGHT_PX;
|
||||
const hasPorts = module.ports.length > 0;
|
||||
|
||||
// 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)[] = [];
|
||||
if (mainPorts.length > 0) {
|
||||
for (let i = 0; i < mainPorts.length; i += PORTS_PER_ROW) {
|
||||
portRows.push(mainPorts.slice(i, i + PORTS_PER_ROW));
|
||||
}
|
||||
} else if (sidePorts.length > 0) {
|
||||
portRows.push([]);
|
||||
}
|
||||
|
||||
const availableForPorts = height - 16;
|
||||
const maxRows = Math.max(1, Math.floor(availableForPorts / 14));
|
||||
const visibleRows = portRows.length > 0 ? portRows.slice(0, maxRows) : [];
|
||||
const hiddenPortCount = mainPorts.length - (visibleRows.length > 0 ? visibleRows.flat().length : 0);
|
||||
|
||||
// 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 => {
|
||||
const rack = racks.find((r) => r.id === module.rackId);
|
||||
@@ -96,38 +113,55 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setDeletingLoading(true);
|
||||
try {
|
||||
await apiClient.modules.delete(module.id);
|
||||
removeModuleLocal(module.id);
|
||||
toast.success(`${module.name} removed`);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Delete failed');
|
||||
} finally {
|
||||
setDeletingLoading(false);
|
||||
setConfirmDeleteOpen(false);
|
||||
}
|
||||
function openPort(portId: string) {
|
||||
setActiveConfigPortId(portId);
|
||||
}
|
||||
|
||||
function openPort(portId: string) {
|
||||
setSelectedPortId(portId);
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={cn(
|
||||
'relative w-full border-l-2 select-none flex flex-col justify-between px-2 py-0.5 overflow-hidden transition-opacity',
|
||||
'module-block relative w-full border-l-4 select-none overflow-hidden transition-opacity',
|
||||
colors.bg,
|
||||
colors.border,
|
||||
isDragging ? 'opacity-0' : 'cursor-pointer',
|
||||
isDragging ? 'opacity-0 pointer-events-none' : 'cursor-grab active:cursor-grabbing',
|
||||
!isDragging && hovered && 'brightness-110',
|
||||
previewUSize !== null && 'ring-1 ring-white/30'
|
||||
)}
|
||||
style={{ height }}
|
||||
title={`${module.name}${module.ipAddress ? ` — ${module.ipAddress}` : ''}`}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={() => !isDragging && !isResizing.current && setEditOpen(true)}
|
||||
@@ -136,123 +170,113 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
|
||||
aria-label={`Edit ${module.name}`}
|
||||
onKeyDown={(e) => e.key === 'Enter' && setEditOpen(true)}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className="absolute left-0 top-0 bottom-6 w-4 flex items-start justify-center pt-1.5 cursor-grab active:cursor-grabbing text-white/30 hover:text-white/70 transition-colors z-10 touch-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Drag ${module.name}`}
|
||||
>
|
||||
<GripVertical size={10} />
|
||||
</div>
|
||||
{/* Port grid — primary face content */}
|
||||
{hasPorts && previewUSize === null ? (
|
||||
<div className="flex flex-col gap-1.5 px-2 pt-1.5">
|
||||
{visibleRows.map((row, rowIdx) => (
|
||||
<div key={rowIdx} className="flex items-center w-full min-h-[12px]">
|
||||
{/* Standard ports Group */}
|
||||
<div className="flex gap-[3px] flex-wrap">
|
||||
{row.map((port) => {
|
||||
const hasVlan = port.vlans.length > 0;
|
||||
const vlanColor = hasVlan
|
||||
? port.mode === 'ACCESS'
|
||||
? port.vlans[0]?.vlan?.color || '#10b981'
|
||||
: '#a78bfa'
|
||||
: '#475569';
|
||||
const isCablingSource = cablingFromPortId === port.id;
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex items-center gap-1 min-w-0 pl-3">
|
||||
<span className="text-xs font-semibold text-white truncate flex-1">{module.name}</span>
|
||||
<Badge variant="slate" className="text-[10px] shrink-0">
|
||||
{MODULE_TYPE_LABELS[module.type]}
|
||||
</Badge>
|
||||
</div>
|
||||
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}\n${port.portType}${port.label ? ` · ${port.label}` : ''}`}
|
||||
style={{ backgroundColor: vlanColor }}
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full border border-black/20 hover:scale-125 transition-all outline-none',
|
||||
isCablingSource && 'ring-2 ring-white ring-offset-1 ring-offset-slate-900 animate-pulse'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{module.ipAddress && (
|
||||
<div className="text-[10px] text-slate-300 font-mono truncate">{module.ipAddress}</div>
|
||||
{/* SFP/WAN Group (push to right) */}
|
||||
{rowIdx === 0 && sidePorts.length > 0 && (
|
||||
<div className="flex gap-1.5 ml-auto border-l border-slate-700/50 pl-1.5 h-3 items-center">
|
||||
{sidePorts.map((port) => {
|
||||
const hasVlan = port.vlans.length > 0;
|
||||
const isSfp = port.portType?.includes('SFP');
|
||||
const isWan = port.portType === 'WAN';
|
||||
|
||||
const vlanColor = hasVlan
|
||||
? port.vlans[0]?.vlan?.color || '#3b82f6'
|
||||
: isWan ? '#2563eb' : '#94a3b8';
|
||||
|
||||
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)}
|
||||
title={`${port.portType} ${port.portNumber}`}
|
||||
style={{ backgroundColor: vlanColor }}
|
||||
className={cn(
|
||||
'w-2.5 h-2.5 transition-transform hover:scale-125 border border-black/40',
|
||||
isSfp ? 'rounded-none rotate-45 scale-75' : 'rounded-full ring-1 ring-white/10',
|
||||
isCablingSource && 'ring-2 ring-white ring-offset-1 ring-offset-slate-900 animate-pulse'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{hiddenPortCount > 0 && (
|
||||
<span className="text-[10px] text-slate-500">+{hiddenPortCount} ports</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
previewUSize === null && (
|
||||
<div className="px-2 pt-1.5 text-[10px] text-white/30 italic select-none">no ports</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* U-size preview label during resize */}
|
||||
{/* Resize preview label */}
|
||||
{previewUSize !== null && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<span className="text-xs font-bold text-white/70 bg-black/30 px-1.5 py-0.5 rounded">
|
||||
<span className="text-xs font-bold text-white/80 bg-black/40 px-2 py-0.5 rounded">
|
||||
{previewUSize}U
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Port dots — only if module has ports and enough height */}
|
||||
{hasPorts && height >= 28 && previewUSize === null && (
|
||||
<div
|
||||
className="flex flex-wrap gap-0.5 mt-0.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{module.ports.slice(0, 32).map((port) => {
|
||||
const hasVlan = port.vlans.length > 0;
|
||||
return (
|
||||
<button
|
||||
key={port.id}
|
||||
onClick={() => openPort(port.id)}
|
||||
aria-label={`Port ${port.portNumber}`}
|
||||
className={cn(
|
||||
'w-2.5 h-2.5 rounded-sm border transition-colors',
|
||||
hasVlan
|
||||
? 'bg-green-400 border-green-500 hover:bg-green-300'
|
||||
: 'bg-slate-600 border-slate-500 hover:bg-slate-400'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{module.ports.length > 32 && (
|
||||
<span className="text-[9px] text-slate-400">+{module.ports.length - 32}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete button — hover only */}
|
||||
{hovered && previewUSize === null && (
|
||||
<button
|
||||
className="absolute top-0.5 right-0.5 p-0.5 rounded bg-red-800/80 hover:bg-red-600 text-white transition-colors z-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDeleteOpen(true);
|
||||
}}
|
||||
aria-label={`Delete ${module.name}`}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Resize handle — bottom edge */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-0 left-0 right-0 h-3 flex items-center justify-center z-20',
|
||||
'cursor-ns-resize touch-none',
|
||||
hovered || previewUSize !== null
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 hover:opacity-100',
|
||||
hovered || previewUSize !== null ? 'opacity-100' : 'opacity-0 hover:opacity-100',
|
||||
'transition-opacity'
|
||||
)}
|
||||
onPointerDown={handleResizePointerDown}
|
||||
onPointerDown={(e) => { e.stopPropagation(); handleResizePointerDown(e); }}
|
||||
onPointerMove={handleResizePointerMove}
|
||||
onPointerUp={handleResizePointerUp}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Resize module"
|
||||
title="Drag to resize"
|
||||
>
|
||||
<GripHorizontal size={10} className="text-white/50 pointer-events-none" />
|
||||
<GripHorizontal size={9} className="text-white/40 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModuleEditPanel module={module} open={editOpen} onClose={() => setEditOpen(false)} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDeleteOpen}
|
||||
onClose={() => setConfirmDeleteOpen(false)}
|
||||
onConfirm={handleDelete}
|
||||
title="Remove Module"
|
||||
message={`Remove "${module.name}" from the rack? This will also delete all associated port configuration.`}
|
||||
confirmLabel="Remove"
|
||||
loading={deletingLoading}
|
||||
/>
|
||||
|
||||
{selectedPortId && (
|
||||
<PortConfigModal
|
||||
portId={selectedPortId}
|
||||
open={portModalOpen}
|
||||
onClose={() => {
|
||||
setPortModalOpen(false);
|
||||
setSelectedPortId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import { Trash2, MapPin, GripVertical } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Rack } from '../../types';
|
||||
import { buildOccupancyMap } from '../../lib/utils';
|
||||
import { U_HEIGHT_PX } from '../../lib/constants';
|
||||
import { ModuleBlock } from './ModuleBlock';
|
||||
import { RackSlot } from './RackSlot';
|
||||
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
||||
@@ -12,11 +12,11 @@ import { useRackStore } from '../../store/useRackStore';
|
||||
|
||||
interface RackColumnProps {
|
||||
rack: Rack;
|
||||
/** ID of the module currently being dragged — render its slots as droppable ghosts. */
|
||||
draggingModuleId?: string | null;
|
||||
/** Slot currently hovered by a drag — passed down to RackSlot for blue highlight. */
|
||||
hoverSlot?: { rackId: string; uPosition: number } | null;
|
||||
}
|
||||
|
||||
export function RackColumn({ rack, draggingModuleId }: RackColumnProps) {
|
||||
export function RackColumn({ rack, hoverSlot }: RackColumnProps) {
|
||||
const { deleteRack } = useRackStore();
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
@@ -33,9 +33,6 @@ export function RackColumn({ rack, draggingModuleId }: RackColumnProps) {
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
};
|
||||
|
||||
const occupancy = buildOccupancyMap(rack.modules);
|
||||
const renderedModuleIds = new Set<string>();
|
||||
|
||||
async function handleDelete() {
|
||||
setDeleting(true);
|
||||
try {
|
||||
@@ -51,7 +48,7 @@ export function RackColumn({ rack, draggingModuleId }: RackColumnProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={setNodeRef} style={style} className="flex flex-col min-w-[200px] w-48 shrink-0">
|
||||
<div ref={setNodeRef} style={style} className="flex flex-col min-w-[384px] w-96 shrink-0">
|
||||
{/* Rack header — drag handle for reorder */}
|
||||
<div className="flex items-center gap-1 bg-slate-700 border border-slate-600 rounded-t-lg px-2 py-1.5 group">
|
||||
{/* Drag handle */}
|
||||
@@ -84,31 +81,30 @@ export function RackColumn({ rack, draggingModuleId }: RackColumnProps) {
|
||||
</div>
|
||||
|
||||
{/* U-slot body */}
|
||||
<div className="border-x border-slate-600 bg-[#1e2433] flex flex-col">
|
||||
{Array.from({ length: rack.totalU }, (_, i) => i + 1).map((u) => {
|
||||
const moduleId = occupancy.get(u) ?? null;
|
||||
<div
|
||||
className="relative border-x border-slate-600 bg-[#1e2433]"
|
||||
style={{ height: rack.totalU * U_HEIGHT_PX }}
|
||||
>
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
{Array.from({ length: rack.totalU }, (_, i) => i + 1).map((u) => (
|
||||
<RackSlot
|
||||
key={u}
|
||||
rackId={rack.id}
|
||||
uPosition={u}
|
||||
isOver={hoverSlot?.rackId === rack.id && hoverSlot?.uPosition === u}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
if (moduleId) {
|
||||
const module = rack.modules.find((m) => m.id === moduleId);
|
||||
if (!module) return null;
|
||||
|
||||
// Only render ModuleBlock at its top U
|
||||
if (module.uPosition !== u) return null;
|
||||
if (renderedModuleIds.has(moduleId)) return null;
|
||||
renderedModuleIds.add(moduleId);
|
||||
|
||||
// If this module is being dragged, show empty droppable slot(s) instead
|
||||
if (moduleId === draggingModuleId) {
|
||||
return (
|
||||
<RackSlot key={`ghost-${u}`} rackId={rack.id} uPosition={u} />
|
||||
);
|
||||
}
|
||||
|
||||
return <ModuleBlock key={module.id} module={module} />;
|
||||
}
|
||||
|
||||
return <RackSlot key={u} rackId={rack.id} uPosition={u} />;
|
||||
})}
|
||||
{rack.modules.map((module) => (
|
||||
<div
|
||||
key={module.id}
|
||||
className="absolute left-0 right-0 z-10"
|
||||
style={{ top: (module.uPosition - 1) * U_HEIGHT_PX }}
|
||||
>
|
||||
<ModuleBlock module={module} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Rack footer */}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragStartEvent,
|
||||
@@ -15,7 +16,10 @@ import { apiClient } from '../../api/client';
|
||||
import { RackToolbar } from './RackToolbar';
|
||||
import { RackColumn } from './RackColumn';
|
||||
import { DevicePalette } from './DevicePalette';
|
||||
import { ConnectionLayer } from './ConnectionLayer';
|
||||
import { AddModuleModal } from '../modals/AddModuleModal';
|
||||
import { PortConfigModal } from '../modals/PortConfigModal';
|
||||
import { ConnectionConfigModal } from '../modals/ConnectionConfigModal';
|
||||
import { RackSkeleton } from '../ui/Skeleton';
|
||||
import type { ModuleType } from '../../types';
|
||||
import { MODULE_TYPE_COLORS, MODULE_TYPE_LABELS } from '../../lib/constants';
|
||||
@@ -27,6 +31,11 @@ interface PendingDrop {
|
||||
type: ModuleType;
|
||||
}
|
||||
|
||||
interface HoverSlot {
|
||||
rackId: string;
|
||||
uPosition: number;
|
||||
}
|
||||
|
||||
function DragOverlayItem({ type }: { type: ModuleType }) {
|
||||
const colors = MODULE_TYPE_COLORS[type];
|
||||
return (
|
||||
@@ -50,8 +59,35 @@ function ModuleDragOverlay({ label }: { label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which rack slot (if any) is under the pointer during a drag.
|
||||
*
|
||||
* Strategy: elementFromPoint at the current pointer coordinates.
|
||||
* - All ModuleBlocks get pointer-events:none via the body.rack-dragging CSS rule,
|
||||
* so elementFromPoint sees through them to the slot element beneath.
|
||||
* - DragOverlay has pointer-events:none natively (dnd-kit).
|
||||
* - RackSlot divs carry data-rack-id / data-u-pos attributes that we read here.
|
||||
*/
|
||||
function resolveSlotFromPoint(clientX: number, clientY: number): HoverSlot | null {
|
||||
const el = document.elementFromPoint(clientX, clientY);
|
||||
if (!el) return null;
|
||||
|
||||
const slotEl = el.closest('[data-rack-id][data-u-pos]') as HTMLElement | null;
|
||||
if (!slotEl) return null;
|
||||
|
||||
const rackId = slotEl.dataset.rackId;
|
||||
const uPos = parseInt(slotEl.dataset.uPos ?? '', 10);
|
||||
if (!rackId || isNaN(uPos)) return null;
|
||||
|
||||
return { rackId, uPosition: uPos };
|
||||
}
|
||||
|
||||
const POINTER_SENSOR_OPTIONS = {
|
||||
activationConstraint: { distance: 6 },
|
||||
};
|
||||
|
||||
export function RackPlanner() {
|
||||
const { racks, loading, fetchRacks, moveModule, updateRack } = useRackStore();
|
||||
const { racks, loading, fetchRacks, moveModule, activeConfigPortId, setActiveConfigPortId, activeConfigConnectionId, setActiveConfigConnectionId } = useRackStore();
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Drag state
|
||||
@@ -60,56 +96,103 @@ export function RackPlanner() {
|
||||
const [draggingModuleId, setDraggingModuleId] = useState<string | null>(null);
|
||||
const [pendingDrop, setPendingDrop] = useState<PendingDrop | null>(null);
|
||||
|
||||
// hoverSlot drives the blue highlight on slots during drag.
|
||||
// hoverSlotRef is the reliable read-path inside async handleDragEnd
|
||||
// (avoids stale-closure issues with state).
|
||||
const [hoverSlot, setHoverSlot] = useState<HoverSlot | null>(null);
|
||||
const hoverSlotRef = useRef<HoverSlot | null>(null);
|
||||
|
||||
// Tracks whether ANY module/palette drag is in progress — used to
|
||||
// activate the body.rack-dragging CSS class and the pointermove listener.
|
||||
const isDraggingAnyRef = useRef(false);
|
||||
|
||||
function updateHoverSlot(slot: HoverSlot | null) {
|
||||
hoverSlotRef.current = slot;
|
||||
setHoverSlot(slot);
|
||||
}
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } })
|
||||
useSensor(PointerSensor, POINTER_SENSOR_OPTIONS)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRacks().catch(() => toast.error('Failed to load racks'));
|
||||
}, [fetchRacks]);
|
||||
|
||||
/**
|
||||
* Native pointermove listener registered once on mount.
|
||||
* Only runs while isDraggingAnyRef is true — gives us the exact cursor
|
||||
* position without any reconstruction arithmetic, so resolveSlotFromPoint
|
||||
* is always called with accurate coordinates.
|
||||
*/
|
||||
useEffect(() => {
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!isDraggingAnyRef.current) return;
|
||||
const slot = resolveSlotFromPoint(e.clientX, e.clientY);
|
||||
|
||||
const prev = hoverSlotRef.current;
|
||||
if (prev?.rackId !== slot?.rackId || prev?.uPosition !== slot?.uPosition) {
|
||||
hoverSlotRef.current = slot;
|
||||
setHoverSlot(slot);
|
||||
}
|
||||
}
|
||||
|
||||
// Capture phase so we get the event before any element can stop propagation.
|
||||
window.addEventListener('pointermove', onPointerMove, { capture: true });
|
||||
return () => window.removeEventListener('pointermove', onPointerMove, { capture: true });
|
||||
}, []);
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
const data = event.active.data.current as Record<string, unknown>;
|
||||
if (data?.dragType === 'palette') {
|
||||
setActivePaletteType(data.type as ModuleType);
|
||||
isDraggingAnyRef.current = true;
|
||||
document.body.classList.add('rack-dragging');
|
||||
} else if (data?.dragType === 'module') {
|
||||
setDraggingModuleId(data.moduleId as string);
|
||||
setActiveDragModuleLabel(data.label as string);
|
||||
isDraggingAnyRef.current = true;
|
||||
document.body.classList.add('rack-dragging');
|
||||
}
|
||||
updateHoverSlot(null);
|
||||
}
|
||||
|
||||
async function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
|
||||
// Stop native hover tracking and remove body class FIRST
|
||||
isDraggingAnyRef.current = false;
|
||||
document.body.classList.remove('rack-dragging');
|
||||
|
||||
// Capture hoverSlot BEFORE resetting state
|
||||
const slot = hoverSlotRef.current;
|
||||
|
||||
setActivePaletteType(null);
|
||||
setActiveDragModuleLabel(null);
|
||||
setDraggingModuleId(null);
|
||||
|
||||
if (!over) return;
|
||||
updateHoverSlot(null);
|
||||
|
||||
const dragData = active.data.current as Record<string, unknown>;
|
||||
const dropData = over.data.current as Record<string, unknown> | undefined;
|
||||
|
||||
// --- Palette → slot: open AddModuleModal pre-filled ---
|
||||
if (dragData?.dragType === 'palette' && dropData?.dropType === 'slot') {
|
||||
if (dragData?.dragType === 'palette' && slot) {
|
||||
setPendingDrop({
|
||||
type: dragData.type as ModuleType,
|
||||
rackId: dropData.rackId as string,
|
||||
uPosition: dropData.uPosition as number,
|
||||
rackId: slot.rackId,
|
||||
uPosition: slot.uPosition,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Module → slot: move the module ---
|
||||
if (dragData?.dragType === 'module' && dropData?.dropType === 'slot') {
|
||||
if (dragData?.dragType === 'module' && slot) {
|
||||
const moduleId = dragData.moduleId as string;
|
||||
const targetRackId = dropData.rackId as string;
|
||||
const targetUPosition = dropData.uPosition as number;
|
||||
|
||||
// No-op if dropped on own position
|
||||
if (dragData.fromRackId === targetRackId && dragData.fromUPosition === targetUPosition) return;
|
||||
if (dragData.fromRackId === slot.rackId && dragData.fromUPosition === slot.uPosition) return;
|
||||
|
||||
try {
|
||||
await moveModule(moduleId, targetRackId, targetUPosition);
|
||||
await moveModule(moduleId, slot.rackId, slot.uPosition);
|
||||
toast.success('Module moved');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Move failed');
|
||||
@@ -118,24 +201,26 @@ export function RackPlanner() {
|
||||
}
|
||||
|
||||
// --- Rack header → rack header: reorder racks ---
|
||||
if (dragData?.dragType === 'rack' && over.data.current?.dragType === 'rack') {
|
||||
if (!over) return;
|
||||
const dropData = over.data.current as Record<string, unknown> | undefined;
|
||||
if (dragData?.dragType === 'rack' && dropData?.dragType === 'rack') {
|
||||
const oldIndex = racks.findIndex((r) => r.id === active.id);
|
||||
const newIndex = racks.findIndex((r) => r.id === over.id);
|
||||
if (oldIndex === newIndex) return;
|
||||
|
||||
const reordered = arrayMove(racks, oldIndex, newIndex);
|
||||
// Persist new displayOrder values
|
||||
try {
|
||||
await Promise.all(
|
||||
reordered.map((rack, idx) =>
|
||||
rack.displayOrder !== idx ? apiClient.racks.update(rack.id, { displayOrder: idx }) : Promise.resolve(rack)
|
||||
rack.displayOrder !== idx
|
||||
? apiClient.racks.update(rack.id, { displayOrder: idx })
|
||||
: Promise.resolve(rack)
|
||||
)
|
||||
);
|
||||
// Refresh store to sync
|
||||
await fetchRacks();
|
||||
} catch {
|
||||
toast.error('Failed to save rack order');
|
||||
await fetchRacks(); // rollback
|
||||
await fetchRacks();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,14 +228,19 @@ export function RackPlanner() {
|
||||
const rackIds = racks.map((r) => r.id);
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex flex-col h-screen bg-[#0f1117]">
|
||||
<RackToolbar rackCanvasRef={canvasRef} />
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<DevicePalette />
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="flex-1 overflow-auto relative rack-planner-canvas">
|
||||
{loading ? (
|
||||
<RackSkeleton />
|
||||
) : racks.length === 0 ? (
|
||||
@@ -170,23 +260,30 @@ export function RackPlanner() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<SortableContext items={rackIds} strategy={horizontalListSortingStrategy}>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="flex gap-4 p-4 min-h-full items-start"
|
||||
style={{ background: '#0f1117' }}
|
||||
>
|
||||
{racks.map((rack) => (
|
||||
<RackColumn key={rack.id} rack={rack} draggingModuleId={draggingModuleId} />
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
<>
|
||||
<SortableContext items={rackIds} strategy={horizontalListSortingStrategy}>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="flex gap-4 p-4 min-h-full items-start"
|
||||
style={{ background: '#0f1117' }}
|
||||
>
|
||||
{racks.map((rack) => (
|
||||
<RackColumn
|
||||
key={rack.id}
|
||||
rack={rack}
|
||||
hoverSlot={hoverSlot}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
<ConnectionLayer />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DragOverlay dropAnimation={null}>
|
||||
<DragOverlay dropAnimation={null} className="pointer-events-none" zIndex={999} style={{ pointerEvents: 'none' }}>
|
||||
{activePaletteType && <DragOverlayItem type={activePaletteType} />}
|
||||
{activeDragModuleLabel && <ModuleDragOverlay label={activeDragModuleLabel} />}
|
||||
</DragOverlay>
|
||||
@@ -200,6 +297,22 @@ export function RackPlanner() {
|
||||
initialType={pendingDrop.type}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeConfigPortId && (
|
||||
<PortConfigModal
|
||||
open={!!activeConfigPortId}
|
||||
portId={activeConfigPortId}
|
||||
onClose={() => setActiveConfigPortId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeConfigConnectionId && (
|
||||
<ConnectionConfigModal
|
||||
open={!!activeConfigConnectionId}
|
||||
connectionId={activeConfigConnectionId}
|
||||
onClose={() => setActiveConfigConnectionId(null)}
|
||||
/>
|
||||
)}
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { U_HEIGHT_PX } from '../../lib/constants';
|
||||
import { cn } from '../../lib/utils';
|
||||
@@ -8,20 +7,19 @@ import { AddModuleModal } from '../modals/AddModuleModal';
|
||||
interface RackSlotProps {
|
||||
rackId: string;
|
||||
uPosition: number;
|
||||
/** Passed from RackPlanner via RackColumn — true when a drag is hovering this slot */
|
||||
isOver?: boolean;
|
||||
}
|
||||
|
||||
export function RackSlot({ rackId, uPosition }: RackSlotProps) {
|
||||
export function RackSlot({ rackId, uPosition, isOver = false }: RackSlotProps) {
|
||||
const [addModuleOpen, setAddModuleOpen] = useState(false);
|
||||
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: `slot-${rackId}-${uPosition}`,
|
||||
data: { dropType: 'slot', rackId, uPosition },
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
// Data attributes let RackPlanner's onDragMove identify this slot via elementFromPoint
|
||||
data-rack-id={rackId}
|
||||
data-u-pos={String(uPosition)}
|
||||
className={cn(
|
||||
'w-full border border-dashed transition-colors group cursor-pointer flex items-center justify-between px-2',
|
||||
isOver
|
||||
|
||||
@@ -28,3 +28,15 @@
|
||||
height: 1.75rem; /* 28px per U */
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* During any rack drag, make every module-block and ALL of its children
|
||||
* transparent to pointer-events so that document.elementFromPoint() can
|
||||
* "see through" them to the RackSlot elements underneath.
|
||||
* The `!important` is necessary because individual elements (port buttons,
|
||||
* resize handle) carry their own pointer-events values.
|
||||
*/
|
||||
body.rack-dragging .module-block,
|
||||
body.rack-dragging .module-block * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,10 @@ export const MODULE_TYPE_COLORS: Record<ModuleType, { bg: string; border: string
|
||||
};
|
||||
|
||||
// ---- U-slot height in px (used for layout calculations) ----
|
||||
export const U_HEIGHT_PX = 28;
|
||||
export const U_HEIGHT_PX = 44;
|
||||
|
||||
// ---- Ports rendered per row in ModuleBlock ----
|
||||
export const PORTS_PER_ROW = 24;
|
||||
|
||||
// ---- Default rack size ----
|
||||
export const DEFAULT_RACK_U = 42;
|
||||
|
||||
@@ -2,6 +2,17 @@ import { create } from 'zustand';
|
||||
import type { ServiceMap, ServiceMapSummary } from '../types';
|
||||
import { apiClient } from '../api/client';
|
||||
|
||||
const LAST_MAP_KEY = 'rackmapper:lastMapId';
|
||||
|
||||
function saveLastMapId(id: string | null) {
|
||||
if (id) localStorage.setItem(LAST_MAP_KEY, id);
|
||||
else localStorage.removeItem(LAST_MAP_KEY);
|
||||
}
|
||||
|
||||
function getLastMapId(): string | null {
|
||||
return localStorage.getItem(LAST_MAP_KEY);
|
||||
}
|
||||
|
||||
interface MapState {
|
||||
maps: ServiceMapSummary[];
|
||||
activeMap: ServiceMap | null;
|
||||
@@ -13,7 +24,7 @@ interface MapState {
|
||||
setActiveMap: (map: ServiceMap | null) => void;
|
||||
}
|
||||
|
||||
export const useMapStore = create<MapState>((set) => ({
|
||||
export const useMapStore = create<MapState>((set, get) => ({
|
||||
maps: [],
|
||||
activeMap: null,
|
||||
loading: false,
|
||||
@@ -23,6 +34,15 @@ export const useMapStore = create<MapState>((set) => ({
|
||||
try {
|
||||
const maps = await apiClient.maps.list();
|
||||
set({ maps, loading: false });
|
||||
|
||||
// Auto-restore the last active map after loading the list
|
||||
const lastId = getLastMapId();
|
||||
if (lastId && maps.some((m) => m.id === lastId)) {
|
||||
await get().loadMap(lastId);
|
||||
} else if (maps.length === 1) {
|
||||
// Convenience: auto-load if there's only one map
|
||||
await get().loadMap(maps[0].id);
|
||||
}
|
||||
} catch {
|
||||
set({ loading: false });
|
||||
throw new Error('Failed to load maps');
|
||||
@@ -33,6 +53,7 @@ export const useMapStore = create<MapState>((set) => ({
|
||||
set({ loading: true });
|
||||
try {
|
||||
const map = await apiClient.maps.get(id);
|
||||
saveLastMapId(id);
|
||||
set({ activeMap: map, loading: false });
|
||||
} catch {
|
||||
set({ loading: false });
|
||||
@@ -42,17 +63,26 @@ export const useMapStore = create<MapState>((set) => ({
|
||||
|
||||
createMap: async (name, description) => {
|
||||
const map = await apiClient.maps.create({ name, description });
|
||||
set((s) => ({ maps: [{ id: map.id, name: map.name, description: map.description, createdAt: map.createdAt, updatedAt: map.updatedAt }, ...s.maps] }));
|
||||
set((s) => ({
|
||||
maps: [
|
||||
{ id: map.id, name: map.name, description: map.description, createdAt: map.createdAt, updatedAt: map.updatedAt },
|
||||
...s.maps,
|
||||
],
|
||||
}));
|
||||
return map;
|
||||
},
|
||||
|
||||
deleteMap: async (id) => {
|
||||
await apiClient.maps.delete(id);
|
||||
if (getLastMapId() === id) saveLastMapId(null);
|
||||
set((s) => ({
|
||||
maps: s.maps.filter((m) => m.id !== id),
|
||||
activeMap: s.activeMap?.id === id ? null : s.activeMap,
|
||||
}));
|
||||
},
|
||||
|
||||
setActiveMap: (map) => set({ activeMap: map }),
|
||||
setActiveMap: (map) => {
|
||||
saveLastMapId(map?.id ?? null);
|
||||
set({ activeMap: map });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -19,6 +19,18 @@ interface RackState {
|
||||
removeModuleLocal: (moduleId: string) => void;
|
||||
// Selection
|
||||
setSelectedModule: (id: string | null) => void;
|
||||
// Cabling
|
||||
cablingFromPortId: string | null;
|
||||
setCablingFromPortId: (id: string | null) => void;
|
||||
createConnection: (fromPortId: string, toPortId: string) => Promise<void>;
|
||||
updateConnection: (id: string, data: Partial<{ color: string; label: string; edgeType: string }>) => Promise<void>;
|
||||
deleteConnection: (id: string) => Promise<void>;
|
||||
// Port Config Global Modal
|
||||
activeConfigPortId: string | null;
|
||||
setActiveConfigPortId: (id: string | null) => void;
|
||||
// Connection Config Global Modal
|
||||
activeConfigConnectionId: string | null;
|
||||
setActiveConfigConnectionId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const useRackStore = create<RackState>((set, get) => ({
|
||||
@@ -106,4 +118,33 @@ export const useRackStore = create<RackState>((set, get) => ({
|
||||
},
|
||||
|
||||
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 });
|
||||
},
|
||||
|
||||
updateConnection: async (id, data) => {
|
||||
await apiClient.connections.update(id, data);
|
||||
const racks = await apiClient.racks.list();
|
||||
set({ racks });
|
||||
},
|
||||
|
||||
activeConfigPortId: null,
|
||||
setActiveConfigPortId: (id) => set({ activeConfigPortId: id }),
|
||||
|
||||
activeConfigConnectionId: null,
|
||||
setActiveConfigConnectionId: (id) => set({ activeConfigConnectionId: id }),
|
||||
}));
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -56,6 +56,19 @@ export interface Port {
|
||||
nativeVlan?: number;
|
||||
vlans: PortVlanAssignment[];
|
||||
notes?: string;
|
||||
// Physically connected links (patch cables)
|
||||
sourceConnections?: Connection[];
|
||||
targetConnections?: Connection[];
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
id: string;
|
||||
fromPortId: string;
|
||||
toPortId: string;
|
||||
color?: string;
|
||||
label?: string;
|
||||
edgeType?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Module {
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3001
|
||||
- DATABASE_URL=file:./data/rackmapper.db
|
||||
- DATABASE_URL=file:/app/data/rackmapper.db
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "[entrypoint] RackMapper starting..."
|
||||
|
||||
# Ensure the data directory exists and is writable
|
||||
mkdir -p /app/data
|
||||
echo "[entrypoint] Data directory: $(ls -la /app/data)"
|
||||
|
||||
# Run migrations (creates the SQLite file if it doesn't exist)
|
||||
echo "[entrypoint] Running database migrations..."
|
||||
npx prisma migrate deploy
|
||||
echo "[entrypoint] Migrations complete."
|
||||
|
||||
# Start the server
|
||||
echo "[entrypoint] Starting server..."
|
||||
exec node dist/server/index.js
|
||||
@@ -0,0 +1,43 @@
|
||||
const js = require('@eslint/js');
|
||||
const globals = require('globals');
|
||||
const tseslint = require('typescript-eslint');
|
||||
|
||||
module.exports = tseslint.config(
|
||||
{
|
||||
ignores: ['dist/**', 'client/**', 'node_modules/**'],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
{
|
||||
files: ['server/**/*.ts', 'scripts/**/*.ts', 'prisma/seed.ts'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-misused-promises': [
|
||||
'error',
|
||||
{
|
||||
checksVoidReturn: {
|
||||
arguments: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
},
|
||||
}
|
||||
);
|
||||
Generated
+386
-3
@@ -17,6 +17,7 @@
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/cors": "^2.8.17",
|
||||
@@ -25,11 +26,13 @@
|
||||
"@types/node": "^22.9.0",
|
||||
"concurrently": "^9.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"globals": "^17.4.0",
|
||||
"nodemon": "^3.1.7",
|
||||
"prettier": "^3.3.3",
|
||||
"prisma": "^5.22.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.57.2",
|
||||
"vitest": "^2.1.5"
|
||||
}
|
||||
},
|
||||
@@ -582,6 +585,19 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/globals": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
|
||||
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.39.4",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
|
||||
@@ -1276,6 +1292,288 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.57.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz",
|
||||
"integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.57.2",
|
||||
"@typescript-eslint/type-utils": "8.57.2",
|
||||
"@typescript-eslint/utils": "8.57.2",
|
||||
"@typescript-eslint/visitor-keys": "8.57.2",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.57.2",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.57.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz",
|
||||
"integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.57.2",
|
||||
"@typescript-eslint/types": "8.57.2",
|
||||
"@typescript-eslint/typescript-estree": "8.57.2",
|
||||
"@typescript-eslint/visitor-keys": "8.57.2",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.57.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz",
|
||||
"integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.57.2",
|
||||
"@typescript-eslint/types": "^8.57.2",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.57.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz",
|
||||
"integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.57.2",
|
||||
"@typescript-eslint/visitor-keys": "8.57.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.57.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz",
|
||||
"integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.57.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz",
|
||||
"integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.57.2",
|
||||
"@typescript-eslint/typescript-estree": "8.57.2",
|
||||
"@typescript-eslint/utils": "8.57.2",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.57.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz",
|
||||
"integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.57.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz",
|
||||
"integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.57.2",
|
||||
"@typescript-eslint/tsconfig-utils": "8.57.2",
|
||||
"@typescript-eslint/types": "8.57.2",
|
||||
"@typescript-eslint/visitor-keys": "8.57.2",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"ts-api-utils": "^2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.57.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz",
|
||||
"integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.57.2",
|
||||
"@typescript-eslint/types": "8.57.2",
|
||||
"@typescript-eslint/typescript-estree": "8.57.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.57.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz",
|
||||
"integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.57.2",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
|
||||
@@ -2741,9 +3039,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
|
||||
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
|
||||
"version": "17.4.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz",
|
||||
"integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4337,6 +4635,54 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tinypool": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||
@@ -4409,6 +4755,19 @@
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -4488,6 +4847,30 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.57.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz",
|
||||
"integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.57.2",
|
||||
"@typescript-eslint/parser": "8.57.2",
|
||||
"@typescript-eslint/typescript-estree": "8.57.2",
|
||||
"@typescript-eslint/utils": "8.57.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undefsafe": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/cors": "^2.8.17",
|
||||
@@ -35,11 +36,13 @@
|
||||
"@types/node": "^22.9.0",
|
||||
"concurrently": "^9.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"globals": "^17.4.0",
|
||||
"nodemon": "^3.1.7",
|
||||
"prettier": "^3.3.3",
|
||||
"prisma": "^5.22.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.57.2",
|
||||
"vitest": "^2.1.5"
|
||||
},
|
||||
"prisma": {
|
||||
|
||||
@@ -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");
|
||||
@@ -0,0 +1,20 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Connection" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"fromPortId" TEXT NOT NULL,
|
||||
"toPortId" TEXT NOT NULL,
|
||||
"color" TEXT,
|
||||
"label" TEXT,
|
||||
"edgeType" TEXT NOT NULL DEFAULT 'bezier',
|
||||
"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
|
||||
);
|
||||
INSERT INTO "new_Connection" ("color", "createdAt", "fromPortId", "id", "label", "toPortId") SELECT "color", "createdAt", "fromPortId", "id", "label", "toPortId" FROM "Connection";
|
||||
DROP TABLE "Connection";
|
||||
ALTER TABLE "new_Connection" RENAME TO "Connection";
|
||||
CREATE UNIQUE INDEX "Connection_fromPortId_toPortId_key" ON "Connection"("fromPortId", "toPortId");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
+21
-1
@@ -1,5 +1,7 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
provider = "prisma-client-js"
|
||||
// linux-musl-openssl-3.0.x = Alpine Linux (Docker); native = local dev/build
|
||||
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@@ -51,6 +53,24 @@ model Port {
|
||||
nativeVlan Int?
|
||||
vlans PortVlan[]
|
||||
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")
|
||||
edgeType String @default("bezier") // bezier | straight | step
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([fromPortId, toPortId])
|
||||
}
|
||||
|
||||
model Vlan {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function check() {
|
||||
const modules = await prisma.module.findMany({
|
||||
include: { ports: true }
|
||||
});
|
||||
console.log(JSON.stringify(modules, null, 2));
|
||||
}
|
||||
|
||||
check().catch(console.error);
|
||||
@@ -12,6 +12,7 @@ import { vlansRouter } from './routes/vlans';
|
||||
import { serviceMapRouter } from './routes/serviceMap';
|
||||
import { nodesRouter } from './routes/nodes';
|
||||
import { edgesRouter } from './routes/edges';
|
||||
import connectionsRouter from './routes/connections';
|
||||
import { authMiddleware } from './middleware/authMiddleware';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
|
||||
@@ -44,6 +45,7 @@ app.use('/api/vlans', vlansRouter);
|
||||
app.use('/api/maps', serviceMapRouter);
|
||||
app.use('/api/nodes', nodesRouter);
|
||||
app.use('/api/edges', edgesRouter);
|
||||
app.use('/api/connections', connectionsRouter);
|
||||
|
||||
// ---- Serve Vite build in production ----
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -5,10 +5,13 @@ import { authMiddleware } from '../middleware/authMiddleware';
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
// secure:true requires HTTPS — for plain-HTTP homelab installs (Unraid, etc.)
|
||||
// this must be false so the browser actually sends the cookie back.
|
||||
// Set COOKIE_SECURE=true in your env only if you're behind an HTTPS reverse proxy.
|
||||
const COOKIE_OPTS = {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict' as const,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
secure: process.env.COOKIE_SECURE === 'true',
|
||||
path: '/',
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Router } from 'express';
|
||||
import * as connService from '../services/connectionService';
|
||||
import { ok } from '../types/index';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// POST /api/connections
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { fromPortId, toPortId, color, label, edgeType } = req.body;
|
||||
const conn = await connService.createConnection({ fromPortId, toPortId, color, label, edgeType });
|
||||
res.status(201).json(ok(conn));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/connections/:id
|
||||
router.put('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { color, label, edgeType } = req.body;
|
||||
const conn = await connService.updateConnection(req.params.id, { color, label, edgeType });
|
||||
res.json(ok(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(ok(null));
|
||||
} 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(ok(null));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -61,7 +61,7 @@ racksRouter.delete('/:id', async (req: Request, res: Response, next: NextFunctio
|
||||
|
||||
racksRouter.post('/:id/modules', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { name, type, uPosition, uSize, manufacturer, model, ipAddress, notes, portCount, portType } =
|
||||
const { name, type, uPosition, uSize, manufacturer, model, ipAddress, notes, portCount, portType, sfpCount, wanCount } =
|
||||
req.body as {
|
||||
name: string;
|
||||
type: ModuleType;
|
||||
@@ -73,9 +73,11 @@ racksRouter.post('/:id/modules', async (req: Request, res: Response, next: NextF
|
||||
notes?: string;
|
||||
portCount?: number;
|
||||
portType?: PortType;
|
||||
sfpCount?: number;
|
||||
wanCount?: number;
|
||||
};
|
||||
res.status(201).json(
|
||||
ok(await moduleService.createModule(req.params.id, { name, type, uPosition, uSize, manufacturer, model, ipAddress, notes, portCount, portType }))
|
||||
ok(await moduleService.createModule(req.params.id, { name, type, uPosition, uSize, manufacturer, model, ipAddress, notes, portCount, portType, sfpCount, wanCount }))
|
||||
);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { AppError } from '../types/index';
|
||||
|
||||
export async function createConnection(data: { fromPortId: string; toPortId: string; color?: string; label?: string; edgeType?: 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 updateConnection(id: string, data: Partial<{ color: string; label: string; edgeType: string }>) {
|
||||
return prisma.connection.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteByPorts(portId1: string, portId2: string) {
|
||||
return prisma.connection.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ fromPortId: portId1, toPortId: portId2 },
|
||||
{ fromPortId: portId2, toPortId: portId1 },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -12,6 +12,8 @@ const rackInclude = {
|
||||
vlans: {
|
||||
include: { vlan: true },
|
||||
},
|
||||
sourceConnections: true,
|
||||
targetConnections: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user