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
@@ -21,6 +21,10 @@ export interface PendingDevice {
suggested_type: string | null
status: string
discovery_source: string | null
// All sources that have observed this device (e.g. ["arp", "proxmox"]). A
// merged device shows under every matching filter. Falls back to
// [discovery_source] when absent (older rows).
discovery_sources?: string[]
ieee_address?: string | null
friendly_name?: string | null
device_subtype?: string | null
@@ -18,6 +18,7 @@ import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties'
import { buildMacProperty } from '@/utils/macProperty'
import { formatRelative, formatTimestamp } from '@/utils/timeFormat'
import { getCenteredPosition } from '@/utils/viewportCenter'
import { sourceBuckets, orderedSources, SOURCE_META, type SourceBucket } from '@/utils/pendingSources'
interface PendingDevicesModalProps {
open: boolean
@@ -74,18 +75,9 @@ const TYPE_ICONS: Record<string, React.ElementType> = {
generic: Circle,
}
type SourceFilter = 'all' | 'ip' | 'zigbee' | 'zwave' | 'proxmox'
type SourceFilter = 'all' | SourceBucket
type StatusFilter = 'pending' | 'hidden'
function inferSource(d: PendingDevice): 'zigbee' | 'zwave' | 'proxmox' | 'ip' {
if (d.discovery_source === 'zwave') return 'zwave'
if (d.discovery_source === 'zigbee') return 'zigbee'
if (d.discovery_source === 'proxmox') return 'proxmox'
// Proxmox devices carry a synthetic 'pve-' ieee but are IP hosts, not mesh.
if (d.ieee_address && !d.ieee_address.startsWith('pve-')) return 'zigbee'
return 'ip'
}
const COMMON_PORTS = new Set([22, 80, 443])
function specialServiceName(d: PendingDevice): string | undefined {
@@ -162,7 +154,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
return devices.filter((d) => {
if (sourceFilter !== 'all' && inferSource(d) !== sourceFilter) return false
if (sourceFilter !== 'all' && !sourceBuckets(d).has(sourceFilter)) return false
if (typeFilter !== 'all' && d.suggested_type !== typeFilter) return false
// Inventory-only: optionally hide devices already placed on a canvas.
if (statusFilter === 'pending' && !showOnCanvas && (d.canvas_count ?? 0) > 0) return false
@@ -656,7 +648,7 @@ interface DeviceCardProps {
}
function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRef }: DeviceCardProps) {
const source = inferSource(device)
const sources = orderedSources(device)
const roleType = (device.suggested_type ?? 'generic') as NodeType
const Icon = TYPE_ICONS[roleType] ?? Circle
const activeTheme = useThemeStore((s) => s.activeTheme)
@@ -664,16 +656,6 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
// (from the active theme / style section), instead of a flat grey.
const roleColor = resolveNodeColors({ type: roleType, custom_colors: undefined }, activeTheme).border
const label = deviceLabel(device)
const sourceColor =
source === 'zigbee' ? '#00d4ff'
: source === 'zwave' ? '#ff6e00'
: source === 'proxmox' ? '#e57000'
: '#a855f7'
const sourceLabel =
source === 'zigbee' ? 'ZIGBEE'
: source === 'zwave' ? 'Z-WAVE'
: source === 'proxmox' ? 'PROXMOX'
: (device.discovery_source ?? 'IP').toUpperCase()
const services = device.services ?? []
const visibleServices = services.slice(0, 4)
const moreServices = services.length - visibleServices.length
@@ -737,12 +719,15 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground break-all leading-snug">{label}</div>
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
<span
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
style={{ background: `${sourceColor}22`, color: sourceColor }}
>
{sourceLabel}
</span>
{sources.map((s) => (
<span
key={s}
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
style={{ background: `${SOURCE_META[s].color}22`, color: SOURCE_META[s].color }}
>
{SOURCE_META[s].label}
</span>
))}
{device.suggested_type && (
<span
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
@@ -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'])
})
})
+47
View File
@@ -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))
}