Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad0b1b427d | ||
|
|
4c460cec1c |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Generated
+2
-2
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "3.3.3",
|
||||
"version": "3.3.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Reference in New Issue
Block a user