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
141 lines
5.7 KiB
Python
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}
|