From 43fbe7b0e10988603ced6cca9d3f46296fe092a5 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 3 May 2026 11:03:44 -0400 Subject: [PATCH] fix(admin): dedupe device-link conflict candidates by ciId The reconciler can emit the same configuration_item id multiple times when more than one match rule fires (e.g. exact_serial AND hostname_in_company). The page rendered each occurrence as a separate row keyed on ciId, producing React duplicate-key errors. Dedupe client-side keeping the strongest confidence (exact_serial > mac > hostname_in_company > other) and use the deduped count in the badge. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/admin/device-link-conflicts/page.tsx | 32 +++++++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/app/admin/device-link-conflicts/page.tsx b/app/admin/device-link-conflicts/page.tsx index e321313..72d06fb 100644 --- a/app/admin/device-link-conflicts/page.tsx +++ b/app/admin/device-link-conflicts/page.tsx @@ -55,6 +55,27 @@ function confidenceColor(c: string | null): 'default' | 'secondary' | 'outline' return 'outline'; } +const CONFIDENCE_RANK: Record = { + exact_serial: 0, + mac: 1, + hostname_in_company: 2, +}; + +function dedupeCandidates(candidates: Candidate[]): Candidate[] { + const byId = new Map(); + for (const c of candidates) { + const existing = byId.get(c.ciId); + if (!existing) { + byId.set(c.ciId, c); + continue; + } + const a = CONFIDENCE_RANK[existing.confidence ?? ''] ?? 99; + const b = CONFIDENCE_RANK[c.confidence ?? ''] ?? 99; + if (b < a) byId.set(c.ciId, c); + } + return Array.from(byId.values()); +} + export default function DeviceLinkConflictsPage() { const [items, setItems] = useState(null); const [total, setTotal] = useState(0); @@ -172,7 +193,9 @@ export default function DeviceLinkConflictsPage() { )} - {items?.map((r) => ( + {items?.map((r) => { + const candidates = dedupeCandidates(r.candidates); + return (
@@ -190,7 +213,7 @@ export default function DeviceLinkConflictsPage() {
- {r.candidates.length} candidates + {candidates.length} candidates
@@ -202,7 +225,7 @@ export default function DeviceLinkConflictsPage() {
- {r.candidates.map((c) => { + {candidates.map((c) => { const isResolving = resolving === `${r.id}:${c.ciId}`; return (
- ))} + ); + })}