Compare commits

...

21 Commits

Author SHA1 Message Date
Pouzor f8635df1c5 bump: version 2.1.1 2026-05-17 00:30:21 +02:00
Rémy 1cc9b7c52f Merge pull request #155 from Pouzor/fix/liveview-parity
fix(liveview): nest docker children, apply theme & custom style
2026-05-16 22:28:38 +02:00
Pouzor fdf2b1f2be chore(liveview): include setTheme/setCustomStyle in effect deps 2026-05-16 21:52:52 +02:00
Pouzor 5630e7d202 fix(liveview): nest docker children, apply theme & custom style
- Live view rebuilt parent map from proxmox/group only, so docker_container
  (and any vm/lxc with container_mode=true) lost parentId and rendered
  outside its host. Match App.tsx logic: include any node with
  container_mode=true.
- Live view ignored viewport.theme_id and custom_style on load, so the
  shared canvas always rendered in default theme. Apply both like the
  authenticated canvas does.
- Backend /liveview response now includes custom_style from CanvasState.

Adds regression tests on both ends.
2026-05-16 21:49:55 +02:00
Pouzor be705f0cb9 bump: version 2.1.0 2026-05-16 18:18:46 +02:00
Rémy 1790d24ee7 Merge pull request #154 from Pouzor/feat/docker-container-lxc-parent
feat(node-modal): sanitize parent_id on type change and exclude descendants
2026-05-16 18:03:31 +02:00
Pouzor 8b97f578c6 feat(node-modal): sanitize parent_id on type change and exclude descendants
- App.tsx: filter descendants of edited node from parentCandidates to
  prevent picking a child as parent (would create a cycle).
- NodeModal.tsx: clear parent_id when switching to a type with no valid
  parents, and revalidate parent_id on submit against the type's allowlist.
- Tests: cover getValidParentTypes for lxc/vm/docker_container and types
  that disallow parents.

ha-relevant: yes
2026-05-16 17:41:58 +02:00
Rémy 48e7b3c690 Merge pull request #153 from Pouzor/feat/docker-container-lxc-parent
feat(docker): allow LXC as parent for docker_container
2026-05-16 17:11:50 +02:00
Pouzor 3a4df578ec feat(node-modal): restore Parent Container selector for child types
Adds back the Parent Container selector in NodeModal, shown when:
- child type is lxc or vm (parents: proxmox/vm/lxc/docker_host)
- child type is docker_container (parents: docker_host/lxc/vm/proxmox)

Selector hidden when no valid candidate node exists on the canvas.
Centralizes the parent-type allowlist in getValidParentTypes
(shared with the virtual-edge auto-parent helper).

ha-relevant: yes
2026-05-16 17:08:06 +02:00
Pouzor 43426e96ac feat(docker): allow vm and proxmox as parent for docker_container
Extend docker_container parent rule to accept vm and proxmox nodes
in addition to docker_host and lxc, covering nested virtualization
topologies (Docker on a VM, Docker directly on Proxmox host).

ha-relevant: yes
2026-05-16 16:47:14 +02:00
Pouzor 280d5a2ae1 feat(docker): allow LXC as parent for docker_container
Previously docker_container could only attach to a docker_host via
virtual edge. Extend the rule so an LXC node can also act as parent,
enabling docker_container nesting under LXC without requiring an
intermediate docker_host.

Extracts the virtual-edge parent rule into a pure helper
(resolveVirtualEdgeParent) with unit tests covering all type pairs.

ha-relevant: yes
2026-05-15 22:54:51 +02:00
Rémy cdc6091bd0 Merge pull request #152 from Pouzor/feat/group-edge-handles
feat(group): add 4 side handles for edge connections
2026-05-15 21:19:31 +02:00
Pouzor 14597ab86f feat(group): add 4 side handles for edge connections
Group nodes can now be source/target of edges via snap points on
each side (top/right/bottom/left). Each side exposes a source handle
plus an invisible target overlay, matching the pattern used by
BaseNode.
2026-05-15 21:12:10 +02:00
Rémy 14719a5c39 Merge pull request #136 from findthelorax/feat/multiple-ips
feat: multiple ips in detail panel
2026-05-15 14:40:47 +02:00
Pouzor ea451885af refactor(ip-parsing): centralize multi-delim splitting in splitIps
Extend splitIps to accept comma/semicolon/newline delimiters with dedupe,
and reuse it in DetailPanel for both the IP list and the service-URL host.
Fixes ServiceBadge links pointing at concatenated string when IPs were
entered with ';' or newline separators. Adds tests for new delimiters.
2026-05-15 13:36:15 +02:00
findthelorax 9cd93ef294 updated tests for multiple ips 2026-05-15 11:43:09 +02:00
findthelorax 8d937b2a0d removed comma and allow multiple in a row 2026-05-15 11:31:25 +02:00
findthelorax 60ea0fa11b made multiple ips all clickable separately 2026-05-15 11:31:25 +02:00
Rémy ff0d33128c Merge pull request #150 from Pouzor/feat/edge-endpoint-reconnect
feat(canvas): edge endpoint reconnect + proxmox container snap points
2026-05-15 11:10:47 +02:00
Pouzor 17d4ddd070 feat(canvas): edge endpoint reconnect + proxmox container snap points
- Selected edge shows interactive dots at both endpoints; drag onto any
  handle (different snap point or different node) to reconnect. Edge
  follows the cursor live during drag; drop on empty space leaves edge
  unchanged.
- Endpoint dots render in EdgeLabelRenderer above the node layer and walk
  the elementsFromPoint stack so the underlying handle wins over the
  dragged dot itself.
- ProxmoxGroupNode in container mode now renders bottom_handles snap
  points (was always single 'bottom' handle), matching BaseNode.
- New canvasStore.reconnectEdge action: swaps source/target/handles via
  normalizeHandle, snapshots history, marks dirty.
2026-05-15 03:05:38 +02:00
Rémy 4795dc40b8 Merge pull request #149 from Pouzor/feat/homepage-widget-stats
feat(api): stats endpoint for gethomepage widget
2026-05-15 01:16:48 +02:00
25 changed files with 821 additions and 52 deletions
+1 -1
View File
@@ -1 +1 @@
2.0.3
2.1.1
+2
View File
@@ -34,8 +34,10 @@ async def liveview_canvas(
edges = (await db.execute(select(Edge))).scalars().all()
state = await db.get(CanvasState, 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(
nodes=[NodeResponse.model_validate(n) for n in nodes],
edges=[EdgeResponse.model_validate(e) for e in edges],
viewport=viewport,
custom_style=custom_style,
)
+22
View File
@@ -112,6 +112,28 @@ async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
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 ─────────────────────────────────────────────────
@pytest.mark.asyncio
+65 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "2.0.3",
"version": "2.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "2.0.3",
"version": "2.1.1",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4",
@@ -2520,6 +2520,9 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2534,6 +2537,9 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2548,6 +2554,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2562,6 +2571,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2576,6 +2588,9 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2590,6 +2605,9 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2604,6 +2622,9 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2618,6 +2639,9 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2632,6 +2656,9 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2646,6 +2673,9 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2660,6 +2690,9 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2674,6 +2707,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2688,6 +2724,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2936,6 +2975,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2953,6 +2995,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2970,6 +3015,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2987,6 +3035,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6884,6 +6935,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6905,6 +6959,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6926,6 +6983,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6947,6 +7007,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "2.0.3",
"version": "2.1.1",
"type": "module",
"scripts": {
"dev": "vite",
+29 -11
View File
@@ -4,6 +4,7 @@ import { type Node } from '@xyflow/react'
import { applyDagreLayout } from '@/utils/layout'
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
import { generateUUID } from '@/utils/uuid'
import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
import { generateMarkdownTable } from '@/utils/exportMarkdown'
import { ExportModal } from '@/components/modals/ExportModal'
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_STORAGE_KEY = 'homelable_canvas'
const CONTAINER_MODE_TYPES = new Set<NodeData['type']>(['proxmox', 'vm', 'lxc', 'docker_host'])
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()
@@ -450,16 +450,14 @@ export default function App() {
if (edgeData.type === 'virtual') {
const src = nodes.find((n) => n.id === pendingConnection.source)
const tgt = nodes.find((n) => n.id === pendingConnection.target)
const srcType = src?.data.type as NodeData['type']
const tgtType = tgt?.data.type as NodeData['type']
if ((srcType === 'lxc' || srcType === 'vm') && CONTAINER_MODE_TYPES.has(tgtType)) {
updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
} else if (CONTAINER_MODE_TYPES.has(srcType) && (tgtType === 'lxc' || tgtType === 'vm')) {
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
} else if (srcType === 'docker_container' && tgtType === 'docker_host') {
updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
} else if (tgtType === 'docker_container' && srcType === 'docker_host') {
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
if (src && tgt) {
const assignment = resolveVirtualEdgeParent(
{ id: src.id, type: src.data.type as NodeData['type'] },
{ id: tgt.id, type: tgt.data.type as NodeData['type'] },
)
if (assignment) {
updateNode(assignment.childId, { parent_id: assignment.parentId })
}
}
}
setPendingConnection(null)
@@ -552,6 +550,7 @@ export default function App() {
onClose={() => setAddNodeOpen(false)}
onSubmit={handleAddNode}
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 */}
@@ -562,6 +561,25 @@ export default function App() {
onSubmit={handleUpdateNode}
initial={editNode?.data}
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
+9 -4
View File
@@ -29,7 +29,7 @@ import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
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 STORAGE_KEY = 'homelable_canvas'
@@ -40,6 +40,8 @@ function LiveViewCanvas() {
const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
const { fitView } = useReactFlow()
const activeTheme = useThemeStore((s) => s.activeTheme)
const setTheme = useThemeStore((s) => s.setTheme)
const setCustomStyle = useThemeStore((s) => s.setCustomStyle)
const theme = THEMES[activeTheme]
// Derive initial view state synchronously (avoids calling setState inside an effect):
// - standalone → always ready (localStorage, no key required)
@@ -73,9 +75,12 @@ function LiveViewCanvas() {
const { nodes: apiNodes, edges: apiEdges } = res.data
const proxmoxMap = new Map<string, boolean>(
(apiNodes as ApiNode[])
.filter((n: ApiNode) => n.type === 'proxmox' || n.type === 'group')
.map((n: ApiNode) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
.filter((n: ApiNode) => n.type === 'group' || n.container_mode === true)
.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(
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
@@ -87,7 +92,7 @@ function LiveViewCanvas() {
const detail: string = err.response.data?.detail ?? ''
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
})
}, [loadCanvas])
}, [loadCanvas, setTheme, setCustomStyle])
useEffect(() => {
if (!fitViewPending || nodes.length === 0) return
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore'
// ── Mock heavy dependencies ────────────────────────────────────────────────
@@ -124,6 +125,55 @@ describe('LiveView (non-standalone)', () => {
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 ───────────────────────────────────────────────
it('does not show any Access Denied when key is valid', async () => {
@@ -11,6 +11,8 @@ vi.mock('@xyflow/react', () => ({
NodeResizer: ({ isVisible }: { isVisible: boolean }) => (
<div data-testid="node-resizer" data-visible={isVisible} />
),
Handle: () => null,
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
useReactFlow: () => ({}),
}))
+138 -2
View File
@@ -1,4 +1,4 @@
import { useCallback } from 'react'
import { useCallback, useState } from 'react'
import {
BaseEdge,
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 ──────────────────────────────────────────────────────
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 theme = THEMES[activeTheme]
const sourceType = useStore((s) => s.nodeLookup.get(source)?.type)
@@ -311,6 +415,38 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
</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 */}
{selected && waypoints.map((wp, idx) => {
const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1]
@@ -1,11 +1,15 @@
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 { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { STATUS_COLORS, type NodeData } from '@/types'
export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
const { nodes, updateNode, snapshotHistory } = useCanvasStore()
const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme]
const showBorder = data.custom_colors?.show_border !== false
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 }}
/>
{/* 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 */}
{isVisible && (
<div
@@ -1,5 +1,5 @@
import { createElement } from 'react'
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { createElement, useEffect } from 'react'
import { Handle, Position, NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/react'
import { Layers } from 'lucide-react'
import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
@@ -10,10 +10,13 @@ import { useCanvasStore } from '@/stores/canvasStore'
import { maskIp, splitIps } from '@/utils/maskIp'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { bottomHandleId, bottomHandlePositions } from '@/utils/handleUtils'
import { BaseNode } from './BaseNode'
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 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 }}
/>
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
<Handle
type="source"
position={Position.Bottom}
id="bottom"
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/>
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} />
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
const sourceId = bottomHandleId(idx)
const targetId = `${sourceId}-t`
return (
<span key={sourceId}>
<Handle
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 */}
<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()
})
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', () => {
const { container: groupC } = renderNode({})
expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
+59 -2
View File
@@ -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 { BrandIconPicker } from './BrandIconPicker'
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
import { getValidParentTypes } from '@/utils/virtualEdgeParent'
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
@@ -48,17 +49,25 @@ const DEFAULT_DATA: Partial<NodeData> = {
custom_icon: undefined,
}
interface ParentCandidate {
id: string
label: string
type: NodeType
}
interface NodeModalProps {
open: boolean
onClose: () => void
onSubmit: (data: Partial<NodeData>) => void
initial?: Partial<NodeData>
title?: string
parentCandidates?: ParentCandidate[]
currentNodeId?: string
}
// NodeModal is always mounted with a key that changes on open/edit, so useState
// 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 }
if (ZIGBEE_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
const [form, setForm] = useState<Partial<NodeData>>(merged)
@@ -86,8 +95,17 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
setLabelError(false)
const selectedType = (form.type ?? 'generic') as NodeType
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({
...form,
parent_id: safeParentId,
container_mode: canUseContainerMode ? !!form.container_mode : false,
})
onClose()
@@ -107,7 +125,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<Label className="text-xs text-muted-foreground">Type</Label>
<Select value={form.type} onValueChange={(v) => {
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">
<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>
)}
{/* 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_TYPES.includes((form.type ?? 'generic') as NodeType) && (
<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()
})
// ── Parent Container selector removed ────────────────────────────────
// ── Parent Container selector ─────────────────────────────────────────
it('does not render the Parent Container selector', () => {
renderModal({ initial: BASE })
it('does not render Parent Container for non-child types', () => {
renderModal({
initial: BASE,
parentCandidates: [{ id: 'p1', label: 'Proxmox', type: 'proxmox' }],
})
expect(screen.queryByText('Parent Container')).toBeNull()
})
it('does not render Parent Container for docker_container either', () => {
renderModal({ initial: { ...BASE, type: 'docker_container' } })
it('does not render Parent Container when no valid candidates exist', () => {
renderModal({
initial: { ...BASE, type: 'docker_container' },
parentCandidates: [],
})
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 ────────────────────────────────────────────────────────
it('renders 3 color swatch labels (border, background, icon)', () => {
+22 -8
View File
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input'
import { useCanvasStore } from '@/stores/canvasStore'
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
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 type { Node } from '@xyflow/react'
@@ -85,7 +85,8 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
const { data } = node
const services = data.services ?? []
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 = () => {
if (confirm(`Delete "${data.label}"?`)) {
@@ -223,12 +224,25 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
</a>
</div>
)}
{data.ip && (
<div className="flex justify-between gap-2 items-baseline">
<span className="text-muted-foreground text-xs shrink-0">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}>
{data.ip}<ExternalLink size={10} className="shrink-0" />
</a>
{ipAddresses.length > 0 && (
<div className="flex justify-between gap-2 items-start">
<span className="text-muted-foreground text-xs shrink-0">{ipAddresses.length > 1 ? 'IP Addresses' : 'IP Address'}</span>
<div className="flex flex-wrap justify-end items-center gap-x-2 gap-y-1 max-w-[65%]">
{ipAddresses.map((ip, index) => (
<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>
)}
{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', () => {
setupStore({ ip: '192.168.1.10, 192.168.1.11' })
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')
})
})
+7
View File
@@ -11,6 +11,13 @@
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 */
:root {
--background: #0d1117;
@@ -497,6 +497,31 @@ describe('canvasStore', () => {
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', () => {
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2'), makeEdge('e2', 'n2', 'n3')] }))
useCanvasStore.getState().markSaved()
+19
View File
@@ -44,6 +44,7 @@ interface CanvasState {
updateNode: (id: string, data: Partial<NodeData>) => void
deleteNode: (id: string) => void
updateEdge: (id: string, data: Partial<EdgeData>) => void
reconnectEdge: (id: string, connection: Connection) => void
deleteEdge: (id: string) => void
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
setNodeZIndex: (id: string, zIndex: number) => void
@@ -292,6 +293,24 @@ export const useCanvasStore = create<CanvasState>((set) => ({
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) =>
set((state) => ({
edges: state.edges.filter((e) => e.id !== id),
@@ -55,6 +55,27 @@ describe('splitIps', () => {
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', () => {
@@ -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()
})
})
+5 -4
View File
@@ -25,16 +25,17 @@ function maskSingle(ip: string): string {
*/
export function maskIp(ip: string): string {
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.
* Empty string returns [].
* Split an IP string into trimmed values. Accepts comma, semicolon, or newline
* delimiters (or any combination). Duplicates removed, empty entries dropped.
*/
export function splitIps(ip: string): string[] {
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)
}
/**
+48
View File
@@ -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
}