Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8635df1c5 | |||
| 1cc9b7c52f | |||
| fdf2b1f2be | |||
| 5630e7d202 | |||
| be705f0cb9 | |||
| 1790d24ee7 | |||
| 8b97f578c6 | |||
| 48e7b3c690 | |||
| 3a4df578ec | |||
| 43426e96ac | |||
| 280d5a2ae1 | |||
| cdc6091bd0 | |||
| 14597ab86f | |||
| 14719a5c39 | |||
| ea451885af | |||
| 9cd93ef294 | |||
| 8d937b2a0d | |||
| 60ea0fa11b | |||
| ff0d33128c | |||
| 17d4ddd070 | |||
| 4795dc40b8 |
@@ -34,8 +34,10 @@ async def liveview_canvas(
|
|||||||
edges = (await db.execute(select(Edge))).scalars().all()
|
edges = (await db.execute(select(Edge))).scalars().all()
|
||||||
state = await db.get(CanvasState, 1)
|
state = await db.get(CanvasState, 1)
|
||||||
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
||||||
|
custom_style: dict[str, Any] | None = state.custom_style if state else None
|
||||||
return CanvasStateResponse(
|
return CanvasStateResponse(
|
||||||
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=custom_style,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -112,6 +112,28 @@ async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
|
|||||||
assert nodes[0]["label"] == "Live Node"
|
assert nodes[0]["label"] == "Live Node"
|
||||||
|
|
||||||
|
|
||||||
|
# ── custom_style + theme propagation ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liveview_returns_custom_style_and_theme(client: AsyncClient, auth_headers):
|
||||||
|
"""custom_style and viewport.theme_id from a saved canvas surface in liveview."""
|
||||||
|
settings.liveview_key = "test-key"
|
||||||
|
headers = await auth_headers()
|
||||||
|
payload = {
|
||||||
|
"nodes": [],
|
||||||
|
"edges": [],
|
||||||
|
"viewport": {"x": 0, "y": 0, "zoom": 1, "theme_id": "matrix"},
|
||||||
|
"custom_style": {"fontFamily": "Inter", "nodeRadius": 12},
|
||||||
|
}
|
||||||
|
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
||||||
|
|
||||||
|
res = await client.get("/api/v1/liveview?key=test-key")
|
||||||
|
assert res.status_code == 200
|
||||||
|
body = res.json()
|
||||||
|
assert body["viewport"].get("theme_id") == "matrix"
|
||||||
|
assert body["custom_style"] == {"fontFamily": "Inter", "nodeRadius": 12}
|
||||||
|
|
||||||
|
|
||||||
# ── Re-disable after enabling ─────────────────────────────────────────────────
|
# ── Re-disable after enabling ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Generated
+65
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.0.3",
|
"version": "2.1.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.0.3",
|
"version": "2.1.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
@@ -2520,6 +2520,9 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2534,6 +2537,9 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2548,6 +2554,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2562,6 +2571,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2576,6 +2588,9 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2590,6 +2605,9 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2604,6 +2622,9 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2618,6 +2639,9 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2632,6 +2656,9 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2646,6 +2673,9 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2660,6 +2690,9 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2674,6 +2707,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2688,6 +2724,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2936,6 +2975,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2953,6 +2995,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2970,6 +3015,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2987,6 +3035,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6884,6 +6935,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6905,6 +6959,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6926,6 +6983,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6947,6 +7007,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.3",
|
"version": "2.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+29
-11
@@ -4,6 +4,7 @@ import { type Node } from '@xyflow/react'
|
|||||||
import { applyDagreLayout } from '@/utils/layout'
|
import { applyDagreLayout } from '@/utils/layout'
|
||||||
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||||
import { generateUUID } from '@/utils/uuid'
|
import { generateUUID } from '@/utils/uuid'
|
||||||
|
import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
|
||||||
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||||
import { ExportModal } from '@/components/modals/ExportModal'
|
import { ExportModal } from '@/components/modals/ExportModal'
|
||||||
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
||||||
@@ -37,7 +38,6 @@ import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/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'
|
||||||
const CONTAINER_MODE_TYPES = new Set<NodeData['type']>(['proxmox', 'vm', 'lxc', 'docker_host'])
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
|
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
|
||||||
@@ -450,16 +450,14 @@ export default function App() {
|
|||||||
if (edgeData.type === 'virtual') {
|
if (edgeData.type === 'virtual') {
|
||||||
const src = nodes.find((n) => n.id === pendingConnection.source)
|
const src = nodes.find((n) => n.id === pendingConnection.source)
|
||||||
const tgt = nodes.find((n) => n.id === pendingConnection.target)
|
const tgt = nodes.find((n) => n.id === pendingConnection.target)
|
||||||
const srcType = src?.data.type as NodeData['type']
|
if (src && tgt) {
|
||||||
const tgtType = tgt?.data.type as NodeData['type']
|
const assignment = resolveVirtualEdgeParent(
|
||||||
if ((srcType === 'lxc' || srcType === 'vm') && CONTAINER_MODE_TYPES.has(tgtType)) {
|
{ id: src.id, type: src.data.type as NodeData['type'] },
|
||||||
updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
|
{ id: tgt.id, type: tgt.data.type as NodeData['type'] },
|
||||||
} else if (CONTAINER_MODE_TYPES.has(srcType) && (tgtType === 'lxc' || tgtType === 'vm')) {
|
)
|
||||||
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
|
if (assignment) {
|
||||||
} else if (srcType === 'docker_container' && tgtType === 'docker_host') {
|
updateNode(assignment.childId, { parent_id: assignment.parentId })
|
||||||
updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
|
}
|
||||||
} else if (tgtType === 'docker_container' && srcType === 'docker_host') {
|
|
||||||
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setPendingConnection(null)
|
setPendingConnection(null)
|
||||||
@@ -552,6 +550,7 @@ export default function App() {
|
|||||||
onClose={() => setAddNodeOpen(false)}
|
onClose={() => setAddNodeOpen(false)}
|
||||||
onSubmit={handleAddNode}
|
onSubmit={handleAddNode}
|
||||||
title="Add Node"
|
title="Add Node"
|
||||||
|
parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* key forces re-mount when editing a different node, resetting form state */}
|
{/* key forces re-mount when editing a different node, resetting form state */}
|
||||||
@@ -562,6 +561,25 @@ export default function App() {
|
|||||||
onSubmit={handleUpdateNode}
|
onSubmit={handleUpdateNode}
|
||||||
initial={editNode?.data}
|
initial={editNode?.data}
|
||||||
title="Edit Node"
|
title="Edit Node"
|
||||||
|
parentCandidates={(() => {
|
||||||
|
const descendants = new Set<string>()
|
||||||
|
if (editNodeId) {
|
||||||
|
const queue = [editNodeId]
|
||||||
|
while (queue.length) {
|
||||||
|
const id = queue.shift()!
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.data.parent_id === id && !descendants.has(n.id)) {
|
||||||
|
descendants.add(n.id)
|
||||||
|
queue.push(n.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nodes
|
||||||
|
.filter((n) => !descendants.has(n.id))
|
||||||
|
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))
|
||||||
|
})()}
|
||||||
|
currentNodeId={editNodeId ?? undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EdgeModal
|
<EdgeModal
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
|
|||||||
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
|
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
|
||||||
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||||
import { liveviewApi } from '@/api/client'
|
import { liveviewApi } from '@/api/client'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData, CustomStyleDef } from '@/types'
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
const STORAGE_KEY = 'homelable_canvas'
|
const STORAGE_KEY = 'homelable_canvas'
|
||||||
@@ -40,6 +40,8 @@ function LiveViewCanvas() {
|
|||||||
const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
|
const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
|
||||||
const { fitView } = useReactFlow()
|
const { fitView } = useReactFlow()
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
|
const setTheme = useThemeStore((s) => s.setTheme)
|
||||||
|
const setCustomStyle = useThemeStore((s) => s.setCustomStyle)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
// Derive initial view state synchronously (avoids calling setState inside an effect):
|
// Derive initial view state synchronously (avoids calling setState inside an effect):
|
||||||
// - standalone → always ready (localStorage, no key required)
|
// - standalone → always ready (localStorage, no key required)
|
||||||
@@ -73,9 +75,12 @@ function LiveViewCanvas() {
|
|||||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||||
const proxmoxMap = new Map<string, boolean>(
|
const proxmoxMap = new Map<string, boolean>(
|
||||||
(apiNodes as ApiNode[])
|
(apiNodes as ApiNode[])
|
||||||
.filter((n: ApiNode) => n.type === 'proxmox' || n.type === 'group')
|
.filter((n: ApiNode) => n.type === 'group' || n.container_mode === true)
|
||||||
.map((n: ApiNode) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
|
.map((n: ApiNode) => [n.id, true])
|
||||||
)
|
)
|
||||||
|
const savedTheme = res.data.viewport?.theme_id
|
||||||
|
if (savedTheme) setTheme(savedTheme)
|
||||||
|
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||||
loadCanvas(
|
loadCanvas(
|
||||||
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
|
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
|
||||||
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
||||||
@@ -87,7 +92,7 @@ function LiveViewCanvas() {
|
|||||||
const detail: string = err.response.data?.detail ?? ''
|
const detail: string = err.response.data?.detail ?? ''
|
||||||
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
|
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
|
||||||
})
|
})
|
||||||
}, [loadCanvas])
|
}, [loadCanvas, setTheme, setCustomStyle])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!fitViewPending || nodes.length === 0) return
|
if (!fitViewPending || nodes.length === 0) return
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
|
||||||
// ── Mock heavy dependencies ────────────────────────────────────────────────
|
// ── Mock heavy dependencies ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -124,6 +125,55 @@ describe('LiveView (non-standalone)', () => {
|
|||||||
expect(nodes.find((n) => n.id === 'n1')).toBeDefined()
|
expect(nodes.find((n) => n.id === 'n1')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Nested children (docker_container inside docker_host) ────────────────
|
||||||
|
|
||||||
|
it('nests docker_container under docker_host parent (container_mode=true)', async () => {
|
||||||
|
setSearch('?key=valid')
|
||||||
|
const nestedPayload = {
|
||||||
|
data: {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'host', type: 'docker', label: 'Docker Host', status: 'online',
|
||||||
|
services: [], pos_x: 0, pos_y: 0, container_mode: true,
|
||||||
|
created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ctr', type: 'docker_container', label: 'nginx', status: 'online',
|
||||||
|
services: [], pos_x: 20, pos_y: 30, parent_id: 'host',
|
||||||
|
created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
viewport: { x: 0, y: 0, zoom: 1 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
vi.mocked(liveviewApi.load).mockResolvedValue(nestedPayload as never)
|
||||||
|
render(<LiveView />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||||
|
const ctr = useCanvasStore.getState().nodes.find((n) => n.id === 'ctr')
|
||||||
|
expect(ctr?.parentId).toBe('host')
|
||||||
|
expect(ctr?.extent).toBe('parent')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Theme + custom_style applied from payload ────────────────────────────
|
||||||
|
|
||||||
|
it('applies viewport.theme_id and custom_style from the payload', async () => {
|
||||||
|
setSearch('?key=valid')
|
||||||
|
const styledPayload = {
|
||||||
|
data: {
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
viewport: { x: 0, y: 0, zoom: 1, theme_id: 'matrix' },
|
||||||
|
custom_style: { fontFamily: 'Inter', nodeRadius: 12 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
vi.mocked(liveviewApi.load).mockResolvedValue(styledPayload as never)
|
||||||
|
render(<LiveView />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||||
|
expect(useThemeStore.getState().activeTheme).toBe('matrix')
|
||||||
|
expect(useThemeStore.getState().customStyle).toEqual({ fontFamily: 'Inter', nodeRadius: 12 })
|
||||||
|
})
|
||||||
|
|
||||||
// ── No editing props passed ───────────────────────────────────────────────
|
// ── No editing props passed ───────────────────────────────────────────────
|
||||||
|
|
||||||
it('does not show any Access Denied when key is valid', async () => {
|
it('does not show any Access Denied when key is valid', async () => {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ vi.mock('@xyflow/react', () => ({
|
|||||||
NodeResizer: ({ isVisible }: { isVisible: boolean }) => (
|
NodeResizer: ({ isVisible }: { isVisible: boolean }) => (
|
||||||
<div data-testid="node-resizer" data-visible={isVisible} />
|
<div data-testid="node-resizer" data-visible={isVisible} />
|
||||||
),
|
),
|
||||||
|
Handle: () => null,
|
||||||
|
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||||
useReactFlow: () => ({}),
|
useReactFlow: () => ({}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
BaseEdge,
|
BaseEdge,
|
||||||
EdgeLabelRenderer,
|
EdgeLabelRenderer,
|
||||||
@@ -179,9 +179,113 @@ function segmentMidpoints(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Endpoint dot (interactive reconnection handle pinned to handle) ──────────
|
||||||
|
|
||||||
|
interface EndpointDotProps {
|
||||||
|
edgeId: string
|
||||||
|
role: 'source' | 'target'
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
position?: string
|
||||||
|
color: string
|
||||||
|
source: string
|
||||||
|
target: string
|
||||||
|
sourceHandle: string | null | undefined
|
||||||
|
targetHandle: string | null | undefined
|
||||||
|
onDrag: (pos: { x: number; y: number } | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive endpoint marker rendered above the node layer (via
|
||||||
|
* EdgeLabelRenderer). On pointerup it inspects the element under the cursor
|
||||||
|
* for a React Flow handle (`[data-handleid]`) and calls `reconnectEdge` with
|
||||||
|
* the new endpoint. Drop on empty space leaves the edge unchanged.
|
||||||
|
*
|
||||||
|
* Handles are nudged 3px inward (toward the node) because React Flow's edge
|
||||||
|
* endpoint coords sit at the outer edge of the handle box, not its center.
|
||||||
|
*/
|
||||||
|
function EndpointDot({ edgeId, role, x, y, position, color, source, target, sourceHandle, targetHandle, onDrag }: EndpointDotProps) {
|
||||||
|
const reconnectEdge = useCanvasStore((s) => s.reconnectEdge)
|
||||||
|
const { screenToFlowPosition } = useReactFlow()
|
||||||
|
|
||||||
|
const offset = 3
|
||||||
|
let dx = 0, dy = 0
|
||||||
|
if (position === 'bottom') dy = -offset
|
||||||
|
else if (position === 'top') dy = offset
|
||||||
|
else if (position === 'left') dx = offset
|
||||||
|
else if (position === 'right') dx = -offset
|
||||||
|
|
||||||
|
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||||
|
if (e.buttons !== 1) return
|
||||||
|
onDrag(screenToFlowPosition({ x: e.clientX, y: e.clientY }))
|
||||||
|
}, [onDrag, screenToFlowPosition])
|
||||||
|
|
||||||
|
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||||
|
// Find the topmost handle under cursor, skipping the dragged dot itself.
|
||||||
|
const stack = document.elementsFromPoint(e.clientX, e.clientY)
|
||||||
|
let handleEl: HTMLElement | null = null
|
||||||
|
for (const node of stack) {
|
||||||
|
const h = (node as HTMLElement).closest?.('[data-handleid]') as HTMLElement | null
|
||||||
|
if (h) { handleEl = h; break }
|
||||||
|
}
|
||||||
|
onDrag(null)
|
||||||
|
if (!handleEl) return // dropped on empty space → keep edge unchanged
|
||||||
|
const newHandleId = handleEl.getAttribute('data-handleid')
|
||||||
|
const newNodeId = handleEl.getAttribute('data-nodeid')
|
||||||
|
if (!newHandleId || !newNodeId) return
|
||||||
|
if (role === 'source') {
|
||||||
|
reconnectEdge(edgeId, { source: newNodeId, target, sourceHandle: newHandleId, targetHandle: targetHandle ?? null })
|
||||||
|
} else {
|
||||||
|
reconnectEdge(edgeId, { source, target: newNodeId, sourceHandle: sourceHandle ?? null, targetHandle: newHandleId })
|
||||||
|
}
|
||||||
|
}, [edgeId, role, source, target, sourceHandle, targetHandle, reconnectEdge, onDrag])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
transform: `translate(-50%, -50%) translate(${x + dx}px, ${y + dy}px)`,
|
||||||
|
width: 15,
|
||||||
|
height: 15,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: color,
|
||||||
|
border: '2px solid #0d1117',
|
||||||
|
cursor: 'grab',
|
||||||
|
pointerEvents: 'all',
|
||||||
|
zIndex: 1000,
|
||||||
|
touchAction: 'none',
|
||||||
|
}}
|
||||||
|
title="Drag to reconnect"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main edge component ──────────────────────────────────────────────────────
|
// ── Main edge component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
export function HomelableEdge({ id, source, target, sourceHandleId, targetHandleId, sourceX: rawSourceX, sourceY: rawSourceY, targetX: rawTargetX, targetY: rawTargetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
||||||
|
const [drag, setDrag] = useState<{ role: 'source' | 'target'; x: number; y: number } | null>(null)
|
||||||
|
|
||||||
|
const sourceX = drag?.role === 'source' ? drag.x : rawSourceX
|
||||||
|
const sourceY = drag?.role === 'source' ? drag.y : rawSourceY
|
||||||
|
const targetX = drag?.role === 'target' ? drag.x : rawTargetX
|
||||||
|
const targetY = drag?.role === 'target' ? drag.y : rawTargetY
|
||||||
|
|
||||||
|
const onSourceDrag = useCallback((pos: { x: number; y: number } | null) => {
|
||||||
|
setDrag(pos ? { role: 'source', x: pos.x, y: pos.y } : null)
|
||||||
|
}, [])
|
||||||
|
const onTargetDrag = useCallback((pos: { x: number; y: number } | null) => {
|
||||||
|
setDrag(pos ? { role: 'target', x: pos.x, y: pos.y } : null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
const sourceType = useStore((s) => s.nodeLookup.get(source)?.type)
|
const sourceType = useStore((s) => s.nodeLookup.get(source)?.type)
|
||||||
@@ -311,6 +415,38 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Endpoint dots — visual indicators for reconnection targets */}
|
||||||
|
{selected && (
|
||||||
|
<>
|
||||||
|
<EndpointDot
|
||||||
|
edgeId={id}
|
||||||
|
role="source"
|
||||||
|
x={sourceX}
|
||||||
|
y={sourceY}
|
||||||
|
position={sourcePosition}
|
||||||
|
color={strokeColor}
|
||||||
|
source={source}
|
||||||
|
target={target}
|
||||||
|
sourceHandle={sourceHandleId}
|
||||||
|
targetHandle={targetHandleId}
|
||||||
|
onDrag={onSourceDrag}
|
||||||
|
/>
|
||||||
|
<EndpointDot
|
||||||
|
edgeId={id}
|
||||||
|
role="target"
|
||||||
|
x={targetX}
|
||||||
|
y={targetY}
|
||||||
|
position={targetPosition}
|
||||||
|
color={strokeColor}
|
||||||
|
source={source}
|
||||||
|
target={target}
|
||||||
|
sourceHandle={sourceHandleId}
|
||||||
|
targetHandle={targetHandleId}
|
||||||
|
onDrag={onTargetDrag}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Existing waypoint drag handles */}
|
{/* Existing waypoint drag handles */}
|
||||||
{selected && waypoints.map((wp, idx) => {
|
{selected && waypoints.map((wp, idx) => {
|
||||||
const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1]
|
const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1]
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { type NodeProps, type Node, NodeResizer } from '@xyflow/react'
|
import { type NodeProps, type Node, NodeResizer, Handle, Position } from '@xyflow/react'
|
||||||
import { Layers, Pencil, Check, X } from 'lucide-react'
|
import { Layers, Pencil, Check, X } from 'lucide-react'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
import { THEMES } from '@/utils/themes'
|
||||||
import { STATUS_COLORS, type NodeData } from '@/types'
|
import { STATUS_COLORS, type NodeData } from '@/types'
|
||||||
|
|
||||||
export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||||
const { nodes, updateNode, snapshotHistory } = useCanvasStore()
|
const { nodes, updateNode, snapshotHistory } = useCanvasStore()
|
||||||
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
|
const theme = THEMES[activeTheme]
|
||||||
const showBorder = data.custom_colors?.show_border !== false
|
const showBorder = data.custom_colors?.show_border !== false
|
||||||
const isVisible = showBorder || selected
|
const isVisible = showBorder || selected
|
||||||
|
|
||||||
@@ -49,6 +53,29 @@ export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
|||||||
handleStyle={{ fill: '#00d4ff', stroke: '#0d1117', width: 8, height: 8, borderRadius: 2 }}
|
handleStyle={{ fill: '#00d4ff', stroke: '#0d1117', width: 8, height: 8, borderRadius: 2 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 4 snap-point handles — one per side. Source + invisible target overlay for each. */}
|
||||||
|
{([
|
||||||
|
['group-top', Position.Top],
|
||||||
|
['group-right', Position.Right],
|
||||||
|
['group-bottom', Position.Bottom],
|
||||||
|
['group-left', Position.Left],
|
||||||
|
] as const).map(([hid, pos]) => (
|
||||||
|
<span key={hid}>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={pos}
|
||||||
|
id={hid}
|
||||||
|
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={pos}
|
||||||
|
id={`${hid}-t`}
|
||||||
|
style={{ opacity: 0, width: 12, height: 12 }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
{isVisible && (
|
{isVisible && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createElement } from 'react'
|
import { createElement, useEffect } from 'react'
|
||||||
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
import { Handle, Position, NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/react'
|
||||||
import { Layers } from 'lucide-react'
|
import { Layers } from 'lucide-react'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
@@ -10,10 +10,13 @@ import { useCanvasStore } from '@/stores/canvasStore'
|
|||||||
import { maskIp, splitIps } from '@/utils/maskIp'
|
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 { bottomHandleId, bottomHandlePositions } from '@/utils/handleUtils'
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
|
|
||||||
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||||
const { data, selected } = props
|
const { id, data, selected } = props
|
||||||
|
const updateNodeInternals = useUpdateNodeInternals()
|
||||||
|
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
|
||||||
|
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
@@ -149,13 +152,26 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||||
/>
|
/>
|
||||||
<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 }} />
|
||||||
<Handle
|
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
|
||||||
type="source"
|
const sourceId = bottomHandleId(idx)
|
||||||
position={Position.Bottom}
|
const targetId = `${sourceId}-t`
|
||||||
id="bottom"
|
return (
|
||||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
<span key={sourceId}>
|
||||||
/>
|
<Handle
|
||||||
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id={sourceId}
|
||||||
|
style={{ left: `${leftPct}%`, background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id={targetId}
|
||||||
|
style={{ left: `${leftPct}%`, opacity: 0, width: 12, height: 12 }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Cluster handles */}
|
{/* Cluster handles */}
|
||||||
<Handle
|
<Handle
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { ReactFlowProvider } from '@xyflow/react'
|
||||||
|
import { GroupNode } from '../GroupNode'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
import type { NodeProps, Node } from '@xyflow/react'
|
||||||
|
|
||||||
|
function renderNode(data: Partial<NodeData> = {}, selected = false) {
|
||||||
|
const fullData: NodeData = {
|
||||||
|
label: 'Group A',
|
||||||
|
type: 'group',
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
...data,
|
||||||
|
}
|
||||||
|
const props = {
|
||||||
|
id: 'g1',
|
||||||
|
data: fullData,
|
||||||
|
selected,
|
||||||
|
type: 'group',
|
||||||
|
zIndex: 0,
|
||||||
|
isConnectable: true,
|
||||||
|
xPos: 0,
|
||||||
|
yPos: 0,
|
||||||
|
dragging: false,
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
selectable: true,
|
||||||
|
positionAbsoluteX: 0,
|
||||||
|
positionAbsoluteY: 0,
|
||||||
|
width: 300,
|
||||||
|
height: 200,
|
||||||
|
dragHandle: undefined,
|
||||||
|
parentId: undefined,
|
||||||
|
sourcePosition: undefined,
|
||||||
|
targetPosition: undefined,
|
||||||
|
} as unknown as NodeProps<Node<NodeData>>
|
||||||
|
return render(
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<GroupNode {...props} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GroupNode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useCanvasStore.setState({ nodes: [], hideIp: false })
|
||||||
|
useThemeStore.setState({ activeTheme: 'default' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders label', () => {
|
||||||
|
const { getByText } = renderNode({ label: 'My Group' })
|
||||||
|
expect(getByText('My Group')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders 4 source handles (one per side)', () => {
|
||||||
|
const { container } = renderNode()
|
||||||
|
expect(container.querySelector('.react-flow__handle-top.source')).not.toBeNull()
|
||||||
|
expect(container.querySelector('.react-flow__handle-right.source')).not.toBeNull()
|
||||||
|
expect(container.querySelector('.react-flow__handle-bottom.source')).not.toBeNull()
|
||||||
|
expect(container.querySelector('.react-flow__handle-left.source')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders 4 target handles (one per side)', () => {
|
||||||
|
const { container } = renderNode()
|
||||||
|
expect(container.querySelector('.react-flow__handle-top.target')).not.toBeNull()
|
||||||
|
expect(container.querySelector('.react-flow__handle-right.target')).not.toBeNull()
|
||||||
|
expect(container.querySelector('.react-flow__handle-bottom.target')).not.toBeNull()
|
||||||
|
expect(container.querySelector('.react-flow__handle-left.target')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('source handles carry side-specific ids', () => {
|
||||||
|
const { container } = renderNode()
|
||||||
|
expect(container.querySelector('[data-handleid="group-top"]')).not.toBeNull()
|
||||||
|
expect(container.querySelector('[data-handleid="group-right"]')).not.toBeNull()
|
||||||
|
expect(container.querySelector('[data-handleid="group-bottom"]')).not.toBeNull()
|
||||||
|
expect(container.querySelector('[data-handleid="group-left"]')).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -102,6 +102,18 @@ describe('ProxmoxGroupNode', () => {
|
|||||||
expect(container.querySelector('.rounded-xl.border-2')).not.toBeNull()
|
expect(container.querySelector('.rounded-xl.border-2')).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('container mode renders bottom_handles snap points', () => {
|
||||||
|
const { container } = renderNode({ bottom_handles: 4 })
|
||||||
|
const sourceHandles = container.querySelectorAll('.react-flow__handle-bottom.source')
|
||||||
|
expect(sourceHandles.length).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('container mode default has single bottom handle', () => {
|
||||||
|
const { container } = renderNode({})
|
||||||
|
const sourceHandles = container.querySelectorAll('.react-flow__handle-bottom.source')
|
||||||
|
expect(sourceHandles.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('renders cluster handles in both modes', () => {
|
it('renders cluster handles in both modes', () => {
|
||||||
const { container: groupC } = renderNode({})
|
const { container: groupC } = renderNode({})
|
||||||
expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
|
expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { resolveNodeColors } from '@/utils/nodeColors'
|
|||||||
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
|
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
|
||||||
import { BrandIconPicker } from './BrandIconPicker'
|
import { BrandIconPicker } from './BrandIconPicker'
|
||||||
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
|
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
|
||||||
|
import { getValidParentTypes } from '@/utils/virtualEdgeParent'
|
||||||
|
|
||||||
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||||
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||||
@@ -48,17 +49,25 @@ const DEFAULT_DATA: Partial<NodeData> = {
|
|||||||
custom_icon: undefined,
|
custom_icon: undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ParentCandidate {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
type: NodeType
|
||||||
|
}
|
||||||
|
|
||||||
interface NodeModalProps {
|
interface NodeModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSubmit: (data: Partial<NodeData>) => void
|
onSubmit: (data: Partial<NodeData>) => void
|
||||||
initial?: Partial<NodeData>
|
initial?: Partial<NodeData>
|
||||||
title?: string
|
title?: string
|
||||||
|
parentCandidates?: ParentCandidate[]
|
||||||
|
currentNodeId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
||||||
// initial value is enough - no need for a reset effect.
|
// initial value is enough - no need for a reset effect.
|
||||||
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' }: NodeModalProps) {
|
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentCandidates = [], currentNodeId }: NodeModalProps) {
|
||||||
const merged = { ...DEFAULT_DATA, ...initial }
|
const merged = { ...DEFAULT_DATA, ...initial }
|
||||||
if (ZIGBEE_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
|
if (ZIGBEE_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
|
||||||
const [form, setForm] = useState<Partial<NodeData>>(merged)
|
const [form, setForm] = useState<Partial<NodeData>>(merged)
|
||||||
@@ -86,8 +95,17 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
setLabelError(false)
|
setLabelError(false)
|
||||||
const selectedType = (form.type ?? 'generic') as NodeType
|
const selectedType = (form.type ?? 'generic') as NodeType
|
||||||
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
|
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
|
||||||
|
const validParentTypes = getValidParentTypes(selectedType)
|
||||||
|
let safeParentId = form.parent_id
|
||||||
|
if (validParentTypes.length === 0) {
|
||||||
|
safeParentId = undefined
|
||||||
|
} else if (safeParentId) {
|
||||||
|
const parent = parentCandidates.find((n) => n.id === safeParentId)
|
||||||
|
if (!parent || !validParentTypes.includes(parent.type)) safeParentId = undefined
|
||||||
|
}
|
||||||
onSubmit({
|
onSubmit({
|
||||||
...form,
|
...form,
|
||||||
|
parent_id: safeParentId,
|
||||||
container_mode: canUseContainerMode ? !!form.container_mode : false,
|
container_mode: canUseContainerMode ? !!form.container_mode : false,
|
||||||
})
|
})
|
||||||
onClose()
|
onClose()
|
||||||
@@ -107,7 +125,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<Label className="text-xs text-muted-foreground">Type</Label>
|
<Label className="text-xs text-muted-foreground">Type</Label>
|
||||||
<Select value={form.type} onValueChange={(v) => {
|
<Select value={form.type} onValueChange={(v) => {
|
||||||
const t = v as NodeType
|
const t = v as NodeType
|
||||||
setForm((f) => ({ ...f, type: t, ...(ZIGBEE_TYPES.includes(t) ? { check_method: 'none' as CheckMethod } : {}) }))
|
setForm((f) => {
|
||||||
|
const next: Partial<NodeData> = { ...f, type: t }
|
||||||
|
if (ZIGBEE_TYPES.includes(t)) next.check_method = 'none' as CheckMethod
|
||||||
|
if (getValidParentTypes(t).length === 0) next.parent_id = undefined
|
||||||
|
return next
|
||||||
|
})
|
||||||
}}>
|
}}>
|
||||||
<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">
|
<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>
|
||||||
@@ -320,6 +343,40 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Parent Container */}
|
||||||
|
{(() => {
|
||||||
|
const childType = (form.type ?? 'generic') as NodeType
|
||||||
|
const validParentTypes = getValidParentTypes(childType)
|
||||||
|
if (validParentTypes.length === 0) return null
|
||||||
|
const validParents = parentCandidates.filter(
|
||||||
|
(n) => n.id !== currentNodeId && validParentTypes.includes(n.type),
|
||||||
|
)
|
||||||
|
if (validParents.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Parent Container</Label>
|
||||||
|
<Select
|
||||||
|
value={form.parent_id ?? 'none'}
|
||||||
|
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Parent container selector">
|
||||||
|
<SelectValue>
|
||||||
|
{form.parent_id
|
||||||
|
? (validParents.find((n) => n.id === form.parent_id)?.label ?? 'None')
|
||||||
|
: 'None'}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
|
<SelectItem value="none" className="text-sm">None</SelectItem>
|
||||||
|
{validParents.map((n) => (
|
||||||
|
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Container mode */}
|
{/* Container mode */}
|
||||||
{CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
|
{CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
|
||||||
<div className="flex items-center justify-between col-span-2 py-1">
|
<div className="flex items-center justify-between col-span-2 py-1">
|
||||||
|
|||||||
@@ -311,18 +311,48 @@ describe('NodeModal', () => {
|
|||||||
expect(screen.queryByText('Reset to defaults')).toBeNull()
|
expect(screen.queryByText('Reset to defaults')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Parent Container selector removed ────────────────────────────────
|
// ── Parent Container selector ─────────────────────────────────────────
|
||||||
|
|
||||||
it('does not render the Parent Container selector', () => {
|
it('does not render Parent Container for non-child types', () => {
|
||||||
renderModal({ initial: BASE })
|
renderModal({
|
||||||
|
initial: BASE,
|
||||||
|
parentCandidates: [{ id: 'p1', label: 'Proxmox', type: 'proxmox' }],
|
||||||
|
})
|
||||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not render Parent Container for docker_container either', () => {
|
it('does not render Parent Container when no valid candidates exist', () => {
|
||||||
renderModal({ initial: { ...BASE, type: 'docker_container' } })
|
renderModal({
|
||||||
|
initial: { ...BASE, type: 'docker_container' },
|
||||||
|
parentCandidates: [],
|
||||||
|
})
|
||||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders Parent Container for docker_container when docker_host candidate exists', () => {
|
||||||
|
renderModal({
|
||||||
|
initial: { ...BASE, type: 'docker_container' },
|
||||||
|
parentCandidates: [{ id: 'dh1', label: 'Docker Host', type: 'docker_host' }],
|
||||||
|
})
|
||||||
|
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Parent Container for docker_container when only an LXC candidate exists', () => {
|
||||||
|
renderModal({
|
||||||
|
initial: { ...BASE, type: 'docker_container' },
|
||||||
|
parentCandidates: [{ id: 'lxc1', label: 'My LXC', type: 'lxc' }],
|
||||||
|
})
|
||||||
|
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Parent Container for lxc when proxmox candidate exists', () => {
|
||||||
|
renderModal({
|
||||||
|
initial: { ...BASE, type: 'lxc' },
|
||||||
|
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox' }],
|
||||||
|
})
|
||||||
|
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
// ── Appearance ────────────────────────────────────────────────────────
|
// ── Appearance ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('renders 3 color swatch labels (border, background, icon)', () => {
|
it('renders 3 color swatch labels (border, background, icon)', () => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input'
|
|||||||
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'
|
||||||
import { primaryIp } from '@/utils/maskIp'
|
import { splitIps } from '@/utils/maskIp'
|
||||||
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
|
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||||
import type { Node } from '@xyflow/react'
|
import type { Node } from '@xyflow/react'
|
||||||
|
|
||||||
@@ -85,7 +85,8 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
const { data } = node
|
const { data } = node
|
||||||
const services = data.services ?? []
|
const services = data.services ?? []
|
||||||
const statusColor = STATUS_COLORS[data.status]
|
const statusColor = STATUS_COLORS[data.status]
|
||||||
const host = data.ip ?? data.hostname
|
const ipAddresses = data.ip ? splitIps(data.ip) : []
|
||||||
|
const host = ipAddresses[0] ?? data.hostname
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (confirm(`Delete "${data.label}"?`)) {
|
if (confirm(`Delete "${data.label}"?`)) {
|
||||||
@@ -223,12 +224,25 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data.ip && (
|
{ipAddresses.length > 0 && (
|
||||||
<div className="flex justify-between gap-2 items-baseline">
|
<div className="flex justify-between gap-2 items-start">
|
||||||
<span className="text-muted-foreground text-xs shrink-0">IP Address</span>
|
<span className="text-muted-foreground text-xs shrink-0">{ipAddresses.length > 1 ? 'IP Addresses' : 'IP Address'}</span>
|
||||||
<a href={`http://${primaryIp(data.ip)}`} target="_blank" rel="noopener noreferrer" className="text-xs font-mono text-[#00d4ff] hover:underline truncate flex items-center gap-1" title={data.ip}>
|
<div className="flex flex-wrap justify-end items-center gap-x-2 gap-y-1 max-w-[65%]">
|
||||||
{data.ip}<ExternalLink size={10} className="shrink-0" />
|
{ipAddresses.map((ip, index) => (
|
||||||
</a>
|
<span key={`${ip}-${index}`} className="inline-flex items-center shrink-0 whitespace-nowrap">
|
||||||
|
<a
|
||||||
|
href={`http://${ip}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs font-mono text-[#00d4ff] hover:underline inline-flex items-center gap-1"
|
||||||
|
title={ip}
|
||||||
|
>
|
||||||
|
{ip}
|
||||||
|
<ExternalLink size={10} className="shrink-0" />
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
||||||
|
|||||||
@@ -442,7 +442,23 @@ describe('DetailPanel', () => {
|
|||||||
it('displays full comma-separated IP string as link text', () => {
|
it('displays full comma-separated IP string as link text', () => {
|
||||||
setupStore({ ip: '192.168.1.10, 192.168.1.11' })
|
setupStore({ ip: '192.168.1.10, 192.168.1.11' })
|
||||||
render(<DetailPanel onEdit={vi.fn()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
expect(screen.getByText(/192\.168\.1\.10, 192\.168\.1\.11/)).toBeDefined()
|
expect(screen.getByRole('link', { name: /192\.168\.1\.10/ })).toBeDefined()
|
||||||
|
expect(screen.getByRole('link', { name: /192\.168\.1\.11/ })).toBeDefined()
|
||||||
|
expect(screen.queryByText(',')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders separate links for semicolon-separated IPs', () => {
|
||||||
|
setupStore({ ip: '192.168.1.10; 192.168.1.11' })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByRole('link', { name: /192\.168\.1\.10/ }).getAttribute('href')).toBe('http://192.168.1.10')
|
||||||
|
expect(screen.getByRole('link', { name: /192\.168\.1\.11/ }).getAttribute('href')).toBe('http://192.168.1.11')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders separate links for newline-separated IPs', () => {
|
||||||
|
setupStore({ ip: '192.168.1.10\n192.168.1.11' })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByRole('link', { name: /192\.168\.1\.10/ }).getAttribute('href')).toBe('http://192.168.1.10')
|
||||||
|
expect(screen.getByRole('link', { name: /192\.168\.1\.11/ }).getAttribute('href')).toBe('http://192.168.1.11')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,13 @@
|
|||||||
to { stroke-dashoffset: 0; }
|
to { stroke-dashoffset: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Disable React Flow's built-in edgeupdater entirely — HomelableEdge renders
|
||||||
|
its own interactive endpoint dots in EdgeLabelRenderer (above the node
|
||||||
|
layer) so the node Handle DOM cannot steal the reconnection drag. */
|
||||||
|
.react-flow__edgeupdater {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Homelable dark theme — always dark */
|
/* Homelable dark theme — always dark */
|
||||||
:root {
|
:root {
|
||||||
--background: #0d1117;
|
--background: #0d1117;
|
||||||
|
|||||||
@@ -497,6 +497,31 @@ describe('canvasStore', () => {
|
|||||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reconnectEdge swaps source/target and normalizes handles', () => {
|
||||||
|
useCanvasStore.setState((s) => ({
|
||||||
|
edges: [...s.edges, { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom', targetHandle: 'top' }],
|
||||||
|
}))
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
useCanvasStore.getState().reconnectEdge('e1', {
|
||||||
|
source: 'n1',
|
||||||
|
target: 'n3',
|
||||||
|
sourceHandle: 'bottom-2-t',
|
||||||
|
targetHandle: 'top-t',
|
||||||
|
})
|
||||||
|
const edge = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
|
||||||
|
expect(edge?.target).toBe('n3')
|
||||||
|
expect(edge?.source).toBe('n1')
|
||||||
|
expect(edge?.sourceHandle).toBe('bottom-2')
|
||||||
|
expect(edge?.targetHandle).toBe('top')
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reconnectEdge snapshots history for undo', () => {
|
||||||
|
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')], past: [] }))
|
||||||
|
useCanvasStore.getState().reconnectEdge('e1', { source: 'n1', target: 'n3', sourceHandle: null, targetHandle: null })
|
||||||
|
expect(useCanvasStore.getState().past.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('deleteEdge removes the edge and marks unsaved', () => {
|
it('deleteEdge removes the edge and marks unsaved', () => {
|
||||||
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2'), makeEdge('e2', 'n2', 'n3')] }))
|
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2'), makeEdge('e2', 'n2', 'n3')] }))
|
||||||
useCanvasStore.getState().markSaved()
|
useCanvasStore.getState().markSaved()
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ interface CanvasState {
|
|||||||
updateNode: (id: string, data: Partial<NodeData>) => void
|
updateNode: (id: string, data: Partial<NodeData>) => void
|
||||||
deleteNode: (id: string) => void
|
deleteNode: (id: string) => void
|
||||||
updateEdge: (id: string, data: Partial<EdgeData>) => void
|
updateEdge: (id: string, data: Partial<EdgeData>) => void
|
||||||
|
reconnectEdge: (id: string, connection: Connection) => void
|
||||||
deleteEdge: (id: string) => void
|
deleteEdge: (id: string) => void
|
||||||
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
|
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
|
||||||
setNodeZIndex: (id: string, zIndex: number) => void
|
setNodeZIndex: (id: string, zIndex: number) => void
|
||||||
@@ -292,6 +293,24 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
hasUnsavedChanges: true,
|
hasUnsavedChanges: true,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
reconnectEdge: (id, connection) =>
|
||||||
|
set((state) => ({
|
||||||
|
edges: state.edges.map((e) =>
|
||||||
|
e.id === id
|
||||||
|
? {
|
||||||
|
...e,
|
||||||
|
source: connection.source ?? e.source,
|
||||||
|
target: connection.target ?? e.target,
|
||||||
|
sourceHandle: normalizeHandle(connection.sourceHandle),
|
||||||
|
targetHandle: normalizeHandle(connection.targetHandle),
|
||||||
|
}
|
||||||
|
: e
|
||||||
|
),
|
||||||
|
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||||
|
future: [],
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
})),
|
||||||
|
|
||||||
deleteEdge: (id) =>
|
deleteEdge: (id) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
edges: state.edges.filter((e) => e.id !== id),
|
edges: state.edges.filter((e) => e.id !== id),
|
||||||
|
|||||||
@@ -55,6 +55,27 @@ describe('splitIps', () => {
|
|||||||
expect(splitIps('')).toEqual([])
|
expect(splitIps('')).toEqual([])
|
||||||
expect(splitIps(' ')).toEqual([])
|
expect(splitIps(' ')).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('splits on semicolons', () => {
|
||||||
|
expect(splitIps('10.0.0.1; 10.0.0.2')).toEqual(['10.0.0.1', '10.0.0.2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('splits on newlines', () => {
|
||||||
|
expect(splitIps('10.0.0.1\n10.0.0.2')).toEqual(['10.0.0.1', '10.0.0.2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('splits on mixed delimiters', () => {
|
||||||
|
expect(splitIps('10.0.0.1,10.0.0.2; 10.0.0.3\n10.0.0.4')).toEqual([
|
||||||
|
'10.0.0.1',
|
||||||
|
'10.0.0.2',
|
||||||
|
'10.0.0.3',
|
||||||
|
'10.0.0.4',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates repeated IPs', () => {
|
||||||
|
expect(splitIps('10.0.0.1, 10.0.0.1; 10.0.0.2')).toEqual(['10.0.0.1', '10.0.0.2'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('primaryIp', () => {
|
describe('primaryIp', () => {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { resolveVirtualEdgeParent, getValidParentTypes } from '../virtualEdgeParent'
|
||||||
|
|
||||||
|
describe('getValidParentTypes', () => {
|
||||||
|
it('returns container-mode types for lxc', () => {
|
||||||
|
expect(getValidParentTypes('lxc')).toEqual(['proxmox', 'vm', 'lxc', 'docker_host'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns container-mode types for vm', () => {
|
||||||
|
expect(getValidParentTypes('vm')).toEqual(['proxmox', 'vm', 'lxc', 'docker_host'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns docker_host/lxc/vm/proxmox for docker_container', () => {
|
||||||
|
expect(getValidParentTypes('docker_container')).toEqual(['docker_host', 'lxc', 'vm', 'proxmox'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty list for types that cannot have a parent', () => {
|
||||||
|
expect(getValidParentTypes('server')).toEqual([])
|
||||||
|
expect(getValidParentTypes('router')).toEqual([])
|
||||||
|
expect(getValidParentTypes('proxmox')).toEqual([])
|
||||||
|
expect(getValidParentTypes('docker_host')).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resolveVirtualEdgeParent', () => {
|
||||||
|
it('nests lxc under proxmox (container-mode parent)', () => {
|
||||||
|
const res = resolveVirtualEdgeParent(
|
||||||
|
{ id: 'lxc1', type: 'lxc' },
|
||||||
|
{ id: 'px1', type: 'proxmox' },
|
||||||
|
)
|
||||||
|
expect(res).toEqual({ childId: 'lxc1', parentId: 'px1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nests vm under proxmox regardless of edge direction', () => {
|
||||||
|
const res = resolveVirtualEdgeParent(
|
||||||
|
{ id: 'px1', type: 'proxmox' },
|
||||||
|
{ id: 'vm1', type: 'vm' },
|
||||||
|
)
|
||||||
|
expect(res).toEqual({ childId: 'vm1', parentId: 'px1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nests docker_container under docker_host', () => {
|
||||||
|
const res = resolveVirtualEdgeParent(
|
||||||
|
{ id: 'dc1', type: 'docker_container' },
|
||||||
|
{ id: 'dh1', type: 'docker_host' },
|
||||||
|
)
|
||||||
|
expect(res).toEqual({ childId: 'dc1', parentId: 'dh1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nests docker_container under lxc (reverse direction)', () => {
|
||||||
|
const res = resolveVirtualEdgeParent(
|
||||||
|
{ id: 'lxc1', type: 'lxc' },
|
||||||
|
{ id: 'dc1', type: 'docker_container' },
|
||||||
|
)
|
||||||
|
expect(res).toEqual({ childId: 'dc1', parentId: 'lxc1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nests docker_container under lxc (forward direction)', () => {
|
||||||
|
const res = resolveVirtualEdgeParent(
|
||||||
|
{ id: 'dc1', type: 'docker_container' },
|
||||||
|
{ id: 'lxc1', type: 'lxc' },
|
||||||
|
)
|
||||||
|
expect(res).toEqual({ childId: 'dc1', parentId: 'lxc1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nests docker_container under vm', () => {
|
||||||
|
const res = resolveVirtualEdgeParent(
|
||||||
|
{ id: 'dc1', type: 'docker_container' },
|
||||||
|
{ id: 'vm1', type: 'vm' },
|
||||||
|
)
|
||||||
|
expect(res).toEqual({ childId: 'dc1', parentId: 'vm1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nests docker_container under proxmox (reverse direction)', () => {
|
||||||
|
const res = resolveVirtualEdgeParent(
|
||||||
|
{ id: 'px1', type: 'proxmox' },
|
||||||
|
{ id: 'dc1', type: 'docker_container' },
|
||||||
|
)
|
||||||
|
expect(res).toEqual({ childId: 'dc1', parentId: 'px1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when docker_container links to unsupported parent type', () => {
|
||||||
|
const res = resolveVirtualEdgeParent(
|
||||||
|
{ id: 'dc1', type: 'docker_container' },
|
||||||
|
{ id: 'srv1', type: 'server' },
|
||||||
|
)
|
||||||
|
expect(res).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for unrelated type pairs', () => {
|
||||||
|
const res = resolveVirtualEdgeParent(
|
||||||
|
{ id: 'srv1', type: 'server' },
|
||||||
|
{ id: 'rt1', type: 'router' },
|
||||||
|
)
|
||||||
|
expect(res).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -25,16 +25,17 @@ function maskSingle(ip: string): string {
|
|||||||
*/
|
*/
|
||||||
export function maskIp(ip: string): string {
|
export function maskIp(ip: string): string {
|
||||||
if (!ip) return ip
|
if (!ip) return ip
|
||||||
return ip.split(',').map(maskSingle).join(', ')
|
return splitIps(ip).map(maskSingle).join(', ')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split a comma-separated IP string into an array of trimmed values.
|
* Split an IP string into trimmed values. Accepts comma, semicolon, or newline
|
||||||
* Empty string returns [].
|
* delimiters (or any combination). Duplicates removed, empty entries dropped.
|
||||||
*/
|
*/
|
||||||
export function splitIps(ip: string): string[] {
|
export function splitIps(ip: string): string[] {
|
||||||
if (!ip?.trim()) return []
|
if (!ip?.trim()) return []
|
||||||
return ip.split(',').map((s) => s.trim()).filter(Boolean)
|
const parts = ip.split(/[\n,;]+/).map((s) => s.trim()).filter(Boolean)
|
||||||
|
return parts.filter((v, i) => parts.indexOf(v) === i)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
export type NodeType = NodeData['type']
|
||||||
|
|
||||||
|
const CONTAINER_MODE_TYPES = new Set<NodeType>(['proxmox', 'vm', 'lxc', 'docker_host'])
|
||||||
|
const DOCKER_CONTAINER_PARENT_TYPES = new Set<NodeType>(['docker_host', 'lxc', 'vm', 'proxmox'])
|
||||||
|
|
||||||
|
export interface VirtualEdgeEndpoint {
|
||||||
|
id: string
|
||||||
|
type: NodeType
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParentAssignment {
|
||||||
|
childId: string
|
||||||
|
parentId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getValidParentTypes(childType: NodeType): NodeType[] {
|
||||||
|
if (childType === 'lxc' || childType === 'vm') {
|
||||||
|
return ['proxmox', 'vm', 'lxc', 'docker_host']
|
||||||
|
}
|
||||||
|
if (childType === 'docker_container') {
|
||||||
|
return ['docker_host', 'lxc', 'vm', 'proxmox']
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveVirtualEdgeParent(
|
||||||
|
source: VirtualEdgeEndpoint,
|
||||||
|
target: VirtualEdgeEndpoint,
|
||||||
|
): ParentAssignment | null {
|
||||||
|
const { type: srcType, id: srcId } = source
|
||||||
|
const { type: tgtType, id: tgtId } = target
|
||||||
|
|
||||||
|
if ((srcType === 'lxc' || srcType === 'vm') && CONTAINER_MODE_TYPES.has(tgtType)) {
|
||||||
|
return { childId: srcId, parentId: tgtId }
|
||||||
|
}
|
||||||
|
if (CONTAINER_MODE_TYPES.has(srcType) && (tgtType === 'lxc' || tgtType === 'vm')) {
|
||||||
|
return { childId: tgtId, parentId: srcId }
|
||||||
|
}
|
||||||
|
if (srcType === 'docker_container' && DOCKER_CONTAINER_PARENT_TYPES.has(tgtType)) {
|
||||||
|
return { childId: srcId, parentId: tgtId }
|
||||||
|
}
|
||||||
|
if (tgtType === 'docker_container' && DOCKER_CONTAINER_PARENT_TYPES.has(srcType)) {
|
||||||
|
return { childId: tgtId, parentId: srcId }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user