feat: prompt on duplicate device instead of silently blocking/merging approve

Single-device approve now guards duplicates per-design the same way bulk
approve does, and asks the user instead of failing or silently merging:

- create_node and approve_device reject a same-design duplicate (ieee, ip
  or mac) with 409 + the existing node; a force flag creates it anyway.
- Frontend shows a confirm dialog: go to existing node, add duplicate
  anyway, or cancel.
- approve_device no longer rejects a device already on another canvas
  (status is global, canvas membership is per-design) — it can be placed
  on a new design, matching bulk approve.
- IEEE (Zigbee/Z-Wave) devices now use the same prompt as ip/mac instead
  of auto-merging into the existing node.
- bulk approve reports which devices it skipped as duplicates.

Closes #260

ha-relevant: maybe
This commit is contained in:
Pouzor
2026-07-09 13:05:19 +02:00
parent 8ebc716de8
commit 0792105b96
9 changed files with 629 additions and 85 deletions
+21
View File
@@ -77,6 +77,26 @@ export interface DeepScanConfig {
export type ScanConfigData = { ranges: string[] } & DeepScanConfig
// A device the backend refused to place because an equivalent node already
// exists on the target design (same ip/mac/ieee). `existing_node_id` points at
// the node already there so the UI can link to it.
export interface SkippedDevice {
device_id: string
label: string
match: 'ip' | 'mac' | 'ieee'
value: string
existing_node_id: string | null
}
// 409 body from single approve / create when a same-design duplicate is found.
export interface DuplicateNodeConflict {
duplicate: true
existing_node_id: string
existing_label: string
match: 'ip' | 'mac' | 'ieee'
value: string
}
export const scanApi = {
trigger: (deepScan?: Partial<DeepScanConfig>) => api.post('/scan/trigger', deepScan ?? {}),
pending: () => api.get('/scan/pending'),
@@ -100,6 +120,7 @@ export const scanApi = {
edges_created: number
edges: { id: string; source: string; target: string; type?: string; source_handle?: string | null; target_handle?: string | null }[]
skipped: number
skipped_devices: SkippedDevice[]
}>('/scan/pending/bulk-approve', { device_ids: ids, design_id: designId ?? undefined }),
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`),