feat(docker): swarm-aware container views — collapse replicas, show host, surface agent-less tasks
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 45s
CI / integration (push) Successful in 2m19s
CI / publish (push) Successful in 1m3s

The container list and dashboard widget listed each swarm task as its own cryptic
row (svc.1.<taskid>) and could only show tasks on nodes that run a Steward agent.
Make both views service-centric and cluster-complete.

- swarm_view.build_swarm_services(): model-free builder that merges real container
  rows (collected local-per-node, so host_id = the node a task runs on) with the
  managers' placement (DockerSwarmService.placement_json). Where placement counts
  exceed the local rows on a node — i.e. nodes with no agent — it synthesizes
  "ghost" replicas so the replica list matches the cluster. Non-swarm containers
  pass through grouped by host.
- /rows + rows.html: a Swarm-services panel collapses each service to one block,
  every replica a host chip (status + cpu); ghosts render dashed "N · no agent".
  Non-swarm/compose containers keep the per-host table below. New Services stat.
- /widget + widget.html: same service collapse with host chips; standalone
  containers stay grouped by host.
- Unit tests for the builder (real+ghost merge, full coverage, partial agent-less,
  synthesized service, down state).

No migration — node_id, placement_json and swarm-node hostnames are already stored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
2026-06-21 12:50:15 -04:00
parent 431f804037
commit 24df8458ea
5 changed files with 345 additions and 21 deletions
+50 -20
View File
@@ -11,6 +11,7 @@ from steward.models.users import UserRole
from steward.models.hosts import Host
from steward.core.time_range import parse_range, DEFAULT_RANGE
from .dedup import dedup_by_container_id
from .swarm_view import build_swarm_services
from .models import (
DockerContainer, DockerDiskUsage, DockerEvent, DockerImage, DockerMetric,
DockerSwarmNode, DockerSwarmService,
@@ -138,7 +139,8 @@ async def _host_map(db, host_ids: set[str]) -> dict[str, Host]:
@docker_bp.get("/rows")
@require_role(UserRole.viewer)
async def rows():
"""HTMX fragment: containers grouped by host, with resource sparklines."""
"""HTMX fragment: swarm services (collapsed, cluster-complete) on top, then
non-swarm containers grouped by host with resource sparklines."""
since, range_key = parse_range(request.args.get("range"))
async with current_app.db_sessionmaker() as db:
@@ -148,12 +150,24 @@ async def rows():
DockerContainer.name,
)
)).scalars())
hosts = await _host_map(db, {c.host_id for c in containers})
services = list((await db.execute(select(DockerSwarmService))).scalars())
nodes = list((await db.execute(select(DockerSwarmNode))).scalars())
hosts = await _host_map(
db,
{c.host_id for c in containers}
| {n.host_id for n in nodes} | {s.host_id for s in services},
)
# Group by host so each container is clearly attributed to the box it
# runs on (names are only unique within a host now).
# Swarm tasks collapse into per-service rows (each replica labelled with
# its host, plus "ghost" replicas for tasks on agent-less nodes, drawn
# from the managers' placement). Non-swarm containers keep the host view.
node_hostname = {n.node_id: n.hostname for n in nodes if n.hostname}
host_name = {h.id: h.name for h in hosts.values()}
swarm_view = build_swarm_services(containers, services, node_hostname, host_name)
standalone = [c for c in containers if not c.service_name]
groups: dict[str, dict] = {}
for c in containers:
for c in standalone:
g = groups.get(c.host_id)
if g is None:
host = hosts.get(c.host_id)
@@ -176,15 +190,15 @@ async def rows():
else:
g["stopped"] += 1
# Sub-group each host's containers by compose project (or swarm service),
# preserving the running-first ordering. Containers with neither label fall
# into an unlabelled bucket rendered flat, so plain hosts look unchanged.
# Sub-group each host's containers by compose project, preserving the
# running-first ordering. Containers with no project fall into an unlabelled
# bucket rendered flat, so plain hosts look unchanged.
for g in groups.values():
subs: dict[str, dict] = {}
order: list[str] = []
for item in g["containers"]:
c = item["container"]
label = c.compose_project or c.service_name or None
label = c.compose_project or None
key = label or ""
if key not in subs:
subs[key] = {"label": label, "containers": []}
@@ -194,14 +208,16 @@ async def rows():
g["grouped"] = any(s["label"] for s in g["subgroups"])
host_groups = sorted(groups.values(), key=lambda g: g["host_name"].lower())
running = sum(g["running"] for g in host_groups)
total = len(containers)
swarm_services = swarm_view["services"]
standalone_running = sum(g["running"] for g in host_groups)
ghost_total = sum(r["count"] for s in swarm_services for r in s["replicas"] if r["ghost"])
return await render_template(
"docker/rows.html",
host_groups=host_groups,
running=running,
stopped=total - running,
total=total,
swarm_services=swarm_services,
running=standalone_running + sum(s["running"] for s in swarm_services),
stopped=len(standalone) - standalone_running,
total=len(containers) + ghost_total,
range_key=range_key,
)
@@ -221,6 +237,8 @@ async def widget():
DockerContainer.name,
)
)).scalars()))
services = list((await db.execute(select(DockerSwarmService))).scalars())
nodes = list((await db.execute(select(DockerSwarmNode))).scalars())
# Recent failures are more actionable than a static "stopped" tally. Count
# DISTINCT container names so the two managers' duplicate die/oom events
# don't double it.
@@ -228,17 +246,28 @@ async def widget():
select(func.count(func.distinct(DockerEvent.container_name)))
.where(DockerEvent.event.in_(("die", "oom")), DockerEvent.at >= since_24h)
)).scalar() or 0
hosts = await _host_map(db, {c.host_id for c in all_containers})
hosts = await _host_map(
db,
{c.host_id for c in all_containers}
| {n.host_id for n in nodes} | {s.host_id for s in services},
)
running = [c for c in all_containers if c.status == "running"]
display = all_containers if show_stopped else running
# Swarm tasks collapse into per-service rows (each replica tagged with its
# host); non-swarm containers stay grouped by host.
node_hostname = {n.node_id: n.hostname for n in nodes if n.hostname}
host_name = {h.id: h.name for h in hosts.values()}
swarm_services = build_swarm_services(all_containers, services, node_hostname, host_name)["services"]
standalone = [c for c in all_containers if not c.service_name]
running = [c for c in standalone if c.status == "running"]
display = standalone if show_stopped else running
# Group for display so multi-host fleets read clearly; single-host stays flat.
groups: dict[str, dict] = {}
for c in display:
g = groups.setdefault(c.host_id, {
"host_id": c.host_id,
"host_name": hosts[c.host_id].name if c.host_id in hosts else c.host_id,
"host_name": host_name.get(c.host_id, c.host_id),
"containers": [],
})
g["containers"].append(c)
@@ -248,9 +277,10 @@ async def widget():
"docker/widget.html",
host_groups=host_groups,
multi_host=len(host_groups) > 1,
running_count=len(running),
swarm_services=swarm_services,
running_count=len(running) + sum(s["running"] for s in swarm_services),
failed_24h=failed_24h,
total_count=len(all_containers),
total_count=len(all_containers) + len(swarm_services),
show_stopped=show_stopped,
widget_id=widget_id,
)