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
@@ -4,7 +4,7 @@ import {
Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2, ServerCog,
} from 'lucide-react'
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { scanApi } from '@/api/client'
import { scanApi, type DuplicateNodeConflict } from '@/api/client'
import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore'
import { useThemeStore } from '@/stores/themeStore'
@@ -93,6 +93,18 @@ function deviceLabel(d: PendingDevice): string {
return d.friendly_name ?? d.hostname ?? specialServiceName(d) ?? d.ip ?? d.ieee_address ?? 'device'
}
// Pull the duplicate-conflict payload out of a 409 approve response, if that's
// what the error is. Anything else (network, 500, non-duplicate 409) → null.
function extractDuplicateConflict(err: unknown): DuplicateNodeConflict | null {
const detail = (err as { response?: { status?: number; data?: { detail?: unknown } } })?.response
if (detail?.status !== 409) return null
const body = detail.data?.detail
if (body && typeof body === 'object' && (body as DuplicateNodeConflict).duplicate) {
return body as DuplicateNodeConflict
}
return null
}
function injectAutoEdges(edges: AutoEdge[] | undefined) {
if (!edges || edges.length === 0) return
useCanvasStore.setState((state) => ({
@@ -116,8 +128,12 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
// Optionally restrict to devices that have at least one detected service.
const [withServicesOnly, setWithServicesOnly] = useState(false)
const { addNode, scanEventTs } = useCanvasStore()
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
const activeDesignId = useDesignStore((s) => s.activeDesignId)
const highlightRef = useRef<HTMLButtonElement>(null)
// Set when a single approve is refused because the same host is already on
// this design — the user decides: link to the existing node or duplicate.
const [dupPrompt, setDupPrompt] = useState<{ device: PendingDevice; conflict: DuplicateNodeConflict } | null>(null)
const load = useCallback(async () => {
setLoading(true)
@@ -253,30 +269,32 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
}
}
const handleApprove = async (device: PendingDevice) => {
// force=true is sent only after the user confirms they want a duplicate node
// on this design (the backend otherwise 409s to let us ask).
const approveDevice = async (device: PendingDevice, force = false) => {
const fallbackLabel = deviceLabel(device)
const type = (device.suggested_type ?? 'generic') as NodeType
const zwave = isZwaveType(type)
const wireless = isZigbeeType(type) || zwave
const properties = zwave
? buildZwaveProperties(device)
: isZigbeeType(type)
? buildZigbeeProperties(device)
: [...(device.properties ?? []), ...buildMacProperty(device.mac)]
const nodeData = {
label: fallbackLabel,
type,
ip: device.ip ?? undefined,
mac: device.mac ?? undefined,
hostname: device.hostname ?? undefined,
status: wireless ? 'online' : 'unknown',
services: (device.services ?? []) as ServiceInfo[],
properties,
// Approve onto the design the user is viewing, not the first design.
design_id: activeDesignId ?? undefined,
}
try {
const fallbackLabel = deviceLabel(device)
const type = (device.suggested_type ?? 'generic') as NodeType
const zwave = isZwaveType(type)
const wireless = isZigbeeType(type) || zwave
const properties = zwave
? buildZwaveProperties(device)
: isZigbeeType(type)
? buildZigbeeProperties(device)
: [...(device.properties ?? []), ...buildMacProperty(device.mac)]
const nodeData = {
label: fallbackLabel,
type,
ip: device.ip ?? undefined,
mac: device.mac ?? undefined,
hostname: device.hostname ?? undefined,
status: wireless ? 'online' : 'unknown',
services: (device.services ?? []) as ServiceInfo[],
properties,
// Approve onto the design the user is viewing, not the first design.
design_id: activeDesignId ?? undefined,
}
const res = await scanApi.approve(device.id, nodeData)
const res = await scanApi.approve(device.id, { ...nodeData, force })
const nodeId = res.data.node_id
addNode({
id: nodeId,
@@ -290,12 +308,32 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
// Keep the row (now on-canvas, shown with an "In N canvas" badge); reload
// for a fresh canvas_count rather than dropping it until reopen.
setSelected(null)
setDupPrompt(null)
await load()
} catch {
} catch (err) {
const conflict = extractDuplicateConflict(err)
// Only ask on the first (non-forced) attempt; a forced retry that still
// fails is a real error.
if (conflict && !force) {
// Close the device-detail modal first: two Base UI dialogs open at once
// trap focus on the underlying one and the prompt never shows.
setSelected(null)
setDupPrompt({ device, conflict })
return
}
toast.error('Failed to approve device')
}
}
const handleApprove = (device: PendingDevice) => approveDevice(device, false)
const goToExistingNode = (nodeId: string) => {
setSelectedNode(nodeId)
setDupPrompt(null)
setSelected(null)
onClose()
}
const handleHide = async (device: PendingDevice) => {
try {
await scanApi.hide(device.id)
@@ -362,6 +400,17 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
await load()
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}`)
// Bulk can't prompt per-device, so report the ones already on this canvas
// (skipped as duplicates) instead of silently dropping them.
const dupes = res.data.skipped_devices ?? []
if (dupes.length > 0) {
const names = dupes.slice(0, 3).map((d) => d.label).join(', ')
const more = dupes.length > 3 ? ` +${dupes.length - 3} more` : ''
toast.info(
`${dupes.length} already on this canvas, skipped: ${names}${more}`,
{ description: 'Matched an existing node by IP/MAC/IEEE on this design.' },
)
}
} catch {
toast.error('Failed to bulk approve devices')
}
@@ -629,6 +678,59 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
)}
</div>
)}
{/* Duplicate confirmation: the host is already on this design. Ask
before creating a second card — never silently drop or duplicate.
Rendered INSIDE the inventory DialogContent on purpose: an open
Base UI Dialog marks all outside content inert/aria-hidden, so a
sibling overlay (or nested dialog) would be unclickable. Keeping it
in the dialog's own subtree avoids that. */}
{dupPrompt && (
<div
role="dialog"
aria-modal="true"
aria-label="Device already on this canvas"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={() => setDupPrompt(null)}
>
<div
className="w-full max-w-md rounded-lg border border-border bg-[#161b22] p-5 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-base font-semibold text-foreground mb-3">Device already on this canvas</h2>
<div className="text-sm text-muted-foreground space-y-3">
<p>
<span className="text-foreground font-medium break-all">{deviceLabel(dupPrompt.device)}</span>{' '}
matches a node already on this design
{' '}(<span className="uppercase text-[11px] font-mono">{dupPrompt.conflict.match}</span>{' '}
<span className="font-mono text-foreground">{dupPrompt.conflict.value}</span>):{' '}
<span className="text-foreground font-medium break-all">{dupPrompt.conflict.existing_label}</span>.
</p>
<p>Add it again anyway, or jump to the existing node?</p>
<div className="flex flex-wrap justify-end gap-2 pt-1">
<button
onClick={() => setDupPrompt(null)}
className="text-xs px-3 py-1.5 rounded border border-border text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
<button
onClick={() => goToExistingNode(dupPrompt.conflict.existing_node_id)}
className="text-xs px-3 py-1.5 rounded bg-[#00d4ff]/20 text-[#00d4ff] hover:bg-[#00d4ff]/30 font-medium transition-colors"
>
Go to existing node
</button>
<button
onClick={() => approveDevice(dupPrompt.device, true)}
className="text-xs px-3 py-1.5 rounded bg-[#e3b341]/20 text-[#e3b341] hover:bg-[#e3b341]/30 font-medium transition-colors"
>
Add duplicate anyway
</button>
</div>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>