Phase 13: multi-host networks & images (0.14.0)

Networks and Images are now per-host, rendered as a section for the local host
plus one per registered agent (like the Stacks page).

- agent_app.py: new /agent/networks (list/inspect/containers/connect/disconnect/
  create/delete/prune) and /agent/images (list/updates/check), reusing
  network_service and a new image_service; DockerError mapped to HTTP status
  (forbidden -> 400 so the proxy doesn't treat it as a token failure).
- routers/agents.py: proxy routes at /api/agents/{id}/networks/* and
  /api/agents/{id}/images/*, audit-logging mutations.
- services/image_service.py: extracted the image-listing logic so the central
  router and the agent share it.
- Frontend: networksApi/imagesApi take an optional agentId; Networks/Images
  pages render NetworksSection/ImagesSection per host with a shared HostHeader.
  Remote "Prune unused" networks resolves the address-pool-exhaustion deploy
  error from the UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-08 11:52:53 +00:00
co-authored by Claude Opus 4.8
parent 4bc0fb8901
commit 012614f5fb
13 changed files with 683 additions and 212 deletions
+169
View File
@@ -17,6 +17,7 @@ from models.agent import Agent, AgentCreate, AgentRead, AgentUpdate
from models.backup_destination import BackupDestination
from models.stack import StackCreate, StackUpdate
from models.user import User
from routers.networks import ContainerRef, NetworkCreate
from services import (
agent_service,
audit_service,
@@ -473,3 +474,171 @@ async def agent_restore_from(
ip=_ip(request),
)
return result
# --------------------------------------------------------------------------- #
# Networks (proxied)
# --------------------------------------------------------------------------- #
@router.get("/{agent_id}/networks")
async def agent_networks(
agent_id: int,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> list[dict]:
agent = _get_or_404(session, agent_id)
return await _proxy(session, agent, "GET", "/agent/networks") or []
@router.get("/{agent_id}/networks/{network_id}")
async def agent_network_inspect(
agent_id: int,
network_id: str,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
agent = _get_or_404(session, agent_id)
return await _proxy(session, agent, "GET", f"/agent/networks/{network_id}")
@router.get("/{agent_id}/networks/{network_id}/containers")
async def agent_network_containers(
agent_id: int,
network_id: str,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> list[dict]:
agent = _get_or_404(session, agent_id)
return await _proxy(session, agent, "GET", f"/agent/networks/{network_id}/containers") or []
@router.post("/{agent_id}/networks/prune")
async def agent_network_prune(
agent_id: int,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
agent = _get_or_404(session, agent_id)
result = await _proxy(session, agent, "POST", "/agent/networks/prune")
audit_service.record(
session, user=user.username, action="agent.network.prune", target=agent.name,
detail=str(result.get("NetworksDeleted") or []), ip=_ip(request),
)
return result
@router.post("/{agent_id}/networks", status_code=201)
async def agent_network_create(
agent_id: int,
body: NetworkCreate,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
agent = _get_or_404(session, agent_id)
result = await _proxy(session, agent, "POST", "/agent/networks", json=body.model_dump())
audit_service.record(
session, user=user.username, action="agent.network.create",
target=f"{agent.name}/{body.name}", ip=_ip(request),
)
return result
@router.delete("/{agent_id}/networks/{network_id}")
async def agent_network_delete(
agent_id: int,
network_id: str,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
agent = _get_or_404(session, agent_id)
result = await _proxy(session, agent, "DELETE", f"/agent/networks/{network_id}")
audit_service.record(
session, user=user.username, action="agent.network.delete",
target=f"{agent.name}/{network_id}", ip=_ip(request),
)
return result
@router.post("/{agent_id}/networks/{network_id}/connect")
async def agent_network_connect(
agent_id: int,
network_id: str,
body: ContainerRef,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
agent = _get_or_404(session, agent_id)
result = await _proxy(
session, agent, "POST", f"/agent/networks/{network_id}/connect", json=body.model_dump()
)
audit_service.record(
session, user=user.username, action="agent.network.connect",
target=f"{agent.name}/{network_id}", detail=body.container, ip=_ip(request),
)
return result
@router.post("/{agent_id}/networks/{network_id}/disconnect")
async def agent_network_disconnect(
agent_id: int,
network_id: str,
body: ContainerRef,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
agent = _get_or_404(session, agent_id)
result = await _proxy(
session, agent, "POST", f"/agent/networks/{network_id}/disconnect", json=body.model_dump()
)
audit_service.record(
session, user=user.username, action="agent.network.disconnect",
target=f"{agent.name}/{network_id}", detail=body.container, ip=_ip(request),
)
return result
# --------------------------------------------------------------------------- #
# Images (proxied)
# --------------------------------------------------------------------------- #
@router.get("/{agent_id}/images")
async def agent_images(
agent_id: int,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> list[dict]:
agent = _get_or_404(session, agent_id)
return await _proxy(session, agent, "GET", "/agent/images") or []
@router.get("/{agent_id}/images/updates")
async def agent_image_updates(
agent_id: int,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
agent = _get_or_404(session, agent_id)
return await _proxy(session, agent, "GET", "/agent/images/updates")
@router.post("/{agent_id}/images/check")
async def agent_image_check(
agent_id: int,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
agent = _get_or_404(session, agent_id)
result = await _proxy(session, agent, "POST", "/agent/images/check")
audit_service.record(
session, user=user.username, action="agent.image.check", target=agent.name,
ip=_ip(request),
)
return result
+2 -41
View File
@@ -4,54 +4,15 @@ from __future__ import annotations
from fastapi import APIRouter, Depends
from auth import get_current_user, require_admin
from docker_client import DockerError, get_client, safe_call
from models.user import User
from services import update_service
from services import image_service, update_service
router = APIRouter(prefix="/api/images", tags=["images"])
COMPOSE_PROJECT_LABEL = "com.docker.compose.project"
@router.get("")
def list_images(_user: User = Depends(get_current_user)) -> list[dict]:
try:
client = get_client()
images = safe_call(client.images.list)
containers = safe_call(client.containers.list, all=True)
except DockerError:
return []
# Map image ref -> stacks using it.
usage: dict[str, set[str]] = {}
for c in containers:
ref = c.attrs.get("Config", {}).get("Image")
stack = c.labels.get(COMPOSE_PROJECT_LABEL)
if ref:
usage.setdefault(ref, set())
if stack:
usage[ref].add(stack)
cache = update_service.get_cache()
result = []
for img in images:
tags = img.tags or []
if not tags:
continue
for tag in tags:
upd = cache.get(tag)
result.append(
{
"id": img.short_id,
"tag": tag,
"size": img.attrs.get("Size", 0),
"created": img.attrs.get("Created"),
"stacks": sorted(usage.get(tag, set())),
"update": upd,
}
)
result.sort(key=lambda r: r["tag"])
return result
return image_service.list_images()
@router.get("/updates")