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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 && <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
|
||||
|
||||
Reference in New Issue
Block a user