diff --git a/frontend/src/components/canvas/edges/index.tsx b/frontend/src/components/canvas/edges/index.tsx
index 3a6c24d..2a4fb8b 100644
--- a/frontend/src/components/canvas/edges/index.tsx
+++ b/frontend/src/components/canvas/edges/index.tsx
@@ -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 (
+
+ )
+}
+
// ── Main edge component ──────────────────────────────────────────────────────
-export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps>) {
+export function HomelableEdge({ id, source, target, sourceHandleId, targetHandleId, sourceX: rawSourceX, sourceY: rawSourceY, targetX: rawTargetX, targetY: rawTargetY, sourcePosition, targetPosition, data, selected }: EdgeProps>) {
+ 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
)}
+ {/* Endpoint dots — visual indicators for reconnection targets */}
+ {selected && (
+ <>
+
+
+ >
+ )}
+
{/* Existing waypoint drag handles */}
{selected && waypoints.map((wp, idx) => {
const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1]
diff --git a/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx b/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx
index 1286164..acbf241 100644
--- a/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx
+++ b/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx
@@ -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>) {
- 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>) {
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/>
-
-
+ {bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
+ const sourceId = bottomHandleId(idx)
+ const targetId = `${sourceId}-t`
+ return (
+
+
+
+
+ )
+ })}
{/* Cluster handles */}
{
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)
diff --git a/frontend/src/index.css b/frontend/src/index.css
index ea075ec..419df7e 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -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;
diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts
index 9918d28..66603e7 100644
--- a/frontend/src/stores/__tests__/canvasStore.test.ts
+++ b/frontend/src/stores/__tests__/canvasStore.test.ts
@@ -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()
diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts
index 25898fc..8f48d99 100644
--- a/frontend/src/stores/canvasStore.ts
+++ b/frontend/src/stores/canvasStore.ts
@@ -44,6 +44,7 @@ interface CanvasState {
updateNode: (id: string, data: Partial) => void
deleteNode: (id: string) => void
updateEdge: (id: string, data: Partial) => 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((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),