diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py index 5377cb6..13c2b31 100644 --- a/backend/app/api/routes/scan.py +++ b/backend/app/api/routes/scan.py @@ -1,6 +1,7 @@ import ipaddress import logging import uuid +from datetime import datetime from typing import Any from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException @@ -193,51 +194,80 @@ async def stop_scan( return {"stopping": True} -async def _canvas_counts( - db: AsyncSession, devices: list[PendingDevice] -) -> dict[str, int]: - """Map each device id → number of distinct canvases (designs) it appears on. +def _agg(values: list[datetime], *, newest: bool) -> datetime | None: + """Pick the newest (max) or oldest (min) of a list of timestamps, or None.""" + present = [v for v in values if v is not None] + 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 - small for a homelab, so this avoids an N+1 per device. + +async def _canvas_correlation( + 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: return {} rows = ( await db.execute( - select(Node.ip, Node.ieee_address, Node.design_id).where( - Node.design_id.isnot(None) - ) + select( + 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() - # ip → set(design_id), ieee → set(design_id) - by_ip: dict[str, set[str]] = {} - by_ieee: dict[str, set[str]] = {} - for ip, ieee, design_id in rows: - if ip: - by_ip.setdefault(ip, set()).add(design_id) - if ieee: - by_ieee.setdefault(ieee, set()).add(design_id) + # Index matching nodes by ip and by ieee so a device can look up both. + by_ip: dict[str, list[Any]] = {} + by_ieee: dict[str, list[Any]] = {} + for row in rows: + if row.ip: + by_ip.setdefault(row.ip, []).append(row) + if row.ieee_address: + by_ieee.setdefault(row.ieee_address, []).append(row) - counts: dict[str, int] = {} + info: dict[str, dict[str, Any]] = {} for d in devices: - designs: set[str] = set() + matched = [] if d.ieee_address: - designs |= by_ieee.get(d.ieee_address, set()) + matched += by_ieee.get(d.ieee_address, []) if d.ip: - designs |= by_ip.get(d.ip, set()) - counts[d.id] = len(designs) - return counts + matched += by_ip.get(d.ip, []) + # De-duplicate nodes matched by both ip and ieee. + 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( db: AsyncSession, devices: list[PendingDevice] ) -> list[PendingDevice]: - """Attach a transient ``canvas_count`` to each device for the response schema.""" - counts = await _canvas_counts(db, devices) + """Attach transient canvas count + linked-node timestamps for the response.""" + info = await _canvas_correlation(db, 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 diff --git a/backend/app/schemas/scan.py b/backend/app/schemas/scan.py index b0f5b78..3b7cef7 100644 --- a/backend/app/schemas/scan.py +++ b/backend/app/schemas/scan.py @@ -24,6 +24,13 @@ class PendingDeviceResponse(BaseModel): # Number of distinct canvases (designs) this device already appears on, # correlated by ip / ieee_address against existing nodes. Computed per-request. 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} diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py index b9213c8..0f09fbe 100644 --- a/backend/tests/test_scan.py +++ b/backend/tests/test_scan.py @@ -1,5 +1,6 @@ """Tests for scan routes: trigger, pending devices, approve/hide/ignore, stop.""" import uuid +from datetime import datetime, timezone from unittest.mock import AsyncMock, patch 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 +# --- 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 --- @pytest.mark.asyncio diff --git a/frontend/src/components/modals/PendingDeviceModal.tsx b/frontend/src/components/modals/PendingDeviceModal.tsx index 542363b..d511dea 100644 --- a/frontend/src/components/modals/PendingDeviceModal.tsx +++ b/frontend/src/components/modals/PendingDeviceModal.tsx @@ -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 { diff --git a/frontend/src/components/modals/PendingDevicesModal.tsx b/frontend/src/components/modals/PendingDevicesModal.tsx index b458fea..e825058 100644 --- a/frontend/src/components/modals/PendingDevicesModal.tsx +++ b/frontend/src/components/modals/PendingDevicesModal.tsx @@ -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 )} )} + + {/* Timestamps — compact relative times, full date on hover. Kept tiny so + the tile footprint stays the same as before. */} +
+ {timestamps.map((t) => ( + + ))} +
) } @@ -771,3 +796,12 @@ function InfoLine({ label, value }: { label: string; value: string }) { ) } + +function TimeLine({ label, iso }: { label: string; iso: string }) { + return ( +
+ {label} + {formatRelative(iso)} +
+ ) +} diff --git a/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx b/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx index 92baef6..25b27be 100644 --- a/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx +++ b/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx @@ -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() + 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() + const card = await screen.findByTestId('pending-card-dev-a') + const inCard = within(card) + expect(inCard.getByText('Discovered')).toBeInTheDocument() + expect(inCard.queryByText('Created')).not.toBeInTheDocument() + }) + }) }) diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index 86d3d75..b75ff31 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -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: '' } diff --git a/frontend/src/utils/__tests__/timeFormat.test.ts b/frontend/src/utils/__tests__/timeFormat.test.ts new file mode 100644 index 0000000..8fd2635 --- /dev/null +++ b/frontend/src/utils/__tests__/timeFormat.test.ts @@ -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') + }) +}) diff --git a/frontend/src/utils/timeFormat.ts b/frontend/src/utils/timeFormat.ts new file mode 100644 index 0000000..7b482d1 --- /dev/null +++ b/frontend/src/utils/timeFormat.ts @@ -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` +}