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) <noreply@anthropic.com>
This commit is contained in:
lorentz 2026-05-03 11:03:44 -04:00
parent c97e5fc45c
commit 43fbe7b0e1

View file

@ -55,6 +55,27 @@ function confidenceColor(c: string | null): 'default' | 'secondary' | 'outline'
return 'outline';
}
const CONFIDENCE_RANK: Record<string, number> = {
exact_serial: 0,
mac: 1,
hostname_in_company: 2,
};
function dedupeCandidates(candidates: Candidate[]): Candidate[] {
const byId = new Map<string, Candidate>();
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<Review[] | null>(null);
const [total, setTotal] = useState(0);
@ -172,7 +193,9 @@ export default function DeviceLinkConflictsPage() {
</Alert>
)}
{items?.map((r) => (
{items?.map((r) => {
const candidates = dedupeCandidates(r.candidates);
return (
<Card key={r.id} className="border-amber-200">
<CardHeader className="pb-3">
<div className="flex items-baseline justify-between gap-3">
@ -190,7 +213,7 @@ export default function DeviceLinkConflictsPage() {
</div>
</div>
<Badge variant="outline" className="text-xs">
{r.candidates.length} candidates
{candidates.length} candidates
</Badge>
</div>
<div className="text-xs text-muted-foreground space-x-3">
@ -202,7 +225,7 @@ export default function DeviceLinkConflictsPage() {
</div>
</CardHeader>
<CardContent className="space-y-2">
{r.candidates.map((c) => {
{candidates.map((c) => {
const isResolving = resolving === `${r.id}:${c.ciId}`;
return (
<div
@ -253,7 +276,8 @@ export default function DeviceLinkConflictsPage() {
})}
</CardContent>
</Card>
))}
);
})}
</CardContent>
</Card>
</div>