From 3d89ba3b6f7f4a18e1c697424b8ed857a334fd95 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Thu, 9 Apr 2026 13:47:36 +0200 Subject: [PATCH] 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 --- backend/app/db/database.py | 25 ++ backend/app/db/models.py | 2 +- backend/app/schemas/canvas.py | 1 + backend/app/schemas/nodes.py | 2 + backend/tests/test_nodes.py | 94 ++++++++ backend/tests/test_properties_migration.py | 176 ++++++++++++++ .../canvas/__tests__/BaseNode.test.tsx | 157 ++++++++++++ .../src/components/canvas/nodes/BaseNode.tsx | 32 ++- frontend/src/components/modals/NodeModal.tsx | 84 ------- .../modals/__tests__/NodeModal.test.tsx | 85 ------- .../src/components/panels/DetailPanel.tsx | 227 ++++++++++++++++-- .../panels/__tests__/DetailPanel.test.tsx | 154 +++++++++--- frontend/src/types/index.ts | 8 + .../src/utils/__tests__/propertyIcons.test.ts | 53 ++++ frontend/src/utils/canvasSerializer.ts | 2 + frontend/src/utils/propertyIcons.ts | 53 ++++ 16 files changed, 930 insertions(+), 225 deletions(-) create mode 100644 backend/tests/test_properties_migration.py create mode 100644 frontend/src/components/canvas/__tests__/BaseNode.test.tsx create mode 100644 frontend/src/utils/__tests__/propertyIcons.test.ts create mode 100644 frontend/src/utils/propertyIcons.ts diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 955adb5..3831ef4 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -63,6 +63,31 @@ async def init_db() -> None: await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT") with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON") + with suppress(OperationalError): + await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON") + # Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL) + with suppress(OperationalError): + rows = await conn.exec_driver_sql( + "SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware " + "FROM nodes WHERE properties IS NULL" + ) + for row in rows.fetchall(): + node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row + props = [] + visible = bool(show_hardware) + if cpu_model: + props.append({"key": "CPU Model", "value": str(cpu_model), "icon": "Cpu", "visible": visible}) + if cpu_count is not None: + props.append({"key": "CPU Cores", "value": str(cpu_count), "icon": "Cpu", "visible": visible}) + if ram_gb is not None: + props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible}) + if disk_gb is not None: + props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible}) + import json as _json + await conn.exec_driver_sql( + "UPDATE nodes SET properties = ? WHERE id = ?", + (_json.dumps(props), node_id), + ) # Migrate animated column from boolean (0/1) to string ('none'/'snake') with suppress(OperationalError): await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1") diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 903dbc6..fe73763 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -42,6 +42,7 @@ class Node(Base): ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True) disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True) show_hardware: Mapped[bool] = mapped_column(Boolean, default=False) + properties: Mapped[list[Any]] = mapped_column(JSON, default=list) width: Mapped[float | None] = mapped_column(Float, nullable=True) height: Mapped[float | None] = mapped_column(Float, nullable=True) bottom_handles: Mapped[int] = mapped_column(Integer, default=1) @@ -49,7 +50,6 @@ class Node(Base): response_time_ms: Mapped[int | None] = mapped_column(Integer) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) - children: Mapped[list["Node"]] = relationship("Node", back_populates="parent") parent: Mapped["Node | None"] = relationship("Node", back_populates="children", remote_side=[id]) diff --git a/backend/app/schemas/canvas.py b/backend/app/schemas/canvas.py index ea2ec7d..18e4e31 100644 --- a/backend/app/schemas/canvas.py +++ b/backend/app/schemas/canvas.py @@ -29,6 +29,7 @@ class NodeSave(BaseModel): ram_gb: float | None = None disk_gb: float | None = None show_hardware: bool = False + properties: list[Any] = [] width: float | None = None height: float | None = None bottom_handles: int = 1 diff --git a/backend/app/schemas/nodes.py b/backend/app/schemas/nodes.py index 70156a6..a7f03e5 100644 --- a/backend/app/schemas/nodes.py +++ b/backend/app/schemas/nodes.py @@ -27,6 +27,7 @@ class NodeBase(BaseModel): ram_gb: float | None = None disk_gb: float | None = None show_hardware: bool = False + properties: list[dict[str, Any]] = [] width: float | None = None height: float | None = None bottom_handles: int = 1 @@ -59,6 +60,7 @@ class NodeUpdate(BaseModel): ram_gb: float | None = None disk_gb: float | None = None show_hardware: bool | None = None + properties: list[dict[str, Any]] | None = None width: float | None = None height: float | None = None bottom_handles: int | None = None diff --git a/backend/tests/test_nodes.py b/backend/tests/test_nodes.py index db54305..048e5c3 100644 --- a/backend/tests/test_nodes.py +++ b/backend/tests/test_nodes.py @@ -115,3 +115,97 @@ async def test_update_node_parent_id(client: AsyncClient, headers: dict): async def test_create_node_requires_auth(client: AsyncClient): res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"}) assert res.status_code == 401 + + +# --- Properties tests --- + +async def test_create_node_default_properties_empty(client: AsyncClient, headers: dict): + """New node has an empty properties list by default.""" + res = await client.post("/api/v1/nodes", json={"type": "server", "label": "Srv", "status": "unknown"}, headers=headers) + assert res.status_code == 201 + assert res.json()["properties"] == [] + + +async def test_create_node_with_properties(client: AsyncClient, headers: dict): + """Node created with properties round-trips correctly.""" + props = [ + {"key": "CPU Model", "value": "i7-12700K", "icon": "Cpu", "visible": True}, + {"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": False}, + ] + res = await client.post( + "/api/v1/nodes", + json={"type": "server", "label": "Srv", "status": "unknown", "properties": props}, + headers=headers, + ) + assert res.status_code == 201 + assert res.json()["properties"] == props + + +async def test_patch_node_properties(client: AsyncClient, headers: dict): + """PATCH with properties replaces the full properties array.""" + create = await client.post("/api/v1/nodes", json={"type": "server", "label": "Srv", "status": "unknown"}, headers=headers) + node_id = create.json()["id"] + + props = [{"key": "Disk", "value": "2 TB", "icon": "HardDrive", "visible": True}] + res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": props}, headers=headers) + assert res.status_code == 200 + assert res.json()["properties"] == props + + +async def test_patch_node_without_properties_does_not_wipe(client: AsyncClient, headers: dict): + """PATCH that omits properties leaves existing properties untouched.""" + props = [{"key": "GPU", "value": "RTX 4090", "icon": "Monitor", "visible": True}] + create = await client.post( + "/api/v1/nodes", + json={"type": "server", "label": "Srv", "status": "unknown", "properties": props}, + headers=headers, + ) + node_id = create.json()["id"] + + # PATCH only the label — properties must survive + res = await client.patch(f"/api/v1/nodes/{node_id}", json={"label": "Updated"}, headers=headers) + assert res.status_code == 200 + assert res.json()["properties"] == props + assert res.json()["label"] == "Updated" + + +async def test_patch_node_clears_properties_with_empty_array(client: AsyncClient, headers: dict): + """PATCH with properties=[] explicitly clears all properties.""" + props = [{"key": "CPU Model", "value": "i5", "icon": "Cpu", "visible": True}] + create = await client.post( + "/api/v1/nodes", + json={"type": "server", "label": "Srv", "status": "unknown", "properties": props}, + headers=headers, + ) + node_id = create.json()["id"] + + res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": []}, headers=headers) + assert res.status_code == 200 + assert res.json()["properties"] == [] + + +async def test_get_node_returns_properties(client: AsyncClient, headers: dict): + """GET /nodes/:id returns the properties field.""" + props = [{"key": "OS", "value": "Debian 12", "icon": "Server", "visible": True}] + create = await client.post( + "/api/v1/nodes", + json={"type": "server", "label": "Srv", "status": "unknown", "properties": props}, + headers=headers, + ) + node_id = create.json()["id"] + + res = await client.get(f"/api/v1/nodes/{node_id}", headers=headers) + assert res.status_code == 200 + assert res.json()["properties"] == props + + +async def test_properties_icon_can_be_null(client: AsyncClient, headers: dict): + """A property with icon=null is valid and round-trips correctly.""" + props = [{"key": "Notes", "value": "custom value", "icon": None, "visible": False}] + create = await client.post( + "/api/v1/nodes", + json={"type": "generic", "label": "G", "status": "unknown", "properties": props}, + headers=headers, + ) + assert create.status_code == 201 + assert create.json()["properties"] == props diff --git a/backend/tests/test_properties_migration.py b/backend/tests/test_properties_migration.py new file mode 100644 index 0000000..c795b95 --- /dev/null +++ b/backend/tests/test_properties_migration.py @@ -0,0 +1,176 @@ +""" +Tests for the hardware → properties migration logic. + +We test the migration function directly against an in-memory SQLite database +so we can set up legacy rows (with hardware columns, NULL properties) and +verify the migration produces the expected properties JSON. +""" +import json +import os + +os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production") + +import pytest +from sqlalchemy.ext.asyncio import create_async_engine + +TEST_DB_URL = "sqlite+aiosqlite:///:memory:" + + +async def _setup_legacy_table(conn): + """Create a minimal nodes table that mimics the pre-migration schema.""" + await conn.exec_driver_sql(""" + CREATE TABLE IF NOT EXISTS nodes ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL DEFAULT 'generic', + label TEXT NOT NULL DEFAULT '', + cpu_model TEXT, + cpu_count INTEGER, + ram_gb REAL, + disk_gb REAL, + show_hardware BOOLEAN NOT NULL DEFAULT 0, + properties JSON + ) + """) + + +async def _run_migration(conn): + """Run only the properties migration portion (extracted from init_db).""" + rows = await conn.exec_driver_sql( + "SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware " + "FROM nodes WHERE properties IS NULL" + ) + for row in rows.fetchall(): + node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row + props = [] + visible = bool(show_hardware) + if cpu_model: + props.append({"key": "CPU Model", "value": str(cpu_model), "icon": "Cpu", "visible": visible}) + if cpu_count is not None: + props.append({"key": "CPU Cores", "value": str(cpu_count), "icon": "Cpu", "visible": visible}) + if ram_gb is not None: + props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible}) + if disk_gb is not None: + props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible}) + await conn.exec_driver_sql( + "UPDATE nodes SET properties = ? WHERE id = ?", + (json.dumps(props), node_id), + ) + + +async def _get_properties(conn, node_id: str) -> list: + rows = await conn.exec_driver_sql("SELECT properties FROM nodes WHERE id = ?", (node_id,)) + raw = rows.fetchone()[0] + return json.loads(raw) if raw else [] + + +@pytest.mark.asyncio +async def test_migration_full_hardware(): + """Node with all 4 hardware fields → 4 property entries with correct icons.""" + engine = create_async_engine(TEST_DB_URL) + async with engine.begin() as conn: + await _setup_legacy_table(conn) + await conn.exec_driver_sql( + "INSERT INTO nodes (id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware) " + "VALUES (?, ?, ?, ?, ?, ?)", + ("node-1", "i7-12700K", 12, 32.0, 2000.0, 1), + ) + await _run_migration(conn) + props = await _get_properties(conn, "node-1") + + assert len(props) == 4 + assert props[0] == {"key": "CPU Model", "value": "i7-12700K", "icon": "Cpu", "visible": True} + assert props[1] == {"key": "CPU Cores", "value": "12", "icon": "Cpu", "visible": True} + assert props[2] == {"key": "RAM", "value": "32.0 GB", "icon": "MemoryStick", "visible": True} + assert props[3] == {"key": "Disk", "value": "2000.0 GB", "icon": "HardDrive", "visible": True} + await engine.dispose() + + +@pytest.mark.asyncio +async def test_migration_partial_hardware(): + """Node with only cpu_model and ram_gb → 2 property entries.""" + engine = create_async_engine(TEST_DB_URL) + async with engine.begin() as conn: + await _setup_legacy_table(conn) + await conn.exec_driver_sql( + "INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware) VALUES (?, ?, ?, ?)", + ("node-2", "Ryzen 5 5600", 16.0, 0), + ) + await _run_migration(conn) + props = await _get_properties(conn, "node-2") + + assert len(props) == 2 + assert props[0]["key"] == "CPU Model" + assert props[0]["visible"] is False + assert props[1]["key"] == "RAM" + assert props[1]["icon"] == "MemoryStick" + await engine.dispose() + + +@pytest.mark.asyncio +async def test_migration_no_hardware(): + """Node with no hardware fields → empty properties array.""" + engine = create_async_engine(TEST_DB_URL) + async with engine.begin() as conn: + await _setup_legacy_table(conn) + await conn.exec_driver_sql( + "INSERT INTO nodes (id) VALUES (?)", + ("node-3",), + ) + await _run_migration(conn) + props = await _get_properties(conn, "node-3") + + assert props == [] + await engine.dispose() + + +@pytest.mark.asyncio +async def test_migration_idempotent(): + """Running migration twice does not duplicate properties.""" + engine = create_async_engine(TEST_DB_URL) + async with engine.begin() as conn: + await _setup_legacy_table(conn) + await conn.exec_driver_sql( + "INSERT INTO nodes (id, cpu_model, show_hardware) VALUES (?, ?, ?)", + ("node-4", "Core i5", 1), + ) + await _run_migration(conn) + await _run_migration(conn) # second pass — node already has properties, should be skipped + props = await _get_properties(conn, "node-4") + + assert len(props) == 1 + await engine.dispose() + + +@pytest.mark.asyncio +async def test_migration_show_hardware_false_sets_visible_false(): + """show_hardware=0 means all migrated properties have visible=False.""" + engine = create_async_engine(TEST_DB_URL) + async with engine.begin() as conn: + await _setup_legacy_table(conn) + await conn.exec_driver_sql( + "INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware) VALUES (?, ?, ?, ?)", + ("node-5", "ARM Cortex-A72", 4.0, 0), + ) + await _run_migration(conn) + props = await _get_properties(conn, "node-5") + + assert all(p["visible"] is False for p in props) + await engine.dispose() + + +@pytest.mark.asyncio +async def test_migration_already_migrated_node_not_touched(): + """Node that already has properties is skipped — existing properties preserved.""" + existing = [{"key": "GPU", "value": "RTX 4090", "icon": "Monitor", "visible": True}] + engine = create_async_engine(TEST_DB_URL) + async with engine.begin() as conn: + await _setup_legacy_table(conn) + await conn.exec_driver_sql( + "INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware, properties) VALUES (?, ?, ?, ?, ?)", + ("node-6", "i9-13900K", 64.0, 1, json.dumps(existing)), + ) + await _run_migration(conn) + props = await _get_properties(conn, "node-6") + + assert props == existing + await engine.dispose() diff --git a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx new file mode 100644 index 0000000..8a2371b --- /dev/null +++ b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx @@ -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): Node { + 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) { + const node = makeNode(data) + return render( + + ) +} + +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() + }) +}) diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index 34aada0..0bacd34 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -4,6 +4,7 @@ import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react' import type { NodeData } from '@/types' import { resolveNodeColors } from '@/utils/nodeColors' import { resolveNodeIcon } from '@/utils/nodeIcons' +import { resolvePropertyIcon } from '@/utils/propertyIcons' import { useThemeStore } from '@/stores/themeStore' import { THEMES } from '@/utils/themes' import { useCanvasStore } from '@/stores/canvasStore' @@ -31,7 +32,11 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: const colors = resolveNodeColors(data, activeTheme) const statusColor = theme.colors.statusColors[data.status] const isOnline = data.status === 'online' - const showHardware = data.show_hardware && (data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) + + // Properties: prefer new system; fall back to legacy hardware fields for unmigrated nodes + const visibleProperties = data.properties?.filter((p) => p.visible) ?? null + const showLegacyHardware = !data.properties && data.show_hardware && + (data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) return (
- {/* Hardware section */} - {showHardware && ( + {/* Properties section (new system) */} + {visibleProperties && visibleProperties.length > 0 && ( + <> +
+
+ {visibleProperties.map((prop, i) => { + const Icon = resolvePropertyIcon(prop.icon) + return ( +
+ {Icon && } + {prop.key} + · {prop.value} +
+ ) + })} +
+ + )} + + {/* Legacy hardware section — fallback for nodes not yet migrated */} + {showLegacyHardware && ( <>
- {/* Line 1: CPU */} {(data.cpu_model || data.cpu_count != null) && (
@@ -119,7 +142,6 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: )}
)} - {/* Line 2: RAM + Disk */} {(data.ram_gb != null || data.disk_gb != null) && (
{data.ram_gb != null && ( diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index f8417de..c39f078 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -49,8 +49,6 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' const [iconSearch, setIconSearch] = useState('') const [iconPickerOpen, setIconPickerOpen] = useState(false) const [labelError, setLabelError] = useState(false) - const hasHardwareData = !!(initial?.cpu_count || initial?.cpu_model || initial?.ram_gb || initial?.disk_gb) - const [hardwareOpen, setHardwareOpen] = useState(hasHardwareData) const set = (key: keyof NodeData, value: unknown) => setForm((f) => ({ ...f, [key]: value })) @@ -334,88 +332,6 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' )}
- {/* Hardware specs (hidden for groupRect) */} - {form.type !== 'groupRect' && ( -
-
- - {hardwareOpen && ( -
- Show on node - -
- )} -
- {hardwareOpen && ( -
-
- - set('cpu_model', e.target.value || undefined)} - placeholder="e.g. Intel Xeon E5-2680" - className="bg-[#21262d] border-[#30363d] text-sm h-8" - /> -
-
- - set('cpu_count', e.target.value ? parseInt(e.target.value, 10) : undefined)} - placeholder="e.g. 8" - className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8" - /> -
-
- - set('ram_gb', e.target.value ? parseFloat(e.target.value) : undefined)} - placeholder="e.g. 32" - className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8" - /> -
-
- - set('disk_gb', e.target.value ? parseFloat(e.target.value) : undefined)} - placeholder="e.g. 500" - className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8" - /> -
-
- )} -
- )} - {/* Bottom connection points (not for group containers) */} {form.type !== 'groupRect' && form.type !== 'group' && (
diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx index adef950..4b24085 100644 --- a/frontend/src/components/modals/__tests__/NodeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/NodeModal.test.tsx @@ -293,91 +293,6 @@ describe('NodeModal', () => { expect(screen.getByText(/Using default colors for/)).toBeDefined() }) - // ── Hardware section ────────────────────────────────────────────────── - - it('renders Hardware toggle button', () => { - renderModal() - expect(screen.getByText('Hardware')).toBeDefined() - }) - - it('hardware fields are hidden by default', () => { - renderModal() - expect(screen.queryByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeNull() - }) - - it('expands hardware fields on toggle click', () => { - renderModal() - fireEvent.click(screen.getByText('Hardware')) - expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined() - expect(screen.getByPlaceholderText('e.g. 8')).toBeDefined() - expect(screen.getByPlaceholderText('e.g. 32')).toBeDefined() - expect(screen.getByPlaceholderText('e.g. 500')).toBeDefined() - }) - - it('auto-expands when initial has hardware data', () => { - renderModal({ initial: { ...BASE, cpu_count: 8, ram_gb: 32 } }) - expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined() - }) - - it('pre-fills hardware fields from initial', () => { - renderModal({ initial: { ...BASE, cpu_model: 'Intel i5', cpu_count: 4, ram_gb: 16, disk_gb: 500 } }) - expect((screen.getByPlaceholderText('e.g. Intel Xeon E5-2680') as HTMLInputElement).value).toBe('Intel i5') - }) - - it('submits hardware fields when filled', () => { - const { onSubmit } = renderModal() - fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Homelab' } }) - fireEvent.click(screen.getByText('Hardware')) - fireEvent.change(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680'), { target: { value: 'Intel i7-12700K' } }) - fireEvent.change(screen.getByPlaceholderText('e.g. 8'), { target: { value: '12' } }) - fireEvent.change(screen.getByPlaceholderText('e.g. 32'), { target: { value: '64' } }) - fireEvent.change(screen.getByPlaceholderText('e.g. 500'), { target: { value: '2000' } }) - fireEvent.click(screen.getByRole('button', { name: 'Add' })) - const data = onSubmit.mock.calls[0][0] as Partial - expect(data.cpu_model).toBe('Intel i7-12700K') - expect(data.cpu_count).toBe(12) - expect(data.ram_gb).toBe(64) - expect(data.disk_gb).toBe(2000) - }) - - it('hides Hardware section for groupRect type', () => { - renderModal({ initial: { type: 'groupRect' } }) - expect(screen.queryByText('Hardware')).toBeNull() - }) - - it('show_hardware toggle hidden when section is collapsed', () => { - renderModal() - expect(screen.queryByText('Show on node')).toBeNull() - }) - - it('show_hardware toggle appears when section is expanded', () => { - renderModal() - fireEvent.click(screen.getByText('Hardware')) - expect(screen.getByText('Show on node')).toBeDefined() - }) - - it('show_hardware defaults to falsy', () => { - const { onSubmit } = renderModal() - fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } }) - fireEvent.click(screen.getByRole('button', { name: 'Add' })) - expect(onSubmit.mock.calls[0][0].show_hardware).toBeFalsy() - }) - - it('toggling show_hardware sets it to true', () => { - const { onSubmit } = renderModal() - fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } }) - fireEvent.click(screen.getByText('Hardware')) - fireEvent.click(screen.getByRole('switch')) - fireEvent.click(screen.getByRole('button', { name: 'Add' })) - expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true) - }) - - it('pre-fills show_hardware from initial', () => { - const { onSubmit } = renderModal({ initial: { label: 'Node', show_hardware: true, cpu_count: 8 } }) - fireEvent.click(screen.getByRole('button', { name: 'Add' })) - expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true) - }) - // ── Bottom connection points ─────────────────────────────────────────── it('shows Bottom Connection Points for server type', () => { diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index 690860f..1c9d82b 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -1,10 +1,11 @@ -import { useState } from 'react' +import { createElement, useState } from 'react' import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { useCanvasStore } from '@/stores/canvasStore' -import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData } from '@/types' +import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types' import { getServiceUrl } from '@/utils/serviceUrl' +import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons' import type { Node } from '@xyflow/react' interface DetailPanelProps { @@ -14,6 +15,9 @@ interface DetailPanelProps { type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string } const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' } +type PropForm = { key: string; value: string; icon: string | null; visible: boolean } +const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true } + export function DetailPanel({ onEdit }: DetailPanelProps) { const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore() @@ -24,6 +28,12 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { const [groupName, setGroupName] = useState('') const [creatingGroup, setCreatingGroup] = useState(false) + // Properties state + const [addingProp, setAddingProp] = useState(false) + const [newProp, setNewProp] = useState(EMPTY_PROP) + const [editingPropIndex, setEditingPropIndex] = useState(null) + const [editProp, setEditProp] = useState(EMPTY_PROP) + // Multi-select panel const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id)) @@ -119,6 +129,52 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { setEditingFor(null) } + // --- Property handlers --- + const properties: NodeProperty[] = data.properties ?? [] + + const handleAddProp = () => { + if (!newProp.key.trim() || !newProp.value.trim()) return + snapshotHistory() + const prop: NodeProperty = { key: newProp.key.trim(), value: newProp.value.trim(), icon: newProp.icon, visible: newProp.visible } + updateNode(node.id, { properties: [...properties, prop] }) + setNewProp(EMPTY_PROP) + setAddingProp(false) + } + + const handleRemoveProp = (index: number) => { + snapshotHistory() + updateNode(node.id, { properties: properties.filter((_, i) => i !== index) }) + if (editingPropIndex === index) setEditingPropIndex(null) + } + + const handleTogglePropVisible = (index: number) => { + snapshotHistory() + updateNode(node.id, { + properties: properties.map((p, i) => i === index ? { ...p, visible: !p.visible } : p), + }) + } + + const handleStartEditProp = (index: number) => { + const p = properties[index] + if (!p) return + setEditProp({ key: p.key, value: p.value, icon: p.icon, visible: p.visible }) + setEditingPropIndex(index) + setAddingProp(false) + } + + const handleSaveEditProp = () => { + if (editingPropIndex === null || !editProp.key.trim() || !editProp.value.trim()) return + snapshotHistory() + updateNode(node.id, { + properties: properties.map((p, i) => + i === editingPropIndex + ? { key: editProp.key.trim(), value: editProp.value.trim(), icon: editProp.icon, visible: editProp.visible } + : p + ), + }) + setEditingPropIndex(null) + } + return (