diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 041aed9..6504004 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -99,6 +99,12 @@ async def init_db() -> None: await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL") with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1") + with suppress(OperationalError): + await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN top_handles INTEGER NOT NULL DEFAULT 1") + with suppress(OperationalError): + await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN left_handles INTEGER NOT NULL DEFAULT 0") + with suppress(OperationalError): + await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN right_handles INTEGER NOT NULL DEFAULT 0") with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT") with suppress(OperationalError): diff --git a/backend/app/db/models.py b/backend/app/db/models.py index f749cc7..875ea5d 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -59,6 +59,9 @@ class Node(Base): width: Mapped[float | None] = mapped_column(Float, nullable=True) height: Mapped[float | None] = mapped_column(Float, nullable=True) bottom_handles: Mapped[int] = mapped_column(Integer, default=1) + top_handles: Mapped[int] = mapped_column(Integer, default=1) + left_handles: Mapped[int] = mapped_column(Integer, default=0) + right_handles: Mapped[int] = mapped_column(Integer, default=0) ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True) last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_scan: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) diff --git a/backend/app/schemas/canvas.py b/backend/app/schemas/canvas.py index 580daba..8e23860 100644 --- a/backend/app/schemas/canvas.py +++ b/backend/app/schemas/canvas.py @@ -34,6 +34,9 @@ class NodeSave(BaseModel): width: float | None = None height: float | None = None bottom_handles: int = 1 + top_handles: int = 1 + left_handles: int = 0 + right_handles: int = 0 pos_x: float = 0 pos_y: float = 0 diff --git a/backend/app/schemas/nodes.py b/backend/app/schemas/nodes.py index 4fb3597..23d3ea6 100644 --- a/backend/app/schemas/nodes.py +++ b/backend/app/schemas/nodes.py @@ -32,6 +32,9 @@ class NodeBase(BaseModel): width: float | None = None height: float | None = None bottom_handles: int = 1 + top_handles: int = 1 + left_handles: int = 0 + right_handles: int = 0 class NodeCreate(NodeBase): @@ -66,6 +69,9 @@ class NodeUpdate(BaseModel): width: float | None = None height: float | None = None bottom_handles: int | None = None + top_handles: int | None = None + left_handles: int | None = None + right_handles: int | None = None class NodeResponse(NodeBase): diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index d26e9c6..a41e3b4 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -51,6 +51,32 @@ async def test_save_canvas_creates_nodes_and_edges(client: AsyncClient, headers: assert canvas["viewport"] == {"x": 1, "y": 2, "zoom": 1.5} +async def test_save_canvas_round_trips_per_side_handles(client: AsyncClient, headers: dict): + # Regression (#243): top/left/right_handles must persist across save+reload, + # not just bottom_handles. + n1 = node_payload(label="Cam", type="camera", top_handles=2, bottom_handles=3, left_handles=1, right_handles=4) + res = await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 1}}, headers=headers) + assert res.status_code == 200 + + node = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0] + assert node["top_handles"] == 2 + assert node["bottom_handles"] == 3 + assert node["left_handles"] == 1 + assert node["right_handles"] == 4 + + +async def test_save_canvas_defaults_per_side_handles(client: AsyncClient, headers: dict): + # Nodes saved without the new fields fall back to top/bottom=1, left/right=0. + n1 = node_payload(label="Srv", type="server") + await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 1}}, headers=headers) + + node = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0] + assert node["top_handles"] == 1 + assert node["bottom_handles"] == 1 + assert node["left_handles"] == 0 + assert node["right_handles"] == 0 + + async def test_load_canvas_exposes_inventory_timestamps(client: AsyncClient, headers: dict): n1 = node_payload(label="Router", type="router") await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 857bb0f..925e99f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,7 @@ import { useEffect, useCallback, useRef, useState } from 'react' import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react' import { type Node } from '@xyflow/react' import { applyDagreLayout } from '@/utils/layout' -import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer' +import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, migrateClusterHandles, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer' import { generateUUID } from '@/utils/uuid' import { getCenteredPosition } from '@/utils/viewportCenter' import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent' @@ -28,6 +28,7 @@ import { ZwaveImportModal } from '@/components/zwave/ZwaveImportModal' import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal' import { TextModal, type TextFormData } from '@/components/modals/TextModal' import { ThemeModal } from '@/components/modals/ThemeModal' +import { CustomStyleModal } from '@/components/modals/CustomStyleModal' import { SearchModal } from '@/components/modals/SearchModal' import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal' import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal' @@ -41,7 +42,7 @@ import { canvasApi, designsApi, liveviewApi } from '@/api/client' import * as standaloneStorage from '@/utils/standaloneStorage' import { demoNodes, demoEdges } from '@/utils/demoData' import { useStatusPolling } from '@/hooks/useStatusPolling' -import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig } from '@/types' +import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig, NodeType } from '@/types' import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types' import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types' @@ -57,6 +58,7 @@ export default function App() { useStatusPolling() const [themeModalOpen, setThemeModalOpen] = useState(false) + const [styleEditorType, setStyleEditorType] = useState(null) const [searchOpen, setSearchOpen] = useState(false) const [scanHistoryOpen, setScanHistoryOpen] = useState(false) const [pendingModalOpen, setPendingModalOpen] = useState(false) @@ -125,8 +127,10 @@ export default function App() { .filter((n) => n.type === 'group' || n.container_mode === true) .map((n) => [n.id, true]) ) - const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)) - const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge) + const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles( + (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)), + (apiEdges as ApiEdge[]).map(deserializeApiEdge), + ) const savedTheme = res.data.viewport?.theme_id if (savedTheme) setTheme(savedTheme) if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) @@ -154,7 +158,8 @@ export default function App() { if (saved.custom_style) setCustomStyle(saved.custom_style) // Floor plans are backend-only; keep the store clear in standalone mode. setFloorMap(null) - loadCanvas(saved.nodes, saved.edges) + const migrated = migrateClusterHandles(saved.nodes, saved.edges) + loadCanvas(migrated.nodes, migrated.edges) } else { setFloorMap(null) loadCanvas(demoNodes, demoEdges) @@ -752,6 +757,7 @@ export default function App() { onSubmit={handleAddNode} title="Add Node" parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))} + onEditTypeStyle={setStyleEditorType} /> {/* key forces re-mount when editing a different node, resetting form state */} @@ -781,6 +787,7 @@ export default function App() { .map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode })) })()} currentNodeId={editNodeId ?? undefined} + onEditTypeStyle={setStyleEditorType} /> setPendingConnection(null)} onSubmit={handleEdgeConfirm} - initial={ - pendingConnection?.sourceHandle?.includes('cluster') || pendingConnection?.targetHandle?.includes('cluster') - ? { type: 'cluster' } - : undefined - } /> setThemeModalOpen(false)} /> + {/* Standalone Custom Style editor, opened from a node's Appearance + shortcut with that node's type preselected. */} + setStyleEditorType(null)} + /> + setSearchOpen(false)} diff --git a/frontend/src/components/LiveView.tsx b/frontend/src/components/LiveView.tsx index d74fc0f..ab35b23 100644 --- a/frontend/src/components/LiveView.tsx +++ b/frontend/src/components/LiveView.tsx @@ -27,7 +27,7 @@ import { useThemeStore } from '@/stores/themeStore' import { THEMES } from '@/utils/themes' 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 { deserializeApiNode, deserializeApiEdge, migrateClusterHandles, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer' import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter' import { liveviewApi } from '@/api/client' import * as standaloneStorage from '@/utils/standaloneStorage' @@ -88,10 +88,11 @@ function LiveViewCanvas() { const savedTheme = res.data.viewport?.theme_id if (savedTheme) setTheme(savedTheme) if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) - loadCanvas( + const migrated = migrateClusterHandles( (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)), (apiEdges as ApiEdge[]).map(deserializeApiEdge), ) + loadCanvas(migrated.nodes, migrated.edges) setViewState('ready') }) .catch((err) => { diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index 57741c7..3f8b91e 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -192,6 +192,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o colorMode={theme.colors.reactFlowColorMode} elevateNodesOnSelect={false} connectionMode={ConnectionMode.Loose} + connectionRadius={30} isValidConnection={isValidConnection} > ({ Handle: () => null, - Position: { Top: 'top', Bottom: 'bottom' }, + Position: { Top: 'top', Bottom: 'bottom', Left: 'left', Right: 'right' }, NodeResizer: () => null, useUpdateNodeInternals: () => vi.fn(), useViewport: () => ({ zoom: mockZoom }), @@ -59,14 +59,9 @@ vi.mock('@/utils/propertyIcons', () => ({ resolvePropertyIcon: (icon: string | null) => icon ? Server : null, })) -vi.mock('@/utils/handleUtils', () => ({ - bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`, - bottomHandlePositions: (count: number) => { - const c = typeof count === 'number' && count > 0 ? Math.floor(count) : 1 - return Array.from({ length: c }, (_, i) => ((i + 1) * 100) / (c + 1)) - }, - clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1, -})) +// handleUtils is pure — use the real implementation so the side-generic API +// (SIDES, handleId, handlePositions, sideHandleCount, …) stays in sync. +vi.mock('@/utils/handleUtils', async (importOriginal) => await importOriginal()) beforeEach(() => { mockZoom = 1 }) @@ -124,6 +119,14 @@ describe('BaseNode — borderWidth zoom scaling', () => { expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px') }) + // Regression: the node root must NOT clip its own bounds, otherwise the outer + // half of each connection handle (which sits centred on the edge) becomes + // non-interactive and the "magnet" snap area is halved. + it('root does not clip overflow (keeps handles grabbable)', () => { + const { container } = renderBaseNode({}) + expect((container.firstChild as HTMLElement).className).not.toContain('overflow-hidden') + }) + it('boxShadow glow ring uses borderWidth when selected + online at zoom=0.5', () => { mockZoom = 0.5 const node = makeNode({ status: 'online' }) @@ -176,15 +179,23 @@ describe('BaseNode — properties rendering', () => { }) }) -describe('BaseNode — port numbers (issue #20)', () => { - it('renders a number above each bottom handle when show_port_numbers is on', () => { +describe('BaseNode — port numbers (issue #20 / #243)', () => { + it('renders a number on each connection point when show_port_numbers is on', () => { + // bottom=4 → labels 1..4; top defaults to 1 → an extra "1" on top. renderBaseNode({ bottom_handles: 4, show_port_numbers: true }) - expect(screen.getByText('1')).toBeDefined() + expect(screen.getAllByText('1')).toHaveLength(2) // top slot 0 + bottom slot 0 expect(screen.getByText('2')).toBeDefined() expect(screen.getByText('3')).toBeDefined() expect(screen.getByText('4')).toBeDefined() }) + it('labels left/right connection points too when enabled', () => { + // top=1, bottom=1, left=2, right=0 → labels: two "1" (top+bottom) + "2" (left). + renderBaseNode({ bottom_handles: 1, left_handles: 2, show_port_numbers: true }) + expect(screen.getByText('2')).toBeDefined() + expect(screen.getAllByText('1')).toHaveLength(3) // top + bottom + left slot 0 + }) + it('does not render port numbers when show_port_numbers is off', () => { renderBaseNode({ bottom_handles: 4 }) expect(screen.queryByText('1')).toBeNull() @@ -192,8 +203,9 @@ describe('BaseNode — port numbers (issue #20)', () => { }) it('numbers match the handle count', () => { + // bottom=2 → 1,2; top default 1 adds one more "1"; no "3". renderBaseNode({ bottom_handles: 2, show_port_numbers: true }) - expect(screen.getByText('1')).toBeDefined() + expect(screen.getAllByText('1')).toHaveLength(2) expect(screen.getByText('2')).toBeDefined() expect(screen.queryByText('3')).toBeNull() }) diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index d3f356b..fbfda1b 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -1,5 +1,5 @@ import { createElement, useEffect, useMemo } from 'react' -import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react' +import { NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react' import { Cpu, MemoryStick, HardDrive, ExternalLink, type LucideIcon } from 'lucide-react' import type { NodeData } from '@/types' import { resolveNodeColors } from '@/utils/nodeColors' @@ -10,7 +10,8 @@ import { useThemeStore } from '@/stores/themeStore' import { THEMES } from '@/utils/themes' import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore' import { maskIp, primaryIp, splitIps } from '@/utils/maskIp' -import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils' +import { sideHandleCount } from '@/utils/handleUtils' +import { SideHandles } from './SideHandles' import { getServiceUrl } from '@/utils/serviceUrl' interface BaseNodeProps extends NodeProps> { @@ -24,7 +25,7 @@ function formatStorage(gb: number): string { export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: BaseNodeProps) { const updateNodeInternals = useUpdateNodeInternals() - useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals]) + useEffect(() => { updateNodeInternals(id) }, [data.top_handles, data.bottom_handles, data.left_handles, data.right_handles, id, updateNodeInternals]) const { zoom } = useViewport() const borderWidth = useMemo(() => Math.max(1, 1 / zoom), [zoom]) @@ -47,9 +48,15 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: const showLegacyHardware = !data.properties && data.show_hardware && (data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) + // Resolved per-side connection-point counts (missing field → side default). + const topCount = sideHandleCount(data, 'top') + const bottomCount = sideHandleCount(data, 'bottom') + const leftCount = sideHandleCount(data, 'left') + const rightCount = sideHandleCount(data, 'right') + return (
- - {/* Status dot — absolute to avoid affecting node auto-width */}
)} - {bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => { - const sourceId = bottomHandleId(idx) - const targetId = `${sourceId}-t` - return ( - - {data.show_port_numbers && ( - - {idx + 1} - - )} - - - - ) - })} +
) } diff --git a/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx b/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx index acbf241..8d30797 100644 --- a/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx +++ b/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx @@ -1,5 +1,5 @@ import { createElement, useEffect } from 'react' -import { Handle, Position, NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/react' +import { 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,47 +10,28 @@ 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' +import { SideHandles } from './SideHandles' export function ProxmoxGroupNode(props: NodeProps>) { const { id, data, selected } = props const updateNodeInternals = useUpdateNodeInternals() - useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals]) + useEffect(() => { updateNodeInternals(id) }, [data.top_handles, data.bottom_handles, data.left_handles, data.right_handles, id, updateNodeInternals]) const activeTheme = useThemeStore((s) => s.activeTheme) const hideIp = useCanvasStore((s) => s.hideIp) const theme = THEMES[activeTheme] const colors = resolveNodeColors(data, activeTheme) - // Render as a regular node when container mode is disabled + // Render as a regular node when container mode is disabled. Cluster links now + // use the configurable per-side connection points (see BaseNode / SideHandles). if (data.container_mode === false) { - const proxmoxAccent = theme.colors.nodeAccents.proxmox.border - return ( - <> - - - - - ) + return } const statusColor = theme.colors.statusColors[data.status] const isOnline = data.status === 'online' const glow = colors.border - const proxmoxAccent = theme.colors.nodeAccents.proxmox.border const resolvedIcon = resolveNodeIcon(Layers, data.custom_icon) return ( @@ -145,48 +126,11 @@ export function ProxmoxGroupNode(props: NodeProps>) {
- - - {bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => { - const sourceId = bottomHandleId(idx) - const targetId = `${sourceId}-t` - return ( - - - - - ) - })} - - {/* Cluster handles */} - - ) diff --git a/frontend/src/components/canvas/nodes/SideHandles.tsx b/frontend/src/components/canvas/nodes/SideHandles.tsx new file mode 100644 index 0000000..98c4592 --- /dev/null +++ b/frontend/src/components/canvas/nodes/SideHandles.tsx @@ -0,0 +1,84 @@ +import type { CSSProperties } from 'react' +import { Handle, Position } from '@xyflow/react' +import type { NodeData } from '@/types' +import { + SIDES, + handleId, + handlePositions, + isVerticalSide, + sideHandleCount, + type Side, +} from '@/utils/handleUtils' + +const POSITION: Record = { + top: Position.Top, + bottom: Position.Bottom, + left: Position.Left, + right: Position.Right, +} + +interface SideHandlesProps { + data: NodeData + handleBackground: string + handleBorder: string + /** Colour for the optional port-number labels. */ + labelColor: string + /** Which sides to render. Defaults to all four. */ + sides?: readonly Side[] + /** When true, render port-number labels if data.show_port_numbers is set. */ + showLabels?: boolean +} + +/** + * Renders the per-side React Flow handles (visible source + invisible target) + * for a node, spaced along each side's axis. Shared by BaseNode and the + * container-mode ProxmoxGroupNode so handle IDs stay identical across both. + */ +export function SideHandles({ + data, + handleBackground, + handleBorder, + labelColor, + sides = SIDES, + showLabels = false, +}: SideHandlesProps) { + return ( + <> + {sides.map((side) => { + const vertical = isVerticalSide(side) + return handlePositions(side, sideHandleCount(data, side)).map((pct, idx) => { + const sourceId = handleId(side, idx) + const targetId = `${sourceId}-t` + const offset: CSSProperties = vertical ? { top: `${pct}%` } : { left: `${pct}%` } + const labelStyle: CSSProperties = vertical + ? { top: `${pct}%`, [side]: 3, transform: 'translateY(-50%)' } + : { left: `${pct}%`, [side]: 3, transform: 'translateX(-50%)' } + return ( + + {showLabels && data.show_port_numbers && ( + + {idx + 1} + + )} + + + + ) + }) + })} + + ) +} diff --git a/frontend/src/components/canvas/nodes/__tests__/ProxmoxGroupNode.test.tsx b/frontend/src/components/canvas/nodes/__tests__/ProxmoxGroupNode.test.tsx index 156cd0d..d54ecab 100644 --- a/frontend/src/components/canvas/nodes/__tests__/ProxmoxGroupNode.test.tsx +++ b/frontend/src/components/canvas/nodes/__tests__/ProxmoxGroupNode.test.tsx @@ -114,10 +114,20 @@ describe('ProxmoxGroupNode', () => { expect(sourceHandles.length).toBe(1) }) - it('renders cluster handles in both modes', () => { + it('no longer renders the always-on cluster handles (#243)', () => { const { container: groupC } = renderNode({}) - expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2) + expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBe(0) const { container: nodeC } = renderNode({ container_mode: false }) - expect(nodeC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2) + expect(nodeC.querySelectorAll('[title="Same cluster"]').length).toBe(0) + }) + + it('renders configurable left/right handles only when counts > 0', () => { + const { container: none } = renderNode({ container_mode: false }) + expect(none.querySelectorAll('.react-flow__handle-left.source').length).toBe(0) + expect(none.querySelectorAll('.react-flow__handle-right.source').length).toBe(0) + + const { container: set } = renderNode({ container_mode: false, left_handles: 1, right_handles: 2 }) + expect(set.querySelectorAll('.react-flow__handle-left.source').length).toBe(1) + expect(set.querySelectorAll('.react-flow__handle-right.source').length).toBe(2) }) }) diff --git a/frontend/src/components/canvas/nodes/__tests__/SideHandles.test.tsx b/frontend/src/components/canvas/nodes/__tests__/SideHandles.test.tsx new file mode 100644 index 0000000..a0f5114 --- /dev/null +++ b/frontend/src/components/canvas/nodes/__tests__/SideHandles.test.tsx @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest' +import { render } from '@testing-library/react' +import { ReactFlowProvider } from '@xyflow/react' +import { SideHandles } from '../SideHandles' +import type { NodeData } from '@/types' + +function renderHandles(data: Partial = {}) { + const full: NodeData = { label: 'n', type: 'server', status: 'online', services: [], ...data } + return render( + + + + ) +} + +describe('SideHandles', () => { + it('renders a source + invisible target handle per slot', () => { + // default node: top=1, bottom=1, left=0, right=0 + const { container } = renderHandles({}) + expect(container.querySelectorAll('.react-flow__handle.source').length).toBe(2) + expect(container.querySelectorAll('.react-flow__handle.target').length).toBe(2) + }) + + it('target (magnet) handle hit area is large enough to snap onto (20px)', () => { + const { container } = renderHandles({}) + const target = container.querySelector('.react-flow__handle.target') as HTMLElement + expect(target.style.width).toBe('20px') + expect(target.style.height).toBe('20px') + expect(target.style.opacity).toBe('0') + }) + + it('renders configured per-side counts', () => { + const { container } = renderHandles({ top_handles: 2, left_handles: 3, right_handles: 1, bottom_handles: 1 }) + expect(container.querySelectorAll('.react-flow__handle.source').length).toBe(7) + }) +}) diff --git a/frontend/src/components/modals/CustomStyleModal.tsx b/frontend/src/components/modals/CustomStyleModal.tsx index c4d962f..2a44913 100644 --- a/frontend/src/components/modals/CustomStyleModal.tsx +++ b/frontend/src/components/modals/CustomStyleModal.tsx @@ -10,6 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { Button } from '@/components/ui/button' import { useThemeStore } from '@/stores/themeStore' import { useCanvasStore } from '@/stores/canvasStore' +import { clampHandles, sideDefault } from '@/utils/handleUtils' import { THEMES } from '@/utils/themes' import { applyOpacity } from '@/utils/colorUtils' import type { @@ -178,6 +179,33 @@ function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditor
+
+
Default connection points
+
New {NODE_TYPE_LABELS[nodeType]} nodes start with these (0–64 per side)
+
+ {([ + ['Top', 'top', 'topHandles'], + ['Right', 'right', 'rightHandles'], + ['Bottom', 'bottom', 'bottomHandles'], + ['Left', 'left', 'leftHandles'], + ] as const).map(([label, side, key]) => ( +
+ {label} + set(key, clampHandles(side, parseInt(e.target.value, 10)))} + aria-label={`${label} default connection points`} + className="w-16 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]" + /> +
+ ))} +
+
+ + onChange(clampHandles(side, Number(e.target.value)))} + className="w-9 h-full bg-transparent text-center text-xs font-mono text-foreground outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + + + {belowLabel && labelEl} + + ) +} + const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health'] const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host'] const ZIGBEE_TYPES: NodeType[] = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] @@ -71,11 +132,13 @@ interface NodeModalProps { title?: string parentCandidates?: ParentCandidate[] currentNodeId?: string + /** Shortcut: open the Custom Style editor for this node's type (canvas-wide). */ + onEditTypeStyle?: (type: NodeType) => void } // 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', parentCandidates = [], currentNodeId }: NodeModalProps) { +export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentCandidates = [], currentNodeId, onEditTypeStyle }: NodeModalProps) { const merged = { ...DEFAULT_DATA, ...initial } if (MESH_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none' const [form, setForm] = useState>(merged) @@ -94,6 +157,16 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' const set = (key: keyof NodeData, value: unknown) => setForm((f) => ({ ...f, [key]: value })) + const customStyle = useThemeStore((s) => s.customStyle) + // Effective default count for a side: the per-type style default if set, + // otherwise the intrinsic side default (top/bottom → 1, left/right → 0). + const effectiveSideDefault = (side: Side): number => { + const styleVal = customStyle.nodes[(form.type ?? 'generic') as NodeType]?.[SIDE_STYLE_KEY[side]] + return clampHandles(side, typeof styleVal === 'number' ? styleVal : sideDefault(side)) + } + const sideValue = (side: Side): number => + clampHandles(side, form[handleCountField(side)] ?? effectiveSideDefault(side)) + const handleSubmit = (e: React.FormEvent) => { e.preventDefault() if (!form.label?.trim()) { @@ -113,8 +186,17 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' const parent = parentCandidates.find((n) => n.id === safeParentId) if (!parent || !isValidParent(parent)) safeParentId = undefined } + const isGroupType = selectedType === 'groupRect' || selectedType === 'group' onSubmit({ ...form, + // Persist the resolved per-side counts so type-style defaults (and + // untouched sliders) are baked into the node. Skipped for group types. + ...(isGroupType ? {} : { + top_handles: sideValue('top'), + bottom_handles: sideValue('bottom'), + left_handles: sideValue('left'), + right_handles: sideValue('right'), + }), parent_id: safeParentId, container_mode: canUseContainerMode ? !!form.container_mode : false, }) @@ -123,13 +205,17 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' return ( !o && onClose()}> - + {title}
-
+
+ {/* ── LEFT column: identity & network ── */} +
+
Information
+
{/* Type + Icon on the same row */}
@@ -208,6 +294,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
+
{/* end Type/Icon subgrid */} {/* Inline icon picker - full width, shown below the type+icon row */} {iconPickerOpen && ( @@ -305,6 +392,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' {labelError &&

Label is required

}
+
{/* Hostname */}
@@ -327,7 +415,9 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' /> comma-separated
+
{/* end Hostname/IP subgrid */} +
{/* Check method — hidden for zigbee nodes (always none/online) */} {!ZIGBEE_TYPES.includes((form.type ?? '') as NodeType) && (
@@ -357,6 +447,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' />
)} +
{/* end Check method/target subgrid */} {/* Parent Container */} {(() => { @@ -423,7 +514,22 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
)} + {/* Notes */} +
+ +