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
+71
View File
@@ -205,3 +205,74 @@ async def test_a_service_log_line_names_its_task(app):
lines=[], name="web", filtered=False, service=True)
assert f"1 · {TASK[:10]}</span>" in page and "&lt;b&gt;boom&lt;/b&gt;" in page
assert "one of its tasks" in empty
def _node(nid, hostname, role="worker", status="ready", availability="active", leader=False):
return NS(node_id=nid, hostname=hostname, role=role, status=status, availability=availability,
leader=leader)
def _swarm_svc(name, placement):
import json
return NS(service_name=name, placement_json=json.dumps(placement))
def _task(name, service, node, cpu, mem, status="running"):
return NS(name=name, service_name=service, node_id=node, status=status, cpu_pct=cpu,
mem_usage_bytes=mem)
def test_each_node_lists_what_runs_on_it_and_what_that_uses():
"""#5303: a node with an agent has figures; one without is Swarm's placement only."""
from plugins.docker.swarm_nodes import node_cards
nodes = [_node("n1", "docker02", role="manager", leader=True), _node("n2", "edge01"),
_node("n3", "docker03", availability="drain")]
services = [_swarm_svc("arr_radarr", [{"node_id": "n1", "running": 1}]),
_swarm_svc("web", [{"node_id": "n1", "running": 2}, {"node_id": "n2", "running": 1}]),
_swarm_svc("db", [{"node_id": "n1", "running": 1}])]
containers = [_task("arr_radarr.1.x", "arr_radarr", "n1", 150.0, 300),
_task("web.1.x", "web", "n1", 10.0, 100), _task("web.2.x", "web", "n1", 20.0, 100),
_task("web.0.old", "web", "n1", 99.0, 999, status="exited"),
_task("traefik", None, None, 5.0, 5)]
cards = node_cards(nodes, services, containers, node_host={"n1": "h1"}, host_names={"h1": "docker02"},
capacity={"h1": (8, 16 << 30)},
service_state={"arr_radarr": "healthy", "web": "healthy", "db": "down"})
assert [c["hostname"] for c in cards] == ["docker03", "docker02", "edge01"] # drained first, then managers
n1 = cards[1]
assert n1["host_id"] == "h1" and n1["leader"] and n1["agent"]
assert [t["name"] for t in n1["tasks"]] == ["db", "arr_radarr", "web"] # trouble, then busiest
assert n1["tasks"][0]["cores"] is None and not n1["tasks"][0]["measured"] # placed, not reported
web = n1["tasks"][2]
assert web["tasks"] == 2 and web["cores"] == 0.3 and web["mem_bytes"] == 200
assert n1["cores_used"] == 1.8 and n1["mem_used"] == 500 and n1["cores"] == 8
edge = cards[2]
assert not edge["agent"] and edge["cores_used"] is None
assert edge["tasks"] == [{"name": "web", "tasks": 1, "cpu_pct": None, "mem_bytes": None, "measured": False,
"state": "healthy", "cores": None}]
assert not cards[0]["ok"] and cards[0]["tasks"] == []
async def test_the_swarm_page_is_a_card_per_node(app):
from plugins.docker.swarm_nodes import node_cards
nodes = [_node("n1", "docker02", role="manager", leader=True), _node("n2", "edge01", status="down")]
cards = node_cards(nodes, [_swarm_svc("web", [{"node_id": "n2", "running": 1}])],
[_task("arr.1.x", "arr", "n1", 50.0, 1 << 30)], node_host={"n1": "h1"},
host_names={"h1": "docker02"}, capacity={"h1": (4, 8 << 30)},
service_state={"arr": "healthy", "web": "degraded"})
troubled = [{"name": "web", "running": 1, "desired": 2, "state": "degraded", "crash_looping": False,
"trouble_since": None, "last_error": {"state": "failed", "error": "no suitable node", "node": None}}]
async with app.test_request_context("/plugins/docker/swarm"):
session["user_role"] = "viewer"
page = await render_plugin_template(app, "docker", "docker/swarm.html", swarms=[{
"managers": ["docker02"], "services_total": 2, "services_healthy": 1, "troubled": troubled,
"nodes": cards}])
page = re.sub(r">\s+<", "><", " ".join(page.split()))
assert "2 need attention" in page and '<span class="tone-warn">1/2</span> services healthy' in page
problems = page[page.index('class="problem-list"'):]
assert "Node down" in problems and "edge01" in problems
assert '<a href="/plugins/docker/service/web">web</a>' in problems and "last task failed: no suitable node" in problems
assert page.index('id="node-n2"') < page.index('id="node-n1"') # the down node first
assert '<a href="/hosts/h1">docker02</a><span class="chip">leader</span>' in page
assert "Containers use <strong>0.50 cores</strong> of 4 · <strong>1.0 GiB</strong> of 8.0 GiB" in page
assert "No Steward agent" in page
assert '<a href="/plugins/docker/service/arr" title="arr">arr</a>' in page