diff --git a/.gitignore b/.gitignore index 3ab8a23..252f8e0 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ htmlcov/ *.db *.db-shm *.db-wal +*.db.back # Docker .docker/ diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 6717f45..3831ef4 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -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") with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT") + with suppress(OperationalError): + await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON") + with suppress(OperationalError): + await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON") + # Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL) + with suppress(OperationalError): + rows = await conn.exec_driver_sql( + "SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware " + "FROM nodes WHERE properties IS NULL" + ) + for row in rows.fetchall(): + node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row + props = [] + visible = bool(show_hardware) + if cpu_model: + props.append({"key": "CPU Model", "value": str(cpu_model), "icon": "Cpu", "visible": visible}) + if cpu_count is not None: + props.append({"key": "CPU Cores", "value": str(cpu_count), "icon": "Cpu", "visible": visible}) + if ram_gb is not None: + props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible}) + if disk_gb is not None: + props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible}) + import json as _json + await conn.exec_driver_sql( + "UPDATE nodes SET properties = ? WHERE id = ?", + (_json.dumps(props), node_id), + ) # Migrate animated column from boolean (0/1) to string ('none'/'snake') with suppress(OperationalError): await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1") diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 0aac1b0..5c004fb 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -42,6 +42,7 @@ class Node(Base): ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True) disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True) show_hardware: Mapped[bool] = mapped_column(Boolean, default=False) + properties: Mapped[list[Any]] = mapped_column(JSON, default=list) width: Mapped[float | None] = mapped_column(Float, nullable=True) height: Mapped[float | None] = mapped_column(Float, nullable=True) bottom_handles: Mapped[int] = mapped_column(Integer, default=1) @@ -49,7 +50,6 @@ class Node(Base): response_time_ms: Mapped[int | None] = mapped_column(Integer) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) - children: Mapped[list["Node"]] = relationship("Node", back_populates="parent") parent: Mapped["Node | None"] = relationship("Node", back_populates="children", remote_side=[id]) @@ -69,6 +69,7 @@ class Edge(Base): animated: Mapped[str] = mapped_column(String, nullable=False, default='none') source_handle: Mapped[str | None] = mapped_column(String) target_handle: Mapped[str | None] = mapped_column(String) + waypoints: Mapped[list[dict[str, float]] | None] = mapped_column(JSON, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) diff --git a/backend/app/main.py b/backend/app/main.py index 65fdb84..7446e89 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -35,7 +35,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app = FastAPI( title="Homelable API", - version="1.8.3", + version="1.9.0", lifespan=lifespan, ) diff --git a/backend/app/schemas/canvas.py b/backend/app/schemas/canvas.py index 5f1e5d4..1eccd96 100644 --- a/backend/app/schemas/canvas.py +++ b/backend/app/schemas/canvas.py @@ -29,6 +29,7 @@ class NodeSave(BaseModel): ram_gb: float | None = None disk_gb: float | None = None show_hardware: bool = False + properties: list[Any] = [] width: float | None = None height: float | None = None bottom_handles: int = 1 @@ -49,6 +50,7 @@ class EdgeSave(BaseModel): animated: str = 'none' source_handle: str | None = None target_handle: str | None = None + waypoints: list[dict[str, float]] | None = None @field_validator('animated', mode='before') @classmethod diff --git a/backend/app/schemas/edges.py b/backend/app/schemas/edges.py index a663806..8baa45b 100644 --- a/backend/app/schemas/edges.py +++ b/backend/app/schemas/edges.py @@ -17,6 +17,7 @@ class EdgeBase(BaseModel): animated: str = 'none' source_handle: str | None = None target_handle: str | None = None + waypoints: list[dict[str, float]] | None = None @field_validator('animated', mode='before') @classmethod @@ -38,6 +39,7 @@ class EdgeUpdate(BaseModel): animated: str | None = None source_handle: str | None = None target_handle: str | None = None + waypoints: list[dict[str, float]] | None = None @field_validator('animated', mode='before') @classmethod diff --git a/backend/app/schemas/nodes.py b/backend/app/schemas/nodes.py index 70156a6..a7f03e5 100644 --- a/backend/app/schemas/nodes.py +++ b/backend/app/schemas/nodes.py @@ -27,6 +27,7 @@ class NodeBase(BaseModel): ram_gb: float | None = None disk_gb: float | None = None show_hardware: bool = False + properties: list[dict[str, Any]] = [] width: float | None = None height: float | None = None bottom_handles: int = 1 @@ -59,6 +60,7 @@ class NodeUpdate(BaseModel): ram_gb: float | None = None disk_gb: float | None = None show_hardware: bool | None = None + properties: list[dict[str, Any]] | None = None width: float | None = None height: float | None = None bottom_handles: int | None = None diff --git a/backend/app/schemas/utils.py b/backend/app/schemas/utils.py index fab5721..21e8770 100644 --- a/backend/app/schemas/utils.py +++ b/backend/app/schemas/utils.py @@ -4,6 +4,6 @@ def normalize_animated(v: object) -> str: return 'snake' if v is False or v == 0 or v == '0' or v is None or v == 'none': return 'none' - if v in ('snake', 'flow'): + if v in ('snake', 'flow', 'basic'): return str(v) return 'none' diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3b785ca..fba9bcf 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -25,6 +25,8 @@ addopts = "--tb=short -q" [tool.coverage.run] source = ["app"] omit = ["*/migrations/*", "*/tests/*"] +concurrency = ["thread"] +core = "sysmon" [tool.coverage.report] skip_empty = true diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index b4c6633..7b25f25 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -256,3 +256,295 @@ async def test_save_canvas_dimensions_cleared_when_null(client: AsyncClient, hea canvas = (await client.get("/api/v1/canvas", headers=headers)).json() assert canvas["nodes"][0]["width"] is None assert canvas["nodes"][0]["height"] is None + + +# ── properties ──────────────────────────────────────────────────────────────── + +async def test_save_canvas_properties_default_empty(client: AsyncClient, headers: dict): + n1 = node_payload() + await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + assert canvas["nodes"][0]["properties"] == [] + + +async def test_save_canvas_persists_properties(client: AsyncClient, headers: dict): + props = [ + {"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": True}, + {"key": "CPU", "value": "Intel i9", "icon": "Cpu", "visible": False}, + ] + n1 = node_payload(properties=props) + await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + returned = canvas["nodes"][0]["properties"] + assert len(returned) == 2 + assert returned[0] == {"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": True} + assert returned[1] == {"key": "CPU", "value": "Intel i9", "icon": "Cpu", "visible": False} + + +async def test_save_canvas_properties_updated_on_second_save(client: AsyncClient, headers: dict): + n1 = node_payload(properties=[{"key": "RAM", "value": "16 GB", "icon": None, "visible": True}]) + await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) + + n1_updated = {**n1, "properties": [ + {"key": "RAM", "value": "64 GB", "icon": "MemoryStick", "visible": True}, + {"key": "Disk", "value": "2 TB", "icon": "HardDrive", "visible": True}, + ]} + await client.post("/api/v1/canvas/save", json={"nodes": [n1_updated], "edges": [], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + props = canvas["nodes"][0]["properties"] + assert len(props) == 2 + assert props[0]["value"] == "64 GB" + assert props[1]["key"] == "Disk" + + +async def test_save_canvas_properties_with_null_icon(client: AsyncClient, headers: dict): + props = [{"key": "Note", "value": "custom rack", "icon": None, "visible": True}] + n1 = node_payload(properties=props) + await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + assert canvas["nodes"][0]["properties"][0]["icon"] is None + + +async def test_save_canvas_properties_cleared_to_empty(client: AsyncClient, headers: dict): + n1 = node_payload(properties=[{"key": "RAM", "value": "32 GB", "icon": None, "visible": True}]) + await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) + + n1_cleared = {**n1, "properties": []} + await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + assert canvas["nodes"][0]["properties"] == [] + + +# ── edge waypoints & handles ────────────────────────────────────────────────── + +async def test_save_canvas_edge_waypoints_default_null(client: AsyncClient, headers: dict): + n1 = node_payload() + n2 = node_payload() + e1 = edge_payload(n1["id"], n2["id"]) + await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + assert canvas["edges"][0]["waypoints"] is None + + +async def test_save_canvas_persists_waypoints_on_edge(client: AsyncClient, headers: dict): + n1 = node_payload() + n2 = node_payload() + waypoints = [{"x": 100.0, "y": 200.0}, {"x": 300.0, "y": 150.0}] + e1 = edge_payload(n1["id"], n2["id"], waypoints=waypoints) + await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + returned = canvas["edges"][0]["waypoints"] + assert returned == [{"x": 100.0, "y": 200.0}, {"x": 300.0, "y": 150.0}] + + +async def test_save_canvas_waypoints_updated_on_second_save(client: AsyncClient, headers: dict): + n1 = node_payload() + n2 = node_payload() + e1 = edge_payload(n1["id"], n2["id"], waypoints=[{"x": 10.0, "y": 20.0}]) + await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers) + + e1_updated = {**e1, "waypoints": [{"x": 50.0, "y": 60.0}, {"x": 70.0, "y": 80.0}]} + await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1_updated], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + assert canvas["edges"][0]["waypoints"] == [{"x": 50.0, "y": 60.0}, {"x": 70.0, "y": 80.0}] + + +async def test_save_canvas_persists_edge_handles(client: AsyncClient, headers: dict): + n1 = node_payload(bottom_handles=3) + n2 = node_payload() + e1 = edge_payload(n1["id"], n2["id"], source_handle="bottom-1", target_handle="top") + await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + edge = canvas["edges"][0] + assert edge["source_handle"] == "bottom-1" + assert edge["target_handle"] == "top" + + +async def test_save_canvas_persists_animated_edge(client: AsyncClient, headers: dict): + n1 = node_payload() + n2 = node_payload() + e1 = edge_payload(n1["id"], n2["id"], animated="snake") + await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + assert canvas["edges"][0]["animated"] == "snake" + + +async def test_save_canvas_persists_animated_basic(client: AsyncClient, headers: dict): + n1 = node_payload() + n2 = node_payload() + e1 = edge_payload(n1["id"], n2["id"], animated="basic") + await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + assert canvas["edges"][0]["animated"] == "basic" + + +# ── node fields ─────────────────────────────────────────────────────────────── + +async def test_save_canvas_persists_all_node_fields(client: AsyncClient, headers: dict): + n1 = node_payload( + type="server", + label="Main Server", + hostname="server.local", + ip="192.168.1.10", + mac="aa:bb:cc:dd:ee:ff", + os="Ubuntu 22.04", + status="online", + check_method="http", + check_target="http://192.168.1.10", + services=[{"name": "nginx", "port": 80}], + notes="Primary web server", + pos_x=150.0, + pos_y=250.0, + bottom_handles=2, + ) + await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + node = canvas["nodes"][0] + assert node["hostname"] == "server.local" + assert node["ip"] == "192.168.1.10" + assert node["mac"] == "aa:bb:cc:dd:ee:ff" + assert node["os"] == "Ubuntu 22.04" + assert node["status"] == "online" + assert node["check_method"] == "http" + assert node["check_target"] == "http://192.168.1.10" + assert node["services"] == [{"name": "nginx", "port": 80}] + assert node["notes"] == "Primary web server" + assert node["pos_x"] == 150.0 + assert node["pos_y"] == 250.0 + assert node["bottom_handles"] == 2 + + +async def test_save_canvas_persists_bottom_handles(client: AsyncClient, headers: dict): + n1 = node_payload(bottom_handles=4) + await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + assert canvas["nodes"][0]["bottom_handles"] == 4 + + +async def test_save_canvas_bottom_handles_defaults_one(client: AsyncClient, headers: dict): + n1 = node_payload() + await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + assert canvas["nodes"][0]["bottom_handles"] == 1 + + +async def test_save_canvas_persists_services_and_notes(client: AsyncClient, headers: dict): + services = [{"name": "ssh", "port": 22}, {"name": "http", "port": 80}] + n1 = node_payload(services=services, notes="My NAS device") + await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + node = canvas["nodes"][0] + assert node["services"] == services + assert node["notes"] == "My NAS device" + + +async def test_save_canvas_persists_check_fields(client: AsyncClient, headers: dict): + n1 = node_payload(check_method="ping", check_target="192.168.1.1") + await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + node = canvas["nodes"][0] + assert node["check_method"] == "ping" + assert node["check_target"] == "192.168.1.1" + + +# ── parent/child nodes ──────────────────────────────────────────────────────── + +async def test_save_canvas_persists_parent_child_nodes(client: AsyncClient, headers: dict): + parent = node_payload(type="proxmox", label="PVE Host") + child = node_payload(type="vm", label="VM-100", parent_id=parent["id"]) + await client.post("/api/v1/canvas/save", json={"nodes": [parent, child], "edges": [], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + node_map = {n["id"]: n for n in canvas["nodes"]} + assert node_map[child["id"]]["parent_id"] == parent["id"] + assert node_map[parent["id"]]["parent_id"] is None + + +async def test_save_canvas_child_removed_with_parent(client: AsyncClient, headers: dict): + parent = node_payload(type="proxmox", label="PVE Host") + child = node_payload(type="lxc", label="LXC-101", parent_id=parent["id"]) + await client.post("/api/v1/canvas/save", json={"nodes": [parent, child], "edges": [], "viewport": {}}, headers=headers) + + # Remove both parent and child + await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + assert canvas["nodes"] == [] + + +# ── groupRect / group node ──────────────────────────────────────────────────── + +async def test_save_canvas_persists_group_node(client: AsyncClient, headers: dict): + group = node_payload(type="group", label="Network Zone", width=400.0, height=300.0) + member = node_payload(type="server", label="Member", parent_id=group["id"]) + await client.post("/api/v1/canvas/save", json={"nodes": [group, member], "edges": [], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + node_map = {n["id"]: n for n in canvas["nodes"]} + assert node_map[group["id"]]["type"] == "group" + assert node_map[group["id"]]["width"] == 400.0 + assert node_map[group["id"]]["height"] == 300.0 + assert node_map[member["id"]]["parent_id"] == group["id"] + + +# ── viewport ────────────────────────────────────────────────────────────────── + +async def test_load_canvas_returns_default_viewport_when_no_state(client: AsyncClient, headers: dict): + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + assert canvas["viewport"] == {"x": 0, "y": 0, "zoom": 1} + + +async def test_save_canvas_updates_existing_canvas_state(client: AsyncClient, headers: dict): + """Second save updates the existing CanvasState row (exercises the state.viewport branch).""" + await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 1, "y": 2, "zoom": 1}}, headers=headers) + await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 99, "y": 88, "zoom": 0.75}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + assert canvas["viewport"] == {"x": 99, "y": 88, "zoom": 0.75} + + +# ── edge types ──────────────────────────────────────────────────────────────── + +async def test_save_canvas_persists_edge_type_vlan(client: AsyncClient, headers: dict): + n1 = node_payload() + n2 = node_payload() + e1 = edge_payload(n1["id"], n2["id"], type="vlan", vlan_id=10, label="VLAN 10") + await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + edge = canvas["edges"][0] + assert edge["type"] == "vlan" + assert edge["vlan_id"] == 10 + assert edge["label"] == "VLAN 10" + + +async def test_save_canvas_edge_update_existing(client: AsyncClient, headers: dict): + """Second save updates an existing edge (exercises the db_edge branch).""" + n1 = node_payload() + n2 = node_payload() + e1 = edge_payload(n1["id"], n2["id"], label="original") + await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers) + + e1_updated = {**e1, "label": "updated", "custom_color": "#ff0000"} + await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1_updated], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + edge = canvas["edges"][0] + assert edge["label"] == "updated" + assert edge["custom_color"] == "#ff0000" diff --git a/backend/tests/test_nodes.py b/backend/tests/test_nodes.py index db54305..048e5c3 100644 --- a/backend/tests/test_nodes.py +++ b/backend/tests/test_nodes.py @@ -115,3 +115,97 @@ async def test_update_node_parent_id(client: AsyncClient, headers: dict): async def test_create_node_requires_auth(client: AsyncClient): res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"}) assert res.status_code == 401 + + +# --- Properties tests --- + +async def test_create_node_default_properties_empty(client: AsyncClient, headers: dict): + """New node has an empty properties list by default.""" + res = await client.post("/api/v1/nodes", json={"type": "server", "label": "Srv", "status": "unknown"}, headers=headers) + assert res.status_code == 201 + assert res.json()["properties"] == [] + + +async def test_create_node_with_properties(client: AsyncClient, headers: dict): + """Node created with properties round-trips correctly.""" + props = [ + {"key": "CPU Model", "value": "i7-12700K", "icon": "Cpu", "visible": True}, + {"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": False}, + ] + res = await client.post( + "/api/v1/nodes", + json={"type": "server", "label": "Srv", "status": "unknown", "properties": props}, + headers=headers, + ) + assert res.status_code == 201 + assert res.json()["properties"] == props + + +async def test_patch_node_properties(client: AsyncClient, headers: dict): + """PATCH with properties replaces the full properties array.""" + create = await client.post("/api/v1/nodes", json={"type": "server", "label": "Srv", "status": "unknown"}, headers=headers) + node_id = create.json()["id"] + + props = [{"key": "Disk", "value": "2 TB", "icon": "HardDrive", "visible": True}] + res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": props}, headers=headers) + assert res.status_code == 200 + assert res.json()["properties"] == props + + +async def test_patch_node_without_properties_does_not_wipe(client: AsyncClient, headers: dict): + """PATCH that omits properties leaves existing properties untouched.""" + props = [{"key": "GPU", "value": "RTX 4090", "icon": "Monitor", "visible": True}] + create = await client.post( + "/api/v1/nodes", + json={"type": "server", "label": "Srv", "status": "unknown", "properties": props}, + headers=headers, + ) + node_id = create.json()["id"] + + # PATCH only the label — properties must survive + res = await client.patch(f"/api/v1/nodes/{node_id}", json={"label": "Updated"}, headers=headers) + assert res.status_code == 200 + assert res.json()["properties"] == props + assert res.json()["label"] == "Updated" + + +async def test_patch_node_clears_properties_with_empty_array(client: AsyncClient, headers: dict): + """PATCH with properties=[] explicitly clears all properties.""" + props = [{"key": "CPU Model", "value": "i5", "icon": "Cpu", "visible": True}] + create = await client.post( + "/api/v1/nodes", + json={"type": "server", "label": "Srv", "status": "unknown", "properties": props}, + headers=headers, + ) + node_id = create.json()["id"] + + res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": []}, headers=headers) + assert res.status_code == 200 + assert res.json()["properties"] == [] + + +async def test_get_node_returns_properties(client: AsyncClient, headers: dict): + """GET /nodes/:id returns the properties field.""" + props = [{"key": "OS", "value": "Debian 12", "icon": "Server", "visible": True}] + create = await client.post( + "/api/v1/nodes", + json={"type": "server", "label": "Srv", "status": "unknown", "properties": props}, + headers=headers, + ) + node_id = create.json()["id"] + + res = await client.get(f"/api/v1/nodes/{node_id}", headers=headers) + assert res.status_code == 200 + assert res.json()["properties"] == props + + +async def test_properties_icon_can_be_null(client: AsyncClient, headers: dict): + """A property with icon=null is valid and round-trips correctly.""" + props = [{"key": "Notes", "value": "custom value", "icon": None, "visible": False}] + create = await client.post( + "/api/v1/nodes", + json={"type": "generic", "label": "G", "status": "unknown", "properties": props}, + headers=headers, + ) + assert create.status_code == 201 + assert create.json()["properties"] == props diff --git a/backend/tests/test_properties_migration.py b/backend/tests/test_properties_migration.py new file mode 100644 index 0000000..c795b95 --- /dev/null +++ b/backend/tests/test_properties_migration.py @@ -0,0 +1,176 @@ +""" +Tests for the hardware → properties migration logic. + +We test the migration function directly against an in-memory SQLite database +so we can set up legacy rows (with hardware columns, NULL properties) and +verify the migration produces the expected properties JSON. +""" +import json +import os + +os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production") + +import pytest +from sqlalchemy.ext.asyncio import create_async_engine + +TEST_DB_URL = "sqlite+aiosqlite:///:memory:" + + +async def _setup_legacy_table(conn): + """Create a minimal nodes table that mimics the pre-migration schema.""" + await conn.exec_driver_sql(""" + CREATE TABLE IF NOT EXISTS nodes ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL DEFAULT 'generic', + label TEXT NOT NULL DEFAULT '', + cpu_model TEXT, + cpu_count INTEGER, + ram_gb REAL, + disk_gb REAL, + show_hardware BOOLEAN NOT NULL DEFAULT 0, + properties JSON + ) + """) + + +async def _run_migration(conn): + """Run only the properties migration portion (extracted from init_db).""" + rows = await conn.exec_driver_sql( + "SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware " + "FROM nodes WHERE properties IS NULL" + ) + for row in rows.fetchall(): + node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row + props = [] + visible = bool(show_hardware) + if cpu_model: + props.append({"key": "CPU Model", "value": str(cpu_model), "icon": "Cpu", "visible": visible}) + if cpu_count is not None: + props.append({"key": "CPU Cores", "value": str(cpu_count), "icon": "Cpu", "visible": visible}) + if ram_gb is not None: + props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible}) + if disk_gb is not None: + props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible}) + await conn.exec_driver_sql( + "UPDATE nodes SET properties = ? WHERE id = ?", + (json.dumps(props), node_id), + ) + + +async def _get_properties(conn, node_id: str) -> list: + rows = await conn.exec_driver_sql("SELECT properties FROM nodes WHERE id = ?", (node_id,)) + raw = rows.fetchone()[0] + return json.loads(raw) if raw else [] + + +@pytest.mark.asyncio +async def test_migration_full_hardware(): + """Node with all 4 hardware fields → 4 property entries with correct icons.""" + engine = create_async_engine(TEST_DB_URL) + async with engine.begin() as conn: + await _setup_legacy_table(conn) + await conn.exec_driver_sql( + "INSERT INTO nodes (id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware) " + "VALUES (?, ?, ?, ?, ?, ?)", + ("node-1", "i7-12700K", 12, 32.0, 2000.0, 1), + ) + await _run_migration(conn) + props = await _get_properties(conn, "node-1") + + assert len(props) == 4 + assert props[0] == {"key": "CPU Model", "value": "i7-12700K", "icon": "Cpu", "visible": True} + assert props[1] == {"key": "CPU Cores", "value": "12", "icon": "Cpu", "visible": True} + assert props[2] == {"key": "RAM", "value": "32.0 GB", "icon": "MemoryStick", "visible": True} + assert props[3] == {"key": "Disk", "value": "2000.0 GB", "icon": "HardDrive", "visible": True} + await engine.dispose() + + +@pytest.mark.asyncio +async def test_migration_partial_hardware(): + """Node with only cpu_model and ram_gb → 2 property entries.""" + engine = create_async_engine(TEST_DB_URL) + async with engine.begin() as conn: + await _setup_legacy_table(conn) + await conn.exec_driver_sql( + "INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware) VALUES (?, ?, ?, ?)", + ("node-2", "Ryzen 5 5600", 16.0, 0), + ) + await _run_migration(conn) + props = await _get_properties(conn, "node-2") + + assert len(props) == 2 + assert props[0]["key"] == "CPU Model" + assert props[0]["visible"] is False + assert props[1]["key"] == "RAM" + assert props[1]["icon"] == "MemoryStick" + await engine.dispose() + + +@pytest.mark.asyncio +async def test_migration_no_hardware(): + """Node with no hardware fields → empty properties array.""" + engine = create_async_engine(TEST_DB_URL) + async with engine.begin() as conn: + await _setup_legacy_table(conn) + await conn.exec_driver_sql( + "INSERT INTO nodes (id) VALUES (?)", + ("node-3",), + ) + await _run_migration(conn) + props = await _get_properties(conn, "node-3") + + assert props == [] + await engine.dispose() + + +@pytest.mark.asyncio +async def test_migration_idempotent(): + """Running migration twice does not duplicate properties.""" + engine = create_async_engine(TEST_DB_URL) + async with engine.begin() as conn: + await _setup_legacy_table(conn) + await conn.exec_driver_sql( + "INSERT INTO nodes (id, cpu_model, show_hardware) VALUES (?, ?, ?)", + ("node-4", "Core i5", 1), + ) + await _run_migration(conn) + await _run_migration(conn) # second pass — node already has properties, should be skipped + props = await _get_properties(conn, "node-4") + + assert len(props) == 1 + await engine.dispose() + + +@pytest.mark.asyncio +async def test_migration_show_hardware_false_sets_visible_false(): + """show_hardware=0 means all migrated properties have visible=False.""" + engine = create_async_engine(TEST_DB_URL) + async with engine.begin() as conn: + await _setup_legacy_table(conn) + await conn.exec_driver_sql( + "INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware) VALUES (?, ?, ?, ?)", + ("node-5", "ARM Cortex-A72", 4.0, 0), + ) + await _run_migration(conn) + props = await _get_properties(conn, "node-5") + + assert all(p["visible"] is False for p in props) + await engine.dispose() + + +@pytest.mark.asyncio +async def test_migration_already_migrated_node_not_touched(): + """Node that already has properties is skipped — existing properties preserved.""" + existing = [{"key": "GPU", "value": "RTX 4090", "icon": "Monitor", "visible": True}] + engine = create_async_engine(TEST_DB_URL) + async with engine.begin() as conn: + await _setup_legacy_table(conn) + await conn.exec_driver_sql( + "INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware, properties) VALUES (?, ?, ?, ?, ?)", + ("node-6", "i9-13900K", 64.0, 1, json.dumps(existing)), + ) + await _run_migration(conn) + props = await _get_properties(conn, "node-6") + + assert props == existing + await engine.dispose() diff --git a/frontend/package.json b/frontend/package.json index 271ae8c..d8ac74a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.8.3", + "version": "1.9.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 54dbfb5..a36b9d0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -357,6 +357,13 @@ export default function App() { setEditEdgeId(null) }, [editEdgeId, deleteEdge, snapshotHistory]) + const handleClearWaypoints = useCallback(() => { + if (!editEdgeId) return + snapshotHistory() + updateEdge(editEdgeId, { waypoints: [] }) + setEditEdgeId(null) + }, [editEdgeId, updateEdge, snapshotHistory]) + const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null @@ -446,6 +453,7 @@ export default function App() { onClose={() => setEditEdgeId(null)} onSubmit={handleEdgeUpdate} onDelete={handleEdgeDelete} + onClearWaypoints={handleClearWaypoints} initial={editEdge?.data} title="Edit Link" /> diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index 5c80571..3847dc6 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -98,7 +98,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o > diff --git a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx new file mode 100644 index 0000000..8a2371b --- /dev/null +++ b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx @@ -0,0 +1,157 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { Server } from 'lucide-react' +import { BaseNode } from '../nodes/BaseNode' +import type { NodeData } from '@/types' +import type { Node } from '@xyflow/react' + +vi.mock('@xyflow/react', () => ({ + Handle: () => null, + Position: { Top: 'top', Bottom: 'bottom' }, + NodeResizer: () => null, + useUpdateNodeInternals: () => vi.fn(), +})) + +vi.mock('@/stores/themeStore', () => ({ + useThemeStore: () => 'dark', +})) + +vi.mock('@/stores/canvasStore', () => ({ + useCanvasStore: () => ({ hideIp: false }), +})) + +vi.mock('@/utils/themes', () => ({ + THEMES: { + dark: { + colors: { + statusColors: { online: '#39d353', offline: '#f85149', pending: '#e3b341', unknown: '#8b949e' }, + nodeSubtextColor: '#8b949e', + nodeLabelColor: '#e6edf3', + nodeIconBackground: '#21262d', + handleBackground: '#30363d', + handleBorder: '#30363d', + }, + }, + }, +})) + +vi.mock('@/utils/nodeColors', () => ({ + resolveNodeColors: () => ({ background: '#161b22', border: '#30363d', icon: '#00d4ff' }), +})) + +vi.mock('@/utils/nodeIcons', () => ({ + resolveNodeIcon: (_typeIcon: unknown) => _typeIcon, +})) + +vi.mock('@/utils/maskIp', () => ({ + maskIp: (ip: string) => ip, +})) + +vi.mock('@/utils/handleUtils', () => ({ + BOTTOM_HANDLE_IDS: ['bottom'], + BOTTOM_HANDLE_POSITIONS: { 1: [50] }, +})) + +function makeNode(data: Partial): Node { + return { + id: 'n1', + type: data.type ?? 'server', + position: { x: 0, y: 0 }, + data: { + label: 'Test Node', + type: 'server', + status: 'online', + services: [], + ...data, + }, + } +} + +function renderBaseNode(data: Partial) { + const node = makeNode(data) + return render( + + ) +} + +describe('BaseNode — properties rendering', () => { + it('renders visible properties on the node', () => { + renderBaseNode({ + properties: [ + { key: 'CPU Model', value: 'i7-12700K', icon: 'Cpu', visible: true }, + { key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: true }, + ], + }) + expect(screen.getByText('CPU Model')).toBeDefined() + // Value is rendered with a middle-dot prefix: "· 32 GB" + expect(screen.getByText(/32 GB/)).toBeDefined() + }) + + it('does not render properties with visible=false', () => { + renderBaseNode({ + properties: [ + { key: 'Secret', value: 'hidden', icon: null, visible: false }, + ], + }) + expect(screen.queryByText('Secret')).toBeNull() + }) + + it('renders nothing when properties array is empty', () => { + const { container } = renderBaseNode({ properties: [] }) + // No properties section — only the main node card + expect(container.querySelectorAll('.flex.flex-col.gap-1').length).toBe(0) + }) + + it('renders label and ip regardless of properties', () => { + renderBaseNode({ + label: 'My Server', + ip: '192.168.1.10', + properties: [{ key: 'OS', value: 'Debian 12', icon: 'Server', visible: true }], + }) + expect(screen.getByText('My Server')).toBeDefined() + expect(screen.getByText('192.168.1.10')).toBeDefined() + expect(screen.getByText('OS')).toBeDefined() + }) +}) + +describe('BaseNode — legacy hardware fallback', () => { + it('renders legacy hardware when properties is undefined and show_hardware is true', () => { + renderBaseNode({ + properties: undefined, + show_hardware: true, + cpu_model: 'Intel Xeon E5-2680', + ram_gb: 32, + }) + expect(screen.getByText('Intel Xeon E5-2680')).toBeDefined() + }) + + it('does not render legacy hardware when properties array is present (even if empty)', () => { + renderBaseNode({ + properties: [], + show_hardware: true, + cpu_model: 'Intel Xeon E5-2680', + }) + // properties array exists → new system, legacy section skipped + expect(screen.queryByText('Intel Xeon E5-2680')).toBeNull() + }) + + it('does not render legacy hardware when show_hardware is false', () => { + renderBaseNode({ + properties: undefined, + show_hardware: false, + cpu_model: 'Intel Xeon E5-2680', + }) + expect(screen.queryByText('Intel Xeon E5-2680')).toBeNull() + }) +}) diff --git a/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts b/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts new file mode 100644 index 0000000..a42a943 --- /dev/null +++ b/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect } from 'vitest' +import { buildWaypointPath, distToSegment, findInsertIndex, snap45, snap45both } from '../waypointUtils' + +describe('buildWaypointPath — bezier (default)', () => { + it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => { + // With only 2 pts (src + target), catmull-rom = cubic bezier + const path = buildWaypointPath(0, 0, [], 100, 100) + expect(path).toMatch(/^M 0 0 C/) + }) + + it('routes through a single waypoint with smooth curve', () => { + const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }], 100, 100) + expect(path).toMatch(/^M 0 0 C/) + // Should not be a straight polyline + expect(path).not.toContain(' L ') + }) + + it('routes through multiple waypoints', () => { + const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100) + expect(path).toMatch(/^M 0 0 C/) + }) +}) + +describe('buildWaypointPath — smooth style', () => { + it('builds a direct straight line with no waypoints (no bend)', () => { + // Only 2 points → no intermediate vertex → no rounding needed + expect(buildWaypointPath(0, 0, [], 100, 100, 'smooth')).toBe('M 0 0 L 100 100') + }) + + it('routes through a single waypoint with straight lines (no intermediate bend)', () => { + // 3 pts: src → wp → target — only 1 intermediate → rounded corners at wp + const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }], 100, 100, 'smooth') + // Should start at source and end at target + expect(path).toMatch(/^M 0 0/) + expect(path).toMatch(/100 100$/) + // Should contain a quadratic bezier at the waypoint corner + expect(path).toContain('Q') + }) + + it('routes through multiple waypoints with rounded corners', () => { + const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 'smooth') + expect(path).toMatch(/^M 0 0/) + expect(path).toMatch(/100 100$/) + expect(path).toContain('Q') + }) + + it('does not round corners when segment is too short (r clamped to 0)', () => { + // Adjacent waypoints very close together — r → 0, falls back to L + const path = buildWaypointPath(0, 0, [{ x: 1, y: 0 }, { x: 2, y: 0 }], 100, 0, 'smooth') + expect(path).toMatch(/^M 0 0/) + }) +}) + +describe('snap45', () => { + // Use positions very close to a 45° angle so deviation < SNAP_THRESHOLD (15px) + it('snaps horizontal direction when close (deviation < threshold)', () => { + // (100, 3) — nearly horizontal, deviation from 0° ≈ 3px → snaps + const r = snap45({ x: 0, y: 0 }, { x: 100, y: 3 }) + expect(r.y).toBe(0) + expect(r.x).toBeGreaterThan(0) + }) + + it('snaps vertical direction when close', () => { + const r = snap45({ x: 0, y: 0 }, { x: 3, y: 100 }) + expect(r.x).toBe(0) + expect(r.y).toBeGreaterThan(0) + }) + + it('snaps 45° diagonal when close', () => { + // (80, 83) — nearly 45°, deviation ≈ 2px → snaps + const r = snap45({ x: 0, y: 0 }, { x: 80, y: 83 }) + expect(r.x).toBe(r.y) + }) + + it('does NOT snap when deviation exceeds threshold', () => { + // (100, 40) — deviation from 0° is ~40px > 15 → no snap + const pos = { x: 100, y: 40 } + const r = snap45({ x: 0, y: 0 }, pos) + expect(r).toEqual(pos) + }) + + it('returns pos unchanged when distance < 1', () => { + const pos = { x: 5, y: 5 } + expect(snap45({ x: 5, y: 5 }, pos)).toBe(pos) + }) + + it('preserves distance from origin when snapping', () => { + const from = { x: 0, y: 0 } + const pos = { x: 100, y: 3 } // close to horizontal + const r = snap45(from, pos) + const origDist = Math.hypot(pos.x - from.x, pos.y - from.y) + const snapDist = Math.hypot(r.x - from.x, r.y - from.y) + expect(snapDist).toBeCloseTo(origDist, 0) + }) +}) + +describe('snap45both', () => { + it('finds intersection satisfying 45° from both adjacent points (axis-aligned)', () => { + // prev=(0,0), next=(100,100): diagonal — midpoint (50,50) should satisfy both + const r = snap45both({ x: 0, y: 0 }, { x: 100, y: 100 }, { x: 50, y: 50 }) + // Result must be on a 45°-ray from (0,0) + const a1 = Math.atan2(r.y - 0, r.x - 0) / (Math.PI / 4) + expect(Math.abs(a1 - Math.round(a1))).toBeLessThan(0.05) + // Result must be on a 45°-ray from (100,100) + const a2 = Math.atan2(r.y - 100, r.x - 100) / (Math.PI / 4) + expect(Math.abs(a2 - Math.round(a2))).toBeLessThan(0.05) + }) + + it('snaps so both incoming and outgoing segments are at 45° when within threshold', () => { + // prev=(0,0), next=(200,0) — valid intersection at (100,100) (45° from each) + // pos=(100,93) is 7px away → within 15px threshold → should snap to (100,100) + const r = snap45both({ x: 0, y: 0 }, { x: 200, y: 0 }, { x: 100, y: 93 }) + const a1 = Math.atan2(r.y - 0, r.x - 0) / (Math.PI / 4) + expect(Math.abs(a1 - Math.round(a1))).toBeLessThan(0.05) + const a2 = Math.atan2(r.y - 0, r.x - 200) / (Math.PI / 4) + expect(Math.abs(a2 - Math.round(a2))).toBeLessThan(0.05) + }) + + it('returns raw pos when beyond threshold', () => { + // pos=(100,80) is 20px from nearest intersection (100,100) → no snap + const pos = { x: 100, y: 80 } + const r = snap45both({ x: 0, y: 0 }, { x: 200, y: 0 }, pos) + expect(r).toEqual(pos) + }) + + it('falls back gracefully when prev === next', () => { + // No valid intersection → fallback to snap45 + const r = snap45both({ x: 50, y: 50 }, { x: 50, y: 50 }, { x: 100, y: 90 }) + expect(r).toBeDefined() + }) +}) + +describe('distToSegment', () => { + it('returns 0 when point is on the segment', () => { + expect(distToSegment({ x: 50, y: 0 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(0) + }) + + it('returns perpendicular distance when point is beside segment', () => { + expect(distToSegment({ x: 50, y: 10 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(10) + }) + + it('returns distance to nearest endpoint when point is past the segment', () => { + expect(distToSegment({ x: 200, y: 0 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(100) + }) + + it('handles zero-length segment (a === b)', () => { + expect(distToSegment({ x: 3, y: 4 }, { x: 0, y: 0 }, { x: 0, y: 0 })).toBeCloseTo(5) + }) +}) + +describe('findInsertIndex', () => { + it('returns 0 when there are no waypoints (only one segment)', () => { + expect(findInsertIndex(0, 0, [], 100, 0, { x: 50, y: 5 })).toBe(0) + }) + + it('inserts before first waypoint when click is on first segment', () => { + const idx = findInsertIndex(0, 0, [{ x: 100, y: 0 }], 200, 0, { x: 30, y: 5 }) + expect(idx).toBe(0) + }) + + it('inserts after first waypoint when click is on second segment', () => { + const idx = findInsertIndex(0, 0, [{ x: 100, y: 0 }], 200, 0, { x: 160, y: 5 }) + expect(idx).toBe(1) + }) + + it('picks the closest segment among multiple', () => { + const idx = findInsertIndex( + 0, 0, + [{ x: 100, y: 0 }, { x: 100, y: 100 }], + 200, 100, + { x: 150, y: 105 }, + ) + expect(idx).toBe(2) + }) +}) diff --git a/frontend/src/components/canvas/edges/index.tsx b/frontend/src/components/canvas/edges/index.tsx index 9917d25..fcb05ba 100644 --- a/frontend/src/components/canvas/edges/index.tsx +++ b/frontend/src/components/canvas/edges/index.tsx @@ -1,15 +1,19 @@ +import { useCallback } from 'react' import { BaseEdge, EdgeLabelRenderer, getBezierPath, getSmoothStepPath, + useReactFlow, useStore, type EdgeProps, type Edge, } from '@xyflow/react' -import type { EdgeData, EdgeType } from '@/types' +import type { EdgeData, EdgeType, Waypoint } from '@/types' import { useThemeStore } from '@/stores/themeStore' +import { useCanvasStore } from '@/stores/canvasStore' import { THEMES } from '@/utils/themes' +import { buildWaypointPath, snap45, snap45both } from './waypointUtils' const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149'] @@ -18,6 +22,165 @@ function getVlanColor(vlanId?: number): string { return VLAN_COLORS[vlanId % VLAN_COLORS.length] } +// ── Waypoint drag handle ───────────────────────────────────────────────────── + +interface WaypointHandleProps { + edgeId: string + index: number + waypoint: Waypoint + waypoints: Waypoint[] + color: string + pathStyle?: string + prevPoint: Waypoint + nextPoint: Waypoint +} + +function WaypointHandle({ edgeId, index, waypoint, waypoints, color, pathStyle, prevPoint, nextPoint }: WaypointHandleProps) { + const { screenToFlowPosition } = useReactFlow() + const updateEdge = useCanvasStore((s) => s.updateEdge) + + const handlePointerDown = useCallback((e: React.PointerEvent) => { + e.stopPropagation() + e.currentTarget.setPointerCapture(e.pointerId) + }, []) + + const handlePointerMove = useCallback((e: React.PointerEvent) => { + if (e.buttons !== 1) return + let pos = screenToFlowPosition({ x: e.clientX, y: e.clientY }) + if (pathStyle === 'smooth') { + // Find the intersection of 45°-rays from both adjacent points so that + // ALL segments (prev→this and this→next) snap to 45° simultaneously. + pos = snap45both(prevPoint, nextPoint, pos) + } + const next = [...waypoints] + next[index] = pos + updateEdge(edgeId, { waypoints: next }) + }, [screenToFlowPosition, waypoints, index, edgeId, updateEdge, pathStyle, prevPoint, nextPoint]) + + const handlePointerUp = useCallback((e: React.PointerEvent) => { + e.currentTarget.releasePointerCapture(e.pointerId) + }, []) + + const handleDoubleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + updateEdge(edgeId, { waypoints: waypoints.filter((_, i) => i !== index) }) + }, [edgeId, waypoints, index, updateEdge]) + + return ( +
+ ) +} + +// ── Add waypoint handle (+ button at segment midpoints) ────────────────────── + +interface AddWaypointHandleProps { + edgeId: string + insertIndex: number + x: number + y: number + waypoints: Waypoint[] + color: string + pathStyle?: string + prevPoint: Waypoint +} + +function AddWaypointHandle({ edgeId, insertIndex, x, y, waypoints, color, pathStyle, prevPoint }: AddWaypointHandleProps) { + const updateEdge = useCanvasStore((s) => s.updateEdge) + + const handleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + let pos = { x, y } + if (pathStyle === 'smooth') pos = snap45(prevPoint, pos) + const next = [...waypoints.slice(0, insertIndex), pos, ...waypoints.slice(insertIndex)] + updateEdge(edgeId, { waypoints: next }) + }, [edgeId, insertIndex, x, y, waypoints, updateEdge, pathStyle, prevPoint]) + + return ( +
+ + +
+ ) +} + +// ── Segment midpoints ──────────────────────────────────────────────────────── + +/** + * Compute + handle positions for each path segment. + * For smooth style: bias the first + handle to the source handle axis and the + * last + handle to the target handle axis, so clicking always gives a clean + * perpendicular exit/entry (no diagonal guesswork near the nodes). + */ +function segmentMidpoints( + sourceX: number, sourceY: number, + waypoints: Waypoint[], + targetX: number, targetY: number, + pathStyle?: string, + sourcePosition?: string, +): { x: number; y: number; insertIndex: number }[] { + const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }] + const isSmooth = pathStyle === 'smooth' + + return pts.slice(0, -1).map((a, i) => { + const b = pts[i + 1] + let mx = (a.x + b.x) / 2 + const my = (a.y + b.y) / 2 + + // For smooth style with no existing waypoints, bias the single + handle onto + // the source handle axis so clicking it creates a perpendicular exit. + // Only applies to bottom/top handles (vertical exits) and only when the edge + // has no waypoints yet — once waypoints exist, all + handles stay at the + // real segment midpoint so they remain visually on the edge. + if (isSmooth && i === 0 && pts.length === 2) { + const vertSrc = sourcePosition === 'bottom' || sourcePosition === 'top' + if (vertSrc) mx = a.x // same X as source → + sits directly below/above node + } + + return { x: mx, y: my, insertIndex: i } + }) +} + +// ── Main edge component ────────────────────────────────────────────────────── + export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps>) { const activeTheme = useThemeStore((s) => s.activeTheme) const theme = THEMES[activeTheme] @@ -25,11 +188,26 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t const targetType = useStore((s) => s.nodeLookup.get(target)?.type) const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox' + const waypoints: Waypoint[] = Array.isArray(data?.waypoints) && data.waypoints.length > 0 + ? data.waypoints as Waypoint[] + : [] + + const hasWaypoints = waypoints.length > 0 + + const pathStyle = data?.path_style as string | undefined + const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition } - const [edgePath, labelX] = data?.path_style === 'smooth' + const [autoPath, labelX] = pathStyle === 'smooth' ? getSmoothStepPath({ ...pathArgs, borderRadius: 8 }) : getBezierPath(pathArgs) + const edgePath = hasWaypoints + ? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle) + : autoPath + + const midX = hasWaypoints ? (sourceX + targetX) / 2 : labelX + const midY = (sourceY + targetY) / 2 + const edgeType: EdgeType = data?.type ?? 'ethernet' const edgeColors = theme.colors.edgeColors @@ -43,6 +221,11 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t } const customColor = data?.custom_color as string | undefined + const strokeColor: string = selected + ? theme.colors.edgeSelectedColor + : customColor + ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : (BASE_STYLES[edgeType].stroke as string ?? edgeColors.ethernet)) + const style: React.CSSProperties = { ...BASE_STYLES[edgeType], ...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}), @@ -50,16 +233,36 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t ...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}), } - // Normalize animated value — supports legacy boolean (true → 'snake') - const animMode: 'none' | 'snake' | 'flow' = + const animMode: 'none' | 'snake' | 'flow' | 'basic' = data?.animated === true || data?.animated === 'snake' ? 'snake' : - data?.animated === 'flow' ? 'flow' : 'none' + data?.animated === 'flow' ? 'flow' : + data?.animated === 'basic' ? 'basic' : 'none' const animColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string) + const midpoints = selected + ? segmentMidpoints(sourceX, sourceY, waypoints, targetX, targetY, pathStyle, sourcePosition) + : [] + return ( <> - + + + {animMode === 'basic' && ( + + )} + {animMode === 'snake' && ( )} - {data?.label && ( - + + {data?.label && (
{data.label as string}
-
- )} + )} + + {/* Existing waypoint drag handles */} + {selected && waypoints.map((wp, idx) => { + const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1] + const nextPoint = idx === waypoints.length - 1 ? { x: targetX, y: targetY } : waypoints[idx + 1] + return ( + + ) + })} + + {/* + handles at segment midpoints to add new waypoints */} + {selected && midpoints.map((mp) => { + const prevPoint = mp.insertIndex === 0 + ? { x: sourceX, y: sourceY } + : waypoints[mp.insertIndex - 1] + return ( + + ) + })} +
) } diff --git a/frontend/src/components/canvas/edges/waypointUtils.ts b/frontend/src/components/canvas/edges/waypointUtils.ts new file mode 100644 index 0000000..9dc5066 --- /dev/null +++ b/frontend/src/components/canvas/edges/waypointUtils.ts @@ -0,0 +1,167 @@ +import type { Waypoint } from '@/types' + +// ── Path builders ───────────────────────────────────────────────────────────── + +/** Catmull-Rom → cubic bezier for smooth curves through waypoints */ +function buildCatmullRomPath(pts: Waypoint[]): string { + if (pts.length < 2) return `M ${pts[0].x} ${pts[0].y}` + let d = `M ${pts[0].x} ${pts[0].y}` + for (let i = 0; i < pts.length - 1; i++) { + const p0 = pts[Math.max(i - 1, 0)] + const p1 = pts[i] + const p2 = pts[i + 1] + const p3 = pts[Math.min(i + 2, pts.length - 1)] + const cp1x = p1.x + (p2.x - p0.x) / 6 + const cp1y = p1.y + (p2.y - p0.y) / 6 + const cp2x = p2.x - (p3.x - p1.x) / 6 + const cp2y = p2.y - (p3.y - p1.y) / 6 + d += ` C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${p2.x} ${p2.y}` + } + return d +} + +/** Polyline with rounded corners at each waypoint vertex (quadratic bezier) */ +function buildRoundedPolylinePath(pts: Waypoint[], radius = 8): string { + if (pts.length < 2) return `M ${pts[0].x} ${pts[0].y}` + if (pts.length === 2) return `M ${pts[0].x} ${pts[0].y} L ${pts[1].x} ${pts[1].y}` + + let d = `M ${pts[0].x} ${pts[0].y}` + + for (let i = 1; i < pts.length - 1; i++) { + const prev = pts[i - 1] + const curr = pts[i] + const next = pts[i + 1] + + const dx1 = curr.x - prev.x + const dy1 = curr.y - prev.y + const len1 = Math.hypot(dx1, dy1) + + const dx2 = next.x - curr.x + const dy2 = next.y - curr.y + const len2 = Math.hypot(dx2, dy2) + + if (len1 < 1 || len2 < 1) { + d += ` L ${curr.x} ${curr.y}` + continue + } + + const r = Math.min(radius, len1 / 2, len2 / 2) + + // Approach point (on segment prev→curr, r units before corner) + const bx = curr.x - (dx1 / len1) * r + const by = curr.y - (dy1 / len1) * r + + // Departure point (on segment curr→next, r units after corner) + const ax = curr.x + (dx2 / len2) * r + const ay = curr.y + (dy2 / len2) * r + + d += ` L ${bx} ${by} Q ${curr.x} ${curr.y} ${ax} ${ay}` + } + + d += ` L ${pts[pts.length - 1].x} ${pts[pts.length - 1].y}` + return d +} + +export function buildWaypointPath( + sourceX: number, sourceY: number, + waypoints: Waypoint[], + targetX: number, targetY: number, + pathStyle: string = 'bezier', +): string { + const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }] + return pathStyle === 'smooth' ? buildRoundedPolylinePath(pts) : buildCatmullRomPath(pts) +} + +// ── 45° snapping ────────────────────────────────────────────────────────────── + +/** + * Snap `pos` to the nearest 45°-multiple direction from `from`. + * Only snaps when within SNAP_THRESHOLD px of a 45° position. + */ +export function snap45(from: Waypoint, pos: Waypoint): Waypoint { + const dx = pos.x - from.x + const dy = pos.y - from.y + const dist = Math.hypot(dx, dy) + if (dist < 1) return pos + const angle = Math.atan2(dy, dx) + const snapped = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4) + const candidate = { + x: Math.round(from.x + dist * Math.cos(snapped)), + y: Math.round(from.y + dist * Math.sin(snapped)), + } + const deviation = Math.hypot(candidate.x - pos.x, candidate.y - pos.y) + return deviation <= SNAP_THRESHOLD ? candidate : pos +} + +/** Snap threshold in flow-space pixels. Only snap when this close to a 45° position. */ +const SNAP_THRESHOLD = 15 + +/** + * Find the position closest to `pos` that lies simultaneously on a 45°-ray + * from `prev` AND on a 45°-ray from `next`. + * + * Only snaps when the nearest valid intersection is within SNAP_THRESHOLD px — + * outside that zone the raw drag position is returned, allowing free placement. + */ +export function snap45both(prev: Waypoint, next: Waypoint, pos: Waypoint): Waypoint { + let best: Waypoint | null = null + let bestDist = Infinity + + for (let i = 0; i < 8; i++) { + const a1 = i * Math.PI / 4 + const c1 = Math.cos(a1), s1 = Math.sin(a1) + + for (let j = 0; j < 8; j++) { + const a2 = j * Math.PI / 4 + const c2 = Math.cos(a2), s2 = Math.sin(a2) + + const dx = next.x - prev.x + const dy = next.y - prev.y + const det = -c1 * s2 + c2 * s1 + if (Math.abs(det) < 1e-6) continue + + const t = (-dx * s2 + c2 * dy) / det + const s = (c1 * dy - s1 * dx) / det + if (t < -1e-6 || s < -1e-6) continue + + const ix = prev.x + t * c1 + const iy = prev.y + t * s1 + const d = Math.hypot(ix - pos.x, iy - pos.y) + if (d < bestDist) { + bestDist = d + best = { x: Math.round(ix), y: Math.round(iy) } + } + } + } + + // Only snap if close enough — otherwise let the waypoint move freely + if (best === null || bestDist > SNAP_THRESHOLD) return pos + return best +} + +// ── Geometry helpers ────────────────────────────────────────────────────────── + +export function distToSegment(p: Waypoint, a: Waypoint, b: Waypoint): number { + const dx = b.x - a.x + const dy = b.y - a.y + const lenSq = dx * dx + dy * dy + if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y) + const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq)) + return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)) +} + +export function findInsertIndex( + sourceX: number, sourceY: number, + waypoints: Waypoint[], + targetX: number, targetY: number, + point: Waypoint, +): number { + const allPts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }] + let minDist = Infinity + let best = 0 + for (let i = 0; i < allPts.length - 1; i++) { + const d = distToSegment(point, allPts[i], allPts[i + 1]) + if (d < minDist) { minDist = d; best = i } + } + return best +} diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index 9519846..0bacd34 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -4,6 +4,7 @@ import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react' import type { NodeData } from '@/types' import { resolveNodeColors } from '@/utils/nodeColors' import { resolveNodeIcon } from '@/utils/nodeIcons' +import { resolvePropertyIcon } from '@/utils/propertyIcons' import { useThemeStore } from '@/stores/themeStore' import { THEMES } from '@/utils/themes' import { useCanvasStore } from '@/stores/canvasStore' @@ -31,7 +32,11 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: const colors = resolveNodeColors(data, activeTheme) const statusColor = theme.colors.statusColors[data.status] const isOnline = data.status === 'online' - const showHardware = data.show_hardware && (data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) + + // Properties: prefer new system; fall back to legacy hardware fields for unmigrated nodes + const visibleProperties = data.properties?.filter((p) => p.visible) ?? null + const showLegacyHardware = !data.properties && data.show_hardware && + (data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) return (
- {/* Hardware section */} - {showHardware && ( + {/* Properties section (new system) */} + {visibleProperties && visibleProperties.length > 0 && ( + <> +
+
+ {visibleProperties.map((prop, i) => { + const Icon = resolvePropertyIcon(prop.icon) + return ( +
+ {Icon && } + {prop.key} + · {prop.value} +
+ ) + })} +
+ + )} + + {/* Legacy hardware section — fallback for nodes not yet migrated */} + {showLegacyHardware && ( <>
- {/* Line 1: CPU */} {(data.cpu_model || data.cpu_count != null) && (
@@ -117,7 +142,6 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: )}
)} - {/* Line 2: RAM + Disk */} {(data.ram_gb != null || data.disk_gb != null) && (
{data.ram_gb != null && ( diff --git a/frontend/src/components/canvas/nodes/GroupRectNode.tsx b/frontend/src/components/canvas/nodes/GroupRectNode.tsx index 999eb95..33709db 100644 --- a/frontend/src/components/canvas/nodes/GroupRectNode.tsx +++ b/frontend/src/components/canvas/nodes/GroupRectNode.tsx @@ -73,7 +73,7 @@ export function GroupRectNode({ id, data, selected }: NodeProps>) background: '#00d4ff', border: '1px solid #0d1117', }} - lineStyle={{ borderColor: '#00d4ff55', borderWidth: 1 }} + lineStyle={{ borderColor: 'transparent' }} />
>) justifyContent: posStyle.justifyContent, padding: 12, background: backgroundColor, - border: `${selected ? borderWidth + 1 : borderWidth}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`, + border: `${borderWidth}px ${borderStyle} ${borderColor}`, + boxShadow: selected ? '0 0 0 1px #00d4ff, 0 0 8px #00d4ff44' : 'none', borderRadius: 10, boxSizing: 'border-box', cursor: 'default', diff --git a/frontend/src/components/modals/EdgeModal.tsx b/frontend/src/components/modals/EdgeModal.tsx index 95d07b7..664b12d 100644 --- a/frontend/src/components/modals/EdgeModal.tsx +++ b/frontend/src/components/modals/EdgeModal.tsx @@ -10,11 +10,12 @@ import { EDGE_DEFAULT_COLORS } from '@/utils/edgeColors' const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][] -type AnimMode = 'none' | 'snake' | 'flow' +type AnimMode = 'none' | 'basic' | 'snake' | 'flow' function toAnimMode(v: EdgeData['animated']): AnimMode { if (v === true || v === 'snake') return 'snake' if (v === 'flow') return 'flow' + if (v === 'basic') return 'basic' return 'none' } @@ -23,11 +24,12 @@ interface EdgeModalProps { onClose: () => void onSubmit: (data: EdgeData) => void onDelete?: () => void + onClearWaypoints?: () => void initial?: Partial title?: string } -export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title = 'Connect Nodes' }: EdgeModalProps) { +export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints, initial, title = 'Connect Nodes' }: EdgeModalProps) { const [type, setType] = useState(initial?.type ?? 'ethernet') const [label, setLabel] = useState(initial?.label ?? '') const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '') @@ -126,7 +128,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
- {(['none', 'snake', 'flow'] as AnimMode[]).map((mode, i) => ( + {(['none', 'basic', 'snake', 'flow'] as AnimMode[]).map((mode, i) => ( ))}
@@ -175,6 +177,16 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
+ {onClearWaypoints && initial?.waypoints && initial.waypoints.length > 0 && ( + + )} +
{onDelete ? (
- {/* Hardware specs (hidden for groupRect) */} - {form.type !== 'groupRect' && ( -
-
- - {hardwareOpen && ( -
- Show on node - -
- )} -
- {hardwareOpen && ( -
-
- - set('cpu_model', e.target.value || undefined)} - placeholder="e.g. Intel Xeon E5-2680" - className="bg-[#21262d] border-[#30363d] text-sm h-8" - /> -
-
- - set('cpu_count', e.target.value ? parseInt(e.target.value, 10) : undefined)} - placeholder="e.g. 8" - className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8" - /> -
-
- - set('ram_gb', e.target.value ? parseFloat(e.target.value) : undefined)} - placeholder="e.g. 32" - className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8" - /> -
-
- - set('disk_gb', e.target.value ? parseFloat(e.target.value) : undefined)} - placeholder="e.g. 500" - className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8" - /> -
-
- )} -
- )} - {/* Bottom connection points (not for group containers) */} {form.type !== 'groupRect' && form.type !== 'group' && (
diff --git a/frontend/src/components/modals/__tests__/EdgeModal.test.tsx b/frontend/src/components/modals/__tests__/EdgeModal.test.tsx index 9d9cd51..7b97730 100644 --- a/frontend/src/components/modals/__tests__/EdgeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/EdgeModal.test.tsx @@ -122,6 +122,21 @@ describe('EdgeModal', () => { expect(onSubmit.mock.calls[0][0].animated).toBe('flow') }) + it('selecting Basic sends animated: "basic"', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByText('Basic')) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].animated).toBe('basic') + }) + + it('pre-fills animation from initial "basic" string', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].animated).toBe('basic') + }) + it('selecting None after Snake omits animated from payload', () => { const onSubmit = vi.fn() render() @@ -187,4 +202,55 @@ describe('EdgeModal', () => { expect(onDelete).toHaveBeenCalledOnce() expect(onClose).toHaveBeenCalledOnce() }) + + // ── Waypoints / Clear path ──────────────────────────────────────────────── + + it('does not show Clear path button when onClearWaypoints is not provided', () => { + render() + expect(screen.queryByText(/Clear path/)).toBeNull() + }) + + it('does not show Clear path button when waypoints are empty', () => { + render() + expect(screen.queryByText(/Clear path/)).toBeNull() + }) + + it('does not show Clear path button when no initial waypoints', () => { + render() + expect(screen.queryByText(/Clear path/)).toBeNull() + }) + + it('shows Clear path button with count when waypoints exist', () => { + render( + , + ) + expect(screen.getByText('Clear path (2 points)')).toBeDefined() + }) + + it('shows singular "point" when only one waypoint', () => { + render( + , + ) + expect(screen.getByText('Clear path (1 point)')).toBeDefined() + }) + + it('calls onClearWaypoints and onClose when Clear path is clicked', () => { + const onClearWaypoints = vi.fn() + const onClose = vi.fn() + render( + , + ) + fireEvent.click(screen.getByText('Clear path (1 point)')) + expect(onClearWaypoints).toHaveBeenCalledOnce() + expect(onClose).toHaveBeenCalledOnce() + }) }) diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx index adef950..4b24085 100644 --- a/frontend/src/components/modals/__tests__/NodeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/NodeModal.test.tsx @@ -293,91 +293,6 @@ describe('NodeModal', () => { expect(screen.getByText(/Using default colors for/)).toBeDefined() }) - // ── Hardware section ────────────────────────────────────────────────── - - it('renders Hardware toggle button', () => { - renderModal() - expect(screen.getByText('Hardware')).toBeDefined() - }) - - it('hardware fields are hidden by default', () => { - renderModal() - expect(screen.queryByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeNull() - }) - - it('expands hardware fields on toggle click', () => { - renderModal() - fireEvent.click(screen.getByText('Hardware')) - expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined() - expect(screen.getByPlaceholderText('e.g. 8')).toBeDefined() - expect(screen.getByPlaceholderText('e.g. 32')).toBeDefined() - expect(screen.getByPlaceholderText('e.g. 500')).toBeDefined() - }) - - it('auto-expands when initial has hardware data', () => { - renderModal({ initial: { ...BASE, cpu_count: 8, ram_gb: 32 } }) - expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined() - }) - - it('pre-fills hardware fields from initial', () => { - renderModal({ initial: { ...BASE, cpu_model: 'Intel i5', cpu_count: 4, ram_gb: 16, disk_gb: 500 } }) - expect((screen.getByPlaceholderText('e.g. Intel Xeon E5-2680') as HTMLInputElement).value).toBe('Intel i5') - }) - - it('submits hardware fields when filled', () => { - const { onSubmit } = renderModal() - fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Homelab' } }) - fireEvent.click(screen.getByText('Hardware')) - fireEvent.change(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680'), { target: { value: 'Intel i7-12700K' } }) - fireEvent.change(screen.getByPlaceholderText('e.g. 8'), { target: { value: '12' } }) - fireEvent.change(screen.getByPlaceholderText('e.g. 32'), { target: { value: '64' } }) - fireEvent.change(screen.getByPlaceholderText('e.g. 500'), { target: { value: '2000' } }) - fireEvent.click(screen.getByRole('button', { name: 'Add' })) - const data = onSubmit.mock.calls[0][0] as Partial - expect(data.cpu_model).toBe('Intel i7-12700K') - expect(data.cpu_count).toBe(12) - expect(data.ram_gb).toBe(64) - expect(data.disk_gb).toBe(2000) - }) - - it('hides Hardware section for groupRect type', () => { - renderModal({ initial: { type: 'groupRect' } }) - expect(screen.queryByText('Hardware')).toBeNull() - }) - - it('show_hardware toggle hidden when section is collapsed', () => { - renderModal() - expect(screen.queryByText('Show on node')).toBeNull() - }) - - it('show_hardware toggle appears when section is expanded', () => { - renderModal() - fireEvent.click(screen.getByText('Hardware')) - expect(screen.getByText('Show on node')).toBeDefined() - }) - - it('show_hardware defaults to falsy', () => { - const { onSubmit } = renderModal() - fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } }) - fireEvent.click(screen.getByRole('button', { name: 'Add' })) - expect(onSubmit.mock.calls[0][0].show_hardware).toBeFalsy() - }) - - it('toggling show_hardware sets it to true', () => { - const { onSubmit } = renderModal() - fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } }) - fireEvent.click(screen.getByText('Hardware')) - fireEvent.click(screen.getByRole('switch')) - fireEvent.click(screen.getByRole('button', { name: 'Add' })) - expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true) - }) - - it('pre-fills show_hardware from initial', () => { - const { onSubmit } = renderModal({ initial: { label: 'Node', show_hardware: true, cpu_count: 8 } }) - fireEvent.click(screen.getByRole('button', { name: 'Add' })) - expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true) - }) - // ── Bottom connection points ─────────────────────────────────────────── it('shows Bottom Connection Points for server type', () => { diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index 690860f..1c9d82b 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -1,10 +1,11 @@ -import { useState } from 'react' +import { createElement, useState } from 'react' import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { useCanvasStore } from '@/stores/canvasStore' -import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData } from '@/types' +import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types' import { getServiceUrl } from '@/utils/serviceUrl' +import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons' import type { Node } from '@xyflow/react' interface DetailPanelProps { @@ -14,6 +15,9 @@ interface DetailPanelProps { type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string } const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' } +type PropForm = { key: string; value: string; icon: string | null; visible: boolean } +const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true } + export function DetailPanel({ onEdit }: DetailPanelProps) { const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore() @@ -24,6 +28,12 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { const [groupName, setGroupName] = useState('') const [creatingGroup, setCreatingGroup] = useState(false) + // Properties state + const [addingProp, setAddingProp] = useState(false) + const [newProp, setNewProp] = useState(EMPTY_PROP) + const [editingPropIndex, setEditingPropIndex] = useState(null) + const [editProp, setEditProp] = useState(EMPTY_PROP) + // Multi-select panel const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id)) @@ -119,6 +129,52 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { setEditingFor(null) } + // --- Property handlers --- + const properties: NodeProperty[] = data.properties ?? [] + + const handleAddProp = () => { + if (!newProp.key.trim() || !newProp.value.trim()) return + snapshotHistory() + const prop: NodeProperty = { key: newProp.key.trim(), value: newProp.value.trim(), icon: newProp.icon, visible: newProp.visible } + updateNode(node.id, { properties: [...properties, prop] }) + setNewProp(EMPTY_PROP) + setAddingProp(false) + } + + const handleRemoveProp = (index: number) => { + snapshotHistory() + updateNode(node.id, { properties: properties.filter((_, i) => i !== index) }) + if (editingPropIndex === index) setEditingPropIndex(null) + } + + const handleTogglePropVisible = (index: number) => { + snapshotHistory() + updateNode(node.id, { + properties: properties.map((p, i) => i === index ? { ...p, visible: !p.visible } : p), + }) + } + + const handleStartEditProp = (index: number) => { + const p = properties[index] + if (!p) return + setEditProp({ key: p.key, value: p.value, icon: p.icon, visible: p.visible }) + setEditingPropIndex(index) + setAddingProp(false) + } + + const handleSaveEditProp = () => { + if (editingPropIndex === null || !editProp.key.trim() || !editProp.value.trim()) return + snapshotHistory() + updateNode(node.id, { + properties: properties.map((p, i) => + i === editingPropIndex + ? { key: editProp.key.trim(), value: editProp.value.trim(), icon: editProp.icon, visible: editProp.visible } + : p + ), + }) + setEditingPropIndex(null) + } + return ( ) } @@ -548,6 +551,34 @@ function SettingsPanel() { ) } +function VersionBadge() { + const current = __APP_VERSION__ + const { latest, hasUpdate } = useLatestRelease(current) + + return ( +
+ + v{current} + + {hasUpdate && latest && ( + + ↑ v{latest.version} available + + )} +
+ ) +} + const MAC_OUI: Record = { '52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' }, 'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' }, diff --git a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx index 70d04f5..3ecad14 100644 --- a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx +++ b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx @@ -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() - 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() - 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() - 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() - 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) + render() - 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) + render() - 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) + render() - 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) + render() - 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() - 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) render() - 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' }) }) diff --git a/frontend/src/components/panels/__tests__/VersionBadge.test.tsx b/frontend/src/components/panels/__tests__/VersionBadge.test.tsx new file mode 100644 index 0000000..7aad9de --- /dev/null +++ b/frontend/src/components/panels/__tests__/VersionBadge.test.tsx @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { Sidebar } from '../Sidebar' +import { useCanvasStore } from '@/stores/canvasStore' + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock('@/stores/canvasStore') + +vi.mock('@/api/client', () => ({ + scanApi: { + trigger: vi.fn().mockResolvedValue({}), + pending: vi.fn().mockResolvedValue({ data: [] }), + hidden: vi.fn().mockResolvedValue({ data: [] }), + runs: vi.fn().mockResolvedValue({ data: [] }), + stop: vi.fn().mockResolvedValue({}), + getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }), + }, + settingsApi: { + get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }), + save: vi.fn().mockResolvedValue({}), + }, +})) + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +vi.mock('@/components/ui/Logo', () => ({ Logo: () => null })) +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: () => null, +})) +vi.mock('@/components/modals/PendingDeviceModal', () => ({ PendingDeviceModal: () => null })) +vi.mock('@/components/modals/StatusTimelineModal', () => ({ StatusTimelineModal: () => null })) + +vi.mock('@/hooks/useLatestRelease', () => ({ + useLatestRelease: vi.fn(), +})) + +import { useLatestRelease } from '@/hooks/useLatestRelease' + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function renderSidebar() { + vi.mocked(useCanvasStore).mockReturnValue({ + nodes: [], + hasUnsavedChanges: false, + hideIp: false, + toggleHideIp: vi.fn(), + addNode: vi.fn(), + scanEventTs: 0, + } as unknown as ReturnType) + + return render( + , + ) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('VersionBadge', () => { + beforeEach(() => { + vi.mocked(useLatestRelease).mockReturnValue({ latest: null, hasUpdate: false }) + }) + + it('displays the current app version', () => { + renderSidebar() + expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument() + }) + + it('links current version to its GitHub release page', () => { + renderSidebar() + const link = screen.getByText(`v${__APP_VERSION__}`).closest('a') + expect(link).toHaveAttribute( + 'href', + `https://github.com/Pouzor/homelable/releases/tag/v${__APP_VERSION__}`, + ) + expect(link).toHaveAttribute('target', '_blank') + }) + + it('does not show update badge when on latest version', () => { + renderSidebar() + expect(screen.queryByText(/available/)).not.toBeInTheDocument() + }) + + it('shows update badge when a newer version is available', async () => { + vi.mocked(useLatestRelease).mockReturnValue({ + latest: { version: '9.9.9', url: 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9' }, + hasUpdate: true, + }) + renderSidebar() + await waitFor(() => expect(screen.getByText('↑ v9.9.9 available')).toBeInTheDocument()) + }) + + it('update badge links to the latest release URL', async () => { + vi.mocked(useLatestRelease).mockReturnValue({ + latest: { version: '9.9.9', url: 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9' }, + hasUpdate: true, + }) + renderSidebar() + await waitFor(() => { + const badge = screen.getByText('↑ v9.9.9 available').closest('a') + expect(badge).toHaveAttribute('href', 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9') + expect(badge).toHaveAttribute('target', '_blank') + }) + }) + + it('does not show update badge when hasUpdate is false even if latest exists', () => { + vi.mocked(useLatestRelease).mockReturnValue({ + latest: { version: __APP_VERSION__, url: 'https://github.com' }, + hasUpdate: false, + }) + renderSidebar() + expect(screen.queryByText(/available/)).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/hooks/__tests__/useLatestRelease.test.ts b/frontend/src/hooks/__tests__/useLatestRelease.test.ts new file mode 100644 index 0000000..54092bc --- /dev/null +++ b/frontend/src/hooks/__tests__/useLatestRelease.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' + +// Reset module between tests so the module-level cache is cleared +async function freshHook() { + vi.resetModules() + const mod = await import('../useLatestRelease') + return mod.useLatestRelease +} + +const CURRENT = '1.8.3' + +function mockFetch(payload: unknown, ok = true) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok, + json: () => Promise.resolve(payload), + }), + ) +} + +describe('useLatestRelease', () => { + beforeEach(() => { + vi.unstubAllGlobals() + }) + + it('returns no update when latest version matches current', async () => { + mockFetch({ tag_name: 'v1.8.3', html_url: 'https://github.com/Pouzor/homelable/releases/tag/v1.8.3' }) + const useLatestRelease = await freshHook() + const { result } = renderHook(() => useLatestRelease(CURRENT)) + await waitFor(() => expect(result.current.latest).not.toBeNull()) + expect(result.current.hasUpdate).toBe(false) + }) + + it('returns update when latest version is newer', async () => { + mockFetch({ tag_name: 'v1.9.0', html_url: 'https://github.com/Pouzor/homelable/releases/tag/v1.9.0' }) + const useLatestRelease = await freshHook() + const { result } = renderHook(() => useLatestRelease(CURRENT)) + await waitFor(() => expect(result.current.hasUpdate).toBe(true)) + expect(result.current.latest?.version).toBe('1.9.0') + expect(result.current.latest?.url).toBe('https://github.com/Pouzor/homelable/releases/tag/v1.9.0') + }) + + it('strips leading v from tag_name', async () => { + mockFetch({ tag_name: 'v2.0.0', html_url: 'https://github.com/example' }) + const useLatestRelease = await freshHook() + const { result } = renderHook(() => useLatestRelease(CURRENT)) + await waitFor(() => expect(result.current.latest).not.toBeNull()) + expect(result.current.latest?.version).toBe('2.0.0') + }) + + it('does not show update when API returns non-ok response', async () => { + mockFetch({ message: 'Not Found' }, false) + const useLatestRelease = await freshHook() + const { result } = renderHook(() => useLatestRelease(CURRENT)) + await new Promise((r) => setTimeout(r, 50)) + expect(result.current.hasUpdate).toBe(false) + expect(result.current.latest).toBeNull() + }) + + it('does not show update when API returns missing tag_name', async () => { + mockFetch({ html_url: 'https://github.com/example' }) + const useLatestRelease = await freshHook() + const { result } = renderHook(() => useLatestRelease(CURRENT)) + await new Promise((r) => setTimeout(r, 50)) + expect(result.current.hasUpdate).toBe(false) + }) + + it('does not show update when API returns missing html_url', async () => { + mockFetch({ tag_name: 'v2.0.0' }) + const useLatestRelease = await freshHook() + const { result } = renderHook(() => useLatestRelease(CURRENT)) + await new Promise((r) => setTimeout(r, 50)) + expect(result.current.hasUpdate).toBe(false) + }) + + it('does not show update when fetch throws', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error'))) + const useLatestRelease = await freshHook() + const { result } = renderHook(() => useLatestRelease(CURRENT)) + await new Promise((r) => setTimeout(r, 50)) + expect(result.current.hasUpdate).toBe(false) + }) + + it('fetches only once when hook is mounted multiple times concurrently', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ tag_name: 'v1.8.3', html_url: 'https://github.com' }), + }) + vi.stubGlobal('fetch', fetchMock) + const useLatestRelease = await freshHook() + // Mount all three before the fetch resolves — cache is set to 'pending' after first mount + const a = renderHook(() => useLatestRelease(CURRENT)) + const b = renderHook(() => useLatestRelease(CURRENT)) + const c = renderHook(() => useLatestRelease(CURRENT)) + await waitFor(() => { + expect(a.result.current.latest).not.toBeNull() + }) + expect(fetchMock).toHaveBeenCalledTimes(1) + // All hooks see the same result once cache resolves + expect(b.result.current.hasUpdate).toBe(false) + expect(c.result.current.hasUpdate).toBe(false) + }) +}) diff --git a/frontend/src/hooks/useLatestRelease.ts b/frontend/src/hooks/useLatestRelease.ts new file mode 100644 index 0000000..443d379 --- /dev/null +++ b/frontend/src/hooks/useLatestRelease.ts @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react' + +interface ReleaseInfo { + version: string + url: string +} + +let cache: ReleaseInfo | null | 'error' | 'pending' = null + +export function useLatestRelease(currentVersion: string) { + const [latest, setLatest] = useState( + cache && cache !== 'error' && cache !== 'pending' ? cache : null, + ) + + useEffect(() => { + if (cache !== null) return + cache = 'pending' + fetch('https://api.github.com/repos/Pouzor/homelable/releases/latest', { + headers: { Accept: 'application/vnd.github+json' }, + }) + .then((res) => { + if (!res.ok) { cache = 'error'; return } + return res.json() + }) + .then((data) => { + if (!data || typeof data.tag_name !== 'string' || !data.html_url) { + cache = 'error' + return + } + const version = data.tag_name.replace(/^v/, '') + const info: ReleaseInfo = { version, url: data.html_url } + cache = info + setLatest(info) + }) + .catch(() => { + cache = 'error' + }) + }, []) + + const hasUpdate = latest !== null && latest.version !== currentVersion + return { latest, hasUpdate } +} diff --git a/frontend/src/index.css b/frontend/src/index.css index f962a64..ea075ec 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -6,6 +6,11 @@ @custom-variant dark (&:is(.dark *)); +@keyframes homelable-basic-dash { + from { stroke-dashoffset: 10; } + to { stroke-dashoffset: 0; } +} + /* Homelable dark theme — always dark */ :root { --background: #0d1117; diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index 116d60f..9df76ad 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -191,6 +191,10 @@ export const useCanvasStore = create((set) => ({ let nodes = state.nodes.map((n) => { if (n.id !== id) return n const updated: Node = { ...n, data: { ...n.data, ...data } } + // When properties change, clear stored height so the node auto-sizes to fit new content + if ('properties' in data && n.data.type !== 'proxmox' && n.data.type !== 'groupRect') { + updated.height = undefined + } if ('parent_id' in data) { const newParentId = data.parent_id ?? undefined if (!newParentId && n.parentId) { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 1ecebcc..2481349 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 { label: string type: NodeType @@ -62,6 +69,7 @@ export interface NodeData extends Record { ram_gb?: number disk_gb?: number show_hardware?: boolean + properties?: NodeProperty[] parent_id?: string container_mode?: boolean custom_colors?: { @@ -87,6 +95,11 @@ export interface NodeData extends Record { export type EdgePathStyle = 'bezier' | 'smooth' +export interface Waypoint { + x: number + y: number +} + export interface EdgeData extends Record { type: EdgeType label?: string @@ -94,7 +107,8 @@ export interface EdgeData extends Record { speed?: string custom_color?: string path_style?: EdgePathStyle - animated?: boolean | 'snake' | 'flow' | 'none' + animated?: boolean | 'snake' | 'flow' | 'basic' | 'none' + waypoints?: Waypoint[] } export const NODE_TYPE_LABELS: Record = { diff --git a/frontend/src/utils/__tests__/canvasSerializer.test.ts b/frontend/src/utils/__tests__/canvasSerializer.test.ts index 42b0bac..2d26a52 100644 --- a/frontend/src/utils/__tests__/canvasSerializer.test.ts +++ b/frontend/src/utils/__tests__/canvasSerializer.test.ts @@ -236,6 +236,36 @@ describe('serializeEdge', () => { expect(result.custom_color).toBeNull() expect(result.path_style).toBeNull() }) + + it('serializes waypoints when present', () => { + const edge = makeRfEdge({ data: { type: 'ethernet', waypoints: [{ x: 10, y: 20 }, { x: 30, y: 40 }] } }) + const result = serializeEdge(edge) + expect(result.waypoints).toEqual([{ x: 10, y: 20 }, { x: 30, y: 40 }]) + }) + + it('serializes waypoints as null when empty array', () => { + const edge = makeRfEdge({ data: { type: 'ethernet', waypoints: [] } }) + const result = serializeEdge(edge) + expect(result.waypoints).toBeNull() + }) + + it('serializes waypoints as null when absent', () => { + const result = serializeEdge(makeRfEdge()) + expect(result.waypoints).toBeNull() + }) +}) + +describe('deserializeApiEdge — waypoints', () => { + it('restores waypoints from API edge', () => { + const edge = makeApiEdge({ waypoints: [{ x: 5, y: 15 }, { x: 25, y: 35 }] }) + const result = deserializeApiEdge(edge) + expect((result.data as { waypoints: unknown }).waypoints).toEqual([{ x: 5, y: 15 }, { x: 25, y: 35 }]) + }) + + it('has no waypoints when API edge has none', () => { + const result = deserializeApiEdge(makeApiEdge()) + expect((result.data as { waypoints?: unknown }).waypoints).toBeUndefined() + }) }) // ── deserializeApiNode — regular nodes ─────────────────────────────────────── diff --git a/frontend/src/utils/__tests__/propertyIcons.test.ts b/frontend/src/utils/__tests__/propertyIcons.test.ts new file mode 100644 index 0000000..e2e2ad9 --- /dev/null +++ b/frontend/src/utils/__tests__/propertyIcons.test.ts @@ -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() + }) +}) diff --git a/frontend/src/utils/__tests__/standaloneStorage.test.ts b/frontend/src/utils/__tests__/standaloneStorage.test.ts index f1ad38b..e60c651 100644 --- a/frontend/src/utils/__tests__/standaloneStorage.test.ts +++ b/frontend/src/utils/__tests__/standaloneStorage.test.ts @@ -134,6 +134,73 @@ describe('Standalone localStorage save/load cycle', () => { expect(stored.data?.label).toBe('VLAN 20') }) + it('preserves node properties through the round-trip', () => { + const props = [ + { key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: true }, + { key: 'CPU', value: 'Intel i9', icon: 'Cpu', visible: false }, + ] + const nodes = [makeNode('n1', { data: { label: 'n1', type: 'server', status: 'unknown', services: [], properties: props } })] + const raw = standaloneSerialize(nodes, []) + const { nodes: loaded } = standaloneDeserialize(raw) + useCanvasStore.getState().loadCanvas(loaded, []) + + const stored = useCanvasStore.getState().nodes[0] + expect(stored.data.properties).toEqual(props) + }) + + it('preserves empty properties array through the round-trip', () => { + const nodes = [makeNode('n1', { data: { label: 'n1', type: 'server', status: 'unknown', services: [], properties: [] } })] + const raw = standaloneSerialize(nodes, []) + const { nodes: loaded } = standaloneDeserialize(raw) + useCanvasStore.getState().loadCanvas(loaded, []) + + expect(useCanvasStore.getState().nodes[0].data.properties).toEqual([]) + }) + + it('preserves edge waypoints through the round-trip', () => { + const waypoints = [{ x: 100, y: 200 }, { x: 300, y: 150 }] + const edges: Edge[] = [{ + id: 'e1', source: 'n1', target: 'n2', type: 'ethernet', + data: { type: 'ethernet', waypoints }, + }] + const raw = standaloneSerialize([], edges) + const { edges: loaded } = standaloneDeserialize(raw) + useCanvasStore.getState().loadCanvas([], loaded) + + expect(useCanvasStore.getState().edges[0].data?.waypoints).toEqual(waypoints) + }) + + it('preserves basic animation through the round-trip', () => { + const edges: Edge[] = [{ + id: 'e1', source: 'n1', target: 'n2', type: 'ethernet', + data: { type: 'ethernet', animated: 'basic' }, + }] + const raw = standaloneSerialize([], edges) + const { edges: loaded } = standaloneDeserialize(raw) + useCanvasStore.getState().loadCanvas([], loaded) + + expect(useCanvasStore.getState().edges[0].data?.animated).toBe('basic') + }) + + it('preserves all three animation types through the round-trip', () => { + const n1 = makeNode('n1') + const n2 = makeNode('n2') + const n3 = makeNode('n3') + const edges: Edge[] = [ + { id: 'e1', source: 'n1', target: 'n2', type: 'ethernet', data: { type: 'ethernet', animated: 'snake' } }, + { id: 'e2', source: 'n2', target: 'n3', type: 'ethernet', data: { type: 'ethernet', animated: 'flow' } }, + { id: 'e3', source: 'n1', target: 'n3', type: 'ethernet', data: { type: 'ethernet', animated: 'basic' } }, + ] + const raw = standaloneSerialize([n1, n2, n3], edges) + const { edges: loaded } = standaloneDeserialize(raw) + useCanvasStore.getState().loadCanvas([n1, n2, n3], loaded) + + const stored = useCanvasStore.getState().edges + expect(stored.find((e) => e.id === 'e1')?.data?.animated).toBe('snake') + expect(stored.find((e) => e.id === 'e2')?.data?.animated).toBe('flow') + expect(stored.find((e) => e.id === 'e3')?.data?.animated).toBe('basic') + }) + // ── loadCanvas marks clean ──────────────────────────────────────────────── it('loadCanvas sets hasUnsavedChanges to false', () => { diff --git a/frontend/src/utils/canvasSerializer.ts b/frontend/src/utils/canvasSerializer.ts index 7b3e896..68b20d7 100644 --- a/frontend/src/utils/canvasSerializer.ts +++ b/frontend/src/utils/canvasSerializer.ts @@ -1,5 +1,5 @@ import type { Node, Edge } from '@xyflow/react' -import type { NodeData, EdgeData } from '@/types' +import type { NodeData, EdgeData, Waypoint } from '@/types' import { normalizeHandle } from '@/utils/handleUtils' // ── Types ──────────────────────────────────────────────────────────────────── @@ -28,6 +28,7 @@ export interface ApiNode extends Record { ram_gb?: number | null disk_gb?: number | null show_hardware?: boolean + properties?: unknown[] | null width?: number | null height?: number | null bottom_handles?: number @@ -43,9 +44,10 @@ export interface ApiEdge { speed?: string | null custom_color?: string | null path_style?: string | null - animated?: boolean | 'snake' | 'flow' | 'none' + animated?: boolean | 'snake' | 'flow' | 'basic' | 'none' source_handle?: string | null target_handle?: string | null + waypoints?: Waypoint[] | null } // ── Serialization (RF node → API save payload) ─────────────────────────────── @@ -99,6 +101,7 @@ export function serializeNode(n: Node): Record { 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, @@ -121,6 +124,7 @@ export function serializeEdge(e: Edge): Record { animated: e.data?.animated ?? false, source_handle: normalizeHandle(e.sourceHandle), target_handle: normalizeHandle(e.targetHandle), + waypoints: e.data?.waypoints?.length ? e.data.waypoints : null, } } diff --git a/frontend/src/utils/propertyIcons.ts b/frontend/src/utils/propertyIcons.ts new file mode 100644 index 0000000..af24a00 --- /dev/null +++ b/frontend/src/utils/propertyIcons.ts @@ -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 = { + 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 +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..54eaa07 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,3 @@ +/// + +declare const __APP_VERSION__: string diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index db648cc..c7f1f73 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,8 +2,12 @@ import path from 'path' import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +import pkg from './package.json' export default defineConfig({ + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + }, plugins: [react(), tailwindcss()], resolve: { alias: {