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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user