Files
FabledSteward/plugins/docker/swarm_view.py
T
bvandeusen 24df8458ea
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 45s
CI / integration (push) Successful in 2m19s
CI / publish (push) Successful in 1m3s
feat(docker): swarm-aware container views — collapse replicas, show host, surface agent-less tasks
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
2026-06-21 12:50:15 -04:00

141 lines
5.7 KiB
Python

"""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}