Merge pull request #56 from Pouzor/1.9
v1.9.0 — Node properties, edge waypoints, version display
This commit is contained in:
@@ -45,6 +45,7 @@ htmlcov/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.db.back
|
||||
|
||||
# Docker
|
||||
.docker/
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -115,3 +115,97 @@ async def test_update_node_parent_id(client: AsyncClient, headers: dict):
|
||||
async def test_create_node_requires_auth(client: AsyncClient):
|
||||
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
# --- Properties tests ---
|
||||
|
||||
async def test_create_node_default_properties_empty(client: AsyncClient, headers: dict):
|
||||
"""New node has an empty properties list by default."""
|
||||
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "Srv", "status": "unknown"}, headers=headers)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["properties"] == []
|
||||
|
||||
|
||||
async def test_create_node_with_properties(client: AsyncClient, headers: dict):
|
||||
"""Node created with properties round-trips correctly."""
|
||||
props = [
|
||||
{"key": "CPU Model", "value": "i7-12700K", "icon": "Cpu", "visible": True},
|
||||
{"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": False},
|
||||
]
|
||||
res = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["properties"] == props
|
||||
|
||||
|
||||
async def test_patch_node_properties(client: AsyncClient, headers: dict):
|
||||
"""PATCH with properties replaces the full properties array."""
|
||||
create = await client.post("/api/v1/nodes", json={"type": "server", "label": "Srv", "status": "unknown"}, headers=headers)
|
||||
node_id = create.json()["id"]
|
||||
|
||||
props = [{"key": "Disk", "value": "2 TB", "icon": "HardDrive", "visible": True}]
|
||||
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": props}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["properties"] == props
|
||||
|
||||
|
||||
async def test_patch_node_without_properties_does_not_wipe(client: AsyncClient, headers: dict):
|
||||
"""PATCH that omits properties leaves existing properties untouched."""
|
||||
props = [{"key": "GPU", "value": "RTX 4090", "icon": "Monitor", "visible": True}]
|
||||
create = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
||||
headers=headers,
|
||||
)
|
||||
node_id = create.json()["id"]
|
||||
|
||||
# PATCH only the label — properties must survive
|
||||
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"label": "Updated"}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["properties"] == props
|
||||
assert res.json()["label"] == "Updated"
|
||||
|
||||
|
||||
async def test_patch_node_clears_properties_with_empty_array(client: AsyncClient, headers: dict):
|
||||
"""PATCH with properties=[] explicitly clears all properties."""
|
||||
props = [{"key": "CPU Model", "value": "i5", "icon": "Cpu", "visible": True}]
|
||||
create = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
||||
headers=headers,
|
||||
)
|
||||
node_id = create.json()["id"]
|
||||
|
||||
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": []}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["properties"] == []
|
||||
|
||||
|
||||
async def test_get_node_returns_properties(client: AsyncClient, headers: dict):
|
||||
"""GET /nodes/:id returns the properties field."""
|
||||
props = [{"key": "OS", "value": "Debian 12", "icon": "Server", "visible": True}]
|
||||
create = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
||||
headers=headers,
|
||||
)
|
||||
node_id = create.json()["id"]
|
||||
|
||||
res = await client.get(f"/api/v1/nodes/{node_id}", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["properties"] == props
|
||||
|
||||
|
||||
async def test_properties_icon_can_be_null(client: AsyncClient, headers: dict):
|
||||
"""A property with icon=null is valid and round-trips correctly."""
|
||||
props = [{"key": "Notes", "value": "custom value", "icon": None, "visible": False}]
|
||||
create = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "generic", "label": "G", "status": "unknown", "properties": props},
|
||||
headers=headers,
|
||||
)
|
||||
assert create.status_code == 201
|
||||
assert create.json()["properties"] == props
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Tests for the hardware → properties migration logic.
|
||||
|
||||
We test the migration function directly against an in-memory SQLite database
|
||||
so we can set up legacy rows (with hardware columns, NULL properties) and
|
||||
verify the migration produces the expected properties JSON.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
async def _setup_legacy_table(conn):
|
||||
"""Create a minimal nodes table that mimics the pre-migration schema."""
|
||||
await conn.exec_driver_sql("""
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL DEFAULT 'generic',
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
cpu_model TEXT,
|
||||
cpu_count INTEGER,
|
||||
ram_gb REAL,
|
||||
disk_gb REAL,
|
||||
show_hardware BOOLEAN NOT NULL DEFAULT 0,
|
||||
properties JSON
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def _run_migration(conn):
|
||||
"""Run only the properties migration portion (extracted from init_db)."""
|
||||
rows = await conn.exec_driver_sql(
|
||||
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
|
||||
"FROM nodes WHERE properties IS NULL"
|
||||
)
|
||||
for row in rows.fetchall():
|
||||
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row
|
||||
props = []
|
||||
visible = bool(show_hardware)
|
||||
if cpu_model:
|
||||
props.append({"key": "CPU Model", "value": str(cpu_model), "icon": "Cpu", "visible": visible})
|
||||
if cpu_count is not None:
|
||||
props.append({"key": "CPU Cores", "value": str(cpu_count), "icon": "Cpu", "visible": visible})
|
||||
if ram_gb is not None:
|
||||
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
|
||||
if disk_gb is not None:
|
||||
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET properties = ? WHERE id = ?",
|
||||
(json.dumps(props), node_id),
|
||||
)
|
||||
|
||||
|
||||
async def _get_properties(conn, node_id: str) -> list:
|
||||
rows = await conn.exec_driver_sql("SELECT properties FROM nodes WHERE id = ?", (node_id,))
|
||||
raw = rows.fetchone()[0]
|
||||
return json.loads(raw) if raw else []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_full_hardware():
|
||||
"""Node with all 4 hardware fields → 4 property entries with correct icons."""
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_legacy_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
("node-1", "i7-12700K", 12, 32.0, 2000.0, 1),
|
||||
)
|
||||
await _run_migration(conn)
|
||||
props = await _get_properties(conn, "node-1")
|
||||
|
||||
assert len(props) == 4
|
||||
assert props[0] == {"key": "CPU Model", "value": "i7-12700K", "icon": "Cpu", "visible": True}
|
||||
assert props[1] == {"key": "CPU Cores", "value": "12", "icon": "Cpu", "visible": True}
|
||||
assert props[2] == {"key": "RAM", "value": "32.0 GB", "icon": "MemoryStick", "visible": True}
|
||||
assert props[3] == {"key": "Disk", "value": "2000.0 GB", "icon": "HardDrive", "visible": True}
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_partial_hardware():
|
||||
"""Node with only cpu_model and ram_gb → 2 property entries."""
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_legacy_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware) VALUES (?, ?, ?, ?)",
|
||||
("node-2", "Ryzen 5 5600", 16.0, 0),
|
||||
)
|
||||
await _run_migration(conn)
|
||||
props = await _get_properties(conn, "node-2")
|
||||
|
||||
assert len(props) == 2
|
||||
assert props[0]["key"] == "CPU Model"
|
||||
assert props[0]["visible"] is False
|
||||
assert props[1]["key"] == "RAM"
|
||||
assert props[1]["icon"] == "MemoryStick"
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_no_hardware():
|
||||
"""Node with no hardware fields → empty properties array."""
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_legacy_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id) VALUES (?)",
|
||||
("node-3",),
|
||||
)
|
||||
await _run_migration(conn)
|
||||
props = await _get_properties(conn, "node-3")
|
||||
|
||||
assert props == []
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_idempotent():
|
||||
"""Running migration twice does not duplicate properties."""
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_legacy_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, cpu_model, show_hardware) VALUES (?, ?, ?)",
|
||||
("node-4", "Core i5", 1),
|
||||
)
|
||||
await _run_migration(conn)
|
||||
await _run_migration(conn) # second pass — node already has properties, should be skipped
|
||||
props = await _get_properties(conn, "node-4")
|
||||
|
||||
assert len(props) == 1
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_show_hardware_false_sets_visible_false():
|
||||
"""show_hardware=0 means all migrated properties have visible=False."""
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_legacy_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware) VALUES (?, ?, ?, ?)",
|
||||
("node-5", "ARM Cortex-A72", 4.0, 0),
|
||||
)
|
||||
await _run_migration(conn)
|
||||
props = await _get_properties(conn, "node-5")
|
||||
|
||||
assert all(p["visible"] is False for p in props)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_already_migrated_node_not_touched():
|
||||
"""Node that already has properties is skipped — existing properties preserved."""
|
||||
existing = [{"key": "GPU", "value": "RTX 4090", "icon": "Monitor", "visible": True}]
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_legacy_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware, properties) VALUES (?, ?, ?, ?, ?)",
|
||||
("node-6", "i9-13900K", 64.0, 1, json.dumps(existing)),
|
||||
)
|
||||
await _run_migration(conn)
|
||||
props = await _get_properties(conn, "node-6")
|
||||
|
||||
assert props == existing
|
||||
await engine.dispose()
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.8.3",
|
||||
"version": "1.9.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -98,7 +98,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={24}
|
||||
gap={16}
|
||||
size={1}
|
||||
color={theme.colors.canvasDotColor}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Server } from 'lucide-react'
|
||||
import { BaseNode } from '../nodes/BaseNode'
|
||||
import type { NodeData } from '@/types'
|
||||
import type { Node } from '@xyflow/react'
|
||||
|
||||
vi.mock('@xyflow/react', () => ({
|
||||
Handle: () => null,
|
||||
Position: { Top: 'top', Bottom: 'bottom' },
|
||||
NodeResizer: () => null,
|
||||
useUpdateNodeInternals: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/themeStore', () => ({
|
||||
useThemeStore: () => 'dark',
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ hideIp: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/themes', () => ({
|
||||
THEMES: {
|
||||
dark: {
|
||||
colors: {
|
||||
statusColors: { online: '#39d353', offline: '#f85149', pending: '#e3b341', unknown: '#8b949e' },
|
||||
nodeSubtextColor: '#8b949e',
|
||||
nodeLabelColor: '#e6edf3',
|
||||
nodeIconBackground: '#21262d',
|
||||
handleBackground: '#30363d',
|
||||
handleBorder: '#30363d',
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeColors', () => ({
|
||||
resolveNodeColors: () => ({ background: '#161b22', border: '#30363d', icon: '#00d4ff' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeIcons', () => ({
|
||||
resolveNodeIcon: (_typeIcon: unknown) => _typeIcon,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/maskIp', () => ({
|
||||
maskIp: (ip: string) => ip,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/handleUtils', () => ({
|
||||
BOTTOM_HANDLE_IDS: ['bottom'],
|
||||
BOTTOM_HANDLE_POSITIONS: { 1: [50] },
|
||||
}))
|
||||
|
||||
function makeNode(data: Partial<NodeData>): Node<NodeData> {
|
||||
return {
|
||||
id: 'n1',
|
||||
type: data.type ?? 'server',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Node',
|
||||
type: 'server',
|
||||
status: 'online',
|
||||
services: [],
|
||||
...data,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function renderBaseNode(data: Partial<NodeData>) {
|
||||
const node = makeNode(data)
|
||||
return render(
|
||||
<BaseNode
|
||||
id={node.id}
|
||||
data={node.data}
|
||||
selected={false}
|
||||
icon={Server}
|
||||
type="server"
|
||||
dragging={false}
|
||||
zIndex={0}
|
||||
isConnectable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('BaseNode — properties rendering', () => {
|
||||
it('renders visible properties on the node', () => {
|
||||
renderBaseNode({
|
||||
properties: [
|
||||
{ key: 'CPU Model', value: 'i7-12700K', icon: 'Cpu', visible: true },
|
||||
{ key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: true },
|
||||
],
|
||||
})
|
||||
expect(screen.getByText('CPU Model')).toBeDefined()
|
||||
// Value is rendered with a middle-dot prefix: "· 32 GB"
|
||||
expect(screen.getByText(/32 GB/)).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not render properties with visible=false', () => {
|
||||
renderBaseNode({
|
||||
properties: [
|
||||
{ key: 'Secret', value: 'hidden', icon: null, visible: false },
|
||||
],
|
||||
})
|
||||
expect(screen.queryByText('Secret')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders nothing when properties array is empty', () => {
|
||||
const { container } = renderBaseNode({ properties: [] })
|
||||
// No properties section — only the main node card
|
||||
expect(container.querySelectorAll('.flex.flex-col.gap-1').length).toBe(0)
|
||||
})
|
||||
|
||||
it('renders label and ip regardless of properties', () => {
|
||||
renderBaseNode({
|
||||
label: 'My Server',
|
||||
ip: '192.168.1.10',
|
||||
properties: [{ key: 'OS', value: 'Debian 12', icon: 'Server', visible: true }],
|
||||
})
|
||||
expect(screen.getByText('My Server')).toBeDefined()
|
||||
expect(screen.getByText('192.168.1.10')).toBeDefined()
|
||||
expect(screen.getByText('OS')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BaseNode — legacy hardware fallback', () => {
|
||||
it('renders legacy hardware when properties is undefined and show_hardware is true', () => {
|
||||
renderBaseNode({
|
||||
properties: undefined,
|
||||
show_hardware: true,
|
||||
cpu_model: 'Intel Xeon E5-2680',
|
||||
ram_gb: 32,
|
||||
})
|
||||
expect(screen.getByText('Intel Xeon E5-2680')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not render legacy hardware when properties array is present (even if empty)', () => {
|
||||
renderBaseNode({
|
||||
properties: [],
|
||||
show_hardware: true,
|
||||
cpu_model: 'Intel Xeon E5-2680',
|
||||
})
|
||||
// properties array exists → new system, legacy section skipped
|
||||
expect(screen.queryByText('Intel Xeon E5-2680')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not render legacy hardware when show_hardware is false', () => {
|
||||
renderBaseNode({
|
||||
properties: undefined,
|
||||
show_hardware: false,
|
||||
cpu_model: 'Intel Xeon E5-2680',
|
||||
})
|
||||
expect(screen.queryByText('Intel Xeon E5-2680')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${waypoint.x}px, ${waypoint.y}px)`,
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
background: color,
|
||||
border: '2px solid #0d1117',
|
||||
cursor: 'grab',
|
||||
pointerEvents: 'all',
|
||||
zIndex: 10,
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
title="Drag to move · Double-click to remove"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${x}px, ${y}px)`,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: '50%',
|
||||
background: '#0d1117',
|
||||
border: `1.5px solid ${color}`,
|
||||
color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 12,
|
||||
lineHeight: 1,
|
||||
cursor: 'crosshair',
|
||||
pointerEvents: 'all',
|
||||
zIndex: 9,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
title="Click to add waypoint"
|
||||
>
|
||||
+
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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<Edge<EdgeData>>) {
|
||||
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 (
|
||||
<>
|
||||
<BaseEdge id={id} path={edgePath} style={style} />
|
||||
<BaseEdge id={id} path={edgePath} style={animMode === 'basic' ? { ...style, stroke: 'transparent' } : style} interactionWidth={16} />
|
||||
|
||||
{animMode === 'basic' && (
|
||||
<path
|
||||
d={edgePath}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={style.strokeWidth as number ?? 2}
|
||||
strokeDasharray="5"
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
animation: 'homelable-basic-dash 0.5s linear infinite',
|
||||
animationDirection: sourceY <= targetY ? 'normal' : 'reverse',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{animMode === 'snake' && (
|
||||
<path
|
||||
d={edgePath}
|
||||
@@ -92,12 +295,12 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
</path>
|
||||
)}
|
||||
|
||||
{data?.label && (
|
||||
<EdgeLabelRenderer>
|
||||
<EdgeLabelRenderer>
|
||||
{data?.label && (
|
||||
<div
|
||||
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${(sourceY + targetY) / 2}px)`,
|
||||
transform: `translate(-50%, -50%) translate(${midX}px, ${midY}px)`,
|
||||
background: theme.colors.edgeLabelBackground,
|
||||
color: theme.colors.edgeLabelColor,
|
||||
border: `1px solid ${theme.colors.edgeLabelBorder}`,
|
||||
@@ -105,8 +308,47 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
>
|
||||
{data.label as string}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* 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 (
|
||||
<WaypointHandle
|
||||
key={`wp-${idx}`}
|
||||
edgeId={id}
|
||||
index={idx}
|
||||
waypoint={wp}
|
||||
waypoints={waypoints}
|
||||
color={strokeColor}
|
||||
pathStyle={pathStyle}
|
||||
prevPoint={prevPoint}
|
||||
nextPoint={nextPoint}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* + 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 (
|
||||
<AddWaypointHandle
|
||||
key={`add-${mp.insertIndex}`}
|
||||
edgeId={id}
|
||||
insertIndex={mp.insertIndex}
|
||||
x={mp.x}
|
||||
y={mp.y}
|
||||
waypoints={waypoints}
|
||||
color={strokeColor}
|
||||
pathStyle={pathStyle}
|
||||
prevPoint={prevPoint}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react'
|
||||
import type { NodeData } from '@/types'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { resolveNodeIcon } from '@/utils/nodeIcons'
|
||||
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
@@ -31,7 +32,11 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
const colors = resolveNodeColors(data, activeTheme)
|
||||
const statusColor = theme.colors.statusColors[data.status]
|
||||
const isOnline = data.status === 'online'
|
||||
const showHardware = data.show_hardware && (data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null)
|
||||
|
||||
// Properties: prefer new system; fall back to legacy hardware fields for unmigrated nodes
|
||||
const visibleProperties = data.properties?.filter((p) => p.visible) ?? null
|
||||
const showLegacyHardware = !data.properties && data.show_hardware &&
|
||||
(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -39,11 +44,13 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
style={{
|
||||
background: colors.background,
|
||||
borderColor: colors.border,
|
||||
borderWidth: selected ? 2 : 1,
|
||||
boxShadow: isOnline
|
||||
borderWidth: 1,
|
||||
boxShadow: isOnline && selected
|
||||
? `0 0 0 1px ${colors.border}, 0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
|
||||
: isOnline
|
||||
? `0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
|
||||
: selected
|
||||
? `0 0 8px ${colors.border}44`
|
||||
? `0 0 0 1px ${colors.border}, 0 0 8px ${colors.border}44`
|
||||
: 'none',
|
||||
opacity: data.status === 'offline' ? 0.55 : 1,
|
||||
minWidth: 140,
|
||||
@@ -55,7 +62,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
isVisible={selected}
|
||||
minWidth={140}
|
||||
minHeight={50}
|
||||
lineStyle={{ borderColor: colors.border, borderWidth: 1 }}
|
||||
lineStyle={{ borderColor: 'transparent' }}
|
||||
handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8 }}
|
||||
/>
|
||||
<Handle
|
||||
@@ -100,12 +107,30 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hardware section */}
|
||||
{showHardware && (
|
||||
{/* Properties section (new system) */}
|
||||
{visibleProperties && visibleProperties.length > 0 && (
|
||||
<>
|
||||
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
||||
<div className="flex flex-col gap-1 px-2.5 py-1.5">
|
||||
{visibleProperties.map((prop, i) => {
|
||||
const Icon = resolvePropertyIcon(prop.icon)
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||
{Icon && <Icon size={9} className="shrink-0" />}
|
||||
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
|
||||
<span className="truncate" title={prop.value}>· {prop.value}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Legacy hardware section — fallback for nodes not yet migrated */}
|
||||
{showLegacyHardware && (
|
||||
<>
|
||||
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
||||
<div className="flex flex-col gap-1 px-2.5 py-1.5">
|
||||
{/* Line 1: CPU */}
|
||||
{(data.cpu_model || data.cpu_count != null) && (
|
||||
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||
<Cpu size={9} className="shrink-0" />
|
||||
@@ -117,7 +142,6 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Line 2: RAM + Disk */}
|
||||
{(data.ram_gb != null || data.disk_gb != null) && (
|
||||
<div className="flex items-center gap-2 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||
{data.ram_gb != null && (
|
||||
|
||||
@@ -73,7 +73,7 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
background: '#00d4ff',
|
||||
border: '1px solid #0d1117',
|
||||
}}
|
||||
lineStyle={{ borderColor: '#00d4ff55', borderWidth: 1 }}
|
||||
lineStyle={{ borderColor: 'transparent' }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
@@ -86,7 +86,8 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
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',
|
||||
|
||||
@@ -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<EdgeData>
|
||||
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<EdgeType>(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 =
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Animation</Label>
|
||||
<div className="flex rounded-md overflow-hidden border border-[#30363d]">
|
||||
{(['none', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
|
||||
{(['none', 'basic', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
@@ -135,10 +137,10 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
||||
style={{
|
||||
background: animation === mode ? '#00d4ff22' : '#21262d',
|
||||
color: animation === mode ? '#00d4ff' : '#8b949e',
|
||||
borderRight: i < 2 ? '1px solid #30363d' : undefined,
|
||||
borderRight: i < 3 ? '1px solid #30363d' : undefined,
|
||||
}}
|
||||
>
|
||||
{mode === 'none' ? 'None' : mode === 'snake' ? 'Snake' : 'Flow'}
|
||||
{mode === 'none' ? 'None' : mode === 'basic' ? 'Basic' : mode === 'snake' ? 'Snake' : 'Flow'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -175,6 +177,16 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{onClearWaypoints && initial?.waypoints && initial.waypoints.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onClearWaypoints(); onClose() }}
|
||||
className="text-[10px] text-muted-foreground hover:text-[#e3b341] transition-colors text-left"
|
||||
>
|
||||
Clear path ({initial.waypoints.length} point{initial.waypoints.length !== 1 ? 's' : ''})
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between gap-2 pt-1">
|
||||
{onDelete ? (
|
||||
<Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10" onClick={handleDelete}>
|
||||
|
||||
@@ -49,8 +49,6 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
const [iconSearch, setIconSearch] = useState('')
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||
const [labelError, setLabelError] = useState(false)
|
||||
const hasHardwareData = !!(initial?.cpu_count || initial?.cpu_model || initial?.ram_gb || initial?.disk_gb)
|
||||
const [hardwareOpen, setHardwareOpen] = useState(hasHardwareData)
|
||||
|
||||
const set = (key: keyof NodeData, value: unknown) =>
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
@@ -334,88 +332,6 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hardware specs (hidden for groupRect) */}
|
||||
{form.type !== 'groupRect' && (
|
||||
<div className="flex flex-col gap-2 col-span-2">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHardwareOpen((o) => !o)}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span className="font-medium">Hardware</span>
|
||||
<ChevronDown size={12} style={{ transform: hardwareOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
{hardwareOpen && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted-foreground/60">Show on node</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={!!form.show_hardware}
|
||||
onClick={() => set('show_hardware', !form.show_hardware)}
|
||||
className="relative inline-flex h-4 w-7 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none"
|
||||
style={{ background: form.show_hardware ? '#00d4ff' : '#30363d' }}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform"
|
||||
style={{ transform: form.show_hardware ? 'translateX(12px)' : 'translateX(0)' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hardwareOpen && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">CPU Model</Label>
|
||||
<Input
|
||||
value={form.cpu_model ?? ''}
|
||||
onChange={(e) => set('cpu_model', e.target.value || undefined)}
|
||||
placeholder="e.g. Intel Xeon E5-2680"
|
||||
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">CPU Cores</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.cpu_count ?? ''}
|
||||
onChange={(e) => set('cpu_count', e.target.value ? parseInt(e.target.value, 10) : undefined)}
|
||||
placeholder="e.g. 8"
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">RAM (GB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.5}
|
||||
value={form.ram_gb ?? ''}
|
||||
onChange={(e) => set('ram_gb', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
placeholder="e.g. 32"
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">Disk (GB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={form.disk_gb ?? ''}
|
||||
onChange={(e) => set('disk_gb', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
placeholder="e.g. 500"
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom connection points (not for group containers) */}
|
||||
{form.type !== 'groupRect' && form.type !== 'group' && (
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
|
||||
@@ -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(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
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(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ animated: 'basic' }} />)
|
||||
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(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
@@ -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(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }] }} />)
|
||||
expect(screen.queryByText(/Clear path/)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not show Clear path button when waypoints are empty', () => {
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()} initial={{ type: 'ethernet', waypoints: [] }} />)
|
||||
expect(screen.queryByText(/Clear path/)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not show Clear path button when no initial waypoints', () => {
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()} />)
|
||||
expect(screen.queryByText(/Clear path/)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows Clear path button with count when waypoints exist', () => {
|
||||
render(
|
||||
<EdgeModal
|
||||
open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()}
|
||||
initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }, { x: 3, y: 4 }] }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Clear path (2 points)')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows singular "point" when only one waypoint', () => {
|
||||
render(
|
||||
<EdgeModal
|
||||
open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()}
|
||||
initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }] }}
|
||||
/>,
|
||||
)
|
||||
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(
|
||||
<EdgeModal
|
||||
open onClose={onClose} onSubmit={vi.fn()} onClearWaypoints={onClearWaypoints}
|
||||
initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }] }}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByText('Clear path (1 point)'))
|
||||
expect(onClearWaypoints).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -293,91 +293,6 @@ describe('NodeModal', () => {
|
||||
expect(screen.getByText(/Using default colors for/)).toBeDefined()
|
||||
})
|
||||
|
||||
// ── Hardware section ──────────────────────────────────────────────────
|
||||
|
||||
it('renders Hardware toggle button', () => {
|
||||
renderModal()
|
||||
expect(screen.getByText('Hardware')).toBeDefined()
|
||||
})
|
||||
|
||||
it('hardware fields are hidden by default', () => {
|
||||
renderModal()
|
||||
expect(screen.queryByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeNull()
|
||||
})
|
||||
|
||||
it('expands hardware fields on toggle click', () => {
|
||||
renderModal()
|
||||
fireEvent.click(screen.getByText('Hardware'))
|
||||
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('e.g. 8')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('e.g. 32')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('e.g. 500')).toBeDefined()
|
||||
})
|
||||
|
||||
it('auto-expands when initial has hardware data', () => {
|
||||
renderModal({ initial: { ...BASE, cpu_count: 8, ram_gb: 32 } })
|
||||
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
|
||||
})
|
||||
|
||||
it('pre-fills hardware fields from initial', () => {
|
||||
renderModal({ initial: { ...BASE, cpu_model: 'Intel i5', cpu_count: 4, ram_gb: 16, disk_gb: 500 } })
|
||||
expect((screen.getByPlaceholderText('e.g. Intel Xeon E5-2680') as HTMLInputElement).value).toBe('Intel i5')
|
||||
})
|
||||
|
||||
it('submits hardware fields when filled', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Homelab' } })
|
||||
fireEvent.click(screen.getByText('Hardware'))
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680'), { target: { value: 'Intel i7-12700K' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 8'), { target: { value: '12' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 32'), { target: { value: '64' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 500'), { target: { value: '2000' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
||||
expect(data.cpu_model).toBe('Intel i7-12700K')
|
||||
expect(data.cpu_count).toBe(12)
|
||||
expect(data.ram_gb).toBe(64)
|
||||
expect(data.disk_gb).toBe(2000)
|
||||
})
|
||||
|
||||
it('hides Hardware section for groupRect type', () => {
|
||||
renderModal({ initial: { type: 'groupRect' } })
|
||||
expect(screen.queryByText('Hardware')).toBeNull()
|
||||
})
|
||||
|
||||
it('show_hardware toggle hidden when section is collapsed', () => {
|
||||
renderModal()
|
||||
expect(screen.queryByText('Show on node')).toBeNull()
|
||||
})
|
||||
|
||||
it('show_hardware toggle appears when section is expanded', () => {
|
||||
renderModal()
|
||||
fireEvent.click(screen.getByText('Hardware'))
|
||||
expect(screen.getByText('Show on node')).toBeDefined()
|
||||
})
|
||||
|
||||
it('show_hardware defaults to falsy', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect(onSubmit.mock.calls[0][0].show_hardware).toBeFalsy()
|
||||
})
|
||||
|
||||
it('toggling show_hardware sets it to true', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
|
||||
fireEvent.click(screen.getByText('Hardware'))
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
|
||||
})
|
||||
|
||||
it('pre-fills show_hardware from initial', () => {
|
||||
const { onSubmit } = renderModal({ initial: { label: 'Node', show_hardware: true, cpu_count: 8 } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
|
||||
})
|
||||
|
||||
// ── Bottom connection points ───────────────────────────────────────────
|
||||
|
||||
it('shows Bottom Connection Points for server type', () => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { createElement, useState } from 'react'
|
||||
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData } from '@/types'
|
||||
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
|
||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||
import type { Node } from '@xyflow/react'
|
||||
|
||||
interface DetailPanelProps {
|
||||
@@ -14,6 +15,9 @@ interface DetailPanelProps {
|
||||
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string }
|
||||
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' }
|
||||
|
||||
type PropForm = { key: string; value: string; icon: string | null; visible: boolean }
|
||||
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
|
||||
|
||||
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore()
|
||||
|
||||
@@ -24,6 +28,12 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const [groupName, setGroupName] = useState('')
|
||||
const [creatingGroup, setCreatingGroup] = useState(false)
|
||||
|
||||
// Properties state
|
||||
const [addingProp, setAddingProp] = useState(false)
|
||||
const [newProp, setNewProp] = useState<PropForm>(EMPTY_PROP)
|
||||
const [editingPropIndex, setEditingPropIndex] = useState<number | null>(null)
|
||||
const [editProp, setEditProp] = useState<PropForm>(EMPTY_PROP)
|
||||
|
||||
// Multi-select panel
|
||||
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
|
||||
|
||||
@@ -119,6 +129,52 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
setEditingFor(null)
|
||||
}
|
||||
|
||||
// --- Property handlers ---
|
||||
const properties: NodeProperty[] = data.properties ?? []
|
||||
|
||||
const handleAddProp = () => {
|
||||
if (!newProp.key.trim() || !newProp.value.trim()) return
|
||||
snapshotHistory()
|
||||
const prop: NodeProperty = { key: newProp.key.trim(), value: newProp.value.trim(), icon: newProp.icon, visible: newProp.visible }
|
||||
updateNode(node.id, { properties: [...properties, prop] })
|
||||
setNewProp(EMPTY_PROP)
|
||||
setAddingProp(false)
|
||||
}
|
||||
|
||||
const handleRemoveProp = (index: number) => {
|
||||
snapshotHistory()
|
||||
updateNode(node.id, { properties: properties.filter((_, i) => i !== index) })
|
||||
if (editingPropIndex === index) setEditingPropIndex(null)
|
||||
}
|
||||
|
||||
const handleTogglePropVisible = (index: number) => {
|
||||
snapshotHistory()
|
||||
updateNode(node.id, {
|
||||
properties: properties.map((p, i) => i === index ? { ...p, visible: !p.visible } : p),
|
||||
})
|
||||
}
|
||||
|
||||
const handleStartEditProp = (index: number) => {
|
||||
const p = properties[index]
|
||||
if (!p) return
|
||||
setEditProp({ key: p.key, value: p.value, icon: p.icon, visible: p.visible })
|
||||
setEditingPropIndex(index)
|
||||
setAddingProp(false)
|
||||
}
|
||||
|
||||
const handleSaveEditProp = () => {
|
||||
if (editingPropIndex === null || !editProp.key.trim() || !editProp.value.trim()) return
|
||||
snapshotHistory()
|
||||
updateNode(node.id, {
|
||||
properties: properties.map((p, i) =>
|
||||
i === editingPropIndex
|
||||
? { key: editProp.key.trim(), value: editProp.value.trim(), icon: editProp.icon, visible: editProp.visible }
|
||||
: p
|
||||
),
|
||||
})
|
||||
setEditingPropIndex(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
@@ -153,15 +209,54 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
{data.last_seen && <DetailRow label="Last Seen" value={new Date(data.last_seen.endsWith('Z') ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
|
||||
</div>
|
||||
|
||||
{(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && (
|
||||
<div className="flex flex-col gap-3 px-4 py-3 text-sm border-t border-border">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Hardware</span>
|
||||
{data.cpu_model && <DetailRow label="CPU" value={data.cpu_model} />}
|
||||
{data.cpu_count != null && <DetailRow label="Cores" value={String(data.cpu_count)} mono />}
|
||||
{data.ram_gb != null && <DetailRow label="RAM" value={formatStorage(data.ram_gb)} mono />}
|
||||
{data.disk_gb != null && <DetailRow label="Disk" value={formatStorage(data.disk_gb)} mono />}
|
||||
{/* Properties section */}
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground">Properties{properties.length > 0 ? ` (${properties.length})` : ''}</span>
|
||||
<button
|
||||
onClick={() => { setAddingProp((v) => !v); setEditingPropIndex(null) }}
|
||||
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors"
|
||||
>
|
||||
<Plus size={10} /> Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{addingProp && (
|
||||
<PropertyForm
|
||||
form={newProp}
|
||||
onChange={setNewProp}
|
||||
onConfirm={handleAddProp}
|
||||
onCancel={() => { setAddingProp(false); setNewProp(EMPTY_PROP) }}
|
||||
confirmLabel="Add"
|
||||
/>
|
||||
)}
|
||||
{properties.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{properties.map((prop, i) =>
|
||||
editingPropIndex === i ? (
|
||||
<PropertyForm
|
||||
key={`edit-${i}`}
|
||||
form={editProp}
|
||||
onChange={setEditProp}
|
||||
onConfirm={handleSaveEditProp}
|
||||
onCancel={() => setEditingPropIndex(null)}
|
||||
confirmLabel="Save"
|
||||
/>
|
||||
) : (
|
||||
<PropertyBadge
|
||||
key={`${prop.key}-${i}`}
|
||||
prop={prop}
|
||||
onToggleVisible={() => handleTogglePropVisible(i)}
|
||||
onEdit={() => handleStartEditProp(i)}
|
||||
onRemove={() => handleRemoveProp(i)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{properties.length === 0 && !addingProp && (
|
||||
<p className="text-[10px] text-muted-foreground/50">No properties — click Add to define one.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
@@ -365,11 +460,6 @@ function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onS
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function formatStorage(gb: number): string {
|
||||
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
|
||||
return `${gb} GB`
|
||||
}
|
||||
|
||||
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-2 items-baseline">
|
||||
@@ -407,6 +497,113 @@ function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFo
|
||||
)
|
||||
}
|
||||
|
||||
// --- Property components ---
|
||||
|
||||
function PropertyForm({ form, onChange, onConfirm, onCancel, confirmLabel }: {
|
||||
form: PropForm
|
||||
onChange: (f: PropForm) => void
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
confirmLabel: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
|
||||
<Input
|
||||
value={form.key}
|
||||
onChange={(e) => onChange({ ...form, key: e.target.value })}
|
||||
placeholder="Label (e.g. CPU Model)"
|
||||
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
||||
autoFocus
|
||||
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
|
||||
/>
|
||||
<Input
|
||||
value={form.value}
|
||||
onChange={(e) => onChange({ ...form, value: e.target.value })}
|
||||
placeholder="Value (e.g. i7-12700K)"
|
||||
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
||||
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
|
||||
/>
|
||||
{/* Icon picker */}
|
||||
<div className="flex flex-wrap gap-1 pt-0.5">
|
||||
<button
|
||||
onClick={() => onChange({ ...form, icon: null })}
|
||||
title="No icon"
|
||||
className={`w-6 h-6 rounded flex items-center justify-center text-[10px] border transition-colors ${
|
||||
form.icon === null ? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]' : 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
|
||||
}`}
|
||||
>
|
||||
–
|
||||
</button>
|
||||
{PROPERTY_ICON_NAMES.map((name) => {
|
||||
const Icon = PROPERTY_ICONS[name]
|
||||
const active = form.icon === name
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => onChange({ ...form, icon: name })}
|
||||
title={name}
|
||||
className={`w-6 h-6 rounded flex items-center justify-center border transition-colors ${
|
||||
active ? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]' : 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
|
||||
}`}
|
||||
>
|
||||
{createElement(Icon, { size: 11 })}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Visible toggle */}
|
||||
<label className="flex items-center gap-2 cursor-pointer pt-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.visible}
|
||||
onChange={(e) => onChange({ ...form, visible: e.target.checked })}
|
||||
className="accent-[#00d4ff] w-3 h-3"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">Show on node</span>
|
||||
</label>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PropertyBadge({ prop, onToggleVisible, onEdit, onRemove }: {
|
||||
prop: NodeProperty
|
||||
onToggleVisible: () => void
|
||||
onEdit: () => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const Icon = resolvePropertyIcon(prop.icon)
|
||||
return (
|
||||
<div className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors" style={{ background: '#21262d', borderColor: '#30363d' }}>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{Icon && createElement(Icon, { size: 11, className: 'shrink-0 text-muted-foreground' })}
|
||||
<span className="font-medium truncate text-foreground" title={prop.key}>{prop.key}</span>
|
||||
<span className="text-muted-foreground truncate" title={prop.value}>· {prop.value}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={onToggleVisible}
|
||||
title={prop.visible ? 'Hide on node' : 'Show on node'}
|
||||
className="text-[#8b949e] hover:text-[#00d4ff] transition-colors"
|
||||
>
|
||||
{prop.visible ? <Eye size={10} /> : <EyeOff size={10} />}
|
||||
</button>
|
||||
<button onClick={onEdit} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff]" title="Edit property">
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
<button onClick={onRemove} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149]" title="Remove property">
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { scanApi, settingsApi } from '@/api/client'
|
||||
import { toast } from 'sonner'
|
||||
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
@@ -152,6 +153,8 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!collapsed && <VersionBadge />}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -548,6 +551,34 @@ function SettingsPanel() {
|
||||
)
|
||||
}
|
||||
|
||||
function VersionBadge() {
|
||||
const current = __APP_VERSION__
|
||||
const { latest, hasUpdate } = useLatestRelease(current)
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2 border-t border-border flex flex-col gap-1">
|
||||
<a
|
||||
href={`https://github.com/Pouzor/homelable/releases/tag/v${current}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
v{current}
|
||||
</a>
|
||||
{hasUpdate && latest && (
|
||||
<a
|
||||
href={latest.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-[#e3b341]/15 text-[#e3b341] border border-[#e3b341]/30 hover:bg-[#e3b341]/25 transition-colors self-start"
|
||||
>
|
||||
↑ v{latest.version} available
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MAC_OUI: Record<string, { label: string; title: string }> = {
|
||||
'52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' },
|
||||
'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' },
|
||||
|
||||
@@ -69,58 +69,137 @@ describe('DetailPanel', () => {
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
describe('Hardware section', () => {
|
||||
it('does not render hardware section when no hardware data', () => {
|
||||
setupStore({ label: 'Server' })
|
||||
describe('Properties section', () => {
|
||||
it('renders empty state when no properties', () => {
|
||||
setupStore({ properties: [] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.queryByText('Hardware')).toBeNull()
|
||||
expect(screen.getByText(/No properties/)).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders hardware section when cpu_count is set', () => {
|
||||
setupStore({ cpu_count: 8 })
|
||||
it('renders properties with key and value', () => {
|
||||
setupStore({
|
||||
properties: [
|
||||
{ key: 'CPU Model', value: 'i7-12700K', icon: 'Cpu', visible: true },
|
||||
{ key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: false },
|
||||
],
|
||||
})
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('Hardware')).toBeDefined()
|
||||
expect(screen.getByText('8')).toBeDefined()
|
||||
expect(screen.getByText('CPU Model')).toBeDefined()
|
||||
// Value is rendered with a middle-dot prefix: "· 32 GB"
|
||||
expect(screen.getByText(/32 GB/)).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders cpu_model', () => {
|
||||
setupStore({ cpu_model: 'Intel Xeon E5-2680' })
|
||||
it('shows Properties count when properties exist', () => {
|
||||
setupStore({
|
||||
properties: [
|
||||
{ key: 'CPU Model', value: 'i7', icon: null, visible: true },
|
||||
{ key: 'RAM', value: '16 GB', icon: null, visible: true },
|
||||
],
|
||||
})
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('Intel Xeon E5-2680')).toBeDefined()
|
||||
expect(screen.getByText('Properties (2)')).toBeDefined()
|
||||
})
|
||||
|
||||
it('formats ram_gb in GB', () => {
|
||||
setupStore({ ram_gb: 32 })
|
||||
it('shows add form when Add is clicked', () => {
|
||||
setupStore({ properties: [] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('32 GB')).toBeDefined()
|
||||
// There are multiple "Add" buttons (services + properties) — find the one after "Properties"
|
||||
const addButtons = screen.getAllByText('Add')
|
||||
fireEvent.click(addButtons[0]) // first Add = properties (rendered above services)
|
||||
expect(screen.getByPlaceholderText('Label (e.g. CPU Model)')).toBeDefined()
|
||||
})
|
||||
|
||||
it('formats ram_gb >= 1024 as TB', () => {
|
||||
setupStore({ ram_gb: 2048 })
|
||||
it('calls updateNode with new property on Add confirm', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ properties: [] })],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('2 TB')).toBeDefined()
|
||||
const addButtons = screen.getAllByText('Add')
|
||||
fireEvent.click(addButtons[0]) // first Add = properties
|
||||
// Form is now open — fill key and value
|
||||
fireEvent.change(screen.getByPlaceholderText('Label (e.g. CPU Model)'), { target: { value: 'GPU' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { target: { value: 'RTX 4090' } })
|
||||
// The PropertyForm confirm button is labeled "Add" — use the form's confirm button
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { key: 'Enter' })
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
const [, payload] = updateNode.mock.calls[0]
|
||||
expect(payload.properties[0]).toMatchObject({ key: 'GPU', value: 'RTX 4090', visible: true })
|
||||
})
|
||||
|
||||
it('formats disk_gb in GB', () => {
|
||||
setupStore({ disk_gb: 500 })
|
||||
it('calls updateNode with toggled visibility when eye button is clicked', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ properties: [{ key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: true }] })],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('500 GB')).toBeDefined()
|
||||
fireEvent.click(screen.getByTitle('Hide on node'))
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
const [, payload] = updateNode.mock.calls[0]
|
||||
expect(payload.properties[0].visible).toBe(false)
|
||||
})
|
||||
|
||||
it('formats disk_gb >= 1024 as TB', () => {
|
||||
setupStore({ disk_gb: 1536 })
|
||||
it('calls updateNode without the property when remove button is clicked', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ properties: [{ key: 'GPU', value: 'RTX 4090', icon: null, visible: true }] })],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('1.5 TB')).toBeDefined()
|
||||
fireEvent.click(screen.getByTitle('Remove property'))
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
const [, payload] = updateNode.mock.calls[0]
|
||||
expect(payload.properties).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders all hardware fields together', () => {
|
||||
setupStore({ cpu_count: 16, cpu_model: 'AMD EPYC', ram_gb: 128, disk_gb: 4096 })
|
||||
it('does not submit add form when key is empty', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ properties: [] })],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('Hardware')).toBeDefined()
|
||||
expect(screen.getByText('AMD EPYC')).toBeDefined()
|
||||
expect(screen.getByText('16')).toBeDefined()
|
||||
expect(screen.getByText('128 GB')).toBeDefined()
|
||||
expect(screen.getByText('4 TB')).toBeDefined()
|
||||
const addButtons = screen.getAllByText('Add')
|
||||
fireEvent.click(addButtons[0])
|
||||
// Only fill value, leave key empty
|
||||
fireEvent.change(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { target: { value: 'some value' } })
|
||||
const confirmButtons = screen.getAllByRole('button', { name: 'Add' })
|
||||
fireEvent.click(confirmButtons[confirmButtons.length - 1])
|
||||
expect(updateNode).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -189,7 +268,9 @@ describe('DetailPanel', () => {
|
||||
it('shows add form when Add is clicked', () => {
|
||||
setupStore({})
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
// Two "Add" buttons: first = properties, second = services
|
||||
const addButtons = screen.getAllByText('Add')
|
||||
fireEvent.click(addButtons[addButtons.length - 1])
|
||||
expect(screen.getByPlaceholderText('Service name')).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -198,18 +279,21 @@ describe('DetailPanel', () => {
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({})],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
// Two "Add" header buttons: first = properties, second = services
|
||||
const addHeaders = screen.getAllByText('Add')
|
||||
fireEvent.click(addHeaders[addHeaders.length - 1])
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
|
||||
// Two "Add" buttons exist: the header toggle and the form confirm — pick the form's
|
||||
const addButtons = screen.getAllByRole('button', { name: 'Add' })
|
||||
fireEvent.click(addButtons[addButtons.length - 1])
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('Port'), { key: 'Enter' })
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp' })
|
||||
})
|
||||
|
||||
@@ -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<typeof useCanvasStore>)
|
||||
|
||||
return render(
|
||||
<Sidebar
|
||||
onAddNode={vi.fn()}
|
||||
onAddGroupRect={vi.fn()}
|
||||
onScan={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onNodeApproved={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<ReleaseInfo | null>(
|
||||
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 }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -191,6 +191,10 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
let nodes = state.nodes.map((n) => {
|
||||
if (n.id !== id) return n
|
||||
const updated: Node<NodeData> = { ...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) {
|
||||
|
||||
@@ -43,6 +43,13 @@ export interface ServiceInfo {
|
||||
category?: string
|
||||
}
|
||||
|
||||
export interface NodeProperty {
|
||||
key: string
|
||||
value: string
|
||||
icon: string | null
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
export interface NodeData extends Record<string, unknown> {
|
||||
label: string
|
||||
type: NodeType
|
||||
@@ -62,6 +69,7 @@ export interface NodeData extends Record<string, unknown> {
|
||||
ram_gb?: number
|
||||
disk_gb?: number
|
||||
show_hardware?: boolean
|
||||
properties?: NodeProperty[]
|
||||
parent_id?: string
|
||||
container_mode?: boolean
|
||||
custom_colors?: {
|
||||
@@ -87,6 +95,11 @@ export interface NodeData extends Record<string, unknown> {
|
||||
|
||||
export type EdgePathStyle = 'bezier' | 'smooth'
|
||||
|
||||
export interface Waypoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface EdgeData extends Record<string, unknown> {
|
||||
type: EdgeType
|
||||
label?: string
|
||||
@@ -94,7 +107,8 @@ export interface EdgeData extends Record<string, unknown> {
|
||||
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<NodeType, string> = {
|
||||
|
||||
@@ -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 ───────────────────────────────────────
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<EdgeData>[] = [{
|
||||
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<EdgeData>[] = [{
|
||||
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<EdgeData>[] = [
|
||||
{ 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', () => {
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
ram_gb?: number | null
|
||||
disk_gb?: number | null
|
||||
show_hardware?: boolean
|
||||
properties?: unknown[] | null
|
||||
width?: number | null
|
||||
height?: number | null
|
||||
bottom_handles?: number
|
||||
@@ -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<NodeData>): Record<string, unknown> {
|
||||
ram_gb: n.data.ram_gb ?? null,
|
||||
disk_gb: n.data.disk_gb ?? null,
|
||||
show_hardware: n.data.show_hardware ?? false,
|
||||
properties: n.data.properties ?? [],
|
||||
width: n.width ?? null,
|
||||
height: n.height ?? null,
|
||||
bottom_handles: n.data.bottom_handles ?? 1,
|
||||
@@ -121,6 +124,7 @@ export function serializeEdge(e: Edge<EdgeData>): Record<string, unknown> {
|
||||
animated: e.data?.animated ?? false,
|
||||
source_handle: normalizeHandle(e.sourceHandle),
|
||||
target_handle: normalizeHandle(e.targetHandle),
|
||||
waypoints: e.data?.waypoints?.length ? e.data.waypoints : null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Battery,
|
||||
Box,
|
||||
Clock,
|
||||
Cpu,
|
||||
Database,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Hash,
|
||||
Key,
|
||||
Layers,
|
||||
Link,
|
||||
MemoryStick,
|
||||
Monitor,
|
||||
Network,
|
||||
Server,
|
||||
Shield,
|
||||
Tag,
|
||||
Thermometer,
|
||||
Wifi,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export const PROPERTY_ICONS: Record<string, LucideIcon> = {
|
||||
Battery,
|
||||
Box,
|
||||
Clock,
|
||||
Cpu,
|
||||
Database,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Hash,
|
||||
Key,
|
||||
Layers,
|
||||
Link,
|
||||
MemoryStick,
|
||||
Monitor,
|
||||
Network,
|
||||
Server,
|
||||
Shield,
|
||||
Tag,
|
||||
Thermometer,
|
||||
Wifi,
|
||||
Zap,
|
||||
}
|
||||
|
||||
export const PROPERTY_ICON_NAMES = Object.keys(PROPERTY_ICONS) as (keyof typeof PROPERTY_ICONS)[]
|
||||
|
||||
export function resolvePropertyIcon(name: string | null | undefined): LucideIcon | null {
|
||||
if (!name) return null
|
||||
return PROPERTY_ICONS[name] ?? null
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user