diff --git a/frontend/src/components/panels/__tests__/Sidebar.test.tsx b/frontend/src/components/panels/__tests__/Sidebar.test.tsx
new file mode 100644
index 0000000..79ce5eb
--- /dev/null
+++ b/frontend/src/components/panels/__tests__/Sidebar.test.tsx
@@ -0,0 +1,269 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import { Sidebar } from '../Sidebar'
+import { useCanvasStore } from '@/stores/canvasStore'
+import type { Node } from '@xyflow/react'
+import type { NodeData } from '@/types'
+
+// ── Mocks ────────────────────────────────────────────────────────────────────
+
+vi.mock('@/stores/canvasStore')
+
+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({}),
+ },
+ settingsApi: {
+ get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
+ save: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
+ },
+}))
+
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+
+vi.mock('@/components/ui/Logo', () => ({
+ Logo: ({ showText }: { showText: boolean }) => (
+
+ ),
+}))
+
+vi.mock('@/components/ui/tooltip', () => ({
+ Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ TooltipContent: () => null,
+}))
+
+vi.mock('@/components/modals/PendingDeviceModal', () => ({
+ PendingDeviceModal: () => null,
+}))
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+const makeNode = (id: string, status: NodeData['status'], type: NodeData['type'] = 'server'): Node => ({
+ id,
+ type,
+ position: { x: 0, y: 0 },
+ data: { label: id, type, status, services: [] },
+})
+
+const mockToggleHideIp = vi.fn()
+
+function mockStore(overrides: Partial> = {}) {
+ vi.mocked(useCanvasStore).mockReturnValue({
+ nodes: [],
+ hasUnsavedChanges: false,
+ hideIp: false,
+ toggleHideIp: mockToggleHideIp,
+ addNode: vi.fn(),
+ scanEventTs: 0,
+ ...overrides,
+ } as ReturnType)
+}
+
+const defaultProps = {
+ onAddNode: vi.fn(),
+ onAddGroupRect: vi.fn(),
+ onScan: vi.fn(),
+ onSave: vi.fn(),
+ onNodeApproved: vi.fn(),
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe('Sidebar', () => {
+ beforeEach(() => {
+ mockStore()
+ vi.clearAllMocks()
+ })
+
+ // ── Render ─────────────────────────────────────────────────────────────────
+
+ it('renders logo and nav items', () => {
+ render()
+ expect(screen.getByTestId('logo')).toBeInTheDocument()
+ expect(screen.getByText('Add Node')).toBeInTheDocument()
+ expect(screen.getByText('Save Canvas')).toBeInTheDocument()
+ expect(screen.getByText('Scan Network')).toBeInTheDocument()
+ })
+
+ it('shows all view nav items', () => {
+ render()
+ expect(screen.getByText('Canvas')).toBeInTheDocument()
+ expect(screen.getByText('Pending Devices')).toBeInTheDocument()
+ expect(screen.getByText('Hidden Devices')).toBeInTheDocument()
+ expect(screen.getByText('Scan History')).toBeInTheDocument()
+ })
+
+ // ── Stats ──────────────────────────────────────────────────────────────────
+
+ it('displays total / online / offline counts from store', () => {
+ mockStore({
+ nodes: [
+ makeNode('n1', 'online'),
+ makeNode('n2', 'online'),
+ makeNode('n3', 'offline'),
+ makeNode('n4', 'unknown'),
+ ],
+ })
+ 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('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
+ })
+
+ // ── Collapse ───────────────────────────────────────────────────────────────
+
+ it('collapses sidebar on toggle button click', () => {
+ render()
+ const aside = screen.getByRole('complementary')
+ expect(aside).toHaveStyle({ width: '220px' })
+
+ const toggle = aside.querySelector('button')!
+ fireEvent.click(toggle)
+ expect(aside).toHaveStyle({ width: '48px' })
+ })
+
+ it('hides label text when collapsed', () => {
+ render()
+ const aside = screen.getByRole('complementary')
+ const toggle = aside.querySelector('button')!
+ fireEvent.click(toggle)
+ expect(screen.queryByText('Add Node')).not.toBeInTheDocument()
+ })
+
+ it('hides stats footer when collapsed', () => {
+ render()
+ expect(screen.getByText('Total')).toBeInTheDocument()
+ const toggle = screen.getByRole('complementary').querySelector('button')!
+ fireEvent.click(toggle)
+ expect(screen.queryByText('Total')).not.toBeInTheDocument()
+ })
+
+ it('shows logo with showText=false when collapsed', () => {
+ render()
+ const logo = screen.getByTestId('logo')
+ expect(logo).toHaveAttribute('data-show-text', 'true')
+ const toggle = screen.getByRole('complementary').querySelector('button')!
+ fireEvent.click(toggle)
+ expect(logo).toHaveAttribute('data-show-text', 'false')
+ })
+
+ // ── Action callbacks ───────────────────────────────────────────────────────
+
+ it('calls onAddNode when Add Node is clicked', () => {
+ render()
+ fireEvent.click(screen.getByText('Add Node'))
+ expect(defaultProps.onAddNode).toHaveBeenCalledOnce()
+ })
+
+ it('calls onAddGroupRect when Add Zone is clicked', () => {
+ render()
+ fireEvent.click(screen.getByText('Add Zone'))
+ expect(defaultProps.onAddGroupRect).toHaveBeenCalledOnce()
+ })
+
+ it('calls onSave when Save Canvas is clicked', () => {
+ render()
+ fireEvent.click(screen.getByText('Save Canvas'))
+ expect(defaultProps.onSave).toHaveBeenCalledOnce()
+ })
+
+ it('calls toggleHideIp when Hide IPs is clicked', () => {
+ render()
+ fireEvent.click(screen.getByText('Hide IPs'))
+ expect(mockToggleHideIp).toHaveBeenCalledOnce()
+ })
+
+ it('shows Show IPs label when hideIp is true', () => {
+ mockStore({ hideIp: true })
+ render()
+ expect(screen.getByText('Show IPs')).toBeInTheDocument()
+ })
+
+ // ── Unsaved changes badge ──────────────────────────────────────────────────
+
+ 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()
+ })
+
+ it('does not show unsaved badge when no changes', () => {
+ mockStore({ hasUnsavedChanges: false })
+ render()
+ const saveBtn = screen.getByText('Save Canvas').closest('button')!
+ const badge = saveBtn.querySelector('span.rounded-full')
+ expect(badge).not.toBeInTheDocument()
+ })
+
+ // ── Scan action ────────────────────────────────────────────────────────────
+
+ it('calls scanApi.trigger and onScan prop when Scan Network is clicked', async () => {
+ const { scanApi } = await import('@/api/client')
+ render()
+ fireEvent.click(screen.getByText('Scan Network'))
+ await waitFor(() => expect(scanApi.trigger).toHaveBeenCalledOnce())
+ expect(defaultProps.onScan).toHaveBeenCalledOnce()
+ })
+
+ it('switches to Scan History view after scan is triggered', async () => {
+ render()
+ fireEvent.click(screen.getByText('Scan Network'))
+ await waitFor(() => expect(screen.getByText('History')).toBeInTheDocument())
+ })
+
+ // ── Navigation ─────────────────────────────────────────────────────────────
+
+ it('shows Pending panel when Pending Devices nav item is clicked', async () => {
+ render()
+ fireEvent.click(screen.getByText('Pending Devices'))
+ await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument())
+ })
+
+ it('shows Hidden panel when Hidden Devices nav item is clicked', async () => {
+ render()
+ fireEvent.click(screen.getByText('Hidden Devices'))
+ await waitFor(() => expect(screen.getByText('No hidden devices')).toBeInTheDocument())
+ })
+
+ it('shows History panel when Scan History nav item is clicked', async () => {
+ render()
+ fireEvent.click(screen.getByText('Scan History'))
+ await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
+ })
+
+ it('toggles Settings panel on Settings click', async () => {
+ render()
+ fireEvent.click(screen.getByText('Settings'))
+ 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()
+ })
+})
diff --git a/frontend/src/hooks/__tests__/useStatusPolling.test.ts b/frontend/src/hooks/__tests__/useStatusPolling.test.ts
new file mode 100644
index 0000000..845c231
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useStatusPolling.test.ts
@@ -0,0 +1,170 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { renderHook } from '@testing-library/react'
+import { useStatusPolling } from '../useStatusPolling'
+import { useCanvasStore } from '@/stores/canvasStore'
+import { useAuthStore } from '@/stores/authStore'
+
+vi.mock('@/stores/canvasStore')
+vi.mock('@/stores/authStore')
+
+const mockUpdateNode = vi.fn()
+const mockNotifyScanDeviceFound = vi.fn()
+
+class MockWebSocket {
+ static instances: MockWebSocket[] = []
+ url: string
+ onopen: (() => void) | null = null
+ onmessage: ((e: { data: string }) => void) | null = null
+ onerror: ((e: unknown) => void) | null = null
+ send = vi.fn()
+ close = vi.fn()
+
+ constructor(url: string) {
+ this.url = url
+ MockWebSocket.instances.push(this)
+ }
+}
+
+describe('useStatusPolling', () => {
+ beforeEach(() => {
+ MockWebSocket.instances = []
+ vi.stubGlobal('WebSocket', MockWebSocket)
+
+ vi.mocked(useCanvasStore).mockReturnValue({
+ updateNode: mockUpdateNode,
+ notifyScanDeviceFound: mockNotifyScanDeviceFound,
+ } as ReturnType)
+
+ vi.mocked(useAuthStore).mockReturnValue({
+ isAuthenticated: true,
+ token: 'test-token',
+ } as ReturnType)
+
+ Object.defineProperty(window, 'location', {
+ value: { protocol: 'http:', host: 'localhost:5173' },
+ writable: true,
+ })
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ mockUpdateNode.mockClear()
+ mockNotifyScanDeviceFound.mockClear()
+ })
+
+ it('does not open WebSocket when not authenticated', () => {
+ vi.mocked(useAuthStore).mockReturnValue({
+ isAuthenticated: false,
+ token: null,
+ } as ReturnType)
+ renderHook(() => useStatusPolling())
+ expect(MockWebSocket.instances).toHaveLength(0)
+ })
+
+ it('does not open WebSocket when token is missing', () => {
+ vi.mocked(useAuthStore).mockReturnValue({
+ isAuthenticated: true,
+ token: null,
+ } as ReturnType)
+ renderHook(() => useStatusPolling())
+ expect(MockWebSocket.instances).toHaveLength(0)
+ })
+
+ it('connects to correct ws:// URL', () => {
+ renderHook(() => useStatusPolling())
+ expect(MockWebSocket.instances).toHaveLength(1)
+ expect(MockWebSocket.instances[0].url).toBe('ws://localhost:5173/api/v1/status/ws/status')
+ })
+
+ it('uses wss:// when page is served over https', () => {
+ Object.defineProperty(window, 'location', {
+ value: { protocol: 'https:', host: 'mylab.local' },
+ writable: true,
+ })
+ renderHook(() => useStatusPolling())
+ expect(MockWebSocket.instances[0].url).toMatch(/^wss:\/\//)
+ })
+
+ it('sends token as first message on open', () => {
+ renderHook(() => useStatusPolling())
+ const ws = MockWebSocket.instances[0]
+ ws.onopen?.()
+ expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ token: 'test-token' }))
+ })
+
+ it('calls updateNode with correct data on status message', () => {
+ renderHook(() => useStatusPolling())
+ const ws = MockWebSocket.instances[0]
+ ws.onmessage?.({
+ data: JSON.stringify({
+ node_id: 'node-1',
+ status: 'online',
+ checked_at: '2024-01-01T12:00:00Z',
+ response_time_ms: 42,
+ }),
+ })
+ expect(mockUpdateNode).toHaveBeenCalledWith('node-1', {
+ status: 'online',
+ response_time_ms: 42,
+ last_seen: '2024-01-01T12:00:00Z',
+ })
+ })
+
+ it('sets last_seen to undefined when status is offline', () => {
+ renderHook(() => useStatusPolling())
+ const ws = MockWebSocket.instances[0]
+ ws.onmessage?.({
+ data: JSON.stringify({
+ node_id: 'node-1',
+ status: 'offline',
+ checked_at: '2024-01-01T12:00:00Z',
+ }),
+ })
+ expect(mockUpdateNode).toHaveBeenCalledWith('node-1', {
+ status: 'offline',
+ response_time_ms: undefined,
+ last_seen: undefined,
+ })
+ })
+
+ it('sets response_time_ms to undefined when null in message', () => {
+ renderHook(() => useStatusPolling())
+ const ws = MockWebSocket.instances[0]
+ ws.onmessage?.({
+ data: JSON.stringify({ node_id: 'node-1', status: 'online', response_time_ms: null }),
+ })
+ expect(mockUpdateNode).toHaveBeenCalledWith(
+ 'node-1',
+ expect.objectContaining({ response_time_ms: undefined }),
+ )
+ })
+
+ it('calls notifyScanDeviceFound on scan_device_found message', () => {
+ renderHook(() => useStatusPolling())
+ const ws = MockWebSocket.instances[0]
+ ws.onmessage?.({ data: JSON.stringify({ type: 'scan_device_found' }) })
+ expect(mockNotifyScanDeviceFound).toHaveBeenCalledOnce()
+ expect(mockUpdateNode).not.toHaveBeenCalled()
+ })
+
+ it('ignores malformed JSON without throwing', () => {
+ renderHook(() => useStatusPolling())
+ const ws = MockWebSocket.instances[0]
+ expect(() => ws.onmessage?.({ data: 'not-valid-json{{' })).not.toThrow()
+ expect(mockUpdateNode).not.toHaveBeenCalled()
+ })
+
+ it('ignores messages with no node_id or status', () => {
+ renderHook(() => useStatusPolling())
+ const ws = MockWebSocket.instances[0]
+ ws.onmessage?.({ data: JSON.stringify({ some: 'unknown-field' }) })
+ expect(mockUpdateNode).not.toHaveBeenCalled()
+ })
+
+ it('closes WebSocket on unmount', () => {
+ const { unmount } = renderHook(() => useStatusPolling())
+ const ws = MockWebSocket.instances[0]
+ unmount()
+ expect(ws.close).toHaveBeenCalledOnce()
+ })
+})