feat: replace static hardware fields with dynamic node properties
Replaces the 4 fixed hardware columns (cpu_count, cpu_model, ram_gb, disk_gb) with a flexible properties system. Each property has a key, value, icon (from a curated Lucide picker), and a visibility toggle that controls whether it appears on the canvas node card. - Backend: add `properties` JSON column to Node model; data migration converts existing hardware rows to properties with correct icons (idempotent, old columns kept for safety) - Backend: add `properties` to NodeBase, NodeUpdate, NodeSave schemas and canvasSerializer so values survive canvas save/load - Frontend: add NodeProperty type; new propertyIcons.ts registry (20 icons); BaseNode renders visible properties with legacy hardware fallback for unmigrated nodes - Frontend: DetailPanel gains interactive properties section (add / edit / remove / toggle visibility / icon picker) replacing the read-only hardware block; hardware section removed from NodeModal - Tests: 6 migration tests, 7 API tests, 8 DetailPanel property tests, 6 BaseNode render/fallback tests, 9 propertyIcons util tests
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Server } from 'lucide-react'
|
||||
import { BaseNode } from '../nodes/BaseNode'
|
||||
import type { NodeData } from '@/types'
|
||||
import type { Node } from '@xyflow/react'
|
||||
|
||||
vi.mock('@xyflow/react', () => ({
|
||||
Handle: () => null,
|
||||
Position: { Top: 'top', Bottom: 'bottom' },
|
||||
NodeResizer: () => null,
|
||||
useUpdateNodeInternals: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/themeStore', () => ({
|
||||
useThemeStore: () => 'dark',
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ hideIp: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/themes', () => ({
|
||||
THEMES: {
|
||||
dark: {
|
||||
colors: {
|
||||
statusColors: { online: '#39d353', offline: '#f85149', pending: '#e3b341', unknown: '#8b949e' },
|
||||
nodeSubtextColor: '#8b949e',
|
||||
nodeLabelColor: '#e6edf3',
|
||||
nodeIconBackground: '#21262d',
|
||||
handleBackground: '#30363d',
|
||||
handleBorder: '#30363d',
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeColors', () => ({
|
||||
resolveNodeColors: () => ({ background: '#161b22', border: '#30363d', icon: '#00d4ff' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeIcons', () => ({
|
||||
resolveNodeIcon: (_typeIcon: unknown) => _typeIcon,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/maskIp', () => ({
|
||||
maskIp: (ip: string) => ip,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/handleUtils', () => ({
|
||||
BOTTOM_HANDLE_IDS: ['bottom'],
|
||||
BOTTOM_HANDLE_POSITIONS: { 1: [50] },
|
||||
}))
|
||||
|
||||
function makeNode(data: Partial<NodeData>): Node<NodeData> {
|
||||
return {
|
||||
id: 'n1',
|
||||
type: data.type ?? 'server',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Node',
|
||||
type: 'server',
|
||||
status: 'online',
|
||||
services: [],
|
||||
...data,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function renderBaseNode(data: Partial<NodeData>) {
|
||||
const node = makeNode(data)
|
||||
return render(
|
||||
<BaseNode
|
||||
id={node.id}
|
||||
data={node.data}
|
||||
selected={false}
|
||||
icon={Server}
|
||||
type="server"
|
||||
dragging={false}
|
||||
zIndex={0}
|
||||
isConnectable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('BaseNode — properties rendering', () => {
|
||||
it('renders visible properties on the node', () => {
|
||||
renderBaseNode({
|
||||
properties: [
|
||||
{ key: 'CPU Model', value: 'i7-12700K', icon: 'Cpu', visible: true },
|
||||
{ key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: true },
|
||||
],
|
||||
})
|
||||
expect(screen.getByText('CPU Model')).toBeDefined()
|
||||
// Value is rendered with a middle-dot prefix: "· 32 GB"
|
||||
expect(screen.getByText(/32 GB/)).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not render properties with visible=false', () => {
|
||||
renderBaseNode({
|
||||
properties: [
|
||||
{ key: 'Secret', value: 'hidden', icon: null, visible: false },
|
||||
],
|
||||
})
|
||||
expect(screen.queryByText('Secret')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders nothing when properties array is empty', () => {
|
||||
const { container } = renderBaseNode({ properties: [] })
|
||||
// No properties section — only the main node card
|
||||
expect(container.querySelectorAll('.flex.flex-col.gap-1').length).toBe(0)
|
||||
})
|
||||
|
||||
it('renders label and ip regardless of properties', () => {
|
||||
renderBaseNode({
|
||||
label: 'My Server',
|
||||
ip: '192.168.1.10',
|
||||
properties: [{ key: 'OS', value: 'Debian 12', icon: 'Server', visible: true }],
|
||||
})
|
||||
expect(screen.getByText('My Server')).toBeDefined()
|
||||
expect(screen.getByText('192.168.1.10')).toBeDefined()
|
||||
expect(screen.getByText('OS')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BaseNode — legacy hardware fallback', () => {
|
||||
it('renders legacy hardware when properties is undefined and show_hardware is true', () => {
|
||||
renderBaseNode({
|
||||
properties: undefined,
|
||||
show_hardware: true,
|
||||
cpu_model: 'Intel Xeon E5-2680',
|
||||
ram_gb: 32,
|
||||
})
|
||||
expect(screen.getByText('Intel Xeon E5-2680')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not render legacy hardware when properties array is present (even if empty)', () => {
|
||||
renderBaseNode({
|
||||
properties: [],
|
||||
show_hardware: true,
|
||||
cpu_model: 'Intel Xeon E5-2680',
|
||||
})
|
||||
// properties array exists → new system, legacy section skipped
|
||||
expect(screen.queryByText('Intel Xeon E5-2680')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not render legacy hardware when show_hardware is false', () => {
|
||||
renderBaseNode({
|
||||
properties: undefined,
|
||||
show_hardware: false,
|
||||
cpu_model: 'Intel Xeon E5-2680',
|
||||
})
|
||||
expect(screen.queryByText('Intel Xeon E5-2680')).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user