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:
@@ -29,6 +29,12 @@ export interface PendingDevice {
|
||||
discovered_at: string
|
||||
// How many canvases (designs) this device already appears on. Computed server-side.
|
||||
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 {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { NodeType, ServiceInfo } from '@/types'
|
||||
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
|
||||
import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties'
|
||||
import { buildMacProperty } from '@/utils/macProperty'
|
||||
import { formatRelative, formatTimestamp } from '@/utils/timeFormat'
|
||||
|
||||
interface PendingDevicesModalProps {
|
||||
open: boolean
|
||||
@@ -663,6 +664,22 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
|
||||
const visibleServices = services.slice(0, 4)
|
||||
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
|
||||
? 'border-[#e3b341] bg-[#2d3748]'
|
||||
: selected
|
||||
@@ -759,6 +776,14 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -771,3 +796,12 @@ function InfoLine({ label, value }: { label: string; value: string }) {
|
||||
</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 { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'
|
||||
import { PendingDevicesModal } from '../PendingDevicesModal'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
|
||||
@@ -364,4 +364,37 @@ describe('PendingDevicesModal', () => {
|
||||
expect(screen.queryByTestId('pending-card-dev-b')).not.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 { splitIps } from '@/utils/maskIp'
|
||||
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||
import { formatTimestamp } from '@/utils/timeFormat'
|
||||
import type { Node } from '@xyflow/react'
|
||||
|
||||
interface DetailPanelProps {
|
||||
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 }
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
}
|
||||
Reference in New Issue
Block a user