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, DialogClose, 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' import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties' import { buildMacProperty } from '@/utils/macProperty' interface PendingDevicesModalProps { open: boolean onClose: () => void highlightId?: string initialStatus?: 'pending' | 'hidden' } const PORT_COLORS: Record = { 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 = { 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 = { 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([]) const [loading, setLoading] = useState(false) const [selected, setSelected] = useState(null) const [selectMode, setSelectMode] = useState(false) const [selectedIds, setSelectedIds] = useState>(new Set()) const [search, setSearch] = useState('') const [sourceFilter, setSourceFilter] = useState('all') const [typeFilter, setTypeFilter] = useState('all') const [statusFilter, setStatusFilter] = useState(initialStatus) const { addNode, scanEventTs } = useCanvasStore() const highlightRef = useRef(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() 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 () => { const targets = filtered if (targets.length === 0) return const filtersActive = targets.length !== devices.length try { if (filtersActive) { const results = await Promise.allSettled(targets.map((d) => scanApi.ignore(d.id))) const failed = results.filter((r) => r.status === 'rejected').length const removedIds = new Set( targets.filter((_, i) => results[i].status === 'fulfilled').map((d) => d.id) ) setDevices((prev) => prev.filter((d) => !removedIds.has(d.id))) setSelectedIds(new Set()) if (failed > 0) toast.error(`Removed ${removedIds.size}, ${failed} failed`) else toast.success(`Removed ${removedIds.size} device${removedIds.size !== 1 ? 's' : ''}`) } else { 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 type = (device.suggested_type ?? 'generic') as NodeType const zigbee = isZigbeeType(type) const properties = zigbee ? buildZigbeeProperties(device) : buildMacProperty(device.mac) const nodeData = { label: fallbackLabel, type, ip: device.ip ?? undefined, mac: device.mac ?? undefined, hostname: device.hostname ?? undefined, status: zigbee ? 'online' : 'unknown', services: (device.services ?? []) as ServiceInfo[], properties, } 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: zigbee ? ('online' as const) : ('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) } 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 = {} 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 type = (d.suggested_type ?? 'generic') as NodeType const zigbee = isZigbeeType(type) addNode({ id: nodeId, type, position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 }, data: { label: deviceLabel(d), type, ip: d.ip ?? undefined, mac: d.mac ?? undefined, hostname: d.hostname ?? undefined, status: zigbee ? ('online' as const) : ('unknown' as const), services: (d.services ?? []) as ServiceInfo[], properties: zigbee ? buildZigbeeProperties(d) : buildMacProperty(d.mac), }, }) }) 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(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) { // Enter confirms the bulk action for the current view: approving // hidden devices would be wrong — they restore. e.preventDefault() if (statusFilter === 'hidden') handleBulkRestore() else handleBulkApprove() } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) // statusFilter is included so Enter dispatches the correct bulk action // (approve vs restore) even if the device list doesn't change on switch. // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, selectMode, selectedIds, filtered, statusFilter]) return ( <> { if (!v) onClose() }}>
{statusFilter === 'pending' ? 'Pending Devices' : 'Hidden Devices'} ({filtered.length}{filtered.length !== devices.length && ` of ${devices.length}`})
{statusFilter === 'pending' && devices.length > 0 && ( )} {/* Route the close X through Base UI's DialogClose (same path as outside-click) instead of a raw onClick — the latter's synthetic click was being dropped on Firefox/Windows. */} } >
{/* Toolbar */}
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" />
{/* Body */}
{loading && (
)} {!loading && filtered.length === 0 && (

{devices.length === 0 ? `No ${statusFilter} devices` : 'No devices match filters'}

)} {!loading && filtered.length > 0 && (
{filtered.map((d) => ( handleCardClick(d)} cardRef={d.id === highlightId ? highlightRef : undefined} /> ))}
)}
{/* Selection action bar */} {selectMode && (
{selectedIds.size} selected
{statusFilter === 'pending' && ( <> )} {statusFilter === 'hidden' && ( )}
)}
setSelected(null)} onApprove={handleApprove} onHide={handleHide} onIgnore={handleIgnore} /> ) } interface DeviceCardProps { device: PendingDevice selected: boolean selectMode: boolean highlighted: boolean onClick: () => void cardRef?: React.Ref } 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 ( ) } function InfoLine({ label, value }: { label: string; value: string }) { return (
{label} {value}
) }