diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 53086b8..d46f8e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -459,8 +459,13 @@ export default function App() { snapshotHistory() const existingNode = nodes.find((n) => n.id === editNodeId) updateNode(editNodeId, data) - // If container_mode changed, apply structural changes (children parentId, node dimensions) - if (typeof data.container_mode === 'boolean') { + // Only run the structural container transition when container_mode ACTUALLY + // changed. The modal always includes container_mode in its payload, so firing + // on presence alone re-ran the absolute<->relative child-position conversion on + // every edit (e.g. an icon change), corrupting nested child positions (they pile + // up in a corner). Gate on a real toggle instead. + const prevContainerMode = !!existingNode?.data.container_mode + if (typeof data.container_mode === 'boolean' && data.container_mode !== prevContainerMode) { setProxmoxContainerMode(editNodeId, data.container_mode) } // Sync virtual edge when parent_id changes on an LXC/VM node diff --git a/frontend/src/stores/__tests__/canvasStore/containers.test.ts b/frontend/src/stores/__tests__/canvasStore/containers.test.ts index 76a8902..501f910 100644 --- a/frontend/src/stores/__tests__/canvasStore/containers.test.ts +++ b/frontend/src/stores/__tests__/canvasStore/containers.test.ts @@ -169,6 +169,23 @@ describe('canvasStore — containers & nesting', () => { expect(updatedChild?.extent).toBeUndefined() }) + // Regression: editing any field on a container host (e.g. its icon) used to + // re-fire setProxmoxContainerMode(true) because the modal always re-sends + // container_mode. Re-running the ON transition re-applied the + // absolute->relative conversion to children that were ALREADY relative, + // collapsing them into a corner. A redundant ON call must now be a no-op for + // already-nested children. + it('setProxmoxContainerMode ON is idempotent for already-nested children', () => { + const proxmox: Node = { id: 'px', type: 'proxmox', position: { x: 500, y: -300 }, width: 852, height: 212, data: { label: 'px', type: 'proxmox', status: 'unknown', services: [], container_mode: true } } + const child: Node = { id: 'vm1', type: 'vm', position: { x: 10, y: 62 }, data: { label: 'vm1', type: 'vm', status: 'unknown', services: [], parent_id: 'px' }, parentId: 'px', extent: 'parent' } + useCanvasStore.setState({ nodes: [proxmox, child] }) + useCanvasStore.getState().setProxmoxContainerMode('px', true) + const c = useCanvasStore.getState().nodes.find((n) => n.id === 'vm1') + expect(c?.position).toEqual({ x: 10, y: 62 }) + expect(c?.parentId).toBe('px') + expect(c?.extent).toBe('parent') + }) + it('loadCanvas sorts parents before children', () => { const parent = makeNode('p1') const child: Node = { ...makeNode('c1', { parent_id: 'p1' }), parentId: 'p1', extent: 'parent' } diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index 34be96c..c3ef3d3 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -448,7 +448,14 @@ export const useCanvasStore = create((set) => ({ : { ...withMode, width: undefined, height: undefined } } if (n.data.parent_id === proxmoxId) { + // Idempotency guard: only convert a child's position when its nesting + // state actually changes. A child that already matches the target mode + // keeps its position untouched -- re-running the absolute<->relative + // conversion on an already-relative child corrupts it (children pile + // into a corner). See handleUpdateNode in App.tsx. + const alreadyNested = n.parentId === proxmoxId && n.extent === 'parent' if (enabled && parentNode) { + if (alreadyNested) return n return { ...n, parentId: proxmoxId, @@ -460,6 +467,7 @@ export const useCanvasStore = create((set) => ({ } } if (!enabled && parentNode) { + if (!n.parentId) return n return { ...n, parentId: undefined,