Merge pull request #233 from Pouzor/feat/inventory-timestamps

feat: surface inventory timestamps on nodes
This commit is contained in:
Rémy
2026-06-27 23:39:32 +02:00
committed by GitHub
18 changed files with 564 additions and 36 deletions
+91 -30
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
@@ -278,15 +308,38 @@ async def bulk_approve_devices(
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar() first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
default_design_id = first_design.id if first_design else None default_design_id = first_design.id if first_design else None
# Accept every selected device that isn't user-hidden. We intentionally do NOT
# filter on status == "pending": a device's status is global, but canvas
# membership is per-design. A device approved onto another canvas (or whose
# node was later deleted) must still be placeable on THIS design. Duplicates
# are guarded per-design below, not by the global status flag.
result = await db.execute( result = await db.execute(
select(PendingDevice).where( select(PendingDevice).where(
PendingDevice.id.in_(payload.device_ids), PendingDevice.id.in_(payload.device_ids),
PendingDevice.status == "pending", PendingDevice.status != "hidden",
) )
) )
devices = result.scalars().all() devices = result.scalars().all()
# What already sits on the target canvas, so we skip devices already placed
# here (by ip or ieee_address) instead of creating duplicate nodes.
existing = (
await db.execute(
select(Node.ip, Node.ieee_address).where(Node.design_id == default_design_id)
)
).all()
placed_ips = {ip for ip, _ in existing if ip}
placed_ieee = {ieee for _, ieee in existing if ieee}
created_nodes: list[Node] = [] created_nodes: list[Node] = []
approved_devices: list[PendingDevice] = []
for device in devices: for device in devices:
already_here = (
(device.ip is not None and device.ip in placed_ips)
or (device.ieee_address is not None and device.ieee_address in placed_ieee)
)
if already_here:
continue
device.status = "approved" device.status = "approved"
node_type = device.suggested_type or "generic" node_type = device.suggested_type or "generic"
is_wireless = _is_wireless(node_type) is_wireless = _is_wireless(node_type)
@@ -309,12 +362,20 @@ async def bulk_approve_devices(
) )
db.add(node) db.add(node)
created_nodes.append(node) created_nodes.append(node)
approved_devices.append(device)
# Track within this batch so a duplicate selection (same ip/ieee) is not
# placed twice on the same canvas.
if device.ip:
placed_ips.add(device.ip)
if device.ieee_address:
placed_ieee.add(device.ieee_address)
await db.flush() # populates node.id from Python-side default before reading await db.flush() # populates node.id from Python-side default before reading
# node_ids and approved_device_ids stay index-aligned for the client's mapping.
node_ids = [n.id for n in created_nodes] node_ids = [n.id for n in created_nodes]
approved_device_ids = [d.id for d in devices] approved_device_ids = [d.id for d in approved_devices]
all_edges: list[dict[str, str]] = [] all_edges: list[dict[str, str]] = []
for device in devices: for device in approved_devices:
all_edges.extend(await _resolve_pending_links_for_ieee(db, device.ieee_address)) all_edges.extend(await _resolve_pending_links_for_ieee(db, device.ieee_address))
await db.commit() await db.commit()
+3
View File
@@ -293,6 +293,9 @@ async def init_db() -> None:
"UPDATE nodes SET properties = ? WHERE id = ?", "UPDATE nodes SET properties = ? WHERE id = ?",
(_json.dumps(props), node_id), (_json.dumps(props), node_id),
) )
# Inventory timestamp: last time a scan observed this node (idempotent)
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN last_scan DATETIME")
# Migrate animated column from boolean (0/1) to string ('none'/'snake') # Migrate animated column from boolean (0/1) to string ('none'/'snake')
with suppress(OperationalError): with suppress(OperationalError):
await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1") await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1")
+10
View File
@@ -61,6 +61,7 @@ class Node(Base):
bottom_handles: Mapped[int] = mapped_column(Integer, default=1) bottom_handles: Mapped[int] = mapped_column(Integer, default=1)
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True) ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True)
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_scan: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
response_time_ms: Mapped[int | None] = mapped_column(Integer) response_time_ms: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
@@ -99,6 +100,9 @@ class CanvasState(Base):
class PendingDevice(Base): class PendingDevice(Base):
__tablename__ = "pending_devices" __tablename__ = "pending_devices"
# Permit the plain (non-Mapped[]) annotations on the transient request-only
# attributes below; without this SQLAlchemy 2.0 tries to map them as columns.
__allow_unmapped__ = True
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid) id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
ip: Mapped[str | None] = mapped_column(String, nullable=True) ip: Mapped[str | None] = mapped_column(String, nullable=True)
@@ -120,6 +124,12 @@ class PendingDevice(Base):
# Transient (not persisted): populated per-request by the scan routes to report # Transient (not persisted): populated per-request by the scan routes to report
# how many canvases this device already appears on. Not a mapped column. # how many canvases this device already appears on. Not a mapped column.
canvas_count: int = 0 canvas_count: int = 0
# Transient (not persisted): timestamps from the linked canvas node(s),
# correlated by ip / ieee_address. None when the device is not on any canvas.
node_created_at: datetime | None = None
node_last_scan: datetime | None = None
node_last_modified: datetime | None = None
node_last_seen: datetime | None = None
class PendingDeviceLink(Base): class PendingDeviceLink(Base):
+1
View File
@@ -73,6 +73,7 @@ class NodeResponse(NodeBase):
design_id: str | None = None design_id: str | None = None
ieee_address: str | None = None ieee_address: str | None = None
last_seen: datetime | None = None last_seen: datetime | None = None
last_scan: datetime | None = None
response_time_ms: int | None = None response_time_ms: int | None = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
+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}
+16 -2
View File
@@ -11,10 +11,10 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from sqlalchemy import select from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import PendingDevice, ScanRun from app.db.models import Node, PendingDevice, ScanRun
from app.services.fingerprint import fingerprint_ports, suggest_node_type from app.services.fingerprint import fingerprint_ports, suggest_node_type
from app.services.http_probe import probe_open_ports from app.services.http_probe import probe_open_ports
@@ -542,6 +542,20 @@ async def run_scan(
)) ))
devices_found += 1 devices_found += 1
# Stamp last_scan on any canvas node that matches this device by IP
# (or MAC, when known) so the inventory shows when the scanner last
# observed it. Matches across designs.
host_mac = host.get("mac")
node_match = [Node.ip == ip]
if host_mac:
node_match.append(Node.mac == host_mac)
matching_nodes = (await db.execute(
select(Node).where(or_(*node_match))
)).scalars().all()
scanned_at = datetime.now(timezone.utc)
for node in matching_nodes:
node.last_scan = scanned_at
await db.commit() await db.commit()
await broadcast_scan_update(run_id=run_id, devices_found=devices_found) await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
+12
View File
@@ -51,6 +51,18 @@ async def test_save_canvas_creates_nodes_and_edges(client: AsyncClient, headers:
assert canvas["viewport"] == {"x": 1, "y": 2, "zoom": 1.5} assert canvas["viewport"] == {"x": 1, "y": 2, "zoom": 1.5}
async def test_load_canvas_exposes_inventory_timestamps(client: AsyncClient, headers: dict):
n1 = node_payload(label="Router", type="router")
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
node = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0]
# created_at / updated_at always set; last_seen / last_scan null until observed.
assert node["created_at"] is not None
assert node["updated_at"] is not None
assert "last_seen" in node
assert node["last_scan"] is None
async def test_save_canvas_updates_existing_node(client: AsyncClient, headers: dict): async def test_save_canvas_updates_existing_node(client: AsyncClient, headers: dict):
n1 = node_payload(label="Old Label") n1 = node_payload(label="Old Label")
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
+22
View File
@@ -105,6 +105,28 @@ async def test_legacy_canvas_migrates_into_default_design(legacy_engine):
await engine.dispose() await engine.dispose()
async def test_legacy_nodes_gain_last_scan_column(legacy_engine):
"""A legacy nodes table (no last_scan) gains the column after init_db."""
db_path, engine = legacy_engine
await _build_legacy_schema(engine)
await database.init_db()
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
try:
async with check.begin() as conn:
cols = (await conn.exec_driver_sql("PRAGMA table_info(nodes)")).fetchall()
assert "last_scan" in {c[1] for c in cols}
# Existing rows backfill to NULL (never scanned yet).
last_scan = (await conn.exec_driver_sql(
"SELECT last_scan FROM nodes WHERE id='n1'"
)).fetchone()
assert last_scan[0] is None
finally:
await check.dispose()
await engine.dispose()
async def test_migration_is_idempotent(legacy_engine): async def test_migration_is_idempotent(legacy_engine):
"""Running init_db twice must not duplicate the design or drop any data.""" """Running init_db twice must not duplicate the design or drop any data."""
db_path, engine = legacy_engine db_path, engine = legacy_engine
+120
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
@@ -741,6 +792,75 @@ async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_p
assert all(d["status"] == "approved" for d in inventory) assert all(d["status"] == "approved" for d in inventory)
@pytest.mark.asyncio
async def test_bulk_approve_places_already_approved_device_on_another_design(
client: AsyncClient, headers, db_session, two_pending_devices
):
"""Regression: a device already approved (status='approved', e.g. placed on
another canvas) must still get a node on the design being approved onto.
Previously bulk-approve filtered status=='pending', so selecting an
already-approved device created no node — the user saw fewer nodes than
they selected."""
ids = [d.id for d in two_pending_devices]
design_a = await _add_design(db_session, "Canvas A")
design_b = await _add_design(db_session, "Canvas B")
# Approve both onto design A.
res_a = await client.post(
"/api/v1/scan/pending/bulk-approve",
json={"device_ids": ids, "design_id": design_a},
headers=headers,
)
assert res_a.json()["approved"] == 2
# Re-approve the same (now status='approved') devices onto design B.
res_b = await client.post(
"/api/v1/scan/pending/bulk-approve",
json={"device_ids": ids, "design_id": design_b},
headers=headers,
)
data_b = res_b.json()
assert data_b["approved"] == 2, "already-approved devices must place onto the new canvas"
assert data_b["skipped"] == 0
# Two nodes now exist on each design.
from app.db.models import Node as NodeModel
nodes_b = (
await db_session.execute(select(NodeModel).where(NodeModel.design_id == design_b))
).scalars().all()
assert len(nodes_b) == 2
@pytest.mark.asyncio
async def test_bulk_approve_skips_device_already_on_target_design(
client: AsyncClient, headers, db_session, two_pending_devices
):
"""A device already on the target canvas (same ip) is not placed twice."""
ids = [d.id for d in two_pending_devices]
design = await _add_design(db_session, "Canvas")
# First device already sits on the canvas (matched by ip).
db_session.add(_node(design, ip="192.168.1.10"))
await db_session.commit()
res = await client.post(
"/api/v1/scan/pending/bulk-approve",
json={"device_ids": ids, "design_id": design},
headers=headers,
)
data = res.json()
assert data["approved"] == 1 # only the second device (192.168.1.11)
assert data["skipped"] == 1
from app.db.models import Node as NodeModel
nodes = (
await db_session.execute(select(NodeModel).where(NodeModel.design_id == design))
).scalars().all()
# The pre-existing node plus the one newly approved — no duplicate for .10.
assert len(nodes) == 2
assert sorted(n.ip for n in nodes) == ["192.168.1.10", "192.168.1.11"]
@pytest.fixture @pytest.fixture
async def zigbee_pending_device(db_session): async def zigbee_pending_device(db_session):
device = PendingDevice( device = PendingDevice(
+78
View File
@@ -404,6 +404,84 @@ async def test_run_scan_adds_nmap_devices_as_pending(mem_db):
assert any(d.ip == "192.168.1.5" for d in devices) assert any(d.ip == "192.168.1.5" for d in devices)
@pytest.mark.asyncio
async def test_run_scan_stamps_last_scan_on_matching_node_by_ip(mem_db):
"""A scan that sees a device matching a canvas node (by IP) stamps last_scan."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
session.add(Node(id="n1", type="server", label="NAS", ip="192.168.1.5"))
await session.commit()
nmap_hosts = [{"ip": "192.168.1.5", "hostname": "nas.lan", "mac": None, "os": None, "open_ports": []}]
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
node = await session.get(Node, "n1")
assert node is not None
assert node.last_scan is not None
@pytest.mark.asyncio
async def test_run_scan_stamps_last_scan_on_matching_node_by_mac(mem_db):
"""A node with no IP but a matching MAC still gets last_scan stamped."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
session.add(Node(id="n2", type="iot", label="Sensor", mac="AA:BB:CC:DD:EE:FF"))
await session.commit()
nmap_hosts = [{"ip": "192.168.1.9", "hostname": None, "mac": "AA:BB:CC:DD:EE:FF", "os": None, "open_ports": []}]
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
node = await session.get(Node, "n2")
assert node is not None
assert node.last_scan is not None
@pytest.mark.asyncio
async def test_run_scan_leaves_last_scan_untouched_on_unmatched_node(mem_db):
"""A node whose IP/MAC is not seen by the scan keeps last_scan = None."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
session.add(Node(id="n3", type="server", label="Other", ip="10.0.0.99"))
await session.commit()
nmap_hosts = [{"ip": "192.168.1.5", "hostname": None, "mac": None, "os": None, "open_ports": []}]
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
node = await session.get(Node, "n3")
assert node is not None
assert node.last_scan is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_scan_mdns_only_device_added(mem_db): async def test_run_scan_mdns_only_device_added(mem_db):
"""Devices found only by mDNS (not nmap) should appear in pending_devices.""" """Devices found only by mDNS (not nmap) should appear in pending_devices."""
@@ -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
@@ -562,7 +563,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
</p> </p>
)} )}
{!loading && filtered.length > 0 && ( {!loading && filtered.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-2">
{filtered.map((d) => ( {filtered.map((d) => (
<DeviceCard <DeviceCard
key={d.id} key={d.id}
@@ -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
@@ -674,7 +691,7 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
ref={cardRef} ref={cardRef}
onClick={onClick} onClick={onClick}
data-testid={`pending-card-${device.id}`} data-testid={`pending-card-${device.id}`}
className={`relative text-left rounded-lg border p-3 transition-all duration-150 ${borderClass}`} className={`relative text-left rounded-lg border p-2.5 transition-all duration-150 ${borderClass}`}
> >
{selectMode && selected && ( {selectMode && selected && (
<CheckCircle2 <CheckCircle2
@@ -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,6 +8,7 @@ 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 {
@@ -252,7 +253,10 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
{data.mac && <DetailRow label="MAC" value={data.mac} mono />} {data.mac && <DetailRow label="MAC" value={data.mac} mono />}
{data.os && <DetailRow label="OS" value={data.os} />} {data.os && <DetailRow label="OS" value={data.os} />}
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />} {data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
{data.last_seen && <DetailRow label="Last Seen" value={new Date(/[Zz]|[+-]\d{2}:?\d{2}$/.test(data.last_seen) ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />} {data.last_seen && <DetailRow label="Last Seen" value={formatTimestamp(data.last_seen)} />}
{data.last_scan && <DetailRow label="Last Scan" value={formatTimestamp(data.last_scan)} />}
{data.created_at && <DetailRow label="Created" value={formatTimestamp(data.created_at)} />}
{data.updated_at && <DetailRow label="Last Modified" value={formatTimestamp(data.updated_at)} />}
</div> </div>
{/* Size section — manual width/height entry for pixel-exact sizing */} {/* Size section — manual width/height entry for pixel-exact sizing */}
@@ -212,6 +212,37 @@ describe('DetailPanel', () => {
}) })
}) })
describe('Inventory timestamps', () => {
it('renders Created, Last Scan and Last Modified rows when present', () => {
setupStore({
created_at: '2026-01-02T10:00:00Z',
last_scan: '2026-06-01T08:30:00Z',
updated_at: '2026-06-20T12:00:00Z',
last_seen: '2026-06-25T09:15:00Z',
})
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('Created')).toBeInTheDocument()
expect(screen.getByText('Last Scan')).toBeInTheDocument()
expect(screen.getByText('Last Modified')).toBeInTheDocument()
expect(screen.getByText('Last Seen')).toBeInTheDocument()
})
it('omits rows whose timestamps are absent', () => {
setupStore({ created_at: '2026-01-02T10:00:00Z' })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('Created')).toBeInTheDocument()
expect(screen.queryByText('Last Scan')).not.toBeInTheDocument()
expect(screen.queryByText('Last Modified')).not.toBeInTheDocument()
})
it('parses naive (suffix-less) UTC timestamps without throwing', () => {
setupStore({ created_at: '2026-01-02 10:00:00' })
render(<DetailPanel onEdit={vi.fn()} />)
// Renders a locale string for the date (year is locale-independent).
expect(screen.getByText(/2026/)).toBeInTheDocument()
})
})
describe('Panel actions', () => { describe('Panel actions', () => {
it('calls setSelectedNode(null) when close button is clicked', () => { it('calls setSelectedNode(null) when close button is clicked', () => {
const setSelectedNode = vi.fn() const setSelectedNode = vi.fn()
+3
View File
@@ -102,6 +102,9 @@ export interface NodeData extends Record<string, unknown> {
check_target?: string check_target?: string
services: ServiceInfo[] services: ServiceInfo[]
last_seen?: string last_seen?: string
last_scan?: string
created_at?: string
updated_at?: string
response_time_ms?: number response_time_ms?: number
notes?: string notes?: string
cpu_count?: number cpu_count?: number
@@ -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`
}