feat: merge IP-scanned and Proxmox-imported devices by MAC

Reconcile the same physical device discovered by both the nmap IP scan and
the Proxmox importer into a single inventory row, keyed on MAC. Previously
each path only deduped by IP, and the importer captured no MAC (and no IP for
stopped guests), so most guests double-listed.

Backend:
- mac_utils.normalize_mac: canonical MAC (lowercase, ':'-separated), the
  cross-source join key. Normalized on write and on compare.
- proxmox_service: capture the guest NIC MAC agent-free from the net0 config
  (qemu virtio=<MAC>, lxc hwaddr=<MAC>); works for stopped guests. Resolver
  now returns (ip, mac).
- proxmox persist: match existing Node/PendingDevice by ieee OR ip OR MAC;
  fill mac, keep the vm/lxc type, union sources.
- scanner persist: match PendingDevice by ip OR MAC; fill the IP a Proxmox
  import lacked, keep a pve row's type, union the scan source. Stamp query
  matches raw + normalized MAC (legacy-safe).
- Multi-source tags: new PendingDevice.discovery_sources JSON column so a
  merged device shows under both the IP and Proxmox filters. Idempotent
  migration backfills from discovery_source (legacy NULL-scalar rows with an
  IP become ["arp"]). _sources_after_merge preserves a scanned row's IP origin
  through the merge without tagging a pure Proxmox guest.
- Import now broadcasts a scan update on completion so an open inventory
  reloads without a manual refresh.

Frontend:
- pendingSources: sourceBuckets/orderedSources map discovery_sources to filter
  buckets; a device with ["arp","proxmox"] matches both filters and renders
  both badges. PendingDevicesModal filter + badges use them.

Tests: MAC normalization, config MAC capture, cross-source merge both
directions, legacy-row IP-tag preservation, no-false-IP-tag guard, refresh
broadcast, and the frontend bucket mapping.

ha-relevant: maybe
This commit is contained in:
Pouzor
2026-07-06 20:11:03 +02:00
parent 05ef746f22
commit adf82f8f01
16 changed files with 572 additions and 76 deletions
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest'
import { sourceBuckets, orderedSources } from '../pendingSources'
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
function device(overrides: Partial<PendingDevice> = {}): PendingDevice {
return {
id: 'd1',
ip: null,
mac: null,
hostname: null,
os: null,
services: [],
suggested_type: null,
status: 'pending',
discovery_source: null,
discovered_at: '2026-07-05T00:00:00Z',
...overrides,
}
}
describe('sourceBuckets', () => {
it('returns both IP and Proxmox for a merged device', () => {
const buckets = sourceBuckets(device({ discovery_sources: ['arp', 'proxmox'] }))
expect([...buckets].sort()).toEqual(['ip', 'proxmox'])
})
it('maps arp and mdns to the single ip bucket', () => {
expect([...sourceBuckets(device({ discovery_sources: ['arp'] }))]).toEqual(['ip'])
expect([...sourceBuckets(device({ discovery_sources: ['mdns'] }))]).toEqual(['ip'])
})
it('falls back to legacy discovery_source when discovery_sources is empty', () => {
expect([...sourceBuckets(device({ discovery_source: 'zigbee' }))]).toEqual(['zigbee'])
expect([...sourceBuckets(device({ discovery_source: 'proxmox' }))]).toEqual(['proxmox'])
})
it('uses the ieee heuristic when no source is recorded', () => {
// Mesh device (non-pve ieee) with no discovery_source → zigbee.
expect([...sourceBuckets(device({ ieee_address: '0x00124b00' }))]).toEqual(['zigbee'])
// Nothing at all → ip.
expect([...sourceBuckets(device())]).toEqual(['ip'])
})
})
describe('orderedSources', () => {
it('renders IP before Proxmox regardless of input order', () => {
expect(orderedSources(device({ discovery_sources: ['proxmox', 'arp'] }))).toEqual(['ip', 'proxmox'])
})
})