Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fb77ab00b | |||
| 56cfbd1e76 | |||
| 43761c60cb | |||
| ad958feabd | |||
| e3876e934c | |||
| dfa4a9c849 | |||
| 785be6a5dd | |||
| 39f8d16ef1 | |||
| 0095bf8425 | |||
| 2cc97a6de9 | |||
| 29a2ef1b20 | |||
| 0c836e0575 | |||
| 6518eb313b | |||
| 09a591f5f4 | |||
| 988b804b90 | |||
| 6fa0ada325 | |||
| 17613f42d1 | |||
| 0a44b69c4e | |||
| 84235d81bf | |||
| 31b61904ac | |||
| babbcb1dc5 | |||
| 12fde681ba | |||
| 63922f0841 | |||
| 896cd4fa21 | |||
| 18f9bb7bdf | |||
| da287d459c | |||
| adb2088752 | |||
| cd0e08fb91 | |||
| 3ccdde0bea | |||
| d0a49d0a0d | |||
| 31b5bc4515 | |||
| b05d70663c | |||
| 4e68af7cac | |||
| 528c362633 | |||
| bfe520cd49 | |||
| 29c97ae501 | |||
| 4ecd241bf4 | |||
| 96786f155b | |||
| 1d6127fed3 | |||
| 26633f760d | |||
| 745002593f | |||
| 1a978c5e51 | |||
| 16adff5cff | |||
| 45b0965fb7 | |||
| f1bcd6ef78 | |||
| 26be37f731 | |||
| bfd7ccd36c | |||
| ada30311ed | |||
| 6ead20125d | |||
| 8626fb2ca4 | |||
| 5952274c27 | |||
| 9c035e2be2 | |||
| cad3add223 | |||
| fc888629c3 | |||
| 6a21cc729e | |||
| 06370529c6 | |||
| 892710faac | |||
| 431fb47498 |
@@ -0,0 +1,15 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||||
|
patreon: # Replace with a single Patreon username
|
||||||
|
open_collective: # Replace with a single Open Collective username
|
||||||
|
ko_fi: pouzor
|
||||||
|
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||||
|
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
|
liberapay: # Replace with a single Liberapay username
|
||||||
|
issuehunt: # Replace with a single IssueHunt username
|
||||||
|
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||||
|
polar: # Replace with a single Polar username
|
||||||
|
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||||
|
thanks_dev: # Replace with a single thanks.dev username
|
||||||
|
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||||
@@ -99,7 +99,7 @@ The page shows your canvas in pan/zoom-only mode — no editing, no credentials
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MCP Server (AI Integration) (optionnal)
|
## MCP Server (AI Integration) (optional)
|
||||||
|
|
||||||
Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
|
Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_c
|
|||||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||||
viewport=viewport,
|
viewport=viewport,
|
||||||
|
custom_style=state.custom_style if state else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -67,13 +68,14 @@ async def save_canvas(
|
|||||||
else:
|
else:
|
||||||
db.add(Edge(**edge_data.model_dump()))
|
db.add(Edge(**edge_data.model_dump()))
|
||||||
|
|
||||||
# Upsert viewport
|
# Upsert viewport + custom style
|
||||||
state = await db.get(CanvasState, 1)
|
state = await db.get(CanvasState, 1)
|
||||||
if state:
|
if state:
|
||||||
state.viewport = body.viewport
|
state.viewport = body.viewport
|
||||||
|
state.custom_style = body.custom_style
|
||||||
state.saved_at = datetime.now(timezone.utc)
|
state.saved_at = datetime.now(timezone.utc)
|
||||||
else:
|
else:
|
||||||
db.add(CanvasState(id=1, viewport=body.viewport))
|
db.add(CanvasState(id=1, viewport=body.viewport, custom_style=body.custom_style))
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"saved": True}
|
return {"saved": True}
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ async def init_db() -> None:
|
|||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON")
|
||||||
|
with suppress(OperationalError):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE canvas_state ADD COLUMN custom_style JSON")
|
||||||
# Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL)
|
# Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL)
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
rows = await conn.exec_driver_sql(
|
rows = await conn.exec_driver_sql(
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ class CanvasState(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||||
viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||||
|
custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -62,9 +62,11 @@ class CanvasSaveRequest(BaseModel):
|
|||||||
nodes: list[NodeSave] = []
|
nodes: list[NodeSave] = []
|
||||||
edges: list[EdgeSave] = []
|
edges: list[EdgeSave] = []
|
||||||
viewport: dict[str, Any] = {}
|
viewport: dict[str, Any] = {}
|
||||||
|
custom_style: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class CanvasStateResponse(BaseModel):
|
class CanvasStateResponse(BaseModel):
|
||||||
nodes: list[NodeResponse]
|
nodes: list[NodeResponse]
|
||||||
edges: list[EdgeResponse]
|
edges: list[EdgeResponse]
|
||||||
viewport: dict[str, Any]
|
viewport: dict[str, Any]
|
||||||
|
custom_style: dict[str, Any] | None = None
|
||||||
|
|||||||
@@ -557,3 +557,42 @@ async def test_save_canvas_edge_update_existing(client: AsyncClient, headers: di
|
|||||||
edge = canvas["edges"][0]
|
edge = canvas["edges"][0]
|
||||||
assert edge["label"] == "updated"
|
assert edge["label"] == "updated"
|
||||||
assert edge["custom_color"] == "#ff0000"
|
assert edge["custom_color"] == "#ff0000"
|
||||||
|
|
||||||
|
|
||||||
|
# ── custom_style ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_save_and_load_custom_style(client: AsyncClient, headers: dict):
|
||||||
|
custom_style = {
|
||||||
|
"nodes": {
|
||||||
|
"server": {"borderColor": "#ff0000", "borderOpacity": 0.8, "bgColor": "#000000", "bgOpacity": 1, "iconColor": "#ff0000", "iconOpacity": 1, "width": 200, "height": 80},
|
||||||
|
},
|
||||||
|
"edges": {
|
||||||
|
"ethernet": {"color": "#00ff00", "opacity": 1, "pathStyle": "bezier", "animated": "none"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
payload = {"nodes": [], "edges": [], "viewport": {"theme_id": "custom"}, "custom_style": custom_style}
|
||||||
|
res = await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["custom_style"] is not None
|
||||||
|
assert canvas["custom_style"]["nodes"]["server"]["borderColor"] == "#ff0000"
|
||||||
|
assert canvas["custom_style"]["edges"]["ethernet"]["color"] == "#00ff00"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_load_canvas_custom_style_null_by_default(client: AsyncClient, headers: dict):
|
||||||
|
res = await client.get("/api/v1/canvas", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["custom_style"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_custom_style_overwrite(client: AsyncClient, headers: dict):
|
||||||
|
style_v1 = {"nodes": {"server": {"borderColor": "#aabbcc", "borderOpacity": 1, "bgColor": "#000000", "bgOpacity": 1, "iconColor": "#aabbcc", "iconOpacity": 1, "width": 0, "height": 0}}, "edges": {}}
|
||||||
|
style_v2 = {"nodes": {"proxmox": {"borderColor": "#ff6e00", "borderOpacity": 1, "bgColor": "#111111", "bgOpacity": 1, "iconColor": "#ff6e00", "iconOpacity": 1, "width": 0, "height": 0}}, "edges": {}}
|
||||||
|
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}, "custom_style": style_v1}, headers=headers)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}, "custom_style": style_v2}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert "proxmox" in canvas["custom_style"]["nodes"]
|
||||||
|
assert "server" not in canvas["custom_style"]["nodes"]
|
||||||
|
|||||||
Generated
+413
-3
@@ -1,18 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.8.2",
|
"version": "1.10.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.8.2",
|
"version": "1.10.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
@@ -2011,6 +2012,415 @@
|
|||||||
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
|
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-arrow": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-compose-refs": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||||
|
"version": "1.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
|
||||||
|
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-escape-keydown": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-id": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-popper": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/react-dom": "^2.0.0",
|
||||||
|
"@radix-ui/react-arrow": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||||
|
"@radix-ui/react-use-rect": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1",
|
||||||
|
"@radix-ui/rect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-portal": {
|
||||||
|
"version": "1.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
|
||||||
|
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-presence": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-tooltip": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-popper": "1.2.8",
|
||||||
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-visually-hidden": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-effect-event": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-escape-keydown": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-rect": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/rect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-size": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-visually-hidden": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/rect": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.3",
|
"version": "1.0.0-rc.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||||
@@ -3024,7 +3434,7 @@
|
|||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.10.2",
|
"version": "1.13.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { useThemeStore } from '@/stores/themeStore'
|
|||||||
import { canvasApi } from '@/api/client'
|
import { canvasApi } from '@/api/client'
|
||||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||||
import type { NodeData, EdgeData } from '@/types'
|
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||||
@@ -39,7 +39,7 @@ export default function App() {
|
|||||||
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
|
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
const { activeTheme, setTheme } = useThemeStore()
|
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||||
|
|
||||||
useStatusPolling()
|
useStatusPolling()
|
||||||
|
|
||||||
@@ -60,20 +60,20 @@ export default function App() {
|
|||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (STANDALONE) {
|
if (STANDALONE) {
|
||||||
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme }))
|
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle }))
|
||||||
markSaved()
|
markSaved()
|
||||||
toast.success('Canvas saved')
|
toast.success('Canvas saved')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const nodesToSave = nodes.map(serializeNode)
|
const nodesToSave = nodes.map(serializeNode)
|
||||||
const edgesToSave = edges.map(serializeEdge)
|
const edgesToSave = edges.map(serializeEdge)
|
||||||
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme } })
|
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle })
|
||||||
markSaved()
|
markSaved()
|
||||||
toast.success('Canvas saved')
|
toast.success('Canvas saved')
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Save failed')
|
toast.error('Save failed')
|
||||||
}
|
}
|
||||||
}, [nodes, edges, markSaved, activeTheme])
|
}, [nodes, edges, markSaved, activeTheme, customStyle])
|
||||||
|
|
||||||
// Keep a ref so the keydown handler always calls the latest version
|
// Keep a ref so the keydown handler always calls the latest version
|
||||||
const handleSaveRef = useRef(handleSave)
|
const handleSaveRef = useRef(handleSave)
|
||||||
@@ -85,8 +85,9 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(STANDALONE_STORAGE_KEY)
|
const saved = localStorage.getItem(STANDALONE_STORAGE_KEY)
|
||||||
if (saved) {
|
if (saved) {
|
||||||
const { nodes: savedNodes, edges: savedEdges, theme_id } = JSON.parse(saved)
|
const { nodes: savedNodes, edges: savedEdges, theme_id, custom_style } = JSON.parse(saved)
|
||||||
if (theme_id) setTheme(theme_id)
|
if (theme_id) setTheme(theme_id)
|
||||||
|
if (custom_style) setCustomStyle(custom_style)
|
||||||
loadCanvas(savedNodes, savedEdges)
|
loadCanvas(savedNodes, savedEdges)
|
||||||
} else {
|
} else {
|
||||||
loadCanvas(demoNodes, demoEdges)
|
loadCanvas(demoNodes, demoEdges)
|
||||||
@@ -111,13 +112,14 @@ export default function App() {
|
|||||||
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
|
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
|
||||||
const savedTheme = res.data.viewport?.theme_id
|
const savedTheme = res.data.viewport?.theme_id
|
||||||
if (savedTheme) setTheme(savedTheme)
|
if (savedTheme) setTheme(savedTheme)
|
||||||
|
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||||
loadCanvas(rfNodes, rfEdges)
|
loadCanvas(rfNodes, rfEdges)
|
||||||
} else {
|
} else {
|
||||||
loadCanvas(demoNodes, demoEdges)
|
loadCanvas(demoNodes, demoEdges)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => loadCanvas(demoNodes, demoEdges))
|
.catch(() => loadCanvas(demoNodes, demoEdges))
|
||||||
}, [isAuthenticated, loadCanvas, setTheme])
|
}, [isAuthenticated, loadCanvas, setTheme, setCustomStyle])
|
||||||
|
|
||||||
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
|
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
|
||||||
const undoRef = useRef(undo)
|
const undoRef = useRef(undo)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const canvasApi = {
|
|||||||
nodes: object[]
|
nodes: object[]
|
||||||
edges: object[]
|
edges: object[]
|
||||||
viewport: object
|
viewport: object
|
||||||
|
custom_style?: object | null
|
||||||
}) => api.post('/canvas/save', payload),
|
}) => api.post('/canvas/save', payload),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,8 +56,9 @@ vi.mock('@/utils/propertyIcons', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/handleUtils', () => ({
|
vi.mock('@/utils/handleUtils', () => ({
|
||||||
BOTTOM_HANDLE_IDS: ['bottom'],
|
bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`,
|
||||||
BOTTOM_HANDLE_POSITIONS: { 1: [50] },
|
bottomHandlePositions: () => [50],
|
||||||
|
clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
beforeEach(() => { mockZoom = 1 })
|
beforeEach(() => { mockZoom = 1 })
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { buildWaypointPath, distToSegment, findInsertIndex, snap45, snap45both } from '../waypointUtils'
|
import { buildWaypointPath, distToSegment, findInsertIndex, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from '../waypointUtils'
|
||||||
|
|
||||||
describe('buildWaypointPath — bezier (default)', () => {
|
describe('buildWaypointPath — bezier (default)', () => {
|
||||||
it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => {
|
it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => {
|
||||||
@@ -173,3 +173,35 @@ describe('findInsertIndex', () => {
|
|||||||
expect(idx).toBe(2)
|
expect(idx).toBe(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getWaypointLabelPosition', () => {
|
||||||
|
it('uses the routed midpoint for a symmetric bezier waypoint path', () => {
|
||||||
|
const point = getWaypointLabelPosition(0, 0, [{ x: 50, y: 100 }], 100, 0)
|
||||||
|
expect(point.x).toBeCloseTo(50, 0)
|
||||||
|
expect(point.y).toBeCloseTo(100, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the routed midpoint for a smooth waypoint path', () => {
|
||||||
|
const point = getWaypointLabelPosition(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 'smooth')
|
||||||
|
expect(point.x).toBeCloseTo(50, 0)
|
||||||
|
expect(point.y).toBeCloseTo(50, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the source point when the path is degenerate', () => {
|
||||||
|
const point = getWaypointLabelPosition(10, 20, [], 10, 20, 'smooth')
|
||||||
|
expect(point).toEqual({ x: 10, y: 20 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getAddWaypointHandlePosition', () => {
|
||||||
|
it('places bezier add handle on the rendered curved segment', () => {
|
||||||
|
const point = getAddWaypointHandlePosition(0, 0, [{ x: 50, y: 100 }], 100, 0, 0, 'bezier')
|
||||||
|
expect(point.x).toBeCloseTo(21.875, 3)
|
||||||
|
expect(point.y).toBeCloseTo(56.25, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps smooth add handle at straight segment midpoint', () => {
|
||||||
|
const point = getAddWaypointHandlePosition(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 1, 'smooth')
|
||||||
|
expect(point).toEqual({ x: 50, y: 50 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type { EdgeData, EdgeType, Waypoint } from '@/types'
|
|||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { buildWaypointPath, snap45, snap45both } from './waypointUtils'
|
import { buildWaypointPath, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from './waypointUtils'
|
||||||
|
|
||||||
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
|
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
|
||||||
|
|
||||||
@@ -161,9 +161,9 @@ function segmentMidpoints(
|
|||||||
const isSmooth = pathStyle === 'smooth'
|
const isSmooth = pathStyle === 'smooth'
|
||||||
|
|
||||||
return pts.slice(0, -1).map((a, i) => {
|
return pts.slice(0, -1).map((a, i) => {
|
||||||
const b = pts[i + 1]
|
const base = getAddWaypointHandlePosition(sourceX, sourceY, waypoints, targetX, targetY, i, pathStyle)
|
||||||
let mx = (a.x + b.x) / 2
|
let mx = base.x
|
||||||
const my = (a.y + b.y) / 2
|
const my = base.y
|
||||||
|
|
||||||
// For smooth style with no existing waypoints, bias the single + handle onto
|
// For smooth style with no existing waypoints, bias the single + handle onto
|
||||||
// the source handle axis so clicking it creates a perpendicular exit.
|
// the source handle axis so clicking it creates a perpendicular exit.
|
||||||
@@ -205,8 +205,9 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
|
? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
|
||||||
: autoPath
|
: autoPath
|
||||||
|
|
||||||
const midX = hasWaypoints ? (sourceX + targetX) / 2 : labelX
|
const labelPosition = hasWaypoints
|
||||||
const midY = (sourceY + targetY) / 2
|
? getWaypointLabelPosition(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
|
||||||
|
: { x: labelX, y: (sourceY + targetY) / 2 }
|
||||||
|
|
||||||
const edgeType: EdgeType = data?.type ?? 'ethernet'
|
const edgeType: EdgeType = data?.type ?? 'ethernet'
|
||||||
const edgeColors = theme.colors.edgeColors
|
const edgeColors = theme.colors.edgeColors
|
||||||
@@ -300,7 +301,7 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
<div
|
<div
|
||||||
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
|
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(-50%, -50%) translate(${midX}px, ${midY}px)`,
|
transform: `translate(-50%, -50%) translate(${labelPosition.x}px, ${labelPosition.y}px)`,
|
||||||
background: theme.colors.edgeLabelBackground,
|
background: theme.colors.edgeLabelBackground,
|
||||||
color: theme.colors.edgeLabelColor,
|
color: theme.colors.edgeLabelColor,
|
||||||
border: `1px solid ${theme.colors.edgeLabelBorder}`,
|
border: `1px solid ${theme.colors.edgeLabelBorder}`,
|
||||||
|
|||||||
@@ -72,6 +72,216 @@ export function buildWaypointPath(
|
|||||||
return pathStyle === 'smooth' ? buildRoundedPolylinePath(pts) : buildCatmullRomPath(pts)
|
return pathStyle === 'smooth' ? buildRoundedPolylinePath(pts) : buildCatmullRomPath(pts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function interpolateLine(a: Waypoint, b: Waypoint, t: number): Waypoint {
|
||||||
|
return {
|
||||||
|
x: a.x + (b.x - a.x) * t,
|
||||||
|
y: a.y + (b.y - a.y) * t,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolateQuadratic(a: Waypoint, b: Waypoint, c: Waypoint, t: number): Waypoint {
|
||||||
|
const mt = 1 - t
|
||||||
|
return {
|
||||||
|
x: mt * mt * a.x + 2 * mt * t * b.x + t * t * c.x,
|
||||||
|
y: mt * mt * a.y + 2 * mt * t * b.y + t * t * c.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolateCubic(a: Waypoint, b: Waypoint, c: Waypoint, d: Waypoint, t: number): Waypoint {
|
||||||
|
const mt = 1 - t
|
||||||
|
return {
|
||||||
|
x: mt * mt * mt * a.x + 3 * mt * mt * t * b.x + 3 * mt * t * t * c.x + t * t * t * d.x,
|
||||||
|
y: mt * mt * mt * a.y + 3 * mt * mt * t * b.y + 3 * mt * t * t * c.y + t * t * t * d.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function approximateLength(pointAt: (t: number) => Waypoint, steps = 24): number {
|
||||||
|
let length = 0
|
||||||
|
let prev = pointAt(0)
|
||||||
|
|
||||||
|
for (let step = 1; step <= steps; step++) {
|
||||||
|
const next = pointAt(step / steps)
|
||||||
|
length += Math.hypot(next.x - prev.x, next.y - prev.y)
|
||||||
|
prev = next
|
||||||
|
}
|
||||||
|
|
||||||
|
return length
|
||||||
|
}
|
||||||
|
|
||||||
|
type PathSegment = {
|
||||||
|
length: number
|
||||||
|
pointAt: (t: number) => Waypoint
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBezierSegments(pts: Waypoint[]): PathSegment[] {
|
||||||
|
if (pts.length < 2) return []
|
||||||
|
|
||||||
|
return pts.slice(0, -1).map((_, 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 cp1 = {
|
||||||
|
x: p1.x + (p2.x - p0.x) / 6,
|
||||||
|
y: p1.y + (p2.y - p0.y) / 6,
|
||||||
|
}
|
||||||
|
const cp2 = {
|
||||||
|
x: p2.x - (p3.x - p1.x) / 6,
|
||||||
|
y: p2.y - (p3.y - p1.y) / 6,
|
||||||
|
}
|
||||||
|
const pointAt = (t: number) => interpolateCubic(p1, cp1, cp2, p2, t)
|
||||||
|
|
||||||
|
return {
|
||||||
|
length: approximateLength(pointAt),
|
||||||
|
pointAt,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSmoothSegments(pts: Waypoint[], radius = 8): PathSegment[] {
|
||||||
|
if (pts.length < 2) return []
|
||||||
|
if (pts.length === 2) {
|
||||||
|
const pointAt = (t: number) => interpolateLine(pts[0], pts[1], t)
|
||||||
|
return [{ length: Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y), pointAt }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments: PathSegment[] = []
|
||||||
|
let cursor = pts[0]
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const start = { x: cursor.x, y: cursor.y }
|
||||||
|
const end = { x: curr.x, y: curr.y }
|
||||||
|
const lineToCurr = (t: number) => interpolateLine(start, end, t)
|
||||||
|
segments.push({
|
||||||
|
length: Math.hypot(curr.x - cursor.x, curr.y - cursor.y),
|
||||||
|
pointAt: lineToCurr,
|
||||||
|
})
|
||||||
|
cursor = curr
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = Math.min(radius, len1 / 2, len2 / 2)
|
||||||
|
const before = {
|
||||||
|
x: curr.x - (dx1 / len1) * r,
|
||||||
|
y: curr.y - (dy1 / len1) * r,
|
||||||
|
}
|
||||||
|
const after = {
|
||||||
|
x: curr.x + (dx2 / len2) * r,
|
||||||
|
y: curr.y + (dy2 / len2) * r,
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineStart = { x: cursor.x, y: cursor.y }
|
||||||
|
const lineEnd = { x: before.x, y: before.y }
|
||||||
|
const lineToBefore = (t: number) => interpolateLine(lineStart, lineEnd, t)
|
||||||
|
segments.push({
|
||||||
|
length: Math.hypot(before.x - cursor.x, before.y - cursor.y),
|
||||||
|
pointAt: lineToBefore,
|
||||||
|
})
|
||||||
|
|
||||||
|
const curveAroundCorner = (t: number) => interpolateQuadratic(before, curr, after, t)
|
||||||
|
segments.push({
|
||||||
|
length: approximateLength(curveAroundCorner),
|
||||||
|
pointAt: curveAroundCorner,
|
||||||
|
})
|
||||||
|
|
||||||
|
cursor = after
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetStart = { x: cursor.x, y: cursor.y }
|
||||||
|
const targetEnd = { x: pts[pts.length - 1].x, y: pts[pts.length - 1].y }
|
||||||
|
const lineToTarget = (t: number) => interpolateLine(targetStart, targetEnd, t)
|
||||||
|
segments.push({
|
||||||
|
length: Math.hypot(pts[pts.length - 1].x - cursor.x, pts[pts.length - 1].y - cursor.y),
|
||||||
|
pointAt: lineToTarget,
|
||||||
|
})
|
||||||
|
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWaypointLabelPosition(
|
||||||
|
sourceX: number, sourceY: number,
|
||||||
|
waypoints: Waypoint[],
|
||||||
|
targetX: number, targetY: number,
|
||||||
|
pathStyle: string = 'bezier',
|
||||||
|
): Waypoint {
|
||||||
|
const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
|
||||||
|
const segments = pathStyle === 'smooth' ? buildSmoothSegments(pts) : buildBezierSegments(pts)
|
||||||
|
|
||||||
|
if (segments.length === 0) return pts[0]
|
||||||
|
|
||||||
|
const totalLength = segments.reduce((sum, segment) => sum + segment.length, 0)
|
||||||
|
if (totalLength <= 0) return pts[Math.floor(pts.length / 2)]
|
||||||
|
|
||||||
|
let remaining = totalLength / 2
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (remaining <= segment.length) {
|
||||||
|
const t = segment.length === 0 ? 0 : remaining / segment.length
|
||||||
|
return segment.pointAt(t)
|
||||||
|
}
|
||||||
|
remaining -= segment.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSegment = segments[segments.length - 1]
|
||||||
|
return lastSegment.pointAt(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBezierSegmentPoint(
|
||||||
|
pts: Waypoint[],
|
||||||
|
insertIndex: number,
|
||||||
|
t: number,
|
||||||
|
): Waypoint {
|
||||||
|
const i = Math.max(0, Math.min(insertIndex, pts.length - 2))
|
||||||
|
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 cp1 = {
|
||||||
|
x: p1.x + (p2.x - p0.x) / 6,
|
||||||
|
y: p1.y + (p2.y - p0.y) / 6,
|
||||||
|
}
|
||||||
|
const cp2 = {
|
||||||
|
x: p2.x - (p3.x - p1.x) / 6,
|
||||||
|
y: p2.y - (p3.y - p1.y) / 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
return interpolateCubic(p1, cp1, cp2, p2, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAddWaypointHandlePosition(
|
||||||
|
sourceX: number, sourceY: number,
|
||||||
|
waypoints: Waypoint[],
|
||||||
|
targetX: number, targetY: number,
|
||||||
|
insertIndex: number,
|
||||||
|
pathStyle: string = 'bezier',
|
||||||
|
): Waypoint {
|
||||||
|
const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
|
||||||
|
|
||||||
|
if (pts.length < 2) return { x: sourceX, y: sourceY }
|
||||||
|
|
||||||
|
if (pathStyle !== 'smooth') {
|
||||||
|
return getBezierSegmentPoint(pts, insertIndex, 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
const i = Math.max(0, Math.min(insertIndex, pts.length - 2))
|
||||||
|
return {
|
||||||
|
x: (pts[i].x + pts[i + 1].x) / 2,
|
||||||
|
y: (pts[i].y + pts[i + 1].y) / 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 45° snapping ──────────────────────────────────────────────────────────────
|
// ── 45° snapping ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useThemeStore } from '@/stores/themeStore'
|
|||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { maskIp, splitIps } from '@/utils/maskIp'
|
import { maskIp, splitIps } from '@/utils/maskIp'
|
||||||
import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils'
|
import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils'
|
||||||
|
|
||||||
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
@@ -56,7 +56,8 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44`
|
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44`
|
||||||
: 'none',
|
: 'none',
|
||||||
opacity: data.status === 'offline' ? 0.55 : 1,
|
opacity: data.status === 'offline' ? 0.55 : 1,
|
||||||
minWidth: 140,
|
// Grow node width when many bottom handles so each stays clickable (~14px slot).
|
||||||
|
minWidth: Math.max(140, clampBottomHandles(data.bottom_handles ?? 1) * 14),
|
||||||
width: width ? '100%' : undefined,
|
width: width ? '100%' : undefined,
|
||||||
height: height ? '100%' : undefined,
|
height: height ? '100%' : undefined,
|
||||||
}}
|
}}
|
||||||
@@ -66,7 +67,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
minWidth={140}
|
minWidth={140}
|
||||||
minHeight={50}
|
minHeight={50}
|
||||||
lineStyle={{ borderColor: 'transparent' }}
|
lineStyle={{ borderColor: 'transparent' }}
|
||||||
handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8 }}
|
handleStyle={{ borderColor: colors.border, background: colors.border, width: 16, height: 16 }}
|
||||||
/>
|
/>
|
||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
@@ -76,6 +77,13 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
/>
|
/>
|
||||||
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||||
|
|
||||||
|
{/* Status dot — absolute to avoid affecting node auto-width */}
|
||||||
|
<div
|
||||||
|
className="absolute top-2 right-2 w-1.5 h-1.5 rounded-full"
|
||||||
|
style={{ backgroundColor: statusColor }}
|
||||||
|
title={data.status}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Main row */}
|
{/* Main row */}
|
||||||
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2 min-w-0 overflow-hidden">
|
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2 min-w-0 overflow-hidden">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
@@ -121,7 +129,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
return (
|
return (
|
||||||
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden" style={{ color: theme.colors.nodeSubtextColor }}>
|
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||||
{Icon && <Icon size={9} className="shrink-0" />}
|
{Icon && <Icon size={9} className="shrink-0" />}
|
||||||
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
|
<span className="truncate max-w-15 shrink-0" title={prop.key}>{prop.key}</span>
|
||||||
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -139,7 +147,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||||
<Cpu size={9} className="shrink-0" />
|
<Cpu size={9} className="shrink-0" />
|
||||||
{data.cpu_model && (
|
{data.cpu_model && (
|
||||||
<span className="truncate max-w-[80px]" title={data.cpu_model}>{data.cpu_model}</span>
|
<span className="truncate max-w-20" title={data.cpu_model}>{data.cpu_model}</span>
|
||||||
)}
|
)}
|
||||||
{data.cpu_count != null && (
|
{data.cpu_count != null && (
|
||||||
<span className="shrink-0">{data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`}</span>
|
<span className="shrink-0">{data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`}</span>
|
||||||
@@ -166,16 +174,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Status dot */}
|
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
|
||||||
<div
|
const sourceId = bottomHandleId(idx)
|
||||||
className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full shrink-0"
|
const targetId = `${sourceId}-t`
|
||||||
style={{ backgroundColor: statusColor }}
|
|
||||||
title={data.status}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(BOTTOM_HANDLE_POSITIONS[data.bottom_handles ?? 1] ?? BOTTOM_HANDLE_POSITIONS[1]).map((leftPct, idx) => {
|
|
||||||
const sourceId = BOTTOM_HANDLE_IDS[idx]
|
|
||||||
const targetId = idx === 0 ? 'bottom-t' : `bottom-${idx + 1}-t`
|
|
||||||
return (
|
return (
|
||||||
<span key={sourceId}>
|
<span key={sourceId}>
|
||||||
<Handle
|
<Handle
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type { NodeData } from '@/types'
|
|||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
import { resolveNodeIcon } from '@/utils/nodeIcons'
|
import { resolveNodeIcon } from '@/utils/nodeIcons'
|
||||||
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { maskIp, splitIps } from '@/utils/maskIp'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
@@ -13,6 +15,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
const { data, selected } = props
|
const { data, selected } = props
|
||||||
|
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
const colors = resolveNodeColors(data, activeTheme)
|
const colors = resolveNodeColors(data, activeTheme)
|
||||||
|
|
||||||
@@ -53,7 +56,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
minHeight={160}
|
minHeight={160}
|
||||||
isVisible={selected}
|
isVisible={selected}
|
||||||
lineStyle={{ borderColor: glow, opacity: 0.6 }}
|
lineStyle={{ borderColor: glow, opacity: 0.6 }}
|
||||||
handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground }}
|
handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground, width: 6, height: 6 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Group border */}
|
{/* Group border */}
|
||||||
@@ -71,7 +74,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
>
|
>
|
||||||
{/* Header bar */}
|
{/* Header bar */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 px-2.5 py-1.5 shrink-0"
|
className="flex flex-row items-start gap-2 px-2.5 py-1.5 shrink-0"
|
||||||
style={{
|
style={{
|
||||||
background: isOnline ? `${glow}18` : `${theme.colors.nodeIconBackground}88`,
|
background: isOnline ? `${glow}18` : `${theme.colors.nodeIconBackground}88`,
|
||||||
borderBottom: `1px solid ${isOnline ? `${glow}33` : theme.colors.handleBackground}`,
|
borderBottom: `1px solid ${isOnline ? `${glow}33` : theme.colors.handleBackground}`,
|
||||||
@@ -93,18 +96,19 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
>
|
>
|
||||||
{data.label}
|
{data.label}
|
||||||
</span>
|
</span>
|
||||||
{data.ip && (
|
{data.ip && splitIps(data.ip).map((ip) => (
|
||||||
<span
|
<span
|
||||||
|
key={ip}
|
||||||
className="font-mono text-[9px] truncate"
|
className="font-mono text-[9px] truncate"
|
||||||
style={{ color: theme.colors.nodeSubtextColor }}
|
style={{ color: theme.colors.nodeSubtextColor }}
|
||||||
>
|
>
|
||||||
{data.ip}
|
{hideIp ? maskIp(ip) : ip}
|
||||||
</span>
|
</span>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* Status dot */}
|
{/* Status dot */}
|
||||||
<div
|
<div
|
||||||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
className="ml-auto w-1.5 h-1.5 rounded-full shrink-0"
|
||||||
style={{ backgroundColor: statusColor }}
|
style={{ backgroundColor: statusColor }}
|
||||||
title={data.status}
|
title={data.status}
|
||||||
/>
|
/>
|
||||||
@@ -125,7 +129,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Icon && <Icon size={9} className="shrink-0" />}
|
{Icon && <Icon size={9} className="shrink-0" />}
|
||||||
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
|
<span className="truncate max-w-15 shrink-0" title={prop.key}>{prop.key}</span>
|
||||||
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type NodeProps, type Node } from '@xyflow/react'
|
import { type NodeProps, type Node } from '@xyflow/react'
|
||||||
import {
|
import {
|
||||||
Globe, Router, Network, Server, Layers, Box, Container,
|
Globe, Router, Network, Server, Layers, Box, Container,
|
||||||
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package,
|
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package, Flame,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
@@ -10,6 +10,7 @@ type N = NodeProps<Node<NodeData>>
|
|||||||
|
|
||||||
export const IspNode = (props: N) => <BaseNode {...props} icon={Globe} />
|
export const IspNode = (props: N) => <BaseNode {...props} icon={Globe} />
|
||||||
export const RouterNode = (props: N) => <BaseNode {...props} icon={Router} />
|
export const RouterNode = (props: N) => <BaseNode {...props} icon={Router} />
|
||||||
|
export const FirewallNode = (props: N) => <BaseNode {...props} icon={Flame} />
|
||||||
export const SwitchNode = (props: N) => <BaseNode {...props} icon={Network} />
|
export const SwitchNode = (props: N) => <BaseNode {...props} icon={Network} />
|
||||||
export const ServerNode = (props: N) => <BaseNode {...props} icon={Server} />
|
export const ServerNode = (props: N) => <BaseNode {...props} icon={Server} />
|
||||||
export const ProxmoxNode = (props: N) => <BaseNode {...props} icon={Layers} />
|
export const ProxmoxNode = (props: N) => <BaseNode {...props} icon={Layers} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode } from './index'
|
import { IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode } from './index'
|
||||||
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||||
import { GroupRectNode } from './GroupRectNode'
|
import { GroupRectNode } from './GroupRectNode'
|
||||||
import { GroupNode } from './GroupNode'
|
import { GroupNode } from './GroupNode'
|
||||||
@@ -6,6 +6,7 @@ import { GroupNode } from './GroupNode'
|
|||||||
export const nodeTypes = {
|
export const nodeTypes = {
|
||||||
isp: IspNode,
|
isp: IspNode,
|
||||||
router: RouterNode,
|
router: RouterNode,
|
||||||
|
firewall: FirewallNode,
|
||||||
switch: SwitchNode,
|
switch: SwitchNode,
|
||||||
server: ServerNode,
|
server: ServerNode,
|
||||||
proxmox: ProxmoxGroupNode,
|
proxmox: ProxmoxGroupNode,
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
Globe, Router, Network, Server, Layers, Box, Container, HardDrive,
|
||||||
|
Cpu, Wifi, Camera, Printer, Monitor, PlugZap, Anchor, Package, Circle, Flame,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { THEMES } from '@/utils/themes'
|
||||||
|
import { applyOpacity } from '@/utils/colorUtils'
|
||||||
|
import type {
|
||||||
|
NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, EdgePathStyle,
|
||||||
|
} from '@/types'
|
||||||
|
import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types'
|
||||||
|
|
||||||
|
// ── Node types exposed for custom style (skip groupRect/group) ───────────────
|
||||||
|
|
||||||
|
const EDITABLE_NODE_TYPES: NodeType[] = [
|
||||||
|
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas',
|
||||||
|
'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host',
|
||||||
|
'docker_container', 'generic',
|
||||||
|
]
|
||||||
|
|
||||||
|
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
||||||
|
|
||||||
|
const NODE_ICONS: Record<string, LucideIcon> = {
|
||||||
|
isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers,
|
||||||
|
vm: Box, lxc: Container, nas: HardDrive, iot: Cpu, ap: Wifi,
|
||||||
|
camera: Camera, printer: Printer, computer: Monitor, cpl: PlugZap,
|
||||||
|
docker_host: Anchor, docker_container: Package, generic: Circle,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Default style for a node type (from default theme) ─────────────────────
|
||||||
|
|
||||||
|
function defaultNodeStyle(nodeType: NodeType): NodeTypeStyle {
|
||||||
|
const accent = THEMES.default.colors.nodeAccents[nodeType] ?? THEMES.default.colors.nodeAccents.generic
|
||||||
|
return {
|
||||||
|
borderColor: accent.border,
|
||||||
|
borderOpacity: 1,
|
||||||
|
bgColor: THEMES.default.colors.nodeCardBackground,
|
||||||
|
bgOpacity: 1,
|
||||||
|
iconColor: accent.icon,
|
||||||
|
iconOpacity: 1,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultEdgeStyle(edgeType: EdgeType): EdgeTypeStyle {
|
||||||
|
return {
|
||||||
|
color: THEMES.default.colors.edgeColors[edgeType],
|
||||||
|
opacity: 1,
|
||||||
|
pathStyle: 'bezier',
|
||||||
|
animated: 'none',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Color + opacity row ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ColorRowProps {
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
opacity: number
|
||||||
|
onColorChange: (v: string) => void
|
||||||
|
onOpacityChange: (v: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColorRow({ label, color, opacity, onColorChange, onOpacityChange }: ColorRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-[#8b949e] w-20 shrink-0">{label}</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={color}
|
||||||
|
onChange={(e) => onColorChange(e.target.value)}
|
||||||
|
className="w-7 h-7 rounded cursor-pointer border border-[#30363d] bg-transparent p-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={opacity}
|
||||||
|
onChange={(e) => onOpacityChange(parseFloat(e.target.value))}
|
||||||
|
className="flex-1 h-1 accent-[#00d4ff]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-[#8b949e] w-8 text-right">
|
||||||
|
{Math.round(opacity * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="w-5 h-5 rounded border border-[#30363d] shrink-0"
|
||||||
|
style={{ background: applyOpacity(color, opacity) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Node type editor ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface NodeEditorProps {
|
||||||
|
nodeType: NodeType
|
||||||
|
style: NodeTypeStyle
|
||||||
|
onChange: (s: NodeTypeStyle) => void
|
||||||
|
onApplyToExisting: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditorProps) {
|
||||||
|
const set = useCallback(<K extends keyof NodeTypeStyle>(k: K, v: NodeTypeStyle[K]) => {
|
||||||
|
onChange({ ...style, [k]: v })
|
||||||
|
}, [style, onChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="text-sm font-semibold text-[#e6edf3]">{NODE_TYPE_LABELS[nodeType]}</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<ColorRow
|
||||||
|
label="Border"
|
||||||
|
color={style.borderColor}
|
||||||
|
opacity={style.borderOpacity}
|
||||||
|
onColorChange={(v) => set('borderColor', v)}
|
||||||
|
onOpacityChange={(v) => set('borderOpacity', v)}
|
||||||
|
/>
|
||||||
|
<ColorRow
|
||||||
|
label="Background"
|
||||||
|
color={style.bgColor}
|
||||||
|
opacity={style.bgOpacity}
|
||||||
|
onColorChange={(v) => set('bgColor', v)}
|
||||||
|
onOpacityChange={(v) => set('bgOpacity', v)}
|
||||||
|
/>
|
||||||
|
<ColorRow
|
||||||
|
label="Icon"
|
||||||
|
color={style.iconColor}
|
||||||
|
opacity={style.iconOpacity}
|
||||||
|
onColorChange={(v) => set('iconColor', v)}
|
||||||
|
onOpacityChange={(v) => set('iconOpacity', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[#30363d] pt-3">
|
||||||
|
<div className="text-xs text-[#8b949e] mb-1">Default size</div>
|
||||||
|
<div className="text-xs text-[#8b949e]/60 mb-2">0 = auto (min 140 × 50 px, grows with content)</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-[#8b949e]">W</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={10}
|
||||||
|
value={style.width}
|
||||||
|
onChange={(e) => set('width', parseInt(e.target.value) || 0)}
|
||||||
|
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-[#8b949e]">H</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={10}
|
||||||
|
value={style.height}
|
||||||
|
onChange={(e) => set('height', parseInt(e.target.value) || 0)}
|
||||||
|
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="self-start bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||||
|
onClick={onApplyToExisting}
|
||||||
|
>
|
||||||
|
Apply to existing {NODE_TYPE_LABELS[nodeType]} nodes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edge type editor ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface EdgeEditorProps {
|
||||||
|
edgeType: EdgeType
|
||||||
|
style: EdgeTypeStyle
|
||||||
|
onChange: (s: EdgeTypeStyle) => void
|
||||||
|
onApplyToExisting: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function EdgeEditor({ edgeType, style, onChange, onApplyToExisting }: EdgeEditorProps) {
|
||||||
|
const set = useCallback(<K extends keyof EdgeTypeStyle>(k: K, v: EdgeTypeStyle[K]) => {
|
||||||
|
onChange({ ...style, [k]: v })
|
||||||
|
}, [style, onChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="text-sm font-semibold text-[#e6edf3]">{EDGE_TYPE_LABELS[edgeType]}</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<ColorRow
|
||||||
|
label="Color"
|
||||||
|
color={style.color}
|
||||||
|
opacity={style.opacity}
|
||||||
|
onColorChange={(v) => set('color', v)}
|
||||||
|
onOpacityChange={(v) => set('opacity', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[#30363d] pt-3 flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-[#8b949e] mb-2">Path style</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['bezier', 'smooth'] as EdgePathStyle[]).map((ps) => (
|
||||||
|
<button
|
||||||
|
key={ps}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set('pathStyle', ps)}
|
||||||
|
className="px-3 py-1 text-xs rounded border transition-colors"
|
||||||
|
style={{
|
||||||
|
borderColor: style.pathStyle === ps ? '#00d4ff' : '#30363d',
|
||||||
|
background: style.pathStyle === ps ? '#00d4ff22' : 'transparent',
|
||||||
|
color: style.pathStyle === ps ? '#00d4ff' : '#8b949e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ps.charAt(0).toUpperCase() + ps.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-[#8b949e] mb-2">Animation</div>
|
||||||
|
<select
|
||||||
|
value={style.animated}
|
||||||
|
onChange={(e) => set('animated', e.target.value as EdgeTypeStyle['animated'])}
|
||||||
|
className="w-full h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
|
||||||
|
>
|
||||||
|
<option value="none">None</option>
|
||||||
|
<option value="basic">Basic</option>
|
||||||
|
<option value="flow">Flow</option>
|
||||||
|
<option value="snake">Snake</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="self-start bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||||
|
onClick={onApplyToExisting}
|
||||||
|
>
|
||||||
|
Apply to existing {EDGE_TYPE_LABELS[edgeType]} edges
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main modal ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Tab = 'nodes' | 'edges'
|
||||||
|
type Selection = { kind: 'node'; type: NodeType } | { kind: 'edge'; type: EdgeType } | null
|
||||||
|
|
||||||
|
interface CustomStyleModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
|
||||||
|
const { customStyle, setCustomStyle } = useThemeStore()
|
||||||
|
const { markUnsaved, applyTypeNodeStyle, applyTypeEdgeStyle, applyAllCustomStyles } = useCanvasStore()
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<Tab>('nodes')
|
||||||
|
const [selection, setSelection] = useState<Selection>(null)
|
||||||
|
const [draft, setDraft] = useState<CustomStyleDef>(() => ({
|
||||||
|
nodes: { ...customStyle.nodes },
|
||||||
|
edges: { ...customStyle.edges },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handleOpen = (isOpen: boolean) => {
|
||||||
|
if (isOpen) {
|
||||||
|
// Reset draft to current saved customStyle on open
|
||||||
|
setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } })
|
||||||
|
setSelection(null)
|
||||||
|
} else {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNodeStyle = (t: NodeType): NodeTypeStyle =>
|
||||||
|
draft.nodes[t] ?? defaultNodeStyle(t)
|
||||||
|
|
||||||
|
const getEdgeStyle = (t: EdgeType): EdgeTypeStyle =>
|
||||||
|
draft.edges[t] ?? defaultEdgeStyle(t)
|
||||||
|
|
||||||
|
const handleNodeChange = (t: NodeType, s: NodeTypeStyle) =>
|
||||||
|
setDraft((d) => ({ ...d, nodes: { ...d.nodes, [t]: s } }))
|
||||||
|
|
||||||
|
const handleEdgeChange = (t: EdgeType, s: EdgeTypeStyle) =>
|
||||||
|
setDraft((d) => ({ ...d, edges: { ...d.edges, [t]: s } }))
|
||||||
|
|
||||||
|
const handleApplyNodeType = (t: NodeType) => {
|
||||||
|
const style = getNodeStyle(t)
|
||||||
|
applyTypeNodeStyle(t, style)
|
||||||
|
toast.success(`Applied style to all ${NODE_TYPE_LABELS[t]} nodes`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyEdgeType = (t: EdgeType) => {
|
||||||
|
const style = getEdgeStyle(t)
|
||||||
|
applyTypeEdgeStyle(t, style)
|
||||||
|
toast.success(`Applied style to all ${EDGE_TYPE_LABELS[t]} edges`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
setCustomStyle(draft)
|
||||||
|
markUnsaved()
|
||||||
|
toast.success('Custom style saved — save your canvas to persist')
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyAll = () => {
|
||||||
|
setCustomStyle(draft)
|
||||||
|
applyAllCustomStyles(draft)
|
||||||
|
markUnsaved()
|
||||||
|
toast.success('Custom style applied to all nodes and edges')
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedNodeStyle = selection?.kind === 'node' ? getNodeStyle(selection.type) : null
|
||||||
|
const selectedEdgeStyle = selection?.kind === 'edge' ? getEdgeStyle(selection.type) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpen}>
|
||||||
|
<DialogContent className="bg-[#161b22] border-[#30363d] max-w-[calc(100%-2rem)] sm:max-w-3xl max-h-[90vh] flex flex-col p-0 gap-0">
|
||||||
|
<DialogHeader className="px-5 pt-5 pb-3 border-b border-[#30363d]">
|
||||||
|
<DialogTitle className="text-sm font-semibold">Custom Style Editor</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-1 overflow-hidden min-h-0">
|
||||||
|
{/* Left panel — type list */}
|
||||||
|
<div className="w-52 shrink-0 border-r border-[#30363d] flex flex-col overflow-hidden">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b border-[#30363d]">
|
||||||
|
{(['nodes', 'edges'] as Tab[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setTab(t); setSelection(null) }}
|
||||||
|
className="flex-1 py-2 text-xs font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
borderBottom: tab === t ? '2px solid #00d4ff' : '2px solid transparent',
|
||||||
|
color: tab === t ? '#00d4ff' : '#8b949e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type list */}
|
||||||
|
<div className="flex-1 overflow-y-auto py-1">
|
||||||
|
{tab === 'nodes' && EDITABLE_NODE_TYPES.map((t) => {
|
||||||
|
const Icon = NODE_ICONS[t] ?? Circle
|
||||||
|
const style = draft.nodes[t]
|
||||||
|
const isSelected = selection?.kind === 'node' && selection.type === t
|
||||||
|
const swatchColor = style
|
||||||
|
? applyOpacity(style.borderColor, style.borderOpacity)
|
||||||
|
: THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelection({ kind: 'node', type: t })}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left"
|
||||||
|
style={{
|
||||||
|
background: isSelected ? '#21262d' : 'transparent',
|
||||||
|
color: isSelected ? '#e6edf3' : '#8b949e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={13} />
|
||||||
|
<span className="flex-1 truncate">{NODE_TYPE_LABELS[t]}</span>
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
style={{ background: swatchColor }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{tab === 'edges' && EDITABLE_EDGE_TYPES.map((t) => {
|
||||||
|
const style = draft.edges[t]
|
||||||
|
const isSelected = selection?.kind === 'edge' && selection.type === t
|
||||||
|
const swatchColor = style
|
||||||
|
? applyOpacity(style.color, style.opacity)
|
||||||
|
: THEMES.default.colors.edgeColors[t]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelection({ kind: 'edge', type: t })}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left"
|
||||||
|
style={{
|
||||||
|
background: isSelected ? '#21262d' : 'transparent',
|
||||||
|
color: isSelected ? '#e6edf3' : '#8b949e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">{EDGE_TYPE_LABELS[t]}</span>
|
||||||
|
<span
|
||||||
|
className="w-8 h-1.5 rounded-full shrink-0"
|
||||||
|
style={{ background: swatchColor }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right panel — editor */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-5">
|
||||||
|
{!selection && (
|
||||||
|
<div className="flex items-center justify-center h-full text-xs text-[#8b949e]">
|
||||||
|
Select a {tab === 'nodes' ? 'node type' : 'edge type'} from the list to edit its style
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selection?.kind === 'node' && selectedNodeStyle && (
|
||||||
|
<NodeEditor
|
||||||
|
key={selection.type}
|
||||||
|
nodeType={selection.type}
|
||||||
|
style={selectedNodeStyle}
|
||||||
|
onChange={(s) => handleNodeChange(selection.type, s)}
|
||||||
|
onApplyToExisting={() => handleApplyNodeType(selection.type)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selection?.kind === 'edge' && selectedEdgeStyle && (
|
||||||
|
<EdgeEditor
|
||||||
|
key={selection.type}
|
||||||
|
edgeType={selection.type}
|
||||||
|
style={selectedEdgeStyle}
|
||||||
|
onChange={(s) => handleEdgeChange(selection.type, s)}
|
||||||
|
onApplyToExisting={() => handleApplyEdgeType(selection.type)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-between gap-2 px-5 py-3 border-t border-[#30363d]">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-[#30363d] text-[#e6edf3] hover:bg-[#21262d]"
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
Save Custom Style
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||||
|
onClick={handleApplyAll}
|
||||||
|
>
|
||||||
|
Apply All to Canvas
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import modalStyles from './modal-interactive.module.css'
|
||||||
import { RotateCcw } from 'lucide-react'
|
import { RotateCcw } from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -68,8 +69,8 @@ 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">Link Type</Label>
|
<Label className="text-xs text-muted-foreground">Link Type</Label>
|
||||||
<Select value={type} onValueChange={(v) => setType(v as EdgeType)}>
|
<Select value={type} onValueChange={(v) => setType(v as EdgeType)}>
|
||||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Edge type selector">
|
||||||
<SelectValue />
|
<SelectValue>{EDGE_TYPE_LABELS[type]}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
{EDGE_TYPES.map(([value, label]) => (
|
{EDGE_TYPES.map(([value, label]) => (
|
||||||
@@ -89,7 +90,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
value={vlanId}
|
value={vlanId}
|
||||||
onChange={(e) => setVlanId(e.target.value)}
|
onChange={(e) => setVlanId(e.target.value)}
|
||||||
placeholder="e.g. 20"
|
placeholder="e.g. 20"
|
||||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -100,19 +101,21 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
value={label}
|
value={label}
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
placeholder="e.g. 1G, trunk..."
|
placeholder="e.g. 1G, trunk..."
|
||||||
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Path Style</Label>
|
<Label className="text-xs text-muted-foreground">Path Style</Label>
|
||||||
<div className="flex rounded-md overflow-hidden border border-[#30363d]">
|
<div className={`flex rounded-md overflow-hidden border border-[#30363d] ${modalStyles['modal-interactive']}`}>
|
||||||
{(['bezier', 'smooth'] as EdgePathStyle[]).map((style) => (
|
{(['bezier', 'smooth'] as EdgePathStyle[]).map((style) => (
|
||||||
<button
|
<button
|
||||||
key={style}
|
key={style}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPathStyle(style)}
|
onClick={() => setPathStyle(style)}
|
||||||
className="flex-1 py-1 text-xs capitalize transition-colors"
|
className="flex-1 py-1 text-xs capitalize transition-colors cursor-pointer"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Path style ${style}`}
|
||||||
style={{
|
style={{
|
||||||
background: pathStyle === style ? '#00d4ff22' : '#21262d',
|
background: pathStyle === style ? '#00d4ff22' : '#21262d',
|
||||||
color: pathStyle === style ? '#00d4ff' : '#8b949e',
|
color: pathStyle === style ? '#00d4ff' : '#8b949e',
|
||||||
@@ -127,13 +130,15 @@ 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] ${modalStyles['modal-interactive']}`}>
|
||||||
{(['none', 'basic', '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"
|
||||||
onClick={() => setAnimation(mode)}
|
onClick={() => setAnimation(mode)}
|
||||||
className="flex-1 py-1 text-xs capitalize transition-colors"
|
className="flex-1 py-1 text-xs capitalize transition-colors cursor-pointer"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Animation mode ${mode}`}
|
||||||
style={{
|
style={{
|
||||||
background: animation === mode ? '#00d4ff22' : '#21262d',
|
background: animation === mode ? '#00d4ff22' : '#21262d',
|
||||||
color: animation === mode ? '#00d4ff' : '#8b949e',
|
color: animation === mode ? '#00d4ff' : '#8b949e',
|
||||||
@@ -160,8 +165,10 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<label
|
<label
|
||||||
className="relative flex items-center gap-2.5 px-2.5 h-8 rounded-md border cursor-pointer"
|
className={`relative flex items-center gap-2.5 px-2.5 h-8 rounded-md border cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
style={{ borderColor: customColor ? effectiveColor : '#30363d', background: '#21262d' }}
|
style={{ borderColor: customColor ? effectiveColor : '#30363d', background: '#21262d' }}
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Edge color picker"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
@@ -189,13 +196,13 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
|
|
||||||
<div className="flex justify-between gap-2 pt-1">
|
<div className="flex justify-between gap-2 pt-1">
|
||||||
{onDelete ? (
|
{onDelete ? (
|
||||||
<Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10" onClick={handleDelete}>
|
<Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer" onClick={handleDelete}>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
) : <span />}
|
) : <span />}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={onClose}>Cancel</Button>
|
<Button type="button" variant="ghost" size="sm" className="cursor-pointer" onClick={onClose}>Cancel</Button>
|
||||||
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90">
|
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer">
|
||||||
{onDelete ? 'Save' : 'Connect'}
|
{onDelete ? 'Save' : 'Connect'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import modalStyles from './modal-interactive.module.css'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -129,7 +130,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
value={form.label}
|
value={form.label}
|
||||||
onChange={(e) => set('label', e.target.value)}
|
onChange={(e) => set('label', e.target.value)}
|
||||||
placeholder="Zone name…"
|
placeholder="Zone name…"
|
||||||
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -137,7 +138,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Font</Label>
|
<Label className="text-xs text-muted-foreground">Font</Label>
|
||||||
<Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}>
|
<Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}>
|
||||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`}>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
@@ -162,7 +163,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
type="button"
|
type="button"
|
||||||
title={value}
|
title={value}
|
||||||
onClick={() => set('text_position', value)}
|
onClick={() => set('text_position', value)}
|
||||||
className="h-8 rounded text-base transition-colors"
|
className={`h-8 rounded text-base transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
@@ -187,7 +188,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => set('label_position', value)}
|
onClick={() => set('label_position', value)}
|
||||||
className="flex items-center justify-center h-8 rounded text-xs transition-colors"
|
className={`flex items-center justify-center h-8 rounded text-xs transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
@@ -248,7 +249,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => set('text_size', value)}
|
onClick={() => set('text_size', value)}
|
||||||
className="flex items-center justify-center h-8 rounded transition-colors"
|
className={`flex items-center justify-center h-8 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
@@ -275,7 +276,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
type="button"
|
type="button"
|
||||||
title={label}
|
title={label}
|
||||||
onClick={() => set('border_style', value)}
|
onClick={() => set('border_style', value)}
|
||||||
className="flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors"
|
className={`flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
@@ -301,7 +302,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => set('border_width', value)}
|
onClick={() => set('border_width', value)}
|
||||||
className="flex items-center justify-center h-8 rounded text-xs transition-colors"
|
className={`flex items-center justify-center h-8 rounded text-xs transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
@@ -319,7 +320,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
|
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
|
||||||
<Select value={String(form.z_order)} onValueChange={(v: string | null) => set('z_order', v !== null ? Number(v) : 1)}>
|
<Select value={String(form.z_order)} onValueChange={(v: string | null) => set('z_order', v !== null ? Number(v) : 1)}>
|
||||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']}`}>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
@@ -338,17 +339,17 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10"
|
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
|
||||||
onClick={() => { onDelete(); onClose() }}
|
onClick={() => { onDelete(); onClose() }}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2 ml-auto">
|
<div className="flex gap-2 ml-auto">
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
|
<Button type="button" variant="ghost" size="sm" className={`cursor-pointer ${modalStyles['modal-cancel-hover']}`} onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90">
|
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer">
|
||||||
{title === 'Add Zone' ? 'Add' : 'Save'}
|
{title === 'Add Zone' ? 'Add' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Fragment, createElement, useState } from 'react'
|
import { Fragment, createElement, useState } from 'react'
|
||||||
|
import modalStyles from './modal-interactive.module.css'
|
||||||
import { RotateCcw, ChevronDown } from 'lucide-react'
|
import { RotateCcw, ChevronDown } from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -8,9 +9,10 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSepa
|
|||||||
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
|
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
|
||||||
|
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
|
||||||
|
|
||||||
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||||
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||||
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
|
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
|
||||||
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
||||||
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
|
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
|
||||||
@@ -96,7 +98,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Type</Label>
|
<Label className="text-xs text-muted-foreground">Type</Label>
|
||||||
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
|
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
|
||||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8 w-full">
|
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Node type selector">
|
||||||
<SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
|
<SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
@@ -137,7 +139,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIconPickerOpen((o) => !o)}
|
onClick={() => setIconPickerOpen((o) => !o)}
|
||||||
className="flex items-center justify-between gap-2 h-8 px-3 rounded-md bg-[#21262d] border border-[#30363d] text-sm hover:border-[#8b949e] transition-colors w-full"
|
className={`flex items-center justify-between gap-2 h-8 px-3 bg-[#21262d] border border-[#30363d] text-sm transition-colors w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`}
|
||||||
|
aria-label="Icon picker trigger"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2 min-w-0">
|
<span className="flex items-center gap-2 min-w-0">
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -160,7 +163,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
value={iconSearch}
|
value={iconSearch}
|
||||||
onChange={(e) => setIconSearch(e.target.value)}
|
onChange={(e) => setIconSearch(e.target.value)}
|
||||||
placeholder="Search icons…"
|
placeholder="Search icons…"
|
||||||
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
className={`bg-[#21262d] border-[#30363d] text-xs h-7 ${modalStyles['modal-radius']}`}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
|
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
|
||||||
@@ -182,7 +185,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
type="button"
|
type="button"
|
||||||
title={entry.label}
|
title={entry.label}
|
||||||
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
|
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
|
||||||
className="flex items-center justify-center w-7 h-7 rounded transition-colors"
|
className={`flex items-center justify-center w-7 h-7 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
|
aria-label={`Select icon ${entry.label}`}
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : 'transparent',
|
background: isSelected ? '#00d4ff22' : 'transparent',
|
||||||
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
|
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
|
||||||
@@ -210,7 +214,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
value={form.label ?? ''}
|
value={form.label ?? ''}
|
||||||
onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }}
|
onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }}
|
||||||
placeholder="My Server"
|
placeholder="My Server"
|
||||||
className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'}`}
|
className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'} ${modalStyles['modal-radius']}`}
|
||||||
/>
|
/>
|
||||||
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
|
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -222,26 +226,27 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
value={form.hostname ?? ''}
|
value={form.hostname ?? ''}
|
||||||
onChange={(e) => set('hostname', e.target.value)}
|
onChange={(e) => set('hostname', e.target.value)}
|
||||||
placeholder="server.lan"
|
placeholder="server.lan"
|
||||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* IP */}
|
{/* IP */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">IP Address <span className="text-muted-foreground/50">(comma-separated)</span></Label>
|
<Label className="text-xs text-muted-foreground">IP Address</Label>
|
||||||
<Input
|
<Input
|
||||||
value={form.ip ?? ''}
|
value={form.ip ?? ''}
|
||||||
onChange={(e) => set('ip', e.target.value)}
|
onChange={(e) => set('ip', e.target.value)}
|
||||||
placeholder="192.168.1.x, 2001:db8::1"
|
placeholder="192.168.1.x, 2001:db8::1"
|
||||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||||
/>
|
/>
|
||||||
|
<span className="text-[10px] text-muted-foreground/50">comma-separated</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Check method */}
|
{/* Check method */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Check Method</Label>
|
<Label className="text-xs text-muted-foreground">Check Method</Label>
|
||||||
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
|
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
|
||||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Check method selector">
|
||||||
<SelectValue>{CHECK_METHOD_LABELS[(form.check_method ?? 'ping') as CheckMethod]}</SelectValue>
|
<SelectValue>{CHECK_METHOD_LABELS[(form.check_method ?? 'ping') as CheckMethod]}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
@@ -259,7 +264,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
value={form.check_target ?? ''}
|
value={form.check_target ?? ''}
|
||||||
onChange={(e) => set('check_target', e.target.value)}
|
onChange={(e) => set('check_target', e.target.value)}
|
||||||
placeholder="http://..."
|
placeholder="http://..."
|
||||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -271,7 +276,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
value={form.parent_id ?? 'none'}
|
value={form.parent_id ?? 'none'}
|
||||||
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
|
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Parent container selector">
|
||||||
<SelectValue placeholder="None (standalone)">
|
<SelectValue placeholder="None (standalone)">
|
||||||
{form.parent_id
|
{form.parent_id
|
||||||
? (filteredParentNodes.find((n) => n.id === form.parent_id)?.label ?? 'None (standalone)')
|
? (filteredParentNodes.find((n) => n.id === form.parent_id)?.label ?? 'None (standalone)')
|
||||||
@@ -300,7 +305,9 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={!!form.container_mode}
|
aria-checked={!!form.container_mode}
|
||||||
onClick={() => set('container_mode', !form.container_mode)}
|
onClick={() => set('container_mode', !form.container_mode)}
|
||||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none"
|
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none ${modalStyles['modal-interactive']}`}
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Toggle container mode"
|
||||||
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
|
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -333,9 +340,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
return (
|
return (
|
||||||
<div key={key} className="flex flex-col gap-1 items-center">
|
<div key={key} className="flex flex-col gap-1 items-center">
|
||||||
<label
|
<label
|
||||||
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden transition-all"
|
className={`relative w-full h-7 rounded-md border cursor-pointer overflow-hidden transition-all ${modalStyles['modal-interactive']}`}
|
||||||
style={{ borderColor: isCustom ? currentValue : '#30363d' }}
|
style={{ borderColor: isCustom ? currentValue : '#30363d' }}
|
||||||
title={`${key.charAt(0).toUpperCase() + key.slice(1)}: ${currentValue}`}
|
title={`${key.charAt(0).toUpperCase() + key.slice(1)}: ${currentValue}`}
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Color picker for ${key}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
@@ -358,21 +367,24 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
{/* Bottom connection points (not for group containers) */}
|
{/* Bottom connection points (not for group containers) */}
|
||||||
{form.type !== 'groupRect' && form.type !== 'group' && (
|
{form.type !== 'groupRect' && form.type !== 'group' && (
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
|
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
|
||||||
<Select
|
<span className="text-xs font-mono text-foreground">{clampBottomHandles(form.bottom_handles ?? 1)}</span>
|
||||||
value={String(form.bottom_handles ?? 1)}
|
</div>
|
||||||
onValueChange={(v) => set('bottom_handles', parseInt(v ?? '1', 10))}
|
<input
|
||||||
>
|
type="range"
|
||||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
min={MIN_BOTTOM_HANDLES}
|
||||||
<SelectValue />
|
max={MAX_BOTTOM_HANDLES}
|
||||||
</SelectTrigger>
|
step={1}
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
value={clampBottomHandles(form.bottom_handles ?? 1)}
|
||||||
<SelectItem value="1" className="text-sm">1 - center</SelectItem>
|
onChange={(e) => set('bottom_handles', clampBottomHandles(Number(e.target.value)))}
|
||||||
<SelectItem value="2" className="text-sm">2 - left / right</SelectItem>
|
aria-label="Bottom connection points slider"
|
||||||
<SelectItem value="3" className="text-sm">3 - left / center / right</SelectItem>
|
className="w-full accent-[#00d4ff] cursor-pointer"
|
||||||
<SelectItem value="4" className="text-sm">4 - evenly spaced</SelectItem>
|
/>
|
||||||
</SelectContent>
|
<div className="flex justify-between text-[10px] text-muted-foreground/60 font-mono">
|
||||||
</Select>
|
<span>{MIN_BOTTOM_HANDLES}</span>
|
||||||
|
<span>{MAX_BOTTOM_HANDLES}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -383,23 +395,43 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
value={form.notes ?? ''}
|
value={form.notes ?? ''}
|
||||||
onChange={(e) => set('notes', e.target.value)}
|
onChange={(e) => set('notes', e.target.value)}
|
||||||
placeholder="Optional notes"
|
placeholder="Optional notes"
|
||||||
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-1">
|
<div className="flex justify-between gap-2 pt-1">
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
|
{/* Show delete button only for edit mode (not add) */}
|
||||||
|
{title !== 'Add Node' ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm('Delete this node?')) {
|
||||||
|
onSubmit({ ...form, _delete: true })
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ minWidth: 64 }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
) : <span />}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant="ghost" size="sm" className={`cursor-pointer ${modalStyles['modal-cancel-hover']}`} onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer"
|
||||||
>
|
>
|
||||||
{title === 'Add Node' ? 'Add' : 'Save'}
|
{title === 'Add Node' ? 'Add' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState } from 'react'
|
import { useRef, useState, type KeyboardEvent } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Check } from 'lucide-react'
|
import { Check, Pencil } from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { THEMES, THEME_ORDER, type ThemeId } from '@/utils/themes'
|
import { THEMES, THEME_ORDER, type ThemeId } from '@/utils/themes'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { CustomStyleModal } from './CustomStyleModal'
|
||||||
|
|
||||||
// Node-type accent colors to display as preview swatches
|
// Node-type accent colors to display as preview swatches
|
||||||
const PREVIEW_TYPES = ['isp', 'server', 'proxmox', 'switch', 'iot'] as const
|
const PREVIEW_TYPES = ['isp', 'server', 'proxmox', 'switch', 'iot'] as const
|
||||||
@@ -14,17 +15,37 @@ interface ThemeCardProps {
|
|||||||
themeId: ThemeId
|
themeId: ThemeId
|
||||||
selected: boolean
|
selected: boolean
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
|
onKeyDown?: (event: KeyboardEvent<HTMLButtonElement>) => void
|
||||||
|
buttonRef?: (element: HTMLButtonElement | null) => void
|
||||||
|
onEdit?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThemeCard({ themeId, selected, onClick }: ThemeCardProps) {
|
function ThemeCard({ themeId, selected, onClick, onKeyDown, buttonRef, onEdit }: ThemeCardProps) {
|
||||||
|
const { customStyle } = useThemeStore()
|
||||||
const preset = THEMES[themeId]
|
const preset = THEMES[themeId]
|
||||||
const c = preset.colors
|
const c = preset.colors
|
||||||
|
const isCustom = themeId === 'custom'
|
||||||
|
|
||||||
|
// For custom theme, use defined node colors for preview swatches
|
||||||
|
const swatchColors = isCustom
|
||||||
|
? PREVIEW_TYPES.map((t) => customStyle.nodes[t]?.borderColor ?? c.nodeAccents[t].border)
|
||||||
|
: PREVIEW_TYPES.map((t) => c.nodeAccents[t].border)
|
||||||
|
|
||||||
|
const ethernetColor = isCustom
|
||||||
|
? (customStyle.edges['ethernet']?.color ?? c.edgeColors.ethernet)
|
||||||
|
: c.edgeColors.ethernet
|
||||||
|
const wifiColor = isCustom
|
||||||
|
? (customStyle.edges['wifi']?.color ?? c.edgeColors.wifi)
|
||||||
|
: c.edgeColors.wifi
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="relative w-full h-full">
|
||||||
<button
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full"
|
onKeyDown={onKeyDown}
|
||||||
|
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full h-full flex flex-col"
|
||||||
style={{
|
style={{
|
||||||
borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground,
|
borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground,
|
||||||
background: c.canvasBackground,
|
background: c.canvasBackground,
|
||||||
@@ -46,42 +67,56 @@ function ThemeCard({ themeId, selected, onClick }: ThemeCardProps) {
|
|||||||
className="rounded-md mb-2.5 flex flex-col gap-1.5 p-2"
|
className="rounded-md mb-2.5 flex flex-col gap-1.5 p-2"
|
||||||
style={{ background: c.nodeCardBackground, border: `1px solid ${c.handleBackground}` }}
|
style={{ background: c.nodeCardBackground, border: `1px solid ${c.handleBackground}` }}
|
||||||
>
|
>
|
||||||
{/* Node accent dots */}
|
|
||||||
<div className="flex gap-1 items-center flex-wrap">
|
<div className="flex gap-1 items-center flex-wrap">
|
||||||
{PREVIEW_TYPES.map((type) => (
|
{swatchColors.map((color, i) => (
|
||||||
<span
|
<span
|
||||||
key={type}
|
key={i}
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
style={{ backgroundColor: c.nodeAccents[type].border }}
|
style={{ backgroundColor: color }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* Edge line */}
|
<div style={{ height: 2, background: ethernetColor, width: '80%', borderRadius: 2 }} />
|
||||||
<div style={{ height: 2, background: c.edgeColors.ethernet, width: '80%', borderRadius: 2 }} />
|
|
||||||
{/* Wifi dashed line */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: 1,
|
height: 1,
|
||||||
width: '55%',
|
width: '55%',
|
||||||
backgroundImage: `repeating-linear-gradient(90deg, ${c.edgeColors.wifi} 0 5px, transparent 5px 8px)`,
|
backgroundImage: `repeating-linear-gradient(90deg, ${wifiColor} 0 5px, transparent 5px 8px)`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Label */}
|
|
||||||
<div
|
<div
|
||||||
className="text-xs font-semibold leading-tight"
|
className="text-sm font-semibold leading-tight wrap-break-word"
|
||||||
style={{ color: c.nodeLabelColor }}
|
style={{ color: c.nodeLabelColor }}
|
||||||
>
|
>
|
||||||
{preset.label}
|
{preset.label}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="text-[10px] leading-snug mt-0.5 line-clamp-2"
|
className="text-xs leading-snug mt-1 line-clamp-3 whitespace-normal wrap-break-word overflow-hidden min-h-12"
|
||||||
style={{ color: c.nodeSubtextColor }}
|
style={{ color: c.nodeSubtextColor }}
|
||||||
>
|
>
|
||||||
{preset.description}
|
{preset.description}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Edit button — only for custom theme */}
|
||||||
|
{isCustom && onEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onEdit() }}
|
||||||
|
title="Edit custom style"
|
||||||
|
className="absolute bottom-2 right-2 flex items-center justify-center w-6 h-6 rounded-md transition-colors"
|
||||||
|
style={{
|
||||||
|
background: c.nodeCardBackground,
|
||||||
|
color: c.nodeLabelColor,
|
||||||
|
border: `1px solid ${c.handleBackground}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +128,8 @@ interface ThemeModalProps {
|
|||||||
export function ThemeModal({ open, onClose }: ThemeModalProps) {
|
export function ThemeModal({ open, onClose }: ThemeModalProps) {
|
||||||
const { activeTheme, setTheme } = useThemeStore()
|
const { activeTheme, setTheme } = useThemeStore()
|
||||||
const { markUnsaved } = useCanvasStore()
|
const { markUnsaved } = useCanvasStore()
|
||||||
|
const cardRefs = useRef<Array<HTMLButtonElement | null>>([])
|
||||||
|
const [customStyleOpen, setCustomStyleOpen] = useState(false)
|
||||||
|
|
||||||
// Capture the theme that was active when the modal opened
|
// Capture the theme that was active when the modal opened
|
||||||
const [originalTheme] = useState<ThemeId>(activeTheme)
|
const [originalTheme] = useState<ThemeId>(activeTheme)
|
||||||
@@ -100,40 +137,65 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) {
|
|||||||
|
|
||||||
const handleSelect = (id: ThemeId) => {
|
const handleSelect = (id: ThemeId) => {
|
||||||
setSelected(id)
|
setSelected(id)
|
||||||
// Live-preview the selected theme on the canvas
|
|
||||||
setTheme(id)
|
setTheme(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCardKeyDown = (index: number) => (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
handleApply()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
const direction = event.key === 'ArrowRight' ? 1 : -1
|
||||||
|
const nextIndex = (index + direction + THEME_ORDER.length) % THEME_ORDER.length
|
||||||
|
const nextTheme = THEME_ORDER[nextIndex]
|
||||||
|
|
||||||
|
setSelected(nextTheme)
|
||||||
|
setTheme(nextTheme)
|
||||||
|
|
||||||
|
const nextCard = cardRefs.current[nextIndex]
|
||||||
|
if (!nextCard) return
|
||||||
|
|
||||||
|
nextCard.focus({ preventScroll: true })
|
||||||
|
nextCard.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
|
||||||
|
}
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
setTheme(selected)
|
setTheme(selected)
|
||||||
markUnsaved()
|
markUnsaved()
|
||||||
onClose()
|
onClose()
|
||||||
toast.info('Style applied — save your canvas to make it permanent', {
|
toast.info('Style applied — save your canvas to make it permanent', { duration: 5000 })
|
||||||
duration: 5000,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
// Revert to the original theme
|
|
||||||
setTheme(originalTheme)
|
setTheme(originalTheme)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Dialog open={open} onOpenChange={(o) => { if (!o) handleCancel() }}>
|
<Dialog open={open} onOpenChange={(o) => { if (!o) handleCancel() }}>
|
||||||
<DialogContent className="bg-[#161b22] border-[#30363d] w-[90vw] max-w-4xl">
|
<DialogContent className="bg-[#161b22] border-[#30363d] w-fit max-w-[calc(100%-2rem)] sm:max-w-[50vw]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-sm font-semibold">Choose Canvas Style</DialogTitle>
|
<DialogTitle className="text-sm font-semibold">Choose Canvas Style</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid grid-cols-5 gap-3 py-1">
|
<div className="flex items-stretch flex-nowrap gap-3 py-1 overflow-x-auto overflow-y-hidden pb-2 pr-1">
|
||||||
{THEME_ORDER.map((id) => (
|
{THEME_ORDER.map((id, index) => (
|
||||||
|
<div key={id} className="shrink-0 w-30 md:w-24 h-full">
|
||||||
<ThemeCard
|
<ThemeCard
|
||||||
key={id}
|
|
||||||
themeId={id}
|
themeId={id}
|
||||||
selected={selected === id}
|
selected={selected === id}
|
||||||
onClick={() => handleSelect(id)}
|
onClick={() => handleSelect(id)}
|
||||||
|
onKeyDown={handleCardKeyDown(index)}
|
||||||
|
buttonRef={(element) => { cardRefs.current[index] = element }}
|
||||||
|
onEdit={id === 'custom' ? () => setCustomStyleOpen(true) : undefined}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -163,5 +225,8 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<CustomStyleModal open={customStyleOpen} onClose={() => setCustomStyleOpen(false)} />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,28 @@ describe('NodeModal', () => {
|
|||||||
expect(onClose).toHaveBeenCalledOnce()
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Delete confirm ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('deletes and closes when Delete confirm is accepted', () => {
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
const { onClose, onSubmit } = renderModal({ title: 'Edit Node', initial: BASE })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ _delete: true }))
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
confirmSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Regression: bare-if without braces used to call onClose() unconditionally,
|
||||||
|
// closing the modal even when the user cancelled the confirm dialog.
|
||||||
|
it('does not delete or close when Delete confirm is cancelled', () => {
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||||
|
const { onClose, onSubmit } = renderModal({ title: 'Edit Node', initial: BASE })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled()
|
||||||
|
expect(onClose).not.toHaveBeenCalled()
|
||||||
|
confirmSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
// ── Label validation ──────────────────────────────────────────────────
|
// ── Label validation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
it('blocks submit and shows error when label is empty', () => {
|
it('blocks submit and shows error when label is empty', () => {
|
||||||
@@ -345,18 +367,37 @@ describe('NodeModal', () => {
|
|||||||
|
|
||||||
it('defaults bottom_handles to 1', () => {
|
it('defaults bottom_handles to 1', () => {
|
||||||
renderModal({ initial: BASE })
|
renderModal({ initial: BASE })
|
||||||
expect(selects()[2].value).toBe('1')
|
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||||
|
expect(slider.value).toBe('1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pre-fills bottom_handles from initial', () => {
|
it('pre-fills bottom_handles from initial', () => {
|
||||||
renderModal({ initial: { ...BASE, bottom_handles: 3 } })
|
renderModal({ initial: { ...BASE, bottom_handles: 3 } })
|
||||||
expect(selects()[2].value).toBe('3')
|
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||||
|
expect(slider.value).toBe('3')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('submits updated bottom_handles', () => {
|
it('submits updated bottom_handles', () => {
|
||||||
const { onSubmit } = renderModal({ initial: BASE })
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
fireEvent.change(selects()[2], { target: { value: '4' } })
|
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||||
|
fireEvent.change(slider, { target: { value: '12' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(4)
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports the full 1..48 range', () => {
|
||||||
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
|
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||||
|
expect(slider.min).toBe('1')
|
||||||
|
expect(slider.max).toBe('48')
|
||||||
|
fireEvent.change(slider, { target: { value: '48' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(48)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps pre-filled out-of-range values into [1,48]', () => {
|
||||||
|
renderModal({ initial: { ...BASE, bottom_handles: 9999 } })
|
||||||
|
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||||
|
expect(slider.value).toBe('48')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/* SidebarItem pointer on hover */
|
||||||
|
.sidebar-pointer:hover {
|
||||||
|
cursor: pointer !important;
|
||||||
|
}
|
||||||
|
/* Consistent border radius for all modal input/select/button elements */
|
||||||
|
.modal-radius {
|
||||||
|
border-radius: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pointer cursor for close X */
|
||||||
|
.modal-close-pointer:hover {
|
||||||
|
cursor: pointer !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle hover background for cancel button */
|
||||||
|
.modal-cancel-hover:hover {
|
||||||
|
background: #21262d !important;
|
||||||
|
}
|
||||||
|
/* Shared hover/focus border effect for interactive modal elements */
|
||||||
|
.modal-interactive {
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.modal-interactive:hover,
|
||||||
|
.modal-interactive:focus {
|
||||||
|
border-color: #8b949e !important;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { createElement, useState } from 'react'
|
|||||||
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
|
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
|
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
|
||||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||||
@@ -199,7 +200,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
<span className="font-semibold text-sm text-foreground truncate">{data.label}</span>
|
<span className="font-semibold text-sm text-foreground truncate">{data.label}</span>
|
||||||
<button aria-label="Close panel" onClick={() => setSelectedNode(null)} className="text-muted-foreground hover:text-foreground transition-colors">
|
<button aria-label="Close panel" onClick={() => setSelectedNode(null)} className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,7 +243,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
<span className="text-xs text-muted-foreground">Properties{properties.length > 0 ? ` (${properties.length})` : ''}</span>
|
<span className="text-xs text-muted-foreground">Properties{properties.length > 0 ? ` (${properties.length})` : ''}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setAddingProp((v) => !v); setEditingPropIndex(null) }}
|
onClick={() => { setAddingProp((v) => !v); setEditingPropIndex(null) }}
|
||||||
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors"
|
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<Plus size={10} /> Add
|
<Plus size={10} /> Add
|
||||||
</button>
|
</button>
|
||||||
@@ -288,7 +289,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
<div className="px-4 py-3 border-t border-border">
|
<div className="px-4 py-3 border-t border-border">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs text-muted-foreground">Services{services.length > 0 ? ` (${services.length})` : ''}</span>
|
<span className="text-xs text-muted-foreground">Services{services.length > 0 ? ` (${services.length})` : ''}</span>
|
||||||
<button onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors">
|
<button onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer">
|
||||||
<Plus size={10} /> Add
|
<Plus size={10} /> Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -315,10 +316,10 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
|
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
|
||||||
<Button size="sm" variant="secondary" className="flex-1 gap-1.5" onClick={() => onEdit(node.id)}>
|
<Button size="sm" variant="secondary" className="flex-1 gap-1.5 cursor-pointer" onClick={() => onEdit(node.id)}>
|
||||||
<Edit size={14} /> Edit
|
<Edit size={14} /> Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="destructive" className="gap-1.5" aria-label="Delete node" onClick={handleDelete}>
|
<Button size="sm" variant="destructive" className="gap-1.5 cursor-pointer" aria-label="Delete node" onClick={handleDelete}>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -661,23 +662,87 @@ const CATEGORY_COLORS: Record<string, string> = {
|
|||||||
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
|
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
|
||||||
const url = getServiceUrl(svc, host)
|
const url = getServiceUrl(svc, host)
|
||||||
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
||||||
const portLabel = svc.port != null ? String(svc.port) : 'host'
|
const hasPort = svc.port != null
|
||||||
const pathLabel = svc.path?.trim() ? svc.path.trim() : null
|
const portLabel = hasPort ? String(svc.port) : ''
|
||||||
const inner = (
|
const pathLabel = svc.path?.trim() ? svc.path.trim() : ''
|
||||||
<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', cursor: url ? 'pointer' : 'default' }}>
|
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
return (
|
||||||
|
<div
|
||||||
|
className="group flex items-center gap-1 border rounded-md text-xs transition-colors px-2 py-1.5 min-w-0"
|
||||||
|
style={{ background: '#21262d', borderColor: '#30363d' }}
|
||||||
|
>
|
||||||
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||||
<span className="font-medium truncate" style={{ color }} title={svc.service_name}>{svc.service_name}</span>
|
{url ? (
|
||||||
{pathLabel && <span className="truncate text-[#8b949e]" title={pathLabel}>{pathLabel}</span>}
|
<a
|
||||||
</div>
|
href={url}
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
target="_blank"
|
||||||
<span className="font-mono text-[#8b949e]">{portLabel}/{svc.protocol}</span>
|
rel="noopener noreferrer"
|
||||||
{url && <ExternalLink size={10} className="text-muted-foreground" />}
|
className="font-medium truncate min-w-0 flex-1"
|
||||||
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5" title="Edit service"><Pencil size={10} /></button>
|
style={{ color }}
|
||||||
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5" title="Remove service"><X size={10} /></button>
|
title={svc.service_name}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{svc.service_name}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="font-medium truncate min-w-0 flex-1"
|
||||||
|
style={{ color }}
|
||||||
|
title={svc.service_name}
|
||||||
|
>
|
||||||
|
{svc.service_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{pathLabel && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span
|
||||||
|
className="truncate text-[#8b949e] max-w-[80px]"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={pathLabel}
|
||||||
|
>
|
||||||
|
{pathLabel}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">{pathLabel}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
{hasPort && (
|
||||||
|
<span className="font-mono text-[#8b949e] shrink-0">{portLabel}/{svc.protocol}</span>
|
||||||
|
)}
|
||||||
|
{url ? (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex w-2.5 h-2.5 items-center justify-center shrink-0"
|
||||||
|
aria-label="Open service link"
|
||||||
|
style={{ color: 'inherit' }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ExternalLink size={10} className="text-muted-foreground" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="w-2.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }}
|
||||||
|
className="opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5"
|
||||||
|
title="Edit service"
|
||||||
|
>
|
||||||
|
<Pencil size={10} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }}
|
||||||
|
className="opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5"
|
||||||
|
title="Remove service"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
if (url) return <a href={url} target="_blank" rel="noopener noreferrer" className="block hover:opacity-80 transition-opacity">{inner}</a>
|
|
||||||
return inner
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useAuthStore } from '@/stores/authStore'
|
|||||||
import { scanApi, settingsApi } from '@/api/client'
|
import { scanApi, settingsApi } from '@/api/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
||||||
|
|
||||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
@@ -42,13 +43,19 @@ interface SidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
|
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
|
||||||
const [_collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const [_activeView, setActiveView] = useState<SidebarView>('canvas')
|
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
||||||
|
const [prevForceView, setPrevForceView] = useState(forceView)
|
||||||
const logout = useAuthStore((s) => s.logout)
|
const logout = useAuthStore((s) => s.logout)
|
||||||
|
|
||||||
// When forceView is set, override local state without useEffect
|
// forceView acts as a one-shot trigger from parent; user clicks afterwards still control view.
|
||||||
const collapsed = forceView ? false : _collapsed
|
if (forceView !== prevForceView) {
|
||||||
const activeView = forceView ?? _activeView
|
setPrevForceView(forceView)
|
||||||
|
if (forceView) {
|
||||||
|
setActiveView(forceView)
|
||||||
|
setCollapsed(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
||||||
|
|
||||||
@@ -593,7 +600,7 @@ function ScanHistoryPanel() {
|
|||||||
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
|
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
|
||||||
)}
|
)}
|
||||||
{r.error && (
|
{r.error && (
|
||||||
<div className="text-[#f85149] text-[10px] mt-1 leading-tight break-words whitespace-pre-wrap">
|
<div className="text-[#f85149] text-[10px] mt-1 leading-tight wrap-break-word whitespace-pre-wrap">
|
||||||
{r.error}
|
{r.error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -749,7 +756,7 @@ function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onCl
|
|||||||
const btn = (
|
const btn = (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors ${
|
className={`relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors cursor-pointer ${
|
||||||
active
|
active
|
||||||
? 'bg-[#00d4ff]/10 text-[#00d4ff]'
|
? 'bg-[#00d4ff]/10 text-[#00d4ff]'
|
||||||
: accent
|
: accent
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button
|
<Button
|
||||||
size="sm" variant="ghost"
|
size="sm" variant="ghost"
|
||||||
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
|
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer hover:bg-[#21262d]"
|
||||||
onClick={onUndo}
|
onClick={onUndo}
|
||||||
disabled={past.length === 0}
|
disabled={past.length === 0}
|
||||||
title="Undo (Ctrl+Z)"
|
title="Undo (Ctrl+Z)"
|
||||||
@@ -48,7 +48,7 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm" variant="ghost"
|
size="sm" variant="ghost"
|
||||||
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
|
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer hover:bg-[#21262d]"
|
||||||
onClick={onRedo}
|
onClick={onRedo}
|
||||||
disabled={future.length === 0}
|
disabled={future.length === 0}
|
||||||
title="Redo (Ctrl+Y)"
|
title="Redo (Ctrl+Y)"
|
||||||
@@ -56,13 +56,13 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
|||||||
<Redo2 size={14} />
|
<Redo2 size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="w-px h-4 bg-border mx-1" />
|
<div className="w-px h-4 bg-border mx-1" />
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onAutoLayout}>
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onAutoLayout}>
|
||||||
<LayoutDashboard size={14} /> Auto Layout
|
<LayoutDashboard size={14} /> Auto Layout
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onChangeStyle}>
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onChangeStyle}>
|
||||||
<Palette size={14} /> Style
|
<Palette size={14} /> Style
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
|
||||||
<Upload size={14} /> Import
|
<Upload size={14} /> Import
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
@@ -72,21 +72,21 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportYaml} title="Export canvas as YAML">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportYaml} title="Export canvas as YAML">
|
||||||
<Download size={14} /> Export
|
<Download size={14} /> Export
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport} title="Download canvas as PNG">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExport} title="Download canvas as PNG">
|
||||||
<FileDown size={14} /> PNG
|
<FileDown size={14} /> PNG
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportMd} title="Copy inventory as Markdown table">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportMd} title="Copy inventory as Markdown table">
|
||||||
<Table2 size={14} /> MD
|
<Table2 size={14} /> MD
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onShortcuts} title="Keyboard shortcuts (?)">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onShortcuts} title="Keyboard shortcuts (?)">
|
||||||
<HelpCircle size={14} />
|
<HelpCircle size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1.5 relative"
|
className="gap-1.5 relative cursor-pointer border border-transparent hover:border-white"
|
||||||
style={{
|
style={{
|
||||||
background: hasUnsavedChanges ? '#00d4ff' : undefined,
|
background: hasUnsavedChanges ? '#00d4ff' : undefined,
|
||||||
color: hasUnsavedChanges ? '#0d1117' : undefined,
|
color: hasUnsavedChanges ? '#0d1117' : undefined,
|
||||||
|
|||||||
@@ -445,4 +445,46 @@ describe('DetailPanel', () => {
|
|||||||
expect(screen.getByText(/192\.168\.1\.10, 192\.168\.1\.11/)).toBeDefined()
|
expect(screen.getByText(/192\.168\.1\.10, 192\.168\.1\.11/)).toBeDefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('ServiceBadge rendering', () => {
|
||||||
|
it('renders service name and port/protocol label', () => {
|
||||||
|
setupStore({ services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('nginx')).toBeDefined()
|
||||||
|
expect(screen.getByText('8080/tcp')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders path label when path is set', () => {
|
||||||
|
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'web', path: '/admin' }] })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('/admin')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders no path text when path is empty', () => {
|
||||||
|
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'web', path: '' }] })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.queryByText('/')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders port/protocol omitted when port is absent', () => {
|
||||||
|
setupStore({ services: [{ protocol: 'tcp', service_name: 'health', path: '' }] })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('health')).toBeDefined()
|
||||||
|
expect(screen.queryByText(/\/tcp/)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders service name as link when ip and port are set', () => {
|
||||||
|
setupStore({ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
const link = screen.getByRole('link', { name: 'nginx' })
|
||||||
|
expect(link.getAttribute('href')).toContain('192.168.1.10')
|
||||||
|
expect(link.getAttribute('target')).toBe('_blank')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders service name as plain text when no url can be built', () => {
|
||||||
|
setupStore({ ip: undefined, services: [{ protocol: 'tcp', service_name: 'health', path: '' }] })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('health').tagName).not.toBe('A')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -267,6 +267,18 @@ describe('Sidebar', () => {
|
|||||||
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Regression: forceView used to override local state on every render, freezing
|
||||||
|
// the sidebar on whichever view the parent forced (e.g. 'history' after a scan).
|
||||||
|
it('allows switching views after forceView is set by parent', async () => {
|
||||||
|
const { rerender } = render(<Sidebar {...defaultProps} forceView="history" />)
|
||||||
|
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
||||||
|
// Parent keeps forceView as 'history'; user clicks another nav item.
|
||||||
|
rerender(<Sidebar {...defaultProps} forceView="history" />)
|
||||||
|
fireEvent.click(screen.getByText('Pending Devices'))
|
||||||
|
await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByText('No scans yet')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('toggles Settings panel on Settings click', async () => {
|
it('toggles Settings panel on Settings click', async () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Settings'))
|
fireEvent.click(screen.getByText('Settings'))
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import modalStyles from '../modals/modal-interactive.module.css'
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
@@ -63,13 +64,12 @@ function DialogContent({
|
|||||||
render={
|
render={
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="absolute top-2 right-2"
|
className={"absolute top-2 right-2 " + modalStyles['modal-close-pointer']}
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<XIcon
|
<XIcon />
|
||||||
/>
|
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,64 +1,30 @@
|
|||||||
"use client"
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||||
|
|
||||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
const Tooltip = TooltipPrimitive.Root
|
||||||
import { cn } from "@/lib/utils"
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
function TooltipProvider({
|
|
||||||
delay = 0,
|
|
||||||
...props
|
|
||||||
}: TooltipPrimitive.Provider.Props) {
|
|
||||||
return (
|
|
||||||
<TooltipPrimitive.Provider
|
|
||||||
data-slot="tooltip-provider"
|
|
||||||
delay={delay}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
|
||||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
|
||||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function TooltipContent({
|
function TooltipContent({
|
||||||
className,
|
className,
|
||||||
side = "top",
|
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
align = "center",
|
|
||||||
alignOffset = 0,
|
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: TooltipPrimitive.Popup.Props &
|
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>) {
|
||||||
Pick<
|
|
||||||
TooltipPrimitive.Positioner.Props,
|
|
||||||
"align" | "alignOffset" | "side" | "sideOffset"
|
|
||||||
>) {
|
|
||||||
return (
|
return (
|
||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipPrimitive.Positioner
|
<TooltipPrimitive.Content
|
||||||
align={align}
|
|
||||||
alignOffset={alignOffset}
|
|
||||||
side={side}
|
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className="isolate z-50"
|
className={[
|
||||||
>
|
'z-50 overflow-hidden rounded-md border border-[#30363d] bg-[#161b22] px-2 py-1 text-xs text-[#e6edf3] shadow-md',
|
||||||
<TooltipPrimitive.Popup
|
'animate-in fade-in-0 zoom-in-95',
|
||||||
data-slot="tooltip-content"
|
className,
|
||||||
className={cn(
|
]
|
||||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
.filter(Boolean)
|
||||||
className
|
.join(' ')}
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
</TooltipPrimitive.Content>
|
||||||
</TooltipPrimitive.Popup>
|
|
||||||
</TooltipPrimitive.Positioner>
|
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -721,4 +721,117 @@ describe('canvasStore', () => {
|
|||||||
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
|
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
|
||||||
expect(updated?.sourceHandle).toBe('bottom')
|
expect(updated?.sourceHandle).toBe('bottom')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Regression: handle cap raised from 4 to 48 — remap must scale.
|
||||||
|
it('remaps high-count handles when shrinking from 12 to 2', () => {
|
||||||
|
const node = makeNode('n1', { bottom_handles: 12 })
|
||||||
|
const edges = [
|
||||||
|
{ ...makeEdge('e2', 'n1', 'n2'), sourceHandle: 'bottom-2' },
|
||||||
|
{ ...makeEdge('e3', 'n1', 'n2'), sourceHandle: 'bottom-5' },
|
||||||
|
{ ...makeEdge('e12', 'n1', 'n2'), sourceHandle: 'bottom-12' },
|
||||||
|
]
|
||||||
|
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges })
|
||||||
|
|
||||||
|
useCanvasStore.getState().updateNode('n1', { bottom_handles: 2 })
|
||||||
|
|
||||||
|
const after = useCanvasStore.getState().edges
|
||||||
|
expect(after.find((e) => e.id === 'e2')?.sourceHandle).toBe('bottom-2')
|
||||||
|
expect(after.find((e) => e.id === 'e3')?.sourceHandle).toBe('bottom')
|
||||||
|
expect(after.find((e) => e.id === 'e12')?.sourceHandle).toBe('bottom')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('canvasStore — custom style apply', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
hasUnsavedChanges: false,
|
||||||
|
selectedNodeId: null,
|
||||||
|
selectedNodeIds: [],
|
||||||
|
editingGroupRectId: null,
|
||||||
|
past: [],
|
||||||
|
future: [],
|
||||||
|
clipboard: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const serverStyle = {
|
||||||
|
borderColor: '#ff0000',
|
||||||
|
borderOpacity: 1,
|
||||||
|
bgColor: '#111111',
|
||||||
|
bgOpacity: 1,
|
||||||
|
iconColor: '#ff0000',
|
||||||
|
iconOpacity: 1,
|
||||||
|
width: 220,
|
||||||
|
height: 90,
|
||||||
|
}
|
||||||
|
|
||||||
|
it('applyTypeNodeStyle updates matching nodes custom_colors', () => {
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: [makeNode('n1', { type: 'server' }), makeNode('n2', { type: 'proxmox' })],
|
||||||
|
edges: [],
|
||||||
|
})
|
||||||
|
useCanvasStore.getState().applyTypeNodeStyle('server', serverStyle)
|
||||||
|
|
||||||
|
const n1 = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!
|
||||||
|
const n2 = useCanvasStore.getState().nodes.find((n) => n.id === 'n2')!
|
||||||
|
expect(n1.data.custom_colors?.border).toBe('#ff0000')
|
||||||
|
expect(n1.width).toBe(220)
|
||||||
|
expect(n1.height).toBe(90)
|
||||||
|
expect(n2.data.custom_colors?.border).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applyTypeNodeStyle with opacity < 1 produces rgba', () => {
|
||||||
|
useCanvasStore.setState({ nodes: [makeNode('n1', { type: 'server' })], edges: [] })
|
||||||
|
useCanvasStore.getState().applyTypeNodeStyle('server', { ...serverStyle, borderOpacity: 0.5 })
|
||||||
|
|
||||||
|
const n1 = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!
|
||||||
|
expect(n1.data.custom_colors?.border).toMatch(/^rgba\(/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applyTypeNodeStyle marks canvas unsaved', () => {
|
||||||
|
useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [] })
|
||||||
|
useCanvasStore.getState().applyTypeNodeStyle('server', serverStyle)
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applyTypeEdgeStyle updates matching edges', () => {
|
||||||
|
const e1: Edge<EdgeData> = { id: 'e1', source: 'n1', target: 'n2', type: 'ethernet', data: { type: 'ethernet' } }
|
||||||
|
const e2: Edge<EdgeData> = { id: 'e2', source: 'n1', target: 'n2', type: 'wifi', data: { type: 'wifi' } }
|
||||||
|
useCanvasStore.setState({ nodes: [], edges: [e1, e2] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().applyTypeEdgeStyle('ethernet', { color: '#00ff00', opacity: 1, pathStyle: 'smooth', animated: 'flow' })
|
||||||
|
|
||||||
|
const updated1 = useCanvasStore.getState().edges.find((e) => e.id === 'e1')!
|
||||||
|
const updated2 = useCanvasStore.getState().edges.find((e) => e.id === 'e2')!
|
||||||
|
expect(updated1.data?.custom_color).toBe('#00ff00')
|
||||||
|
expect(updated1.data?.path_style).toBe('smooth')
|
||||||
|
expect(updated1.data?.animated).toBe('flow')
|
||||||
|
expect(updated2.data?.custom_color).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applyAllCustomStyles applies all defined types', () => {
|
||||||
|
const proxmoxNode = makeNode('np', { type: 'proxmox' })
|
||||||
|
const serverNode = makeNode('ns', { type: 'server' })
|
||||||
|
const e1: Edge<EdgeData> = { id: 'e1', source: 'np', target: 'ns', type: 'ethernet', data: { type: 'ethernet' } }
|
||||||
|
useCanvasStore.setState({ nodes: [proxmoxNode, serverNode], edges: [e1] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().applyAllCustomStyles({
|
||||||
|
nodes: {
|
||||||
|
proxmox: { borderColor: '#ff6e00', borderOpacity: 1, bgColor: '#111', bgOpacity: 1, iconColor: '#ff6e00', iconOpacity: 1, width: 0, height: 0 },
|
||||||
|
},
|
||||||
|
edges: {
|
||||||
|
ethernet: { color: '#aabbcc', opacity: 1, pathStyle: 'bezier', animated: 'none' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const np = useCanvasStore.getState().nodes.find((n) => n.id === 'np')!
|
||||||
|
const ns = useCanvasStore.getState().nodes.find((n) => n.id === 'ns')!
|
||||||
|
const e = useCanvasStore.getState().edges.find((e) => e.id === 'e1')!
|
||||||
|
expect(np.data.custom_colors?.border).toBe('#ff6e00')
|
||||||
|
expect(ns.data.custom_colors?.border).toBeUndefined()
|
||||||
|
expect(e.data?.custom_color).toBe('#aabbcc')
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
import type { CustomStyleDef } from '@/types'
|
||||||
|
|
||||||
describe('themeStore', () => {
|
describe('themeStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useThemeStore.setState({ activeTheme: 'default' })
|
useThemeStore.setState({ activeTheme: 'default', customStyle: { nodes: {}, edges: {} } })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('starts with default theme', () => {
|
it('starts with default theme', () => {
|
||||||
@@ -15,8 +16,8 @@ describe('themeStore', () => {
|
|||||||
expect(useThemeStore.getState().activeTheme).toBe('matrix')
|
expect(useThemeStore.getState().activeTheme).toBe('matrix')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('setTheme can switch between all presets', () => {
|
it('setTheme can switch between all presets including custom', () => {
|
||||||
const themes = ['default', 'dark', 'light', 'neon', 'matrix'] as const
|
const themes = ['default', 'dark', 'light', 'neon', 'matrix', 'custom'] as const
|
||||||
for (const id of themes) {
|
for (const id of themes) {
|
||||||
useThemeStore.getState().setTheme(id)
|
useThemeStore.getState().setTheme(id)
|
||||||
expect(useThemeStore.getState().activeTheme).toBe(id)
|
expect(useThemeStore.getState().activeTheme).toBe(id)
|
||||||
@@ -28,4 +29,26 @@ describe('themeStore', () => {
|
|||||||
useThemeStore.getState().setTheme('default')
|
useThemeStore.getState().setTheme('default')
|
||||||
expect(useThemeStore.getState().activeTheme).toBe('default')
|
expect(useThemeStore.getState().activeTheme).toBe('default')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('starts with empty customStyle', () => {
|
||||||
|
const { customStyle } = useThemeStore.getState()
|
||||||
|
expect(customStyle.nodes).toEqual({})
|
||||||
|
expect(customStyle.edges).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setCustomStyle replaces the entire definition', () => {
|
||||||
|
const def: CustomStyleDef = {
|
||||||
|
nodes: { server: { borderColor: '#ff0000', borderOpacity: 1, bgColor: '#000000', bgOpacity: 1, iconColor: '#ff0000', iconOpacity: 1, width: 200, height: 80 } },
|
||||||
|
edges: { ethernet: { color: '#00ff00', opacity: 0.8, pathStyle: 'bezier', animated: 'none' } },
|
||||||
|
}
|
||||||
|
useThemeStore.getState().setCustomStyle(def)
|
||||||
|
expect(useThemeStore.getState().customStyle.nodes.server?.borderColor).toBe('#ff0000')
|
||||||
|
expect(useThemeStore.getState().customStyle.edges.ethernet?.color).toBe('#00ff00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setCustomStyle with empty def clears styles', () => {
|
||||||
|
useThemeStore.getState().setCustomStyle({ nodes: { server: { borderColor: '#aaa', borderOpacity: 1, bgColor: '#000', bgOpacity: 1, iconColor: '#aaa', iconOpacity: 1, width: 0, height: 0 } }, edges: {} })
|
||||||
|
useThemeStore.getState().setCustomStyle({ nodes: {}, edges: {} })
|
||||||
|
expect(useThemeStore.getState().customStyle.nodes).toEqual({})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import {
|
|||||||
applyEdgeChanges,
|
applyEdgeChanges,
|
||||||
addEdge,
|
addEdge,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import type { NodeData, EdgeData } from '@/types'
|
import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef } from '@/types'
|
||||||
import { generateUUID } from '@/utils/uuid'
|
import { generateUUID } from '@/utils/uuid'
|
||||||
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
|
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
|
||||||
|
import { applyOpacity } from '@/utils/colorUtils'
|
||||||
|
|
||||||
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||||
|
|
||||||
@@ -58,6 +59,9 @@ interface CanvasState {
|
|||||||
notifyScanDeviceFound: () => void
|
notifyScanDeviceFound: () => void
|
||||||
hideIp: boolean
|
hideIp: boolean
|
||||||
toggleHideIp: () => void
|
toggleHideIp: () => void
|
||||||
|
applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void
|
||||||
|
applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void
|
||||||
|
applyAllCustomStyles: (def: CustomStyleDef) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCanvasStore = create<CanvasState>((set) => ({
|
export const useCanvasStore = create<CanvasState>((set) => ({
|
||||||
@@ -468,4 +472,82 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearFitViewPending: () => set({ fitViewPending: false }),
|
clearFitViewPending: () => set({ fitViewPending: false }),
|
||||||
|
|
||||||
|
applyTypeNodeStyle: (nodeType, style) =>
|
||||||
|
set((state) => ({
|
||||||
|
nodes: state.nodes.map((n) => {
|
||||||
|
if (n.data.type !== nodeType) return n
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
width: style.width > 0 ? style.width : n.width,
|
||||||
|
height: style.height > 0 ? style.height : n.height,
|
||||||
|
data: {
|
||||||
|
...n.data,
|
||||||
|
custom_colors: {
|
||||||
|
...n.data.custom_colors,
|
||||||
|
border: applyOpacity(style.borderColor, style.borderOpacity),
|
||||||
|
background: applyOpacity(style.bgColor, style.bgOpacity),
|
||||||
|
icon: applyOpacity(style.iconColor, style.iconOpacity),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
})),
|
||||||
|
|
||||||
|
applyTypeEdgeStyle: (edgeType, style) =>
|
||||||
|
set((state) => ({
|
||||||
|
edges: state.edges.map((e) => {
|
||||||
|
if ((e.data?.type ?? 'ethernet') !== edgeType) return e
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
data: {
|
||||||
|
...e.data,
|
||||||
|
type: edgeType,
|
||||||
|
custom_color: applyOpacity(style.color, style.opacity),
|
||||||
|
path_style: style.pathStyle,
|
||||||
|
animated: style.animated,
|
||||||
|
} as EdgeData,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
})),
|
||||||
|
|
||||||
|
applyAllCustomStyles: (def) =>
|
||||||
|
set((state) => {
|
||||||
|
const nodes = state.nodes.map((n) => {
|
||||||
|
const style = def.nodes[n.data.type]
|
||||||
|
if (!style) return n
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
width: style.width > 0 ? style.width : n.width,
|
||||||
|
height: style.height > 0 ? style.height : n.height,
|
||||||
|
data: {
|
||||||
|
...n.data,
|
||||||
|
custom_colors: {
|
||||||
|
...n.data.custom_colors,
|
||||||
|
border: applyOpacity(style.borderColor, style.borderOpacity),
|
||||||
|
background: applyOpacity(style.bgColor, style.bgOpacity),
|
||||||
|
icon: applyOpacity(style.iconColor, style.iconOpacity),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const edges = state.edges.map((e) => {
|
||||||
|
const edgeType = (e.data?.type ?? 'ethernet') as EdgeType
|
||||||
|
const style = def.edges[edgeType]
|
||||||
|
if (!style) return e
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
data: {
|
||||||
|
...e.data,
|
||||||
|
type: edgeType,
|
||||||
|
custom_color: applyOpacity(style.color, style.opacity),
|
||||||
|
path_style: style.pathStyle,
|
||||||
|
animated: style.animated,
|
||||||
|
} as EdgeData,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { nodes, edges, hasUnsavedChanges: true }
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { ThemeId } from '@/utils/themes'
|
import type { ThemeId } from '@/utils/themes'
|
||||||
|
import type { CustomStyleDef } from '@/types'
|
||||||
|
|
||||||
interface ThemeState {
|
interface ThemeState {
|
||||||
activeTheme: ThemeId
|
activeTheme: ThemeId
|
||||||
setTheme: (id: ThemeId) => void
|
setTheme: (id: ThemeId) => void
|
||||||
|
customStyle: CustomStyleDef
|
||||||
|
setCustomStyle: (def: CustomStyleDef) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useThemeStore = create<ThemeState>((set) => ({
|
export const useThemeStore = create<ThemeState>((set) => ({
|
||||||
activeTheme: 'default',
|
activeTheme: 'default',
|
||||||
setTheme: (id) => set({ activeTheme: id }),
|
setTheme: (id) => set({ activeTheme: id }),
|
||||||
|
customStyle: { nodes: {}, edges: {} },
|
||||||
|
setCustomStyle: (def) => set({ customStyle: def }),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { CheckMethod } from '@/types'
|
|||||||
|
|
||||||
describe('NODE_TYPE_LABELS', () => {
|
describe('NODE_TYPE_LABELS', () => {
|
||||||
it('has an entry for every node type', () => {
|
it('has an entry for every node type', () => {
|
||||||
const expectedTypes = ['isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', 'iot', 'ap', 'camera', 'generic']
|
const expectedTypes = ['isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', 'iot', 'ap', 'camera', 'generic']
|
||||||
expectedTypes.forEach((t) => {
|
expectedTypes.forEach((t) => {
|
||||||
expect(NODE_TYPE_LABELS).toHaveProperty(t)
|
expect(NODE_TYPE_LABELS).toHaveProperty(t)
|
||||||
expect(typeof NODE_TYPE_LABELS[t as keyof typeof NODE_TYPE_LABELS]).toBe('string')
|
expect(typeof NODE_TYPE_LABELS[t as keyof typeof NODE_TYPE_LABELS]).toBe('string')
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export type NodeType =
|
export type NodeType =
|
||||||
| 'isp'
|
| 'isp'
|
||||||
| 'router'
|
| 'router'
|
||||||
|
| 'firewall'
|
||||||
| 'switch'
|
| 'switch'
|
||||||
| 'server'
|
| 'server'
|
||||||
| 'proxmox'
|
| 'proxmox'
|
||||||
@@ -92,6 +93,7 @@ export interface NodeData extends Record<string, unknown> {
|
|||||||
height?: number
|
height?: number
|
||||||
}
|
}
|
||||||
custom_icon?: string
|
custom_icon?: string
|
||||||
|
/** Number of bottom connection points, 1..48. Default 1 (centered). */
|
||||||
bottom_handles?: number
|
bottom_handles?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +118,7 @@ export interface EdgeData extends Record<string, unknown> {
|
|||||||
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||||
isp: 'ISP / Modem',
|
isp: 'ISP / Modem',
|
||||||
router: 'Router',
|
router: 'Router',
|
||||||
|
firewall: 'Firewall',
|
||||||
switch: 'Switch',
|
switch: 'Switch',
|
||||||
server: 'Server',
|
server: 'Server',
|
||||||
proxmox: 'Proxmox VE',
|
proxmox: 'Proxmox VE',
|
||||||
@@ -150,3 +153,26 @@ export const EDGE_TYPE_LABELS: Record<EdgeType, string> = {
|
|||||||
virtual: 'Virtual',
|
virtual: 'Virtual',
|
||||||
cluster: 'Cluster',
|
cluster: 'Cluster',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NodeTypeStyle {
|
||||||
|
borderColor: string
|
||||||
|
borderOpacity: number
|
||||||
|
bgColor: string
|
||||||
|
bgOpacity: number
|
||||||
|
iconColor: string
|
||||||
|
iconOpacity: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EdgeTypeStyle {
|
||||||
|
color: string
|
||||||
|
opacity: number
|
||||||
|
pathStyle: EdgePathStyle
|
||||||
|
animated: 'none' | 'snake' | 'flow' | 'basic'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomStyleDef {
|
||||||
|
nodes: Partial<Record<NodeType, NodeTypeStyle>>
|
||||||
|
edges: Partial<Record<EdgeType, EdgeTypeStyle>>
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,51 +1,105 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import {
|
import {
|
||||||
BOTTOM_HANDLE_IDS,
|
MIN_BOTTOM_HANDLES,
|
||||||
BOTTOM_HANDLE_POSITIONS,
|
MAX_BOTTOM_HANDLES,
|
||||||
|
bottomHandleId,
|
||||||
|
bottomHandlePositions,
|
||||||
|
clampBottomHandles,
|
||||||
normalizeHandle,
|
normalizeHandle,
|
||||||
removedBottomHandleIds,
|
removedBottomHandleIds,
|
||||||
} from '../handleUtils'
|
} from '../handleUtils'
|
||||||
|
|
||||||
describe('BOTTOM_HANDLE_IDS', () => {
|
describe('bottomHandleId', () => {
|
||||||
it('first id is always "bottom" for backward compatibility', () => {
|
it('first id is always "bottom" for backward compatibility', () => {
|
||||||
expect(BOTTOM_HANDLE_IDS[0]).toBe('bottom')
|
expect(bottomHandleId(0)).toBe('bottom')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has ids for 1–4 handles', () => {
|
it('subsequent ids follow bottom-N pattern (1-indexed shift)', () => {
|
||||||
expect(BOTTOM_HANDLE_IDS).toHaveLength(4)
|
expect(bottomHandleId(1)).toBe('bottom-2')
|
||||||
expect(BOTTOM_HANDLE_IDS).toEqual(['bottom', 'bottom-2', 'bottom-3', 'bottom-4'])
|
expect(bottomHandleId(2)).toBe('bottom-3')
|
||||||
|
expect(bottomHandleId(3)).toBe('bottom-4')
|
||||||
|
expect(bottomHandleId(11)).toBe('bottom-12')
|
||||||
|
expect(bottomHandleId(47)).toBe('bottom-48')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('BOTTOM_HANDLE_POSITIONS', () => {
|
describe('clampBottomHandles', () => {
|
||||||
|
it('clamps below MIN to MIN', () => {
|
||||||
|
expect(clampBottomHandles(0)).toBe(MIN_BOTTOM_HANDLES)
|
||||||
|
expect(clampBottomHandles(-5)).toBe(MIN_BOTTOM_HANDLES)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps above MAX to MAX', () => {
|
||||||
|
expect(clampBottomHandles(49)).toBe(MAX_BOTTOM_HANDLES)
|
||||||
|
expect(clampBottomHandles(9999)).toBe(MAX_BOTTOM_HANDLES)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns MIN for non-finite or non-number', () => {
|
||||||
|
expect(clampBottomHandles(NaN)).toBe(MIN_BOTTOM_HANDLES)
|
||||||
|
expect(clampBottomHandles(Infinity)).toBe(MIN_BOTTOM_HANDLES)
|
||||||
|
expect(clampBottomHandles('4' as unknown)).toBe(MIN_BOTTOM_HANDLES)
|
||||||
|
expect(clampBottomHandles(undefined)).toBe(MIN_BOTTOM_HANDLES)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('floors fractional values', () => {
|
||||||
|
expect(clampBottomHandles(3.9)).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes valid integers through', () => {
|
||||||
|
expect(clampBottomHandles(1)).toBe(1)
|
||||||
|
expect(clampBottomHandles(24)).toBe(24)
|
||||||
|
expect(clampBottomHandles(48)).toBe(48)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('bottomHandlePositions — backward-compat lock for 1..4', () => {
|
||||||
|
// These exact arrays are the pre-multi-handle hand-tuned positions.
|
||||||
|
// Existing user canvases depend on them — do NOT change.
|
||||||
it('1 handle is centered at 50%', () => {
|
it('1 handle is centered at 50%', () => {
|
||||||
expect(BOTTOM_HANDLE_POSITIONS[1]).toEqual([50])
|
expect(bottomHandlePositions(1)).toEqual([50])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('2 handles are symmetric', () => {
|
it('2 handles use exact prior positions', () => {
|
||||||
const [a, b] = BOTTOM_HANDLE_POSITIONS[2]
|
expect(bottomHandlePositions(2)).toEqual([25, 75])
|
||||||
expect(a).toBeLessThan(50)
|
|
||||||
expect(b).toBeGreaterThan(50)
|
|
||||||
expect(a + b).toBe(100)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('3 handles include a center at 50%', () => {
|
it('3 handles use exact prior positions', () => {
|
||||||
expect(BOTTOM_HANDLE_POSITIONS[3]).toContain(50)
|
expect(bottomHandlePositions(3)).toEqual([20, 50, 80])
|
||||||
expect(BOTTOM_HANDLE_POSITIONS[3]).toHaveLength(3)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('4 handles are evenly spaced', () => {
|
it('4 handles use exact prior positions', () => {
|
||||||
const pos = BOTTOM_HANDLE_POSITIONS[4]
|
expect(bottomHandlePositions(4)).toEqual([15, 38, 62, 85])
|
||||||
expect(pos).toHaveLength(4)
|
})
|
||||||
// All values should be between 0 and 100 exclusive
|
})
|
||||||
|
|
||||||
|
describe('bottomHandlePositions — uniform spacing for ≥5', () => {
|
||||||
|
it('returns count entries', () => {
|
||||||
|
expect(bottomHandlePositions(5)).toHaveLength(5)
|
||||||
|
expect(bottomHandlePositions(12)).toHaveLength(12)
|
||||||
|
expect(bottomHandlePositions(48)).toHaveLength(48)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('all positions strictly between 0 and 100', () => {
|
||||||
|
const pos = bottomHandlePositions(48)
|
||||||
pos.forEach((p) => {
|
pos.forEach((p) => {
|
||||||
expect(p).toBeGreaterThan(0)
|
expect(p).toBeGreaterThan(0)
|
||||||
expect(p).toBeLessThan(100)
|
expect(p).toBeLessThan(100)
|
||||||
})
|
})
|
||||||
// Positions should be strictly increasing
|
})
|
||||||
|
|
||||||
|
it('positions are strictly increasing and uniform', () => {
|
||||||
|
const pos = bottomHandlePositions(12)
|
||||||
for (let i = 1; i < pos.length; i++) {
|
for (let i = 1; i < pos.length; i++) {
|
||||||
expect(pos[i]).toBeGreaterThan(pos[i - 1])
|
expect(pos[i]).toBeGreaterThan(pos[i - 1])
|
||||||
}
|
}
|
||||||
|
const step = 100 / 13
|
||||||
|
expect(pos[0]).toBeCloseTo(step, 5)
|
||||||
|
expect(pos[11]).toBeCloseTo(step * 12, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps out-of-range counts before computing', () => {
|
||||||
|
expect(bottomHandlePositions(0)).toEqual([50])
|
||||||
|
expect(bottomHandlePositions(99)).toHaveLength(MAX_BOTTOM_HANDLES)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -63,16 +117,10 @@ describe('normalizeHandle', () => {
|
|||||||
expect(normalizeHandle('bottom-t')).toBe('bottom')
|
expect(normalizeHandle('bottom-t')).toBe('bottom')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('maps bottom-2-t → bottom-2', () => {
|
it('maps bottom-N-t → bottom-N for any N', () => {
|
||||||
expect(normalizeHandle('bottom-2-t')).toBe('bottom-2')
|
expect(normalizeHandle('bottom-2-t')).toBe('bottom-2')
|
||||||
})
|
expect(normalizeHandle('bottom-12-t')).toBe('bottom-12')
|
||||||
|
expect(normalizeHandle('bottom-48-t')).toBe('bottom-48')
|
||||||
it('maps bottom-3-t → bottom-3', () => {
|
|
||||||
expect(normalizeHandle('bottom-3-t')).toBe('bottom-3')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps bottom-4-t → bottom-4', () => {
|
|
||||||
expect(normalizeHandle('bottom-4-t')).toBe('bottom-4')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('passes through non-stub handles unchanged', () => {
|
it('passes through non-stub handles unchanged', () => {
|
||||||
@@ -90,22 +138,35 @@ describe('removedBottomHandleIds', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('4 → 1 removes bottom-2, bottom-3, bottom-4', () => {
|
it('4 → 1 removes bottom-2, bottom-3, bottom-4', () => {
|
||||||
const removed = removedBottomHandleIds(4, 1)
|
expect(removedBottomHandleIds(4, 1)).toEqual(new Set(['bottom-2', 'bottom-3', 'bottom-4']))
|
||||||
expect(removed).toEqual(new Set(['bottom-2', 'bottom-3', 'bottom-4']))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('4 → 2 removes bottom-3, bottom-4', () => {
|
it('4 → 2 removes bottom-3, bottom-4', () => {
|
||||||
const removed = removedBottomHandleIds(4, 2)
|
expect(removedBottomHandleIds(4, 2)).toEqual(new Set(['bottom-3', 'bottom-4']))
|
||||||
expect(removed).toEqual(new Set(['bottom-3', 'bottom-4']))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('3 → 2 removes only bottom-3', () => {
|
it('3 → 2 removes only bottom-3', () => {
|
||||||
const removed = removedBottomHandleIds(3, 2)
|
expect(removedBottomHandleIds(3, 2)).toEqual(new Set(['bottom-3']))
|
||||||
expect(removed).toEqual(new Set(['bottom-3']))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('never removes "bottom" (index 0)', () => {
|
it('never removes "bottom" (index 0)', () => {
|
||||||
const removed = removedBottomHandleIds(4, 1)
|
expect(removedBottomHandleIds(4, 1).has('bottom')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Regression: scaling the cap from 4 → 48 must not break the remap loop.
|
||||||
|
it('scales to high counts (48 → 1 removes 47 ids)', () => {
|
||||||
|
const removed = removedBottomHandleIds(48, 1)
|
||||||
|
expect(removed.size).toBe(47)
|
||||||
|
expect(removed.has('bottom-2')).toBe(true)
|
||||||
|
expect(removed.has('bottom-48')).toBe(true)
|
||||||
expect(removed.has('bottom')).toBe(false)
|
expect(removed.has('bottom')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('10 → 2 leaves bottom + bottom-2, removes bottom-3..bottom-10', () => {
|
||||||
|
const removed = removedBottomHandleIds(10, 2)
|
||||||
|
expect(removed.size).toBe(8)
|
||||||
|
expect(removed.has('bottom-2')).toBe(false)
|
||||||
|
expect(removed.has('bottom-3')).toBe(true)
|
||||||
|
expect(removed.has('bottom-10')).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { THEMES, THEME_ORDER, type ThemeId } from '../themes'
|
|||||||
import type { NodeType, EdgeType, NodeStatus } from '@/types'
|
import type { NodeType, EdgeType, NodeStatus } from '@/types'
|
||||||
|
|
||||||
const NODE_TYPES: NodeType[] = [
|
const NODE_TYPES: NodeType[] = [
|
||||||
'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc',
|
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc',
|
||||||
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host', 'docker_container', 'generic', 'groupRect',
|
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host', 'docker_container', 'generic', 'groupRect',
|
||||||
]
|
]
|
||||||
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Node, Edge } from '@xyflow/react'
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
import type { NodeData, EdgeData, Waypoint } from '@/types'
|
import type { NodeData, EdgeData, Waypoint } from '@/types'
|
||||||
import { normalizeHandle } from '@/utils/handleUtils'
|
import { normalizeHandle, clampBottomHandles } from '@/utils/handleUtils'
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
|||||||
properties: n.data.properties ?? [],
|
properties: n.data.properties ?? [],
|
||||||
width: n.measured?.width ?? n.width ?? null,
|
width: n.measured?.width ?? n.width ?? null,
|
||||||
height: n.measured?.height ?? n.height ?? null,
|
height: n.measured?.height ?? n.height ?? null,
|
||||||
bottom_handles: n.data.bottom_handles ?? 1,
|
bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1),
|
||||||
pos_x: n.position.x,
|
pos_x: n.position.x,
|
||||||
pos_y: n.position.y,
|
pos_y: n.position.y,
|
||||||
}
|
}
|
||||||
@@ -155,7 +155,7 @@ export function deserializeApiNode(
|
|||||||
id: n.id,
|
id: n.id,
|
||||||
type: normalizedType,
|
type: normalizedType,
|
||||||
position: { x: n.pos_x, y: n.pos_y },
|
position: { x: n.pos_x, y: n.pos_y },
|
||||||
data: { ...n, type: normalizedType } as unknown as NodeData,
|
data: { ...n, type: normalizedType, bottom_handles: clampBottomHandles(n.bottom_handles ?? 1) } as unknown as NodeData,
|
||||||
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
|
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
|
||||||
...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false
|
...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false
|
||||||
? { width: n.width ?? 300, height: n.height ?? 200 }
|
? { width: n.width ?? 300, height: n.height ?? 200 }
|
||||||
|
|||||||
@@ -27,3 +27,17 @@ export function rgbaToHex8(hex6: string, alpha: number): string {
|
|||||||
const alphaHex = alphaByte.toString(16).padStart(2, '0')
|
const alphaHex = alphaByte.toString(16).padStart(2, '0')
|
||||||
return `${hex6}${alphaHex}`
|
return `${hex6}${alphaHex}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combine a hex color and opacity (0–1) into a CSS rgba() string.
|
||||||
|
* Returns the plain hex when opacity is 1.
|
||||||
|
*/
|
||||||
|
export function applyOpacity(hex: string, opacity: number): string {
|
||||||
|
if (opacity >= 1) return hex
|
||||||
|
let h = hex.replace('#', '')
|
||||||
|
if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2]
|
||||||
|
const r = parseInt(h.slice(0, 2), 16)
|
||||||
|
const g = parseInt(h.slice(2, 4), 16)
|
||||||
|
const b = parseInt(h.slice(4, 6), 16)
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${Math.round(opacity * 100) / 100})`
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,20 +2,44 @@
|
|||||||
* Bottom handle configuration for multi-handle nodes.
|
* Bottom handle configuration for multi-handle nodes.
|
||||||
*
|
*
|
||||||
* Handle IDs: index 0 = 'bottom' (always the default, backward-compatible)
|
* Handle IDs: index 0 = 'bottom' (always the default, backward-compatible)
|
||||||
* index 1 = 'bottom-2', index 2 = 'bottom-3', index 3 = 'bottom-4'
|
* index N≥1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 47 = 'bottom-48')
|
||||||
*
|
*
|
||||||
* Invisible target handles follow the same pattern with a '-t' suffix:
|
* Invisible target handles follow the same pattern with a '-t' suffix:
|
||||||
* 'bottom-t', 'bottom-2-t', 'bottom-3-t', 'bottom-4-t'
|
* 'bottom-t', 'bottom-2-t', ..., 'bottom-48-t'
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const BOTTOM_HANDLE_IDS = ['bottom', 'bottom-2', 'bottom-3', 'bottom-4'] as const
|
export const MIN_BOTTOM_HANDLES = 1
|
||||||
|
export const MAX_BOTTOM_HANDLES = 48
|
||||||
|
|
||||||
/** Left % position for each handle slot, per count. */
|
/** Returns the source handle ID at a given slot index. */
|
||||||
export const BOTTOM_HANDLE_POSITIONS: Record<number, number[]> = {
|
export function bottomHandleId(idx: number): string {
|
||||||
1: [50],
|
return idx === 0 ? 'bottom' : `bottom-${idx + 1}`
|
||||||
2: [25, 75],
|
}
|
||||||
3: [20, 50, 80],
|
|
||||||
4: [15, 38, 62, 85],
|
/** Clamp a raw count into the supported range. Non-finite or non-int → MIN. */
|
||||||
|
export function clampBottomHandles(n: unknown): number {
|
||||||
|
if (typeof n !== 'number' || !Number.isFinite(n)) return MIN_BOTTOM_HANDLES
|
||||||
|
const i = Math.floor(n)
|
||||||
|
if (i < MIN_BOTTOM_HANDLES) return MIN_BOTTOM_HANDLES
|
||||||
|
if (i > MAX_BOTTOM_HANDLES) return MAX_BOTTOM_HANDLES
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Left % positions for each handle slot.
|
||||||
|
* Counts 1..4 keep their original hand-tuned values to preserve exact pixel
|
||||||
|
* positions on canvases saved before the multi-handle expansion.
|
||||||
|
* Counts ≥ 5 use uniform spacing.
|
||||||
|
*/
|
||||||
|
export function bottomHandlePositions(count: number): number[] {
|
||||||
|
const c = clampBottomHandles(count)
|
||||||
|
switch (c) {
|
||||||
|
case 1: return [50]
|
||||||
|
case 2: return [25, 75]
|
||||||
|
case 3: return [20, 50, 80]
|
||||||
|
case 4: return [15, 38, 62, 85]
|
||||||
|
default: return Array.from({ length: c }, (_, i) => ((i + 1) * 100) / (c + 1))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,7 +63,7 @@ export function normalizeHandle(h: string | null | undefined): string | null {
|
|||||||
export function removedBottomHandleIds(oldCount: number, newCount: number): Set<string> {
|
export function removedBottomHandleIds(oldCount: number, newCount: number): Set<string> {
|
||||||
const removed = new Set<string>()
|
const removed = new Set<string>()
|
||||||
for (let i = newCount; i < oldCount; i++) {
|
for (let i = newCount; i < oldCount; i++) {
|
||||||
removed.add(BOTTOM_HANDLE_IDS[i])
|
removed.add(bottomHandleId(i))
|
||||||
}
|
}
|
||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
// Storage & Databases
|
// Storage & Databases
|
||||||
Database, Archive, Cloud, FolderOpen,
|
Database, Archive, Cloud, FolderOpen,
|
||||||
// Security & Auth
|
// Security & Auth
|
||||||
Shield, ShieldCheck, Lock, Key, Users, UserCheck,
|
Shield, ShieldCheck, Lock, Key, Users, UserCheck, Flame,
|
||||||
// Automation & IoT
|
// Automation & IoT
|
||||||
Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio,
|
Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio,
|
||||||
// Transfers & sync
|
// Transfers & sync
|
||||||
@@ -34,6 +34,7 @@ export const ICON_REGISTRY: IconEntry[] = [
|
|||||||
// --- Infrastructure ---
|
// --- Infrastructure ---
|
||||||
{ key: 'globe', label: 'Globe / ISP', category: 'Infrastructure', icon: Globe },
|
{ key: 'globe', label: 'Globe / ISP', category: 'Infrastructure', icon: Globe },
|
||||||
{ key: 'router', label: 'Router', category: 'Infrastructure', icon: Router },
|
{ key: 'router', label: 'Router', category: 'Infrastructure', icon: Router },
|
||||||
|
{ key: 'flame', label: 'Firewall', category: 'Infrastructure', icon: Flame },
|
||||||
{ key: 'network', label: 'Switch / Network', category: 'Infrastructure', icon: Network },
|
{ key: 'network', label: 'Switch / Network', category: 'Infrastructure', icon: Network },
|
||||||
{ key: 'server', label: 'Server', category: 'Infrastructure', icon: Server },
|
{ key: 'server', label: 'Server', category: 'Infrastructure', icon: Server },
|
||||||
{ key: 'layers', label: 'Proxmox / Hypervisor', category: 'Infrastructure', icon: Layers },
|
{ key: 'layers', label: 'Proxmox / Hypervisor', category: 'Infrastructure', icon: Layers },
|
||||||
@@ -120,6 +121,7 @@ export const ICON_MAP: Record<string, LucideIcon> = Object.fromEntries(
|
|||||||
export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
|
export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
|
||||||
isp: Globe,
|
isp: Globe,
|
||||||
router: Router,
|
router: Router,
|
||||||
|
firewall: Flame,
|
||||||
switch: Network,
|
switch: Network,
|
||||||
server: Server,
|
server: Server,
|
||||||
proxmox: Layers,
|
proxmox: Layers,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { NodeType, EdgeType, NodeStatus } from '@/types'
|
import type { NodeType, EdgeType, NodeStatus } from '@/types'
|
||||||
|
|
||||||
export type ThemeId = 'default' | 'dark' | 'light' | 'neon' | 'matrix'
|
export type ThemeId = 'default' | 'dark' | 'light' | 'neon' | 'matrix' | 'custom'
|
||||||
|
|
||||||
export interface ThemeColors {
|
export interface ThemeColors {
|
||||||
// Per node-type accent (border + icon)
|
// Per node-type accent (border + icon)
|
||||||
@@ -44,6 +44,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
nodeAccents: {
|
nodeAccents: {
|
||||||
isp: { border: '#00d4ff', icon: '#00d4ff' },
|
isp: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
router: { border: '#00d4ff', icon: '#00d4ff' },
|
router: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
firewall: { border: '#f85149', icon: '#f85149' },
|
||||||
switch: { border: '#39d353', icon: '#39d353' },
|
switch: { border: '#39d353', icon: '#39d353' },
|
||||||
server: { border: '#a855f7', icon: '#a855f7' },
|
server: { border: '#a855f7', icon: '#a855f7' },
|
||||||
proxmox: { border: '#ff6e00', icon: '#ff6e00' },
|
proxmox: { border: '#ff6e00', icon: '#ff6e00' },
|
||||||
@@ -100,6 +101,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
nodeAccents: {
|
nodeAccents: {
|
||||||
isp: { border: '#22d3ee', icon: '#22d3ee' },
|
isp: { border: '#22d3ee', icon: '#22d3ee' },
|
||||||
router: { border: '#22d3ee', icon: '#22d3ee' },
|
router: { border: '#22d3ee', icon: '#22d3ee' },
|
||||||
|
firewall: { border: '#ef4444', icon: '#ef4444' },
|
||||||
switch: { border: '#4ade80', icon: '#4ade80' },
|
switch: { border: '#4ade80', icon: '#4ade80' },
|
||||||
server: { border: '#c084fc', icon: '#c084fc' },
|
server: { border: '#c084fc', icon: '#c084fc' },
|
||||||
proxmox: { border: '#fb923c', icon: '#fb923c' },
|
proxmox: { border: '#fb923c', icon: '#fb923c' },
|
||||||
@@ -156,6 +158,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
nodeAccents: {
|
nodeAccents: {
|
||||||
isp: { border: '#0284c7', icon: '#0284c7' },
|
isp: { border: '#0284c7', icon: '#0284c7' },
|
||||||
router: { border: '#0284c7', icon: '#0284c7' },
|
router: { border: '#0284c7', icon: '#0284c7' },
|
||||||
|
firewall: { border: '#dc2626', icon: '#dc2626' },
|
||||||
switch: { border: '#16a34a', icon: '#16a34a' },
|
switch: { border: '#16a34a', icon: '#16a34a' },
|
||||||
server: { border: '#7c3aed', icon: '#7c3aed' },
|
server: { border: '#7c3aed', icon: '#7c3aed' },
|
||||||
proxmox: { border: '#ea580c', icon: '#ea580c' },
|
proxmox: { border: '#ea580c', icon: '#ea580c' },
|
||||||
@@ -212,6 +215,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
nodeAccents: {
|
nodeAccents: {
|
||||||
isp: { border: '#00ffff', icon: '#00ffff' },
|
isp: { border: '#00ffff', icon: '#00ffff' },
|
||||||
router: { border: '#00ffff', icon: '#00ffff' },
|
router: { border: '#00ffff', icon: '#00ffff' },
|
||||||
|
firewall: { border: '#ff0040', icon: '#ff0040' },
|
||||||
switch: { border: '#00ff80', icon: '#00ff80' },
|
switch: { border: '#00ff80', icon: '#00ff80' },
|
||||||
server: { border: '#ff00ff', icon: '#ff00ff' },
|
server: { border: '#ff00ff', icon: '#ff00ff' },
|
||||||
proxmox: { border: '#ff8800', icon: '#ff8800' },
|
proxmox: { border: '#ff8800', icon: '#ff8800' },
|
||||||
@@ -268,6 +272,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
nodeAccents: {
|
nodeAccents: {
|
||||||
isp: { border: '#00ff41', icon: '#00ff41' },
|
isp: { border: '#00ff41', icon: '#00ff41' },
|
||||||
router: { border: '#00ff41', icon: '#00ff41' },
|
router: { border: '#00ff41', icon: '#00ff41' },
|
||||||
|
firewall: { border: '#88ff00', icon: '#88ff00' },
|
||||||
switch: { border: '#00cc33', icon: '#00cc33' },
|
switch: { border: '#00cc33', icon: '#00cc33' },
|
||||||
server: { border: '#008822', icon: '#008822' },
|
server: { border: '#008822', icon: '#008822' },
|
||||||
proxmox: { border: '#33ff66', icon: '#33ff66' },
|
proxmox: { border: '#33ff66', icon: '#33ff66' },
|
||||||
@@ -315,7 +320,64 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
reactFlowColorMode: 'dark',
|
reactFlowColorMode: 'dark',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
custom: {
|
||||||
|
id: 'custom',
|
||||||
|
label: 'Custom',
|
||||||
|
description: 'Your own colors per node and edge type',
|
||||||
|
colors: {
|
||||||
|
nodeAccents: {
|
||||||
|
isp: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
router: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
firewall: { border: '#f85149', icon: '#f85149' },
|
||||||
|
switch: { border: '#39d353', icon: '#39d353' },
|
||||||
|
server: { border: '#a855f7', icon: '#a855f7' },
|
||||||
|
proxmox: { border: '#ff6e00', icon: '#ff6e00' },
|
||||||
|
vm: { border: '#a855f7', icon: '#a855f7' },
|
||||||
|
lxc: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
nas: { border: '#39d353', icon: '#39d353' },
|
||||||
|
iot: { border: '#e3b341', icon: '#e3b341' },
|
||||||
|
ap: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
camera: { border: '#8b949e', icon: '#8b949e' },
|
||||||
|
printer: { border: '#8b949e', icon: '#8b949e' },
|
||||||
|
computer: { border: '#a855f7', icon: '#a855f7' },
|
||||||
|
cpl: { border: '#e3b341', icon: '#e3b341' },
|
||||||
|
docker_host: { border: '#2496ED', icon: '#2496ED' },
|
||||||
|
docker_container: { border: '#0ea5e9', icon: '#0ea5e9' },
|
||||||
|
generic: { border: '#8b949e', icon: '#8b949e' },
|
||||||
|
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
group: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
},
|
||||||
|
nodeCardBackground: '#21262d',
|
||||||
|
nodeIconBackground: '#161b22',
|
||||||
|
nodeLabelColor: '#e6edf3',
|
||||||
|
nodeSubtextColor: '#8b949e',
|
||||||
|
statusColors: {
|
||||||
|
online: '#39d353',
|
||||||
|
offline: '#f85149',
|
||||||
|
pending: '#e3b341',
|
||||||
|
unknown: '#8b949e',
|
||||||
|
},
|
||||||
|
edgeColors: {
|
||||||
|
ethernet: '#30363d',
|
||||||
|
wifi: '#00d4ff',
|
||||||
|
iot: '#e3b341',
|
||||||
|
vlan: '#00d4ff',
|
||||||
|
virtual: '#8b949e',
|
||||||
|
cluster: '#ff6e00',
|
||||||
|
},
|
||||||
|
edgeSelectedColor: '#00d4ff',
|
||||||
|
edgeLabelBackground:'#161b22',
|
||||||
|
edgeLabelColor: '#8b949e',
|
||||||
|
edgeLabelBorder: '#30363d',
|
||||||
|
canvasBackground: '#0d1117',
|
||||||
|
canvasDotColor: '#30363d',
|
||||||
|
handleBackground: '#30363d',
|
||||||
|
handleBorder: '#8b949e',
|
||||||
|
reactFlowColorMode: 'dark',
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ordered list for display in the modal
|
// Ordered list for display in the modal
|
||||||
export const THEME_ORDER: ThemeId[] = ['default', 'dark', 'light', 'neon', 'matrix']
|
export const THEME_ORDER: ThemeId[] = ['default', 'dark', 'light', 'neon', 'matrix', 'custom']
|
||||||
|
|||||||
+13
-7
@@ -1,7 +1,8 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI
|
||||||
from mcp.server import Server
|
from mcp.server import Server
|
||||||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
||||||
|
from starlette.routing import Mount
|
||||||
|
|
||||||
from .auth import ApiKeyMiddleware
|
from .auth import ApiKeyMiddleware
|
||||||
from .backend_client import backend
|
from .backend_client import backend
|
||||||
@@ -28,15 +29,20 @@ async def lifespan(app: FastAPI):
|
|||||||
await backend.stop()
|
await backend.stop()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Homelable MCP", lifespan=lifespan)
|
# Mount the session manager as an ASGI sub-app instead of wrapping it in a
|
||||||
|
# FastAPI @app.api_route handler. Wrapping it in a route handler causes
|
||||||
|
# FastAPI to send http.response.start after the session manager has already
|
||||||
|
# started the response, raising `RuntimeError: Unexpected ASGI message
|
||||||
|
# 'http.response.start' sent, after response already completed` on every
|
||||||
|
# POST /mcp — which makes the server unreachable from any MCP client.
|
||||||
|
app = FastAPI(
|
||||||
|
title="Homelable MCP",
|
||||||
|
lifespan=lifespan,
|
||||||
|
routes=[Mount("/mcp", app=session_manager.handle_request)],
|
||||||
|
)
|
||||||
app.add_middleware(ApiKeyMiddleware)
|
app.add_middleware(ApiKeyMiddleware)
|
||||||
|
|
||||||
|
|
||||||
@app.api_route("/mcp", methods=["GET", "POST", "DELETE"])
|
|
||||||
async def mcp_endpoint(request: Request):
|
|
||||||
await session_manager.handle_request(request.scope, request.receive, request._send)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
Reference in New Issue
Block a user