From 62f674b15df23ce75f13521ec7c3563c0eae6112 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 18 Apr 2026 21:09:49 +0200 Subject: [PATCH 1/8] fix: extract handleNodeDoubleClick into useCallback and add tests Replace inline arrow with named useCallback handler to avoid creating a new fn ref on every render. Add two CanvasContainer tests covering the double-click callback and the no-op path when prop is omitted. --- frontend/src/App.tsx | 6 +++++- .../canvas/__tests__/CanvasContainer.test.tsx | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c3ff8df..fdd1b1c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -343,6 +343,10 @@ export default function App() { setEditEdgeId(edge.id) }, []) + const handleNodeDoubleClick = useCallback((node: Node) => { + handleEditNode(node.id) + }, [handleEditNode]) + const handleEdgeUpdate = useCallback((data: EdgeData) => { if (!editEdgeId) return snapshotHistory() @@ -400,7 +404,7 @@ export default function App() { handleEditNode(node.id)} + onNodeDoubleClick={handleNodeDoubleClick} onNodeDragStart={snapshotHistory} onOpenPending={(deviceId) => { setHighlightPendingId(undefined) diff --git a/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx b/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx index a80ced1..332e983 100644 --- a/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx +++ b/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx @@ -104,6 +104,24 @@ describe('CanvasContainer', () => { }).not.toThrow() }) + // ── Node double-click ───────────────────────────────────────────────────── + + it('calls onNodeDoubleClick prop when a node is double-clicked', () => { + const onNodeDoubleClick = vi.fn() + const node = makeNode('n1') + render() + ;(rfProps.onNodeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, node) + expect(onNodeDoubleClick).toHaveBeenCalledWith(node) + }) + + it('does not throw when onNodeDoubleClick is not provided', () => { + const node = makeNode('n1') + render() + expect(() => { + ;(rfProps.onNodeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, node) + }).not.toThrow() + }) + // ── Connection validation ───────────────────────────────────────────────── it('isValidConnection returns false for self-connections', () => { From 12f46715c10455692c9da63de5f00c5657ec0d4d Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 18 Apr 2026 21:36:09 +0200 Subject: [PATCH 2/8] fix: bump python-multipart, pytest, pytest-asyncio to fix CVEs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - python-multipart 0.0.22 → 0.0.26 (CVE-2026-40347) - pytest 8.3.3 → 9.0.3 (CVE-2025-71176) - pytest-asyncio 0.24.0 → 0.26.0 (pytest 9 compat) --- backend/requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 792a5bc..e30f7bc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,7 +9,7 @@ pydantic-settings==2.5.2 python-jose[cryptography]==3.5.0 passlib[bcrypt]==1.7.4 bcrypt==4.0.1 -python-multipart==0.0.22 +python-multipart==0.0.26 apscheduler==3.10.4 python-nmap==0.7.1 pyyaml==6.0.2 @@ -21,6 +21,6 @@ zeroconf==0.131.0 # Dev ruff==0.6.9 mypy==1.11.2 -pytest==8.3.3 -pytest-asyncio==0.24.0 +pytest==9.0.3 +pytest-asyncio==0.26.0 pytest-cov==5.0.0 From 72d5a51b4464ef28f70ce73f8bbd775636be4415 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 18 Apr 2026 21:45:10 +0200 Subject: [PATCH 3/8] fix: bump pytest-asyncio to 1.0.0 for pytest 9 compatibility pytest-asyncio 0.26.0 requires pytest<9; 1.0.0 supports pytest 9.x --- backend/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index e30f7bc..48ec18f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -22,5 +22,5 @@ zeroconf==0.131.0 ruff==0.6.9 mypy==1.11.2 pytest==9.0.3 -pytest-asyncio==0.26.0 +pytest-asyncio==1.0.0 pytest-cov==5.0.0 From a7b244502e758e8959cdae5bcff61862874bbafb Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 18 Apr 2026 21:52:32 +0200 Subject: [PATCH 4/8] fix: bump pytest-asyncio to 1.3.0 (first version supporting pytest 9) --- backend/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 48ec18f..3bd01ad 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -22,5 +22,5 @@ zeroconf==0.131.0 ruff==0.6.9 mypy==1.11.2 pytest==9.0.3 -pytest-asyncio==1.0.0 +pytest-asyncio==1.3.0 pytest-cov==5.0.0 From 3a5cb0de21aa6fe82cc79a77d1a8df34c0881a28 Mon Sep 17 00:00:00 2001 From: findthelorax Date: Thu, 16 Apr 2026 11:44:24 -0400 Subject: [PATCH 5/8] feature: extend zoom out and border width math --- frontend/src/components/canvas/CanvasContainer.tsx | 2 ++ frontend/src/components/canvas/nodes/BaseNode.tsx | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index cee03e8..1daad1d 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -95,6 +95,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o panActivationKeyCode="Space" selectionMode={SelectionMode.Partial} multiSelectionKeyCode={['Meta', 'Control']} + minZoom={0.25} + maxZoom={2.5} snapToGrid snapGrid={[8, 8]} colorMode={theme.colors.reactFlowColorMode} diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index 0bacd34..5be7dcf 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -1,5 +1,5 @@ import { createElement, useEffect } from 'react' -import { Handle, Position, NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/react' +import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react' import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react' import type { NodeData } from '@/types' import { resolveNodeColors } from '@/utils/nodeColors' @@ -24,6 +24,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: const updateNodeInternals = useUpdateNodeInternals() useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals]) + const { zoom } = useViewport() + const borderWidth = Math.max(1, 1 / zoom) + const activeTheme = useThemeStore((s) => s.activeTheme) const hideIp = useCanvasStore((s) => s.hideIp) const theme = THEMES[activeTheme] @@ -44,7 +47,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: style={{ background: colors.background, borderColor: colors.border, - borderWidth: 1, + borderWidth, boxShadow: isOnline && selected ? `0 0 0 1px ${colors.border}, 0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a` : isOnline From 35c3d00f1776174c92ad7d2d621b26c501bc47fa Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 18 Apr 2026 22:34:03 +0200 Subject: [PATCH 6/8] fix: memoize borderWidth and sync boxShadow glow ring with zoom - Wrap borderWidth in useMemo to avoid re-computing on unrelated renders - Replace hardcoded 1px in boxShadow with borderWidth so glow ring matches border thickness at all zoom levels - Add useViewport mock + 4 zoom-scaling tests to BaseNode.test.tsx --- .../canvas/__tests__/BaseNode.test.tsx | 34 +++++++++++++++++++ .../src/components/canvas/nodes/BaseNode.tsx | 8 ++--- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx index 8a2371b..3c5cf6c 100644 --- a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx +++ b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx @@ -5,11 +5,14 @@ import { BaseNode } from '../nodes/BaseNode' import type { NodeData } from '@/types' import type { Node } from '@xyflow/react' +let mockZoom = 1 + vi.mock('@xyflow/react', () => ({ Handle: () => null, Position: { Top: 'top', Bottom: 'bottom' }, NodeResizer: () => null, useUpdateNodeInternals: () => vi.fn(), + useViewport: () => ({ zoom: mockZoom }), })) vi.mock('@/stores/themeStore', () => ({ @@ -85,6 +88,37 @@ function renderBaseNode(data: Partial) { ) } +describe('BaseNode — borderWidth zoom scaling', () => { + it('borderWidth is 1px at zoom=1', () => { + mockZoom = 1 + const { container } = renderBaseNode({}) + expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px') + }) + + it('borderWidth scales to 2px at zoom=0.5', () => { + mockZoom = 0.5 + const { container } = renderBaseNode({}) + expect((container.firstChild as HTMLElement).style.borderWidth).toBe('2px') + }) + + it('borderWidth is clamped to 1px at zoom=2', () => { + mockZoom = 2 + const { container } = renderBaseNode({}) + expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px') + }) + + it('boxShadow glow ring uses borderWidth when selected + online at zoom=0.5', () => { + mockZoom = 0.5 + const node = makeNode({ status: 'online' }) + const { container } = render( + + ) + expect((container.firstChild as HTMLElement).style.boxShadow).toContain('0 0 0 2px') + }) +}) + describe('BaseNode — properties rendering', () => { it('renders visible properties on the node', () => { renderBaseNode({ diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index 5be7dcf..e760c87 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -1,4 +1,4 @@ -import { createElement, useEffect } from 'react' +import { createElement, useEffect, useMemo } from 'react' import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react' import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react' import type { NodeData } from '@/types' @@ -25,7 +25,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals]) const { zoom } = useViewport() - const borderWidth = Math.max(1, 1 / zoom) + const borderWidth = useMemo(() => Math.max(1, 1 / zoom), [zoom]) const activeTheme = useThemeStore((s) => s.activeTheme) const hideIp = useCanvasStore((s) => s.hideIp) @@ -49,11 +49,11 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: borderColor: colors.border, borderWidth, boxShadow: isOnline && selected - ? `0 0 0 1px ${colors.border}, 0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a` + ? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a` : isOnline ? `0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a` : selected - ? `0 0 0 1px ${colors.border}, 0 0 8px ${colors.border}44` + ? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44` : 'none', opacity: data.status === 'offline' ? 0.55 : 1, minWidth: 140, From 6a3da5aded1039da38ca1c30c00c1a7c4ca0b0f5 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 18 Apr 2026 22:41:37 +0200 Subject: [PATCH 7/8] fix: memoize onBeforeDelete and fix test mock selectors Extract onBeforeDelete into useCallback to avoid new fn ref each render. Fix useThemeStore/useCanvasStore mocks to call the selector fn rather than ignoring it, and reset mockZoom in beforeEach. --- frontend/src/components/canvas/CanvasContainer.tsx | 7 ++++++- .../src/components/canvas/__tests__/BaseNode.test.tsx | 8 +++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index 1daad1d..2e74c83 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -73,6 +73,11 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o onNodeDoubleClick?.(node) }, [onNodeDoubleClick]) + const handleBeforeDelete = useCallback(async () => { + snapshotHistory() + return true + }, [snapshotHistory]) + return (
{ snapshotHistory(); return true }} + onBeforeDelete={handleBeforeDelete} selectionOnDrag={lassoMode} panOnDrag={lassoMode ? [1, 2] : true} panActivationKeyCode="Space" diff --git a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx index 3c5cf6c..a4fd826 100644 --- a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx +++ b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen } from '@testing-library/react' import { Server } from 'lucide-react' import { BaseNode } from '../nodes/BaseNode' @@ -16,11 +16,11 @@ vi.mock('@xyflow/react', () => ({ })) vi.mock('@/stores/themeStore', () => ({ - useThemeStore: () => 'dark', + useThemeStore: (sel: (s: { activeTheme: string }) => unknown) => sel({ activeTheme: 'dark' }), })) vi.mock('@/stores/canvasStore', () => ({ - useCanvasStore: () => ({ hideIp: false }), + useCanvasStore: (sel: (s: { hideIp: boolean }) => unknown) => sel({ hideIp: false }), })) vi.mock('@/utils/themes', () => ({ @@ -89,6 +89,8 @@ function renderBaseNode(data: Partial) { } describe('BaseNode — borderWidth zoom scaling', () => { + beforeEach(() => { mockZoom = 1 }) + it('borderWidth is 1px at zoom=1', () => { mockZoom = 1 const { container } = renderBaseNode({}) From 70311e633168864cc1c9244809e1c85217d1d0c8 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 18 Apr 2026 22:50:31 +0200 Subject: [PATCH 8/8] fix: propertyIcons mock, key stability, isValidConnection memoized - Add resolvePropertyIcon mock to BaseNode tests - Reset mockZoom in top-level beforeEach to prevent test bleed - Use prop.key instead of index as list key in BaseNode properties - Extract isValidConnection into useCallback in CanvasContainer --- frontend/src/components/canvas/CanvasContainer.tsx | 7 ++++++- frontend/src/components/canvas/__tests__/BaseNode.test.tsx | 6 ++++++ frontend/src/components/canvas/nodes/BaseNode.tsx | 4 ++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index 2e74c83..9ec2726 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -78,6 +78,11 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o return true }, [snapshotHistory]) + const isValidConnection = useCallback( + (connection: { source: string | null; target: string | null }) => connection.source !== connection.target, + [] + ) + return (
connection.source !== connection.target} + isValidConnection={isValidConnection} > ({ maskIp: (ip: string) => ip, })) +vi.mock('@/utils/propertyIcons', () => ({ + resolvePropertyIcon: (icon: string | null) => icon ? Server : null, +})) + vi.mock('@/utils/handleUtils', () => ({ BOTTOM_HANDLE_IDS: ['bottom'], BOTTOM_HANDLE_POSITIONS: { 1: [50] }, })) +beforeEach(() => { mockZoom = 1 }) + function makeNode(data: Partial): Node { return { id: 'n1', diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index e760c87..8b95b7e 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -115,10 +115,10 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: <>
- {visibleProperties.map((prop, i) => { + {visibleProperties.map((prop) => { const Icon = resolvePropertyIcon(prop.icon) return ( -
+
{Icon && } {prop.key} · {prop.value}