From 19b7d38ec0fac9dd5150458a6702e3f1ce707433 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 27 Jun 2026 03:13:49 +0200 Subject: [PATCH] feat: surface inventory timestamps on nodes Add creation_date, last_scan and last_modify to the node inventory and the right-hand detail panel (last_seen already shown): - creation_date -> existing created_at column - last_modify -> existing updated_at column (bumped on any node change) - last_scan -> new column, stamped when a scan observes a node by IP/MAC - last_seen -> unchanged Backend: new nodes.last_scan column + idempotent migration, NodeResponse field, scanner stamps matching canvas nodes per scanned host. Frontend: NodeData fields + DetailPanel rows with UTC-safe timestamp formatting. ha-relevant: maybe --- backend/app/db/database.py | 3 + backend/app/db/models.py | 1 + backend/app/schemas/nodes.py | 1 + backend/app/services/scanner.py | 18 ++++- backend/tests/test_canvas.py | 12 +++ backend/tests/test_migrations.py | 22 ++++++ backend/tests/test_scanner.py | 78 +++++++++++++++++++ .../src/components/panels/DetailPanel.tsx | 12 ++- .../panels/__tests__/DetailPanel.test.tsx | 31 ++++++++ frontend/src/types/index.ts | 3 + 10 files changed, 178 insertions(+), 3 deletions(-) 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