Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b04deb608 | |||
| 531fb12eab | |||
| e666abefad | |||
| f1e9fd7cf8 | |||
| 9134812e32 | |||
| 4844576c3b | |||
| 4976f2e694 | |||
| d6a7b062f4 | |||
| 94c6ac7fa7 | |||
| e0f96001e2 |
@@ -45,6 +45,7 @@ htmlcov/
|
|||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
*.db.back
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
.docker/
|
.docker/
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class Edge(Base):
|
|||||||
animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
|
animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
|
||||||
source_handle: Mapped[str | None] = mapped_column(String)
|
source_handle: Mapped[str | None] = mapped_column(String)
|
||||||
target_handle: Mapped[str | None] = mapped_column(String)
|
target_handle: Mapped[str | None] = mapped_column(String)
|
||||||
waypoints: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
waypoints: Mapped[list[dict[str, float]] | None] = mapped_column(JSON, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Homelable API",
|
title="Homelable API",
|
||||||
version="1.8.3",
|
version="1.9.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class EdgeSave(BaseModel):
|
|||||||
animated: str = 'none'
|
animated: str = 'none'
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
waypoints: list | None = None
|
waypoints: list[dict[str, float]] | None = None
|
||||||
|
|
||||||
@field_validator('animated', mode='before')
|
@field_validator('animated', mode='before')
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class EdgeBase(BaseModel):
|
|||||||
animated: str = 'none'
|
animated: str = 'none'
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
waypoints: list | None = None
|
waypoints: list[dict[str, float]] | None = None
|
||||||
|
|
||||||
@field_validator('animated', mode='before')
|
@field_validator('animated', mode='before')
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -39,7 +39,7 @@ class EdgeUpdate(BaseModel):
|
|||||||
animated: str | None = None
|
animated: str | None = None
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
waypoints: list | None = None
|
waypoints: list[dict[str, float]] | None = None
|
||||||
|
|
||||||
@field_validator('animated', mode='before')
|
@field_validator('animated', mode='before')
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ def normalize_animated(v: object) -> str:
|
|||||||
return 'snake'
|
return 'snake'
|
||||||
if v is False or v == 0 or v == '0' or v is None or v == 'none':
|
if v is False or v == 0 or v == '0' or v is None or v == 'none':
|
||||||
return 'none'
|
return 'none'
|
||||||
if v in ('snake', 'flow'):
|
if v in ('snake', 'flow', 'basic'):
|
||||||
return str(v)
|
return str(v)
|
||||||
return 'none'
|
return 'none'
|
||||||
|
|||||||
Binary file not shown.
@@ -25,6 +25,8 @@ addopts = "--tb=short -q"
|
|||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source = ["app"]
|
source = ["app"]
|
||||||
omit = ["*/migrations/*", "*/tests/*"]
|
omit = ["*/migrations/*", "*/tests/*"]
|
||||||
|
concurrency = ["thread"]
|
||||||
|
core = "sysmon"
|
||||||
|
|
||||||
[tool.coverage.report]
|
[tool.coverage.report]
|
||||||
skip_empty = true
|
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()
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
assert canvas["nodes"][0]["width"] is None
|
assert canvas["nodes"][0]["width"] is None
|
||||||
assert canvas["nodes"][0]["height"] 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"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.8.3",
|
"version": "1.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -233,9 +233,10 @@ 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)` } : {}),
|
...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
const animMode: 'none' | 'snake' | 'flow' =
|
const animMode: 'none' | 'snake' | 'flow' | 'basic' =
|
||||||
data?.animated === true || data?.animated === 'snake' ? 'snake' :
|
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 animColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string)
|
||||||
|
|
||||||
@@ -245,7 +246,22 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<BaseEdge id={id} path={edgePath} style={style} interactionWidth={16} />
|
<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' && (
|
{animMode === 'snake' && (
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ import { EDGE_DEFAULT_COLORS } from '@/utils/edgeColors'
|
|||||||
|
|
||||||
const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][]
|
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 {
|
function toAnimMode(v: EdgeData['animated']): AnimMode {
|
||||||
if (v === true || v === 'snake') return 'snake'
|
if (v === true || v === 'snake') return 'snake'
|
||||||
if (v === 'flow') return 'flow'
|
if (v === 'flow') return 'flow'
|
||||||
|
if (v === 'basic') return 'basic'
|
||||||
return 'none'
|
return 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +128,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Animation</Label>
|
<Label className="text-xs text-muted-foreground">Animation</Label>
|
||||||
<div className="flex rounded-md overflow-hidden border border-[#30363d]">
|
<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
|
<button
|
||||||
key={mode}
|
key={mode}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -136,10 +137,10 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
style={{
|
style={{
|
||||||
background: animation === mode ? '#00d4ff22' : '#21262d',
|
background: animation === mode ? '#00d4ff22' : '#21262d',
|
||||||
color: animation === mode ? '#00d4ff' : '#8b949e',
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -122,6 +122,21 @@ describe('EdgeModal', () => {
|
|||||||
expect(onSubmit.mock.calls[0][0].animated).toBe('flow')
|
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', () => {
|
it('selecting None after Snake omits animated from payload', () => {
|
||||||
const onSubmit = vi.fn()
|
const onSubmit = vi.fn()
|
||||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@keyframes homelable-basic-dash {
|
||||||
|
from { stroke-dashoffset: 10; }
|
||||||
|
to { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Homelable dark theme — always dark */
|
/* Homelable dark theme — always dark */
|
||||||
:root {
|
:root {
|
||||||
--background: #0d1117;
|
--background: #0d1117;
|
||||||
|
|||||||
@@ -191,6 +191,10 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
let nodes = state.nodes.map((n) => {
|
let nodes = state.nodes.map((n) => {
|
||||||
if (n.id !== id) return n
|
if (n.id !== id) return n
|
||||||
const updated: Node<NodeData> = { ...n, data: { ...n.data, ...data } }
|
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) {
|
if ('parent_id' in data) {
|
||||||
const newParentId = data.parent_id ?? undefined
|
const newParentId = data.parent_id ?? undefined
|
||||||
if (!newParentId && n.parentId) {
|
if (!newParentId && n.parentId) {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export interface EdgeData extends Record<string, unknown> {
|
|||||||
speed?: string
|
speed?: string
|
||||||
custom_color?: string
|
custom_color?: string
|
||||||
path_style?: EdgePathStyle
|
path_style?: EdgePathStyle
|
||||||
animated?: boolean | 'snake' | 'flow' | 'none'
|
animated?: boolean | 'snake' | 'flow' | 'basic' | 'none'
|
||||||
waypoints?: Waypoint[]
|
waypoints?: Waypoint[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,73 @@ describe('Standalone localStorage save/load cycle', () => {
|
|||||||
expect(stored.data?.label).toBe('VLAN 20')
|
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 ────────────────────────────────────────────────
|
// ── loadCanvas marks clean ────────────────────────────────────────────────
|
||||||
|
|
||||||
it('loadCanvas sets hasUnsavedChanges to false', () => {
|
it('loadCanvas sets hasUnsavedChanges to false', () => {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export interface ApiEdge {
|
|||||||
speed?: string | null
|
speed?: string | null
|
||||||
custom_color?: string | null
|
custom_color?: string | null
|
||||||
path_style?: string | null
|
path_style?: string | null
|
||||||
animated?: boolean | 'snake' | 'flow' | 'none'
|
animated?: boolean | 'snake' | 'flow' | 'basic' | 'none'
|
||||||
source_handle?: string | null
|
source_handle?: string | null
|
||||||
target_handle?: string | null
|
target_handle?: string | null
|
||||||
waypoints?: Waypoint[] | null
|
waypoints?: Waypoint[] | null
|
||||||
|
|||||||
Reference in New Issue
Block a user