import { useState } from 'react' import { Network, Router, Cpu, CheckCircle2, XCircle, Loader2, Plus } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { zigbeeApi } from '@/api/client' import { toast } from 'sonner' import type { ZigbeeNode, ZigbeeEdge } from './types' interface ZigbeeImportModalProps { open: boolean onClose: () => void onAddToCanvas: (nodes: ZigbeeNode[], edges: ZigbeeEdge[]) => void onPendingImported?: ( coordinator?: { id: string; label: string; ieee_address: string } | null, ) => void } type ImportMode = 'pending' | 'canvas' interface ConnectionForm { mqtt_host: string mqtt_port: string mqtt_username: string mqtt_password: string base_topic: string mqtt_tls: boolean mqtt_tls_insecure: boolean port_user_edited: boolean } const DEFAULT_FORM: ConnectionForm = { mqtt_host: '', mqtt_port: '1883', mqtt_username: '', mqtt_password: '', base_topic: 'zigbee2mqtt', mqtt_tls: false, mqtt_tls_insecure: false, port_user_edited: false, } const DEVICE_TYPE_ICON = { zigbee_coordinator: Network, zigbee_router: Router, zigbee_enddevice: Cpu, } as const const DEVICE_TYPE_LABEL = { zigbee_coordinator: 'Coordinator', zigbee_router: 'Router', zigbee_enddevice: 'End Device', } as const const DEVICE_TYPE_COLOR = { zigbee_coordinator: '#00d4ff', zigbee_router: '#39d353', zigbee_enddevice: '#e3b341', } as const export function ZigbeeImportModal({ open, onClose, onAddToCanvas, onPendingImported }: ZigbeeImportModalProps) { const [form, setForm] = useState(DEFAULT_FORM) const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle') const [connectionMsg, setConnectionMsg] = useState('') const [loading, setLoading] = useState(false) const [devices, setDevices] = useState([]) const [edges, setEdges] = useState([]) const [checked, setChecked] = useState>(new Set()) const [importMode, setImportMode] = useState('pending') const updateField = (field: keyof ConnectionForm, value: string) => setForm((f) => ({ ...f, [field]: value, ...(field === 'mqtt_port' ? { port_user_edited: true } : {}), })) const toggleTls = (next: boolean) => setForm((f) => { const port = f.port_user_edited ? f.mqtt_port : next ? '8883' : '1883' return { ...f, mqtt_tls: next, mqtt_tls_insecure: next ? f.mqtt_tls_insecure : false, mqtt_port: port, } }) const buildPayload = () => ({ mqtt_host: form.mqtt_host.trim(), mqtt_port: Number(form.mqtt_port) || (form.mqtt_tls ? 8883 : 1883), mqtt_username: form.mqtt_username.trim() || undefined, mqtt_password: form.mqtt_password || undefined, base_topic: form.base_topic.trim() || 'zigbee2mqtt', mqtt_tls: form.mqtt_tls, mqtt_tls_insecure: form.mqtt_tls_insecure, }) const handleTestConnection = async () => { if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return } setConnectionStatus('testing') try { const res = await zigbeeApi.testConnection({ mqtt_host: form.mqtt_host.trim(), mqtt_port: Number(form.mqtt_port) || (form.mqtt_tls ? 8883 : 1883), mqtt_username: form.mqtt_username.trim() || undefined, mqtt_password: form.mqtt_password || undefined, mqtt_tls: form.mqtt_tls, mqtt_tls_insecure: form.mqtt_tls_insecure, }) if (res.data.connected) { setConnectionStatus('ok') setConnectionMsg(res.data.message) } else { setConnectionStatus('fail') setConnectionMsg(res.data.message) } } catch { setConnectionStatus('fail') setConnectionMsg('Request failed — check broker address') } } const extractError = (err: unknown): string | undefined => { if (err && typeof err === 'object' && 'response' in err) { return (err as { response?: { data?: { detail?: string } } }).response?.data?.detail } return undefined } const handleFetchDevices = async () => { if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return } setLoading(true) try { if (importMode === 'pending') { await zigbeeApi.importToPending(buildPayload()) toast.success('Zigbee import started — track progress in Scan History') onPendingImported?.(null) handleClose() } else { const res = await zigbeeApi.importNetwork(buildPayload()) setDevices(res.data.nodes) setEdges(res.data.edges) setChecked(new Set(res.data.nodes.map((n) => n.id))) if (res.data.device_count === 0) { toast.info('No Zigbee devices found in the network map') } else { toast.success(`Found ${res.data.device_count} device${res.data.device_count !== 1 ? 's' : ''}`) } } } catch (err: unknown) { toast.error(extractError(err) ?? 'Failed to fetch Zigbee devices') } finally { setLoading(false) } } const toggleCheck = (id: string) => setChecked((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id); else next.add(id) return next }) const toggleAll = () => { setChecked(checked.size === devices.length ? new Set() : new Set(devices.map((d) => d.id))) } const handleAddToCanvas = () => { const selectedDevices = devices.filter((d) => checked.has(d.id)) const selectedIds = new Set(selectedDevices.map((d) => d.id)) const selectedEdges = edges.filter((e) => selectedIds.has(e.source) && selectedIds.has(e.target)) onAddToCanvas(selectedDevices, selectedEdges) toast.success(`Added ${selectedDevices.length} device${selectedDevices.length !== 1 ? 's' : ''} to canvas`) onClose() } const handleClose = () => { setDevices([]) setEdges([]) setChecked(new Set()) setConnectionStatus('idle') setConnectionMsg('') setImportMode('pending') onClose() } const groupedDevices = { zigbee_coordinator: devices.filter((d) => d.type === 'zigbee_coordinator'), zigbee_router: devices.filter((d) => d.type === 'zigbee_router'), zigbee_enddevice: devices.filter((d) => d.type === 'zigbee_enddevice'), } as const return ( !v && handleClose()}> Zigbee2MQTT Import
{/* Connection Form */}
updateField('mqtt_host', e.target.value)} placeholder="192.168.1.x or mqtt.local" className="font-mono text-sm bg-[#0d1117] border-border" />
updateField('mqtt_port', e.target.value)} placeholder="1883" type="number" className="font-mono text-sm bg-[#0d1117] border-border" />
updateField('base_topic', e.target.value)} placeholder="zigbee2mqtt" className="font-mono text-sm bg-[#0d1117] border-border" />
updateField('mqtt_username', e.target.value)} placeholder="mqtt_user" className="text-sm bg-[#0d1117] border-border" />
updateField('mqtt_password', e.target.value)} placeholder="••••••••" type="password" autoComplete="new-password" className="text-sm bg-[#0d1117] border-border" />
{/* Connection status indicator */} {connectionStatus !== 'idle' && (
{connectionStatus === 'testing' && } {connectionStatus === 'ok' && } {connectionStatus === 'fail' && } {connectionStatus === 'testing' ? 'Testing…' : connectionMsg}
)}
Send devices to:

Fetching the network map can take several minutes on large meshes.

{/* Device List */} {devices.length > 0 && (
{ if (el) el.indeterminate = checked.size > 0 && checked.size < devices.length }} onChange={toggleAll} className="w-3 h-3 accent-[#00d4ff] cursor-pointer" title="Select all" /> Devices ({checked.size}/{devices.length} selected)
{(Object.entries(groupedDevices) as [keyof typeof groupedDevices, ZigbeeNode[]][]) .filter(([, group]) => group.length > 0) .map(([type, group]) => { const Icon = DEVICE_TYPE_ICON[type] const color = DEVICE_TYPE_COLOR[type] return (
{DEVICE_TYPE_LABEL[type]} ({group.length})
{group.map((device) => (
toggleCheck(device.id)} > toggleCheck(device.id)} onClick={(e) => e.stopPropagation()} className="w-3 h-3 mt-0.5 accent-[#00d4ff] cursor-pointer shrink-0" />
{device.friendly_name}
{device.ieee_address}
{(device.model || device.vendor) && (
{[device.vendor, device.model].filter(Boolean).join(' · ')}
)}
{device.lqi != null && ( LQI {device.lqi} )}
))}
) })}
)}
{devices.length > 0 && ( )}
) }