Merge pull request #154 from Pouzor/feat/docker-container-lxc-parent

feat(node-modal): sanitize parent_id on type change and exclude descendants
This commit is contained in:
Rémy
2026-05-16 18:03:31 +02:00
committed by GitHub
5 changed files with 146 additions and 8 deletions
+20
View File
@@ -550,6 +550,7 @@ export default function App() {
onClose={() => setAddNodeOpen(false)}
onSubmit={handleAddNode}
title="Add Node"
parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))}
/>
{/* key forces re-mount when editing a different node, resetting form state */}
@@ -560,6 +561,25 @@ export default function App() {
onSubmit={handleUpdateNode}
initial={editNode?.data}
title="Edit Node"
parentCandidates={(() => {
const descendants = new Set<string>()
if (editNodeId) {
const queue = [editNodeId]
while (queue.length) {
const id = queue.shift()!
for (const n of nodes) {
if (n.data.parent_id === id && !descendants.has(n.id)) {
descendants.add(n.id)
queue.push(n.id)
}
}
}
}
return nodes
.filter((n) => !descendants.has(n.id))
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))
})()}
currentNodeId={editNodeId ?? undefined}
/>
<EdgeModal
+59 -2
View File
@@ -11,6 +11,7 @@ import { resolveNodeColors } from '@/utils/nodeColors'
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
import { BrandIconPicker } from './BrandIconPicker'
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
import { getValidParentTypes } from '@/utils/virtualEdgeParent'
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
@@ -48,17 +49,25 @@ const DEFAULT_DATA: Partial<NodeData> = {
custom_icon: undefined,
}
interface ParentCandidate {
id: string
label: string
type: NodeType
}
interface NodeModalProps {
open: boolean
onClose: () => void
onSubmit: (data: Partial<NodeData>) => void
initial?: Partial<NodeData>
title?: string
parentCandidates?: ParentCandidate[]
currentNodeId?: string
}
// NodeModal is always mounted with a key that changes on open/edit, so useState
// initial value is enough - no need for a reset effect.
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' }: NodeModalProps) {
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentCandidates = [], currentNodeId }: NodeModalProps) {
const merged = { ...DEFAULT_DATA, ...initial }
if (ZIGBEE_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
const [form, setForm] = useState<Partial<NodeData>>(merged)
@@ -86,8 +95,17 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
setLabelError(false)
const selectedType = (form.type ?? 'generic') as NodeType
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
const validParentTypes = getValidParentTypes(selectedType)
let safeParentId = form.parent_id
if (validParentTypes.length === 0) {
safeParentId = undefined
} else if (safeParentId) {
const parent = parentCandidates.find((n) => n.id === safeParentId)
if (!parent || !validParentTypes.includes(parent.type)) safeParentId = undefined
}
onSubmit({
...form,
parent_id: safeParentId,
container_mode: canUseContainerMode ? !!form.container_mode : false,
})
onClose()
@@ -107,7 +125,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<Label className="text-xs text-muted-foreground">Type</Label>
<Select value={form.type} onValueChange={(v) => {
const t = v as NodeType
setForm((f) => ({ ...f, type: t, ...(ZIGBEE_TYPES.includes(t) ? { check_method: 'none' as CheckMethod } : {}) }))
setForm((f) => {
const next: Partial<NodeData> = { ...f, type: t }
if (ZIGBEE_TYPES.includes(t)) next.check_method = 'none' as CheckMethod
if (getValidParentTypes(t).length === 0) next.parent_id = undefined
return next
})
}}>
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Node type selector">
<SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
@@ -320,6 +343,40 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</div>
)}
{/* Parent Container */}
{(() => {
const childType = (form.type ?? 'generic') as NodeType
const validParentTypes = getValidParentTypes(childType)
if (validParentTypes.length === 0) return null
const validParents = parentCandidates.filter(
(n) => n.id !== currentNodeId && validParentTypes.includes(n.type),
)
if (validParents.length === 0) return null
return (
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Parent Container</Label>
<Select
value={form.parent_id ?? 'none'}
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
>
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Parent container selector">
<SelectValue>
{form.parent_id
? (validParents.find((n) => n.id === form.parent_id)?.label ?? 'None')
: 'None'}
</SelectValue>
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
<SelectItem value="none" className="text-sm">None</SelectItem>
{validParents.map((n) => (
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
})()}
{/* Container mode */}
{CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
<div className="flex items-center justify-between col-span-2 py-1">
@@ -311,18 +311,48 @@ describe('NodeModal', () => {
expect(screen.queryByText('Reset to defaults')).toBeNull()
})
// ── Parent Container selector removed ────────────────────────────────
// ── Parent Container selector ─────────────────────────────────────────
it('does not render the Parent Container selector', () => {
renderModal({ initial: BASE })
it('does not render Parent Container for non-child types', () => {
renderModal({
initial: BASE,
parentCandidates: [{ id: 'p1', label: 'Proxmox', type: 'proxmox' }],
})
expect(screen.queryByText('Parent Container')).toBeNull()
})
it('does not render Parent Container for docker_container either', () => {
renderModal({ initial: { ...BASE, type: 'docker_container' } })
it('does not render Parent Container when no valid candidates exist', () => {
renderModal({
initial: { ...BASE, type: 'docker_container' },
parentCandidates: [],
})
expect(screen.queryByText('Parent Container')).toBeNull()
})
it('renders Parent Container for docker_container when docker_host candidate exists', () => {
renderModal({
initial: { ...BASE, type: 'docker_container' },
parentCandidates: [{ id: 'dh1', label: 'Docker Host', type: 'docker_host' }],
})
expect(screen.getByText('Parent Container')).toBeDefined()
})
it('renders Parent Container for docker_container when only an LXC candidate exists', () => {
renderModal({
initial: { ...BASE, type: 'docker_container' },
parentCandidates: [{ id: 'lxc1', label: 'My LXC', type: 'lxc' }],
})
expect(screen.getByText('Parent Container')).toBeDefined()
})
it('renders Parent Container for lxc when proxmox candidate exists', () => {
renderModal({
initial: { ...BASE, type: 'lxc' },
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox' }],
})
expect(screen.getByText('Parent Container')).toBeDefined()
})
// ── Appearance ────────────────────────────────────────────────────────
it('renders 3 color swatch labels (border, background, icon)', () => {
@@ -1,5 +1,26 @@
import { describe, it, expect } from 'vitest'
import { resolveVirtualEdgeParent } from '../virtualEdgeParent'
import { resolveVirtualEdgeParent, getValidParentTypes } from '../virtualEdgeParent'
describe('getValidParentTypes', () => {
it('returns container-mode types for lxc', () => {
expect(getValidParentTypes('lxc')).toEqual(['proxmox', 'vm', 'lxc', 'docker_host'])
})
it('returns container-mode types for vm', () => {
expect(getValidParentTypes('vm')).toEqual(['proxmox', 'vm', 'lxc', 'docker_host'])
})
it('returns docker_host/lxc/vm/proxmox for docker_container', () => {
expect(getValidParentTypes('docker_container')).toEqual(['docker_host', 'lxc', 'vm', 'proxmox'])
})
it('returns empty list for types that cannot have a parent', () => {
expect(getValidParentTypes('server')).toEqual([])
expect(getValidParentTypes('router')).toEqual([])
expect(getValidParentTypes('proxmox')).toEqual([])
expect(getValidParentTypes('docker_host')).toEqual([])
})
})
describe('resolveVirtualEdgeParent', () => {
it('nests lxc under proxmox (container-mode parent)', () => {
+10
View File
@@ -15,6 +15,16 @@ export interface ParentAssignment {
parentId: string
}
export function getValidParentTypes(childType: NodeType): NodeType[] {
if (childType === 'lxc' || childType === 'vm') {
return ['proxmox', 'vm', 'lxc', 'docker_host']
}
if (childType === 'docker_container') {
return ['docker_host', 'lxc', 'vm', 'proxmox']
}
return []
}
export function resolveVirtualEdgeParent(
source: VirtualEdgeEndpoint,
target: VirtualEdgeEndpoint,