diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fa561f6..f80851f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { type Node } from '@xyflow/react' import { applyDagreLayout } from '@/utils/layout' import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer' import { generateUUID } from '@/utils/uuid' +import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent' import { generateMarkdownTable } from '@/utils/exportMarkdown' import { ExportModal } from '@/components/modals/ExportModal' import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml' @@ -37,7 +38,6 @@ import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE_STORAGE_KEY = 'homelable_canvas' -const CONTAINER_MODE_TYPES = new Set(['proxmox', 'vm', 'lxc', 'docker_host']) export default function App() { const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore() @@ -450,16 +450,14 @@ export default function App() { if (edgeData.type === 'virtual') { const src = nodes.find((n) => n.id === pendingConnection.source) const tgt = nodes.find((n) => n.id === pendingConnection.target) - const srcType = src?.data.type as NodeData['type'] - const tgtType = tgt?.data.type as NodeData['type'] - if ((srcType === 'lxc' || srcType === 'vm') && CONTAINER_MODE_TYPES.has(tgtType)) { - updateNode(pendingConnection.source, { parent_id: pendingConnection.target }) - } else if (CONTAINER_MODE_TYPES.has(srcType) && (tgtType === 'lxc' || tgtType === 'vm')) { - updateNode(pendingConnection.target, { parent_id: pendingConnection.source }) - } else if (srcType === 'docker_container' && tgtType === 'docker_host') { - updateNode(pendingConnection.source, { parent_id: pendingConnection.target }) - } else if (tgtType === 'docker_container' && srcType === 'docker_host') { - updateNode(pendingConnection.target, { parent_id: pendingConnection.source }) + if (src && tgt) { + const assignment = resolveVirtualEdgeParent( + { id: src.id, type: src.data.type as NodeData['type'] }, + { id: tgt.id, type: tgt.data.type as NodeData['type'] }, + ) + if (assignment) { + updateNode(assignment.childId, { parent_id: assignment.parentId }) + } } } setPendingConnection(null) diff --git a/frontend/src/utils/__tests__/virtualEdgeParent.test.ts b/frontend/src/utils/__tests__/virtualEdgeParent.test.ts new file mode 100644 index 0000000..6308866 --- /dev/null +++ b/frontend/src/utils/__tests__/virtualEdgeParent.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest' +import { resolveVirtualEdgeParent } from '../virtualEdgeParent' + +describe('resolveVirtualEdgeParent', () => { + it('nests lxc under proxmox (container-mode parent)', () => { + const res = resolveVirtualEdgeParent( + { id: 'lxc1', type: 'lxc' }, + { id: 'px1', type: 'proxmox' }, + ) + expect(res).toEqual({ childId: 'lxc1', parentId: 'px1' }) + }) + + it('nests vm under proxmox regardless of edge direction', () => { + const res = resolveVirtualEdgeParent( + { id: 'px1', type: 'proxmox' }, + { id: 'vm1', type: 'vm' }, + ) + expect(res).toEqual({ childId: 'vm1', parentId: 'px1' }) + }) + + it('nests docker_container under docker_host', () => { + const res = resolveVirtualEdgeParent( + { id: 'dc1', type: 'docker_container' }, + { id: 'dh1', type: 'docker_host' }, + ) + expect(res).toEqual({ childId: 'dc1', parentId: 'dh1' }) + }) + + it('nests docker_container under lxc (reverse direction)', () => { + const res = resolveVirtualEdgeParent( + { id: 'lxc1', type: 'lxc' }, + { id: 'dc1', type: 'docker_container' }, + ) + expect(res).toEqual({ childId: 'dc1', parentId: 'lxc1' }) + }) + + it('nests docker_container under lxc (forward direction)', () => { + const res = resolveVirtualEdgeParent( + { id: 'dc1', type: 'docker_container' }, + { id: 'lxc1', type: 'lxc' }, + ) + expect(res).toEqual({ childId: 'dc1', parentId: 'lxc1' }) + }) + + it('nests docker_container under vm', () => { + const res = resolveVirtualEdgeParent( + { id: 'dc1', type: 'docker_container' }, + { id: 'vm1', type: 'vm' }, + ) + expect(res).toEqual({ childId: 'dc1', parentId: 'vm1' }) + }) + + it('nests docker_container under proxmox (reverse direction)', () => { + const res = resolveVirtualEdgeParent( + { id: 'px1', type: 'proxmox' }, + { id: 'dc1', type: 'docker_container' }, + ) + expect(res).toEqual({ childId: 'dc1', parentId: 'px1' }) + }) + + it('returns null when docker_container links to unsupported parent type', () => { + const res = resolveVirtualEdgeParent( + { id: 'dc1', type: 'docker_container' }, + { id: 'srv1', type: 'server' }, + ) + expect(res).toBeNull() + }) + + it('returns null for unrelated type pairs', () => { + const res = resolveVirtualEdgeParent( + { id: 'srv1', type: 'server' }, + { id: 'rt1', type: 'router' }, + ) + expect(res).toBeNull() + }) +}) diff --git a/frontend/src/utils/virtualEdgeParent.ts b/frontend/src/utils/virtualEdgeParent.ts new file mode 100644 index 0000000..b239822 --- /dev/null +++ b/frontend/src/utils/virtualEdgeParent.ts @@ -0,0 +1,38 @@ +import type { NodeData } from '@/types' + +export type NodeType = NodeData['type'] + +const CONTAINER_MODE_TYPES = new Set(['proxmox', 'vm', 'lxc', 'docker_host']) +const DOCKER_CONTAINER_PARENT_TYPES = new Set(['docker_host', 'lxc', 'vm', 'proxmox']) + +export interface VirtualEdgeEndpoint { + id: string + type: NodeType +} + +export interface ParentAssignment { + childId: string + parentId: string +} + +export function resolveVirtualEdgeParent( + source: VirtualEdgeEndpoint, + target: VirtualEdgeEndpoint, +): ParentAssignment | null { + const { type: srcType, id: srcId } = source + const { type: tgtType, id: tgtId } = target + + if ((srcType === 'lxc' || srcType === 'vm') && CONTAINER_MODE_TYPES.has(tgtType)) { + return { childId: srcId, parentId: tgtId } + } + if (CONTAINER_MODE_TYPES.has(srcType) && (tgtType === 'lxc' || tgtType === 'vm')) { + return { childId: tgtId, parentId: srcId } + } + if (srcType === 'docker_container' && DOCKER_CONTAINER_PARENT_TYPES.has(tgtType)) { + return { childId: srcId, parentId: tgtId } + } + if (tgtType === 'docker_container' && DOCKER_CONTAINER_PARENT_TYPES.has(srcType)) { + return { childId: tgtId, parentId: srcId } + } + return null +}