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`),
@@ -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>
@@ -14,6 +14,7 @@ const mockHide = vi.fn()
const mockPending = vi.fn()
const mockHidden = vi.fn()
const mockAddNode = vi.fn()
const mockSetSelectedNode = vi.fn()
vi.mock('@/api/client', () => ({
scanApi: {
@@ -30,11 +31,15 @@ vi.mock('@/api/client', () => ({
},
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
vi.mock('@/components/modals/PendingDeviceModal', () => ({
PendingDeviceModal: ({ device }: { device: unknown }) =>
device ? <div data-testid="approval-modal" /> : null,
PendingDeviceModal: ({ device, onApprove }: { device: unknown; onApprove: (d: unknown) => void }) =>
device ? (
<div data-testid="approval-modal">
<button data-testid="do-approve" onClick={() => onApprove(device)}>approve</button>
</div>
) : null,
}))
const DEVICE_IP = {
@@ -104,10 +109,12 @@ const DEVICE_PROXMOX = {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useCanvasStore).mockReturnValue({
addNode: mockAddNode,
scanEventTs: 0,
} as unknown as ReturnType<typeof useCanvasStore>)
// Apply the selector when one is passed (setSelectedNode is read via a
// selector), else return the whole store (destructured in the component).
vi.mocked(useCanvasStore).mockImplementation(((sel?: (s: unknown) => unknown) => {
const store = { addNode: mockAddNode, scanEventTs: 0, setSelectedNode: mockSetSelectedNode }
return sel ? sel(store) : store
}) as unknown as typeof useCanvasStore)
// setState is used by injectAutoEdges
;(useCanvasStore as unknown as { setState: (fn: unknown) => void }).setState = vi.fn()
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZIGBEE] })
@@ -115,7 +122,7 @@ beforeEach(() => {
mockApprove.mockResolvedValue({ data: { node_id: 'n1', edges: [], edges_created: 0 } })
mockHide.mockResolvedValue({ data: {} })
mockBulkApprove.mockResolvedValue({
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], edges: [], edges_created: 0 },
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], edges: [], edges_created: 0, skipped_devices: [] },
})
mockBulkHide.mockResolvedValue({ data: { hidden: 2, skipped: 0 } })
mockRestore.mockResolvedValue({ data: { restored: true, device_id: 'dev-a' } })
@@ -235,6 +242,60 @@ describe('PendingDevicesModal', () => {
expect(screen.getByTestId('approval-modal')).toBeInTheDocument()
})
const DUP_409 = {
response: {
status: 409,
data: {
detail: {
duplicate: true,
existing_node_id: 'n-existing',
existing_label: 'Existing Srv',
match: 'ip',
value: '192.168.1.10',
},
},
},
}
it('single approve prompts instead of failing when the host is already on the design', async () => {
mockApprove.mockRejectedValueOnce(DUP_409)
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
fireEvent.click(screen.getByTestId('do-approve'))
// The duplicate dialog appears (not a silent failure).
await waitFor(() => expect(screen.getByText('Device already on this canvas')).toBeInTheDocument())
expect(screen.getByText('Existing Srv')).toBeInTheDocument()
// Regression: the device-detail modal must close so it doesn't trap focus
// and hide the prompt (two stacked Base UI dialogs).
expect(screen.queryByTestId('approval-modal')).not.toBeInTheDocument()
})
it('"Add duplicate anyway" retries the approve with force=true', async () => {
mockApprove.mockRejectedValueOnce(DUP_409)
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
fireEvent.click(screen.getByTestId('do-approve'))
await waitFor(() => expect(screen.getByText('Device already on this canvas')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /Add duplicate anyway/ }))
await waitFor(() => expect(mockApprove).toHaveBeenCalledTimes(2))
expect(mockApprove).toHaveBeenLastCalledWith('dev-a', expect.objectContaining({ force: true }))
})
it('"Go to existing node" selects the existing node and closes the modal', async () => {
mockApprove.mockRejectedValueOnce(DUP_409)
const onClose = vi.fn()
render(<PendingDevicesModal {...baseProps} onClose={onClose} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
fireEvent.click(screen.getByTestId('do-approve'))
await waitFor(() => expect(screen.getByText('Device already on this canvas')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /Go to existing node/ }))
expect(mockSetSelectedNode).toHaveBeenCalledWith('n-existing')
expect(onClose).toHaveBeenCalled()
})
it('toggles selection in select mode instead of opening approval', async () => {
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
@@ -263,6 +324,29 @@ describe('PendingDevicesModal', () => {
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b'], null))
})
it('bulk approve reports devices skipped as duplicates', async () => {
const { toast } = await import('sonner')
mockBulkApprove.mockResolvedValue({
data: {
approved: 1, node_ids: ['n1'], device_ids: ['dev-b'], edges: [], edges_created: 0,
skipped: 1,
skipped_devices: [{ device_id: 'dev-a', label: 'host-a', match: 'ip', value: '192.168.1.10', existing_node_id: 'n-existing' }],
},
})
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
fireEvent.click(screen.getByTestId('pending-card-dev-b'))
fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
await waitFor(() =>
expect(toast.info).toHaveBeenCalledWith(
expect.stringContaining('1 already on this canvas'),
expect.anything(),
),
)
})
it('keeps approved devices listed after bulk approve (reloads, not strips)', async () => {
// After approve, pending() still returns the rows (now on-canvas w/ badge).
mockPending