0863c2db94
Mirrors IP scan flow: POST /zigbee/import-pending now creates a ScanRun(kind=zigbee, status=running) and returns immediately. Networkmap fetch + pending upsert run in the background, status transitions to done/error when finished. Frontend: import modal closes on submit, scan history shows the run with a ZIG/IP kind chip and toasts on completion. Pending modal auto-refreshes when run finishes. scan_runs.kind column added (default 'ip', idempotent migration). Existing zigbee tests refactored to exercise _persist_pending_import directly (background tasks don't see the test session); route test verifies the run is created with kind=zigbee.
451 lines
18 KiB
TypeScript
451 lines
18 KiB
TypeScript
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<ConnectionForm>(DEFAULT_FORM)
|
|
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle')
|
|
const [connectionMsg, setConnectionMsg] = useState('')
|
|
const [loading, setLoading] = useState(false)
|
|
const [devices, setDevices] = useState<ZigbeeNode[]>([])
|
|
const [edges, setEdges] = useState<ZigbeeEdge[]>([])
|
|
const [checked, setChecked] = useState<Set<string>>(new Set())
|
|
const [importMode, setImportMode] = useState<ImportMode>('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 (
|
|
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
|
<DialogContent className="bg-[#161b22] border-border max-w-xl max-h-[85vh] flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-foreground flex items-center gap-2">
|
|
<Network size={16} className="text-[#00d4ff]" />
|
|
Zigbee2MQTT Import
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="flex-1 overflow-y-auto space-y-4 py-2 min-h-0">
|
|
{/* Connection Form */}
|
|
<div className="space-y-3">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="col-span-2 space-y-1">
|
|
<Label className="text-xs text-muted-foreground">Broker Host</Label>
|
|
<Input
|
|
value={form.mqtt_host}
|
|
onChange={(e) => updateField('mqtt_host', e.target.value)}
|
|
placeholder="192.168.1.x or mqtt.local"
|
|
className="font-mono text-sm bg-[#0d1117] border-border"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs text-muted-foreground">Port</Label>
|
|
<Input
|
|
value={form.mqtt_port}
|
|
onChange={(e) => updateField('mqtt_port', e.target.value)}
|
|
placeholder="1883"
|
|
type="number"
|
|
className="font-mono text-sm bg-[#0d1117] border-border"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs text-muted-foreground">Base Topic</Label>
|
|
<Input
|
|
value={form.base_topic}
|
|
onChange={(e) => updateField('base_topic', e.target.value)}
|
|
placeholder="zigbee2mqtt"
|
|
className="font-mono text-sm bg-[#0d1117] border-border"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs text-muted-foreground">Username (optional)</Label>
|
|
<Input
|
|
value={form.mqtt_username}
|
|
onChange={(e) => updateField('mqtt_username', e.target.value)}
|
|
placeholder="mqtt_user"
|
|
className="text-sm bg-[#0d1117] border-border"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs text-muted-foreground">Password (optional)</Label>
|
|
<Input
|
|
value={form.mqtt_password}
|
|
onChange={(e) => updateField('mqtt_password', e.target.value)}
|
|
placeholder="••••••••"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
className="text-sm bg-[#0d1117] border-border"
|
|
/>
|
|
</div>
|
|
<div className="col-span-2 flex items-center gap-4 pt-1">
|
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.mqtt_tls}
|
|
onChange={(e) => toggleTls(e.target.checked)}
|
|
className="w-3 h-3 accent-[#00d4ff] cursor-pointer"
|
|
/>
|
|
Use TLS (port 8883)
|
|
</label>
|
|
<label
|
|
className={`flex items-center gap-1.5 text-xs cursor-pointer ${
|
|
form.mqtt_tls ? 'text-[#f85149]' : 'text-muted-foreground/40 cursor-not-allowed'
|
|
}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={form.mqtt_tls_insecure}
|
|
disabled={!form.mqtt_tls}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, mqtt_tls_insecure: e.target.checked }))
|
|
}
|
|
className="w-3 h-3 accent-[#f85149] cursor-pointer disabled:cursor-not-allowed"
|
|
/>
|
|
Skip cert verify (self-signed only)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Connection status indicator */}
|
|
{connectionStatus !== 'idle' && (
|
|
<div className={`flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-md border ${
|
|
connectionStatus === 'ok'
|
|
? 'bg-[#39d353]/10 border-[#39d353]/30 text-[#39d353]'
|
|
: connectionStatus === 'fail'
|
|
? 'bg-[#f85149]/10 border-[#f85149]/30 text-[#f85149]'
|
|
: 'bg-[#e3b341]/10 border-[#e3b341]/30 text-[#e3b341]'
|
|
}`}>
|
|
{connectionStatus === 'testing' && <Loader2 size={12} className="animate-spin" />}
|
|
{connectionStatus === 'ok' && <CheckCircle2 size={12} />}
|
|
{connectionStatus === 'fail' && <XCircle size={12} />}
|
|
<span>{connectionStatus === 'testing' ? 'Testing…' : connectionMsg}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-3 text-xs">
|
|
<span className="text-muted-foreground">Send devices to:</span>
|
|
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
|
|
<input
|
|
type="radio"
|
|
name="zigbee-import-mode"
|
|
checked={importMode === 'pending'}
|
|
onChange={() => setImportMode('pending')}
|
|
className="accent-[#00d4ff] cursor-pointer"
|
|
/>
|
|
Pending section
|
|
</label>
|
|
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
|
|
<input
|
|
type="radio"
|
|
name="zigbee-import-mode"
|
|
checked={importMode === 'canvas'}
|
|
onChange={() => setImportMode('canvas')}
|
|
className="accent-[#00d4ff] cursor-pointer"
|
|
/>
|
|
Canvas directly
|
|
</label>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="gap-1.5 text-muted-foreground hover:text-foreground border border-border hover:bg-[#21262d]"
|
|
onClick={handleTestConnection}
|
|
disabled={connectionStatus === 'testing' || loading}
|
|
>
|
|
{connectionStatus === 'testing'
|
|
? <Loader2 size={13} className="animate-spin" />
|
|
: <CheckCircle2 size={13} />}
|
|
Test Connection
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
style={{ background: '#00d4ff', color: '#0d1117' }}
|
|
className="gap-1.5"
|
|
onClick={handleFetchDevices}
|
|
disabled={loading || connectionStatus === 'testing'}
|
|
>
|
|
{loading ? <Loader2 size={13} className="animate-spin" /> : <Network size={13} />}
|
|
{importMode === 'pending' ? 'Import to Pending' : 'Fetch Devices'}
|
|
</Button>
|
|
</div>
|
|
<p className="text-[11px] text-muted-foreground italic">
|
|
Fetching the network map can take several minutes on large meshes.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Device List */}
|
|
{devices.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-1.5">
|
|
<input
|
|
type="checkbox"
|
|
checked={checked.size === devices.length}
|
|
ref={(el) => { 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"
|
|
/>
|
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
Devices ({checked.size}/{devices.length} selected)
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{(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 (
|
|
<div key={type}>
|
|
<div className="flex items-center gap-1.5 mb-1">
|
|
<Icon size={11} style={{ color }} />
|
|
<span className="text-[10px] font-medium uppercase tracking-wider" style={{ color }}>
|
|
{DEVICE_TYPE_LABEL[type]} ({group.length})
|
|
</span>
|
|
</div>
|
|
{group.map((device) => (
|
|
<div
|
|
key={device.id}
|
|
className={`flex items-start gap-2 p-2 mb-1 rounded-md text-xs cursor-pointer transition-colors border ${
|
|
checked.has(device.id)
|
|
? 'bg-[#21262d] border-[#00d4ff]/40'
|
|
: 'bg-[#21262d] border-transparent hover:bg-[#30363d]'
|
|
}`}
|
|
onClick={() => toggleCheck(device.id)}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={checked.has(device.id)}
|
|
onChange={() => toggleCheck(device.id)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="w-3 h-3 mt-0.5 accent-[#00d4ff] cursor-pointer shrink-0"
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-foreground font-medium truncate">{device.friendly_name}</div>
|
|
<div className="font-mono text-[10px] text-muted-foreground truncate">{device.ieee_address}</div>
|
|
{(device.model || device.vendor) && (
|
|
<div className="text-[10px] text-muted-foreground truncate">
|
|
{[device.vendor, device.model].filter(Boolean).join(' · ')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{device.lqi != null && (
|
|
<span
|
|
className="text-[9px] font-mono px-1 py-0.5 rounded border shrink-0"
|
|
style={{ color: '#8b949e', borderColor: '#8b949e40' }}
|
|
>
|
|
LQI {device.lqi}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2 shrink-0 pt-2 border-t border-border">
|
|
<Button variant="ghost" onClick={handleClose}>Cancel</Button>
|
|
{devices.length > 0 && (
|
|
<Button
|
|
onClick={handleAddToCanvas}
|
|
disabled={checked.size === 0}
|
|
style={{ background: '#00d4ff', color: '#0d1117' }}
|
|
className="gap-1.5"
|
|
>
|
|
<Plus size={13} />
|
|
Add {checked.size} to Canvas
|
|
</Button>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|