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()
|
||||
Reference in New Issue
Block a user