24df8458ea
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
80 lines
3.7 KiB
Python
80 lines
3.7 KiB
Python
"""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"
|