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:
@@ -56,6 +56,7 @@ export const scanApi = {
|
||||
pending: () => api.get('/scan/pending'),
|
||||
hidden: () => api.get('/scan/hidden'),
|
||||
runs: () => api.get('/scan/runs'),
|
||||
clearPending: () => api.delete('/scan/pending'),
|
||||
approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData),
|
||||
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
||||
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
ConnectionMode,
|
||||
useReactFlow,
|
||||
type Node,
|
||||
} from '@xyflow/react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
@@ -36,7 +37,8 @@ const STORAGE_KEY = 'homelable_canvas'
|
||||
type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'network-error' | 'ready'
|
||||
|
||||
function LiveViewCanvas() {
|
||||
const { nodes, edges, loadCanvas } = useCanvasStore()
|
||||
const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
|
||||
const { fitView } = useReactFlow()
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const theme = THEMES[activeTheme]
|
||||
// Derive initial view state synchronously (avoids calling setState inside an effect):
|
||||
@@ -87,6 +89,15 @@ function LiveViewCanvas() {
|
||||
})
|
||||
}, [loadCanvas])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fitViewPending || nodes.length === 0) return
|
||||
const id = setTimeout(() => {
|
||||
fitView({ padding: 0.12, duration: 350 })
|
||||
clearFitViewPending()
|
||||
}, 50)
|
||||
return () => clearTimeout(id)
|
||||
}, [fitViewPending, nodes.length, fitView, clearFitViewPending])
|
||||
|
||||
const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
|
||||
const ip = node.data.ip
|
||||
if (ip) window.open(`http://${ip}`, '_blank', 'noopener,noreferrer')
|
||||
@@ -129,7 +140,6 @@ function LiveViewCanvas() {
|
||||
elementsSelectable={false}
|
||||
panOnDrag
|
||||
zoomOnScroll
|
||||
fitView
|
||||
colorMode={theme.colors.reactFlowColorMode}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
onNodeClick={onNodeClick}
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock('@xyflow/react', () => ({
|
||||
Controls: () => null,
|
||||
BackgroundVariant: { Dots: 'dots' },
|
||||
ConnectionMode: { Loose: 'loose' },
|
||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||
|
||||
@@ -50,6 +51,8 @@ describe('LiveView (non-standalone)', () => {
|
||||
useCanvasStore.setState({ nodes: [], edges: [] })
|
||||
})
|
||||
|
||||
afterEach(() => { setSearch('') })
|
||||
|
||||
// ── No key ────────────────────────────────────────────────────────────────
|
||||
|
||||
it('shows no-key error when ?key= is missing', async () => {
|
||||
@@ -133,11 +136,25 @@ describe('LiveView (non-standalone)', () => {
|
||||
|
||||
// ── Standalone mode ────────────────────────────────────────────────────────
|
||||
|
||||
const XYFLOW_MOCK = {
|
||||
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
ReactFlow: () => <div data-testid="react-flow" />,
|
||||
Background: () => null,
|
||||
Controls: () => null,
|
||||
BackgroundVariant: { Dots: 'dots' },
|
||||
ConnectionMode: { Loose: 'loose' },
|
||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||
}
|
||||
|
||||
describe('LiveView (standalone — localStorage)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
useCanvasStore.setState({ nodes: [], edges: [] })
|
||||
vi.mocked(liveviewApi.load).mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setSearch('')
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('loads canvas from localStorage without calling the API', async () => {
|
||||
@@ -151,25 +168,12 @@ describe('LiveView (standalone — localStorage)', () => {
|
||||
}
|
||||
localStorage.setItem('homelable_canvas', JSON.stringify(stored))
|
||||
|
||||
// Stub VITE_STANDALONE before re-importing
|
||||
vi.stubEnv('VITE_STANDALONE', 'true')
|
||||
vi.resetModules()
|
||||
const { default: LiveViewStandalone } = await import('../LiveView')
|
||||
|
||||
setSearch('') // no key needed in standalone
|
||||
render(<LiveViewStandalone />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('react-flow')).toBeDefined()
|
||||
})
|
||||
expect(liveviewApi.load).not.toHaveBeenCalled()
|
||||
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('shows canvas (empty) when localStorage has no saved data', async () => {
|
||||
vi.stubEnv('VITE_STANDALONE', 'true')
|
||||
vi.resetModules()
|
||||
const mockLoad = vi.fn()
|
||||
vi.doMock('@xyflow/react', () => XYFLOW_MOCK)
|
||||
vi.doMock('@xyflow/react/dist/style.css', () => ({}))
|
||||
vi.doMock('@/api/client', () => ({ liveviewApi: { load: mockLoad } }))
|
||||
const { default: LiveViewStandalone } = await import('../LiveView')
|
||||
|
||||
setSearch('')
|
||||
@@ -178,8 +182,24 @@ describe('LiveView (standalone — localStorage)', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('react-flow')).toBeDefined()
|
||||
})
|
||||
expect(liveviewApi.load).not.toHaveBeenCalled()
|
||||
expect(mockLoad).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
vi.unstubAllEnvs()
|
||||
it('shows canvas (empty) when localStorage has no saved data', async () => {
|
||||
vi.stubEnv('VITE_STANDALONE', 'true')
|
||||
vi.resetModules()
|
||||
const mockLoad = vi.fn()
|
||||
vi.doMock('@xyflow/react', () => XYFLOW_MOCK)
|
||||
vi.doMock('@xyflow/react/dist/style.css', () => ({}))
|
||||
vi.doMock('@/api/client', () => ({ liveviewApi: { load: mockLoad } }))
|
||||
const { default: LiveViewStandalone } = await import('../LiveView')
|
||||
|
||||
setSearch('')
|
||||
render(<LiveViewStandalone />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('react-flow')).toBeDefined()
|
||||
})
|
||||
expect(mockLoad).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
BackgroundVariant,
|
||||
ConnectionMode,
|
||||
SelectionMode,
|
||||
useReactFlow,
|
||||
type Node,
|
||||
type Edge,
|
||||
type Connection,
|
||||
@@ -33,7 +34,19 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
nodes, edges,
|
||||
onNodesChange, onEdgesChange,
|
||||
setSelectedNode, snapshotHistory,
|
||||
fitViewPending, clearFitViewPending,
|
||||
} = useCanvasStore()
|
||||
const { fitView } = useReactFlow()
|
||||
|
||||
// Fit view after canvas loads (fitViewPending is set by loadCanvas)
|
||||
useEffect(() => {
|
||||
if (!fitViewPending || nodes.length === 0) return
|
||||
const id = setTimeout(() => {
|
||||
fitView({ padding: 0.12, duration: 350 })
|
||||
clearFitViewPending()
|
||||
}, 50)
|
||||
return () => clearTimeout(id)
|
||||
}, [fitViewPending, nodes.length, fitView, clearFitViewPending])
|
||||
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const theme = THEMES[activeTheme]
|
||||
@@ -77,7 +90,6 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
multiSelectionKeyCode={['Meta', 'Control']}
|
||||
snapToGrid
|
||||
snapGrid={[16, 16]}
|
||||
fitView
|
||||
colorMode={theme.colors.reactFlowColorMode}
|
||||
elevateNodesOnSelect={false}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
|
||||
@@ -20,6 +20,7 @@ vi.mock('@xyflow/react', () => ({
|
||||
BackgroundVariant: { Dots: 'dots' },
|
||||
ConnectionMode: { Loose: 'loose' },
|
||||
SelectionMode: { Partial: 'partial' },
|
||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||
|
||||
@@ -26,7 +26,7 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox'
|
||||
|
||||
const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }
|
||||
const [edgePath, labelX, labelY] = data?.path_style === 'smooth'
|
||||
const [edgePath, labelX] = data?.path_style === 'smooth'
|
||||
? getSmoothStepPath({ ...pathArgs, borderRadius: 8 })
|
||||
: getBezierPath(pathArgs)
|
||||
|
||||
@@ -95,9 +95,9 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
{data?.label && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
className="absolute pointer-events-none font-mono text-[10px] px-1 rounded"
|
||||
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${(sourceY + targetY) / 2}px)`,
|
||||
background: theme.colors.edgeLabelBackground,
|
||||
color: theme.colors.edgeLabelColor,
|
||||
border: `1px solid ${theme.colors.edgeLabelBorder}`,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createElement } from 'react'
|
||||
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||
import { createElement, useEffect } from 'react'
|
||||
import { Handle, Position, NodeResizer, useUpdateNodeInternals, 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'
|
||||
@@ -8,6 +8,7 @@ import { useThemeStore } from '@/stores/themeStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { maskIp } from '@/utils/maskIp'
|
||||
import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils'
|
||||
|
||||
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
||||
icon: LucideIcon
|
||||
@@ -18,7 +19,10 @@ function formatStorage(gb: number): string {
|
||||
return `${gb} GB`
|
||||
}
|
||||
|
||||
export function BaseNode({ data, selected, icon: typeIcon, width, height }: BaseNodeProps) {
|
||||
export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: BaseNodeProps) {
|
||||
const updateNodeInternals = useUpdateNodeInternals()
|
||||
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
|
||||
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||
const theme = THEMES[activeTheme]
|
||||
@@ -141,13 +145,26 @@ export function BaseNode({ data, selected, icon: typeIcon, width, height }: Base
|
||||
title={data.status}
|
||||
/>
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="bottom"
|
||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||
/>
|
||||
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||
{(BOTTOM_HANDLE_POSITIONS[data.bottom_handles ?? 1] ?? BOTTOM_HANDLE_POSITIONS[1]).map((leftPct, idx) => {
|
||||
const sourceId = BOTTOM_HANDLE_IDS[idx]
|
||||
const targetId = idx === 0 ? 'bottom-t' : `bottom-${idx + 1}-t`
|
||||
return (
|
||||
<span key={sourceId}>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id={sourceId}
|
||||
style={{ left: `${leftPct}%`, background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Bottom}
|
||||
id={targetId}
|
||||
style={{ left: `${leftPct}%`, opacity: 0, width: 12, height: 12 }}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { ICON_REGISTRY, ICON_CATEGORIES } from '@/utils/nodeIcons'
|
||||
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
|
||||
|
||||
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||
@@ -75,11 +75,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Type */}
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
{/* Type + Icon on the same row */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Type</Label>
|
||||
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
|
||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
@@ -103,7 +103,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">Icon</Label>
|
||||
{form.custom_icon && (
|
||||
@@ -120,70 +120,72 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIconPickerOpen((o) => !o)}
|
||||
className="flex items-center justify-between gap-2 h-8 px-3 rounded-md bg-[#21262d] border border-[#30363d] text-sm hover:border-[#8b949e] transition-colors"
|
||||
className="flex items-center justify-between gap-2 h-8 px-3 rounded-md bg-[#21262d] border border-[#30363d] text-sm hover:border-[#8b949e] transition-colors w-full"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
{(() => {
|
||||
const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon)
|
||||
if (entry) {
|
||||
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff]' })}<span className="text-foreground">{entry.label}</span></>
|
||||
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}<span className="text-foreground truncate">{entry.label}</span></>
|
||||
}
|
||||
return <span className="text-muted-foreground">Default (from type)</span>
|
||||
const defaultIcon = NODE_TYPE_DEFAULT_ICONS[form.type as NodeType] ?? NODE_TYPE_DEFAULT_ICONS.generic
|
||||
return <>{createElement(defaultIcon, { size: 13, className: 'text-muted-foreground shrink-0' })}<span className="text-muted-foreground truncate">Default</span></>
|
||||
})()}
|
||||
</span>
|
||||
<ChevronDown size={12} className="text-muted-foreground shrink-0" style={{ transform: iconPickerOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
{/* Inline picker panel */}
|
||||
{iconPickerOpen && (
|
||||
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d]">
|
||||
<Input
|
||||
value={iconSearch}
|
||||
onChange={(e) => setIconSearch(e.target.value)}
|
||||
placeholder="Search icons…"
|
||||
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
|
||||
{ICON_CATEGORIES.map((cat) => {
|
||||
const entries = ICON_REGISTRY.filter(
|
||||
(e) => e.category === cat &&
|
||||
(iconSearch === '' || e.label.toLowerCase().includes(iconSearch.toLowerCase()) || e.key.includes(iconSearch.toLowerCase()))
|
||||
)
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<div key={cat}>
|
||||
<p className="text-[9px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1">{cat}</p>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{entries.map((entry) => {
|
||||
const isSelected = form.custom_icon === entry.key
|
||||
return (
|
||||
<button
|
||||
key={entry.key}
|
||||
type="button"
|
||||
title={entry.label}
|
||||
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
|
||||
className="flex items-center justify-center w-7 h-7 rounded transition-colors"
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : 'transparent',
|
||||
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
|
||||
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = '#21262d' }}
|
||||
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
{createElement(entry.icon, { size: 13 })}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline icon picker — full width, shown below the type+icon row */}
|
||||
{iconPickerOpen && (
|
||||
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
|
||||
<Input
|
||||
value={iconSearch}
|
||||
onChange={(e) => setIconSearch(e.target.value)}
|
||||
placeholder="Search icons…"
|
||||
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
|
||||
{ICON_CATEGORIES.map((cat) => {
|
||||
const entries = ICON_REGISTRY.filter(
|
||||
(e) => e.category === cat &&
|
||||
(iconSearch === '' || e.label.toLowerCase().includes(iconSearch.toLowerCase()) || e.key.includes(iconSearch.toLowerCase()))
|
||||
)
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<div key={cat}>
|
||||
<p className="text-[9px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1">{cat}</p>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{entries.map((entry) => {
|
||||
const isSelected = form.custom_icon === entry.key
|
||||
return (
|
||||
<button
|
||||
key={entry.key}
|
||||
type="button"
|
||||
title={entry.label}
|
||||
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
|
||||
className="flex items-center justify-center w-7 h-7 rounded transition-colors"
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : 'transparent',
|
||||
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
|
||||
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = '#21262d' }}
|
||||
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
{createElement(entry.icon, { size: 13 })}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">Label *</Label>
|
||||
@@ -414,6 +416,27 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom connection points (not for group containers) */}
|
||||
{form.type !== 'groupRect' && form.type !== 'group' && (
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
|
||||
<Select
|
||||
value={String(form.bottom_handles ?? 1)}
|
||||
onValueChange={(v) => set('bottom_handles', parseInt(v ?? '1', 10))}
|
||||
>
|
||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
<SelectItem value="1" className="text-sm">1 — center</SelectItem>
|
||||
<SelectItem value="2" className="text-sm">2 — left / right</SelectItem>
|
||||
<SelectItem value="3" className="text-sm">3 — left / center / right</SelectItem>
|
||||
<SelectItem value="4" className="text-sm">4 — evenly spaced</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">Notes</Label>
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface PendingDevice {
|
||||
services: Service[]
|
||||
suggested_type: string | null
|
||||
status: string
|
||||
discovery_source: string | null
|
||||
discovered_at: string
|
||||
}
|
||||
|
||||
@@ -101,6 +102,9 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
||||
{device.suggested_type && (
|
||||
<InfoRow label="Type" value={device.suggested_type} />
|
||||
)}
|
||||
{device.discovery_source && (
|
||||
<InfoRow label="Source" value={device.discovery_source.toUpperCase()} />
|
||||
)}
|
||||
<InfoRow label="Discovered" value={new Date(device.discovered_at).toLocaleString()} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -99,7 +99,6 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="outline" onClick={handleSave} disabled={saving}>Save</Button>
|
||||
<Button
|
||||
onClick={handleScanNow}
|
||||
disabled={saving}
|
||||
|
||||
@@ -1,170 +1,414 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { NodeModal } from '../NodeModal'
|
||||
import type { NodeData } from '@/types'
|
||||
|
||||
// ── Mock Shadcn Select with native <select> for testability ───────────────
|
||||
|
||||
vi.mock('@/components/ui/select', () => ({
|
||||
Select: ({ value, onValueChange, children }: {
|
||||
value?: string; onValueChange?: (v: string) => void; children: React.ReactNode
|
||||
}) => (
|
||||
<select value={value} onChange={(e) => onValueChange?.(e.target.value)}>
|
||||
{children}
|
||||
</select>
|
||||
),
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
SelectValue: () => null,
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
SelectGroup: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
SelectLabel: () => null,
|
||||
SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => (
|
||||
<option value={value}>{children}</option>
|
||||
),
|
||||
SelectSeparator: () => null,
|
||||
}))
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function renderModal(props: Partial<Parameters<typeof NodeModal>[0]> = {}) {
|
||||
const onClose = vi.fn()
|
||||
const onSubmit = vi.fn()
|
||||
render(<NodeModal open onClose={onClose} onSubmit={onSubmit} {...props} />)
|
||||
return { onClose, onSubmit }
|
||||
}
|
||||
|
||||
/** Get <select> elements in document order: [0]=Type, [1]=CheckMethod, [2]=BottomHandles */
|
||||
function selects() { return screen.getAllByRole('combobox') as HTMLSelectElement[] }
|
||||
|
||||
const BASE: Partial<NodeData> = {
|
||||
type: 'server', label: 'My Server', hostname: 'server.lan',
|
||||
ip: '192.168.1.10', check_method: 'ping', services: [],
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('NodeModal', () => {
|
||||
|
||||
// ── Visibility ────────────────────────────────────────────────────────
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(
|
||||
<NodeModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />
|
||||
)
|
||||
const { container } = render(<NodeModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders form fields when open', () => {
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
renderModal()
|
||||
expect(screen.getByPlaceholderText('My Server')).toBeDefined()
|
||||
expect(screen.getByText('Add Node')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not call onSubmit when label is empty and shows error', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
it('shows "Add" button for default title', () => {
|
||||
renderModal()
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows "Save" button when title is Edit Node', () => {
|
||||
renderModal({ title: 'Edit Node' })
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('pre-fills form from initial prop', () => {
|
||||
renderModal({ initial: BASE })
|
||||
expect((screen.getByPlaceholderText('My Server') as HTMLInputElement).value).toBe('My Server')
|
||||
expect((screen.getByPlaceholderText('server.lan') as HTMLInputElement).value).toBe('server.lan')
|
||||
expect((screen.getByPlaceholderText('192.168.1.x') as HTMLInputElement).value).toBe('192.168.1.10')
|
||||
})
|
||||
|
||||
// ── Cancel ────────────────────────────────────────────────────────────
|
||||
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
const { onClose } = renderModal()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// ── Label validation ──────────────────────────────────────────────────
|
||||
|
||||
it('blocks submit and shows error when label is empty', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Label is required')).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls onSubmit with form data when label is filled', () => {
|
||||
const onSubmit = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(<NodeModal open onClose={onClose} onSubmit={onSubmit} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'My NAS' } })
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(onSubmit).toHaveBeenCalledOnce()
|
||||
expect(onSubmit.mock.calls[0][0].label).toBe('My NAS')
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
it('blocks submit when label is whitespace only', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: ' ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears label error when user starts typing', () => {
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(screen.getByText('Label is required')).toBeDefined()
|
||||
renderModal()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'x' } })
|
||||
expect(screen.queryByText('Label is required')).toBeNull()
|
||||
})
|
||||
|
||||
it('pre-fills form from initial prop', () => {
|
||||
render(
|
||||
<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'Pre-filled', ip: '10.0.0.1' }} />
|
||||
)
|
||||
const input = screen.getByPlaceholderText('My Server') as HTMLInputElement
|
||||
expect(input.value).toBe('Pre-filled')
|
||||
})
|
||||
// ── Form submission ───────────────────────────────────────────────────
|
||||
|
||||
it('shows Save button text when title is Edit Node', () => {
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Node" />)
|
||||
expect(screen.getByText('Save')).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<NodeModal open onClose={onClose} onSubmit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
it('calls onSubmit and onClose with form data on valid submit', () => {
|
||||
const { onSubmit, onClose } = renderModal({ initial: BASE })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect(onSubmit).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
||||
expect(data.label).toBe('My Server')
|
||||
expect(data.type).toBe('server')
|
||||
})
|
||||
|
||||
describe('Hardware section', () => {
|
||||
it('renders Hardware toggle button', () => {
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.getByText('Hardware')).toBeDefined()
|
||||
})
|
||||
it('submits updated hostname, IP and notes', () => {
|
||||
const { onSubmit } = renderModal({ initial: BASE })
|
||||
fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'nas.local' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.x'), { target: { value: '10.0.0.1' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Optional notes'), { target: { value: 'rack A' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
||||
expect(data.hostname).toBe('nas.local')
|
||||
expect(data.ip).toBe('10.0.0.1')
|
||||
expect(data.notes).toBe('rack A')
|
||||
})
|
||||
|
||||
it('hardware fields are hidden by default', () => {
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.queryByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeNull()
|
||||
})
|
||||
it('submits check_target', () => {
|
||||
const { onSubmit } = renderModal({ initial: BASE })
|
||||
fireEvent.change(screen.getByPlaceholderText('http://...'), { target: { value: 'http://192.168.1.10:8080' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).check_target).toBe('http://192.168.1.10:8080')
|
||||
})
|
||||
|
||||
it('expands hardware fields on toggle click', () => {
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Hardware'))
|
||||
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('e.g. 8')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('e.g. 32')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('e.g. 500')).toBeDefined()
|
||||
})
|
||||
// ── Type selector ─────────────────────────────────────────────────────
|
||||
|
||||
it('submits hardware fields when filled', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Homelab' } })
|
||||
fireEvent.click(screen.getByText('Hardware'))
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680'), { target: { value: 'Intel i7-12700K' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 8'), { target: { value: '12' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 32'), { target: { value: '64' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 500'), { target: { value: '2000' } })
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0]
|
||||
expect(submitted.cpu_model).toBe('Intel i7-12700K')
|
||||
expect(submitted.cpu_count).toBe(12)
|
||||
expect(submitted.ram_gb).toBe(64)
|
||||
expect(submitted.disk_gb).toBe(2000)
|
||||
})
|
||||
it('pre-fills type from initial', () => {
|
||||
renderModal({ initial: { ...BASE, type: 'router' } })
|
||||
expect(selects()[0].value).toBe('router')
|
||||
})
|
||||
|
||||
it('auto-expands when initial has hardware data', () => {
|
||||
render(
|
||||
<NodeModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
initial={{ label: 'Server', cpu_count: 8, ram_gb: 32 }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
|
||||
})
|
||||
it('changes type and submits it', () => {
|
||||
const { onSubmit } = renderModal({ initial: BASE })
|
||||
fireEvent.change(selects()[0], { target: { value: 'nas' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).type).toBe('nas')
|
||||
})
|
||||
|
||||
it('hides hardware section for groupRect type', () => {
|
||||
render(
|
||||
<NodeModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
initial={{ type: 'groupRect' }}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText('Hardware')).toBeNull()
|
||||
})
|
||||
// ── Check method ──────────────────────────────────────────────────────
|
||||
|
||||
it('show on node toggle is hidden when section is collapsed', () => {
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.queryByText('Show on node')).toBeNull()
|
||||
})
|
||||
it('pre-fills check_method from initial', () => {
|
||||
renderModal({ initial: { ...BASE, check_method: 'http' } })
|
||||
expect(selects()[1].value).toBe('http')
|
||||
})
|
||||
|
||||
it('show on node toggle appears when section is expanded', () => {
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Hardware'))
|
||||
expect(screen.getByText('Show on node')).toBeDefined()
|
||||
})
|
||||
it('changes check_method and submits it', () => {
|
||||
const { onSubmit } = renderModal({ initial: BASE })
|
||||
fireEvent.change(selects()[1], { target: { value: 'ssh' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).check_method).toBe('ssh')
|
||||
})
|
||||
|
||||
it('show_hardware defaults to false', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(onSubmit.mock.calls[0][0].show_hardware).toBeFalsy()
|
||||
})
|
||||
// ── Icon picker ───────────────────────────────────────────────────────
|
||||
|
||||
it('toggling show on node sets show_hardware to true', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
|
||||
fireEvent.click(screen.getByText('Hardware'))
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
|
||||
})
|
||||
it('shows "Default" label when no custom icon', () => {
|
||||
renderModal({ initial: BASE })
|
||||
expect(screen.getByText('Default')).toBeDefined()
|
||||
})
|
||||
|
||||
it('pre-fills show_hardware from initial prop', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(
|
||||
<NodeModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
initial={{ label: 'Node', show_hardware: true, cpu_count: 8 }}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
|
||||
it('opens icon picker on trigger button click', () => {
|
||||
renderModal({ initial: BASE })
|
||||
expect(screen.queryByPlaceholderText('Search icons…')).toBeNull()
|
||||
fireEvent.click(screen.getByText('Default'))
|
||||
expect(screen.getByPlaceholderText('Search icons…')).toBeDefined()
|
||||
})
|
||||
|
||||
it('closes picker and shows icon label after selecting an icon', () => {
|
||||
renderModal({ initial: BASE })
|
||||
fireEvent.click(screen.getByText('Default'))
|
||||
fireEvent.click(screen.getByTitle('Database (SQL/NoSQL)'))
|
||||
expect(screen.queryByPlaceholderText('Search icons…')).toBeNull()
|
||||
expect(screen.getByText('Database (SQL/NoSQL)')).toBeDefined()
|
||||
})
|
||||
|
||||
it('submits custom_icon key after picking', () => {
|
||||
const { onSubmit } = renderModal({ initial: BASE })
|
||||
fireEvent.click(screen.getByText('Default'))
|
||||
fireEvent.click(screen.getByTitle('Database (SQL/NoSQL)'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).custom_icon).toBe('database')
|
||||
})
|
||||
|
||||
it('shows Reset button when custom_icon is set', () => {
|
||||
renderModal({ initial: { ...BASE, custom_icon: 'database' } })
|
||||
expect(screen.getByRole('button', { name: /Reset/i })).toBeDefined()
|
||||
})
|
||||
|
||||
it('hides Reset button when no custom_icon', () => {
|
||||
renderModal({ initial: BASE })
|
||||
expect(screen.queryByRole('button', { name: /Reset/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('resets custom_icon and shows Default on Reset click', () => {
|
||||
renderModal({ initial: { ...BASE, custom_icon: 'database' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Reset/i }))
|
||||
expect(screen.getByText('Default')).toBeDefined()
|
||||
})
|
||||
|
||||
it('filters icons by search query', () => {
|
||||
renderModal({ initial: BASE })
|
||||
fireEvent.click(screen.getByText('Default'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Search icons…'), { target: { value: 'grafana' } })
|
||||
expect(screen.getByTitle('Grafana / Kibana')).toBeDefined()
|
||||
expect(screen.queryByTitle('Router')).toBeNull()
|
||||
})
|
||||
|
||||
// ── Container mode (proxmox only) ─────────────────────────────────────
|
||||
|
||||
it('shows Container Mode toggle for proxmox type', () => {
|
||||
renderModal({ initial: { ...BASE, type: 'proxmox' } })
|
||||
expect(screen.getByText('Container Mode')).toBeDefined()
|
||||
})
|
||||
|
||||
it('hides Container Mode for non-proxmox types', () => {
|
||||
renderModal({ initial: BASE })
|
||||
expect(screen.queryByText('Container Mode')).toBeNull()
|
||||
})
|
||||
|
||||
it('toggles container_mode on click', () => {
|
||||
const { onSubmit } = renderModal({ initial: { ...BASE, type: 'proxmox', container_mode: true } })
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).container_mode).toBe(false)
|
||||
})
|
||||
|
||||
// ── Parent Proxmox (vm / lxc only) ───────────────────────────────────
|
||||
|
||||
it('shows Parent Proxmox for vm with proxmoxNodes', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'vm' },
|
||||
proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }],
|
||||
})
|
||||
expect(screen.getByText('Parent Proxmox')).toBeDefined()
|
||||
expect(screen.getByText('PVE-01')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows Parent Proxmox for lxc with proxmoxNodes', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'lxc' },
|
||||
proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }],
|
||||
})
|
||||
expect(screen.getByText('Parent Proxmox')).toBeDefined()
|
||||
})
|
||||
|
||||
it('hides Parent Proxmox for server type', () => {
|
||||
renderModal({ initial: BASE, proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }] })
|
||||
expect(screen.queryByText('Parent Proxmox')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides Parent Proxmox for vm when no proxmoxNodes', () => {
|
||||
renderModal({ initial: { ...BASE, type: 'vm' } })
|
||||
expect(screen.queryByText('Parent Proxmox')).toBeNull()
|
||||
})
|
||||
|
||||
// ── Appearance ────────────────────────────────────────────────────────
|
||||
|
||||
it('renders 3 color swatch labels (border, background, icon)', () => {
|
||||
renderModal({ initial: BASE })
|
||||
expect(screen.getByText('border')).toBeDefined()
|
||||
expect(screen.getByText('background')).toBeDefined()
|
||||
expect(screen.getByText('icon')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows default colors hint when no custom_colors', () => {
|
||||
renderModal({ initial: BASE })
|
||||
expect(screen.getByText(/Using default colors for/)).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows Reset to defaults when custom_colors are set', () => {
|
||||
renderModal({ initial: { ...BASE, custom_colors: { border: '#ff0000' } } })
|
||||
expect(screen.getByText('Reset to defaults')).toBeDefined()
|
||||
})
|
||||
|
||||
it('resets custom_colors on Reset to defaults click', () => {
|
||||
renderModal({ initial: { ...BASE, custom_colors: { border: '#ff0000' } } })
|
||||
fireEvent.click(screen.getByText('Reset to defaults'))
|
||||
expect(screen.queryByText('Reset to defaults')).toBeNull()
|
||||
expect(screen.getByText(/Using default colors for/)).toBeDefined()
|
||||
})
|
||||
|
||||
// ── Hardware section ──────────────────────────────────────────────────
|
||||
|
||||
it('renders Hardware toggle button', () => {
|
||||
renderModal()
|
||||
expect(screen.getByText('Hardware')).toBeDefined()
|
||||
})
|
||||
|
||||
it('hardware fields are hidden by default', () => {
|
||||
renderModal()
|
||||
expect(screen.queryByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeNull()
|
||||
})
|
||||
|
||||
it('expands hardware fields on toggle click', () => {
|
||||
renderModal()
|
||||
fireEvent.click(screen.getByText('Hardware'))
|
||||
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('e.g. 8')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('e.g. 32')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('e.g. 500')).toBeDefined()
|
||||
})
|
||||
|
||||
it('auto-expands when initial has hardware data', () => {
|
||||
renderModal({ initial: { ...BASE, cpu_count: 8, ram_gb: 32 } })
|
||||
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
|
||||
})
|
||||
|
||||
it('pre-fills hardware fields from initial', () => {
|
||||
renderModal({ initial: { ...BASE, cpu_model: 'Intel i5', cpu_count: 4, ram_gb: 16, disk_gb: 500 } })
|
||||
expect((screen.getByPlaceholderText('e.g. Intel Xeon E5-2680') as HTMLInputElement).value).toBe('Intel i5')
|
||||
})
|
||||
|
||||
it('submits hardware fields when filled', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Homelab' } })
|
||||
fireEvent.click(screen.getByText('Hardware'))
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680'), { target: { value: 'Intel i7-12700K' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 8'), { target: { value: '12' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 32'), { target: { value: '64' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 500'), { target: { value: '2000' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
||||
expect(data.cpu_model).toBe('Intel i7-12700K')
|
||||
expect(data.cpu_count).toBe(12)
|
||||
expect(data.ram_gb).toBe(64)
|
||||
expect(data.disk_gb).toBe(2000)
|
||||
})
|
||||
|
||||
it('hides Hardware section for groupRect type', () => {
|
||||
renderModal({ initial: { type: 'groupRect' } })
|
||||
expect(screen.queryByText('Hardware')).toBeNull()
|
||||
})
|
||||
|
||||
it('show_hardware toggle hidden when section is collapsed', () => {
|
||||
renderModal()
|
||||
expect(screen.queryByText('Show on node')).toBeNull()
|
||||
})
|
||||
|
||||
it('show_hardware toggle appears when section is expanded', () => {
|
||||
renderModal()
|
||||
fireEvent.click(screen.getByText('Hardware'))
|
||||
expect(screen.getByText('Show on node')).toBeDefined()
|
||||
})
|
||||
|
||||
it('show_hardware defaults to falsy', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect(onSubmit.mock.calls[0][0].show_hardware).toBeFalsy()
|
||||
})
|
||||
|
||||
it('toggling show_hardware sets it to true', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
|
||||
fireEvent.click(screen.getByText('Hardware'))
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
|
||||
})
|
||||
|
||||
it('pre-fills show_hardware from initial', () => {
|
||||
const { onSubmit } = renderModal({ initial: { label: 'Node', show_hardware: true, cpu_count: 8 } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
|
||||
})
|
||||
|
||||
// ── Bottom connection points ───────────────────────────────────────────
|
||||
|
||||
it('shows Bottom Connection Points for server type', () => {
|
||||
renderModal({ initial: BASE })
|
||||
expect(screen.getByText('Bottom Connection Points')).toBeDefined()
|
||||
})
|
||||
|
||||
it('hides Bottom Connection Points for groupRect', () => {
|
||||
renderModal({ initial: { ...BASE, type: 'groupRect' } })
|
||||
expect(screen.queryByText('Bottom Connection Points')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides Bottom Connection Points for group', () => {
|
||||
renderModal({ initial: { ...BASE, type: 'group' } })
|
||||
expect(screen.queryByText('Bottom Connection Points')).toBeNull()
|
||||
})
|
||||
|
||||
it('defaults bottom_handles to 1', () => {
|
||||
renderModal({ initial: BASE })
|
||||
expect(selects()[2].value).toBe('1')
|
||||
})
|
||||
|
||||
it('pre-fills bottom_handles from initial', () => {
|
||||
renderModal({ initial: { ...BASE, bottom_handles: 3 } })
|
||||
expect(selects()[2].value).toBe('3')
|
||||
})
|
||||
|
||||
it('submits updated bottom_handles', () => {
|
||||
const { onSubmit } = renderModal({ initial: BASE })
|
||||
fireEvent.change(selects()[2], { target: { value: '4' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,16 +38,6 @@ describe('ScanConfigModal', () => {
|
||||
expect(input).toBeDefined()
|
||||
})
|
||||
|
||||
it('saves only ranges (interval managed by settings endpoint)', async () => {
|
||||
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['10.0.0.0/8'] } } as never)
|
||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||
await screen.findByDisplayValue('10.0.0.0/8')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => {
|
||||
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['10.0.0.0/8'] })
|
||||
})
|
||||
})
|
||||
|
||||
it('adds a new empty range on "Add range" click', async () => {
|
||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||
await screen.findByDisplayValue('192.168.1.0/24')
|
||||
@@ -59,53 +49,29 @@ describe('ScanConfigModal', () => {
|
||||
it('delete button disabled when only one range', async () => {
|
||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||
await screen.findByDisplayValue('192.168.1.0/24')
|
||||
// Only 1 range → delete button disabled
|
||||
const trashButtons = document.querySelectorAll('button[disabled]')
|
||||
expect(trashButtons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('can remove a range when more than one exist', async () => {
|
||||
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['192.168.1.0/24', '10.0.0.0/8'], } } as never)
|
||||
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['192.168.1.0/24', '10.0.0.0/8'] } } as never)
|
||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||
await screen.findByDisplayValue('192.168.1.0/24')
|
||||
// Both trash buttons should be enabled
|
||||
const trashButtons = screen.getAllByRole('button').filter((b) => !b.hasAttribute('disabled') && b.querySelector('svg'))
|
||||
expect(trashButtons.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('shows error toast and does not save when all ranges are empty', async () => {
|
||||
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: [''], } } as never)
|
||||
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: [''] } } as never)
|
||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||
await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Add at least one IP range')
|
||||
})
|
||||
expect(scanApi.saveConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves config and closes on Save click', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(<ScanConfigModal open onClose={onClose} onScanNow={vi.fn()} />)
|
||||
await screen.findByDisplayValue('192.168.1.0/24')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => {
|
||||
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] })
|
||||
expect(toast.success).toHaveBeenCalledWith('Scan config saved')
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error toast when save fails', async () => {
|
||||
vi.mocked(scanApi.saveConfig).mockRejectedValue(new Error('network'))
|
||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||
await screen.findByDisplayValue('192.168.1.0/24')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to save config')
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onScanNow after saving on "Scan Now" click', async () => {
|
||||
const onScanNow = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
@@ -113,7 +79,7 @@ describe('ScanConfigModal', () => {
|
||||
await screen.findByDisplayValue('192.168.1.0/24')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
||||
await waitFor(() => {
|
||||
expect(scanApi.saveConfig).toHaveBeenCalled()
|
||||
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] })
|
||||
expect(onScanNow).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -126,12 +92,11 @@ describe('ScanConfigModal', () => {
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('strips whitespace from ranges before saving', async () => {
|
||||
it('strips whitespace from ranges before scanning', async () => {
|
||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||
const input = await screen.findByDisplayValue('192.168.1.0/24')
|
||||
// Type a range with surrounding whitespace
|
||||
fireEvent.change(input, { target: { value: ' 10.0.0.0/8 ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
||||
await waitFor(() => {
|
||||
expect(scanApi.saveConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ranges: ['10.0.0.0/8'] })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle } from 'lucide-react'
|
||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X } from 'lucide-react'
|
||||
import { Logo } from '@/components/ui/Logo'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
@@ -174,6 +174,16 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleClearAll = async () => {
|
||||
try {
|
||||
await scanApi.clearPending()
|
||||
setDevices([])
|
||||
toast.success('Pending devices cleared')
|
||||
} catch {
|
||||
toast.error('Failed to clear pending devices')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -230,9 +240,16 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
|
||||
<div className="p-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pending</span>
|
||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5">
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5" title="Refresh">
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
{devices.length > 0 && (
|
||||
<button onClick={handleClearAll} className="text-muted-foreground hover:text-[#f85149] p-0.5" title="Clear all pending">
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
|
||||
{!loading && devices.length === 0 && (
|
||||
@@ -252,6 +269,8 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
|
||||
const hasHttps = d.services.some((s) => s.port === 443)
|
||||
const otherCount = d.services.filter((s) => s.port !== 22 && s.port !== 80 && s.port !== 443).length
|
||||
const virtualBadge = detectVirtualBadge(d.mac)
|
||||
const sourceColor = d.discovery_source === 'mdns' ? '#a855f7' : '#8b949e'
|
||||
const sourceLabel = d.discovery_source === 'mdns' ? 'mDNS' : d.discovery_source === 'arp' ? 'ARP' : null
|
||||
return (
|
||||
<button
|
||||
key={d.id}
|
||||
@@ -265,8 +284,9 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
|
||||
{showIpBelow && (
|
||||
<div className="font-mono text-muted-foreground truncate pl-3 text-[10px] mt-0.5">{d.ip}</div>
|
||||
)}
|
||||
{(hasSsh || hasHttp || hasHttps || otherCount > 0 || virtualBadge) && (
|
||||
{(hasSsh || hasHttp || hasHttps || otherCount > 0 || virtualBadge || sourceLabel) && (
|
||||
<div className="flex items-center gap-1 pl-3 mt-1.5 flex-wrap">
|
||||
{sourceLabel && <ServiceBadge label={sourceLabel} color={sourceColor} />}
|
||||
{virtualBadge && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
|
||||
@@ -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 }),
|
||||
}))
|
||||
|
||||
@@ -82,6 +82,7 @@ export interface NodeData extends Record<string, unknown> {
|
||||
height?: number
|
||||
}
|
||||
custom_icon?: string
|
||||
bottom_handles?: number
|
||||
}
|
||||
|
||||
export type EdgePathStyle = 'bezier' | 'smooth'
|
||||
|
||||
@@ -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