feat(api): phase 3 step 2 — topology read endpoints (list/get/status/catalog)

GET /api/v1/topologies — paginated list with status filter. Extends
repo.list_topologies() to accept limit/offset and adds count_topologies()
for the total envelope field.

GET /api/v1/topologies/{id} — hydrated TopologyDetail; 404 if missing.
GET /api/v1/topologies/{id}/status-events — audit trail, limit-capped.

Catalog helpers for the phase-4 canvas UI:
* GET /topologies/services — full service catalog.
* GET /topologies/next-subnet?base=172.20 — wraps SubnetAllocator against
  reserved_subnets across non-torn-down topologies.
* GET /topologies/{id}/lans/{lan_id}/next-ip — IPAllocator pre-seeded
  with existing decky IPs in that LAN.

All read routes are viewer-or-admin. Sub-routers are included in an
order that keeps literal catalog paths (/services, /next-subnet) from
being shadowed by the /{topology_id} trie branch.
This commit is contained in:
2026-04-20 18:25:33 -04:00
parent 2379b2aeda
commit f182c98ffa
7 changed files with 413 additions and 4 deletions

View File

@@ -247,10 +247,16 @@ class BaseRepository(ABC):
raise NotImplementedError
async def list_topologies(
self, status: Optional[str] = None
self,
status: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> list[dict[str, Any]]:
raise NotImplementedError
async def count_topologies(self, status: Optional[str] = None) -> int:
raise NotImplementedError
async def update_topology_status(
self,
topology_id: str,

View File

@@ -949,11 +949,18 @@ class SQLModelRepository(BaseRepository):
return self._deserialize_json_fields(d, ("config_snapshot",))
async def list_topologies(
self, status: Optional[str] = None
self,
status: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> list[dict[str, Any]]:
statement = select(Topology).order_by(desc(Topology.created_at))
if status:
statement = statement.where(Topology.status == status)
if offset is not None:
statement = statement.offset(offset)
if limit is not None:
statement = statement.limit(limit)
async with self._session() as session:
result = await session.execute(statement)
return [
@@ -963,6 +970,15 @@ class SQLModelRepository(BaseRepository):
for r in result.scalars().all()
]
async def count_topologies(self, status: Optional[str] = None) -> int:
from sqlalchemy import func
statement = select(func.count(Topology.id))
if status:
statement = statement.where(Topology.status == status)
async with self._session() as session:
result = await session.execute(statement)
return int(result.scalar_one() or 0)
async def update_topology_status(
self,
topology_id: str,