diff --git a/backend/app/db/database.py b/backend/app/db/database.py
index a95258a..041aed9 100644
--- a/backend/app/db/database.py
+++ b/backend/app/db/database.py
@@ -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")
diff --git a/backend/app/db/models.py b/backend/app/db/models.py
index 1e49386..9209b2b 100644
--- a/backend/app/db/models.py
+++ b/backend/app/db/models.py
@@ -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)
diff --git a/backend/app/schemas/nodes.py b/backend/app/schemas/nodes.py
index 5c220ed..4fb3597 100644
--- a/backend/app/schemas/nodes.py
+++ b/backend/app/schemas/nodes.py
@@ -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
diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py
index c19d1a5..07401e8 100644
--- a/backend/app/services/scanner.py
+++ b/backend/app/services/scanner.py
@@ -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)
diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py
index 19202b6..d26e9c6 100644
--- a/backend/tests/test_canvas.py
+++ b/backend/tests/test_canvas.py
@@ -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)
diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py
index fe225f9..d4f2d33 100644
--- a/backend/tests/test_migrations.py
+++ b/backend/tests/test_migrations.py
@@ -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
diff --git a/backend/tests/test_scanner.py b/backend/tests/test_scanner.py
index 2b02326..c493a7d 100644
--- a/backend/tests/test_scanner.py
+++ b/backend/tests/test_scanner.py
@@ -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."""
diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx
index 7b5f748..86d3d75 100644
--- a/frontend/src/components/panels/DetailPanel.tsx
+++ b/frontend/src/components/panels/DetailPanel.tsx
@@ -14,6 +14,13 @@ 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: '' }
@@ -252,7 +259,10 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
{data.mac && }
{data.os && }
{data.check_method && }
- {data.last_seen && }
+ {data.last_seen && }
+ {data.last_scan && }
+ {data.created_at && }
+ {data.updated_at && }
{/* Size section — manual width/height entry for pixel-exact sizing */}
diff --git a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx
index e81e1d5..03b5982 100644
--- a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx
+++ b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx
@@ -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()
+ 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()
+ 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()
+ // 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()
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 22132b7..7f438fb 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -102,6 +102,9 @@ export interface NodeData extends Record {
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