diff --git a/plugins/docker/routes.py b/plugins/docker/routes.py index 37ab55c..0736a05 100644 --- a/plugins/docker/routes.py +++ b/plugins/docker/routes.py @@ -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, ) diff --git a/plugins/docker/swarm_view.py b/plugins/docker/swarm_view.py new file mode 100644 index 0000000..cfffa80 --- /dev/null +++ b/plugins/docker/swarm_view.py @@ -0,0 +1,140 @@ +"""Model-free builder for the swarm-aware container view. + +Merges two sources into one service-grouped, cluster-complete picture: + • docker_containers — real container rows, collected LOCAL-per-node, so each + carries the host (= node) it runs on plus its swarm service/node labels. + • DockerSwarmService.placement_json — the managers' cluster-wide task→node + counts, which include tasks on nodes that run no Steward agent (and so have + no container row of their own). + +For every swarm service we list the real replicas we have detail for, then add +"ghost" replicas for the remaining placement count on each node (agent-less +nodes). Non-swarm containers pass through grouped by host, unchanged. + +Kept model-free (no ORM imports) so it's unit-testable and safe to import in +tests without the plugin-loader "table already defined" gotcha. +""" +from __future__ import annotations + +import json + + +def _service_state(running: int, desired: int) -> str: + if desired > 0 and running >= desired: + return "healthy" + if running > 0: + return "degraded" + return "down" + + +def _placement(service) -> list[dict]: + raw = getattr(service, "placement_json", None) or "[]" + try: + data = json.loads(raw) + except (ValueError, TypeError): + return [] + return [p for p in data if isinstance(p, dict)] + + +def build_swarm_services(containers, services, node_hostname: dict, host_name: dict) -> dict: + """Return {"services": [...], "standalone": [...]}. + + services[i] = {name, mode, desired, running, state, replicas:[...]} + real replica = {ghost:False, host, host_id, name, status, cpu_pct, mem_pct, + health, restart_count} + ghost replica = {ghost:True, host, count} # placement with no local row + standalone[i] = {host_id, host, containers:[...]} # non-swarm, by host + """ + containers = list(containers) + swarm_cs = [c for c in containers if getattr(c, "service_name", None)] + standalone_cs = [c for c in containers if not getattr(c, "service_name", None)] + + # Dedup service rows by name (every manager reports the same cluster-global + # set); keep the first — placement is identical across managers. + svc_by_name: dict[str, object] = {} + for s in services: + name = getattr(s, "service_name", None) + if name and name not in svc_by_name: + svc_by_name[name] = s + + # Real containers grouped by service name. + cs_by_service: dict[str, list] = {} + for c in swarm_cs: + cs_by_service.setdefault(c.service_name, []).append(c) + + def _host_of(c): + return (host_name.get(getattr(c, "host_id", None)) + or node_hostname.get(getattr(c, "node_id", None)) + or getattr(c, "host_id", None) or "?") + + out_services = [] + for name in sorted(set(svc_by_name) | set(cs_by_service)): + svc = svc_by_name.get(name) + reals = cs_by_service.get(name, []) + + replicas = [] + for c in sorted(reals, key=lambda c: (_host_of(c).lower(), c.name)): + replicas.append({ + "ghost": False, + "host": _host_of(c), + "host_id": getattr(c, "host_id", None), + "name": c.name, + "status": getattr(c, "status", "unknown"), + "cpu_pct": getattr(c, "cpu_pct", None), + "mem_pct": getattr(c, "mem_pct", None), + "health": getattr(c, "health", None), + "restart_count": getattr(c, "restart_count", 0) or 0, + }) + + # Real running replicas per node, to subtract from placement. + real_running_by_node: dict[str, int] = {} + for c in reals: + if (getattr(c, "status", "") or "").lower() == "running": + nid = getattr(c, "node_id", None) + if nid: + real_running_by_node[nid] = real_running_by_node.get(nid, 0) + 1 + + ghosts = [] + for p in _placement(svc) if svc is not None else []: + nid = p.get("node_id", "") + placed = int(p.get("running", 0) or 0) + ghost = placed - real_running_by_node.get(nid, 0) + if ghost > 0: + ghosts.append({ + "ghost": True, + "host": node_hostname.get(nid) or (nid[:12] if nid else "?"), + "count": ghost, + }) + ghosts.sort(key=lambda g: g["host"].lower()) + replicas.extend(ghosts) + + running = getattr(svc, "running", None) if svc is not None else None + desired = getattr(svc, "desired", None) if svc is not None else None + # No service row (manager didn't report it): infer from what we can see. + if running is None: + running = sum(1 for r in replicas + if (not r["ghost"] and (r["status"] or "").lower() == "running")) + if desired is None: + desired = len(replicas) + + out_services.append({ + "name": name, + "mode": getattr(svc, "mode", "replicated") if svc is not None else "replicated", + "running": running, + "desired": desired, + "state": _service_state(int(running or 0), int(desired or 0)), + "replicas": replicas, + }) + + # Non-swarm containers grouped by host, running-first order preserved. + groups: dict[str, dict] = {} + for c in standalone_cs: + hid = getattr(c, "host_id", None) + g = groups.get(hid) + if g is None: + g = groups[hid] = {"host_id": hid, "host": host_name.get(hid) or hid or "?", + "containers": []} + g["containers"].append(c) + standalone = sorted(groups.values(), key=lambda g: g["host"].lower()) + + return {"services": out_services, "standalone": standalone} diff --git a/plugins/docker/templates/docker/rows.html b/plugins/docker/templates/docker/rows.html index 9d3b058..1d04717 100644 --- a/plugins/docker/templates/docker/rows.html +++ b/plugins/docker/templates/docker/rows.html @@ -14,12 +14,61 @@
Total
{{ total }} + {% if swarm_services %} +
+
Services
+ {{ swarm_services | length }} +
+ {% endif %}
Hosts
{{ host_groups | length }}
+{# ── Swarm services: collapsed per-service, each replica labelled with its host. + Ghost replicas (tasks on nodes with no Steward agent) come from the managers' + placement data so the picture is cluster-complete. ──────────────────────── #} +{% if swarm_services %} +
+
+ Swarm services + Topology → +
+
+ {% for s in swarm_services %} +
+
+ + {{ s.name }} + {{ s.mode }} + + {{ s.running }}/{{ s.desired }} running + +
+
+ {% for r in s.replicas %} + {% if r.ghost %} +
+ + {{ r.host }} + {{ r.count }} · no agent +
+ {% else %} +
+ + {{ r.host }} + {% if r.cpu_pct is not none %}{{ "%.0f" | format(r.cpu_pct) }}%{% endif %} +
+ {% endif %} + {% endfor %} +
+
+ {% endfor %} +
+
+{% endif %} + {# ── Container tables, one per host ───────────────────────────────────────── #} {% if host_groups %} {% for g in host_groups %} @@ -114,7 +163,7 @@ {% endfor %} -{% else %} +{% elif not swarm_services %}
No containers reported yet. Containers are collected by the Steward host agent — diff --git a/plugins/docker/templates/docker/widget.html b/plugins/docker/templates/docker/widget.html index 01c4e80..ad86814 100644 --- a/plugins/docker/templates/docker/widget.html +++ b/plugins/docker/templates/docker/widget.html @@ -14,6 +14,32 @@
+{# Swarm services — collapsed, each with its replicas' host chips (dashed = a + node with no Steward agent, count-only from placement). #} +{% if swarm_services %} +
Swarm services
+
+ {% for s in swarm_services %} +
+
+ + {{ s.name }} + {{ s.running }}/{{ s.desired }} +
+
+ {% for r in s.replicas %} + {% if r.ghost %} + {{ r.host }} ×{{ r.count }} + {% else %} + {{ r.host }} + {% endif %} + {% endfor %} +
+
+ {% endfor %} +
+{% endif %} + {% for g in host_groups %} {% if multi_host %}
{{ g.host_name }}
diff --git a/tests/plugins/docker/test_swarm_view.py b/tests/plugins/docker/test_swarm_view.py new file mode 100644 index 0000000..19a7d12 --- /dev/null +++ b/tests/plugins/docker/test_swarm_view.py @@ -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"