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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user