From 0e260ede733dd035dc81867801f09eb4f7ee5ac2 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 12 Jun 2026 00:11:16 +0200 Subject: [PATCH] feat: move scan history from sidebar panel into a modal Replace the inline sidebar 'history' view with a dedicated ScanHistoryModal. Same data and actions (refresh, auto-refresh while running, stop scan, transition toasts) plus run duration, finished timestamp, and kind/status filters. Scan/Zigbee start now surface a toast instead of force-switching the sidebar view. ha-relevant: yes --- frontend/src/App.tsx | 17 +- .../components/modals/ScanHistoryModal.tsx | 311 ++++++++++++++++++ .../__tests__/ScanHistoryModal.test.tsx} | 147 +++++---- frontend/src/components/panels/Sidebar.tsx | 182 +--------- .../panels/__tests__/Sidebar.test.tsx | 17 +- 5 files changed, 410 insertions(+), 264 deletions(-) create mode 100644 frontend/src/components/modals/ScanHistoryModal.tsx rename frontend/src/components/{panels/__tests__/ScanHistoryPanel.test.tsx => modals/__tests__/ScanHistoryModal.test.tsx} (50%) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9ed350e..d18b3b1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,7 @@ import { TextModal, type TextFormData } from '@/components/modals/TextModal' import { ThemeModal } from '@/components/modals/ThemeModal' import { SearchModal } from '@/components/modals/SearchModal' import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal' +import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal' import { ShortcutsModal } from '@/components/modals/ShortcutsModal' import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal' import { useCanvasStore } from '@/stores/canvasStore' @@ -53,7 +54,7 @@ export default function App() { const [themeModalOpen, setThemeModalOpen] = useState(false) const [searchOpen, setSearchOpen] = useState(false) - const [sidebarForceView, setSidebarForceView] = useState<'history' | undefined>(undefined) + const [scanHistoryOpen, setScanHistoryOpen] = useState(false) const [pendingModalOpen, setPendingModalOpen] = useState(false) const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending') const [pendingHighlightId, setPendingHighlightId] = useState(undefined) @@ -610,7 +611,7 @@ export default function App() { onZigbeeImport={() => setZigbeeImportOpen(true)} onSave={handleSave} onOpenSettings={() => setSettingsOpen(true)} - forceView={sidebarForceView} + onOpenHistory={() => setScanHistoryOpen(true)} onOpenPending={openPendingModal} />
@@ -711,8 +712,6 @@ export default function App() { onClose={() => setScanConfigOpen(false)} onScanNow={() => { toast.success('Network scan started — check Scan History for results') - setSidebarForceView(undefined) - setTimeout(() => setSidebarForceView('history'), 0) }} /> )} @@ -723,12 +722,18 @@ export default function App() { onClose={() => setZigbeeImportOpen(false)} onAddToCanvas={handleZigbeeAddToCanvas} onPendingImported={() => { - setSidebarForceView(undefined) - setTimeout(() => setSidebarForceView('history'), 0) + toast.success('Zigbee import started — check Scan History for results') }} /> )} + {!STANDALONE && ( + setScanHistoryOpen(false)} + /> + )} + setAddGroupRectOpen(false)} diff --git a/frontend/src/components/modals/ScanHistoryModal.tsx b/frontend/src/components/modals/ScanHistoryModal.tsx new file mode 100644 index 0000000..b76e919 --- /dev/null +++ b/frontend/src/components/modals/ScanHistoryModal.tsx @@ -0,0 +1,311 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { RefreshCw, X, Loader2, StopCircle, Clock, ScanLine, Network, Inbox } from 'lucide-react' +import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { scanApi } from '@/api/client' +import { useCanvasStore } from '@/stores/canvasStore' +import { toast } from 'sonner' + +export interface ScanRun { + id: string + status: string + kind?: string + ranges: string[] + devices_found: number + started_at: string + finished_at: string | null + error: string | null +} + +interface ScanHistoryModalProps { + open: boolean + onClose: () => void +} + +type KindFilter = 'all' | 'ip' | 'zigbee' +type StatusFilter = 'all' | 'running' | 'done' | 'error' | 'cancelled' + +const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [ + { key: 'all', label: 'All' }, + { key: 'running', label: 'Running' }, + { key: 'done', label: 'Done' }, + { key: 'error', label: 'Error' }, + { key: 'cancelled', label: 'Cancelled' }, +] + +const KIND_FILTERS: { key: KindFilter; label: string }[] = [ + { key: 'all', label: 'All' }, + { key: 'ip', label: 'IP' }, + { key: 'zigbee', label: 'Zigbee' }, +] + +function statusColor(s: string): string { + return s === 'done' ? '#39d353' + : s === 'running' ? '#e3b341' + : s === 'error' ? '#f85149' + : s === 'cancelled' ? '#8b949e' + : '#8b949e' +} + +function parseUtc(ts: string): number { + return new Date(ts.endsWith('Z') ? ts : ts + 'Z').getTime() +} + +function formatDuration(ms: number): string { + if (ms < 0) ms = 0 + const s = Math.floor(ms / 1000) + if (s < 60) return `${s}s` + const m = Math.floor(s / 60) + const rem = s % 60 + if (m < 60) return rem ? `${m}m ${rem}s` : `${m}m` + const h = Math.floor(m / 60) + return `${h}h ${m % 60}m` +} + +function runDuration(r: ScanRun, now: number): string { + const start = parseUtc(r.started_at) + const end = r.finished_at ? parseUtc(r.finished_at) : now + return formatDuration(end - start) +} + +export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) { + const [runs, setRuns] = useState([]) + const [loading, setLoading] = useState(false) + const [stopping, setStopping] = useState(null) + const [kindFilter, setKindFilter] = useState('all') + const [statusFilter, setStatusFilter] = useState('all') + const [now, setNow] = useState(() => Date.now()) + const prevRunsRef = useRef([]) + + const load = useCallback(async () => { + setLoading(true) + try { + const res = await scanApi.runs() + const next: ScanRun[] = res.data + + // Surface transitions and refresh dependent UI + for (const run of next) { + const prev = prevRunsRef.current.find((r) => r.id === run.id) + if (prev?.status === 'running' && run.status === 'error') { + toast.error(`Scan failed: ${run.error ?? 'unknown error'}`) + } + if (prev?.status === 'running' && run.status === 'done') { + if (run.kind === 'zigbee') { + toast.success(`Zigbee import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`) + } + useCanvasStore.getState().notifyScanDeviceFound() + } + } + prevRunsRef.current = next + setRuns(next) + } catch { + toast.error('Failed to load scan history') + } finally { + setLoading(false) + } + }, []) + + // Load when opened; reset prior-state tracker so we don't replay old transitions + useEffect(() => { + if (!open) return + prevRunsRef.current = [] + load() + }, [open, load]) + + // Auto-refresh every 3s while any run is still running (only when open) + useEffect(() => { + if (!open) return + const hasRunning = runs.some((r) => r.status === 'running') + if (!hasRunning) return + const id = setInterval(load, 3000) + return () => clearInterval(id) + }, [open, runs, load]) + + // Tick the clock every second while a scan is running (for live elapsed duration) + useEffect(() => { + if (!open) return + const hasRunning = runs.some((r) => r.status === 'running') + if (!hasRunning) return + const id = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(id) + }, [open, runs]) + + const handleStop = async (runId: string) => { + setStopping(runId) + try { + await scanApi.stop(runId) + toast.success('Scan stop requested') + } catch { + toast.error('Failed to stop scan') + } finally { + setStopping(null) + } + } + + const filtered = runs.filter((r) => { + const k = r.kind === 'zigbee' ? 'zigbee' : 'ip' + if (kindFilter !== 'all' && k !== kindFilter) return false + if (statusFilter !== 'all' && r.status !== statusFilter) return false + return true + }) + + return ( + { if (!v) onClose() }}> + + +
+ + + Scan History + + ({filtered.length}{filtered.length !== runs.length && ` of ${runs.length}`}) + + +
+ + + } + > + + +
+
+
+ + {/* Filters */} +
+
+ Type + {KIND_FILTERS.map((f) => ( + setKindFilter(f.key)}> + {f.label} + + ))} +
+
+ Status + {STATUS_FILTERS.map((f) => ( + setStatusFilter(f.key)}> + {f.label} + + ))} +
+
+ + {/* List */} +
+ {loading && runs.length === 0 && ( +
+ +
+ )} + {!loading && filtered.length === 0 && ( +
+ +

{runs.length === 0 ? 'No scans yet' : 'No scans match the filters'}

+
+ )} + {filtered.map((r) => { + const isZigbee = r.kind === 'zigbee' + return ( +
+
+ + {r.status} + {r.status === 'running' && } + + {isZigbee ? : } + {isZigbee ? 'Zigbee' : 'IP'} + + + {r.devices_found} found + + {r.status === 'running' && ( + + + + + Stop scan + + )} +
+ + {/* Meta grid */} +
+ + + + +
+ + {r.ranges.length > 0 && ( +
+ Ranges: + {r.ranges.join(', ')} +
+ )} + + {r.error && ( +
+ {r.error} +
+ )} +
+ ) + })} +
+
+
+ ) +} + +function FilterChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { + return ( + + ) +} + +function Meta({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+ {label} + {value} +
+ ) +} diff --git a/frontend/src/components/panels/__tests__/ScanHistoryPanel.test.tsx b/frontend/src/components/modals/__tests__/ScanHistoryModal.test.tsx similarity index 50% rename from frontend/src/components/panels/__tests__/ScanHistoryPanel.test.tsx rename to frontend/src/components/modals/__tests__/ScanHistoryModal.test.tsx index 3f3d282..764c2d9 100644 --- a/frontend/src/components/panels/__tests__/ScanHistoryPanel.test.tsx +++ b/frontend/src/components/modals/__tests__/ScanHistoryModal.test.tsx @@ -1,21 +1,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import { Sidebar } from '../Sidebar' -import * as canvasStore from '@/stores/canvasStore' +import { ScanHistoryModal } from '../ScanHistoryModal' import { TooltipProvider } from '@/components/ui/tooltip' -vi.mock('@/stores/canvasStore') vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +vi.mock('@/stores/canvasStore', () => ({ + useCanvasStore: { getState: () => ({ notifyScanDeviceFound: vi.fn() }) }, +})) vi.mock('@/api/client', () => ({ scanApi: { - trigger: vi.fn(), - pending: vi.fn().mockResolvedValue({ data: [] }), - hidden: vi.fn().mockResolvedValue({ data: [] }), runs: vi.fn().mockResolvedValue({ data: [] }), stop: vi.fn(), - getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }), }, - settingsApi: { get: vi.fn(), save: vi.fn() }, })) import { scanApi } from '@/api/client' @@ -24,9 +20,10 @@ import { toast } from 'sonner' const RUNNING_RUN = { id: 'run-1', status: 'running', + kind: 'ip', ranges: ['192.168.1.0/24'], devices_found: 2, - started_at: new Date().toISOString(), + started_at: new Date(Date.now() - 5000).toISOString(), finished_at: null, error: null, } @@ -34,16 +31,18 @@ const RUNNING_RUN = { const DONE_RUN = { id: 'run-2', status: 'done', + kind: 'ip', ranges: ['192.168.1.0/24'], devices_found: 3, - started_at: new Date().toISOString(), - finished_at: new Date().toISOString(), + started_at: new Date(Date.now() - 60000).toISOString(), + finished_at: new Date(Date.now() - 30000).toISOString(), error: null, } const CANCELLED_RUN = { id: 'run-3', status: 'cancelled', + kind: 'ip', ranges: ['192.168.1.0/24'], devices_found: 1, started_at: new Date().toISOString(), @@ -51,36 +50,26 @@ const CANCELLED_RUN = { error: null, } -function renderSidebar() { - vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ - nodes: [], - hasUnsavedChanges: false, - hideIp: false, - toggleHideIp: vi.fn(), - addNode: vi.fn(), - scanEventTs: 0, - } as unknown as ReturnType) +const ZIGBEE_RUN = { + id: 'run-4', + status: 'done', + kind: 'zigbee', + ranges: [], + devices_found: 7, + started_at: new Date().toISOString(), + finished_at: new Date().toISOString(), + error: null, +} +function renderModal() { return render( - + ) } -async function openHistory() { - fireEvent.click(screen.getByRole('button', { name: 'Scan History' })) - // Wait for runs to load - await waitFor(() => expect(scanApi.runs).toHaveBeenCalled()) -} - -describe('ScanHistoryPanel — stop scan', () => { +describe('ScanHistoryModal', () => { beforeEach(() => { vi.mocked(toast.success).mockReset() vi.mocked(toast.error).mockReset() @@ -88,68 +77,82 @@ describe('ScanHistoryPanel — stop scan', () => { vi.mocked(scanApi.runs).mockResolvedValue({ data: [] } as never) }) - it('shows stop button only for running scans', async () => { - vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never) - renderSidebar() - await openHistory() - - await waitFor(() => expect(screen.getByText('running')).toBeDefined()) - - // Exactly one stop button rendered (for the running scan only) - const stopButtons = screen.getAllByRole('button', { name: 'Stop scan' }) - expect(stopButtons).toHaveLength(1) + it('loads runs when opened', async () => { + vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN] } as never) + renderModal() + await waitFor(() => expect(scanApi.runs).toHaveBeenCalled()) + expect(await screen.findByText('done')).toBeDefined() }) - it('calls scanApi.stop with the correct run ID on click', async () => { + it('shows empty state when no scans', async () => { + renderModal() + expect(await screen.findByText('No scans yet')).toBeDefined() + }) + + it('shows stop button only for running scans', async () => { + vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never) + renderModal() + await waitFor(() => expect(screen.getByText('running')).toBeDefined()) + expect(screen.getAllByRole('button', { name: 'Stop scan' })).toHaveLength(1) + }) + + it('calls scanApi.stop with the correct run ID', async () => { vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never) vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never) - renderSidebar() - await openHistory() - + renderModal() const stopBtn = await screen.findByRole('button', { name: 'Stop scan' }) fireEvent.click(stopBtn) - - await waitFor(() => { - expect(scanApi.stop).toHaveBeenCalledWith('run-1') - }) + await waitFor(() => expect(scanApi.stop).toHaveBeenCalledWith('run-1')) }) it('shows success toast when stop succeeds', async () => { vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never) vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never) - renderSidebar() - await openHistory() - + renderModal() const stopBtn = await screen.findByRole('button', { name: 'Stop scan' }) fireEvent.click(stopBtn) - - await waitFor(() => { - expect(toast.success).toHaveBeenCalledWith('Scan stop requested') - }) + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Scan stop requested')) }) it('shows error toast when stop fails', async () => { vi.mocked(scanApi.stop).mockRejectedValue(new Error('network')) vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never) - renderSidebar() - await openHistory() - + renderModal() const stopBtn = await screen.findByRole('button', { name: 'Stop scan' }) fireEvent.click(stopBtn) - - await waitFor(() => { - expect(toast.error).toHaveBeenCalledWith('Failed to stop scan') - }) + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to stop scan')) }) - it('renders cancelled status without stop button or spinner', async () => { + it('renders cancelled status without a stop button', async () => { vi.mocked(scanApi.runs).mockResolvedValue({ data: [CANCELLED_RUN] } as never) - renderSidebar() - await openHistory() - + renderModal() await waitFor(() => expect(screen.getByText('cancelled')).toBeDefined()) - - // No stop button expect(screen.queryByRole('button', { name: 'Stop scan' })).toBeNull() }) + + it('shows duration for a finished run', async () => { + vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN] } as never) + renderModal() + // DONE_RUN ran 30s + expect(await screen.findByText('30s')).toBeDefined() + }) + + it('filters by status', async () => { + vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never) + renderModal() + await waitFor(() => expect(screen.getByText('done')).toBeDefined()) + fireEvent.click(screen.getByRole('button', { name: 'Running' })) + expect(screen.queryByText('done')).toBeNull() + expect(screen.getByText('running')).toBeDefined() + }) + + it('filters by kind', async () => { + vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN, ZIGBEE_RUN] } as never) + renderModal() + await waitFor(() => expect(screen.getAllByText('done').length).toBe(2)) + fireEvent.click(screen.getByRole('button', { name: 'Zigbee' })) + // Only the zigbee run (7 found) remains + expect(screen.getByText('7 found')).toBeDefined() + expect(screen.queryByText('3 found')).toBeNull() + }) }) diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index ed195b9..1900eb9 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -1,11 +1,11 @@ -import { useState, useCallback, useEffect, useRef } from 'react' -import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Settings, StopCircle, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react' +import { useState, useCallback } from 'react' +import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react' import { Logo } from '@/components/ui/Logo' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useCanvasStore } from '@/stores/canvasStore' import { useDesignStore } from '@/stores/designStore' import { useAuthStore } from '@/stores/authStore' -import { designsApi, scanApi } from '@/api/client' +import { designsApi } from '@/api/client' import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons' import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal' import type { Design } from '@/types' @@ -14,24 +14,11 @@ import { useLatestRelease } from '@/hooks/useLatestRelease' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' -type SidebarView = 'canvas' | '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' }, ] -interface ScanRun { - id: string - status: string - kind?: string - ranges: string[] - devices_found: number - started_at: string - finished_at: string | null - error: string | null -} - interface SidebarProps { onAddNode: () => void onAddGroupRect: () => void @@ -40,14 +27,12 @@ interface SidebarProps { onZigbeeImport: () => void onSave: () => void onOpenSettings: () => void - forceView?: SidebarView + onOpenHistory: () => void onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void } -export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, onOpenSettings, forceView, onOpenPending }: SidebarProps) { +export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) { const [collapsed, setCollapsed] = useState(false) - const [activeView, setActiveView] = useState(forceView ?? 'canvas') - const [prevForceView, setPrevForceView] = useState(forceView) const logout = useAuthStore((s) => s.logout) const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore() const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false) @@ -81,15 +66,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee } }, [designs.length, removeDesign]) - // forceView acts as a one-shot trigger from parent; user clicks afterwards still control view. - if (forceView !== prevForceView) { - setPrevForceView(forceView) - if (forceView) { - setActiveView(forceView) - setCollapsed(false) - } - } - const { nodes, hasUnsavedChanges } = useCanvasStore() const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text') @@ -193,8 +169,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee icon={LayoutDashboard} label="Canvas" collapsed={collapsed} - active={activeView === 'canvas'} - onClick={() => setActiveView('canvas')} + active /> {!STANDALONE && PENDING_TRIGGERS.map((t) => ( setActiveView('history')} + onClick={onOpenHistory} /> )} - {/* View content (only when expanded) */} - {!collapsed && activeView !== 'canvas' && ( -
- {activeView === 'history' && } -
- )} - - {/* Stats (only on canvas view) */} - {!collapsed && activeView === 'canvas' && ( -
- )} + {!collapsed &&
} {/* Stats footer */} {!collapsed && ( @@ -294,136 +258,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee ) } - -function ScanHistoryPanel() { - const [runs, setRuns] = useState([]) - const [loading, setLoading] = useState(false) - const prevRunsRef = useRef([]) - - const load = useCallback(async () => { - setLoading(true) - try { - const res = await scanApi.runs() - const next: ScanRun[] = res.data - - // Surface transitions and refresh dependent UI - for (const run of next) { - const prev = prevRunsRef.current.find((r) => r.id === run.id) - if (prev?.status === 'running' && run.status === 'error') { - toast.error(`Scan failed: ${run.error ?? 'unknown error'}`) - } - if (prev?.status === 'running' && run.status === 'done') { - if (run.kind === 'zigbee') { - toast.success(`Zigbee import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`) - } - // Notify pending modal/canvas to refresh - useCanvasStore.getState().notifyScanDeviceFound() - } - } - prevRunsRef.current = next - setRuns(next) - } catch { - toast.error('Failed to load scan history') - } finally { - setLoading(false) - } - }, []) - - // Initial load - useEffect(() => { load() }, [load]) - - // Auto-refresh every 3s while any run is still running - useEffect(() => { - const hasRunning = runs.some((r) => r.status === 'running') - if (!hasRunning) return - const id = setInterval(load, 3000) - return () => clearInterval(id) - }, [runs, load]) - - const [stopping, setStopping] = useState(null) - - const handleStop = async (runId: string) => { - setStopping(runId) - try { - await scanApi.stop(runId) - toast.success('Scan stop requested') - } catch { - toast.error('Failed to stop scan') - } finally { - setStopping(null) - } - } - - const statusColor = (s: string) => - s === 'done' ? '#39d353' - : s === 'running' ? '#e3b341' - : s === 'error' ? '#f85149' - : s === 'cancelled' ? '#8b949e' - : '#8b949e' - - return ( -
-
- History - -
- {loading && runs.length === 0 && } - {!loading && runs.length === 0 && ( -

No scans yet

- )} - {runs.map((r) => ( -
-
- - {r.status} - {r.status === 'running' && } - - {r.kind === 'zigbee' ? 'ZIG' : 'IP'} - - {r.devices_found} found - {r.status === 'running' && ( - - - - - Stop scan - - )} -
-
- {new Date(r.started_at.endsWith('Z') ? r.started_at : r.started_at + 'Z').toLocaleString()} -
- {r.ranges.length > 0 && ( -
{r.ranges.join(', ')}
- )} - {r.error && ( -
- {r.error} -
- )} -
- ))} -
- ) -} - function VersionBadge() { const current = __APP_VERSION__ const { latest, hasUpdate } = useLatestRelease(current) diff --git a/frontend/src/components/panels/__tests__/Sidebar.test.tsx b/frontend/src/components/panels/__tests__/Sidebar.test.tsx index 1ee0da8..d98c715 100644 --- a/frontend/src/components/panels/__tests__/Sidebar.test.tsx +++ b/frontend/src/components/panels/__tests__/Sidebar.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { render, screen, fireEvent } from '@testing-library/react' import { Sidebar } from '../Sidebar' import { useCanvasStore } from '@/stores/canvasStore' import { useAuthStore } from '@/stores/authStore' @@ -67,10 +67,12 @@ function mockAuth() { const defaultProps = { onAddNode: vi.fn(), onAddGroupRect: vi.fn(), + onAddText: vi.fn(), onScan: vi.fn(), onZigbeeImport: vi.fn(), onSave: vi.fn(), onOpenSettings: vi.fn(), + onOpenHistory: vi.fn(), onOpenPending: vi.fn(), } @@ -243,19 +245,10 @@ describe('Sidebar', () => { expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'hidden') }) - it('shows History panel when Scan History nav item is clicked', async () => { + it('calls onOpenHistory when Scan History nav item is clicked', () => { render() fireEvent.click(screen.getByText('Scan History')) - await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument()) - }) - - // 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()) - rerender() - fireEvent.click(screen.getByText('Canvas')) - await waitFor(() => expect(screen.queryByText('No scans yet')).not.toBeInTheDocument()) + expect(defaultProps.onOpenHistory).toHaveBeenCalledOnce() }) it('calls onOpenSettings when Settings is clicked', () => {