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 = ?", "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")
+1
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)
+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
+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
+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."""
+11 -1
View File
@@ -14,6 +14,13 @@ interface DetailPanelProps {
onEdit: (id: string) => void 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 } type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '', path: '' } const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '', path: '' }
@@ -252,7 +259,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