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
+79
View File
@@ -0,0 +1,79 @@
"""Unit tests for the swarm-aware container view builder (pure, no DB/ORM)."""
from types import SimpleNamespace
from plugins.docker.swarm_view import build_swarm_services
NODE_HOSTNAME = {"nA": "docker01", "nB": "docker02", "nC": "docker03"}
HOST_NAME = {"h1": "docker01", "h2": "docker02"} # agents run on docker01/02 only
def _c(host_id, name, *, service=None, node=None, status="running", cpu=1.0):
return SimpleNamespace(host_id=host_id, name=name, service_name=service,
node_id=node, status=status, cpu_pct=cpu, mem_pct=2.0,
health=None, restart_count=0, container_id=name)
def _svc(name, running, desired, placement, mode="replicated"):
import json
return SimpleNamespace(service_name=name, running=running, desired=desired,
mode=mode, placement_json=json.dumps(placement))
def test_real_replicas_plus_agentless_ghost():
containers = [
_c("h1", "web.1.aaa", service="web", node="nA"),
_c("h2", "web.2.bbb", service="web", node="nB"),
_c("h1", "db", node=None), # standalone, non-swarm
]
services = [_svc("web", running=3, desired=3,
placement=[{"node_id": "nA", "running": 1},
{"node_id": "nB", "running": 1},
{"node_id": "nC", "running": 1}])] # nC has no agent
view = build_swarm_services(containers, services, NODE_HOSTNAME, HOST_NAME)
assert len(view["services"]) == 1
svc = view["services"][0]
assert svc["name"] == "web" and svc["state"] == "healthy"
reals = [r for r in svc["replicas"] if not r["ghost"]]
ghosts = [r for r in svc["replicas"] if r["ghost"]]
assert sorted(r["host"] for r in reals) == ["docker01", "docker02"]
assert len(ghosts) == 1 and ghosts[0]["host"] == "docker03" and ghosts[0]["count"] == 1
# The non-swarm container is grouped by host, untouched.
assert len(view["standalone"]) == 1
g = view["standalone"][0]
assert g["host"] == "docker01" and [c.name for c in g["containers"]] == ["db"]
def test_placement_fully_covered_has_no_ghosts():
containers = [_c("h1", "api.1.x", service="api", node="nA"),
_c("h2", "api.2.y", service="api", node="nB")]
services = [_svc("api", 2, 2, [{"node_id": "nA", "running": 1},
{"node_id": "nB", "running": 1}])]
view = build_swarm_services(containers, services, NODE_HOSTNAME, HOST_NAME)
assert all(not r["ghost"] for r in view["services"][0]["replicas"])
def test_multiple_tasks_same_node_partially_agentless():
# Placement says 2 running on nA, but we only have 1 local row → 1 ghost on nA.
containers = [_c("h1", "cache.1.x", service="cache", node="nA")]
services = [_svc("cache", 2, 2, [{"node_id": "nA", "running": 2}])]
view = build_swarm_services(containers, services, NODE_HOSTNAME, HOST_NAME)
ghosts = [r for r in view["services"][0]["replicas"] if r["ghost"]]
assert len(ghosts) == 1 and ghosts[0]["count"] == 1 and ghosts[0]["host"] == "docker01"
def test_service_without_a_service_row_is_synthesised():
# Container labeled with a service, but no manager reported the service row.
containers = [_c("h1", "orphan.1.x", service="orphan", node="nA")]
view = build_swarm_services(containers, [], NODE_HOSTNAME, HOST_NAME)
svc = view["services"][0]
assert svc["name"] == "orphan" and svc["running"] == 1 and svc["desired"] == 1
assert all(not r["ghost"] for r in svc["replicas"])
def test_down_service_state():
services = [_svc("idle", 0, 2, [])]
view = build_swarm_services([], services, NODE_HOSTNAME, HOST_NAME)
assert view["services"][0]["state"] == "down"