From 2656b7d35111b121dd46a1bfc706d0600533f3b7 Mon Sep 17 00:00:00 2001 From: Philippe Parage Date: Thu, 21 May 2026 07:21:58 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(attachments):=20add=20normalizeAttachm?= =?UTF-8?q?ent=20legacy=E2=86=92canonical=20mapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/normalizeAttachment.test.js | 33 +++++++++++++++++++++++ src/composables/useInfraBuilder.js | 21 +++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 src/__tests__/normalizeAttachment.test.js diff --git a/src/__tests__/normalizeAttachment.test.js b/src/__tests__/normalizeAttachment.test.js new file mode 100644 index 0000000..c4b0fa3 --- /dev/null +++ b/src/__tests__/normalizeAttachment.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest' +import { normalizeAttachment } from '../composables/useInfraBuilder' + +describe('normalizeAttachment — legacy → canonical attachment shape', () => { + it('maps node_id → target_node and order → order_in_stage, dropping the legacy keys', () => { + const out = normalizeAttachment({ id: 'a', node_id: 'vm-a', order: 2, stage: 'pre' }) + expect(out.target_node).toBe('vm-a') + expect(out.order_in_stage).toBe(2) + expect(out.node_id).toBeUndefined() + expect(out.order).toBeUndefined() + expect(out.stage).toBe('pre') + }) + + it('preserves a literal order of 0', () => { + expect(normalizeAttachment({ id: 'a', node_id: 'x', order: 0 }).order_in_stage).toBe(0) + }) + + it('is idempotent on already-canonical rows', () => { + const canon = { id: 'a', target_node: 'vm-a', order_in_stage: 1, source: { kind: 'inline_yaml' } } + expect(normalizeAttachment(canon)).toEqual(canon) + }) + + it('prefers an existing target_node over node_id when both are present', () => { + const out = normalizeAttachment({ id: 'a', target_node: 'canon', node_id: 'legacy' }) + expect(out.target_node).toBe('canon') + expect(out.node_id).toBeUndefined() + }) + + it('passes non-object input through unchanged', () => { + expect(normalizeAttachment(null)).toBeNull() + expect(normalizeAttachment(undefined)).toBeUndefined() + }) +}) diff --git a/src/composables/useInfraBuilder.js b/src/composables/useInfraBuilder.js index 8e0c2e6..c42fe5e 100644 --- a/src/composables/useInfraBuilder.js +++ b/src/composables/useInfraBuilder.js @@ -32,6 +32,26 @@ export function getTeamScopeAncestorId(node, allNodes) { return null } +/** + * Upgrade a persisted attachment row to the canonical schema shape: + * node_id → target_node, order → order_in_stage. + * Pure and idempotent — canonical rows pass through unchanged, and legacy keys + * are always removed so downstream code only ever sees canonical names. + */ +export function normalizeAttachment(raw) { + if (!raw || typeof raw !== 'object') return raw + const a = { ...raw } + if (a.target_node === undefined && a.node_id !== undefined) { + a.target_node = a.node_id + } + delete a.node_id + if (a.order_in_stage === undefined && a.order !== undefined) { + a.order_in_stage = a.order + } + delete a.order + return a +} + /** * Compute the effective attachments for each node: direct attachments on the * node itself PLUS any attachments with `scope: 'group_inherited'` defined on @@ -438,5 +458,6 @@ export function useInfraBuilder() { getTeamScopeAncestorId, computeEffectiveAttachments, applyBulkAttachmentEdit, + normalizeAttachment, } } From 1a6ba3b3aa1dc269635e14b3419cd3bfccb6102c Mon Sep 17 00:00:00 2001 From: Philippe Parage Date: Thu, 21 May 2026 07:24:59 +0200 Subject: [PATCH 2/5] refactor(attachments): read target_node/order_in_stage in inheritance + bulk edit --- src/__tests__/attachmentInheritance.test.js | 16 ++++++++-------- src/composables/useInfraBuilder.js | 12 ++++++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/__tests__/attachmentInheritance.test.js b/src/__tests__/attachmentInheritance.test.js index 956f988..e025aed 100644 --- a/src/__tests__/attachmentInheritance.test.js +++ b/src/__tests__/attachmentInheritance.test.js @@ -18,7 +18,7 @@ describe('computeEffectiveAttachments', () => { ] it('direct node attachments remain on their node, unflagged as inherited', () => { - const atts = [{ id: 'a1', node_id: 'vm-a', scope: 'node', stage: 'run' }] + const atts = [{ id: 'a1', target_node: 'vm-a', scope: 'node', stage: 'run' }] const out = computeEffectiveAttachments(nodes, atts) expect(out.get('vm-a')).toHaveLength(1) expect(out.get('vm-a')[0].id).toBe('a1') @@ -27,7 +27,7 @@ describe('computeEffectiveAttachments', () => { }) it('group_inherited attachments propagate to all descendants (including nested group descendants)', () => { - const atts = [{ id: 'inh', node_id: 'g1', scope: 'group_inherited', stage: 'preflight' }] + const atts = [{ id: 'inh', target_node: 'g1', scope: 'group_inherited', stage: 'preflight' }] const out = computeEffectiveAttachments(nodes, atts) // Group owns its own copy (non-inherited — it's the source row) expect(out.get('g1')).toHaveLength(1) @@ -46,8 +46,8 @@ describe('computeEffectiveAttachments', () => { it('merges direct + inherited attachments on the same node', () => { const atts = [ - { id: 'direct', node_id: 'vm-a', scope: 'node', stage: 'post' }, - { id: 'inh', node_id: 'g1', scope: 'group_inherited', stage: 'pre' }, + { id: 'direct', target_node: 'vm-a', scope: 'node', stage: 'post' }, + { id: 'inh', target_node: 'g1', scope: 'group_inherited', stage: 'pre' }, ] const out = computeEffectiveAttachments(nodes, atts) const list = out.get('vm-a') @@ -58,7 +58,7 @@ describe('computeEffectiveAttachments', () => { }) it('does not mutate the source attachments array', () => { - const atts = [{ id: 'inh', node_id: 'g1', scope: 'group_inherited' }] + const atts = [{ id: 'inh', target_node: 'g1', scope: 'group_inherited' }] const snapshot = JSON.stringify(atts) computeEffectiveAttachments(nodes, atts) expect(JSON.stringify(atts)).toBe(snapshot) @@ -67,9 +67,9 @@ describe('computeEffectiveAttachments', () => { describe('applyBulkAttachmentEdit', () => { const atts = [ - { id: 'a1', node_id: 'vm-a', stage: 'pre', order: 0 }, - { id: 'a2', node_id: 'vm-b', stage: 'pre', order: 1 }, - { id: 'a3', node_id: 'g1', scope: 'node' }, + { id: 'a1', target_node: 'vm-a', stage: 'pre', order_in_stage: 0 }, + { id: 'a2', target_node: 'vm-b', stage: 'pre', order_in_stage: 1 }, + { id: 'a3', target_node: 'g1', scope: 'node' }, ] it('sets stage on selected rows only', () => { diff --git a/src/composables/useInfraBuilder.js b/src/composables/useInfraBuilder.js index c42fe5e..b2977bc 100644 --- a/src/composables/useInfraBuilder.js +++ b/src/composables/useInfraBuilder.js @@ -61,7 +61,7 @@ export function normalizeAttachment(raw) { * duplicate records. * * Pure function — callers pass the full nodes list + a flat attachments list: - * attachments: [{ id, node_id, scope?: 'node' | 'group_inherited', ... }] + * attachments: [{ id, target_node, scope?: 'node' | 'group_inherited', ... }] * * Returns: Map */ @@ -73,19 +73,19 @@ export function computeEffectiveAttachments(allNodes, attachments) { // 1) Direct attachments go on their owning node unchanged. const groupInherited = [] for (const a of attachments || []) { - if (!a?.node_id) continue + if (!a?.target_node) continue if (a.scope === 'group_inherited') { groupInherited.push(a) continue } - if (!byNode.has(a.node_id)) byNode.set(a.node_id, []) - byNode.get(a.node_id).push({ ...a, inherited: false }) + if (!byNode.has(a.target_node)) byNode.set(a.target_node, []) + byNode.get(a.target_node).push({ ...a, inherited: false }) } // 2) Inherited attachments: each group_inherited attachment on a group node // propagates to all descendant nodes (leaf + nested groups) as a copy. for (const a of groupInherited) { - const group = byId.get(a.node_id) + const group = byId.get(a.target_node) if (!group) continue // Also attach to the group itself so Config tab shows it on the group row. if (!byNode.has(group.id)) byNode.set(group.id, []) @@ -128,7 +128,7 @@ export function applyBulkAttachmentEdit(attachments, selectedIds, edit) { if (!selected.has(a.id)) return a const next = { ...a } if (edit?.setStage !== undefined) next.stage = edit.setStage - if (edit?.setOrder !== undefined) next.order = Number(edit.setOrder) || 0 + if (edit?.setOrder !== undefined) next.order_in_stage = Number(edit.setOrder) || 0 if (edit?.setScope !== undefined) next.scope = edit.setScope if (edit?.addVars) { next.vars = { ...(a.vars || {}), ...edit.addVars } From 53aa12db4a78fcc127e653dcb1330e5c7a54c45e Mon Sep 17 00:00:00 2001 From: Philippe Parage Date: Thu, 21 May 2026 07:27:33 +0200 Subject: [PATCH 3/5] test(attachments): guard that canonical attachments compose onto their node --- src/__tests__/composeAttachments.test.js | 49 ++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/__tests__/composeAttachments.test.js diff --git a/src/__tests__/composeAttachments.test.js b/src/__tests__/composeAttachments.test.js new file mode 100644 index 0000000..8944266 --- /dev/null +++ b/src/__tests__/composeAttachments.test.js @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest' +import { compose } from '@/overlay/compose' + +function baseDoc() { + return { + schema_version: '1.0', + kind: 'lab', + name: 'base', + nodes: [{ id: 'vm-a', kind: 'vm' }], + } +} + +describe('compose — UI attachment shape reaches the right node', () => { + it('appends a canonical attachment (target_node + source) onto its node, dropping target_node', () => { + const overlay = { + schema_version: '1.0', + source_url: 'x', + source_sha: 'y', + attachments_added: [ + { + id: 'att1', + target_node: 'vm-a', + stage: 'main', + order_in_stage: 0, + source: { kind: 'catalog_role', ref: 'src-a:roles/wazuh' }, + }, + ], + } + const eff = compose(baseDoc(), overlay) + const node = eff.nodes.find((n) => n.id === 'vm-a') + expect(node.attachments).toHaveLength(1) + const att = node.attachments[0] + expect(att.source.kind).toBe('catalog_role') + expect(att.stage).toBe('main') + expect(att.order_in_stage).toBe(0) + expect(att.target_node).toBeUndefined() // compose strips the routing key + }) + + it('drops a legacy attachment that uses node_id instead of target_node (guards the drift)', () => { + const overlay = { + schema_version: '1.0', + source_url: 'x', + source_sha: 'y', + attachments_added: [{ id: 'legacy', node_id: 'vm-a', stage: 'main' }], + } + const eff = compose(baseDoc(), overlay) + expect(eff.nodes.find((n) => n.id === 'vm-a').attachments).toBeUndefined() + }) +}) From 3d30ccf7abe23195d395341ab45c93624586dc00 Mon Sep 17 00:00:00 2001 From: Philippe Parage Date: Thu, 21 May 2026 07:29:00 +0200 Subject: [PATCH 4/5] refactor(attachments): AttachmentManager reads target_node/order_in_stage --- src/components/project/AttachmentManager.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/project/AttachmentManager.vue b/src/components/project/AttachmentManager.vue index 4f06a2c..e5e63a2 100644 --- a/src/components/project/AttachmentManager.vue +++ b/src/components/project/AttachmentManager.vue @@ -50,7 +50,7 @@ const selectionIntersectsGroup = computed(() => { ) for (const a of props.attachments) { if (!selected.value.has(a.id)) continue - if (a.node_id && groupIds.has(a.node_id)) return true + if (a.target_node && groupIds.has(a.target_node)) return true } return false }) @@ -178,9 +178,9 @@ function applyNodeScoped() { /> {{ a.id }} - {{ a.node_id }} + {{ a.target_node }} {{ a.stage || '—' }} - {{ a.order ?? '—' }} + {{ a.order_in_stage ?? '—' }} {{ a.scope || 'node' }} From 94905daa221c14620014054bd8282facc5fda56d Mon Sep 17 00:00:00 2001 From: Philippe Parage Date: Thu, 21 May 2026 07:30:35 +0200 Subject: [PATCH 5/5] refactor(attachments): normalize legacy attachment rows on read --- src/views/ProjectEditor.vue | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/views/ProjectEditor.vue b/src/views/ProjectEditor.vue index 93fbd08..5533ff8 100644 --- a/src/views/ProjectEditor.vue +++ b/src/views/ProjectEditor.vue @@ -44,7 +44,7 @@ import { useNetworkZones } from '../composables/useNetworkZones' import { useCanvasLiveStatus } from '../composables/useCanvasLiveStatus' import { useDeploymentStore } from '../stores/deploymentStore.ts' import NetworkZoneOverlay from '../components/NetworkZoneOverlay.vue' -import { useInfraBuilder, computeDockerTetherEdges, nextKeyboardSelection } from '../composables/useInfraBuilder' +import { useInfraBuilder, computeDockerTetherEdges, nextKeyboardSelection, normalizeAttachment } from '../composables/useInfraBuilder' import { useDeployment } from '../composables/useDeployment' import { useApiConfig } from '../composables/useApiConfig' import { useWebSocketStatus } from '../composables/useWebSocketStatus' @@ -128,7 +128,9 @@ const dockerTetherEdges = computed(() => computeDockerTetherEdges(liveNodes.valu const renderedEdges = computed(() => [...(edges.value || []), ...dockerTetherEdges.value]) // Problems panel — reactive over the live canvas graph. -const attachmentsRef = computed(() => currentProject.value?.attachments || []) +const attachmentsRef = computed(() => + (currentProject.value?.attachments || []).map(normalizeAttachment), +) const { problems: problemList } = useProblems(liveNodes, liveEdges, attachmentsRef) const showProblemsPanel = ref(true)