feat(pending): full-screen modal with grid cards, filters, bulk restore

Replaces sidebar pending/hidden panels with a wide modal showing
devices as cards. Adds search, segmented source/status filters,
type filter, select mode for bulk approve/hide/restore, and
keyboard shortcuts. Hidden cards click-to-restore (no approval
detour); approval no longer pops the edit modal.

Backend: new restore + bulk-restore endpoints (hidden -> pending).
This commit is contained in:
Pouzor
2026-05-09 23:40:44 +02:00
parent 456f26a85a
commit 277879488f
8 changed files with 1016 additions and 579 deletions
+35
View File
@@ -174,6 +174,41 @@ async def bulk_hide_devices(
return {"hidden": len(devices), "skipped": len(payload.device_ids) - len(devices)}
@router.post("/pending/{device_id}/restore", response_model=dict)
async def restore_device(
device_id: str,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, Any]:
device = await db.get(PendingDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
if device.status != "hidden":
raise HTTPException(status_code=409, detail="Device is not hidden")
device.status = "pending"
await db.commit()
return {"restored": True, "device_id": device_id}
@router.post("/pending/bulk-restore", response_model=dict)
async def bulk_restore_devices(
payload: BulkActionRequest,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, Any]:
result = await db.execute(
select(PendingDevice).where(
PendingDevice.id.in_(payload.device_ids),
PendingDevice.status == "hidden",
)
)
devices = result.scalars().all()
for device in devices:
device.status = "pending"
await db.commit()
return {"restored": len(devices), "skipped": len(payload.device_ids) - len(devices)}
@router.post("/pending/{device_id}/approve", response_model=dict)
async def approve_device(
device_id: str,
+43
View File
@@ -140,6 +140,49 @@ async def test_hide_device(client: AsyncClient, headers, pending_device):
assert len(hidden_res.json()) == 1
# --- Restore hidden device ---
@pytest.mark.asyncio
async def test_restore_device(client: AsyncClient, headers, pending_device):
# Hide first
await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
# Restore
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
assert res.status_code == 200
assert res.json()["restored"] is True
# Now back in pending, gone from hidden
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
assert len(pending_res.json()) == 1
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
assert hidden_res.json() == []
@pytest.mark.asyncio
async def test_restore_device_rejects_non_hidden(client: AsyncClient, headers, pending_device):
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
assert res.status_code == 409
@pytest.mark.asyncio
async def test_bulk_restore_devices(client: AsyncClient, headers, pending_device):
# Hide
await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
res = await client.post(
"/api/v1/scan/pending/bulk-restore",
headers=headers,
json={"device_ids": [pending_device.id]},
)
assert res.status_code == 200
assert res.json()["restored"] == 1
assert res.json()["skipped"] == 0
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
assert len(pending_res.json()) == 1
# --- Ignore device ---
@pytest.mark.asyncio
+21 -20
View File
@@ -23,6 +23,7 @@ import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
import { ThemeModal } from '@/components/modals/ThemeModal'
import { SearchModal } from '@/components/modals/SearchModal'
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
@@ -47,8 +48,16 @@ export default function App() {
const [themeModalOpen, setThemeModalOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const [sidebarForceView, setSidebarForceView] = useState<'pending' | 'history' | undefined>(undefined)
const [highlightPendingId, setHighlightPendingId] = useState<string | undefined>(undefined)
const [sidebarForceView, setSidebarForceView] = useState<'history' | undefined>(undefined)
const [pendingModalOpen, setPendingModalOpen] = useState(false)
const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending')
const [pendingHighlightId, setPendingHighlightId] = useState<string | undefined>(undefined)
const openPendingModal = useCallback((deviceId?: string, status: 'pending' | 'hidden' = 'pending') => {
setPendingHighlightId(undefined)
setPendingModalStatus(status)
setPendingModalOpen(true)
if (deviceId) setTimeout(() => setPendingHighlightId(deviceId), 0)
}, [])
const [shortcutsOpen, setShortcutsOpen] = useState(false)
const [addNodeOpen, setAddNodeOpen] = useState(false)
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
@@ -437,9 +446,8 @@ export default function App() {
onScan={() => setScanConfigOpen(true)}
onZigbeeImport={() => setZigbeeImportOpen(true)}
onSave={handleSave}
onNodeApproved={setEditNodeId}
forceView={sidebarForceView}
highlightPendingId={highlightPendingId}
onOpenPending={openPendingModal}
/>
<div className="flex flex-col flex-1 min-w-0">
<Toolbar
@@ -461,14 +469,7 @@ export default function App() {
onEdgeDoubleClick={handleEdgeDoubleClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeDragStart={snapshotHistory}
onOpenPending={(deviceId) => {
setHighlightPendingId(undefined)
setSidebarForceView(undefined)
setTimeout(() => {
setHighlightPendingId(deviceId)
setSidebarForceView('pending')
}, 0)
}}
onOpenPending={(deviceId) => openPendingModal(deviceId)}
/>
</div>
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
@@ -608,17 +609,17 @@ export default function App() {
<SearchModal
open={searchOpen}
onClose={() => setSearchOpen(false)}
onOpenPending={(deviceId) => {
setHighlightPendingId(undefined)
setSidebarForceView(undefined)
setTimeout(() => {
setHighlightPendingId(deviceId)
setSidebarForceView('pending')
}, 0)
}}
onOpenPending={(deviceId) => openPendingModal(deviceId)}
/>
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
<PendingDevicesModal
open={pendingModalOpen}
onClose={() => setPendingModalOpen(false)}
highlightId={pendingHighlightId}
initialStatus={pendingModalStatus}
/>
<ExportModal
open={exportModalOpen}
onClose={() => setExportModalOpen(false)}
+2
View File
@@ -77,6 +77,8 @@ export const scanApi = {
skipped: number
}>('/scan/pending/bulk-approve', { device_ids: ids }),
bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }),
restore: (id: string) => api.post<{ restored: boolean; device_id: string }>(`/scan/pending/${id}/restore`),
bulkRestore: (ids: string[]) => api.post<{ restored: number; skipped: number }>('/scan/pending/bulk-restore', { device_ids: ids }),
stop: (runId: string) => api.post(`/scan/${runId}/stop`),
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
@@ -0,0 +1,658 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import {
Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network,
Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2,
} from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { scanApi } from '@/api/client'
import { useCanvasStore } from '@/stores/canvasStore'
import { toast } from 'sonner'
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
import type { NodeType, ServiceInfo } from '@/types'
interface PendingDevicesModalProps {
open: boolean
onClose: () => void
highlightId?: string
initialStatus?: 'pending' | 'hidden'
}
const PORT_COLORS: Record<number, string> = {
22: '#a855f7', // SSH purple
80: '#00d4ff', // HTTP cyan
443: '#39d353', // HTTPS green
53: '#e3b341', // DNS amber
3306: '#a855f7', // MySQL
5432: '#a855f7', // Postgres
6379: '#f85149', // Redis
9090: '#e3b341', // Prometheus
3000: '#00d4ff', // Grafana/dev
8080: '#00d4ff',
8443: '#39d353',
}
const CATEGORY_COLORS: Record<string, string> = {
hypervisor: '#ff6e00',
nas: '#39d353',
automation: '#a855f7',
containers: '#00d4ff',
network: '#39d353',
security: '#f85149',
monitoring: '#e3b341',
database: '#a855f7',
web: '#00d4ff',
media: '#ff6e00',
iot: '#e3b341',
}
function serviceColor(port: number | null | undefined, category?: string | null): string {
if (port != null && PORT_COLORS[port]) return PORT_COLORS[port]
if (category && CATEGORY_COLORS[category.toLowerCase()]) return CATEGORY_COLORS[category.toLowerCase()]
return '#8b949e'
}
const TYPE_ICONS: Record<string, React.ElementType> = {
isp: Globe,
router: Router,
server: Server,
proxmox: Layers,
vm: Box,
lxc: Container,
nas: HardDrive,
iot: Cpu,
ap: Wifi,
switch: Network,
generic: Circle,
}
type SourceFilter = 'all' | 'ip' | 'zigbee'
type StatusFilter = 'pending' | 'hidden'
function inferSource(d: PendingDevice): 'zigbee' | 'ip' {
if (d.discovery_source === 'zigbee' || d.ieee_address) return 'zigbee'
return 'ip'
}
const COMMON_PORTS = new Set([22, 80, 443])
function specialServiceName(d: PendingDevice): string | undefined {
const candidates = (d.services ?? []).filter(
(s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port) && s.service_name,
)
// Deprioritize generic web category so apps like home assistant / jellyfin win
const nonWeb = candidates.find((s) => s.category?.toLowerCase() !== 'web')
return (nonWeb ?? candidates[0])?.service_name ?? undefined
}
function deviceLabel(d: PendingDevice): string {
return d.friendly_name ?? d.hostname ?? specialServiceName(d) ?? d.ip ?? d.ieee_address ?? 'device'
}
function injectAutoEdges(edges: { id: string; source: string; target: string }[] | undefined) {
if (!edges || edges.length === 0) return
useCanvasStore.setState((state) => ({
edges: [
...state.edges,
...edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: 'bottom',
targetHandle: 'top-t',
type: 'iot',
data: { type: 'iot' as const },
})),
],
hasUnsavedChanges: true,
}))
}
export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending' }: PendingDevicesModalProps) {
const [devices, setDevices] = useState<PendingDevice[]>([])
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<PendingDevice | null>(null)
const [selectMode, setSelectMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
const [typeFilter, setTypeFilter] = useState<string>('all')
const [statusFilter, setStatusFilter] = useState<StatusFilter>(initialStatus)
const { addNode, scanEventTs } = useCanvasStore()
const highlightRef = useRef<HTMLButtonElement>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const res = statusFilter === 'pending' ? await scanApi.pending() : await scanApi.hidden()
setDevices(res.data)
} catch {
toast.error(`Failed to load ${statusFilter} devices`)
} finally {
setLoading(false)
}
}, [statusFilter])
useEffect(() => { if (open) load() }, [open, load])
useEffect(() => { if (open && scanEventTs > 0) load() }, [scanEventTs, open, load])
// Reset transient state when reopening
useEffect(() => {
if (!open) {
setSelectMode(false)
setSelectedIds(new Set())
setSearch('')
} else {
setStatusFilter(initialStatus)
}
}, [open, initialStatus])
const distinctTypes = useMemo(() => {
const set = new Set<string>()
devices.forEach((d) => { if (d.suggested_type) set.add(d.suggested_type) })
return [...set].sort()
}, [devices])
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
return devices.filter((d) => {
if (sourceFilter !== 'all' && inferSource(d) !== sourceFilter) return false
if (typeFilter !== 'all' && d.suggested_type !== typeFilter) return false
if (q) {
const hay = [
d.friendly_name, d.hostname, d.ip, d.mac, d.ieee_address, d.vendor, d.model,
...d.services.map((s) => s.service_name),
].filter(Boolean).join(' ').toLowerCase()
if (!hay.includes(q)) return false
}
return true
})
}, [devices, search, sourceFilter, typeFilter])
useEffect(() => {
if (!highlightId || loading || !open) return
highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, [highlightId, loading, open, filtered])
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
const handleCardClick = (d: PendingDevice) => {
if (selectMode) { toggleSelect(d.id); return }
if (statusFilter === 'hidden') { handleRestore(d); return }
setSelected(d)
}
const handleRestore = async (device: PendingDevice) => {
try {
await scanApi.restore(device.id)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
toast.success(`Restored ${deviceLabel(device)}`)
} catch {
toast.error('Failed to restore device')
}
}
const handleBulkRestore = async () => {
const ids = [...selectedIds]
if (ids.length === 0) return
try {
const res = await scanApi.bulkRestore(ids)
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
setSelectedIds(new Set())
toast.success(`Restored ${res.data.restored} device${res.data.restored !== 1 ? 's' : ''}`)
} catch {
toast.error('Failed to bulk restore devices')
}
}
const enterSelectMode = () => {
setSelectMode(true)
}
const exitSelectMode = () => {
setSelectMode(false)
setSelectedIds(new Set())
}
const selectAllVisible = () => {
setSelectedIds(new Set(filtered.map((d) => d.id)))
}
const handleClearAll = async () => {
try {
await scanApi.clearPending()
setDevices([])
setSelectedIds(new Set())
toast.success('Pending devices cleared')
} catch {
toast.error('Failed to clear pending devices')
}
}
const handleApprove = async (device: PendingDevice) => {
try {
const fallbackLabel = deviceLabel(device)
const nodeData = {
label: fallbackLabel,
type: (device.suggested_type ?? 'generic') as NodeType,
ip: device.ip ?? undefined,
hostname: device.hostname ?? undefined,
status: 'unknown',
services: (device.services ?? []) as ServiceInfo[],
}
const res = await scanApi.approve(device.id, nodeData)
const nodeId = res.data.node_id
addNode({
id: nodeId,
type: nodeData.type,
position: { x: 400, y: 300 },
data: { ...nodeData, status: 'unknown' as const },
})
injectAutoEdges(res.data.edges)
const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
toast.success(`Approved ${nodeData.label}${extra}`)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
setSelected(null)
onNodeApproved(nodeId)
} catch {
toast.error('Failed to approve device')
}
}
const handleHide = async (device: PendingDevice) => {
try {
await scanApi.hide(device.id)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
setSelected(null)
toast.success('Device hidden')
} catch {
toast.error('Failed to hide device')
}
}
const handleIgnore = async (device: PendingDevice) => {
try {
await scanApi.ignore(device.id)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
setSelected(null)
} catch {
toast.error('Failed to remove device')
}
}
const handleBulkApprove = async () => {
const ids = [...selectedIds]
if (ids.length === 0) return
try {
const res = await scanApi.bulkApprove(ids)
const deviceToNode: Record<string, string> = {}
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
const approvedDevices = devices.filter((d) => ids.includes(d.id))
approvedDevices.forEach((d, i) => {
const nodeId = deviceToNode[d.id]
if (!nodeId) return
addNode({
id: nodeId,
type: (d.suggested_type ?? 'generic') as NodeType,
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
data: {
label: deviceLabel(d),
type: (d.suggested_type ?? 'generic') as NodeType,
ip: d.ip ?? undefined,
hostname: d.hostname ?? undefined,
status: 'unknown' as const,
services: (d.services ?? []) as ServiceInfo[],
},
})
})
injectAutoEdges(res.data.edges)
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
setSelectedIds(new Set())
const linkExtra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}${linkExtra}`)
} catch {
toast.error('Failed to bulk approve devices')
}
}
const handleBulkHide = async () => {
const ids = [...selectedIds]
if (ids.length === 0) return
try {
const res = await scanApi.bulkHide(ids)
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
setSelectedIds(new Set())
toast.success(`Hidden ${res.data.hidden} device${res.data.hidden !== 1 ? 's' : ''}`)
} catch {
toast.error('Failed to bulk hide devices')
}
}
// Keyboard shortcuts: 's' select-mode, 'a' select-all-visible, Esc clears selection or closes, '/' focuses search
const searchRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!open) return
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null
const inField = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT')
if (e.key === 'Escape') {
if (selectMode && selectedIds.size > 0) { e.preventDefault(); setSelectedIds(new Set()) }
return
}
if (inField) return
if (e.key === '/') { e.preventDefault(); searchRef.current?.focus() }
else if (e.key.toLowerCase() === 's') { e.preventDefault(); if (selectMode) exitSelectMode(); else enterSelectMode() }
else if (e.key.toLowerCase() === 'a' && selectMode) { e.preventDefault(); selectAllVisible() }
else if (e.key === 'Enter' && selectMode && selectedIds.size > 0) { e.preventDefault(); handleBulkApprove() }
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
})
return (
<>
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent
showCloseButton={false}
className="!max-w-none w-[95vw] h-[90vh] 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">
{statusFilter === 'pending' ? 'Pending Devices' : 'Hidden Devices'}
<span className="text-muted-foreground font-normal text-xs">
({filtered.length}{filtered.length !== devices.length && ` of ${devices.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} />
</button>
{statusFilter === 'pending' && devices.length > 0 && (
<button onClick={handleClearAll} className="text-muted-foreground hover:text-[#f85149] p-1.5 rounded transition-colors" title="Clear all pending">
<Trash2 size={14} />
</button>
)}
<button onClick={onClose} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Close">
<X size={14} />
</button>
</div>
</div>
</DialogHeader>
{/* Toolbar */}
<div className="px-4 py-2 border-b border-border bg-[#161b22] shrink-0 flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
ref={searchRef}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search name, IP, MAC, IEEE, service…"
className="w-full text-xs bg-[#0d1117] border border-border rounded px-7 py-1.5 outline-none focus:border-[#00d4ff]/50"
/>
</div>
<div className="flex rounded border border-border overflow-hidden text-xs" role="group" aria-label="Source filter">
<button
onClick={() => setSourceFilter('all')}
className={`px-2.5 py-1.5 transition-colors ${sourceFilter === 'all' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
>
All
</button>
<button
onClick={() => setSourceFilter('ip')}
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'ip' ? 'bg-[#a855f7]/20 text-[#a855f7]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
>
IP scan
</button>
<button
onClick={() => setSourceFilter('zigbee')}
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'zigbee' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
>
Zigbee
</button>
</div>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="text-xs bg-[#0d1117] border border-border rounded px-2 py-1.5 outline-none focus:border-[#00d4ff]/50"
aria-label="Type filter"
>
<option value="all">All types</option>
{distinctTypes.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<div className="flex rounded border border-border overflow-hidden text-xs">
<button
onClick={() => setStatusFilter('pending')}
className={`px-2.5 py-1.5 transition-colors ${statusFilter === 'pending' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
>
Pending
</button>
<button
onClick={() => setStatusFilter('hidden')}
className={`px-2.5 py-1.5 transition-colors ${statusFilter === 'hidden' ? 'bg-[#8b949e]/20 text-foreground' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
>
Hidden
</button>
</div>
<button
onClick={() => selectMode ? exitSelectMode() : enterSelectMode()}
className={`text-xs px-2.5 py-1.5 rounded border transition-colors ${selectMode ? 'bg-[#00d4ff]/20 text-[#00d4ff] border-[#00d4ff]/50' : 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground'}`}
title="Toggle select mode (s)"
>
{selectMode ? 'Exit select' : 'Select mode'}
</button>
</div>
{/* Body */}
<div className="flex-1 min-h-0 overflow-y-auto p-4">
{loading && (
<div className="flex items-center justify-center py-10">
<Loader2 size={20} className="animate-spin text-muted-foreground" />
</div>
)}
{!loading && filtered.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-10">
{devices.length === 0 ? `No ${statusFilter} devices` : 'No devices match filters'}
</p>
)}
{!loading && filtered.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-3">
{filtered.map((d) => (
<DeviceCard
key={d.id}
device={d}
selected={selectedIds.has(d.id)}
selectMode={selectMode}
highlighted={d.id === highlightId}
onClick={() => handleCardClick(d)}
cardRef={d.id === highlightId ? highlightRef : undefined}
/>
))}
</div>
)}
</div>
{/* Selection action bar */}
{selectMode && (
<div className="px-4 py-2.5 border-t border-border bg-[#161b22] shrink-0 flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground mr-1">
{selectedIds.size} selected
</span>
<button
onClick={selectAllVisible}
className="text-xs px-2.5 py-1.5 rounded border border-border text-muted-foreground hover:text-foreground transition-colors"
>
Select all visible ({filtered.length})
</button>
<button
onClick={() => setSelectedIds(new Set())}
disabled={selectedIds.size === 0}
className="text-xs px-2.5 py-1.5 rounded border border-border text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
>
Clear
</button>
<div className="flex-1" />
{statusFilter === 'pending' && (
<>
<button
onClick={handleBulkApprove}
disabled={selectedIds.size === 0}
className="text-xs px-3 py-1.5 rounded bg-[#39d353]/20 text-[#39d353] hover:bg-[#39d353]/30 disabled:opacity-40 font-medium transition-colors"
>
Approve ({selectedIds.size})
</button>
<button
onClick={handleBulkHide}
disabled={selectedIds.size === 0}
className="text-xs px-3 py-1.5 rounded bg-[#8b949e]/20 text-[#8b949e] hover:bg-[#8b949e]/30 disabled:opacity-40 font-medium transition-colors"
>
Hide ({selectedIds.size})
</button>
</>
)}
{statusFilter === 'hidden' && (
<button
onClick={handleBulkRestore}
disabled={selectedIds.size === 0}
className="text-xs px-3 py-1.5 rounded bg-[#e3b341]/20 text-[#e3b341] hover:bg-[#e3b341]/30 disabled:opacity-40 font-medium transition-colors"
>
Restore ({selectedIds.size})
</button>
)}
</div>
)}
</DialogContent>
</Dialog>
<PendingDeviceModal
device={selected}
onClose={() => setSelected(null)}
onApprove={handleApprove}
onHide={handleHide}
onIgnore={handleIgnore}
/>
</>
)
}
interface DeviceCardProps {
device: PendingDevice
selected: boolean
selectMode: boolean
highlighted: boolean
onClick: () => void
cardRef?: React.Ref<HTMLButtonElement>
}
function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRef }: DeviceCardProps) {
const source = inferSource(device)
const Icon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
const label = deviceLabel(device)
const sourceColor = source === 'zigbee' ? '#00d4ff' : '#a855f7'
const sourceLabel = source === 'zigbee' ? 'ZIGBEE' : (device.discovery_source ?? 'IP').toUpperCase()
const services = device.services ?? []
const visibleServices = services.slice(0, 4)
const moreServices = services.length - visibleServices.length
const borderClass = highlighted
? 'border-[#e3b341] bg-[#2d3748]'
: selected
? 'border-[#00d4ff] bg-[#00d4ff]/5 shadow-[0_0_0_1px_rgba(0,212,255,0.4)] scale-[1.02]'
: 'border-border bg-[#161b22] hover:border-[#30363d] hover:bg-[#21262d]'
return (
<button
ref={cardRef}
onClick={onClick}
data-testid={`pending-card-${device.id}`}
className={`relative text-left rounded-lg border p-3 transition-all duration-150 ${borderClass}`}
>
{selectMode && selected && (
<CheckCircle2
size={18}
className="absolute top-2 right-2 text-[#00d4ff] fill-[#0d1117]"
/>
)}
{!selectMode && device.status === 'hidden' && (
<EyeOff size={14} className="absolute top-2 right-2 text-muted-foreground" />
)}
{/* Header */}
<div className="flex items-start gap-2 mb-2">
<div className="shrink-0 w-8 h-8 rounded bg-[#21262d] flex items-center justify-center text-foreground">
<Icon size={16} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground break-all leading-snug">{label}</div>
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
<span
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
style={{ background: `${sourceColor}22`, color: sourceColor }}
>
{sourceLabel}
</span>
{device.suggested_type && (
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider bg-[#21262d] text-muted-foreground">
{device.suggested_type}
</span>
)}
{device.lqi != null && (
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider bg-[#21262d] text-muted-foreground">
LQI {device.lqi}
</span>
)}
</div>
</div>
</div>
{/* Tech grid */}
<div className="grid grid-cols-2 gap-x-2 gap-y-0.5 text-[11px] mb-2">
{device.ip && <InfoLine label="IP" value={device.ip} />}
{device.mac && <InfoLine label="MAC" value={device.mac} />}
{device.ieee_address && <InfoLine label="IEEE" value={device.ieee_address} />}
{device.hostname && <InfoLine label="Host" value={device.hostname} />}
{device.vendor && <InfoLine label="Vendor" value={device.vendor} />}
{device.model && <InfoLine label="Model" value={device.model} />}
</div>
{/* Services */}
{visibleServices.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
{visibleServices.map((s, i) => {
const color = serviceColor(s.port, s.category)
return (
<span
key={`${s.port}-${s.protocol}-${i}`}
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
style={{ background: `${color}22`, color }}
title={`${s.service_name} (${s.protocol}/${s.port})`}
>
{s.service_name}
</span>
)
})}
{moreServices > 0 && (
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-[#21262d] text-muted-foreground">
+{moreServices}
</span>
)}
</div>
)}
</button>
)
}
function InfoLine({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-baseline gap-1.5 min-w-0">
<span className="text-muted-foreground shrink-0 w-12">{label}</span>
<span className="font-mono text-foreground truncate">{value}</span>
</div>
)
}
@@ -0,0 +1,216 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { PendingDevicesModal } from '../PendingDevicesModal'
import { useCanvasStore } from '@/stores/canvasStore'
vi.mock('@/stores/canvasStore')
const mockBulkApprove = vi.fn()
const mockBulkHide = vi.fn()
const mockRestore = vi.fn()
const mockBulkRestore = vi.fn()
const mockApprove = vi.fn()
const mockHide = vi.fn()
const mockPending = vi.fn()
const mockHidden = vi.fn()
vi.mock('@/api/client', () => ({
scanApi: {
pending: (...a: unknown[]) => mockPending(...a),
hidden: (...a: unknown[]) => mockHidden(...a),
clearPending: vi.fn().mockResolvedValue({}),
approve: (...a: unknown[]) => mockApprove(...a),
hide: (...a: unknown[]) => mockHide(...a),
ignore: vi.fn().mockResolvedValue({}),
bulkApprove: (...a: unknown[]) => mockBulkApprove(...a),
bulkHide: (...a: unknown[]) => mockBulkHide(...a),
restore: (...a: unknown[]) => mockRestore(...a),
bulkRestore: (...a: unknown[]) => mockBulkRestore(...a),
},
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/components/modals/PendingDeviceModal', () => ({
PendingDeviceModal: ({ device }: { device: unknown }) =>
device ? <div data-testid="approval-modal" /> : null,
}))
const DEVICE_IP = {
id: 'dev-a',
ip: '192.168.1.10',
hostname: 'host-a',
mac: 'aa:bb:cc:dd:ee:01',
os: null,
services: [{ port: 80, protocol: 'tcp', service_name: 'http' }],
suggested_type: 'server',
status: 'pending',
discovery_source: 'arp',
discovered_at: '2026-01-01T00:00:00Z',
}
const DEVICE_ZIGBEE = {
id: 'dev-b',
ip: null,
hostname: null,
mac: null,
os: null,
services: [],
suggested_type: 'iot',
status: 'pending',
discovery_source: 'zigbee',
ieee_address: '0x00124b001234abcd',
friendly_name: 'living-room-bulb',
vendor: 'Philips',
model: 'Hue White',
discovered_at: '2026-01-02T00:00:00Z',
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useCanvasStore).mockReturnValue({
addNode: vi.fn(),
scanEventTs: 0,
} as unknown as ReturnType<typeof useCanvasStore>)
// setState is used by injectAutoEdges
;(useCanvasStore as unknown as { setState: (fn: unknown) => void }).setState = vi.fn()
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZIGBEE] })
mockHidden.mockResolvedValue({ data: [] })
mockApprove.mockResolvedValue({ data: { node_id: 'n1', edges: [], edges_created: 0 } })
mockHide.mockResolvedValue({ data: {} })
mockBulkApprove.mockResolvedValue({
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], edges: [], edges_created: 0 },
})
mockBulkHide.mockResolvedValue({ data: { hidden: 2, skipped: 0 } })
mockRestore.mockResolvedValue({ data: { restored: true, device_id: 'dev-a' } })
mockBulkRestore.mockResolvedValue({ data: { restored: 1, skipped: 0 } })
})
const baseProps = {
open: true,
onClose: vi.fn(),
}
describe('PendingDevicesModal', () => {
it('loads and renders pending devices on open', async () => {
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.getByText('living-room-bulb')).toBeInTheDocument()
})
it('shows source chip ZIGBEE for zigbee device', async () => {
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.getByText('ZIGBEE')).toBeInTheDocument()
})
it('filters by search query', async () => {
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.change(screen.getByPlaceholderText(/Search/), { target: { value: 'living' } })
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument()
})
it('filters by source (zigbee only)', async () => {
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Zigbee' }))
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument()
})
it('filters by suggested type', async () => {
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.change(screen.getByLabelText('Type filter'), { target: { value: 'server' } })
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
expect(screen.queryByTestId('pending-card-dev-b')).not.toBeInTheDocument()
})
it('switches to hidden status loads hidden devices', async () => {
mockHidden.mockResolvedValue({
data: [{ ...DEVICE_IP, id: 'h1', hostname: 'hidden-host', status: 'hidden' }],
})
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Hidden' }))
await waitFor(() => expect(screen.getByTestId('pending-card-h1')).toBeInTheDocument())
expect(mockHidden).toHaveBeenCalled()
})
it('opens approval modal when card is clicked outside select mode', async () => {
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
expect(screen.getByTestId('approval-modal')).toBeInTheDocument()
})
it('toggles selection in select mode instead of opening approval', async () => {
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
expect(screen.queryByTestId('approval-modal')).not.toBeInTheDocument()
expect(screen.getByText('1 selected')).toBeInTheDocument()
})
it('select all visible selects only filtered devices', async () => {
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.change(screen.getByPlaceholderText(/Search/), { target: { value: 'host-a' } })
fireEvent.click(screen.getByRole('button', { name: /Select all visible/ }))
expect(screen.getByText('1 selected')).toBeInTheDocument()
})
it('bulk approve calls API with selected ids', async () => {
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
fireEvent.click(screen.getByTestId('pending-card-dev-b'))
fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
})
it('bulk hide calls API with selected ids', async () => {
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
fireEvent.click(screen.getByRole('button', { name: /Hide \(1\)/ }))
await waitFor(() => expect(mockBulkHide).toHaveBeenCalledWith(['dev-a']))
})
it('does not load when closed', () => {
render(<PendingDevicesModal {...baseProps} open={false} />)
expect(mockPending).not.toHaveBeenCalled()
})
it('respects initialStatus=hidden', async () => {
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, hostname: 'hidden-host', status: 'hidden' }] })
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
await waitFor(() => expect(mockHidden).toHaveBeenCalled())
expect(mockPending).not.toHaveBeenCalled()
})
it('clicking a hidden card restores it instead of opening approval', async () => {
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
await waitFor(() => expect(mockRestore).toHaveBeenCalledWith('dev-a'))
expect(screen.queryByTestId('approval-modal')).not.toBeInTheDocument()
})
it('bulk restore in hidden mode calls API with selected ids', async () => {
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
fireEvent.click(screen.getByRole('button', { name: /Restore \(1\)/ }))
await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a']))
})
})
+28 -425
View File
@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X, LogOut, Network } from 'lucide-react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network } from 'lucide-react'
import { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore'
@@ -8,19 +8,14 @@ import { scanApi, settingsApi } from '@/api/client'
import { toast } from 'sonner'
import { useLatestRelease } from '@/hooks/useLatestRelease'
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' | 'settings'
type SidebarView = 'canvas' | 'history' | 'settings'
const ALL_VIEWS = [
{ id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' },
{ id: 'pending' as SidebarView, icon: ScanLine, label: 'Pending Devices' },
{ id: 'hidden' as SidebarView, icon: EyeOff, label: 'Hidden Devices' },
{ id: 'history' as SidebarView, icon: Clock, label: 'Scan History' },
const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [
{ kind: 'pending', icon: ScanLine, label: 'Pending Devices' },
{ kind: 'hidden', icon: EyeOff, label: 'Hidden Devices' },
]
const VIEWS = STANDALONE ? ALL_VIEWS.slice(0, 1) : ALL_VIEWS
interface ScanRun {
id: string
@@ -38,12 +33,11 @@ interface SidebarProps {
onScan: () => void
onZigbeeImport: () => void
onSave: () => void
onNodeApproved: (nodeId: string) => void
forceView?: SidebarView
highlightPendingId?: string
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
}
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onSave, forceView, onOpenPending }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false)
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
const [prevForceView, setPrevForceView] = useState(forceView)
@@ -88,23 +82,36 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onS
{/* Views */}
<nav className="flex flex-col gap-0.5 p-2">
{VIEWS.map(({ id, icon: Icon, label }) => (
<SidebarItem
icon={LayoutDashboard}
label="Canvas"
collapsed={collapsed}
active={activeView === 'canvas'}
onClick={() => setActiveView('canvas')}
/>
{!STANDALONE && PENDING_TRIGGERS.map((t) => (
<SidebarItem
key={id}
icon={Icon}
label={label}
key={t.kind}
icon={t.icon}
label={t.label}
collapsed={collapsed}
active={activeView === id}
onClick={() => setActiveView(id)}
onClick={() => onOpenPending(undefined, t.kind)}
/>
))}
{!STANDALONE && (
<SidebarItem
icon={Clock}
label="Scan History"
collapsed={collapsed}
active={activeView === 'history'}
onClick={() => setActiveView('history')}
/>
)}
</nav>
{/* View content (only when expanded) */}
{!collapsed && activeView !== 'canvas' && (
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
{activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} highlightId={highlightPendingId} />}
{activeView === 'hidden' && <HiddenDevicesPanel />}
{activeView === 'history' && <ScanHistoryPanel />}
{activeView === 'settings' && <SettingsPanel />}
</div>
@@ -178,361 +185,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onS
)
}
const COMMON_PORTS = new Set([22, 80, 443])
function injectAutoEdges(edges: { id: string; source: string; target: string }[] | undefined) {
if (!edges || edges.length === 0) return
useCanvasStore.setState((state) => ({
edges: [
...state.edges,
...edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: 'bottom',
targetHandle: 'top-t',
type: 'iot',
data: { type: 'iot' as const },
})),
],
hasUnsavedChanges: true,
}))
}
function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: (nodeId: string) => void; highlightId?: string }) {
const [devices, setDevices] = useState<PendingDevice[]>([])
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<PendingDevice | null>(null)
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set())
const { addNode, scanEventTs } = useCanvasStore()
const highlightRef = useRef<HTMLButtonElement>(null)
const allChecked = devices.length > 0 && checkedIds.size === devices.length
const someChecked = checkedIds.size > 0
const toggleCheck = (id: string, e: React.MouseEvent) => {
e.stopPropagation()
setCheckedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
const toggleAll = () => {
setCheckedIds(allChecked ? new Set() : new Set(devices.map((d) => d.id)))
}
const load = useCallback(async () => {
setLoading(true)
try {
const res = await scanApi.pending()
setDevices(res.data)
} catch {
toast.error('Failed to load pending devices')
} finally {
setLoading(false)
}
}, [])
const handleClearAll = async () => {
try {
await scanApi.clearPending()
setDevices([])
setCheckedIds(new Set())
toast.success('Pending devices cleared')
} catch {
toast.error('Failed to clear pending devices')
}
}
const handleBulkApprove = async () => {
const ids = [...checkedIds]
try {
const res = await scanApi.bulkApprove(ids)
const deviceToNode: Record<string, string> = {}
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
const approvedDevices = devices.filter((d) => ids.includes(d.id))
approvedDevices.forEach((d, i) => {
const nodeId = deviceToNode[d.id]
if (!nodeId) return
const fallbackLabel = d.friendly_name ?? d.hostname ?? d.ip ?? d.ieee_address ?? 'device'
addNode({
id: nodeId,
type: (d.suggested_type ?? 'generic') as import('@/types').NodeType,
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
data: {
label: fallbackLabel,
type: (d.suggested_type ?? 'generic') as import('@/types').NodeType,
ip: d.ip ?? undefined,
hostname: d.hostname ?? undefined,
status: 'unknown' as const,
services: (d.services ?? []) as import('@/types').ServiceInfo[],
},
})
onNodeApproved(nodeId)
})
injectAutoEdges(res.data.edges)
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
setCheckedIds(new Set())
const linkExtra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}${linkExtra}`)
} catch {
toast.error('Failed to bulk approve devices')
}
}
const handleBulkHide = async () => {
const ids = [...checkedIds]
try {
const res = await scanApi.bulkHide(ids)
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
setCheckedIds(new Set())
toast.success(`Hidden ${res.data.hidden} device${res.data.hidden !== 1 ? 's' : ''}`)
} catch {
toast.error('Failed to bulk hide devices')
}
}
useEffect(() => { load() }, [load])
useEffect(() => {
if (scanEventTs > 0) load()
}, [scanEventTs, load])
useEffect(() => {
if (!highlightId || loading) return
highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, [highlightId, loading])
const handleApprove = async (device: PendingDevice) => {
try {
const fallbackLabel = device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'device'
const nodeData = {
label: fallbackLabel,
type: (device.suggested_type ?? 'generic') as import('@/types').NodeType,
ip: device.ip ?? undefined,
hostname: device.hostname ?? undefined,
status: 'unknown',
services: (device.services ?? []) as import('@/types').ServiceInfo[],
}
const res = await scanApi.approve(device.id, nodeData)
const nodeId = res.data.node_id
addNode({
id: nodeId,
type: nodeData.type,
position: { x: 400, y: 300 },
data: { ...nodeData, status: 'unknown' as const },
})
injectAutoEdges(res.data.edges)
const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
toast.success(`Approved ${nodeData.label}${extra}`)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
setSelected(null)
onNodeApproved(nodeId)
} catch {
toast.error('Failed to approve device')
}
}
const handleHide = async (device: PendingDevice) => {
try {
await scanApi.hide(device.id)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
toast.success('Device hidden')
} catch {
toast.error('Failed to hide device')
}
}
const handleIgnore = async (device: PendingDevice) => {
try {
await scanApi.ignore(device.id)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
} catch {
toast.error('Failed to ignore device')
}
}
return (
<>
<div className="p-2">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
{devices.length > 0 && (
<input
type="checkbox"
checked={allChecked}
ref={(el) => { if (el) el.indeterminate = someChecked && !allChecked }}
onChange={toggleAll}
className="w-3 h-3 accent-[#00d4ff] cursor-pointer"
title="Select all"
/>
)}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pending</span>
</div>
<div className="flex items-center gap-1">
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5" title="Refresh">
<RefreshCw size={12} />
</button>
{devices.length > 0 && (
<button onClick={handleClearAll} className="text-muted-foreground hover:text-[#f85149] p-0.5" title="Clear all pending">
<X size={12} />
</button>
)}
</div>
</div>
{someChecked && (
<div className="flex items-center gap-1 mb-2">
<button
onClick={handleBulkApprove}
className="flex-1 text-[10px] py-1 px-2 rounded bg-[#39d353]/20 text-[#39d353] hover:bg-[#39d353]/30 transition-colors font-medium"
>
Approve ({checkedIds.size})
</button>
<button
onClick={handleBulkHide}
className="flex-1 text-[10px] py-1 px-2 rounded bg-[#8b949e]/20 text-[#8b949e] hover:bg-[#8b949e]/30 transition-colors font-medium"
>
Hide ({checkedIds.size})
</button>
</div>
)}
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
{!loading && devices.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
)}
{devices.map((d) => {
const isZigbee = d.discovery_source === 'zigbee'
const namedService = d.services.find((s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port))
const titleService = namedService
?? d.services.find((s) => s.port === 80)
?? d.services.find((s) => s.port === 443)
?? d.services.find((s) => s.port === 22)
const title = isZigbee
? (d.friendly_name ?? d.hostname ?? d.ieee_address ?? 'zigbee device')
: (titleService?.service_name ?? d.hostname ?? d.ip ?? 'device')
const showIpBelow = !isZigbee && d.ip != null && title !== d.ip
const hasSsh = d.services.some((s) => s.port === 22)
const hasHttp = d.services.some((s) => s.port === 80)
const hasHttps = d.services.some((s) => s.port === 443)
const otherCount = d.services.filter((s) => s.port !== 22 && s.port !== 80 && s.port !== 443).length
const virtualBadge = detectVirtualBadge(d.mac)
const sourceColor =
d.discovery_source === 'mdns' ? '#a855f7'
: d.discovery_source === 'zigbee' ? '#00d4ff'
: '#8b949e'
const sourceLabel =
d.discovery_source === 'mdns' ? 'mDNS'
: d.discovery_source === 'arp' ? 'ARP'
: d.discovery_source === 'zigbee' ? 'ZIG'
: null
const isHighlighted = d.id === highlightId
return (
<button
key={d.id}
ref={isHighlighted ? highlightRef : null}
onClick={() => setSelected(d)}
className={`w-full mb-1.5 p-2 rounded-md text-xs text-left transition-colors border ${isHighlighted ? 'bg-[#2d3748] border-[#e3b341]' : checkedIds.has(d.id) ? 'bg-[#21262d] border-[#00d4ff]/40' : 'bg-[#21262d] border-transparent hover:bg-[#30363d] hover:border-[#30363d]'}`}
>
<div className="flex items-center gap-1.5">
<input
type="checkbox"
checked={checkedIds.has(d.id)}
onClick={(e) => e.stopPropagation()}
onChange={(e) => { e.stopPropagation(); toggleCheck(d.id, e as unknown as React.MouseEvent) }}
className="w-3 h-3 accent-[#00d4ff] cursor-pointer shrink-0"
/>
<span className="text-foreground truncate font-medium">{title}</span>
</div>
{showIpBelow && (
<div className="font-mono text-muted-foreground truncate pl-3 text-[10px] mt-0.5">{d.ip}</div>
)}
{(hasSsh || hasHttp || hasHttps || otherCount > 0 || virtualBadge || sourceLabel) && (
<div className="flex items-center gap-1 pl-3 mt-1.5 flex-wrap">
{sourceLabel && <ServiceBadge label={sourceLabel} color={sourceColor} />}
{virtualBadge && (
<Tooltip>
<TooltipTrigger asChild>
<span><ServiceBadge label={virtualBadge.label} color="#ff6e00" /></span>
</TooltipTrigger>
<TooltipContent side="right">{virtualBadge.title}</TooltipContent>
</Tooltip>
)}
{hasSsh && <ServiceBadge label="SSH" color="#a855f7" />}
{hasHttp && <ServiceBadge label="HTTP" color="#00d4ff" />}
{hasHttps && <ServiceBadge label="HTTPS" color="#39d353" />}
{otherCount > 0 && <ServiceBadge label={`+${otherCount}`} color="#8b949e" />}
{isZigbee && d.lqi != null && <ServiceBadge label={`LQI ${d.lqi}`} color="#8b949e" />}
</div>
)}
</button>
)
})}
</div>
<PendingDeviceModal
device={selected}
onClose={() => setSelected(null)}
onApprove={handleApprove}
onHide={handleHide}
onIgnore={handleIgnore}
/>
</>
)
}
function HiddenDevicesPanel() {
const [devices, setDevices] = useState<PendingDevice[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const res = await scanApi.hidden()
setDevices(res.data)
} catch {
toast.error('Failed to load hidden devices')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const handleIgnore = async (id: string) => {
try {
await scanApi.ignore(id)
setDevices((prev) => prev.filter((d) => d.id !== id))
} catch {
toast.error('Failed to remove device')
}
}
return (
<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">Hidden</span>
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5">
<RefreshCw size={12} />
</button>
</div>
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
{!loading && devices.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">No hidden devices</p>
)}
{devices.map((d) => (
<div key={d.id} className="mb-2 p-2 rounded-md bg-[#21262d] text-xs">
<div className="font-mono text-foreground">{d.ip}</div>
{d.hostname && <div className="text-muted-foreground truncate">{d.hostname}</div>}
<div className="flex gap-1 mt-1.5">
<ActionButton icon={Trash2} label="Remove" color="red" onClick={() => handleIgnore(d.id)} />
</div>
</div>
))}
</div>
)
}
function ScanHistoryPanel() {
const [runs, setRuns] = useState<ScanRun[]>([])
@@ -731,55 +383,6 @@ function VersionBadge() {
)
}
const MAC_OUI: Record<string, { label: string; title: string }> = {
'52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' },
'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' },
'00:50:56': { label: 'VMware', title: 'VMware Virtual Machine' },
'00:0c:29': { label: 'VMware', title: 'VMware Virtual Machine' },
'08:00:27': { label: 'VBox', title: 'VirtualBox Virtual Machine' },
'00:15:5d': { label: 'Hyper-V', title: 'Hyper-V Virtual Machine' },
}
function detectVirtualBadge(mac: string | null) {
if (!mac) return null
return MAC_OUI[mac.toLowerCase().slice(0, 8)] ?? null
}
function ServiceBadge({ label, color }: { label: string; color: string }) {
return (
<span
className="px-1 py-0.5 rounded text-[9px] font-mono font-medium leading-none border"
style={{ color, borderColor: `${color}40`, backgroundColor: `${color}15` }}
>
{label}
</span>
)
}
interface ActionButtonProps {
icon: React.ElementType
label: string
color?: 'green' | 'red'
onClick: () => void
}
function ActionButton({ icon: Icon, label, color, onClick }: ActionButtonProps) {
const colorClass =
color === 'green' ? 'text-[#39d353] hover:bg-[#39d353]/10' :
color === 'red' ? 'text-[#f85149] hover:bg-[#f85149]/10' :
'text-muted-foreground hover:text-foreground hover:bg-[#30363d]'
return (
<Tooltip>
<TooltipTrigger>
<button onClick={onClick} className={`p-1 rounded ${colorClass} transition-colors`}>
<Icon size={11} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
)
}
interface SidebarItemProps {
icon: React.ElementType
label: string
@@ -11,22 +11,11 @@ import type { NodeData } from '@/types'
vi.mock('@/stores/canvasStore')
vi.mock('@/stores/authStore')
const mockBulkApprove = vi.fn()
const mockBulkHide = vi.fn()
vi.mock('@/api/client', () => ({
scanApi: {
trigger: vi.fn().mockResolvedValue({}),
pending: vi.fn().mockResolvedValue({ data: [] }),
hidden: vi.fn().mockResolvedValue({ data: [] }),
runs: vi.fn().mockResolvedValue({ data: [] }),
stop: vi.fn().mockResolvedValue({}),
clearPending: vi.fn().mockResolvedValue({}),
approve: vi.fn().mockResolvedValue({ data: { approved: true, node_id: 'new-node-1' } }),
hide: vi.fn().mockResolvedValue({ data: { hidden: true } }),
ignore: vi.fn().mockResolvedValue({ data: { ignored: true } }),
bulkApprove: (...args: unknown[]) => mockBulkApprove(...args),
bulkHide: (...args: unknown[]) => mockBulkHide(...args),
},
settingsApi: {
get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
@@ -48,10 +37,6 @@ vi.mock('@/components/ui/tooltip', () => ({
TooltipContent: () => null,
}))
vi.mock('@/components/modals/PendingDeviceModal', () => ({
PendingDeviceModal: () => null,
}))
// ── Helpers ───────────────────────────────────────────────────────────────────
const makeNode = (id: string, status: NodeData['status'], type: NodeData['type'] = 'server'): Node<NodeData> => ({
@@ -86,8 +71,9 @@ const defaultProps = {
onAddNode: vi.fn(),
onAddGroupRect: vi.fn(),
onScan: vi.fn(),
onZigbeeImport: vi.fn(),
onSave: vi.fn(),
onNodeApproved: vi.fn(),
onOpenPending: vi.fn(),
}
// ── Tests ─────────────────────────────────────────────────────────────────────
@@ -129,26 +115,22 @@ describe('Sidebar', () => {
],
})
render(<Sidebar {...defaultProps} />)
// Total (excludes groupRect)
expect(screen.getByText('4')).toBeInTheDocument()
// Online
expect(screen.getByText('2')).toBeInTheDocument()
// Offline
expect(screen.getByText('1')).toBeInTheDocument()
})
it('excludes groupRect nodes from stats', () => {
mockStore({
nodes: [
makeNode('n1', 'unknown'), // 1 real node, not online/offline
makeNode('n1', 'unknown'),
makeNode('zone', 'unknown', 'groupRect'),
],
})
render(<Sidebar {...defaultProps} />)
// Total row shows 1 (groupRect excluded), online/offline both 0
const totalRow = screen.getByText('Total').closest('div')!
expect(totalRow).toHaveTextContent('1')
expect(screen.getAllByText('0')).toHaveLength(2) // online=0, offline=0
expect(screen.getAllByText('0')).toHaveLength(2)
})
// ── Collapse ───────────────────────────────────────────────────────────────
@@ -225,7 +207,6 @@ describe('Sidebar', () => {
it('shows unsaved badge dot on Save Canvas when hasUnsavedChanges', () => {
mockStore({ hasUnsavedChanges: true })
render(<Sidebar {...defaultProps} />)
// The badge is a span sibling of the Save Canvas button icon
const saveBtn = screen.getByText('Save Canvas').closest('button')!
const badge = saveBtn.querySelector('span.rounded-full')
expect(badge).toBeInTheDocument()
@@ -241,24 +222,24 @@ describe('Sidebar', () => {
// ── Scan action ────────────────────────────────────────────────────────────
it('calls onScan prop when Scan Network is clicked (scan trigger moved to ScanConfigModal)', () => {
it('calls onScan prop when Scan Network is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Scan Network'))
expect(defaultProps.onScan).toHaveBeenCalledOnce()
})
// ── Navigation ─────────────────────────────────────────────────────────────
// ── Pending / Hidden open modal ────────────────────────────────────────────
it('shows Pending panel when Pending Devices nav item is clicked', async () => {
it('calls onOpenPending with pending status when Pending Devices is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Pending Devices'))
await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument())
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'pending')
})
it('shows Hidden panel when Hidden Devices nav item is clicked', async () => {
it('calls onOpenPending with hidden status when Hidden Devices is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Hidden Devices'))
await waitFor(() => expect(screen.getByText('No hidden devices')).toBeInTheDocument())
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'hidden')
})
it('shows History panel when Scan History nav item is clicked', async () => {
@@ -267,16 +248,13 @@ describe('Sidebar', () => {
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
})
// Regression: forceView used to override local state on every render, freezing
// the sidebar on whichever view the parent forced (e.g. 'history' after a scan).
// Regression: forceView must not freeze local state across rerenders.
it('allows switching views after forceView is set by parent', async () => {
const { rerender } = render(<Sidebar {...defaultProps} forceView="history" />)
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
// Parent keeps forceView as 'history'; user clicks another nav item.
rerender(<Sidebar {...defaultProps} forceView="history" />)
fireEvent.click(screen.getByText('Pending Devices'))
await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument())
expect(screen.queryByText('No scans yet')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Canvas'))
await waitFor(() => expect(screen.queryByText('No scans yet')).not.toBeInTheDocument())
})
it('toggles Settings panel on Settings click', async () => {
@@ -285,7 +263,6 @@ describe('Sidebar', () => {
await waitFor(() =>
expect(screen.getByText('Status check interval (s)')).toBeInTheDocument(),
)
// Click the nav button again to close (use role to avoid matching the panel heading)
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
})
@@ -303,101 +280,3 @@ describe('Sidebar', () => {
expect(mockLogout).toHaveBeenCalledOnce()
})
})
// ── PendingDevicesPanel — bulk select ─────────────────────────────────────────
const DEVICE_A = {
id: 'dev-a',
ip: '192.168.1.10',
hostname: 'host-a',
mac: null,
os: null,
services: [],
suggested_type: 'generic',
status: 'pending',
discovery_source: 'arp',
}
const DEVICE_B = {
id: 'dev-b',
ip: '192.168.1.11',
hostname: 'host-b',
mac: null,
os: null,
services: [],
suggested_type: 'generic',
status: 'pending',
discovery_source: 'arp',
}
describe('PendingDevicesPanel — bulk select', () => {
beforeEach(() => {
mockStore()
mockAuth()
vi.clearAllMocks()
mockBulkApprove.mockResolvedValue({
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], skipped: 0 },
})
mockBulkHide.mockResolvedValue({ data: { hidden: 2, skipped: 0 } })
})
async function renderWithDevices() {
const { scanApi } = await import('@/api/client')
vi.mocked(scanApi.pending).mockResolvedValue({ data: [DEVICE_A, DEVICE_B] } as never)
render(<Sidebar {...defaultProps} forceView="pending" />)
await waitFor(() => expect(screen.getByText('host-a')).toBeInTheDocument())
}
it('renders checkboxes for each device', async () => {
await renderWithDevices()
const checkboxes = screen.getAllByRole('checkbox')
// select-all + 2 device checkboxes
expect(checkboxes.length).toBe(3)
})
it('shows bulk action bar when a device is checked', async () => {
await renderWithDevices()
const [, firstDeviceCheckbox] = screen.getAllByRole('checkbox')
fireEvent.click(firstDeviceCheckbox)
await waitFor(() => expect(screen.getByText(/Approve \(1\)/)).toBeInTheDocument())
expect(screen.getByText(/Hide \(1\)/)).toBeInTheDocument()
})
it('hides bulk action bar when no device is checked', async () => {
await renderWithDevices()
expect(screen.queryByText(/Approve \(/)).not.toBeInTheDocument()
})
it('select-all checks all devices', async () => {
await renderWithDevices()
const [selectAll] = screen.getAllByRole('checkbox')
fireEvent.click(selectAll)
await waitFor(() => expect(screen.getByText(/Approve \(2\)/)).toBeInTheDocument())
})
it('select-all unchecks all when all are selected', async () => {
await renderWithDevices()
const [selectAll] = screen.getAllByRole('checkbox')
fireEvent.click(selectAll) // select all
fireEvent.click(selectAll) // deselect all
await waitFor(() => expect(screen.queryByText(/Approve \(/)).not.toBeInTheDocument())
})
it('calls bulkApprove with checked ids and removes devices from list', async () => {
await renderWithDevices()
const [selectAll] = screen.getAllByRole('checkbox')
fireEvent.click(selectAll)
fireEvent.click(screen.getByText(/Approve \(2\)/))
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
await waitFor(() => expect(screen.queryByText('host-a')).not.toBeInTheDocument())
})
it('calls bulkHide with checked ids and removes devices from list', async () => {
await renderWithDevices()
const [selectAll] = screen.getAllByRole('checkbox')
fireEvent.click(selectAll)
fireEvent.click(screen.getByText(/Hide \(2\)/))
await waitFor(() => expect(mockBulkHide).toHaveBeenCalledWith(['dev-a', 'dev-b']))
await waitFor(() => expect(screen.queryByText('host-b')).not.toBeInTheDocument())
})
})