Compare commits

...
2 Commits
Author SHA1 Message Date
Pouzor ad0b1b427d chore: bump version to 3.3.4
ha-relevant: no
2026-08-17 19:19:25 +02:00
Pouzor 4c460cec1c fix(scan): stop a re-scan from deleting a device's hand-added services
Since 3.3.0 the inventory row is the only copy of a device's services and
every canvas drawing it reads that list, but the scanner still assigned
`keep.services = fingerprint_ports(...)` — the pre-split behaviour, when a
node held its own copy. One re-scan therefore deleted every service the user
added by hand, on every canvas at once, and brought back the ones they had
deleted. It unions now, like every other writer of that field.

Two more things #347 turned up:

* A node whose view matches nothing the row still holds drew nothing at all —
  the row had been replaced under it, so every key was gone and every key was
  new, and `apply_view` hid the lot. Such a view says nothing about the list
  that replaced it, so it is treated as no view: the row is drawn. An empty
  view is untouched, being a real answer ("this canvas draws none of them").

* The 3.3.3 view seed recovers a node's arrangement from the pre-3.3.0 backup,
  which is 3.2.0-era and cannot know about a property added afterwards. On
  3.3.0-3.3.2 the row was the only place to add one and every canvas drew it,
  so seeding strictly from the backup took it off all of them at once. Those
  are appended visible, keeping the recovered order and hidden flags for
  everything the backup does know. Services keep the strict recovery: holding
  back what a scan fingerprinted is the whole point of the view.

ha-relevant: maybe
2026-08-17 19:05:13 +02:00
9 changed files with 254 additions and 10 deletions
+8
View File
@@ -5,6 +5,14 @@ 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.4] - 2026-08-17
### Fixes
- A network scan no longer deletes the services you added by hand. The scanner replaced a device's whole service list with what it fingerprinted, so one scan wiped manual entries on every canvas at once and brought back the ones you had deleted. It merges now. (#347)
- A node whose services were all replaced under it no longer renders empty: when nothing a node's view names is left on the device, the device's own list is drawn instead of hiding everything. (#347)
- Properties added while running 3.3.0–3.3.2 survive the 3.3.3 upgrade. The view recovered from the pre-3.3.0 backup could not know about them, so they were hidden on every canvas; they are kept visible, and the per-canvas arrangement the backup does know is still restored. (#347)
## [3.3.3] - 2026-08-17
### Features
+1 -1
View File
@@ -1 +1 @@
3.3.3
3.3.4
+45 -5
View File
@@ -308,6 +308,13 @@ def apply_view(items: list[Any] | None, entries: Any, kind: str) -> list[Any]:
version — everything shows, in the row's own order. With one, an item the
view does not list is appended hidden rather than dropped, so a service a
scan added is one toggle away instead of invisible.
A *non-empty* view that matches nothing the row still holds is treated as
having no view at all. It means the row was replaced wholesale under the
node — every key gone, every key new — and hiding the lot would leave a node
drawing nothing while its view claims otherwise. An empty view is different:
it is a real answer ("this canvas draws none of them") and keeps hiding
everything.
"""
key_of = _VIEW_KEY[kind]
facts = list(items or [])
@@ -329,6 +336,8 @@ def apply_view(items: list[Any] | None, entries: Any, kind: str) -> list[Any]:
continue # Deleted from the row since — the view catches up on write.
taken.add(key)
out.append(_stamped(item, bool(entry.get("visible", True))))
if entries and not taken:
return facts
for item in facts:
if key_of(item) not in taken:
out.append(_stamped(item, False))
@@ -754,6 +763,28 @@ async def _row_is_missing_facts(db: AsyncSession, device_id: str, facts: Mapping
)
def _with_later_properties(
view: dict[str, Any] | None, device: InventoryDevice
) -> dict[str, Any] | None:
"""``view``, plus the row's properties the pre-3.3.0 backup never saw.
They are what the user added while running 3.3.0-3.3.2 — the row was the
only place to add them then, and every canvas drew them. Recovering the old
view alone would take them off every canvas at once, which reads as data
loss. Appended in the row's order, after everything the backup placed.
"""
if view is None:
return None
entries = view.get("properties")
if not isinstance(entries, list):
return view
listed = {str(e.get("key")) for e in entries if isinstance(e, dict)}
later = [e for e in view_entries(device.properties, "properties") if e["key"] not in listed]
if not later:
return view
return {**view, "properties": [*entries, *later]}
async def seed_node_views(
db: AsyncSession, *, drawn: Callable[[], Mapping[str, Mapping[str, Any]]] | None = None
) -> int:
@@ -770,6 +801,15 @@ async def seed_node_views(
actually a node to seed. Where it has an answer that answer wins, restoring
the arrangement the user made; where it does not, the row is the seed and
the canvas keeps showing exactly what it shows today. Does not commit.
The backup is 3.2.0-era, so it can only speak for what existed then. A
*property* the row has gained since — one the user added by hand while
running 3.3.0-3.3.2, when the row was all a canvas had — appears in no
backup entry, and seeding strictly from the backup would hide work the user
has been looking at for days. Those are appended visible, keeping the
recovered order and hidden flags for everything the backup does know.
Services are not treated that way: what a row gained since 3.3.0 is mostly a
scan's fingerprint, and holding it back is the whole point of the view.
"""
nodes = list(
(
@@ -803,11 +843,11 @@ async def seed_node_views(
was = was_drawn.get(node.id)
# `strict`: an empty list in the backup is this canvas' real answer —
# it drew no service — and must not be read as "say nothing, show all".
node.display_view = (
next_view(None, view_from_facts(was), device, strict=True)
if was
else view_of_device(device)
)
if was:
view = next_view(None, view_from_facts(was), device, strict=True)
node.display_view = _with_later_properties(view, device)
else:
node.display_view = view_of_device(device)
seeded += 1
await db.flush()
return seeded
+9 -1
View File
@@ -19,6 +19,7 @@ from app.db.models import InventoryDevice, ScanRun
from app.services.discovery_sources import add_source
from app.services.fingerprint import fingerprint_ports, suggest_node_type
from app.services.http_probe import probe_open_ports
from app.services.inventory_sync import merge_services
from app.services.mac_utils import normalize_mac
logger = logging.getLogger(__name__)
@@ -591,7 +592,14 @@ async def run_scan(
keep.mac = norm_mac or keep.mac
keep.hostname = host.get("hostname") or keep.hostname
keep.os = host.get("os") or keep.os
keep.services = services
# Union, never replace. Since 3.3.0 the row is the only copy of
# a device's services — every canvas drawing it reads this list
# — so overwriting it with the fingerprint would delete services
# the user added by hand, on every canvas at once. What a scan
# finds is added; what it no longer sees stays. A service the
# user does not want on a canvas is hidden by that node's view,
# which is where "I deleted this" belongs.
keep.services = merge_services(keep.services, services)
# Don't downgrade a Proxmox-typed guest (vm/lxc) to the generic
# scan guess; the importer knows the true type.
if not (keep.ieee_address or "").startswith("pve-"):
@@ -300,6 +300,54 @@ async def test_a_database_already_on_3_3_recovers_its_layout_from_the_backup(db_
await engine.dispose()
async def test_a_property_added_while_on_3_3_survives_the_recovery(db_320):
"""The backup is 3.2.0-era and cannot know what the user added afterwards.
On 3.3.0-3.3.2 the row was the only place to add a property, and every
canvas drew it. Recovering the view strictly from the backup would hide it
on all of them at once — the "my properties disappeared" half of #347. The
scanner's service find is still held back: only properties are appended.
"""
_, engine = db_320
await _build_320(engine)
rack = '[{"key": "Rack", "value": "A", "icon": null, "visible": true}]'
ssh = '[{"port": 22, "protocol": "tcp", "service_name": "ssh"}]'
async with engine.begin() as conn:
await conn.exec_driver_sql(
f"UPDATE nodes SET properties = '{rack}', services = '{ssh}' WHERE id = 'n1'"
)
await database.init_db() # 3.2.0 -> 3.3.x, and the backup that predates it.
kuma = {"port": 3001, "protocol": "tcp", "service_name": "Uptime Kuma"}
await _wind_back_to_3_3_2(engine, kuma)
from app.db.models import InventoryDevice, Node
session_factory = database.async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
node = await session.get(Node, "n1")
device = await session.get(InventoryDevice, node.device_id)
# What the user re-added by hand while running 3.3.x.
device.properties = [
*device.properties,
{"key": "Ports", "value": "8", "icon": None, "visible": True},
]
await session.commit()
await database.init_db()
async with session_factory() as session:
node = await session.get(Node, "n1")
device = await session.get(InventoryDevice, node.device_id)
payload = inventory_sync.hydrated_node(node, device)
assert [(p["key"], p.get("visible", True)) for p in payload["properties"]] == [
("Rack", True), ("Ports", True),
]
# And the scan's find is still off this canvas.
assert [s["service_name"] for s in payload["services"] if s.get("visible", True)] == ["ssh"]
await engine.dispose()
async def test_without_a_usable_backup_the_row_is_the_seed(db_320):
"""No backup to recover from: keep showing what the canvas shows today.
+98
View File
@@ -1300,6 +1300,104 @@ class TestPerNodeView:
assert await seed_node_views(db_session) == 0
assert node.display_view is None
@pytest.mark.asyncio
async def test_a_view_matching_nothing_left_on_the_row_shows_the_row(self, db_session):
"""The row was replaced wholesale under the node — don't draw a blank node.
A view whose every key is gone tells us nothing about the list that
replaced it, and hiding all of it leaves a node showing no service while
"Show services" is on (#347). An *empty* view is a real answer and still
hides everything — that case is covered below.
"""
device = InventoryDevice(
id="d-1",
services=[
{"port": 8080, "protocol": "tcp", "service_name": "HTTP Alt"},
{"port": 5001, "protocol": "tcp", "service_name": "Synology DSM HTTPS"},
],
)
node = _node(
await _design(db_session),
device_id="d-1",
display_view={"services": [{"key": "9000|tcp|portainer", "visible": True}]},
)
assert [s["service_name"] for s in hydrated_node(node, device)["services"]] == [
"HTTP Alt", "Synology DSM HTTPS",
]
assert all(s.get("visible", True) for s in hydrated_node(node, device)["services"])
@pytest.mark.asyncio
async def test_an_empty_view_still_hides_everything(self, db_session):
""""This canvas draws none of them" is an answer, not a missing view."""
device = InventoryDevice(
id="d-1", services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}]
)
node = _node(await _design(db_session), device_id="d-1", display_view={"services": []})
assert hydrated_node(node, device)["services"] == [
{"port": 22, "protocol": "tcp", "service_name": "ssh", "visible": False}
]
@pytest.mark.asyncio
async def test_seeding_keeps_a_property_added_after_the_backup(self, db_session):
"""The backup is 3.2.0-era: it cannot know a property added since.
Adding one while on 3.3.0-3.3.2 wrote it to the row, and every canvas
drew it. Seeding strictly from the backup would take it off all of them
at once (#347), so it is appended visible after what the backup places.
"""
design = await _design(db_session)
db_session.add(
InventoryDevice(
id="d-1", ip="10.0.0.5",
properties=[
{"key": "Rack", "value": "A", "icon": None, "visible": True},
{"key": "Ports", "value": "8", "icon": None, "visible": True},
],
)
)
node = _node(design, device_id="d-1")
db_session.add(node)
await db_session.commit()
# The backup only knew "Rack", and this canvas had it hidden.
drawn = {node.id: {
"services": [],
"properties": [{"key": "Rack", "value": "A", "icon": None, "visible": False}],
}}
assert await seed_node_views(db_session, drawn=lambda: drawn) == 1
await db_session.commit()
assert node.display_view["properties"] == [
{"key": "rack", "visible": False}, # recovered, still hidden here
{"key": "ports", "visible": True}, # added since the backup, kept
]
@pytest.mark.asyncio
async def test_seeding_still_holds_back_a_service_found_since_the_backup(self, db_session):
"""Services are not treated that way: a scan's find stays off the canvas."""
design = await _design(db_session)
db_session.add(
InventoryDevice(
id="d-1", ip="10.0.0.5",
services=[
{"port": 22, "protocol": "tcp", "service_name": "ssh"},
{"port": 5001, "protocol": "tcp", "service_name": "Synology DSM HTTPS"},
],
)
)
node = _node(design, device_id="d-1")
db_session.add(node)
await db_session.commit()
drawn = {node.id: {
"services": [{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
"properties": [],
}}
assert await seed_node_views(db_session, drawn=lambda: drawn) == 1
await db_session.commit()
assert node.display_view["services"] == [{"key": "22|tcp|ssh", "visible": True}]
@pytest.mark.asyncio
async def test_a_node_without_a_view_still_shows_everything(self, db_session):
"""No view — furniture, or a node an older version linked — is not "hide all"."""
+42
View File
@@ -750,6 +750,48 @@ async def test_run_scan_merges_proxmox_row_by_mac(mem_db):
assert set(row.discovery_sources) == {"proxmox", "arp"} # both filters
@pytest.mark.asyncio
async def test_run_scan_keeps_services_the_fingerprint_cannot_see(mem_db):
"""A re-scan unions its fingerprint onto the row — it never replaces it.
Since 3.3.0 the row is the only copy of a device's services and every canvas
drawing it reads that list, so overwriting it with what nmap happened to
match would delete hand-added services everywhere at once (#347).
"""
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(InventoryDevice(
id="row-1", ip="192.168.1.60", status="approved",
discovery_source="arp", discovery_sources=["arp"],
services=[
{"port": 9000, "protocol": "tcp", "service_name": "Portainer", "path": "/#!/home"},
{"port": 22, "protocol": "tcp", "service_name": "ssh"},
],
))
await session.commit()
nmap_hosts = [{"ip": "192.168.1.60", "hostname": "docker.lan", "mac": None, "os": None,
"open_ports": [{"port": 22, "protocol": "tcp", "banner": "OpenSSH 9.2"}]}]
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:
row = await session.get(InventoryDevice, "row-1")
by_port = {s["port"]: s for s in row.services}
assert by_port[9000]["service_name"] == "Portainer" # hand-added, untouched
assert by_port[9000]["path"] == "/#!/home"
assert 22 in by_port # what the scan saw is still there
assert row.status == "approved"
@pytest.mark.asyncio
async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
"""If nmap and mDNS both find the same IP, it should not be double-counted."""
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "3.3.3",
"version": "3.3.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "3.3.3",
"version": "3.3.4",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "3.3.3",
"version": "3.3.4",
"type": "module",
"scripts": {
"dev": "vite",