From 280d5a2ae182a927803d7ddb42e210ed51c20e2d Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 15 May 2026 22:54:51 +0200 Subject: [PATCH] feat(docker): allow LXC as parent for docker_container Previously docker_container could only attach to a docker_host via virtual edge. Extend the rule so an LXC node can also act as parent, enabling docker_container nesting under LXC without requiring an intermediate docker_host. Extracts the virtual-edge parent rule into a pure helper (resolveVirtualEdgeParent) with unit tests covering all type pairs. ha-relevant: yes --- frontend/src/App.tsx | 20 +++---- .../utils/__tests__/virtualEdgeParent.test.ts | 60 +++++++++++++++++++ frontend/src/utils/virtualEdgeParent.ts | 37 ++++++++++++ 3 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 frontend/src/utils/__tests__/virtualEdgeParent.test.ts create mode 100644 frontend/src/utils/virtualEdgeParent.ts 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..66f638b --- /dev/null +++ b/frontend/src/utils/__tests__/virtualEdgeParent.test.ts @@ -0,0 +1,60 @@ +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('returns null when docker_container links to non-host/lxc target', () => { + 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..924e8f9 --- /dev/null +++ b/frontend/src/utils/virtualEdgeParent.ts @@ -0,0 +1,37 @@ +import type { NodeData } from '@/types' + +export type NodeType = NodeData['type'] + +const CONTAINER_MODE_TYPES = new Set(['proxmox', 'vm', 'lxc', 'docker_host']) + +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' && (tgtType === 'docker_host' || tgtType === 'lxc')) { + return { childId: srcId, parentId: tgtId } + } + if (tgtType === 'docker_container' && (srcType === 'docker_host' || srcType === 'lxc')) { + return { childId: tgtId, parentId: srcId } + } + return null +}