Merge pull request #203 from Pouzor/feat/scan-history-modal
feat: move scan history into a modal
This commit is contained in:
+11
-6
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+75
-72
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user