Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions src/__tests__/attachmentInheritance.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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)
Expand All @@ -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')
Expand All @@ -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)
Expand All @@ -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', () => {
Expand Down
49 changes: 49 additions & 0 deletions src/__tests__/composeAttachments.test.js
Original file line number Diff line number Diff line change
@@ -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()
})
})
33 changes: 33 additions & 0 deletions src/__tests__/normalizeAttachment.test.js
Original file line number Diff line number Diff line change
@@ -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()
})
})
6 changes: 3 additions & 3 deletions src/components/project/AttachmentManager.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Expand Down Expand Up @@ -178,9 +178,9 @@ function applyNodeScoped() {
/>
</td>
<td class="font-mono text-xs">{{ a.id }}</td>
<td class="font-mono text-xs">{{ a.node_id }}</td>
<td class="font-mono text-xs">{{ a.target_node }}</td>
<td>{{ a.stage || '—' }}</td>
<td>{{ a.order ?? '—' }}</td>
<td>{{ a.order_in_stage ?? '—' }}</td>
<td>
<span class="badge badge-xs" :class="a.scope === 'group_inherited' ? 'badge-accent' : 'badge-ghost'">
{{ a.scope || 'node' }}
Expand Down
33 changes: 27 additions & 6 deletions src/composables/useInfraBuilder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -41,7 +61,7 @@ export function getTeamScopeAncestorId(node, allNodes) {
* 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<nodeId, Attachment[]>
*/
Expand All @@ -53,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, [])
Expand Down Expand Up @@ -108,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 }
Expand Down Expand Up @@ -438,5 +458,6 @@ export function useInfraBuilder() {
getTeamScopeAncestorId,
computeEffectiveAttachments,
applyBulkAttachmentEdit,
normalizeAttachment,
}
}
6 changes: 4 additions & 2 deletions src/views/ProjectEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)

Expand Down
Loading