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:
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Discovery-source bucketing for pending inventory devices.
|
||||
*
|
||||
* A device may be observed by more than one discovery path (e.g. an IP scan and
|
||||
* a Proxmox import); `discovery_sources` holds every one. These helpers map that
|
||||
* raw list to the UI's filter/badge buckets so a merged device shows under each
|
||||
* matching filter and renders one badge per source.
|
||||
*/
|
||||
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||
|
||||
export type SourceBucket = 'ip' | 'zigbee' | 'zwave' | 'proxmox'
|
||||
|
||||
export const SOURCE_META: Record<SourceBucket, { color: string; label: string }> = {
|
||||
zigbee: { color: '#00d4ff', label: 'ZIGBEE' },
|
||||
zwave: { color: '#ff6e00', label: 'Z-WAVE' },
|
||||
proxmox: { color: '#e57000', label: 'PROXMOX' },
|
||||
ip: { color: '#a855f7', label: 'IP' },
|
||||
}
|
||||
|
||||
// Stable badge order (IP first — it's the primary discovery path).
|
||||
const SOURCE_ORDER: SourceBucket[] = ['ip', 'proxmox', 'zigbee', 'zwave']
|
||||
|
||||
/** Every source bucket that has observed this device. A device found by both an
|
||||
* IP scan and a Proxmox import returns {ip, proxmox}. */
|
||||
export function sourceBuckets(d: PendingDevice): Set<SourceBucket> {
|
||||
const raw = d.discovery_sources && d.discovery_sources.length > 0
|
||||
? d.discovery_sources
|
||||
: d.discovery_source ? [d.discovery_source] : []
|
||||
const buckets = new Set<SourceBucket>()
|
||||
for (const s of raw) {
|
||||
if (s === 'zwave') buckets.add('zwave')
|
||||
else if (s === 'zigbee') buckets.add('zigbee')
|
||||
else if (s === 'proxmox') buckets.add('proxmox')
|
||||
else buckets.add('ip') // arp / mdns / anything else → IP scan
|
||||
}
|
||||
if (buckets.size === 0) {
|
||||
// No source recorded — legacy heuristic (mesh rows carry a non-pve ieee).
|
||||
if (d.ieee_address && !d.ieee_address.startsWith('pve-')) buckets.add('zigbee')
|
||||
else buckets.add('ip')
|
||||
}
|
||||
return buckets
|
||||
}
|
||||
|
||||
/** Ordered bucket list for badge rendering. */
|
||||
export function orderedSources(d: PendingDevice): SourceBucket[] {
|
||||
const buckets = sourceBuckets(d)
|
||||
return SOURCE_ORDER.filter((b) => buckets.has(b))
|
||||
}
|
||||
Reference in New Issue
Block a user