Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a58725c2ee | ||
|
|
466df59b0c |
@@ -5,6 +5,13 @@ All notable changes to **Homelable** are documented here.
|
||||
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [3.3.2] - 2026-08-17
|
||||
|
||||
### Fixes
|
||||
|
||||
- Devices upgraded from 3.2.0 keep their ip, services, hostname, notes and hardware. The 3.3.0 migration aborted on timestamps SQLite returns as text, so nothing was carried across to the Device Inventory and every device read back blank. (#347, #348)
|
||||
- A canvas saved while an earlier migration was stuck no longer loses its device facts: the migration now fills the blank inventory row that save created, instead of skipping the node and dropping the columns holding its only copy. (#347)
|
||||
|
||||
## [3.3.1] - 2026-08-17
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -16,10 +16,10 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, or_, select, text
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import InventoryDevice, Node
|
||||
@@ -548,6 +548,49 @@ def _decode_json(value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
# Legacy `nodes` columns SQLite stores as DATETIME. Read through raw SQL they come
|
||||
# back as text, and writing text into a DateTime column raises
|
||||
# ``TypeError: SQLite DateTime type only accepts Python datetime and date objects``.
|
||||
_LEGACY_DATETIME_COLUMNS = ("last_seen", "last_scan")
|
||||
|
||||
|
||||
def _decode_dt(value: Any) -> datetime | None:
|
||||
"""Raw SQL hands back DATETIME columns as text; the ORM would have parsed them.
|
||||
|
||||
An unparseable stamp becomes ``None`` rather than an error: `last_seen` and
|
||||
`last_scan` are observations the status checker refreshes within a minute,
|
||||
and losing one must not cost a node its inventory row.
|
||||
"""
|
||||
if value is None or isinstance(value, datetime):
|
||||
return value
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value.strip())
|
||||
except ValueError:
|
||||
logger.debug("Ignoring an unparseable legacy timestamp: %r", value)
|
||||
return None
|
||||
|
||||
|
||||
async def _row_is_missing_facts(db: AsyncSession, device_id: str, facts: Mapping[str, Any]) -> bool:
|
||||
"""Does the linked row lack a fact the node's legacy columns still hold?
|
||||
|
||||
True only where the node has something to give and the row has nothing in
|
||||
that field, so a node whose facts already made it across is left alone and
|
||||
the backfill stays a no-op on later boots.
|
||||
"""
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if device is None:
|
||||
return True # The row is gone; the node needs a new one.
|
||||
for field in (*DEVICE_SCALARS, "label", "type", "ieee_address"):
|
||||
if not _blank(facts.get(field)) and _blank(getattr(device, field, None)):
|
||||
return True
|
||||
return any(
|
||||
facts.get(field) and not getattr(device, field, None)
|
||||
for field in ("services", "properties")
|
||||
)
|
||||
|
||||
|
||||
async def backfill_node_devices(db: AsyncSession) -> dict[str, int]:
|
||||
"""Link every pre-3.3.0 canvas node to a Device Inventory row.
|
||||
|
||||
@@ -571,11 +614,16 @@ async def backfill_node_devices(db: AsyncSession) -> dict[str, int]:
|
||||
|
||||
placeholders = ", ".join(legacy)
|
||||
furniture = ", ".join(f"'{t}'" for t in sorted(FURNITURE_TYPES))
|
||||
# Linked nodes are read too, not just unlinked ones. A canvas saved while an
|
||||
# earlier migration was stuck minted a *blank* inventory row from a UI that
|
||||
# had no facts to show, and linked the node to it. Skipping those would count
|
||||
# them as migrated and drop the columns that still hold their only copy of
|
||||
# the ip, services and notes. A linked node is filled, never overwritten.
|
||||
rows = (
|
||||
await db.execute(
|
||||
text(
|
||||
f"SELECT id, label, type, design_id, {placeholders} FROM nodes "
|
||||
f"WHERE device_id IS NULL AND type NOT IN ({furniture}) "
|
||||
f"SELECT id, label, type, design_id, device_id, {placeholders} FROM nodes "
|
||||
f"WHERE type NOT IN ({furniture}) "
|
||||
"ORDER BY updated_at, created_at, id"
|
||||
)
|
||||
)
|
||||
@@ -588,26 +636,36 @@ async def backfill_node_devices(db: AsyncSession) -> dict[str, int]:
|
||||
facts: dict[str, Any] = {c: row[c] for c in legacy}
|
||||
facts["services"] = _decode_json(facts.get("services")) or []
|
||||
facts["properties"] = _decode_json(facts.get("properties")) or []
|
||||
for field in _LEGACY_DATETIME_COLUMNS:
|
||||
if field in facts:
|
||||
facts[field] = _decode_dt(facts[field])
|
||||
facts["label"] = row["label"]
|
||||
facts["type"] = row["type"]
|
||||
|
||||
# One savepoint per node: a row the merge cannot write — a duplicate
|
||||
# IEEE, a constraint no longer met — is dropped on its own rather than
|
||||
# taking the whole backfill with it. Every node it does not link keeps
|
||||
# its legacy columns, so nothing is lost and the next boot retries.
|
||||
# One savepoint per node: a row the merge cannot write is dropped on its
|
||||
# own rather than taking the whole backfill with it. Every node it does
|
||||
# not link keeps its legacy columns, so nothing is lost and the next boot
|
||||
# retries. The catch is deliberately broad — a node that cannot be read
|
||||
# or written for *any* reason must not cost the others their link.
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
existing = await find_device_for(
|
||||
db, ip=facts.get("ip"), mac=facts.get("mac"), ieee=facts.get("ieee_address")
|
||||
)
|
||||
node = await db.get(Node, row["id"])
|
||||
if node is None: # pragma: no cover - the row was just read
|
||||
continue
|
||||
device = await link_facts(db, node, facts, overwrite_scalars=True)
|
||||
if node.device_id and not await _row_is_missing_facts(db, node.device_id, facts):
|
||||
continue # Already migrated, and its row holds the facts.
|
||||
existing = await find_device_for(
|
||||
db, ip=facts.get("ip"), mac=facts.get("mac"), ieee=facts.get("ieee_address")
|
||||
)
|
||||
# A node that is already linked only has its gaps filled: whatever
|
||||
# is on the row was written after the migration and is newer.
|
||||
device = await link_facts(
|
||||
db, node, facts, overwrite_scalars=node.device_id is None
|
||||
)
|
||||
if device is None:
|
||||
continue
|
||||
await db.flush()
|
||||
except (IntegrityError, OperationalError) as exc:
|
||||
except Exception as exc:
|
||||
skipped += 1
|
||||
logger.warning(
|
||||
"Inventory backfill: node %s (%s) skipped — %s", row["id"], row["label"], exc
|
||||
|
||||
@@ -385,6 +385,133 @@ class TestBackfill:
|
||||
assert stats["linked"] == 0
|
||||
assert (await db_session.execute(select(InventoryDevice))).scalars().all() == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_carries_over_timestamps_stored_as_text(self, db_session):
|
||||
"""The legacy columns are read with raw SQL, so SQLite hands the DATETIME
|
||||
ones back as strings. Writing one straight into the row raises
|
||||
``TypeError: SQLite DateTime type only accepts Python datetime and date
|
||||
objects`` — which used to abort the whole backfill (issue #347)."""
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design = await _design(db_session)
|
||||
node_id = await self._legacy_node(
|
||||
db_session,
|
||||
design,
|
||||
ip="10.0.0.5",
|
||||
label="Dockerhost",
|
||||
status="online",
|
||||
last_seen="2026-08-15 04:10:22.123456",
|
||||
last_scan="2026-08-15 04:00:00",
|
||||
)
|
||||
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats == {"linked": 1, "created": 1, "merged": 0, "skipped": 0}
|
||||
node = await db_session.get(Node, node_id)
|
||||
assert node is not None
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
assert device is not None
|
||||
assert device.last_seen == datetime(2026, 8, 15, 4, 10, 22, 123456)
|
||||
assert device.last_scan == datetime(2026, 8, 15, 4, 0, 0)
|
||||
assert device.status_live == "online"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_stamp_it_cannot_parse_costs_only_the_stamp(self, db_session):
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design = await _design(db_session)
|
||||
await self._legacy_node(db_session, design, ip="10.0.0.5", last_seen="not a date")
|
||||
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats["linked"] == 1
|
||||
device = (await db_session.execute(select(InventoryDevice))).scalars().one()
|
||||
assert device.last_seen is None
|
||||
assert device.ip == "10.0.0.5"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_unwritable_node_does_not_cost_the_others_their_link(self, db_session, monkeypatch):
|
||||
"""Whatever a single node raises — not only a constraint violation — the
|
||||
rest of the canvas still gets linked."""
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design = await _design(db_session)
|
||||
await self._legacy_node(db_session, design, ip="10.0.0.5", label="good")
|
||||
await self._legacy_node(db_session, design, ip="10.0.0.6", label="bad")
|
||||
|
||||
import app.services.inventory_sync as module
|
||||
|
||||
real = module.link_facts
|
||||
|
||||
async def link_facts(db, node, facts, **kwargs):
|
||||
if facts.get("ip") == "10.0.0.6":
|
||||
raise TypeError("SQLite DateTime type only accepts Python datetime")
|
||||
return await real(db, node, facts, **kwargs)
|
||||
|
||||
monkeypatch.setattr(module, "link_facts", link_facts)
|
||||
|
||||
stats = await module.backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats["linked"] == 1
|
||||
assert stats["skipped"] == 1
|
||||
devices = (await db_session.execute(select(InventoryDevice))).scalars().all()
|
||||
assert [d.ip for d in devices] == ["10.0.0.5"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fills_a_blank_row_a_stuck_canvas_save_created(self, db_session):
|
||||
"""A save made while an earlier migration was stuck linked the node to a
|
||||
row minted from a blank UI. Those facts only exist in the legacy columns,
|
||||
and the drop is about to remove them — so fill the row before it does."""
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design = await _design(db_session)
|
||||
db_session.add(InventoryDevice(id="d-blank", ip="", label="Dockerhost", services=[]))
|
||||
await db_session.commit()
|
||||
node_id = await self._legacy_node(
|
||||
db_session, design, label="Dockerhost", ip="192.168.0.42", hostname="clara.lan",
|
||||
notes="in the cupboard", services=[{"port": 443, "protocol": "tcp", "service_name": "https"}],
|
||||
)
|
||||
node = await db_session.get(Node, node_id)
|
||||
node.device_id = "d-blank"
|
||||
await db_session.commit()
|
||||
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats["linked"] == 1
|
||||
device = await db_session.get(InventoryDevice, "d-blank")
|
||||
assert device is not None
|
||||
assert device.ip == "192.168.0.42"
|
||||
assert device.hostname == "clara.lan"
|
||||
assert device.notes == "in the cupboard"
|
||||
assert [s["service_name"] for s in device.services] == ["https"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_leaves_a_linked_row_that_already_has_the_facts_alone(self, db_session):
|
||||
"""The mirror of the above: a row a user has since edited is not reverted
|
||||
to what the node's abandoned columns still say."""
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design = await _design(db_session)
|
||||
db_session.add(InventoryDevice(
|
||||
id="d-1", ip="192.168.0.99", hostname="renamed.lan",
|
||||
label="Dockerhost", type="docker_host",
|
||||
))
|
||||
await db_session.commit()
|
||||
node_id = await self._legacy_node(
|
||||
db_session, design, ip="192.168.0.42", hostname="clara.lan", label="Dockerhost"
|
||||
)
|
||||
node = await db_session.get(Node, node_id)
|
||||
node.device_id = "d-1"
|
||||
await db_session.commit()
|
||||
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats["linked"] == 0
|
||||
device = await db_session.get(InventoryDevice, "d-1")
|
||||
assert device is not None
|
||||
assert device.ip == "192.168.0.99"
|
||||
assert device.hostname == "renamed.lan"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_a_no_op_on_a_second_run(self, db_session):
|
||||
await self._legacy_nodes_table(db_session)
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "3.3.1",
|
||||
"version": "3.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "3.3.1",
|
||||
"version": "3.3.2",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "3.3.1",
|
||||
"version": "3.3.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Reference in New Issue
Block a user