diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py index babfea6..476ec23 100644 --- a/backend/app/api/routes/scan.py +++ b/backend/app/api/routes/scan.py @@ -174,6 +174,41 @@ async def bulk_hide_devices( return {"hidden": len(devices), "skipped": len(payload.device_ids) - len(devices)} +@router.post("/pending/{device_id}/restore", response_model=dict) +async def restore_device( + device_id: str, + db: AsyncSession = Depends(get_db), + _: str = Depends(get_current_user), +) -> dict[str, Any]: + device = await db.get(PendingDevice, device_id) + if not device: + raise HTTPException(status_code=404, detail="Device not found") + if device.status != "hidden": + raise HTTPException(status_code=409, detail="Device is not hidden") + device.status = "pending" + await db.commit() + return {"restored": True, "device_id": device_id} + + +@router.post("/pending/bulk-restore", response_model=dict) +async def bulk_restore_devices( + payload: BulkActionRequest, + db: AsyncSession = Depends(get_db), + _: str = Depends(get_current_user), +) -> dict[str, Any]: + result = await db.execute( + select(PendingDevice).where( + PendingDevice.id.in_(payload.device_ids), + PendingDevice.status == "hidden", + ) + ) + devices = result.scalars().all() + for device in devices: + device.status = "pending" + await db.commit() + return {"restored": len(devices), "skipped": len(payload.device_ids) - len(devices)} + + @router.post("/pending/{device_id}/approve", response_model=dict) async def approve_device( device_id: str, diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py index f59f0ad..3fdf851 100644 --- a/backend/tests/test_scan.py +++ b/backend/tests/test_scan.py @@ -140,6 +140,49 @@ async def test_hide_device(client: AsyncClient, headers, pending_device): assert len(hidden_res.json()) == 1 +# --- Restore hidden device --- + +@pytest.mark.asyncio +async def test_restore_device(client: AsyncClient, headers, pending_device): + # Hide first + await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers) + + # Restore + res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers) + assert res.status_code == 200 + assert res.json()["restored"] is True + + # Now back in pending, gone from hidden + pending_res = await client.get("/api/v1/scan/pending", headers=headers) + assert len(pending_res.json()) == 1 + hidden_res = await client.get("/api/v1/scan/hidden", headers=headers) + assert hidden_res.json() == [] + + +@pytest.mark.asyncio +async def test_restore_device_rejects_non_hidden(client: AsyncClient, headers, pending_device): + res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers) + assert res.status_code == 409 + + +@pytest.mark.asyncio +async def test_bulk_restore_devices(client: AsyncClient, headers, pending_device): + # Hide + await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers) + + res = await client.post( + "/api/v1/scan/pending/bulk-restore", + headers=headers, + json={"device_ids": [pending_device.id]}, + ) + assert res.status_code == 200 + assert res.json()["restored"] == 1 + assert res.json()["skipped"] == 0 + + pending_res = await client.get("/api/v1/scan/pending", headers=headers) + assert len(pending_res.json()) == 1 + + # --- Ignore device --- @pytest.mark.asyncio diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 018b38e..0fc3282 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,7 @@ import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal' import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal' import { ThemeModal } from '@/components/modals/ThemeModal' import { SearchModal } from '@/components/modals/SearchModal' +import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal' import { ShortcutsModal } from '@/components/modals/ShortcutsModal' import { useCanvasStore } from '@/stores/canvasStore' import { useAuthStore } from '@/stores/authStore' @@ -47,8 +48,16 @@ export default function App() { const [themeModalOpen, setThemeModalOpen] = useState(false) const [searchOpen, setSearchOpen] = useState(false) - const [sidebarForceView, setSidebarForceView] = useState<'pending' | 'history' | undefined>(undefined) - const [highlightPendingId, setHighlightPendingId] = useState(undefined) + const [sidebarForceView, setSidebarForceView] = useState<'history' | undefined>(undefined) + const [pendingModalOpen, setPendingModalOpen] = useState(false) + const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending') + const [pendingHighlightId, setPendingHighlightId] = useState(undefined) + const openPendingModal = useCallback((deviceId?: string, status: 'pending' | 'hidden' = 'pending') => { + setPendingHighlightId(undefined) + setPendingModalStatus(status) + setPendingModalOpen(true) + if (deviceId) setTimeout(() => setPendingHighlightId(deviceId), 0) + }, []) const [shortcutsOpen, setShortcutsOpen] = useState(false) const [addNodeOpen, setAddNodeOpen] = useState(false) const [addGroupRectOpen, setAddGroupRectOpen] = useState(false) @@ -437,9 +446,8 @@ export default function App() { onScan={() => setScanConfigOpen(true)} onZigbeeImport={() => setZigbeeImportOpen(true)} onSave={handleSave} - onNodeApproved={setEditNodeId} forceView={sidebarForceView} - highlightPendingId={highlightPendingId} + onOpenPending={openPendingModal} />
{ - setHighlightPendingId(undefined) - setSidebarForceView(undefined) - setTimeout(() => { - setHighlightPendingId(deviceId) - setSidebarForceView('pending') - }, 0) - }} + onOpenPending={(deviceId) => openPendingModal(deviceId)} />
{(selectedNodeId || selectedNodeIds.length > 1) && } @@ -608,17 +609,17 @@ export default function App() { setSearchOpen(false)} - onOpenPending={(deviceId) => { - setHighlightPendingId(undefined) - setSidebarForceView(undefined) - setTimeout(() => { - setHighlightPendingId(deviceId) - setSidebarForceView('pending') - }, 0) - }} + onOpenPending={(deviceId) => openPendingModal(deviceId)} /> setShortcutsOpen(false)} /> + setPendingModalOpen(false)} + highlightId={pendingHighlightId} + initialStatus={pendingModalStatus} + /> + setExportModalOpen(false)} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index dd4f552..1bba57e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -77,6 +77,8 @@ export const scanApi = { skipped: number }>('/scan/pending/bulk-approve', { device_ids: ids }), bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }), + restore: (id: string) => api.post<{ restored: boolean; device_id: string }>(`/scan/pending/${id}/restore`), + bulkRestore: (ids: string[]) => api.post<{ restored: number; skipped: number }>('/scan/pending/bulk-restore', { device_ids: ids }), stop: (runId: string) => api.post(`/scan/${runId}/stop`), getConfig: () => api.get<{ ranges: string[] }>('/scan/config'), saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data), diff --git a/frontend/src/components/modals/PendingDevicesModal.tsx b/frontend/src/components/modals/PendingDevicesModal.tsx new file mode 100644 index 0000000..8f03be6 --- /dev/null +++ b/frontend/src/components/modals/PendingDevicesModal.tsx @@ -0,0 +1,658 @@ +import { useState, useEffect, useCallback, useRef, useMemo } from 'react' +import { + Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network, + Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2, +} from 'lucide-react' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { scanApi } from '@/api/client' +import { useCanvasStore } from '@/stores/canvasStore' +import { toast } from 'sonner' +import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal' +import type { NodeType, ServiceInfo } from '@/types' + +interface PendingDevicesModalProps { + open: boolean + onClose: () => void + highlightId?: string + initialStatus?: 'pending' | 'hidden' +} + +const PORT_COLORS: Record = { + 22: '#a855f7', // SSH purple + 80: '#00d4ff', // HTTP cyan + 443: '#39d353', // HTTPS green + 53: '#e3b341', // DNS amber + 3306: '#a855f7', // MySQL + 5432: '#a855f7', // Postgres + 6379: '#f85149', // Redis + 9090: '#e3b341', // Prometheus + 3000: '#00d4ff', // Grafana/dev + 8080: '#00d4ff', + 8443: '#39d353', +} + +const CATEGORY_COLORS: Record = { + hypervisor: '#ff6e00', + nas: '#39d353', + automation: '#a855f7', + containers: '#00d4ff', + network: '#39d353', + security: '#f85149', + monitoring: '#e3b341', + database: '#a855f7', + web: '#00d4ff', + media: '#ff6e00', + iot: '#e3b341', +} + +function serviceColor(port: number | null | undefined, category?: string | null): string { + if (port != null && PORT_COLORS[port]) return PORT_COLORS[port] + if (category && CATEGORY_COLORS[category.toLowerCase()]) return CATEGORY_COLORS[category.toLowerCase()] + return '#8b949e' +} + +const TYPE_ICONS: Record = { + isp: Globe, + router: Router, + server: Server, + proxmox: Layers, + vm: Box, + lxc: Container, + nas: HardDrive, + iot: Cpu, + ap: Wifi, + switch: Network, + generic: Circle, +} + +type SourceFilter = 'all' | 'ip' | 'zigbee' +type StatusFilter = 'pending' | 'hidden' + +function inferSource(d: PendingDevice): 'zigbee' | 'ip' { + if (d.discovery_source === 'zigbee' || d.ieee_address) return 'zigbee' + return 'ip' +} + +const COMMON_PORTS = new Set([22, 80, 443]) + +function specialServiceName(d: PendingDevice): string | undefined { + const candidates = (d.services ?? []).filter( + (s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port) && s.service_name, + ) + // Deprioritize generic web category so apps like home assistant / jellyfin win + const nonWeb = candidates.find((s) => s.category?.toLowerCase() !== 'web') + return (nonWeb ?? candidates[0])?.service_name ?? undefined +} + +function deviceLabel(d: PendingDevice): string { + return d.friendly_name ?? d.hostname ?? specialServiceName(d) ?? d.ip ?? d.ieee_address ?? 'device' +} + +function injectAutoEdges(edges: { id: string; source: string; target: string }[] | undefined) { + if (!edges || edges.length === 0) return + useCanvasStore.setState((state) => ({ + edges: [ + ...state.edges, + ...edges.map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + sourceHandle: 'bottom', + targetHandle: 'top-t', + type: 'iot', + data: { type: 'iot' as const }, + })), + ], + hasUnsavedChanges: true, + })) +} + +export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending' }: PendingDevicesModalProps) { + const [devices, setDevices] = useState([]) + const [loading, setLoading] = useState(false) + const [selected, setSelected] = useState(null) + const [selectMode, setSelectMode] = useState(false) + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [search, setSearch] = useState('') + const [sourceFilter, setSourceFilter] = useState('all') + const [typeFilter, setTypeFilter] = useState('all') + const [statusFilter, setStatusFilter] = useState(initialStatus) + const { addNode, scanEventTs } = useCanvasStore() + const highlightRef = useRef(null) + + const load = useCallback(async () => { + setLoading(true) + try { + const res = statusFilter === 'pending' ? await scanApi.pending() : await scanApi.hidden() + setDevices(res.data) + } catch { + toast.error(`Failed to load ${statusFilter} devices`) + } finally { + setLoading(false) + } + }, [statusFilter]) + + useEffect(() => { if (open) load() }, [open, load]) + useEffect(() => { if (open && scanEventTs > 0) load() }, [scanEventTs, open, load]) + + // Reset transient state when reopening + useEffect(() => { + if (!open) { + setSelectMode(false) + setSelectedIds(new Set()) + setSearch('') + } else { + setStatusFilter(initialStatus) + } + }, [open, initialStatus]) + + const distinctTypes = useMemo(() => { + const set = new Set() + devices.forEach((d) => { if (d.suggested_type) set.add(d.suggested_type) }) + return [...set].sort() + }, [devices]) + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase() + return devices.filter((d) => { + if (sourceFilter !== 'all' && inferSource(d) !== sourceFilter) return false + if (typeFilter !== 'all' && d.suggested_type !== typeFilter) return false + if (q) { + const hay = [ + d.friendly_name, d.hostname, d.ip, d.mac, d.ieee_address, d.vendor, d.model, + ...d.services.map((s) => s.service_name), + ].filter(Boolean).join(' ').toLowerCase() + if (!hay.includes(q)) return false + } + return true + }) + }, [devices, search, sourceFilter, typeFilter]) + + useEffect(() => { + if (!highlightId || loading || !open) return + highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + }, [highlightId, loading, open, filtered]) + + const toggleSelect = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id); else next.add(id) + return next + }) + } + + const handleCardClick = (d: PendingDevice) => { + if (selectMode) { toggleSelect(d.id); return } + if (statusFilter === 'hidden') { handleRestore(d); return } + setSelected(d) + } + + const handleRestore = async (device: PendingDevice) => { + try { + await scanApi.restore(device.id) + setDevices((prev) => prev.filter((d) => d.id !== device.id)) + toast.success(`Restored ${deviceLabel(device)}`) + } catch { + toast.error('Failed to restore device') + } + } + + const handleBulkRestore = async () => { + const ids = [...selectedIds] + if (ids.length === 0) return + try { + const res = await scanApi.bulkRestore(ids) + setDevices((prev) => prev.filter((d) => !ids.includes(d.id))) + setSelectedIds(new Set()) + toast.success(`Restored ${res.data.restored} device${res.data.restored !== 1 ? 's' : ''}`) + } catch { + toast.error('Failed to bulk restore devices') + } + } + + const enterSelectMode = () => { + setSelectMode(true) + } + + const exitSelectMode = () => { + setSelectMode(false) + setSelectedIds(new Set()) + } + + const selectAllVisible = () => { + setSelectedIds(new Set(filtered.map((d) => d.id))) + } + + const handleClearAll = async () => { + try { + await scanApi.clearPending() + setDevices([]) + setSelectedIds(new Set()) + toast.success('Pending devices cleared') + } catch { + toast.error('Failed to clear pending devices') + } + } + + const handleApprove = async (device: PendingDevice) => { + try { + const fallbackLabel = deviceLabel(device) + const nodeData = { + label: fallbackLabel, + type: (device.suggested_type ?? 'generic') as NodeType, + ip: device.ip ?? undefined, + hostname: device.hostname ?? undefined, + status: 'unknown', + services: (device.services ?? []) as ServiceInfo[], + } + const res = await scanApi.approve(device.id, nodeData) + const nodeId = res.data.node_id + addNode({ + id: nodeId, + type: nodeData.type, + position: { x: 400, y: 300 }, + data: { ...nodeData, status: 'unknown' as const }, + }) + injectAutoEdges(res.data.edges) + const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : '' + toast.success(`Approved ${nodeData.label}${extra}`) + setDevices((prev) => prev.filter((d) => d.id !== device.id)) + setSelected(null) + onNodeApproved(nodeId) + } catch { + toast.error('Failed to approve device') + } + } + + const handleHide = async (device: PendingDevice) => { + try { + await scanApi.hide(device.id) + setDevices((prev) => prev.filter((d) => d.id !== device.id)) + setSelected(null) + toast.success('Device hidden') + } catch { + toast.error('Failed to hide device') + } + } + + const handleIgnore = async (device: PendingDevice) => { + try { + await scanApi.ignore(device.id) + setDevices((prev) => prev.filter((d) => d.id !== device.id)) + setSelected(null) + } catch { + toast.error('Failed to remove device') + } + } + + const handleBulkApprove = async () => { + const ids = [...selectedIds] + if (ids.length === 0) return + try { + const res = await scanApi.bulkApprove(ids) + const deviceToNode: Record = {} + res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] }) + const approvedDevices = devices.filter((d) => ids.includes(d.id)) + approvedDevices.forEach((d, i) => { + const nodeId = deviceToNode[d.id] + if (!nodeId) return + addNode({ + id: nodeId, + type: (d.suggested_type ?? 'generic') as NodeType, + position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 }, + data: { + label: deviceLabel(d), + type: (d.suggested_type ?? 'generic') as NodeType, + ip: d.ip ?? undefined, + hostname: d.hostname ?? undefined, + status: 'unknown' as const, + services: (d.services ?? []) as ServiceInfo[], + }, + }) + }) + injectAutoEdges(res.data.edges) + setDevices((prev) => prev.filter((d) => !ids.includes(d.id))) + setSelectedIds(new Set()) + const linkExtra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : '' + toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}${linkExtra}`) + } catch { + toast.error('Failed to bulk approve devices') + } + } + + const handleBulkHide = async () => { + const ids = [...selectedIds] + if (ids.length === 0) return + try { + const res = await scanApi.bulkHide(ids) + setDevices((prev) => prev.filter((d) => !ids.includes(d.id))) + setSelectedIds(new Set()) + toast.success(`Hidden ${res.data.hidden} device${res.data.hidden !== 1 ? 's' : ''}`) + } catch { + toast.error('Failed to bulk hide devices') + } + } + + // Keyboard shortcuts: 's' select-mode, 'a' select-all-visible, Esc clears selection or closes, '/' focuses search + const searchRef = useRef(null) + useEffect(() => { + if (!open) return + const handler = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null + const inField = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') + if (e.key === 'Escape') { + if (selectMode && selectedIds.size > 0) { e.preventDefault(); setSelectedIds(new Set()) } + return + } + if (inField) return + if (e.key === '/') { e.preventDefault(); searchRef.current?.focus() } + else if (e.key.toLowerCase() === 's') { e.preventDefault(); if (selectMode) exitSelectMode(); else enterSelectMode() } + else if (e.key.toLowerCase() === 'a' && selectMode) { e.preventDefault(); selectAllVisible() } + else if (e.key === 'Enter' && selectMode && selectedIds.size > 0) { e.preventDefault(); handleBulkApprove() } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }) + + return ( + <> + { if (!v) onClose() }}> + + +
+ + {statusFilter === 'pending' ? 'Pending Devices' : 'Hidden Devices'} + + ({filtered.length}{filtered.length !== devices.length && ` of ${devices.length}`}) + + +
+ + {statusFilter === 'pending' && devices.length > 0 && ( + + )} + +
+
+
+ + {/* Toolbar */} +
+
+ + setSearch(e.target.value)} + placeholder="Search name, IP, MAC, IEEE, service…" + className="w-full text-xs bg-[#0d1117] border border-border rounded px-7 py-1.5 outline-none focus:border-[#00d4ff]/50" + /> +
+
+ + + +
+ +
+ + +
+ +
+ + {/* Body */} +
+ {loading && ( +
+ +
+ )} + {!loading && filtered.length === 0 && ( +

+ {devices.length === 0 ? `No ${statusFilter} devices` : 'No devices match filters'} +

+ )} + {!loading && filtered.length > 0 && ( +
+ {filtered.map((d) => ( + handleCardClick(d)} + cardRef={d.id === highlightId ? highlightRef : undefined} + /> + ))} +
+ )} +
+ + {/* Selection action bar */} + {selectMode && ( +
+ + {selectedIds.size} selected + + + +
+ {statusFilter === 'pending' && ( + <> + + + + )} + {statusFilter === 'hidden' && ( + + )} +
+ )} + +
+ + setSelected(null)} + onApprove={handleApprove} + onHide={handleHide} + onIgnore={handleIgnore} + /> + + ) +} + +interface DeviceCardProps { + device: PendingDevice + selected: boolean + selectMode: boolean + highlighted: boolean + onClick: () => void + cardRef?: React.Ref +} + +function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRef }: DeviceCardProps) { + const source = inferSource(device) + const Icon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle + const label = deviceLabel(device) + const sourceColor = source === 'zigbee' ? '#00d4ff' : '#a855f7' + const sourceLabel = source === 'zigbee' ? 'ZIGBEE' : (device.discovery_source ?? 'IP').toUpperCase() + const services = device.services ?? [] + const visibleServices = services.slice(0, 4) + const moreServices = services.length - visibleServices.length + + const borderClass = highlighted + ? 'border-[#e3b341] bg-[#2d3748]' + : selected + ? 'border-[#00d4ff] bg-[#00d4ff]/5 shadow-[0_0_0_1px_rgba(0,212,255,0.4)] scale-[1.02]' + : 'border-border bg-[#161b22] hover:border-[#30363d] hover:bg-[#21262d]' + + return ( + + ) +} + +function InfoLine({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ) +} diff --git a/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx b/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx new file mode 100644 index 0000000..6164950 --- /dev/null +++ b/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx @@ -0,0 +1,216 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { PendingDevicesModal } from '../PendingDevicesModal' +import { useCanvasStore } from '@/stores/canvasStore' + +vi.mock('@/stores/canvasStore') + +const mockBulkApprove = vi.fn() +const mockBulkHide = vi.fn() +const mockRestore = vi.fn() +const mockBulkRestore = vi.fn() +const mockApprove = vi.fn() +const mockHide = vi.fn() +const mockPending = vi.fn() +const mockHidden = vi.fn() + +vi.mock('@/api/client', () => ({ + scanApi: { + pending: (...a: unknown[]) => mockPending(...a), + hidden: (...a: unknown[]) => mockHidden(...a), + clearPending: vi.fn().mockResolvedValue({}), + approve: (...a: unknown[]) => mockApprove(...a), + hide: (...a: unknown[]) => mockHide(...a), + ignore: vi.fn().mockResolvedValue({}), + bulkApprove: (...a: unknown[]) => mockBulkApprove(...a), + bulkHide: (...a: unknown[]) => mockBulkHide(...a), + restore: (...a: unknown[]) => mockRestore(...a), + bulkRestore: (...a: unknown[]) => mockBulkRestore(...a), + }, +})) + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +vi.mock('@/components/modals/PendingDeviceModal', () => ({ + PendingDeviceModal: ({ device }: { device: unknown }) => + device ?
: null, +})) + +const DEVICE_IP = { + id: 'dev-a', + ip: '192.168.1.10', + hostname: 'host-a', + mac: 'aa:bb:cc:dd:ee:01', + os: null, + services: [{ port: 80, protocol: 'tcp', service_name: 'http' }], + suggested_type: 'server', + status: 'pending', + discovery_source: 'arp', + discovered_at: '2026-01-01T00:00:00Z', +} + +const DEVICE_ZIGBEE = { + id: 'dev-b', + ip: null, + hostname: null, + mac: null, + os: null, + services: [], + suggested_type: 'iot', + status: 'pending', + discovery_source: 'zigbee', + ieee_address: '0x00124b001234abcd', + friendly_name: 'living-room-bulb', + vendor: 'Philips', + model: 'Hue White', + discovered_at: '2026-01-02T00:00:00Z', +} + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useCanvasStore).mockReturnValue({ + addNode: vi.fn(), + scanEventTs: 0, + } as unknown as ReturnType) + // setState is used by injectAutoEdges + ;(useCanvasStore as unknown as { setState: (fn: unknown) => void }).setState = vi.fn() + mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZIGBEE] }) + mockHidden.mockResolvedValue({ data: [] }) + mockApprove.mockResolvedValue({ data: { node_id: 'n1', edges: [], edges_created: 0 } }) + mockHide.mockResolvedValue({ data: {} }) + mockBulkApprove.mockResolvedValue({ + data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], edges: [], edges_created: 0 }, + }) + mockBulkHide.mockResolvedValue({ data: { hidden: 2, skipped: 0 } }) + mockRestore.mockResolvedValue({ data: { restored: true, device_id: 'dev-a' } }) + mockBulkRestore.mockResolvedValue({ data: { restored: 1, skipped: 0 } }) +}) + +const baseProps = { + open: true, + onClose: vi.fn(), +} + +describe('PendingDevicesModal', () => { + it('loads and renders pending devices on open', async () => { + render() + await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()) + expect(screen.getByText('living-room-bulb')).toBeInTheDocument() + }) + + it('shows source chip ZIGBEE for zigbee device', async () => { + render() + await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()) + expect(screen.getByText('ZIGBEE')).toBeInTheDocument() + }) + + it('filters by search query', async () => { + render() + await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()) + fireEvent.change(screen.getByPlaceholderText(/Search/), { target: { value: 'living' } }) + expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument() + expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument() + }) + + it('filters by source (zigbee only)', async () => { + render() + await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()) + fireEvent.click(screen.getByRole('button', { name: 'Zigbee' })) + expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument() + expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument() + }) + + it('filters by suggested type', async () => { + render() + await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()) + fireEvent.change(screen.getByLabelText('Type filter'), { target: { value: 'server' } }) + expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument() + expect(screen.queryByTestId('pending-card-dev-b')).not.toBeInTheDocument() + }) + + it('switches to hidden status loads hidden devices', async () => { + mockHidden.mockResolvedValue({ + data: [{ ...DEVICE_IP, id: 'h1', hostname: 'hidden-host', status: 'hidden' }], + }) + render() + await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()) + fireEvent.click(screen.getByRole('button', { name: 'Hidden' })) + await waitFor(() => expect(screen.getByTestId('pending-card-h1')).toBeInTheDocument()) + expect(mockHidden).toHaveBeenCalled() + }) + + it('opens approval modal when card is clicked outside select mode', async () => { + render() + await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()) + fireEvent.click(screen.getByTestId('pending-card-dev-a')) + expect(screen.getByTestId('approval-modal')).toBeInTheDocument() + }) + + it('toggles selection in select mode instead of opening approval', async () => { + render() + await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()) + fireEvent.click(screen.getByRole('button', { name: 'Select mode' })) + fireEvent.click(screen.getByTestId('pending-card-dev-a')) + expect(screen.queryByTestId('approval-modal')).not.toBeInTheDocument() + expect(screen.getByText('1 selected')).toBeInTheDocument() + }) + + it('select all visible selects only filtered devices', async () => { + render() + await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()) + fireEvent.click(screen.getByRole('button', { name: 'Select mode' })) + fireEvent.change(screen.getByPlaceholderText(/Search/), { target: { value: 'host-a' } }) + fireEvent.click(screen.getByRole('button', { name: /Select all visible/ })) + expect(screen.getByText('1 selected')).toBeInTheDocument() + }) + + it('bulk approve calls API with selected ids', async () => { + render() + await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()) + fireEvent.click(screen.getByRole('button', { name: 'Select mode' })) + fireEvent.click(screen.getByTestId('pending-card-dev-a')) + fireEvent.click(screen.getByTestId('pending-card-dev-b')) + fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ })) + await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b'])) + }) + + it('bulk hide calls API with selected ids', async () => { + render() + await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()) + fireEvent.click(screen.getByRole('button', { name: 'Select mode' })) + fireEvent.click(screen.getByTestId('pending-card-dev-a')) + fireEvent.click(screen.getByRole('button', { name: /Hide \(1\)/ })) + await waitFor(() => expect(mockBulkHide).toHaveBeenCalledWith(['dev-a'])) + }) + + it('does not load when closed', () => { + render() + expect(mockPending).not.toHaveBeenCalled() + }) + + it('respects initialStatus=hidden', async () => { + mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, hostname: 'hidden-host', status: 'hidden' }] }) + render() + await waitFor(() => expect(mockHidden).toHaveBeenCalled()) + expect(mockPending).not.toHaveBeenCalled() + }) + + it('clicking a hidden card restores it instead of opening approval', async () => { + mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] }) + render() + await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()) + fireEvent.click(screen.getByTestId('pending-card-dev-a')) + await waitFor(() => expect(mockRestore).toHaveBeenCalledWith('dev-a')) + expect(screen.queryByTestId('approval-modal')).not.toBeInTheDocument() + }) + + it('bulk restore in hidden mode calls API with selected ids', async () => { + mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] }) + render() + await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()) + fireEvent.click(screen.getByRole('button', { name: 'Select mode' })) + fireEvent.click(screen.getByTestId('pending-card-dev-a')) + fireEvent.click(screen.getByRole('button', { name: /Restore \(1\)/ })) + await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a'])) + }) +}) diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index 6cea8ed..93fe090 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -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, X, LogOut, Network } from 'lucide-react' +import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network } from 'lucide-react' import { Logo } from '@/components/ui/Logo' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useCanvasStore } from '@/stores/canvasStore' @@ -8,19 +8,14 @@ import { scanApi, settingsApi } from '@/api/client' import { toast } from 'sonner' import { useLatestRelease } from '@/hooks/useLatestRelease' -import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal' - const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' -type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' | 'settings' +type SidebarView = 'canvas' | 'history' | 'settings' -const ALL_VIEWS = [ - { id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' }, - { id: 'pending' as SidebarView, icon: ScanLine, label: 'Pending Devices' }, - { id: 'hidden' as SidebarView, icon: EyeOff, label: 'Hidden Devices' }, - { id: 'history' as SidebarView, icon: Clock, label: 'Scan History' }, +const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [ + { kind: 'pending', icon: ScanLine, label: 'Pending Devices' }, + { kind: 'hidden', icon: EyeOff, label: 'Hidden Devices' }, ] -const VIEWS = STANDALONE ? ALL_VIEWS.slice(0, 1) : ALL_VIEWS interface ScanRun { id: string @@ -38,12 +33,11 @@ interface SidebarProps { onScan: () => void onZigbeeImport: () => void onSave: () => void - onNodeApproved: (nodeId: string) => void forceView?: SidebarView - highlightPendingId?: string + onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void } -export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) { +export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onSave, forceView, onOpenPending }: SidebarProps) { const [collapsed, setCollapsed] = useState(false) const [activeView, setActiveView] = useState(forceView ?? 'canvas') const [prevForceView, setPrevForceView] = useState(forceView) @@ -88,23 +82,36 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onS {/* Views */} {/* View content (only when expanded) */} {!collapsed && activeView !== 'canvas' && (
- {activeView === 'pending' && } - {activeView === 'hidden' && } {activeView === 'history' && } {activeView === 'settings' && }
@@ -178,361 +185,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onS ) } -const COMMON_PORTS = new Set([22, 80, 443]) - -function injectAutoEdges(edges: { id: string; source: string; target: string }[] | undefined) { - if (!edges || edges.length === 0) return - useCanvasStore.setState((state) => ({ - edges: [ - ...state.edges, - ...edges.map((e) => ({ - id: e.id, - source: e.source, - target: e.target, - sourceHandle: 'bottom', - targetHandle: 'top-t', - type: 'iot', - data: { type: 'iot' as const }, - })), - ], - hasUnsavedChanges: true, - })) -} - -function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: (nodeId: string) => void; highlightId?: string }) { - const [devices, setDevices] = useState([]) - const [loading, setLoading] = useState(false) - const [selected, setSelected] = useState(null) - const [checkedIds, setCheckedIds] = useState>(new Set()) - const { addNode, scanEventTs } = useCanvasStore() - const highlightRef = useRef(null) - - const allChecked = devices.length > 0 && checkedIds.size === devices.length - const someChecked = checkedIds.size > 0 - - const toggleCheck = (id: string, e: React.MouseEvent) => { - e.stopPropagation() - setCheckedIds((prev) => { - const next = new Set(prev) - if (next.has(id)) next.delete(id); else next.add(id) - return next - }) - } - - const toggleAll = () => { - setCheckedIds(allChecked ? new Set() : new Set(devices.map((d) => d.id))) - } - - const load = useCallback(async () => { - setLoading(true) - try { - const res = await scanApi.pending() - setDevices(res.data) - } catch { - toast.error('Failed to load pending devices') - } finally { - setLoading(false) - } - }, []) - - const handleClearAll = async () => { - try { - await scanApi.clearPending() - setDevices([]) - setCheckedIds(new Set()) - toast.success('Pending devices cleared') - } catch { - toast.error('Failed to clear pending devices') - } - } - - const handleBulkApprove = async () => { - const ids = [...checkedIds] - try { - const res = await scanApi.bulkApprove(ids) - const deviceToNode: Record = {} - res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] }) - const approvedDevices = devices.filter((d) => ids.includes(d.id)) - approvedDevices.forEach((d, i) => { - const nodeId = deviceToNode[d.id] - if (!nodeId) return - const fallbackLabel = d.friendly_name ?? d.hostname ?? d.ip ?? d.ieee_address ?? 'device' - addNode({ - id: nodeId, - type: (d.suggested_type ?? 'generic') as import('@/types').NodeType, - position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 }, - data: { - label: fallbackLabel, - type: (d.suggested_type ?? 'generic') as import('@/types').NodeType, - ip: d.ip ?? undefined, - hostname: d.hostname ?? undefined, - status: 'unknown' as const, - services: (d.services ?? []) as import('@/types').ServiceInfo[], - }, - }) - onNodeApproved(nodeId) - }) - injectAutoEdges(res.data.edges) - setDevices((prev) => prev.filter((d) => !ids.includes(d.id))) - setCheckedIds(new Set()) - const linkExtra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : '' - toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}${linkExtra}`) - } catch { - toast.error('Failed to bulk approve devices') - } - } - - const handleBulkHide = async () => { - const ids = [...checkedIds] - try { - const res = await scanApi.bulkHide(ids) - setDevices((prev) => prev.filter((d) => !ids.includes(d.id))) - setCheckedIds(new Set()) - toast.success(`Hidden ${res.data.hidden} device${res.data.hidden !== 1 ? 's' : ''}`) - } catch { - toast.error('Failed to bulk hide devices') - } - } - - useEffect(() => { load() }, [load]) - - useEffect(() => { - if (scanEventTs > 0) load() - }, [scanEventTs, load]) - - useEffect(() => { - if (!highlightId || loading) return - highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) - }, [highlightId, loading]) - - const handleApprove = async (device: PendingDevice) => { - try { - const fallbackLabel = device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'device' - const nodeData = { - label: fallbackLabel, - type: (device.suggested_type ?? 'generic') as import('@/types').NodeType, - ip: device.ip ?? undefined, - hostname: device.hostname ?? undefined, - status: 'unknown', - services: (device.services ?? []) as import('@/types').ServiceInfo[], - } - const res = await scanApi.approve(device.id, nodeData) - const nodeId = res.data.node_id - addNode({ - id: nodeId, - type: nodeData.type, - position: { x: 400, y: 300 }, - data: { ...nodeData, status: 'unknown' as const }, - }) - injectAutoEdges(res.data.edges) - const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : '' - toast.success(`Approved ${nodeData.label}${extra}`) - setDevices((prev) => prev.filter((d) => d.id !== device.id)) - setSelected(null) - onNodeApproved(nodeId) - } catch { - toast.error('Failed to approve device') - } - } - - const handleHide = async (device: PendingDevice) => { - try { - await scanApi.hide(device.id) - setDevices((prev) => prev.filter((d) => d.id !== device.id)) - toast.success('Device hidden') - } catch { - toast.error('Failed to hide device') - } - } - - const handleIgnore = async (device: PendingDevice) => { - try { - await scanApi.ignore(device.id) - setDevices((prev) => prev.filter((d) => d.id !== device.id)) - } catch { - toast.error('Failed to ignore device') - } - } - - return ( - <> -
-
-
- {devices.length > 0 && ( - { if (el) el.indeterminate = someChecked && !allChecked }} - onChange={toggleAll} - className="w-3 h-3 accent-[#00d4ff] cursor-pointer" - title="Select all" - /> - )} - Pending -
-
- - {devices.length > 0 && ( - - )} -
-
- {someChecked && ( -
- - -
- )} - {loading && } - {!loading && devices.length === 0 && ( -

No pending devices

- )} - {devices.map((d) => { - const isZigbee = d.discovery_source === 'zigbee' - const namedService = d.services.find((s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port)) - const titleService = namedService - ?? d.services.find((s) => s.port === 80) - ?? d.services.find((s) => s.port === 443) - ?? d.services.find((s) => s.port === 22) - const title = isZigbee - ? (d.friendly_name ?? d.hostname ?? d.ieee_address ?? 'zigbee device') - : (titleService?.service_name ?? d.hostname ?? d.ip ?? 'device') - const showIpBelow = !isZigbee && d.ip != null && title !== d.ip - const hasSsh = d.services.some((s) => s.port === 22) - const hasHttp = d.services.some((s) => s.port === 80) - 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' - : d.discovery_source === 'zigbee' ? '#00d4ff' - : '#8b949e' - const sourceLabel = - d.discovery_source === 'mdns' ? 'mDNS' - : d.discovery_source === 'arp' ? 'ARP' - : d.discovery_source === 'zigbee' ? 'ZIG' - : null - const isHighlighted = d.id === highlightId - return ( - - ) - })} -
- - setSelected(null)} - onApprove={handleApprove} - onHide={handleHide} - onIgnore={handleIgnore} - /> - - ) -} - -function HiddenDevicesPanel() { - const [devices, setDevices] = useState([]) - const [loading, setLoading] = useState(false) - - const load = useCallback(async () => { - setLoading(true) - try { - const res = await scanApi.hidden() - setDevices(res.data) - } catch { - toast.error('Failed to load hidden devices') - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { load() }, [load]) - - const handleIgnore = async (id: string) => { - try { - await scanApi.ignore(id) - setDevices((prev) => prev.filter((d) => d.id !== id)) - } catch { - toast.error('Failed to remove device') - } - } - - return ( -
-
- Hidden - -
- {loading && } - {!loading && devices.length === 0 && ( -

No hidden devices

- )} - {devices.map((d) => ( -
-
{d.ip}
- {d.hostname &&
{d.hostname}
} -
- handleIgnore(d.id)} /> -
-
- ))} -
- ) -} function ScanHistoryPanel() { const [runs, setRuns] = useState([]) @@ -731,55 +383,6 @@ function VersionBadge() { ) } -const MAC_OUI: Record = { - '52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' }, - 'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' }, - '00:50:56': { label: 'VMware', title: 'VMware Virtual Machine' }, - '00:0c:29': { label: 'VMware', title: 'VMware Virtual Machine' }, - '08:00:27': { label: 'VBox', title: 'VirtualBox Virtual Machine' }, - '00:15:5d': { label: 'Hyper-V', title: 'Hyper-V Virtual Machine' }, -} - -function detectVirtualBadge(mac: string | null) { - if (!mac) return null - return MAC_OUI[mac.toLowerCase().slice(0, 8)] ?? null -} - -function ServiceBadge({ label, color }: { label: string; color: string }) { - return ( - - {label} - - ) -} - -interface ActionButtonProps { - icon: React.ElementType - label: string - color?: 'green' | 'red' - onClick: () => void -} - -function ActionButton({ icon: Icon, label, color, onClick }: ActionButtonProps) { - const colorClass = - color === 'green' ? 'text-[#39d353] hover:bg-[#39d353]/10' : - color === 'red' ? 'text-[#f85149] hover:bg-[#f85149]/10' : - 'text-muted-foreground hover:text-foreground hover:bg-[#30363d]' - return ( - - - - - {label} - - ) -} - interface SidebarItemProps { icon: React.ElementType label: string diff --git a/frontend/src/components/panels/__tests__/Sidebar.test.tsx b/frontend/src/components/panels/__tests__/Sidebar.test.tsx index 5488f1a..91bacea 100644 --- a/frontend/src/components/panels/__tests__/Sidebar.test.tsx +++ b/frontend/src/components/panels/__tests__/Sidebar.test.tsx @@ -11,22 +11,11 @@ import type { NodeData } from '@/types' vi.mock('@/stores/canvasStore') vi.mock('@/stores/authStore') -const mockBulkApprove = vi.fn() -const mockBulkHide = vi.fn() - vi.mock('@/api/client', () => ({ scanApi: { trigger: vi.fn().mockResolvedValue({}), - pending: vi.fn().mockResolvedValue({ data: [] }), - hidden: vi.fn().mockResolvedValue({ data: [] }), runs: vi.fn().mockResolvedValue({ data: [] }), stop: vi.fn().mockResolvedValue({}), - clearPending: vi.fn().mockResolvedValue({}), - approve: vi.fn().mockResolvedValue({ data: { approved: true, node_id: 'new-node-1' } }), - hide: vi.fn().mockResolvedValue({ data: { hidden: true } }), - ignore: vi.fn().mockResolvedValue({ data: { ignored: true } }), - bulkApprove: (...args: unknown[]) => mockBulkApprove(...args), - bulkHide: (...args: unknown[]) => mockBulkHide(...args), }, settingsApi: { get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }), @@ -48,10 +37,6 @@ vi.mock('@/components/ui/tooltip', () => ({ TooltipContent: () => null, })) -vi.mock('@/components/modals/PendingDeviceModal', () => ({ - PendingDeviceModal: () => null, -})) - // ── Helpers ─────────────────────────────────────────────────────────────────── const makeNode = (id: string, status: NodeData['status'], type: NodeData['type'] = 'server'): Node => ({ @@ -86,8 +71,9 @@ const defaultProps = { onAddNode: vi.fn(), onAddGroupRect: vi.fn(), onScan: vi.fn(), + onZigbeeImport: vi.fn(), onSave: vi.fn(), - onNodeApproved: vi.fn(), + onOpenPending: vi.fn(), } // ── Tests ───────────────────────────────────────────────────────────────────── @@ -129,26 +115,22 @@ describe('Sidebar', () => { ], }) render() - // Total (excludes groupRect) expect(screen.getByText('4')).toBeInTheDocument() - // Online expect(screen.getByText('2')).toBeInTheDocument() - // Offline expect(screen.getByText('1')).toBeInTheDocument() }) it('excludes groupRect nodes from stats', () => { mockStore({ nodes: [ - makeNode('n1', 'unknown'), // 1 real node, not online/offline + makeNode('n1', 'unknown'), makeNode('zone', 'unknown', 'groupRect'), ], }) render() - // Total row shows 1 (groupRect excluded), online/offline both 0 const totalRow = screen.getByText('Total').closest('div')! expect(totalRow).toHaveTextContent('1') - expect(screen.getAllByText('0')).toHaveLength(2) // online=0, offline=0 + expect(screen.getAllByText('0')).toHaveLength(2) }) // ── Collapse ─────────────────────────────────────────────────────────────── @@ -225,7 +207,6 @@ describe('Sidebar', () => { it('shows unsaved badge dot on Save Canvas when hasUnsavedChanges', () => { mockStore({ hasUnsavedChanges: true }) render() - // The badge is a span sibling of the Save Canvas button icon const saveBtn = screen.getByText('Save Canvas').closest('button')! const badge = saveBtn.querySelector('span.rounded-full') expect(badge).toBeInTheDocument() @@ -241,24 +222,24 @@ describe('Sidebar', () => { // ── Scan action ──────────────────────────────────────────────────────────── - it('calls onScan prop when Scan Network is clicked (scan trigger moved to ScanConfigModal)', () => { + it('calls onScan prop when Scan Network is clicked', () => { render() fireEvent.click(screen.getByText('Scan Network')) expect(defaultProps.onScan).toHaveBeenCalledOnce() }) - // ── Navigation ───────────────────────────────────────────────────────────── + // ── Pending / Hidden open modal ──────────────────────────────────────────── - it('shows Pending panel when Pending Devices nav item is clicked', async () => { + it('calls onOpenPending with pending status when Pending Devices is clicked', () => { render() fireEvent.click(screen.getByText('Pending Devices')) - await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument()) + expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'pending') }) - it('shows Hidden panel when Hidden Devices nav item is clicked', async () => { + it('calls onOpenPending with hidden status when Hidden Devices is clicked', () => { render() fireEvent.click(screen.getByText('Hidden Devices')) - await waitFor(() => expect(screen.getByText('No hidden devices')).toBeInTheDocument()) + expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'hidden') }) it('shows History panel when Scan History nav item is clicked', async () => { @@ -267,16 +248,13 @@ describe('Sidebar', () => { await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument()) }) - // Regression: forceView used to override local state on every render, freezing - // the sidebar on whichever view the parent forced (e.g. 'history' after a scan). + // Regression: forceView must not freeze local state across rerenders. it('allows switching views after forceView is set by parent', async () => { const { rerender } = render() await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument()) - // Parent keeps forceView as 'history'; user clicks another nav item. rerender() - fireEvent.click(screen.getByText('Pending Devices')) - await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument()) - expect(screen.queryByText('No scans yet')).not.toBeInTheDocument() + fireEvent.click(screen.getByText('Canvas')) + await waitFor(() => expect(screen.queryByText('No scans yet')).not.toBeInTheDocument()) }) it('toggles Settings panel on Settings click', async () => { @@ -285,7 +263,6 @@ describe('Sidebar', () => { await waitFor(() => expect(screen.getByText('Status check interval (s)')).toBeInTheDocument(), ) - // Click the nav button again to close (use role to avoid matching the panel heading) fireEvent.click(screen.getByRole('button', { name: 'Settings' })) expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument() }) @@ -303,101 +280,3 @@ describe('Sidebar', () => { expect(mockLogout).toHaveBeenCalledOnce() }) }) - -// ── PendingDevicesPanel — bulk select ───────────────────────────────────────── - -const DEVICE_A = { - id: 'dev-a', - ip: '192.168.1.10', - hostname: 'host-a', - mac: null, - os: null, - services: [], - suggested_type: 'generic', - status: 'pending', - discovery_source: 'arp', -} - -const DEVICE_B = { - id: 'dev-b', - ip: '192.168.1.11', - hostname: 'host-b', - mac: null, - os: null, - services: [], - suggested_type: 'generic', - status: 'pending', - discovery_source: 'arp', -} - -describe('PendingDevicesPanel — bulk select', () => { - beforeEach(() => { - mockStore() - mockAuth() - vi.clearAllMocks() - mockBulkApprove.mockResolvedValue({ - data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], skipped: 0 }, - }) - mockBulkHide.mockResolvedValue({ data: { hidden: 2, skipped: 0 } }) - }) - - async function renderWithDevices() { - const { scanApi } = await import('@/api/client') - vi.mocked(scanApi.pending).mockResolvedValue({ data: [DEVICE_A, DEVICE_B] } as never) - render() - await waitFor(() => expect(screen.getByText('host-a')).toBeInTheDocument()) - } - - it('renders checkboxes for each device', async () => { - await renderWithDevices() - const checkboxes = screen.getAllByRole('checkbox') - // select-all + 2 device checkboxes - expect(checkboxes.length).toBe(3) - }) - - it('shows bulk action bar when a device is checked', async () => { - await renderWithDevices() - const [, firstDeviceCheckbox] = screen.getAllByRole('checkbox') - fireEvent.click(firstDeviceCheckbox) - await waitFor(() => expect(screen.getByText(/Approve \(1\)/)).toBeInTheDocument()) - expect(screen.getByText(/Hide \(1\)/)).toBeInTheDocument() - }) - - it('hides bulk action bar when no device is checked', async () => { - await renderWithDevices() - expect(screen.queryByText(/Approve \(/)).not.toBeInTheDocument() - }) - - it('select-all checks all devices', async () => { - await renderWithDevices() - const [selectAll] = screen.getAllByRole('checkbox') - fireEvent.click(selectAll) - await waitFor(() => expect(screen.getByText(/Approve \(2\)/)).toBeInTheDocument()) - }) - - it('select-all unchecks all when all are selected', async () => { - await renderWithDevices() - const [selectAll] = screen.getAllByRole('checkbox') - fireEvent.click(selectAll) // select all - fireEvent.click(selectAll) // deselect all - await waitFor(() => expect(screen.queryByText(/Approve \(/)).not.toBeInTheDocument()) - }) - - it('calls bulkApprove with checked ids and removes devices from list', async () => { - await renderWithDevices() - const [selectAll] = screen.getAllByRole('checkbox') - fireEvent.click(selectAll) - fireEvent.click(screen.getByText(/Approve \(2\)/)) - await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b'])) - await waitFor(() => expect(screen.queryByText('host-a')).not.toBeInTheDocument()) - }) - - it('calls bulkHide with checked ids and removes devices from list', async () => { - await renderWithDevices() - const [selectAll] = screen.getAllByRole('checkbox') - fireEvent.click(selectAll) - fireEvent.click(screen.getByText(/Hide \(2\)/)) - await waitFor(() => expect(mockBulkHide).toHaveBeenCalledWith(['dev-a', 'dev-b'])) - await waitFor(() => expect(screen.queryByText('host-b')).not.toBeInTheDocument()) - }) -})