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:
@@ -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")
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -102,12 +107,30 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hardware section */}
|
||||
{showHardware && (
|
||||
{/* Properties section (new system) */}
|
||||
{visibleProperties && visibleProperties.length > 0 && (
|
||||
<>
|
||||
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
||||
<div className="flex flex-col gap-1 px-2.5 py-1.5">
|
||||
{visibleProperties.map((prop, i) => {
|
||||
const Icon = resolvePropertyIcon(prop.icon)
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||
{Icon && <Icon size={9} className="shrink-0" />}
|
||||
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
|
||||
<span className="truncate" title={prop.value}>· {prop.value}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Legacy hardware section — fallback for nodes not yet migrated */}
|
||||
{showLegacyHardware && (
|
||||
<>
|
||||
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
||||
<div className="flex flex-col gap-1 px-2.5 py-1.5">
|
||||
{/* Line 1: CPU */}
|
||||
{(data.cpu_model || data.cpu_count != null) && (
|
||||
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||
<Cpu size={9} className="shrink-0" />
|
||||
@@ -119,7 +142,6 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Line 2: RAM + Disk */}
|
||||
{(data.ram_gb != null || data.disk_gb != null) && (
|
||||
<div className="flex items-center gap-2 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||
{data.ram_gb != null && (
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hardware specs (hidden for groupRect) */}
|
||||
{form.type !== 'groupRect' && (
|
||||
<div className="flex flex-col gap-2 col-span-2">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHardwareOpen((o) => !o)}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span className="font-medium">Hardware</span>
|
||||
<ChevronDown size={12} style={{ transform: hardwareOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
{hardwareOpen && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted-foreground/60">Show on node</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={!!form.show_hardware}
|
||||
onClick={() => set('show_hardware', !form.show_hardware)}
|
||||
className="relative inline-flex h-4 w-7 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none"
|
||||
style={{ background: form.show_hardware ? '#00d4ff' : '#30363d' }}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform"
|
||||
style={{ transform: form.show_hardware ? 'translateX(12px)' : 'translateX(0)' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hardwareOpen && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">CPU Model</Label>
|
||||
<Input
|
||||
value={form.cpu_model ?? ''}
|
||||
onChange={(e) => set('cpu_model', e.target.value || undefined)}
|
||||
placeholder="e.g. Intel Xeon E5-2680"
|
||||
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">CPU Cores</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.cpu_count ?? ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">RAM (GB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.5}
|
||||
value={form.ram_gb ?? ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">Disk (GB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={form.disk_gb ?? ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom connection points (not for group containers) */}
|
||||
{form.type !== 'groupRect' && form.type !== 'group' && (
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
|
||||
@@ -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<NodeData>
|
||||
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', () => {
|
||||
|
||||
@@ -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<PropForm>(EMPTY_PROP)
|
||||
const [editingPropIndex, setEditingPropIndex] = useState<number | null>(null)
|
||||
const [editProp, setEditProp] = useState<PropForm>(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 (
|
||||
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
@@ -153,15 +209,54 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
{data.last_seen && <DetailRow label="Last Seen" value={new Date(data.last_seen.endsWith('Z') ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
|
||||
</div>
|
||||
|
||||
{(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && (
|
||||
<div className="flex flex-col gap-3 px-4 py-3 text-sm border-t border-border">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Hardware</span>
|
||||
{data.cpu_model && <DetailRow label="CPU" value={data.cpu_model} />}
|
||||
{data.cpu_count != null && <DetailRow label="Cores" value={String(data.cpu_count)} mono />}
|
||||
{data.ram_gb != null && <DetailRow label="RAM" value={formatStorage(data.ram_gb)} mono />}
|
||||
{data.disk_gb != null && <DetailRow label="Disk" value={formatStorage(data.disk_gb)} mono />}
|
||||
{/* Properties section */}
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground">Properties{properties.length > 0 ? ` (${properties.length})` : ''}</span>
|
||||
<button
|
||||
onClick={() => { setAddingProp((v) => !v); setEditingPropIndex(null) }}
|
||||
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors"
|
||||
>
|
||||
<Plus size={10} /> Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{addingProp && (
|
||||
<PropertyForm
|
||||
form={newProp}
|
||||
onChange={setNewProp}
|
||||
onConfirm={handleAddProp}
|
||||
onCancel={() => { setAddingProp(false); setNewProp(EMPTY_PROP) }}
|
||||
confirmLabel="Add"
|
||||
/>
|
||||
)}
|
||||
{properties.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{properties.map((prop, i) =>
|
||||
editingPropIndex === i ? (
|
||||
<PropertyForm
|
||||
key={`edit-${i}`}
|
||||
form={editProp}
|
||||
onChange={setEditProp}
|
||||
onConfirm={handleSaveEditProp}
|
||||
onCancel={() => setEditingPropIndex(null)}
|
||||
confirmLabel="Save"
|
||||
/>
|
||||
) : (
|
||||
<PropertyBadge
|
||||
key={`${prop.key}-${i}`}
|
||||
prop={prop}
|
||||
onToggleVisible={() => handleTogglePropVisible(i)}
|
||||
onEdit={() => handleStartEditProp(i)}
|
||||
onRemove={() => handleRemoveProp(i)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{properties.length === 0 && !addingProp && (
|
||||
<p className="text-[10px] text-muted-foreground/50">No properties — click Add to define one.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
@@ -365,11 +460,6 @@ function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onS
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function formatStorage(gb: number): string {
|
||||
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
|
||||
return `${gb} GB`
|
||||
}
|
||||
|
||||
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-2 items-baseline">
|
||||
@@ -407,6 +497,113 @@ function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFo
|
||||
)
|
||||
}
|
||||
|
||||
// --- Property components ---
|
||||
|
||||
function PropertyForm({ form, onChange, onConfirm, onCancel, confirmLabel }: {
|
||||
form: PropForm
|
||||
onChange: (f: PropForm) => void
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
confirmLabel: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
|
||||
<Input
|
||||
value={form.key}
|
||||
onChange={(e) => onChange({ ...form, key: e.target.value })}
|
||||
placeholder="Label (e.g. CPU Model)"
|
||||
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
||||
autoFocus
|
||||
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
|
||||
/>
|
||||
<Input
|
||||
value={form.value}
|
||||
onChange={(e) => onChange({ ...form, value: e.target.value })}
|
||||
placeholder="Value (e.g. i7-12700K)"
|
||||
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
||||
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
|
||||
/>
|
||||
{/* Icon picker */}
|
||||
<div className="flex flex-wrap gap-1 pt-0.5">
|
||||
<button
|
||||
onClick={() => onChange({ ...form, icon: null })}
|
||||
title="No icon"
|
||||
className={`w-6 h-6 rounded flex items-center justify-center text-[10px] border transition-colors ${
|
||||
form.icon === null ? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]' : 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
|
||||
}`}
|
||||
>
|
||||
–
|
||||
</button>
|
||||
{PROPERTY_ICON_NAMES.map((name) => {
|
||||
const Icon = PROPERTY_ICONS[name]
|
||||
const active = form.icon === name
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => onChange({ ...form, icon: name })}
|
||||
title={name}
|
||||
className={`w-6 h-6 rounded flex items-center justify-center border transition-colors ${
|
||||
active ? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]' : 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
|
||||
}`}
|
||||
>
|
||||
{createElement(Icon, { size: 11 })}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Visible toggle */}
|
||||
<label className="flex items-center gap-2 cursor-pointer pt-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.visible}
|
||||
onChange={(e) => onChange({ ...form, visible: e.target.checked })}
|
||||
className="accent-[#00d4ff] w-3 h-3"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">Show on node</span>
|
||||
</label>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PropertyBadge({ prop, onToggleVisible, onEdit, onRemove }: {
|
||||
prop: NodeProperty
|
||||
onToggleVisible: () => void
|
||||
onEdit: () => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const Icon = resolvePropertyIcon(prop.icon)
|
||||
return (
|
||||
<div className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors" style={{ background: '#21262d', borderColor: '#30363d' }}>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{Icon && createElement(Icon, { size: 11, className: 'shrink-0 text-muted-foreground' })}
|
||||
<span className="font-medium truncate text-foreground" title={prop.key}>{prop.key}</span>
|
||||
<span className="text-muted-foreground truncate" title={prop.value}>· {prop.value}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={onToggleVisible}
|
||||
title={prop.visible ? 'Hide on node' : 'Show on node'}
|
||||
className="text-[#8b949e] hover:text-[#00d4ff] transition-colors"
|
||||
>
|
||||
{prop.visible ? <Eye size={10} /> : <EyeOff size={10} />}
|
||||
</button>
|
||||
<button onClick={onEdit} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff]" title="Edit property">
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
<button onClick={onRemove} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149]" title="Remove property">
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
|
||||
}
|
||||
|
||||
@@ -69,58 +69,137 @@ describe('DetailPanel', () => {
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
describe('Hardware section', () => {
|
||||
it('does not render hardware section when no hardware data', () => {
|
||||
setupStore({ label: 'Server' })
|
||||
describe('Properties section', () => {
|
||||
it('renders empty state when no properties', () => {
|
||||
setupStore({ properties: [] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.queryByText('Hardware')).toBeNull()
|
||||
expect(screen.getByText(/No properties/)).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders hardware section when cpu_count is set', () => {
|
||||
setupStore({ cpu_count: 8 })
|
||||
it('renders properties with key and value', () => {
|
||||
setupStore({
|
||||
properties: [
|
||||
{ key: 'CPU Model', value: 'i7-12700K', icon: 'Cpu', visible: true },
|
||||
{ key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: false },
|
||||
],
|
||||
})
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('Hardware')).toBeDefined()
|
||||
expect(screen.getByText('8')).toBeDefined()
|
||||
expect(screen.getByText('CPU Model')).toBeDefined()
|
||||
// Value is rendered with a middle-dot prefix: "· 32 GB"
|
||||
expect(screen.getByText(/32 GB/)).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders cpu_model', () => {
|
||||
setupStore({ cpu_model: 'Intel Xeon E5-2680' })
|
||||
it('shows Properties count when properties exist', () => {
|
||||
setupStore({
|
||||
properties: [
|
||||
{ key: 'CPU Model', value: 'i7', icon: null, visible: true },
|
||||
{ key: 'RAM', value: '16 GB', icon: null, visible: true },
|
||||
],
|
||||
})
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('Intel Xeon E5-2680')).toBeDefined()
|
||||
expect(screen.getByText('Properties (2)')).toBeDefined()
|
||||
})
|
||||
|
||||
it('formats ram_gb in GB', () => {
|
||||
setupStore({ ram_gb: 32 })
|
||||
it('shows add form when Add is clicked', () => {
|
||||
setupStore({ properties: [] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('32 GB')).toBeDefined()
|
||||
// There are multiple "Add" buttons (services + properties) — find the one after "Properties"
|
||||
const addButtons = screen.getAllByText('Add')
|
||||
fireEvent.click(addButtons[0]) // first Add = properties (rendered above services)
|
||||
expect(screen.getByPlaceholderText('Label (e.g. CPU Model)')).toBeDefined()
|
||||
})
|
||||
|
||||
it('formats ram_gb >= 1024 as TB', () => {
|
||||
setupStore({ ram_gb: 2048 })
|
||||
it('calls updateNode with new property on Add confirm', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ properties: [] })],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('2 TB')).toBeDefined()
|
||||
const addButtons = screen.getAllByText('Add')
|
||||
fireEvent.click(addButtons[0]) // first Add = properties
|
||||
// Form is now open — fill key and value
|
||||
fireEvent.change(screen.getByPlaceholderText('Label (e.g. CPU Model)'), { target: { value: 'GPU' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { target: { value: 'RTX 4090' } })
|
||||
// The PropertyForm confirm button is labeled "Add" — use the form's confirm button
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { key: 'Enter' })
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
const [, payload] = updateNode.mock.calls[0]
|
||||
expect(payload.properties[0]).toMatchObject({ key: 'GPU', value: 'RTX 4090', visible: true })
|
||||
})
|
||||
|
||||
it('formats disk_gb in GB', () => {
|
||||
setupStore({ disk_gb: 500 })
|
||||
it('calls updateNode with toggled visibility when eye button is clicked', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ properties: [{ key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: true }] })],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('500 GB')).toBeDefined()
|
||||
fireEvent.click(screen.getByTitle('Hide on node'))
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
const [, payload] = updateNode.mock.calls[0]
|
||||
expect(payload.properties[0].visible).toBe(false)
|
||||
})
|
||||
|
||||
it('formats disk_gb >= 1024 as TB', () => {
|
||||
setupStore({ disk_gb: 1536 })
|
||||
it('calls updateNode without the property when remove button is clicked', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ properties: [{ key: 'GPU', value: 'RTX 4090', icon: null, visible: true }] })],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('1.5 TB')).toBeDefined()
|
||||
fireEvent.click(screen.getByTitle('Remove property'))
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
const [, payload] = updateNode.mock.calls[0]
|
||||
expect(payload.properties).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders all hardware fields together', () => {
|
||||
setupStore({ cpu_count: 16, cpu_model: 'AMD EPYC', ram_gb: 128, disk_gb: 4096 })
|
||||
it('does not submit add form when key is empty', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ properties: [] })],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('Hardware')).toBeDefined()
|
||||
expect(screen.getByText('AMD EPYC')).toBeDefined()
|
||||
expect(screen.getByText('16')).toBeDefined()
|
||||
expect(screen.getByText('128 GB')).toBeDefined()
|
||||
expect(screen.getByText('4 TB')).toBeDefined()
|
||||
const addButtons = screen.getAllByText('Add')
|
||||
fireEvent.click(addButtons[0])
|
||||
// Only fill value, leave key empty
|
||||
fireEvent.change(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { target: { value: 'some value' } })
|
||||
const confirmButtons = screen.getAllByRole('button', { name: 'Add' })
|
||||
fireEvent.click(confirmButtons[confirmButtons.length - 1])
|
||||
expect(updateNode).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -189,7 +268,9 @@ describe('DetailPanel', () => {
|
||||
it('shows add form when Add is clicked', () => {
|
||||
setupStore({})
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
// Two "Add" buttons: first = properties, second = services
|
||||
const addButtons = screen.getAllByText('Add')
|
||||
fireEvent.click(addButtons[addButtons.length - 1])
|
||||
expect(screen.getByPlaceholderText('Service name')).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -198,18 +279,21 @@ describe('DetailPanel', () => {
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({})],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
// Two "Add" header buttons: first = properties, second = services
|
||||
const addHeaders = screen.getAllByText('Add')
|
||||
fireEvent.click(addHeaders[addHeaders.length - 1])
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
|
||||
// Two "Add" buttons exist: the header toggle and the form confirm — pick the form's
|
||||
const addButtons = screen.getAllByRole('button', { name: 'Add' })
|
||||
fireEvent.click(addButtons[addButtons.length - 1])
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('Port'), { key: 'Enter' })
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp' })
|
||||
})
|
||||
|
||||
@@ -43,6 +43,13 @@ export interface ServiceInfo {
|
||||
category?: string
|
||||
}
|
||||
|
||||
export interface NodeProperty {
|
||||
key: string
|
||||
value: string
|
||||
icon: string | null
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
export interface NodeData extends Record<string, unknown> {
|
||||
label: string
|
||||
type: NodeType
|
||||
@@ -62,6 +69,7 @@ export interface NodeData extends Record<string, unknown> {
|
||||
ram_gb?: number
|
||||
disk_gb?: number
|
||||
show_hardware?: boolean
|
||||
properties?: NodeProperty[]
|
||||
parent_id?: string
|
||||
container_mode?: boolean
|
||||
custom_colors?: {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { Cpu, HardDrive, MemoryStick } from 'lucide-react'
|
||||
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '../propertyIcons'
|
||||
|
||||
describe('PROPERTY_ICONS', () => {
|
||||
it('contains the hardware migration icons', () => {
|
||||
expect(PROPERTY_ICONS['Cpu']).toBe(Cpu)
|
||||
expect(PROPERTY_ICONS['HardDrive']).toBe(HardDrive)
|
||||
expect(PROPERTY_ICONS['MemoryStick']).toBe(MemoryStick)
|
||||
})
|
||||
|
||||
it('has at least 10 icons', () => {
|
||||
expect(Object.keys(PROPERTY_ICONS).length).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('every value is a renderable component (function or object)', () => {
|
||||
for (const [, icon] of Object.entries(PROPERTY_ICONS)) {
|
||||
// Lucide icons can be functions or forwardRef objects depending on environment
|
||||
expect(icon).toBeTruthy()
|
||||
expect(['function', 'object']).toContain(typeof icon)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('PROPERTY_ICON_NAMES', () => {
|
||||
it('matches the keys of PROPERTY_ICONS', () => {
|
||||
expect(PROPERTY_ICON_NAMES).toEqual(expect.arrayContaining(Object.keys(PROPERTY_ICONS)))
|
||||
expect(PROPERTY_ICON_NAMES.length).toBe(Object.keys(PROPERTY_ICONS).length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolvePropertyIcon', () => {
|
||||
it('returns the icon for a known name', () => {
|
||||
expect(resolvePropertyIcon('Cpu')).toBe(Cpu)
|
||||
expect(resolvePropertyIcon('HardDrive')).toBe(HardDrive)
|
||||
})
|
||||
|
||||
it('returns null for null input', () => {
|
||||
expect(resolvePropertyIcon(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for undefined input', () => {
|
||||
expect(resolvePropertyIcon(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for unknown icon name', () => {
|
||||
expect(resolvePropertyIcon('NotARealIcon')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(resolvePropertyIcon('')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -28,6 +28,7 @@ export interface ApiNode extends Record<string, unknown> {
|
||||
ram_gb?: number | null
|
||||
disk_gb?: number | null
|
||||
show_hardware?: boolean
|
||||
properties?: unknown[] | null
|
||||
width?: number | null
|
||||
height?: number | null
|
||||
bottom_handles?: number
|
||||
@@ -100,6 +101,7 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
||||
ram_gb: n.data.ram_gb ?? null,
|
||||
disk_gb: n.data.disk_gb ?? null,
|
||||
show_hardware: n.data.show_hardware ?? false,
|
||||
properties: n.data.properties ?? [],
|
||||
width: n.width ?? null,
|
||||
height: n.height ?? null,
|
||||
bottom_handles: n.data.bottom_handles ?? 1,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Battery,
|
||||
Box,
|
||||
Clock,
|
||||
Cpu,
|
||||
Database,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Hash,
|
||||
Key,
|
||||
Layers,
|
||||
Link,
|
||||
MemoryStick,
|
||||
Monitor,
|
||||
Network,
|
||||
Server,
|
||||
Shield,
|
||||
Tag,
|
||||
Thermometer,
|
||||
Wifi,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export const PROPERTY_ICONS: Record<string, LucideIcon> = {
|
||||
Battery,
|
||||
Box,
|
||||
Clock,
|
||||
Cpu,
|
||||
Database,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Hash,
|
||||
Key,
|
||||
Layers,
|
||||
Link,
|
||||
MemoryStick,
|
||||
Monitor,
|
||||
Network,
|
||||
Server,
|
||||
Shield,
|
||||
Tag,
|
||||
Thermometer,
|
||||
Wifi,
|
||||
Zap,
|
||||
}
|
||||
|
||||
export const PROPERTY_ICON_NAMES = Object.keys(PROPERTY_ICONS) as (keyof typeof PROPERTY_ICONS)[]
|
||||
|
||||
export function resolvePropertyIcon(name: string | null | undefined): LucideIcon | null {
|
||||
if (!name) return null
|
||||
return PROPERTY_ICONS[name] ?? null
|
||||
}
|
||||
Reference in New Issue
Block a user