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 @@