diff --git a/decnet/cli.py b/decnet/cli.py index bb3f7c8..ecff922 100644 --- a/decnet/cli.py +++ b/decnet/cli.py @@ -494,6 +494,66 @@ def swarm_check( console.print(table) +@swarm_app.command("deckies") +def swarm_deckies( + host: Optional[str] = typer.Option(None, "--host", help="Filter by worker name or UUID"), + state: Optional[str] = typer.Option(None, "--state", help="Filter by shard state (pending|running|failed|torn_down)"), + url: Optional[str] = typer.Option(None, "--url", help="Override swarm controller URL"), + json_out: bool = typer.Option(False, "--json", help="Emit JSON instead of a table"), +) -> None: + """List deployed deckies across the swarm with their owning worker host.""" + base = _swarmctl_base_url(url) + + host_uuid: Optional[str] = None + if host: + resp = _http_request("GET", base + "/swarm/hosts") + rows = resp.json() + match = next((r for r in rows if r.get("uuid") == host or r.get("name") == host), None) + if match is None: + console.print(f"[red]No enrolled worker matching '{host}'.[/]") + raise typer.Exit(1) + host_uuid = match["uuid"] + + query = [] + if host_uuid: + query.append(f"host_uuid={host_uuid}") + if state: + query.append(f"state={state}") + path = "/swarm/deckies" + ("?" + "&".join(query) if query else "") + + resp = _http_request("GET", base + path) + rows = resp.json() + + if json_out: + console.print_json(data=rows) + return + + if not rows: + console.print("[dim]No deckies deployed.[/]") + return + + table = Table(title="DECNET swarm deckies") + for col in ("decky", "host", "address", "state", "services"): + table.add_column(col) + for r in rows: + services = ",".join(r.get("services") or []) or "—" + state_val = r.get("state") or "pending" + colored = { + "running": f"[green]{state_val}[/]", + "failed": f"[red]{state_val}[/]", + "pending": f"[yellow]{state_val}[/]", + "torn_down": f"[dim]{state_val}[/]", + }.get(state_val, state_val) + table.add_row( + r.get("decky_name") or "", + r.get("host_name") or "", + r.get("host_address") or "", + colored, + services, + ) + console.print(table) + + @swarm_app.command("decommission") def swarm_decommission( name: Optional[str] = typer.Option(None, "--name", help="Worker hostname"), diff --git a/decnet/web/db/models.py b/decnet/web/db/models.py index cfcb70d..5590e95 100644 --- a/decnet/web/db/models.py +++ b/decnet/web/db/models.py @@ -307,6 +307,20 @@ class SwarmHostView(BaseModel): notes: Optional[str] = None +class DeckyShardView(BaseModel): + """One decky → host mapping, enriched with the host's identity for display.""" + decky_name: str + host_uuid: str + host_name: str + host_address: str + host_status: str + services: list[str] + state: str + last_error: Optional[str] = None + compose_hash: Optional[str] = None + updated_at: datetime + + class SwarmDeployRequest(BaseModel): config: DecnetConfig dry_run: bool = False diff --git a/decnet/web/router/swarm/__init__.py b/decnet/web/router/swarm/__init__.py index 744a651..2bd3193 100644 --- a/decnet/web/router/swarm/__init__.py +++ b/decnet/web/router/swarm/__init__.py @@ -15,6 +15,7 @@ from .api_deploy_swarm import router as deploy_swarm_router from .api_teardown_swarm import router as teardown_swarm_router from .api_get_swarm_health import router as get_swarm_health_router from .api_check_hosts import router as check_hosts_router +from .api_list_deckies import router as list_deckies_router swarm_router = APIRouter(prefix="/swarm") @@ -27,6 +28,7 @@ swarm_router.include_router(decommission_host_router) # Deployments swarm_router.include_router(deploy_swarm_router) swarm_router.include_router(teardown_swarm_router) +swarm_router.include_router(list_deckies_router) # Health swarm_router.include_router(get_swarm_health_router) diff --git a/decnet/web/router/swarm/api_list_deckies.py b/decnet/web/router/swarm/api_list_deckies.py new file mode 100644 index 0000000..6017a04 --- /dev/null +++ b/decnet/web/router/swarm/api_list_deckies.py @@ -0,0 +1,47 @@ +"""GET /swarm/deckies — list decky shards with their worker host's identity. + +The DeckyShard table maps decky_name → host_uuid; users want to see which +deckies are running and *where*, so we enrich each shard with the owning +host's name/address/status from SwarmHost rather than making callers do +the join themselves. +""" +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends + +from decnet.web.db.repository import BaseRepository +from decnet.web.dependencies import get_repo +from decnet.web.db.models import DeckyShardView + +router = APIRouter() + + +@router.get("/deckies", response_model=list[DeckyShardView], tags=["Swarm Deckies"]) +async def api_list_deckies( + host_uuid: Optional[str] = None, + state: Optional[str] = None, + repo: BaseRepository = Depends(get_repo), +) -> list[DeckyShardView]: + shards = await repo.list_decky_shards(host_uuid) + hosts = {h["uuid"]: h for h in await repo.list_swarm_hosts()} + + out: list[DeckyShardView] = [] + for s in shards: + if state and s.get("state") != state: + continue + host = hosts.get(s["host_uuid"], {}) + out.append(DeckyShardView( + decky_name=s["decky_name"], + host_uuid=s["host_uuid"], + host_name=host.get("name") or "", + host_address=host.get("address") or "", + host_status=host.get("status") or "unknown", + services=s.get("services") or [], + state=s.get("state") or "pending", + last_error=s.get("last_error"), + compose_hash=s.get("compose_hash"), + updated_at=s["updated_at"], + )) + return out diff --git a/tests/swarm/test_cli_swarm.py b/tests/swarm/test_cli_swarm.py index cafd05f..764d93c 100644 --- a/tests/swarm/test_cli_swarm.py +++ b/tests/swarm/test_cli_swarm.py @@ -152,6 +152,65 @@ def test_swarm_check_json_output(http_stub) -> None: assert '"decky01"' in result.output +# ------------------------------------------------------------- swarm deckies + + +def test_swarm_deckies_empty(http_stub) -> None: + http_stub.script[("GET", "/swarm/deckies")] = _FakeResp([]) + result = runner.invoke(app, ["swarm", "deckies"]) + assert result.exit_code == 0, result.output + assert "No deckies" in result.output + + +def test_swarm_deckies_renders_table(http_stub) -> None: + http_stub.script[("GET", "/swarm/deckies")] = _FakeResp([ + {"decky_name": "decky-01", "host_uuid": "u-1", "host_name": "w1", + "host_address": "10.0.0.1", "host_status": "active", + "services": ["ssh"], "state": "running", "last_error": None, + "compose_hash": None, "updated_at": "2026-04-18T00:00:00Z"}, + {"decky_name": "decky-02", "host_uuid": "u-2", "host_name": "w2", + "host_address": "10.0.0.2", "host_status": "active", + "services": ["smb", "ssh"], "state": "failed", "last_error": "boom", + "compose_hash": None, "updated_at": "2026-04-18T00:00:00Z"}, + ]) + result = runner.invoke(app, ["swarm", "deckies"]) + assert result.exit_code == 0, result.output + assert "decky-01" in result.output + assert "decky-02" in result.output + assert "w1" in result.output and "w2" in result.output + assert "smb,ssh" in result.output + + +def test_swarm_deckies_json_output(http_stub) -> None: + http_stub.script[("GET", "/swarm/deckies")] = _FakeResp([ + {"decky_name": "decky-01", "host_uuid": "u-1", "host_name": "w1", + "host_address": "10.0.0.1", "host_status": "active", + "services": ["ssh"], "state": "running", "last_error": None, + "compose_hash": None, "updated_at": "2026-04-18T00:00:00Z"}, + ]) + result = runner.invoke(app, ["swarm", "deckies", "--json"]) + assert result.exit_code == 0 + assert '"decky_name"' in result.output + assert '"decky-01"' in result.output + + +def test_swarm_deckies_filter_by_host_name_looks_up_uuid(http_stub) -> None: + http_stub.script[("GET", "/swarm/hosts")] = _FakeResp([ + {"uuid": "u-x", "name": "w1"}, + ]) + http_stub.script[("GET", "/swarm/deckies?host_uuid=u-x")] = _FakeResp([]) + result = runner.invoke(app, ["swarm", "deckies", "--host", "w1"]) + assert result.exit_code == 0 + assert http_stub[-1][1].endswith("/swarm/deckies?host_uuid=u-x") + + +def test_swarm_deckies_filter_by_state(http_stub) -> None: + http_stub.script[("GET", "/swarm/deckies?state=failed")] = _FakeResp([]) + result = runner.invoke(app, ["swarm", "deckies", "--state", "failed"]) + assert result.exit_code == 0 + assert http_stub[-1][1].endswith("/swarm/deckies?state=failed") + + # ------------------------------------------------------------- swarm decommission diff --git a/tests/swarm/test_swarm_api.py b/tests/swarm/test_swarm_api.py index 0de25c2..1174825 100644 --- a/tests/swarm/test_swarm_api.py +++ b/tests/swarm/test_swarm_api.py @@ -287,6 +287,57 @@ def test_check_marks_hosts_active(client: TestClient, stub_agent) -> None: assert one["last_heartbeat"] is not None +# ---------------------------------------------------------------- /deckies + + +def test_list_deckies_empty(client: TestClient) -> None: + resp = client.get("/swarm/deckies") + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_list_deckies_joins_host_identity(client: TestClient, repo) -> None: + import asyncio + + h1 = client.post( + "/swarm/enroll", + json={"name": "deck-host-1", "address": "10.0.0.11", "agent_port": 8765}, + ).json() + h2 = client.post( + "/swarm/enroll", + json={"name": "deck-host-2", "address": "10.0.0.12", "agent_port": 8765}, + ).json() + + async def _seed() -> None: + await repo.upsert_decky_shard({ + "decky_name": "decky-01", "host_uuid": h1["host_uuid"], + "services": ["ssh"], "state": "running", + }) + await repo.upsert_decky_shard({ + "decky_name": "decky-02", "host_uuid": h2["host_uuid"], + "services": ["smb", "ssh"], "state": "failed", "last_error": "boom", + }) + + asyncio.get_event_loop().run_until_complete(_seed()) + + rows = client.get("/swarm/deckies").json() + assert len(rows) == 2 + by_name = {r["decky_name"]: r for r in rows} + assert by_name["decky-01"]["host_name"] == "deck-host-1" + assert by_name["decky-01"]["host_address"] == "10.0.0.11" + assert by_name["decky-01"]["state"] == "running" + assert by_name["decky-02"]["services"] == ["smb", "ssh"] + assert by_name["decky-02"]["last_error"] == "boom" + + # host_uuid filter + only = client.get(f"/swarm/deckies?host_uuid={h1['host_uuid']}").json() + assert [r["decky_name"] for r in only] == ["decky-01"] + + # state filter + failed = client.get("/swarm/deckies?state=failed").json() + assert [r["decky_name"] for r in failed] == ["decky-02"] + + # ---------------------------------------------------------------- /health (root)