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
This commit is contained in:
@@ -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}
|
||||
Reference in New Issue
Block a user