Compare commits

...

2 Commits

Author SHA1 Message Date
Pouzor 3d89ba3b6f 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
2026-04-09 13:47:36 +02:00
Pouzor 3afc8ed3d8 fix: persist edge waypoints in backend
Add waypoints JSON column to edges table, include it in all edge
schemas (EdgeBase, EdgeUpdate, canvas CanvasEdge) and add the
idempotent ALTER TABLE migration so existing databases are upgraded
on next startup.
2026-04-08 23:31:53 +02:00
17 changed files with 936 additions and 225 deletions
+27
View File
@@ -61,6 +61,33 @@ async def init_db() -> None:
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1")
with suppress(OperationalError): with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT") 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') # Migrate animated column from boolean (0/1) to string ('none'/'snake')
with suppress(OperationalError): with suppress(OperationalError):
await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1") await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1")
+2 -1
View File
@@ -42,6 +42,7 @@ class Node(Base):
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True) ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
disk_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) 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) width: Mapped[float | None] = mapped_column(Float, nullable=True)
height: 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) 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) response_time_ms: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
children: Mapped[list["Node"]] = relationship("Node", back_populates="parent") children: Mapped[list["Node"]] = relationship("Node", back_populates="parent")
parent: Mapped["Node | None"] = relationship("Node", back_populates="children", remote_side=[id]) parent: Mapped["Node | None"] = relationship("Node", back_populates="children", remote_side=[id])
@@ -69,6 +69,7 @@ class Edge(Base):
animated: Mapped[str] = mapped_column(String, nullable=False, default='none') animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
source_handle: Mapped[str | None] = mapped_column(String) source_handle: Mapped[str | None] = mapped_column(String)
target_handle: Mapped[str | None] = mapped_column(String) target_handle: Mapped[str | None] = mapped_column(String)
waypoints: Mapped[list | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+2
View File
@@ -29,6 +29,7 @@ class NodeSave(BaseModel):
ram_gb: float | None = None ram_gb: float | None = None
disk_gb: float | None = None disk_gb: float | None = None
show_hardware: bool = False show_hardware: bool = False
properties: list[Any] = []
width: float | None = None width: float | None = None
height: float | None = None height: float | None = None
bottom_handles: int = 1 bottom_handles: int = 1
@@ -49,6 +50,7 @@ class EdgeSave(BaseModel):
animated: str = 'none' animated: str = 'none'
source_handle: str | None = None source_handle: str | None = None
target_handle: str | None = None target_handle: str | None = None
waypoints: list | None = None
@field_validator('animated', mode='before') @field_validator('animated', mode='before')
@classmethod @classmethod
+2
View File
@@ -17,6 +17,7 @@ class EdgeBase(BaseModel):
animated: str = 'none' animated: str = 'none'
source_handle: str | None = None source_handle: str | None = None
target_handle: str | None = None target_handle: str | None = None
waypoints: list | None = None
@field_validator('animated', mode='before') @field_validator('animated', mode='before')
@classmethod @classmethod
@@ -38,6 +39,7 @@ class EdgeUpdate(BaseModel):
animated: str | None = None animated: str | None = None
source_handle: str | None = None source_handle: str | None = None
target_handle: str | None = None target_handle: str | None = None
waypoints: list | None = None
@field_validator('animated', mode='before') @field_validator('animated', mode='before')
@classmethod @classmethod
+2
View File
@@ -27,6 +27,7 @@ class NodeBase(BaseModel):
ram_gb: float | None = None ram_gb: float | None = None
disk_gb: float | None = None disk_gb: float | None = None
show_hardware: bool = False show_hardware: bool = False
properties: list[dict[str, Any]] = []
width: float | None = None width: float | None = None
height: float | None = None height: float | None = None
bottom_handles: int = 1 bottom_handles: int = 1
@@ -59,6 +60,7 @@ class NodeUpdate(BaseModel):
ram_gb: float | None = None ram_gb: float | None = None
disk_gb: float | None = None disk_gb: float | None = None
show_hardware: bool | None = None show_hardware: bool | None = None
properties: list[dict[str, Any]] | None = None
width: float | None = None width: float | None = None
height: float | None = None height: float | None = None
bottom_handles: int | None = None bottom_handles: int | None = None
+94
View File
@@ -115,3 +115,97 @@ async def test_update_node_parent_id(client: AsyncClient, headers: dict):
async def test_create_node_requires_auth(client: AsyncClient): async def test_create_node_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"}) res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"})
assert res.status_code == 401 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
+176
View File
@@ -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 type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors' import { resolveNodeColors } from '@/utils/nodeColors'
import { resolveNodeIcon } from '@/utils/nodeIcons' import { resolveNodeIcon } from '@/utils/nodeIcons'
import { resolvePropertyIcon } from '@/utils/propertyIcons'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes' import { THEMES } from '@/utils/themes'
import { useCanvasStore } from '@/stores/canvasStore' 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 colors = resolveNodeColors(data, activeTheme)
const statusColor = theme.colors.statusColors[data.status] const statusColor = theme.colors.statusColors[data.status]
const isOnline = data.status === 'online' 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 ( return (
<div <div
@@ -102,12 +107,30 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
</div> </div>
</div> </div>
{/* Hardware section */} {/* Properties section (new system) */}
{showHardware && ( {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 style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
<div className="flex flex-col gap-1 px-2.5 py-1.5"> <div className="flex flex-col gap-1 px-2.5 py-1.5">
{/* Line 1: CPU */}
{(data.cpu_model || data.cpu_count != null) && ( {(data.cpu_model || data.cpu_count != null) && (
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}> <div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
<Cpu size={9} className="shrink-0" /> <Cpu size={9} className="shrink-0" />
@@ -119,7 +142,6 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
)} )}
</div> </div>
)} )}
{/* Line 2: RAM + Disk */}
{(data.ram_gb != null || data.disk_gb != null) && ( {(data.ram_gb != null || data.disk_gb != null) && (
<div className="flex items-center gap-2 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}> <div className="flex items-center gap-2 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
{data.ram_gb != null && ( {data.ram_gb != null && (
@@ -49,8 +49,6 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
const [iconSearch, setIconSearch] = useState('') const [iconSearch, setIconSearch] = useState('')
const [iconPickerOpen, setIconPickerOpen] = useState(false) const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [labelError, setLabelError] = 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) => const set = (key: keyof NodeData, value: unknown) =>
setForm((f) => ({ ...f, [key]: value })) setForm((f) => ({ ...f, [key]: value }))
@@ -334,88 +332,6 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
)} )}
</div> </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) */} {/* Bottom connection points (not for group containers) */}
{form.type !== 'groupRect' && form.type !== 'group' && ( {form.type !== 'groupRect' && form.type !== 'group' && (
<div className="flex flex-col gap-1.5 col-span-2"> <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() 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 ─────────────────────────────────────────── // ── Bottom connection points ───────────────────────────────────────────
it('shows Bottom Connection Points for server type', () => { it('shows Bottom Connection Points for server type', () => {
+211 -14
View File
@@ -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 { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useCanvasStore } from '@/stores/canvasStore' 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 { getServiceUrl } from '@/utils/serviceUrl'
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
import type { Node } from '@xyflow/react' import type { Node } from '@xyflow/react'
interface DetailPanelProps { interface DetailPanelProps {
@@ -14,6 +15,9 @@ interface DetailPanelProps {
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string } type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string }
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' } 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) { export function DetailPanel({ onEdit }: DetailPanelProps) {
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore() 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 [groupName, setGroupName] = useState('')
const [creatingGroup, setCreatingGroup] = useState(false) 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 // Multi-select panel
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id)) const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
@@ -119,6 +129,52 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
setEditingFor(null) 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 ( return (
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto"> <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"> <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()} />} {data.last_seen && <DetailRow label="Last Seen" value={new Date(data.last_seen.endsWith('Z') ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
</div> </div>
{(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && ( {/* Properties section */}
<div className="flex flex-col gap-3 px-4 py-3 text-sm border-t border-border"> <div className="px-4 py-3 border-t border-border">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Hardware</span> <div className="flex items-center justify-between mb-2">
{data.cpu_model && <DetailRow label="CPU" value={data.cpu_model} />} <span className="text-xs text-muted-foreground">Properties{properties.length > 0 ? ` (${properties.length})` : ''}</span>
{data.cpu_count != null && <DetailRow label="Cores" value={String(data.cpu_count)} mono />} <button
{data.ram_gb != null && <DetailRow label="RAM" value={formatStorage(data.ram_gb)} mono />} onClick={() => { setAddingProp((v) => !v); setEditingPropIndex(null) }}
{data.disk_gb != null && <DetailRow label="Disk" value={formatStorage(data.disk_gb)} mono />} 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> </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="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
@@ -365,11 +460,6 @@ function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onS
// --- Helpers --- // --- 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 }) { function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return ( return (
<div className="flex justify-between gap-2 items-baseline"> <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> = { const CATEGORY_COLORS: Record<string, string> = {
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e', web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
} }
@@ -69,58 +69,137 @@ describe('DetailPanel', () => {
expect(container.firstChild).toBeNull() expect(container.firstChild).toBeNull()
}) })
describe('Hardware section', () => { describe('Properties section', () => {
it('does not render hardware section when no hardware data', () => { it('renders empty state when no properties', () => {
setupStore({ label: 'Server' }) setupStore({ properties: [] })
render(<DetailPanel onEdit={vi.fn()} />) 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', () => { it('renders properties with key and value', () => {
setupStore({ cpu_count: 8 }) 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()} />) render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('Hardware')).toBeDefined() expect(screen.getByText('CPU Model')).toBeDefined()
expect(screen.getByText('8')).toBeDefined() // Value is rendered with a middle-dot prefix: "· 32 GB"
expect(screen.getByText(/32 GB/)).toBeDefined()
}) })
it('renders cpu_model', () => { it('shows Properties count when properties exist', () => {
setupStore({ cpu_model: 'Intel Xeon E5-2680' }) 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()} />) 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', () => { it('shows add form when Add is clicked', () => {
setupStore({ ram_gb: 32 }) setupStore({ properties: [] })
render(<DetailPanel onEdit={vi.fn()} />) 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', () => { it('calls updateNode with new property on Add confirm', () => {
setupStore({ ram_gb: 2048 }) 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()} />) 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', () => { it('calls updateNode with toggled visibility when eye button is clicked', () => {
setupStore({ disk_gb: 500 }) 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()} />) 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', () => { it('calls updateNode without the property when remove button is clicked', () => {
setupStore({ disk_gb: 1536 }) 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()} />) 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', () => { it('does not submit add form when key is empty', () => {
setupStore({ cpu_count: 16, cpu_model: 'AMD EPYC', ram_gb: 128, disk_gb: 4096 }) 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()} />) render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('Hardware')).toBeDefined() const addButtons = screen.getAllByText('Add')
expect(screen.getByText('AMD EPYC')).toBeDefined() fireEvent.click(addButtons[0])
expect(screen.getByText('16')).toBeDefined() // Only fill value, leave key empty
expect(screen.getByText('128 GB')).toBeDefined() fireEvent.change(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { target: { value: 'some value' } })
expect(screen.getByText('4 TB')).toBeDefined() 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', () => { it('shows add form when Add is clicked', () => {
setupStore({}) setupStore({})
render(<DetailPanel onEdit={vi.fn()} />) 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() expect(screen.getByPlaceholderText('Service name')).toBeDefined()
}) })
@@ -198,18 +279,21 @@ describe('DetailPanel', () => {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({})], nodes: [makeNode({})],
selectedNodeId: 'n1', selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(), setSelectedNode: vi.fn(),
deleteNode: vi.fn(), deleteNode: vi.fn(),
updateNode, updateNode,
snapshotHistory: vi.fn(), snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) } as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />) 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('Service name'), { target: { value: 'nginx' } })
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } }) fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
// Two "Add" buttons exist: the header toggle and the form confirm — pick the form's fireEvent.keyDown(screen.getByPlaceholderText('Port'), { key: 'Enter' })
const addButtons = screen.getAllByRole('button', { name: 'Add' })
fireEvent.click(addButtons[addButtons.length - 1])
expect(updateNode).toHaveBeenCalledOnce() expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp' }) expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp' })
}) })
+8
View File
@@ -43,6 +43,13 @@ export interface ServiceInfo {
category?: string category?: string
} }
export interface NodeProperty {
key: string
value: string
icon: string | null
visible: boolean
}
export interface NodeData extends Record<string, unknown> { export interface NodeData extends Record<string, unknown> {
label: string label: string
type: NodeType type: NodeType
@@ -62,6 +69,7 @@ export interface NodeData extends Record<string, unknown> {
ram_gb?: number ram_gb?: number
disk_gb?: number disk_gb?: number
show_hardware?: boolean show_hardware?: boolean
properties?: NodeProperty[]
parent_id?: string parent_id?: string
container_mode?: boolean container_mode?: boolean
custom_colors?: { 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()
})
})
+2
View File
@@ -28,6 +28,7 @@ export interface ApiNode extends Record<string, unknown> {
ram_gb?: number | null ram_gb?: number | null
disk_gb?: number | null disk_gb?: number | null
show_hardware?: boolean show_hardware?: boolean
properties?: unknown[] | null
width?: number | null width?: number | null
height?: number | null height?: number | null
bottom_handles?: number bottom_handles?: number
@@ -100,6 +101,7 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
ram_gb: n.data.ram_gb ?? null, ram_gb: n.data.ram_gb ?? null,
disk_gb: n.data.disk_gb ?? null, disk_gb: n.data.disk_gb ?? null,
show_hardware: n.data.show_hardware ?? false, show_hardware: n.data.show_hardware ?? false,
properties: n.data.properties ?? [],
width: n.width ?? null, width: n.width ?? null,
height: n.height ?? null, height: n.height ?? null,
bottom_handles: n.data.bottom_handles ?? 1, bottom_handles: n.data.bottom_handles ?? 1,
+53
View File
@@ -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
}