diff --git a/backend/requirements.txt b/backend/requirements.txt index 792a5bc..3bd01ad 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==1.3.0 pytest-cov==5.0.0 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a36b9d0..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,6 +404,7 @@ export default function App() { { setHighlightPendingId(undefined) diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index 3847dc6..9ec2726 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -25,11 +25,12 @@ import type { NodeData, EdgeData } from '@/types' interface CanvasContainerProps { onConnect?: (connection: Connection) => void onEdgeDoubleClick?: (edge: Edge) => void + onNodeDoubleClick?: (node: Node) => void onNodeDragStart?: () => void onOpenPending?: (deviceId: string) => void } -export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) { +export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) { const [lassoMode, setLassoMode] = useState(true) const { nodes, edges, @@ -68,6 +69,20 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o onEdgeDoubleClick?.(edge) }, [onEdgeDoubleClick]) + const handleNodeDoubleClick = useCallback((_: React.MouseEvent, node: Node) => { + onNodeDoubleClick?.(node) + }, [onNodeDoubleClick]) + + const handleBeforeDelete = useCallback(async () => { + snapshotHistory() + return true + }, [snapshotHistory]) + + const isValidConnection = useCallback( + (connection: { source: string | null; target: string | null }) => connection.source !== connection.target, + [] + ) + return (
{ snapshotHistory(); return true }} + onBeforeDelete={handleBeforeDelete} selectionOnDrag={lassoMode} panOnDrag={lassoMode ? [1, 2] : true} panActivationKeyCode="Space" selectionMode={SelectionMode.Partial} multiSelectionKeyCode={['Meta', 'Control']} + minZoom={0.25} + maxZoom={2.5} snapToGrid snapGrid={[8, 8]} colorMode={theme.colors.reactFlowColorMode} elevateNodesOnSelect={false} connectionMode={ConnectionMode.Loose} - isValidConnection={(connection) => connection.source !== connection.target} + isValidConnection={isValidConnection} > ({ Handle: () => null, Position: { Top: 'top', Bottom: 'bottom' }, NodeResizer: () => null, useUpdateNodeInternals: () => vi.fn(), + useViewport: () => ({ zoom: mockZoom }), })) 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', () => ({ @@ -47,11 +50,17 @@ vi.mock('@/utils/maskIp', () => ({ 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', @@ -85,6 +94,39 @@ function renderBaseNode(data: Partial) { ) } +describe('BaseNode — borderWidth zoom scaling', () => { + beforeEach(() => { mockZoom = 1 }) + + 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/__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', () => { diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index 0bacd34..8b95b7e 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 { 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' 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 = useMemo(() => Math.max(1, 1 / zoom), [zoom]) + const activeTheme = useThemeStore((s) => s.activeTheme) const hideIp = useCanvasStore((s) => s.hideIp) const theme = THEMES[activeTheme] @@ -44,13 +47,13 @@ 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` + ? `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, @@ -112,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}