feat: show inventory timestamps on Device Inventory tiles

Surface the same lifecycle timestamps on the Device Inventory cards as on the
detail panel. Tiles for devices placed on a canvas show their linked node's
created / last scan / last modified / last seen (correlated by ip or
ieee_address, aggregated across matches: created = oldest, others = newest).
Devices not yet on any canvas fall back to their discovered_at.

Rendered as compact relative times ("2d ago") with the full date on hover, in
a tight two-column footer so the tile keeps its original footprint.

Backend: PendingDeviceResponse gains node_created_at / node_last_scan /
node_last_modified / node_last_seen; the canvas correlation now also pulls node
timestamps in the same single query. Frontend: shared timeFormat util
(absolute + relative), reused by the detail panel.

ha-relevant: maybe
This commit is contained in:
Pouzor
2026-06-27 13:24:29 +02:00
parent 19b7d38ec0
commit 612280e924
9 changed files with 279 additions and 35 deletions
+57 -27
View File
@@ -1,6 +1,7 @@
import ipaddress import ipaddress
import logging import logging
import uuid import uuid
from datetime import datetime
from typing import Any from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
@@ -193,51 +194,80 @@ async def stop_scan(
return {"stopping": True} return {"stopping": True}
async def _canvas_counts( def _agg(values: list[datetime], *, newest: bool) -> datetime | None:
db: AsyncSession, devices: list[PendingDevice] """Pick the newest (max) or oldest (min) of a list of timestamps, or None."""
) -> dict[str, int]: present = [v for v in values if v is not None]
"""Map each device id → number of distinct canvases (designs) it appears on. if not present:
return None
return max(present) if newest else min(present)
Correlates a scanned device to existing nodes by ``ieee_address`` (exact) or
``ip`` (exact). Runs a single node query and groups in Python — node counts are async def _canvas_correlation(
small for a homelab, so this avoids an N+1 per device. db: AsyncSession, devices: list[PendingDevice]
) -> dict[str, dict[str, Any]]:
"""Correlate each device to existing canvas nodes by ``ieee_address`` or ``ip``.
Returns, per device id: the number of distinct canvases (designs) it appears
on, plus aggregated timestamps from every matching node — created_at (oldest),
last_scan / updated_at / last_seen (newest). One node query, grouped in Python
(node counts are small for a homelab), so no N+1 per device.
""" """
if not devices: if not devices:
return {} return {}
rows = ( rows = (
await db.execute( await db.execute(
select(Node.ip, Node.ieee_address, Node.design_id).where( select(
Node.design_id.isnot(None) Node.ip,
) Node.ieee_address,
Node.design_id,
Node.created_at,
Node.last_scan,
Node.updated_at,
Node.last_seen,
).where(Node.design_id.isnot(None))
) )
).all() ).all()
# ip → set(design_id), ieee → set(design_id) # Index matching nodes by ip and by ieee so a device can look up both.
by_ip: dict[str, set[str]] = {} by_ip: dict[str, list[Any]] = {}
by_ieee: dict[str, set[str]] = {} by_ieee: dict[str, list[Any]] = {}
for ip, ieee, design_id in rows: for row in rows:
if ip: if row.ip:
by_ip.setdefault(ip, set()).add(design_id) by_ip.setdefault(row.ip, []).append(row)
if ieee: if row.ieee_address:
by_ieee.setdefault(ieee, set()).add(design_id) by_ieee.setdefault(row.ieee_address, []).append(row)
counts: dict[str, int] = {} info: dict[str, dict[str, Any]] = {}
for d in devices: for d in devices:
designs: set[str] = set() matched = []
if d.ieee_address: if d.ieee_address:
designs |= by_ieee.get(d.ieee_address, set()) matched += by_ieee.get(d.ieee_address, [])
if d.ip: if d.ip:
designs |= by_ip.get(d.ip, set()) matched += by_ip.get(d.ip, [])
counts[d.id] = len(designs) # De-duplicate nodes matched by both ip and ieee.
return counts matched = list({id(m): m for m in matched}.values())
designs = {m.design_id for m in matched}
info[d.id] = {
"canvas_count": len(designs),
"node_created_at": _agg([m.created_at for m in matched], newest=False),
"node_last_scan": _agg([m.last_scan for m in matched], newest=True),
"node_last_modified": _agg([m.updated_at for m in matched], newest=True),
"node_last_seen": _agg([m.last_seen for m in matched], newest=True),
}
return info
async def _with_canvas_counts( async def _with_canvas_counts(
db: AsyncSession, devices: list[PendingDevice] db: AsyncSession, devices: list[PendingDevice]
) -> list[PendingDevice]: ) -> list[PendingDevice]:
"""Attach a transient ``canvas_count`` to each device for the response schema.""" """Attach transient canvas count + linked-node timestamps for the response."""
counts = await _canvas_counts(db, devices) info = await _canvas_correlation(db, devices)
for d in devices: for d in devices:
d.canvas_count = counts.get(d.id, 0) meta = info.get(d.id, {})
d.canvas_count = meta.get("canvas_count", 0)
d.node_created_at = meta.get("node_created_at")
d.node_last_scan = meta.get("node_last_scan")
d.node_last_modified = meta.get("node_last_modified")
d.node_last_seen = meta.get("node_last_seen")
return devices return devices
+7
View File
@@ -24,6 +24,13 @@ class PendingDeviceResponse(BaseModel):
# Number of distinct canvases (designs) this device already appears on, # Number of distinct canvases (designs) this device already appears on,
# correlated by ip / ieee_address against existing nodes. Computed per-request. # correlated by ip / ieee_address against existing nodes. Computed per-request.
canvas_count: int = 0 canvas_count: int = 0
# Timestamps from the linked canvas node(s), correlated by ip / ieee_address.
# Null when the device is not on any canvas yet. Aggregated across matches:
# created_at = oldest; last_scan / last_modified / last_seen = newest.
node_created_at: datetime | None = None
node_last_scan: datetime | None = None
node_last_modified: datetime | None = None
node_last_seen: datetime | None = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
+51
View File
@@ -1,5 +1,6 @@
"""Tests for scan routes: trigger, pending devices, approve/hide/ignore, stop.""" """Tests for scan routes: trigger, pending devices, approve/hide/ignore, stop."""
import uuid import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
@@ -229,6 +230,56 @@ async def test_canvas_count_ignores_nodes_without_design(client, headers, db_ses
assert res.json()[0]["canvas_count"] == 0 assert res.json()[0]["canvas_count"] == 0
# --- Linked-node timestamps on the inventory response ---
@pytest.mark.asyncio
async def test_pending_device_without_node_has_null_node_timestamps(client, headers, pending_device):
# No matching canvas node → node_* timestamps are all null; the device still
# carries its own discovered_at for the "Discovered" fallback on the tile.
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
assert data["discovered_at"] is not None
assert data["node_created_at"] is None
assert data["node_last_scan"] is None
assert data["node_last_modified"] is None
assert data["node_last_seen"] is None
@pytest.mark.asyncio
async def test_pending_device_exposes_linked_node_timestamps(client, headers, db_session, pending_device):
d1 = await _add_design(db_session, "Home")
node = _node(d1, ip="192.168.1.100")
node.last_scan = datetime(2026, 6, 1, 8, 30, tzinfo=timezone.utc)
node.last_seen = datetime(2026, 6, 25, 9, 15, tzinfo=timezone.utc)
db_session.add(node)
await db_session.commit()
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
assert data["node_created_at"] is not None # defaulted on insert
assert data["node_last_modified"] is not None # updated_at defaulted on insert
assert data["node_last_scan"].startswith("2026-06-01")
assert data["node_last_seen"].startswith("2026-06-25")
@pytest.mark.asyncio
async def test_node_timestamps_aggregate_across_matches(client, headers, db_session, pending_device):
# Two canvas nodes share the device IP: created_at takes the OLDEST,
# last_scan takes the NEWEST.
d1 = await _add_design(db_session, "Home")
d2 = await _add_design(db_session, "Lab")
older = _node(d1, ip="192.168.1.100")
older.created_at = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
older.last_scan = datetime(2026, 3, 1, 0, 0, tzinfo=timezone.utc)
newer = _node(d2, ip="192.168.1.100")
newer.created_at = datetime(2026, 5, 1, 0, 0, tzinfo=timezone.utc)
newer.last_scan = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc)
db_session.add_all([older, newer])
await db_session.commit()
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
assert data["node_created_at"].startswith("2026-01-01") # oldest
assert data["node_last_scan"].startswith("2026-06-01") # newest
# --- Approve device --- # --- Approve device ---
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -29,6 +29,12 @@ export interface PendingDevice {
discovered_at: string discovered_at: string
// How many canvases (designs) this device already appears on. Computed server-side. // How many canvases (designs) this device already appears on. Computed server-side.
canvas_count?: number canvas_count?: number
// Timestamps from the linked canvas node(s), correlated by ip/ieee_address.
// Null/absent when the device is not on any canvas yet.
node_created_at?: string | null
node_last_scan?: string | null
node_last_modified?: string | null
node_last_seen?: string | null
} }
interface PendingDeviceModalProps { interface PendingDeviceModalProps {
@@ -13,6 +13,7 @@ import type { NodeType, ServiceInfo } from '@/types'
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties' import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties' import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties'
import { buildMacProperty } from '@/utils/macProperty' import { buildMacProperty } from '@/utils/macProperty'
import { formatRelative, formatTimestamp } from '@/utils/timeFormat'
interface PendingDevicesModalProps { interface PendingDevicesModalProps {
open: boolean open: boolean
@@ -663,6 +664,22 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
const visibleServices = services.slice(0, 4) const visibleServices = services.slice(0, 4)
const moreServices = services.length - visibleServices.length const moreServices = services.length - visibleServices.length
// Timestamps: a device placed on a canvas shows its linked node's lifecycle
// (created / last scan / last modified / last seen). A device that is only in
// the discovery inventory has no node yet, so it falls back to when the
// scanner first saw it.
const onCanvas = (device.canvas_count ?? 0) > 0
const timestamps: { label: string; iso: string }[] = []
if (onCanvas) {
if (device.node_created_at) timestamps.push({ label: 'Created', iso: device.node_created_at })
if (device.node_last_scan) timestamps.push({ label: 'Scan', iso: device.node_last_scan })
if (device.node_last_modified) timestamps.push({ label: 'Modified', iso: device.node_last_modified })
if (device.node_last_seen) timestamps.push({ label: 'Seen', iso: device.node_last_seen })
}
if (timestamps.length === 0) {
timestamps.push({ label: 'Discovered', iso: device.discovered_at })
}
const borderClass = highlighted const borderClass = highlighted
? 'border-[#e3b341] bg-[#2d3748]' ? 'border-[#e3b341] bg-[#2d3748]'
: selected : selected
@@ -759,6 +776,14 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
)} )}
</div> </div>
)} )}
{/* Timestamps — compact relative times, full date on hover. Kept tiny so
the tile footprint stays the same as before. */}
<div className="mt-2 pt-2 border-t border-border/50 grid grid-cols-2 gap-x-2 gap-y-0.5 text-[10px]">
{timestamps.map((t) => (
<TimeLine key={t.label} label={t.label} iso={t.iso} />
))}
</div>
</button> </button>
) )
} }
@@ -771,3 +796,12 @@ function InfoLine({ label, value }: { label: string; value: string }) {
</div> </div>
) )
} }
function TimeLine({ label, iso }: { label: string; iso: string }) {
return (
<div className="flex items-baseline gap-1 min-w-0" title={formatTimestamp(iso)}>
<span className="text-muted-foreground shrink-0">{label}</span>
<span className="font-mono text-foreground/80 truncate">{formatRelative(iso)}</span>
</div>
)
}
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'
import { PendingDevicesModal } from '../PendingDevicesModal' import { PendingDevicesModal } from '../PendingDevicesModal'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
@@ -364,4 +364,37 @@ describe('PendingDevicesModal', () => {
expect(screen.queryByTestId('pending-card-dev-b')).not.toBeInTheDocument() expect(screen.queryByTestId('pending-card-dev-b')).not.toBeInTheDocument()
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument() expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
}) })
describe('tile timestamps', () => {
it('shows the linked node lifecycle timestamps for on-canvas devices', async () => {
mockPending.mockResolvedValue({
data: [{
...DEVICE_IP,
canvas_count: 1,
node_created_at: '2026-01-02T10:00:00Z',
node_last_scan: '2026-06-01T08:30:00Z',
node_last_modified: '2026-06-20T12:00:00Z',
node_last_seen: '2026-06-25T09:15:00Z',
}],
})
render(<PendingDevicesModal {...baseProps} />)
const card = await screen.findByTestId('pending-card-dev-a')
const inCard = within(card)
expect(inCard.getByText('Created')).toBeInTheDocument()
expect(inCard.getByText('Scan')).toBeInTheDocument()
expect(inCard.getByText('Modified')).toBeInTheDocument()
expect(inCard.getByText('Seen')).toBeInTheDocument()
// Discovered fallback is not shown once node timestamps are present.
expect(inCard.queryByText('Discovered')).not.toBeInTheDocument()
})
it('falls back to Discovered for devices not on any canvas', async () => {
mockPending.mockResolvedValue({ data: [DEVICE_IP] })
render(<PendingDevicesModal {...baseProps} />)
const card = await screen.findByTestId('pending-card-dev-a')
const inCard = within(card)
expect(inCard.getByText('Discovered')).toBeInTheDocument()
expect(inCard.queryByText('Created')).not.toBeInTheDocument()
})
})
}) })
@@ -8,19 +8,13 @@ import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type ServiceStatus,
import { getServiceUrl } from '@/utils/serviceUrl' import { getServiceUrl } from '@/utils/serviceUrl'
import { splitIps } from '@/utils/maskIp' import { splitIps } from '@/utils/maskIp'
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons' import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
import { formatTimestamp } from '@/utils/timeFormat'
import type { Node } from '@xyflow/react' import type { Node } from '@xyflow/react'
interface DetailPanelProps { interface DetailPanelProps {
onEdit: (id: string) => void onEdit: (id: string) => void
} }
// Backend timestamps may arrive without a timezone suffix (naive UTC). Append
// 'Z' when absent so the browser parses them as UTC rather than local time.
function formatTimestamp(value: string): string {
const iso = /[Zz]|[+-]\d{2}:?\d{2}$/.test(value) ? value : value + 'Z'
return new Date(iso).toLocaleString()
}
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string } type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '', path: '' } const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '', path: '' }
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest'
import { formatTimestamp, formatRelative } from '../timeFormat'
describe('formatTimestamp', () => {
it('parses an ISO string with a Z suffix', () => {
expect(formatTimestamp('2026-01-02T10:00:00Z')).toContain('2026')
})
it('treats a suffix-less timestamp as UTC (appends Z)', () => {
// Same instant expressed with and without the explicit Z must match.
expect(formatTimestamp('2026-01-02 10:00:00')).toBe(formatTimestamp('2026-01-02T10:00:00Z'))
})
})
describe('formatRelative', () => {
const now = Date.parse('2026-06-27T12:00:00Z')
it('returns "just now" for sub-minute deltas', () => {
expect(formatRelative('2026-06-27T11:59:30Z', now)).toBe('just now')
})
it('formats minutes', () => {
expect(formatRelative('2026-06-27T11:45:00Z', now)).toBe('15m ago')
})
it('formats hours', () => {
expect(formatRelative('2026-06-27T09:00:00Z', now)).toBe('3h ago')
})
it('formats days', () => {
expect(formatRelative('2026-06-25T12:00:00Z', now)).toBe('2d ago')
})
it('formats weeks', () => {
expect(formatRelative('2026-06-06T12:00:00Z', now)).toBe('3w ago')
})
it('formats months', () => {
expect(formatRelative('2026-02-27T12:00:00Z', now)).toBe('4mo ago')
})
it('formats years', () => {
expect(formatRelative('2024-06-27T12:00:00Z', now)).toBe('2y ago')
})
it('clamps future timestamps to "just now"', () => {
expect(formatRelative('2026-06-27T12:05:00Z', now)).toBe('just now')
})
it('handles suffix-less (naive UTC) input', () => {
expect(formatRelative('2026-06-27 11:45:00', now)).toBe('15m ago')
})
})
+36
View File
@@ -0,0 +1,36 @@
// Shared timestamp formatting. Backend timestamps may arrive without a timezone
// suffix (naive UTC); append 'Z' when absent so the browser parses them as UTC
// rather than local time.
function toUtcIso(value: string): string {
return /[Zz]|[+-]\d{2}:?\d{2}$/.test(value) ? value : value + 'Z'
}
/** Full locale date+time, e.g. for tooltips and the detail panel. */
export function formatTimestamp(value: string): string {
return new Date(toUtcIso(value)).toLocaleString()
}
/**
* Compact relative time, e.g. "just now", "5m ago", "3h ago", "2d ago",
* "4w ago", "5mo ago", "2y ago". Future timestamps clamp to "just now".
* `now` is injectable for deterministic tests.
*/
export function formatRelative(value: string, now: number = Date.now()): string {
const then = new Date(toUtcIso(value)).getTime()
if (Number.isNaN(then)) return ''
const sec = Math.floor((now - then) / 1000)
if (sec < 60) return 'just now'
const min = Math.floor(sec / 60)
if (min < 60) return `${min}m ago`
const hr = Math.floor(min / 60)
if (hr < 24) return `${hr}h ago`
const day = Math.floor(hr / 24)
if (day < 7) return `${day}d ago`
const week = Math.floor(day / 7)
if (week < 5) return `${week}w ago`
const month = Math.floor(day / 30)
if (month < 12) return `${month}mo ago`
const year = Math.floor(day / 365)
return `${year}y ago`
}