feat: configurable bottom handles, scanner rewrite, UI polish
## New features - Configurable bottom connection points per node (1–4 handles) - Fit view on load - LiveView improvements - Node modal: inline Type/Icon picker, default icon in trigger - Remove redundant Save button from ScanConfigModal ## Scanner fixes - Phase 1: replace nmap ARP sweep with concurrent asyncio ping sweep (50 parallel pings, 1s timeout). Zero false positives, works in any Docker network mode. Supplements with /proc/net/arp for ICMP-blocked devices. - Phase 2: explicit -sS (root) / -sT (non-root) scan type; bump host-timeout to 60s; gather(return_exceptions=True) so one failing host doesn't abort the batch - Fix 404 on missing device in hide/ignore - Validate CIDR ranges to prevent nmap injection - Thread-safe cancel set, pre-fetch canvas/hidden IPs (no N+1 queries) - Logging: attach StreamHandler to root logger so app.* logs are visible ## Tests - 21 backend scanner tests (ping sweep, ARP cache, Phase 2 tolerance) - Full NodeModal coverage (53 tests) - LiveView, store, edge label tests
This commit is contained in:
@@ -253,11 +253,11 @@ describe('canvasStore', () => {
|
||||
expect(useCanvasStore.getState().selectedNodeIds).toEqual([])
|
||||
})
|
||||
|
||||
it('setSelectedNode(id) preserves existing selectedNodeIds', () => {
|
||||
it('setSelectedNode(id) sets selectedNodeIds to [id], clearing multi-selection', () => {
|
||||
useCanvasStore.setState({ selectedNodeIds: ['n1', 'n2'] })
|
||||
useCanvasStore.getState().setSelectedNode('n1')
|
||||
// does NOT wipe selectedNodeIds when setting a specific id
|
||||
expect(useCanvasStore.getState().selectedNodeIds).toEqual(['n1', 'n2'])
|
||||
// Single node click resets multi-selection to just the clicked node
|
||||
expect(useCanvasStore.getState().selectedNodeIds).toEqual(['n1'])
|
||||
})
|
||||
|
||||
// ── createGroup ───────────────────────────────────────────────────────────
|
||||
@@ -618,4 +618,61 @@ describe('canvasStore', () => {
|
||||
expect(stored?.width).toBeUndefined()
|
||||
expect(stored?.height).toBeUndefined()
|
||||
})
|
||||
|
||||
// ── bottom_handles edge remapping ──────────────────────────────────────────
|
||||
|
||||
it('remaps source edges to "bottom" when bottom_handles is reduced', () => {
|
||||
const node = makeNode('n1', { bottom_handles: 4 })
|
||||
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom-3' }
|
||||
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
|
||||
|
||||
useCanvasStore.getState().updateNode('n1', { bottom_handles: 2 })
|
||||
|
||||
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
|
||||
expect(updated?.sourceHandle).toBe('bottom')
|
||||
})
|
||||
|
||||
it('remaps target edges to "bottom" when bottom_handles is reduced', () => {
|
||||
const node = makeNode('n2', { bottom_handles: 3 })
|
||||
const edge = { ...makeEdge('e1', 'n1', 'n2'), targetHandle: 'bottom-3' }
|
||||
useCanvasStore.setState({ nodes: [makeNode('n1'), node], edges: [edge] })
|
||||
|
||||
useCanvasStore.getState().updateNode('n2', { bottom_handles: 1 })
|
||||
|
||||
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
|
||||
expect(updated?.targetHandle).toBe('bottom')
|
||||
})
|
||||
|
||||
it('does not remap edges that are on handles still present after reduction', () => {
|
||||
const node = makeNode('n1', { bottom_handles: 4 })
|
||||
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom-2' }
|
||||
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
|
||||
|
||||
useCanvasStore.getState().updateNode('n1', { bottom_handles: 3 })
|
||||
|
||||
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
|
||||
expect(updated?.sourceHandle).toBe('bottom-2')
|
||||
})
|
||||
|
||||
it('does not remap edges when bottom_handles increases', () => {
|
||||
const node = makeNode('n1', { bottom_handles: 2 })
|
||||
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom' }
|
||||
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
|
||||
|
||||
useCanvasStore.getState().updateNode('n1', { bottom_handles: 4 })
|
||||
|
||||
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
|
||||
expect(updated?.sourceHandle).toBe('bottom')
|
||||
})
|
||||
|
||||
it('never remaps the "bottom" handle itself', () => {
|
||||
const node = makeNode('n1', { bottom_handles: 4 })
|
||||
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom' }
|
||||
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
|
||||
|
||||
useCanvasStore.getState().updateNode('n1', { bottom_handles: 1 })
|
||||
|
||||
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
|
||||
expect(updated?.sourceHandle).toBe('bottom')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@xyflow/react'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
|
||||
|
||||
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||
|
||||
@@ -52,6 +53,8 @@ interface CanvasState {
|
||||
markSaved: () => void
|
||||
markUnsaved: () => void
|
||||
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
|
||||
fitViewPending: boolean
|
||||
clearFitViewPending: () => void
|
||||
notifyScanDeviceFound: () => void
|
||||
hideIp: boolean
|
||||
toggleHideIp: () => void
|
||||
@@ -66,6 +69,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
editingGroupRectId: null,
|
||||
hideIp: false,
|
||||
scanEventTs: 0,
|
||||
fitViewPending: false,
|
||||
|
||||
past: [],
|
||||
future: [],
|
||||
@@ -149,10 +153,6 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
set((state) => {
|
||||
const extra = connection as Connection & Partial<EdgeData>
|
||||
const edgeType = extra.type ?? 'ethernet'
|
||||
// Normalize invisible stub handle IDs so React Flow can locate the handle
|
||||
// and render the edge immediately (top-t / bottom-t are opacity:0 helpers).
|
||||
const normalizeHandle = (h: string | null | undefined) =>
|
||||
h === 'top-t' ? 'top' : h === 'bottom-t' ? 'bottom' : (h ?? null)
|
||||
return {
|
||||
edges: addEdge({
|
||||
...connection,
|
||||
@@ -165,10 +165,10 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
}
|
||||
}),
|
||||
|
||||
setSelectedNode: (id) => set((state) => ({
|
||||
setSelectedNode: (id) => set({
|
||||
selectedNodeId: id,
|
||||
selectedNodeIds: id ? state.selectedNodeIds : [],
|
||||
})),
|
||||
selectedNodeIds: id ? [id] : [],
|
||||
}),
|
||||
|
||||
addNode: (node) =>
|
||||
set((state) => {
|
||||
@@ -226,7 +226,25 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
const children = nodes.filter((n) => !!n.parentId)
|
||||
nodes = [...parents, ...children]
|
||||
}
|
||||
return { nodes, hasUnsavedChanges: true }
|
||||
// Remap edges when bottom_handles is reduced so no edge disappears
|
||||
let edges = state.edges
|
||||
if ('bottom_handles' in data && data.bottom_handles != null) {
|
||||
const currentNode = state.nodes.find((n) => n.id === id)
|
||||
const oldCount = currentNode?.data.bottom_handles ?? 1
|
||||
const newCount = data.bottom_handles
|
||||
if (newCount < oldCount) {
|
||||
const removed = removedBottomHandleIds(oldCount, newCount)
|
||||
edges = state.edges.map((e) => {
|
||||
if (e.source === id && e.sourceHandle && removed.has(e.sourceHandle))
|
||||
return { ...e, sourceHandle: 'bottom' }
|
||||
if (e.target === id && e.targetHandle && removed.has(e.targetHandle))
|
||||
return { ...e, targetHandle: 'bottom' }
|
||||
return e
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges, hasUnsavedChanges: true }
|
||||
}),
|
||||
|
||||
deleteNode: (id) =>
|
||||
@@ -409,6 +427,8 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
// React Flow requires parents before children in the array
|
||||
const parents = nodes.filter((n) => !n.parentId)
|
||||
const children = nodes.filter((n) => !!n.parentId)
|
||||
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [] })
|
||||
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [], fitViewPending: true })
|
||||
},
|
||||
|
||||
clearFitViewPending: () => set({ fitViewPending: false }),
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user