feat(zigbee): import to pending section with edge persistence
Coordinator auto-approves to a canvas Node; routers/end devices land in pending_devices keyed by IEEE. Discovered parent->child edges are stored in pending_device_links so that approving a pending device later auto-creates the Edge once both endpoints exist as canvas Nodes. - new POST /api/v1/zigbee/import-pending (default mode in modal) - new pending_device_links table; ieee_address on nodes + pending_devices - pending_devices.ip migrated to nullable (table rebuild on existing DBs) - approve / bulk-approve return auto-created edges; sidebar pushes them into the canvas store with bottom -> top-t handles - ZigbeeImportModal: radio toggle pending vs canvas; reset on close - PendingDeviceModal: zigbee badge, IEEE/LQI/vendor/model rows, services hidden for zigbee - Sidebar pending row: ZIG source badge, LQI badge, friendly_name fallback - SearchBar: null-safe IP, also searches friendly_name and ieee_address - Tooltip trigger uses asChild to avoid nested-button hydration error
This commit is contained in:
@@ -540,6 +540,26 @@ export default function App() {
|
||||
open={zigbeeImportOpen}
|
||||
onClose={() => setZigbeeImportOpen(false)}
|
||||
onAddToCanvas={handleZigbeeAddToCanvas}
|
||||
onPendingImported={(coordinator) => {
|
||||
useCanvasStore.getState().notifyScanDeviceFound()
|
||||
if (coordinator) {
|
||||
const exists = useCanvasStore.getState().nodes.some((n) => n.id === coordinator.id)
|
||||
if (!exists) {
|
||||
addNode({
|
||||
id: coordinator.id,
|
||||
type: 'zigbee_coordinator',
|
||||
position: { x: 600, y: 100 },
|
||||
data: {
|
||||
label: coordinator.label,
|
||||
type: 'zigbee_coordinator' as NodeData['type'],
|
||||
status: 'unknown' as const,
|
||||
services: [],
|
||||
},
|
||||
})
|
||||
markUnsaved()
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -58,10 +58,24 @@ export const scanApi = {
|
||||
hidden: () => api.get('/scan/hidden'),
|
||||
runs: () => api.get('/scan/runs'),
|
||||
clearPending: () => api.delete('/scan/pending'),
|
||||
approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData),
|
||||
approve: (id: string, nodeData: object) =>
|
||||
api.post<{
|
||||
approved: boolean
|
||||
node_id: string
|
||||
edges_created: number
|
||||
edges: { id: string; source: string; target: string }[]
|
||||
}>(`/scan/pending/${id}/approve`, nodeData),
|
||||
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
||||
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
||||
bulkApprove: (ids: string[]) => api.post<{ approved: number; node_ids: string[]; device_ids: string[]; skipped: number }>('/scan/pending/bulk-approve', { device_ids: ids }),
|
||||
bulkApprove: (ids: string[]) =>
|
||||
api.post<{
|
||||
approved: number
|
||||
node_ids: string[]
|
||||
device_ids: string[]
|
||||
edges_created: number
|
||||
edges: { id: string; source: string; target: string }[]
|
||||
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 }),
|
||||
stop: (runId: string) => api.post(`/scan/${runId}/stop`),
|
||||
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
|
||||
@@ -98,4 +112,22 @@ export const zigbeeApi = {
|
||||
edges: import('@/components/zigbee/types').ZigbeeEdge[]
|
||||
device_count: number
|
||||
}>('/zigbee/import', data),
|
||||
|
||||
importToPending: (data: {
|
||||
mqtt_host: string
|
||||
mqtt_port: number
|
||||
mqtt_username?: string
|
||||
mqtt_password?: string
|
||||
base_topic?: string
|
||||
mqtt_tls?: boolean
|
||||
mqtt_tls_insecure?: boolean
|
||||
}) =>
|
||||
api.post<{
|
||||
pending_created: number
|
||||
pending_updated: number
|
||||
coordinator: { id: string; label: string; ieee_address: string } | null
|
||||
coordinator_already_existed: boolean
|
||||
links_recorded: number
|
||||
device_count: number
|
||||
}>('/zigbee/import-pending', data),
|
||||
}
|
||||
|
||||
@@ -57,8 +57,10 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
|
||||
|
||||
const pendingResults = q
|
||||
? pendingDevices.filter((d) =>
|
||||
d.ip.toLowerCase().includes(q) ||
|
||||
d.ip?.toLowerCase().includes(q) ||
|
||||
d.hostname?.toLowerCase().includes(q) ||
|
||||
d.friendly_name?.toLowerCase().includes(q) ||
|
||||
d.ieee_address?.toLowerCase().includes(q) ||
|
||||
d.services.some((s) =>
|
||||
s.service_name?.toLowerCase().includes(q) ||
|
||||
s.category?.toLowerCase().includes(q)
|
||||
@@ -196,10 +198,10 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
|
||||
>
|
||||
<span style={{ fontSize: 10, color: '#e3b341', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>pending</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{d.hostname ?? d.ip}
|
||||
{d.friendly_name ?? d.hostname ?? d.ip ?? d.ieee_address ?? 'device'}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
|
||||
{serviceName ?? d.ip}
|
||||
{serviceName ?? d.ip ?? d.ieee_address ?? ''}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ interface Service {
|
||||
|
||||
export interface PendingDevice {
|
||||
id: string
|
||||
ip: string
|
||||
ip: string | null
|
||||
mac: string | null
|
||||
hostname: string | null
|
||||
os: string | null
|
||||
@@ -20,6 +20,12 @@ export interface PendingDevice {
|
||||
suggested_type: string | null
|
||||
status: string
|
||||
discovery_source: string | null
|
||||
ieee_address?: string | null
|
||||
friendly_name?: string | null
|
||||
device_subtype?: string | null
|
||||
model?: string | null
|
||||
vendor?: string | null
|
||||
lqi?: number | null
|
||||
discovered_at: string
|
||||
}
|
||||
|
||||
@@ -77,6 +83,8 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
||||
if (!device) return null
|
||||
|
||||
const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
||||
const isZigbee = device.discovery_source === 'zigbee'
|
||||
const titleLabel = device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'Pending device'
|
||||
|
||||
const handleApprove = () => { onApprove(device) }
|
||||
const handleHide = () => { onHide(device); onClose() }
|
||||
@@ -88,17 +96,30 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<TypeIcon size={15} className="text-[#00d4ff] shrink-0" />
|
||||
{device.hostname ?? device.ip}
|
||||
{titleLabel}
|
||||
{isZigbee && (
|
||||
<span className="ml-1 text-[9px] font-mono uppercase px-1 py-0.5 rounded bg-[#00d4ff]/15 text-[#00d4ff] border border-[#00d4ff]/30">
|
||||
Zigbee
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
{/* Device info */}
|
||||
<div className="flex flex-col gap-1.5 p-3 rounded-md bg-[#21262d] border border-[#30363d]">
|
||||
<InfoRow label="IP" value={device.ip} />
|
||||
{device.ip && <InfoRow label="IP" value={device.ip} />}
|
||||
{device.hostname && <InfoRow label="Hostname" value={device.hostname} />}
|
||||
{device.mac && <InfoRow label="MAC" value={device.mac} />}
|
||||
{device.os && <InfoRow label="OS" value={device.os} />}
|
||||
{device.ieee_address && <InfoRow label="IEEE" value={device.ieee_address} />}
|
||||
{device.friendly_name && device.friendly_name !== device.hostname && (
|
||||
<InfoRow label="Name" value={device.friendly_name} />
|
||||
)}
|
||||
{device.vendor && <InfoRow label="Vendor" value={device.vendor} />}
|
||||
{device.model && <InfoRow label="Model" value={device.model} />}
|
||||
{device.device_subtype && <InfoRow label="Role" value={device.device_subtype} />}
|
||||
{device.lqi != null && <InfoRow label="LQI" value={String(device.lqi)} />}
|
||||
{device.suggested_type && (
|
||||
<InfoRow label="Type" value={device.suggested_type} />
|
||||
)}
|
||||
@@ -108,8 +129,8 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
||||
<InfoRow label="Discovered" value={new Date(device.discovered_at.endsWith('Z') ? device.discovered_at : device.discovered_at + 'Z').toLocaleString()} />
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
<div>
|
||||
{/* Services (skipped for Zigbee devices — they don't have IP services) */}
|
||||
{!isZigbee && <div>
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
Services found ({device.services.length})
|
||||
</p>
|
||||
@@ -138,7 +159,7 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
|
||||
@@ -180,6 +180,25 @@ 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)
|
||||
@@ -237,14 +256,15 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
||||
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: d.hostname ?? d.ip,
|
||||
label: fallbackLabel,
|
||||
type: (d.suggested_type ?? 'generic') as import('@/types').NodeType,
|
||||
ip: d.ip,
|
||||
ip: d.ip ?? undefined,
|
||||
hostname: d.hostname ?? undefined,
|
||||
status: 'unknown' as const,
|
||||
services: (d.services ?? []) as import('@/types').ServiceInfo[],
|
||||
@@ -252,9 +272,11 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
||||
})
|
||||
onNodeApproved(nodeId)
|
||||
})
|
||||
injectAutoEdges(res.data.edges)
|
||||
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
||||
setCheckedIds(new Set())
|
||||
toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}`)
|
||||
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')
|
||||
}
|
||||
@@ -285,10 +307,11 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
||||
|
||||
const handleApprove = async (device: PendingDevice) => {
|
||||
try {
|
||||
const fallbackLabel = device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'device'
|
||||
const nodeData = {
|
||||
label: device.hostname ?? device.ip,
|
||||
label: fallbackLabel,
|
||||
type: (device.suggested_type ?? 'generic') as import('@/types').NodeType,
|
||||
ip: device.ip,
|
||||
ip: device.ip ?? undefined,
|
||||
hostname: device.hostname ?? undefined,
|
||||
status: 'unknown',
|
||||
services: (device.services ?? []) as import('@/types').ServiceInfo[],
|
||||
@@ -301,7 +324,9 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
||||
position: { x: 400, y: 300 },
|
||||
data: { ...nodeData, status: 'unknown' as const },
|
||||
})
|
||||
toast.success(`Approved ${nodeData.label}`)
|
||||
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)
|
||||
@@ -378,20 +403,30 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
||||
<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 = titleService?.service_name ?? d.hostname ?? d.ip
|
||||
const showIpBelow = title !== d.ip
|
||||
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' : '#8b949e'
|
||||
const sourceLabel = d.discovery_source === 'mdns' ? 'mDNS' : d.discovery_source === 'arp' ? 'ARP' : null
|
||||
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
|
||||
@@ -418,7 +453,7 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
||||
{sourceLabel && <ServiceBadge label={sourceLabel} color={sourceColor} />}
|
||||
{virtualBadge && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger asChild>
|
||||
<span><ServiceBadge label={virtualBadge.label} color="#ff6e00" /></span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{virtualBadge.title}</TooltipContent>
|
||||
@@ -428,6 +463,7 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
||||
{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>
|
||||
|
||||
@@ -12,8 +12,13 @@ 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
|
||||
@@ -54,7 +59,7 @@ const DEVICE_TYPE_COLOR = {
|
||||
zigbee_enddevice: '#e3b341',
|
||||
} as const
|
||||
|
||||
export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImportModalProps) {
|
||||
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('')
|
||||
@@ -62,6 +67,7 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport
|
||||
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) => ({
|
||||
@@ -120,24 +126,45 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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')
|
||||
if (importMode === 'pending') {
|
||||
const res = await zigbeeApi.importToPending(buildPayload())
|
||||
const { pending_created, pending_updated, coordinator, coordinator_already_existed, device_count } = res.data
|
||||
if (device_count === 0) {
|
||||
toast.info('No Zigbee devices found in the network map')
|
||||
} else {
|
||||
const coordMsg = coordinator_already_existed
|
||||
? 'coordinator already on canvas'
|
||||
: 'coordinator added to canvas'
|
||||
toast.success(
|
||||
`Imported ${pending_created} new, updated ${pending_updated} (${coordMsg})`,
|
||||
)
|
||||
}
|
||||
onPendingImported?.(coordinator)
|
||||
handleClose()
|
||||
} else {
|
||||
toast.success(`Found ${res.data.device_count} device${res.data.device_count !== 1 ? 's' : ''}`)
|
||||
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) {
|
||||
const msg = err && typeof err === 'object' && 'response' in err
|
||||
? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
: undefined
|
||||
toast.error(msg ?? 'Failed to fetch Zigbee devices')
|
||||
toast.error(extractError(err) ?? 'Failed to fetch Zigbee devices')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -169,6 +196,7 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport
|
||||
setChecked(new Set())
|
||||
setConnectionStatus('idle')
|
||||
setConnectionMsg('')
|
||||
setImportMode('pending')
|
||||
onClose()
|
||||
}
|
||||
|
||||
@@ -285,6 +313,29 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport
|
||||
</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"
|
||||
@@ -306,7 +357,7 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport
|
||||
disabled={loading || connectionStatus === 'testing'}
|
||||
>
|
||||
{loading ? <Loader2 size={13} className="animate-spin" /> : <Network size={13} />}
|
||||
Fetch Devices
|
||||
{importMode === 'pending' ? 'Import to Pending' : 'Fetch Devices'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground italic">
|
||||
|
||||
@@ -6,6 +6,7 @@ vi.mock('@/api/client', () => ({
|
||||
zigbeeApi: {
|
||||
testConnection: vi.fn(),
|
||||
importNetwork: vi.fn(),
|
||||
importToPending: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
||||
@@ -50,6 +51,7 @@ describe('ZigbeeImportModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(zigbeeApi.testConnection).mockReset()
|
||||
vi.mocked(zigbeeApi.importNetwork).mockReset()
|
||||
vi.mocked(zigbeeApi.importToPending).mockReset()
|
||||
vi.mocked(toast.success).mockReset()
|
||||
vi.mocked(toast.error).mockReset()
|
||||
vi.mocked(toast.info).mockReset()
|
||||
@@ -109,12 +111,17 @@ describe('ZigbeeImportModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const selectCanvasMode = () => {
|
||||
fireEvent.click(screen.getByRole('radio', { name: /canvas directly/i }))
|
||||
}
|
||||
|
||||
it('fetches devices and renders them grouped by type', async () => {
|
||||
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||
data: { nodes: sampleNodes, edges: [], device_count: 2 },
|
||||
} as never)
|
||||
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
selectCanvasMode()
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||
@@ -132,6 +139,7 @@ describe('ZigbeeImportModal', () => {
|
||||
} as never)
|
||||
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
selectCanvasMode()
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||
@@ -147,6 +155,7 @@ describe('ZigbeeImportModal', () => {
|
||||
} as never)
|
||||
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
selectCanvasMode()
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||
@@ -168,4 +177,45 @@ describe('ZigbeeImportModal', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(defaultProps.onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('imports to pending by default and notifies parent', async () => {
|
||||
vi.mocked(zigbeeApi.importToPending).mockResolvedValue({
|
||||
data: {
|
||||
pending_created: 2,
|
||||
pending_updated: 0,
|
||||
coordinator: { id: 'coord-uuid', label: 'Coordinator', ieee_address: '0x0000' },
|
||||
coordinator_already_existed: false,
|
||||
links_recorded: 1,
|
||||
device_count: 3,
|
||||
},
|
||||
} as never)
|
||||
const onPendingImported = vi.fn()
|
||||
|
||||
render(<ZigbeeImportModal {...defaultProps} onPendingImported={onPendingImported} />)
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /import to pending/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(zigbeeApi.importToPending).toHaveBeenCalled()
|
||||
expect(onPendingImported).toHaveBeenCalled()
|
||||
expect(defaultProps.onClose).toHaveBeenCalled()
|
||||
})
|
||||
expect(zigbeeApi.importNetwork).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('switching to canvas mode calls importNetwork and not importToPending', async () => {
|
||||
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||
data: { nodes: sampleNodes, edges: [], device_count: 2 },
|
||||
} as never)
|
||||
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByRole('radio', { name: /canvas directly/i }))
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||
|
||||
await waitFor(() => expect(zigbeeApi.importNetwork).toHaveBeenCalled())
|
||||
expect(zigbeeApi.importToPending).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user