Merge pull request #233 from Pouzor/feat/inventory-timestamps
feat: surface inventory timestamps on nodes
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -278,15 +308,38 @@ async def bulk_approve_devices(
|
||||
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
|
||||
|
||||
# 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(
|
||||
select(PendingDevice).where(
|
||||
PendingDevice.id.in_(payload.device_ids),
|
||||
PendingDevice.status == "pending",
|
||||
PendingDevice.status != "hidden",
|
||||
)
|
||||
)
|
||||
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] = []
|
||||
approved_devices: list[PendingDevice] = []
|
||||
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"
|
||||
node_type = device.suggested_type or "generic"
|
||||
is_wireless = _is_wireless(node_type)
|
||||
@@ -309,12 +362,20 @@ async def bulk_approve_devices(
|
||||
)
|
||||
db.add(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
|
||||
# node_ids and approved_device_ids stay index-aligned for the client's mapping.
|
||||
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]] = []
|
||||
for device in devices:
|
||||
for device in approved_devices:
|
||||
all_edges.extend(await _resolve_pending_links_for_ieee(db, device.ieee_address))
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -293,6 +293,9 @@ async def init_db() -> None:
|
||||
"UPDATE nodes SET properties = ? WHERE 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')
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1")
|
||||
|
||||
@@ -61,6 +61,7 @@ class Node(Base):
|
||||
bottom_handles: Mapped[int] = mapped_column(Integer, default=1)
|
||||
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=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)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
@@ -99,6 +100,9 @@ class CanvasState(Base):
|
||||
|
||||
class PendingDevice(Base):
|
||||
__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)
|
||||
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
|
||||
# how many canvases this device already appears on. Not a mapped column.
|
||||
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):
|
||||
|
||||
@@ -73,6 +73,7 @@ class NodeResponse(NodeBase):
|
||||
design_id: str | None = None
|
||||
ieee_address: str | None = None
|
||||
last_seen: datetime | None = None
|
||||
last_scan: datetime | None = None
|
||||
response_time_ms: int | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_, select
|
||||
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.http_probe import probe_open_ports
|
||||
|
||||
@@ -542,6 +542,20 @@ async def run_scan(
|
||||
))
|
||||
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 broadcast_scan_update(run_id=run_id, devices_found=devices_found)
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
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):
|
||||
n1 = node_payload(label="Old Label")
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
@@ -105,6 +105,28 @@ async def test_legacy_canvas_migrates_into_default_design(legacy_engine):
|
||||
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):
|
||||
"""Running init_db twice must not duplicate the design or drop any data."""
|
||||
db_path, engine = legacy_engine
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@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
|
||||
async def zigbee_pending_device(db_session):
|
||||
device = PendingDevice(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@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
|
||||
async def test_run_scan_mdns_only_device_added(mem_db):
|
||||
"""Devices found only by mDNS (not nmap) should appear in pending_devices."""
|
||||
|
||||
@@ -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
|
||||
@@ -562,7 +563,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
</p>
|
||||
)}
|
||||
{!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) => (
|
||||
<DeviceCard
|
||||
key={d.id}
|
||||
@@ -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
|
||||
@@ -674,7 +691,7 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
|
||||
ref={cardRef}
|
||||
onClick={onClick}
|
||||
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 && (
|
||||
<CheckCircle2
|
||||
@@ -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,6 +8,7 @@ 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 {
|
||||
@@ -252,7 +253,10 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
||||
{data.os && <DetailRow label="OS" value={data.os} />}
|
||||
{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>
|
||||
|
||||
{/* 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', () => {
|
||||
it('calls setSelectedNode(null) when close button is clicked', () => {
|
||||
const setSelectedNode = vi.fn()
|
||||
|
||||
@@ -102,6 +102,9 @@ export interface NodeData extends Record<string, unknown> {
|
||||
check_target?: string
|
||||
services: ServiceInfo[]
|
||||
last_seen?: string
|
||||
last_scan?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
response_time_ms?: number
|
||||
notes?: string
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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