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
This commit is contained in:
Pouzor
2026-06-27 03:13:49 +02:00
parent b3f7c38c67
commit 19b7d38ec0
10 changed files with 178 additions and 3 deletions
+3
View File
@@ -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")
+1
View File
@@ -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)
+1
View File
@@ -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
+16 -2
View File
@@ -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)