From 9afaac7612307443bdada84564d77770995f2434 Mon Sep 17 00:00:00 2001 From: anti Date: Mon, 20 Apr 2026 17:48:29 -0400 Subject: [PATCH] feat(topology): nullable layout coords on LAN + TopologyDecky MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MazeNET phase 2 step 5. Pure storage — the generator emits None for x/y and the web canvas fills them in later. No logic changes; no compose, deploy, or validator impact. --- decnet/topology/config.py | 7 ++++ decnet/topology/persistence.py | 4 +++ decnet/web/db/models.py | 8 +++++ tests/topology/test_layout.py | 58 ++++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+) create mode 100644 tests/topology/test_layout.py diff --git a/decnet/topology/config.py b/decnet/topology/config.py index 927a6c5c..1f2098ec 100644 --- a/decnet/topology/config.py +++ b/decnet/topology/config.py @@ -59,6 +59,10 @@ class _PlannedLAN: subnet: str is_dmz: bool parent: Optional[str] # name of parent LAN, None for DMZ + # Canvas coordinates — generator leaves them None; the web editor + # (or a future auto-layouter) fills them in. + x: Optional[float] = None + y: Optional[float] = None @dataclass @@ -73,6 +77,9 @@ class _PlannedDecky: # Mirrors ``DeckyConfig.service_config`` from the flat-fleet path; # services read these via ``compose_fragment(service_cfg=...)``. service_config: dict[str, dict] = field(default_factory=dict) + # Canvas coordinates — see _PlannedLAN.x/y. + x: Optional[float] = None + y: Optional[float] = None @dataclass diff --git a/decnet/topology/persistence.py b/decnet/topology/persistence.py index 4c361c7f..7dcef8cb 100644 --- a/decnet/topology/persistence.py +++ b/decnet/topology/persistence.py @@ -31,6 +31,8 @@ async def persist(repo: Any, plan: GeneratedTopology) -> str: "name": lan.name, "subnet": lan.subnet, "is_dmz": lan.is_dmz, + "x": lan.x, + "y": lan.y, } ) lan_ids[lan.name] = lan_id @@ -55,6 +57,8 @@ async def persist(repo: Any, plan: GeneratedTopology) -> str: "service_config": decky.service_config, }, "ip": primary_ip, + "x": decky.x, + "y": decky.y, } ) decky_ids[decky.name] = decky_uuid diff --git a/decnet/web/db/models.py b/decnet/web/db/models.py index 3a44ea08..085ce71f 100644 --- a/decnet/web/db/models.py +++ b/decnet/web/db/models.py @@ -232,6 +232,10 @@ class LAN(SQLModel, table=True): docker_network_id: Optional[str] = Field(default=None) subnet: str is_dmz: bool = Field(default=False) + # Canvas layout coordinates (set by the web editor). Nullable so + # generator-emitted LANs don't need auto-layout at generation time. + x: Optional[float] = Field(default=None) + y: Optional[float] = Field(default=None) class TopologyDecky(SQLModel, table=True): @@ -270,6 +274,10 @@ class TopologyDecky(SQLModel, table=True): updated_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc) ) + # Canvas layout coordinates (set by the web editor). Nullable so + # generator-emitted deckies don't need auto-layout at generation time. + x: Optional[float] = Field(default=None) + y: Optional[float] = Field(default=None) class TopologyEdge(SQLModel, table=True): diff --git a/tests/topology/test_layout.py b/tests/topology/test_layout.py new file mode 100644 index 00000000..4d0c02fa --- /dev/null +++ b/tests/topology/test_layout.py @@ -0,0 +1,58 @@ +"""Layout coordinate roundtrips for LAN and TopologyDecky.""" +from __future__ import annotations + +import pytest + +from decnet.topology.config import TopologyConfig +from decnet.topology.generator import generate +from decnet.topology.persistence import hydrate, persist +from decnet.web.db.factory import get_repository + + +def _cfg(**kw) -> TopologyConfig: + base = dict( + name="layout", + depth=1, + branching_factor=1, + deckies_per_lan_min=1, + deckies_per_lan_max=1, + cross_edge_probability=0.0, + randomize_services=False, + services_explicit=["ssh"], + seed=4, + ) + base.update(kw) + return TopologyConfig(**base) + + +@pytest.fixture +async def repo(tmp_path): + r = get_repository(db_path=str(tmp_path / "layout.db")) + await r.initialize() + return r + + +@pytest.mark.anyio +async def test_coords_roundtrip_when_set(repo): + plan = generate(_cfg()) + plan.lans[0].x = 10.5 + plan.lans[0].y = -3.25 + plan.deckies[0].x = 42.0 + plan.deckies[0].y = 7.5 + tid = await persist(repo, plan) + hydrated = await hydrate(repo, tid) + lan = next(l for l in hydrated["lans"] if l["name"] == plan.lans[0].name) + assert lan["x"] == 10.5 and lan["y"] == -3.25 + d = next(d for d in hydrated["deckies"] if d["name"] == plan.deckies[0].name) + assert d["x"] == 42.0 and d["y"] == 7.5 + + +@pytest.mark.anyio +async def test_coords_default_to_none(repo): + plan = generate(_cfg()) + tid = await persist(repo, plan) + hydrated = await hydrate(repo, tid) + for lan in hydrated["lans"]: + assert lan["x"] is None and lan["y"] is None + for d in hydrated["deckies"]: + assert d["x"] is None and d["y"] is None