Merge pull request #203 from Pouzor/feat/scan-history-modal

feat: move scan history into a modal
This commit is contained in:
Rémy
2026-06-12 00:40:19 +02:00
committed by GitHub
5 changed files with 410 additions and 264 deletions
+11 -6
View File
@@ -27,6 +27,7 @@ import { TextModal, type TextFormData } from '@/components/modals/TextModal'
import { ThemeModal } from '@/components/modals/ThemeModal' import { ThemeModal } from '@/components/modals/ThemeModal'
import { SearchModal } from '@/components/modals/SearchModal' import { SearchModal } from '@/components/modals/SearchModal'
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal' import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal'
import { ShortcutsModal } from '@/components/modals/ShortcutsModal' import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal' import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
@@ -53,7 +54,7 @@ export default function App() {
const [themeModalOpen, setThemeModalOpen] = useState(false) const [themeModalOpen, setThemeModalOpen] = useState(false)
const [searchOpen, setSearchOpen] = 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 [pendingModalOpen, setPendingModalOpen] = useState(false)
const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending') const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending')
const [pendingHighlightId, setPendingHighlightId] = useState<string | undefined>(undefined) const [pendingHighlightId, setPendingHighlightId] = useState<string | undefined>(undefined)
@@ -610,7 +611,7 @@ export default function App() {
onZigbeeImport={() => setZigbeeImportOpen(true)} onZigbeeImport={() => setZigbeeImportOpen(true)}
onSave={handleSave} onSave={handleSave}
onOpenSettings={() => setSettingsOpen(true)} onOpenSettings={() => setSettingsOpen(true)}
forceView={sidebarForceView} onOpenHistory={() => setScanHistoryOpen(true)}
onOpenPending={openPendingModal} onOpenPending={openPendingModal}
/> />
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0">
@@ -711,8 +712,6 @@ export default function App() {
onClose={() => setScanConfigOpen(false)} onClose={() => setScanConfigOpen(false)}
onScanNow={() => { onScanNow={() => {
toast.success('Network scan started — check Scan History for results') 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)} onClose={() => setZigbeeImportOpen(false)}
onAddToCanvas={handleZigbeeAddToCanvas} onAddToCanvas={handleZigbeeAddToCanvas}
onPendingImported={() => { onPendingImported={() => {
setSidebarForceView(undefined) toast.success('Zigbee import started — check Scan History for results')
setTimeout(() => setSidebarForceView('history'), 0)
}} }}
/> />
)} )}
{!STANDALONE && (
<ScanHistoryModal
open={scanHistoryOpen}
onClose={() => setScanHistoryOpen(false)}
/>
)}
<GroupRectModal <GroupRectModal
open={addGroupRectOpen} open={addGroupRectOpen}
onClose={() => setAddGroupRectOpen(false)} onClose={() => setAddGroupRectOpen(false)}
@@ -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<ScanRun[]>([])
const [loading, setLoading] = useState(false)
const [stopping, setStopping] = useState<string | null>(null)
const [kindFilter, setKindFilter] = useState<KindFilter>('all')
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [now, setNow] = useState(() => Date.now())
const prevRunsRef = useRef<ScanRun[]>([])
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 (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent
showCloseButton={false}
className="!max-w-none w-[90vw] max-w-2xl h-[80vh] p-0 flex flex-col gap-0 bg-[#0d1117] border-border"
>
<DialogHeader className="px-4 py-3 border-b border-border shrink-0">
<div className="flex items-center justify-between gap-3">
<DialogTitle className="text-base font-semibold flex items-center gap-2">
<Clock size={16} className="text-[#00d4ff]" />
Scan History
<span className="text-muted-foreground font-normal text-xs">
({filtered.length}{filtered.length !== runs.length && ` of ${runs.length}`})
</span>
</DialogTitle>
<div className="flex items-center gap-1">
<button onClick={load} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Refresh">
<RefreshCw size={14} className={loading ? 'animate-spin' : undefined} />
</button>
<DialogClose
render={
<button
className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors"
aria-label="Close"
/>
}
>
<X size={14} />
</DialogClose>
</div>
</div>
</DialogHeader>
{/* Filters */}
<div className="px-4 py-2 border-b border-border bg-[#161b22] shrink-0 flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Type</span>
{KIND_FILTERS.map((f) => (
<FilterChip key={f.key} active={kindFilter === f.key} onClick={() => setKindFilter(f.key)}>
{f.label}
</FilterChip>
))}
</div>
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Status</span>
{STATUS_FILTERS.map((f) => (
<FilterChip key={f.key} active={statusFilter === f.key} onClick={() => setStatusFilter(f.key)}>
{f.label}
</FilterChip>
))}
</div>
</div>
{/* List */}
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-2">
{loading && runs.length === 0 && (
<div className="flex justify-center py-10">
<Loader2 size={18} className="animate-spin text-muted-foreground" />
</div>
)}
{!loading && filtered.length === 0 && (
<div className="flex flex-col items-center gap-2 py-12 text-muted-foreground">
<Inbox size={28} className="opacity-50" />
<p className="text-sm">{runs.length === 0 ? 'No scans yet' : 'No scans match the filters'}</p>
</div>
)}
{filtered.map((r) => {
const isZigbee = r.kind === 'zigbee'
return (
<div key={r.id} className="rounded-lg border border-border bg-[#161b22] p-3">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: statusColor(r.status) }} />
<span className="font-mono text-sm text-foreground capitalize">{r.status}</span>
{r.status === 'running' && <Loader2 size={12} className="animate-spin text-[#e3b341]" />}
<span
className="inline-flex items-center gap-1 text-[10px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
style={isZigbee
? { background: '#00d4ff22', color: '#00d4ff' }
: { background: '#a855f722', color: '#a855f7' }}
>
{isZigbee ? <Network size={10} /> : <ScanLine size={10} />}
{isZigbee ? 'Zigbee' : 'IP'}
</span>
<span className="ml-auto text-xs text-muted-foreground font-mono">
{r.devices_found} found
</span>
{r.status === 'running' && (
<Tooltip>
<TooltipTrigger>
<button
aria-label="Stop scan"
onClick={() => handleStop(r.id)}
disabled={stopping === r.id}
className="p-1 text-[#f85149] hover:bg-[#f85149]/10 rounded transition-colors disabled:opacity-50"
>
{stopping === r.id
? <Loader2 size={13} className="animate-spin" />
: <StopCircle size={13} />}
</button>
</TooltipTrigger>
<TooltipContent side="left">Stop scan</TooltipContent>
</Tooltip>
)}
</div>
{/* Meta grid */}
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-[11px]">
<Meta label="Started" value={new Date(parseUtc(r.started_at)).toLocaleString()} />
<Meta
label="Finished"
value={r.finished_at ? new Date(parseUtc(r.finished_at)).toLocaleString() : '—'}
/>
<Meta label="Duration" value={runDuration(r, now)} mono />
<Meta label="Devices" value={`${r.devices_found}`} mono />
</div>
{r.ranges.length > 0 && (
<div className="mt-2 text-[11px]">
<span className="text-muted-foreground">Ranges: </span>
<span className="text-[#8b949e] font-mono break-all">{r.ranges.join(', ')}</span>
</div>
)}
{r.error && (
<div className="mt-2 text-[11px] text-[#f85149] leading-tight whitespace-pre-wrap break-words rounded bg-[#f85149]/10 px-2 py-1.5">
{r.error}
</div>
)}
</div>
)
})}
</div>
</DialogContent>
</Dialog>
)
}
function FilterChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
onClick={onClick}
className={`text-[11px] px-2 py-0.5 rounded-full border transition-colors cursor-pointer ${
active
? 'bg-[#00d4ff]/10 border-[#00d4ff]/40 text-[#00d4ff]'
: 'border-border text-muted-foreground hover:text-foreground hover:border-[#30363d]'
}`}
>
{children}
</button>
)
}
function Meta({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex flex-col">
<span className="text-muted-foreground text-[10px] uppercase tracking-wider">{label}</span>
<span className={`text-foreground ${mono ? 'font-mono' : ''}`}>{value}</span>
</div>
)
}
@@ -1,21 +1,17 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Sidebar } from '../Sidebar' import { ScanHistoryModal } from '../ScanHistoryModal'
import * as canvasStore from '@/stores/canvasStore'
import { TooltipProvider } from '@/components/ui/tooltip' import { TooltipProvider } from '@/components/ui/tooltip'
vi.mock('@/stores/canvasStore')
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/stores/canvasStore', () => ({
useCanvasStore: { getState: () => ({ notifyScanDeviceFound: vi.fn() }) },
}))
vi.mock('@/api/client', () => ({ vi.mock('@/api/client', () => ({
scanApi: { scanApi: {
trigger: vi.fn(),
pending: vi.fn().mockResolvedValue({ data: [] }),
hidden: vi.fn().mockResolvedValue({ data: [] }),
runs: vi.fn().mockResolvedValue({ data: [] }), runs: vi.fn().mockResolvedValue({ data: [] }),
stop: vi.fn(), stop: vi.fn(),
getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }),
}, },
settingsApi: { get: vi.fn(), save: vi.fn() },
})) }))
import { scanApi } from '@/api/client' import { scanApi } from '@/api/client'
@@ -24,9 +20,10 @@ import { toast } from 'sonner'
const RUNNING_RUN = { const RUNNING_RUN = {
id: 'run-1', id: 'run-1',
status: 'running', status: 'running',
kind: 'ip',
ranges: ['192.168.1.0/24'], ranges: ['192.168.1.0/24'],
devices_found: 2, devices_found: 2,
started_at: new Date().toISOString(), started_at: new Date(Date.now() - 5000).toISOString(),
finished_at: null, finished_at: null,
error: null, error: null,
} }
@@ -34,16 +31,18 @@ const RUNNING_RUN = {
const DONE_RUN = { const DONE_RUN = {
id: 'run-2', id: 'run-2',
status: 'done', status: 'done',
kind: 'ip',
ranges: ['192.168.1.0/24'], ranges: ['192.168.1.0/24'],
devices_found: 3, devices_found: 3,
started_at: new Date().toISOString(), started_at: new Date(Date.now() - 60000).toISOString(),
finished_at: new Date().toISOString(), finished_at: new Date(Date.now() - 30000).toISOString(),
error: null, error: null,
} }
const CANCELLED_RUN = { const CANCELLED_RUN = {
id: 'run-3', id: 'run-3',
status: 'cancelled', status: 'cancelled',
kind: 'ip',
ranges: ['192.168.1.0/24'], ranges: ['192.168.1.0/24'],
devices_found: 1, devices_found: 1,
started_at: new Date().toISOString(), started_at: new Date().toISOString(),
@@ -51,36 +50,26 @@ const CANCELLED_RUN = {
error: null, error: null,
} }
function renderSidebar() { const ZIGBEE_RUN = {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ id: 'run-4',
nodes: [], status: 'done',
hasUnsavedChanges: false, kind: 'zigbee',
hideIp: false, ranges: [],
toggleHideIp: vi.fn(), devices_found: 7,
addNode: vi.fn(), started_at: new Date().toISOString(),
scanEventTs: 0, finished_at: new Date().toISOString(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) error: null,
}
function renderModal() {
return render( return render(
<TooltipProvider> <TooltipProvider>
<Sidebar <ScanHistoryModal open onClose={vi.fn()} />
onAddNode={vi.fn()}
onAddGroupRect={vi.fn()}
onScan={vi.fn()}
onSave={vi.fn()}
onNodeApproved={vi.fn()}
/>
</TooltipProvider> </TooltipProvider>
) )
} }
async function openHistory() { describe('ScanHistoryModal', () => {
fireEvent.click(screen.getByRole('button', { name: 'Scan History' }))
// Wait for runs to load
await waitFor(() => expect(scanApi.runs).toHaveBeenCalled())
}
describe('ScanHistoryPanel — stop scan', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(toast.success).mockReset() vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset() vi.mocked(toast.error).mockReset()
@@ -88,68 +77,82 @@ describe('ScanHistoryPanel — stop scan', () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [] } as never) vi.mocked(scanApi.runs).mockResolvedValue({ data: [] } as never)
}) })
it('shows stop button only for running scans', async () => { it('loads runs when opened', async () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never) vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN] } as never)
renderSidebar() renderModal()
await openHistory() await waitFor(() => expect(scanApi.runs).toHaveBeenCalled())
expect(await screen.findByText('done')).toBeDefined()
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('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.stop).mockResolvedValue({ data: { stopping: true } } as never)
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never) vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
renderSidebar() renderModal()
await openHistory()
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' }) const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
fireEvent.click(stopBtn) 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 () => { it('shows success toast when stop succeeds', async () => {
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never) vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never) vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
renderSidebar() renderModal()
await openHistory()
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' }) const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
fireEvent.click(stopBtn) 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 () => { it('shows error toast when stop fails', async () => {
vi.mocked(scanApi.stop).mockRejectedValue(new Error('network')) vi.mocked(scanApi.stop).mockRejectedValue(new Error('network'))
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never) vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
renderSidebar() renderModal()
await openHistory()
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' }) const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
fireEvent.click(stopBtn) 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) vi.mocked(scanApi.runs).mockResolvedValue({ data: [CANCELLED_RUN] } as never)
renderSidebar() renderModal()
await openHistory()
await waitFor(() => expect(screen.getByText('cancelled')).toBeDefined()) await waitFor(() => expect(screen.getByText('cancelled')).toBeDefined())
// No stop button
expect(screen.queryByRole('button', { name: 'Stop scan' })).toBeNull() 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()
})
}) })
+8 -174
View File
@@ -1,11 +1,11 @@
import { useState, useCallback, useEffect, useRef } from 'react' import { useState, useCallback } 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 { 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 { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore' import { useDesignStore } from '@/stores/designStore'
import { useAuthStore } from '@/stores/authStore' 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 { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal' import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
import type { Design } from '@/types' import type { Design } from '@/types'
@@ -14,24 +14,11 @@ import { useLatestRelease } from '@/hooks/useLatestRelease'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
type SidebarView = 'canvas' | 'history'
const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [ const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [
{ kind: 'pending', icon: ScanLine, label: 'Pending Devices' }, { kind: 'pending', icon: ScanLine, label: 'Pending Devices' },
{ kind: 'hidden', icon: EyeOff, label: 'Hidden 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 { interface SidebarProps {
onAddNode: () => void onAddNode: () => void
onAddGroupRect: () => void onAddGroupRect: () => void
@@ -40,14 +27,12 @@ interface SidebarProps {
onZigbeeImport: () => void onZigbeeImport: () => void
onSave: () => void onSave: () => void
onOpenSettings: () => void onOpenSettings: () => void
forceView?: SidebarView onOpenHistory: () => void
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => 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 [collapsed, setCollapsed] = useState(false)
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
const [prevForceView, setPrevForceView] = useState(forceView)
const logout = useAuthStore((s) => s.logout) const logout = useAuthStore((s) => s.logout)
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore() const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false) const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false)
@@ -81,15 +66,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
} }
}, [designs.length, removeDesign]) }, [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 { nodes, hasUnsavedChanges } = useCanvasStore()
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text') 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} icon={LayoutDashboard}
label="Canvas" label="Canvas"
collapsed={collapsed} collapsed={collapsed}
active={activeView === 'canvas'} active
onClick={() => setActiveView('canvas')}
/> />
{!STANDALONE && PENDING_TRIGGERS.map((t) => ( {!STANDALONE && PENDING_TRIGGERS.map((t) => (
<SidebarItem <SidebarItem
@@ -210,23 +185,12 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
icon={Clock} icon={Clock}
label="Scan History" label="Scan History"
collapsed={collapsed} collapsed={collapsed}
active={activeView === 'history'} onClick={onOpenHistory}
onClick={() => setActiveView('history')}
/> />
)} )}
</nav> </nav>
{/* View content (only when expanded) */} {!collapsed && <div className="flex-1" />}
{!collapsed && activeView !== 'canvas' && (
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
{activeView === 'history' && <ScanHistoryPanel />}
</div>
)}
{/* Stats (only on canvas view) */}
{!collapsed && activeView === 'canvas' && (
<div className="flex-1" />
)}
{/* Stats footer */} {/* Stats footer */}
{!collapsed && ( {!collapsed && (
@@ -294,136 +258,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
) )
} }
function ScanHistoryPanel() {
const [runs, setRuns] = useState<ScanRun[]>([])
const [loading, setLoading] = useState(false)
const prevRunsRef = useRef<ScanRun[]>([])
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<string | null>(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 (
<div className="p-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">History</span>
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5">
<RefreshCw size={12} />
</button>
</div>
{loading && runs.length === 0 && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
{!loading && runs.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">No scans yet</p>
)}
{runs.map((r) => (
<div key={r.id} className="mb-2 p-2 rounded-md bg-[#21262d] text-xs">
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: statusColor(r.status) }} />
<span className="font-mono text-foreground capitalize">{r.status}</span>
{r.status === 'running' && <Loader2 size={10} className="animate-spin text-[#e3b341]" />}
<span
className="text-[9px] font-mono px-1 py-0.5 rounded uppercase tracking-wider"
style={r.kind === 'zigbee'
? { background: '#00d4ff22', color: '#00d4ff' }
: { background: '#a855f722', color: '#a855f7' }}
>
{r.kind === 'zigbee' ? 'ZIG' : 'IP'}
</span>
<span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span>
{r.status === 'running' && (
<Tooltip>
<TooltipTrigger>
<button
aria-label="Stop scan"
onClick={() => handleStop(r.id)}
disabled={stopping === r.id}
className="p-0.5 text-[#f85149] hover:bg-[#f85149]/10 rounded transition-colors disabled:opacity-50"
>
{stopping === r.id
? <Loader2 size={11} className="animate-spin" />
: <StopCircle size={11} />
}
</button>
</TooltipTrigger>
<TooltipContent side="left">Stop scan</TooltipContent>
</Tooltip>
)}
</div>
<div className="text-muted-foreground text-[10px] mt-0.5">
{new Date(r.started_at.endsWith('Z') ? r.started_at : r.started_at + 'Z').toLocaleString()}
</div>
{r.ranges.length > 0 && (
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
)}
{r.error && (
<div className="text-[#f85149] text-[10px] mt-1 leading-tight wrap-break-word whitespace-pre-wrap">
{r.error}
</div>
)}
</div>
))}
</div>
)
}
function VersionBadge() { function VersionBadge() {
const current = __APP_VERSION__ const current = __APP_VERSION__
const { latest, hasUpdate } = useLatestRelease(current) const { latest, hasUpdate } = useLatestRelease(current)
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest' 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 { Sidebar } from '../Sidebar'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
@@ -67,10 +67,12 @@ function mockAuth() {
const defaultProps = { const defaultProps = {
onAddNode: vi.fn(), onAddNode: vi.fn(),
onAddGroupRect: vi.fn(), onAddGroupRect: vi.fn(),
onAddText: vi.fn(),
onScan: vi.fn(), onScan: vi.fn(),
onZigbeeImport: vi.fn(), onZigbeeImport: vi.fn(),
onSave: vi.fn(), onSave: vi.fn(),
onOpenSettings: vi.fn(), onOpenSettings: vi.fn(),
onOpenHistory: vi.fn(),
onOpenPending: vi.fn(), onOpenPending: vi.fn(),
} }
@@ -243,19 +245,10 @@ describe('Sidebar', () => {
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'hidden') 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(<Sidebar {...defaultProps} />) render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Scan History')) fireEvent.click(screen.getByText('Scan History'))
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument()) expect(defaultProps.onOpenHistory).toHaveBeenCalledOnce()
})
// Regression: forceView must not freeze local state across rerenders.
it('allows switching views after forceView is set by parent', async () => {
const { rerender } = render(<Sidebar {...defaultProps} forceView="history" />)
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
rerender(<Sidebar {...defaultProps} forceView="history" />)
fireEvent.click(screen.getByText('Canvas'))
await waitFor(() => expect(screen.queryByText('No scans yet')).not.toBeInTheDocument())
}) })
it('calls onOpenSettings when Settings is clicked', () => { it('calls onOpenSettings when Settings is clicked', () => {