feat(docker): the Swarm page is laid out by node: role, what its containers use, and the services placed on each (#5303)
CI / lint (push) Successful in 5s
CI / integration (push) Failing after 1m27s
CI / unit (push) Successful in 1m40s
CI / publish (push) Skipped

#5303 "Swarm page laid out by node: role, resources and the tasks placed on each"

A verdict line first, then the nodes that are down or drained and the
services short of replicas, then one card per node. A node with a Steward
agent shows what its swarm tasks use against the machine's cores and memory,
service by service. A node without one lists Swarm's placement, by count.
The services table moved to the Docker overview, where it can be filtered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 20:11:46 -04:00
co-authored by Claude Opus 5.5
parent a775882bd4
commit aae61f1d2c
5 changed files with 264 additions and 87 deletions
+25 -9
View File
@@ -20,6 +20,7 @@ from .swarm_view import (
build_swarm_services, failing_services, parse_tasks, replaced_tasks, services_problems_first,
task_summary, trouble_since,
)
from .swarm_nodes import node_cards
from .overview import (
DOCKER_SORTS, DOCKER_STATES, NO_STACK, container_row, filter_rows, service_row, sort_rows,
stack_of,
@@ -497,7 +498,9 @@ async def container_detail(host_id: str, name: str):
@docker_bp.get("/swarm")
@require_role(UserRole.viewer)
async def swarm():
"""Swarm topology page: services with replica health, nodes, placement.
"""The Swarm page, laid out by node (#5303): each node's role, whether it
takes work, what its containers use against what the machine has, and the
services placed on it; services short of replicas first.
Swarm services/nodes are cluster-global — every manager's API returns the
same list — but each manager reports them independently (rows keyed by
@@ -515,6 +518,20 @@ async def swarm():
DockerSwarmNode.role, DockerSwarmNode.hostname)
)).scalars())
hosts = await docker_svc.host_map(db, {s.host_id for s in services} | {n.host_id for n in nodes})
containers = await docker_svc.list_containers(db)
# The Steward host on each node: the one whose agent reports its
# containers, else the host named as the node is.
node_host = {c.node_id: c.host_id for c in containers if c.node_id}
unmatched = {(n.hostname or "").lower() for n in nodes if n.node_id not in node_host} - {""}
if unmatched:
by_name = {h.name.lower(): h for h in (await db.execute(
select(Host).where(func.lower(Host.name).in_(unmatched)))).scalars()}
for n in nodes:
h = by_name.get((n.hostname or "").lower())
if n.node_id not in node_host and h is not None:
node_host[n.node_id] = h.id
hosts.update(await docker_svc.host_map(db, set(node_host.values()) - set(hosts)))
capacity = await docker_svc.host_capacity(db, set(node_host.values()))
# ── Group reporting managers into swarms by shared node membership ──────────
manager_ids = {s.host_id for s in services} | {n.host_id for n in nodes}
@@ -586,17 +603,16 @@ async def swarm():
**summary,
"trouble_since": trouble_since(tasks, state, summary["crash_looping"], now),
})
# Problems first: a node that is down or not taking work, then by role.
node_rows = sorted(
node_by_id.values(), key=lambda n: (
n.status == "ready" and n.availability == "active",
n.role, (n.hostname or n.node_id).lower())
)
manager_names = sorted(hosts[m].name if m in hosts else m for m in members)
swarms.append({
"managers": manager_names,
"services": services_problems_first(svc_dicts),
"nodes": node_rows,
"services_total": len(svc_dicts),
"services_healthy": sum(d["state"] == "healthy" for d in svc_dicts),
"troubled": failing_services(svc_dicts),
"nodes": node_cards(
node_by_id.values(), svc_by_name.values(), containers, node_host=node_host,
host_names={h.id: h.name for h in hosts.values()}, capacity=capacity,
service_state={d["name"]: d["state"] for d in svc_dicts}),
})
swarms.sort(key=lambda g: g["managers"][0].lower() if g["managers"] else "")