feat: Device Inventory — show all scanned devices with canvas-presence

Reworks the "Pending Devices" panel into a "Device Inventory": scanned
devices already placed on a canvas are no longer suppressed — they stay
listed and badged with how many canvases they appear on.

- scanner: stop deleting/skipping on-canvas IPs (hidden still suppressed)
- scan API: /pending returns all non-hidden devices; compute canvas_count
  by correlating ip/ieee_address against nodes grouped by design
- frontend: rename to "Device Inventory", top-right canvas-count corner,
  toggle to show/hide on-canvas devices (default show)

ha-relevant: maybe
This commit is contained in:
Pouzor
2026-06-25 18:05:13 +02:00
parent 96107cc657
commit d7ab4ba49a
8 changed files with 236 additions and 42 deletions
@@ -27,6 +27,8 @@ export interface PendingDevice {
vendor?: string | null
lqi?: number | null
discovered_at: string
// How many canvases (designs) this device already appears on. Computed server-side.
canvas_count?: number
}
interface PendingDeviceModalProps {
@@ -119,6 +119,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
const [typeFilter, setTypeFilter] = useState<string>('all')
const [statusFilter, setStatusFilter] = useState<StatusFilter>(initialStatus)
// Inventory shows on-canvas devices by default; toggle off to hide them.
const [showOnCanvas, setShowOnCanvas] = useState(true)
const { addNode, scanEventTs } = useCanvasStore()
const highlightRef = useRef<HTMLButtonElement>(null)
@@ -159,6 +161,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
return devices.filter((d) => {
if (sourceFilter !== 'all' && inferSource(d) !== 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
if (q) {
const hay = [
d.friendly_name, d.hostname, d.ip, d.mac, d.ieee_address, d.vendor, d.model,
@@ -168,7 +172,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
}
return true
})
}, [devices, search, sourceFilter, typeFilter])
}, [devices, search, sourceFilter, typeFilter, statusFilter, showOnCanvas])
useEffect(() => {
if (!highlightId || loading || !open) return
@@ -241,9 +245,11 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
if (failed > 0) toast.error(`Removed ${removedIds.size}, ${failed} failed`)
else toast.success(`Removed ${removedIds.size} device${removedIds.size !== 1 ? 's' : ''}`)
} else {
// Clears only pending rows server-side; approved/on-canvas devices stay,
// so reload rather than blanking the whole inventory.
await scanApi.clearPending()
setDevices([])
setSelectedIds(new Set())
await load()
toast.success('Pending devices cleared')
}
} catch {
@@ -398,7 +404,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
<DialogHeader className="px-4 py-3 border-b border-border shrink-0">
<div className="flex items-center justify-between gap-3">
<DialogTitle className="text-base font-semibold flex items-center gap-2">
{statusFilter === 'pending' ? 'Pending Devices' : 'Hidden Devices'}
{statusFilter === 'pending' ? 'Device Inventory' : 'Hidden Devices'}
<span className="text-muted-foreground font-normal text-xs">
({filtered.length}{filtered.length !== devices.length && ` of ${devices.length}`})
</span>
@@ -479,7 +485,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
onClick={() => setStatusFilter('pending')}
className={`px-2.5 py-1.5 transition-colors ${statusFilter === 'pending' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
>
Pending
Inventory
</button>
<button
onClick={() => setStatusFilter('hidden')}
@@ -488,6 +494,17 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
Hidden
</button>
</div>
{statusFilter === 'pending' && (
<button
onClick={() => setShowOnCanvas((v) => !v)}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded border transition-colors ${showOnCanvas ? 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground' : 'bg-[#00d4ff]/20 text-[#00d4ff] border-[#00d4ff]/50'}`}
title="Show or hide devices already on a canvas"
aria-pressed={!showOnCanvas}
>
<Layers size={12} />
{showOnCanvas ? 'Hide on-canvas' : 'Show on-canvas'}
</button>
)}
<button
onClick={() => selectMode ? exitSelectMode() : enterSelectMode()}
className={`text-xs px-2.5 py-1.5 rounded border transition-colors ${selectMode ? 'bg-[#00d4ff]/20 text-[#00d4ff] border-[#00d4ff]/50' : 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground'}`}
@@ -624,12 +641,24 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
{selectMode && selected && (
<CheckCircle2
size={18}
className="absolute top-2 right-2 text-[#00d4ff] fill-[#0d1117]"
className="absolute top-2 right-2 text-[#00d4ff] fill-[#0d1117] z-10"
/>
)}
{!selectMode && device.status === 'hidden' && (
<EyeOff size={14} className="absolute top-2 right-2 text-muted-foreground" />
)}
{/* Canvas-presence corner: how many canvases this device already sits on.
Hidden while a select-mode checkmark occupies the same corner. */}
{device.status !== 'hidden' && (device.canvas_count ?? 0) > 0 && !(selectMode && selected) && (
<div
className="absolute top-0 right-0 flex items-center gap-1 rounded-bl-lg rounded-tr-lg bg-[#00d4ff] text-[#0d1117] text-xs font-bold px-2 py-1 shadow-md"
title={`On ${device.canvas_count} canvas${device.canvas_count !== 1 ? 'es' : ''}`}
aria-label={`On ${device.canvas_count} canvas${device.canvas_count !== 1 ? 'es' : ''}`}
>
<Layers size={12} strokeWidth={2.5} />
{device.canvas_count}
</div>
)}
{/* Header */}
<div className="flex items-start gap-2 mb-2">
@@ -271,4 +271,55 @@ describe('PendingDevicesModal', () => {
await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a']))
expect(mockBulkApprove).not.toHaveBeenCalled()
})
// --- Device Inventory: rename, canvas badge, on-canvas filter ---
it('titles the pending view "Device Inventory"', async () => {
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.getByText('Device Inventory')).toBeInTheDocument()
})
it('shows "Hidden Devices" title in hidden mode', async () => {
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.getByText('Hidden Devices')).toBeInTheDocument()
})
it('renders a corner canvas-count when canvas_count > 0', async () => {
mockPending.mockResolvedValue({ data: [{ ...DEVICE_IP, canvas_count: 2 }] })
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
const corner = screen.getByLabelText('On 2 canvases')
expect(corner).toHaveTextContent('2')
})
it('uses singular "canvas" for a single canvas', async () => {
mockPending.mockResolvedValue({ data: [{ ...DEVICE_IP, canvas_count: 1 }] })
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.getByLabelText('On 1 canvas')).toHaveTextContent('1')
})
it('does not render the canvas-count corner when canvas_count is 0', async () => {
mockPending.mockResolvedValue({ data: [{ ...DEVICE_IP, canvas_count: 0 }] })
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.queryByLabelText(/On \d+ canvas/)).not.toBeInTheDocument()
})
it('shows on-canvas devices by default and hides them when toggled off', async () => {
mockPending.mockResolvedValue({
data: [DEVICE_IP, { ...DEVICE_ZIGBEE, canvas_count: 1 }],
})
render(<PendingDevicesModal {...baseProps} />)
// Default: both visible (on-canvas shown).
await waitFor(() => expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument())
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
// Toggle off → the on-canvas device (dev-b) disappears.
fireEvent.click(screen.getByRole('button', { name: /Hide on-canvas/ }))
expect(screen.queryByTestId('pending-card-dev-b')).not.toBeInTheDocument()
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
})
})