Perf: serve stacks list from one Docker call; registry host → 10.10.6.10 (0.21.6)

Stacks overview and detail were doing an N+1 inspect storm: list_stacks
called containers_for_stack AND compute_status (which re-fetched) per
stack, and containers.list(sparse=False) full-inspects every container
plus c.image triggered an image-inspect each. For N stacks that was
~2N*(1 list + M inspects + M image-inspects) sequential socket round
trips (~1s for just 2 stacks, growing linearly).

- compose_service.stack_status_summaries(): one low-level
  api.containers(all=True) summary call grouped by compose project label
  → whole list served in a single Docker round-trip (~10x faster).
- compute_status() takes optional pre-fetched containers; get_stack and
  _stack_summary no longer double-fetch.
- containers_for_stack() reads the image name from the inspect it already
  has instead of c.image (drops the per-container image-inspect).
- Same batching applied to the agent's stack list/detail.

Also: Forgejo (registry + git) moved to 10.10.6.10:3020 — updated image
refs in docker-compose.yml, agent/Dockerfile, agent/docker-compose.yml.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-08 18:53:12 +00:00
co-authored by Claude Opus 4.8
parent cf046648bd
commit 5b59f5e8f9
8 changed files with 120 additions and 52 deletions
+49 -19
View File
@@ -184,7 +184,10 @@ def containers_for_stack(stack_id: str) -> list[ContainerInfo]:
id=c.id,
name=c.name,
service=c.labels.get(SERVICE_LABEL, c.name),
image=(c.image.tags[0] if c.image and c.image.tags else attrs.get("Config", {}).get("Image", "")),
# Use the configured image name from the inspect we already have;
# reading c.image.tags would trigger a separate image-inspect call
# per container.
image=attrs.get("Config", {}).get("Image", "") or attrs.get("Image", ""),
state=state.get("Status", c.status),
status=attrs.get("State", {}).get("Status", c.status),
health=health,
@@ -207,21 +210,14 @@ def clear_busy(stack_id: str) -> None:
_BUSY.discard(stack_id)
def compute_status(stack_id: str) -> str:
if stack_id in _BUSY:
return "updating"
try:
containers = containers_for_stack(stack_id)
except DockerError:
return "unknown"
if not containers:
def is_busy(stack_id: str) -> bool:
return stack_id in _BUSY
def _status_from_states(states: list[str]) -> str:
if not states:
return "stopped"
states = [c.state for c in containers]
if any(s in ("dead",) for s in states):
return "error"
if any(
c.state == "exited" and _nonzero_exit(c) for c in containers
):
if any(s == "dead" for s in states):
return "error"
running = [s for s in states if s == "running"]
if len(running) == len(states):
@@ -231,10 +227,44 @@ def compute_status(stack_id: str) -> str:
return "stopped"
def _nonzero_exit(c: ContainerInfo) -> bool:
# We only have the textual state here; treat plain "exited" as stopped, not
# an error unless health says otherwise. Detailed exit codes handled in detail view.
return False
def compute_status(stack_id: str, containers: Optional[list[ContainerInfo]] = None) -> str:
"""Status for one stack. Pass already-fetched ``containers`` to avoid a
redundant Docker round-trip (the detail view already has them)."""
if stack_id in _BUSY:
return "updating"
try:
if containers is None:
containers = containers_for_stack(stack_id)
except DockerError:
return "unknown"
return _status_from_states([c.state for c in containers])
def stack_status_summaries() -> dict[str, dict]:
"""One cheap Docker call → ``{project: {status, total, running}}`` for every
stack at once.
Uses the low-level container *summary* list (``GET /containers/json``, no
per-container inspect) grouped by the compose project label. This is far
cheaper than calling :func:`containers_for_stack` (which full-inspects every
container) once per stack for the overview — turning the stacks list from
O(stacks × containers) Docker round-trips into a single one.
"""
client = get_client()
raw = safe_call(client.api.containers, all=True)
by_project: dict[str, list[str]] = {}
for c in raw:
project = (c.get("Labels") or {}).get(COMPOSE_LABEL)
if project:
by_project.setdefault(project, []).append(c.get("State", ""))
return {
project: {
"status": _status_from_states(states),
"total": len(states),
"running": sum(1 for s in states if s == "running"),
}
for project, states in by_project.items()
}
# --------------------------------------------------------------------------- #