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:
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
BOTTOM_HANDLE_IDS,
|
||||
BOTTOM_HANDLE_POSITIONS,
|
||||
normalizeHandle,
|
||||
removedBottomHandleIds,
|
||||
} from '../handleUtils'
|
||||
|
||||
describe('BOTTOM_HANDLE_IDS', () => {
|
||||
it('first id is always "bottom" for backward compatibility', () => {
|
||||
expect(BOTTOM_HANDLE_IDS[0]).toBe('bottom')
|
||||
})
|
||||
|
||||
it('has ids for 1–4 handles', () => {
|
||||
expect(BOTTOM_HANDLE_IDS).toHaveLength(4)
|
||||
expect(BOTTOM_HANDLE_IDS).toEqual(['bottom', 'bottom-2', 'bottom-3', 'bottom-4'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('BOTTOM_HANDLE_POSITIONS', () => {
|
||||
it('1 handle is centered at 50%', () => {
|
||||
expect(BOTTOM_HANDLE_POSITIONS[1]).toEqual([50])
|
||||
})
|
||||
|
||||
it('2 handles are symmetric', () => {
|
||||
const [a, b] = BOTTOM_HANDLE_POSITIONS[2]
|
||||
expect(a).toBeLessThan(50)
|
||||
expect(b).toBeGreaterThan(50)
|
||||
expect(a + b).toBe(100)
|
||||
})
|
||||
|
||||
it('3 handles include a center at 50%', () => {
|
||||
expect(BOTTOM_HANDLE_POSITIONS[3]).toContain(50)
|
||||
expect(BOTTOM_HANDLE_POSITIONS[3]).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('4 handles are evenly spaced', () => {
|
||||
const pos = BOTTOM_HANDLE_POSITIONS[4]
|
||||
expect(pos).toHaveLength(4)
|
||||
// All values should be between 0 and 100 exclusive
|
||||
pos.forEach((p) => {
|
||||
expect(p).toBeGreaterThan(0)
|
||||
expect(p).toBeLessThan(100)
|
||||
})
|
||||
// Positions should be strictly increasing
|
||||
for (let i = 1; i < pos.length; i++) {
|
||||
expect(pos[i]).toBeGreaterThan(pos[i - 1])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeHandle', () => {
|
||||
it('returns null for null/undefined', () => {
|
||||
expect(normalizeHandle(null)).toBeNull()
|
||||
expect(normalizeHandle(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('maps top-t → top', () => {
|
||||
expect(normalizeHandle('top-t')).toBe('top')
|
||||
})
|
||||
|
||||
it('maps bottom-t → bottom', () => {
|
||||
expect(normalizeHandle('bottom-t')).toBe('bottom')
|
||||
})
|
||||
|
||||
it('maps bottom-2-t → bottom-2', () => {
|
||||
expect(normalizeHandle('bottom-2-t')).toBe('bottom-2')
|
||||
})
|
||||
|
||||
it('maps bottom-3-t → bottom-3', () => {
|
||||
expect(normalizeHandle('bottom-3-t')).toBe('bottom-3')
|
||||
})
|
||||
|
||||
it('maps bottom-4-t → bottom-4', () => {
|
||||
expect(normalizeHandle('bottom-4-t')).toBe('bottom-4')
|
||||
})
|
||||
|
||||
it('passes through non-stub handles unchanged', () => {
|
||||
expect(normalizeHandle('top')).toBe('top')
|
||||
expect(normalizeHandle('bottom')).toBe('bottom')
|
||||
expect(normalizeHandle('bottom-2')).toBe('bottom-2')
|
||||
expect(normalizeHandle('custom-handle')).toBe('custom-handle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('removedBottomHandleIds', () => {
|
||||
it('returns empty set when count does not decrease', () => {
|
||||
expect(removedBottomHandleIds(2, 2).size).toBe(0)
|
||||
expect(removedBottomHandleIds(1, 4).size).toBe(0)
|
||||
})
|
||||
|
||||
it('4 → 1 removes bottom-2, bottom-3, bottom-4', () => {
|
||||
const removed = removedBottomHandleIds(4, 1)
|
||||
expect(removed).toEqual(new Set(['bottom-2', 'bottom-3', 'bottom-4']))
|
||||
})
|
||||
|
||||
it('4 → 2 removes bottom-3, bottom-4', () => {
|
||||
const removed = removedBottomHandleIds(4, 2)
|
||||
expect(removed).toEqual(new Set(['bottom-3', 'bottom-4']))
|
||||
})
|
||||
|
||||
it('3 → 2 removes only bottom-3', () => {
|
||||
const removed = removedBottomHandleIds(3, 2)
|
||||
expect(removed).toEqual(new Set(['bottom-3']))
|
||||
})
|
||||
|
||||
it('never removes "bottom" (index 0)', () => {
|
||||
const removed = removedBottomHandleIds(4, 1)
|
||||
expect(removed.has('bottom')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
import { normalizeHandle } from '@/utils/handleUtils'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -29,6 +30,7 @@ export interface ApiNode extends Record<string, unknown> {
|
||||
show_hardware?: boolean
|
||||
width?: number | null
|
||||
height?: number | null
|
||||
bottom_handles?: number
|
||||
}
|
||||
|
||||
export interface ApiEdge {
|
||||
@@ -99,14 +101,12 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
||||
show_hardware: n.data.show_hardware ?? false,
|
||||
width: n.width ?? null,
|
||||
height: n.height ?? null,
|
||||
bottom_handles: n.data.bottom_handles ?? 1,
|
||||
pos_x: n.position.x,
|
||||
pos_y: n.position.y,
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeHandle = (h: string | null | undefined): string | null =>
|
||||
h === 'top-t' ? 'top' : h === 'bottom-t' ? 'bottom' : (h ?? null)
|
||||
|
||||
export function serializeEdge(e: Edge<EdgeData>): Record<string, unknown> {
|
||||
return {
|
||||
id: e.id,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Bottom handle configuration for multi-handle nodes.
|
||||
*
|
||||
* Handle IDs: index 0 = 'bottom' (always the default, backward-compatible)
|
||||
* index 1 = 'bottom-2', index 2 = 'bottom-3', index 3 = 'bottom-4'
|
||||
*
|
||||
* Invisible target handles follow the same pattern with a '-t' suffix:
|
||||
* 'bottom-t', 'bottom-2-t', 'bottom-3-t', 'bottom-4-t'
|
||||
*/
|
||||
|
||||
export const BOTTOM_HANDLE_IDS = ['bottom', 'bottom-2', 'bottom-3', 'bottom-4'] as const
|
||||
|
||||
/** Left % position for each handle slot, per count. */
|
||||
export const BOTTOM_HANDLE_POSITIONS: Record<number, number[]> = {
|
||||
1: [50],
|
||||
2: [25, 75],
|
||||
3: [20, 50, 80],
|
||||
4: [15, 38, 62, 85],
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw handle ID coming from a React Flow connection event.
|
||||
* Invisible target handles (e.g. 'bottom-2-t') are mapped to their source
|
||||
* counterpart ('bottom-2') so the stored edge ID is stable and consistent.
|
||||
*/
|
||||
export function normalizeHandle(h: string | null | undefined): string | null {
|
||||
if (!h) return null
|
||||
if (h === 'top-t') return 'top'
|
||||
// 'bottom-t' → 'bottom', 'bottom-2-t' → 'bottom-2', etc.
|
||||
const m = h.match(/^(bottom(?:-\d+)?)-t$/)
|
||||
if (m) return m[1]
|
||||
return h
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of handle IDs that are removed when bottom_handles
|
||||
* is reduced from `oldCount` to `newCount`.
|
||||
*/
|
||||
export function removedBottomHandleIds(oldCount: number, newCount: number): Set<string> {
|
||||
const removed = new Set<string>()
|
||||
for (let i = newCount; i < oldCount; i++) {
|
||||
removed.add(BOTTOM_HANDLE_IDS[i])
|
||||
}
|
||||
return removed
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { NodeType } from '@/types'
|
||||
import {
|
||||
// Infrastructure (node types)
|
||||
Globe, Router, Network, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle,
|
||||
@@ -116,6 +117,27 @@ export const ICON_MAP: Record<string, LucideIcon> = Object.fromEntries(
|
||||
ICON_REGISTRY.map((e) => [e.key, e.icon]),
|
||||
)
|
||||
|
||||
export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
|
||||
isp: Globe,
|
||||
router: Router,
|
||||
switch: Network,
|
||||
server: Server,
|
||||
proxmox: Layers,
|
||||
vm: Box,
|
||||
lxc: Container,
|
||||
nas: HardDrive,
|
||||
iot: Cpu,
|
||||
ap: Wifi,
|
||||
camera: Cctv,
|
||||
printer: Printer,
|
||||
computer: Monitor,
|
||||
cpl: PlugZap,
|
||||
docker: Anchor,
|
||||
generic: Circle,
|
||||
group: Circle,
|
||||
groupRect: Circle,
|
||||
}
|
||||
|
||||
/** Resolve the display icon for a node — custom_icon takes priority over type default. */
|
||||
export function resolveNodeIcon(
|
||||
typeIcon: LucideIcon,
|
||||
|
||||
Reference in New Issue
Block a user