diff --git a/plugins/docker/routes.py b/plugins/docker/routes.py index 3dc9f55..1cfc455 100644 --- a/plugins/docker/routes.py +++ b/plugins/docker/routes.py @@ -361,8 +361,12 @@ async def container_detail(host_id: str, name: str): async def swarm(): """Swarm topology page: services with replica health, nodes, placement. - Swarm tables are manager-scoped (each manager reports its own view), so we - group by reporting host — usually one group for a single-manager cluster. + Swarm services/nodes are cluster-global — every manager's API returns the + same list — but each manager reports them independently (rows keyed by + host_id). So we group the reporting managers into swarms (managers that share + any node_id are the same cluster) and dedup within each: one row per service + (by name) and per node (by node_id), keeping the freshest. Without this, two + managers in one cluster list every service/node twice. """ async with current_app.db_sessionmaker() as db: services = list((await db.execute( @@ -374,42 +378,79 @@ async def swarm(): )).scalars()) hosts = await _host_map(db, {s.host_id for s in services} | {n.host_id for n in nodes}) - # node_id → hostname per reporting host, so placement reads as names not ids. - node_names: dict[tuple[str, str], str] = { - (n.host_id, n.node_id): (n.hostname or n.node_id) for n in nodes - } - - groups: dict[str, dict] = {} - for s in services: - g = groups.setdefault(s.host_id, {"services": [], "nodes": []}) - placement = [] - for p in (json.loads(s.placement_json) if s.placement_json else []): - nid = p.get("node_id", "") - placement.append({ - "node": node_names.get((s.host_id, nid), nid[:12] or "?"), - "running": p.get("running", 0), - }) - g["services"].append({ - "name": s.service_name, "mode": s.mode, - "running": s.running, "desired": s.desired, "image": s.image, - "healthy": s.running >= s.desired and s.desired > 0, - "degraded": 0 < s.running < s.desired, - "down": s.running == 0 and s.desired > 0, - "placement": placement, - }) + # ── Group reporting managers into swarms by shared node membership ────────── + manager_ids = {s.host_id for s in services} | {n.host_id for n in nodes} + nodes_by_mgr: dict[str, set[str]] = {} for n in nodes: - g = groups.setdefault(n.host_id, {"services": [], "nodes": []}) - g["nodes"].append(n) + nodes_by_mgr.setdefault(n.host_id, set()).add(n.node_id) - host_groups = [ - {"host_id": hid, - "host_name": hosts[hid].name if hid in hosts else hid, - "host": hosts.get(hid), - **g} - for hid, g in groups.items() - ] - host_groups.sort(key=lambda g: g["host_name"].lower()) - return await render_template("docker/swarm.html", host_groups=host_groups) + parent = {m: m for m in manager_ids} + + def _find(x: str) -> str: + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + + mgrs = list(manager_ids) + for i in range(len(mgrs)): + for j in range(i + 1, len(mgrs)): + a, b = mgrs[i], mgrs[j] + if nodes_by_mgr.get(a) and nodes_by_mgr.get(b) and (nodes_by_mgr[a] & nodes_by_mgr[b]): + parent[_find(a)] = _find(b) + + swarm_members: dict[str, set[str]] = {} + for m in manager_ids: + swarm_members.setdefault(_find(m), set()).add(m) + + # ── Dedup services (by name) + nodes (by node_id) within each swarm ───────── + swarms = [] + for members in swarm_members.values(): + svc_by_name: dict[str, DockerSwarmService] = {} + for s in services: + if s.host_id in members and ( + s.service_name not in svc_by_name + or s.updated_at > svc_by_name[s.service_name].updated_at + ): + svc_by_name[s.service_name] = s + node_by_id: dict[str, DockerSwarmNode] = {} + for n in nodes: + if n.host_id in members and ( + n.node_id not in node_by_id + or n.updated_at > node_by_id[n.node_id].updated_at + ): + node_by_id[n.node_id] = n + + node_names = {nid: (nrow.hostname or nid) for nid, nrow in node_by_id.items()} + svc_dicts = [] + for s in sorted(svc_by_name.values(), key=lambda s: s.service_name): + placement = [] + for p in (json.loads(s.placement_json) if s.placement_json else []): + nid = p.get("node_id", "") + placement.append({ + "node": node_names.get(nid, nid[:12] or "?"), + "running": p.get("running", 0), + }) + svc_dicts.append({ + "name": s.service_name, "mode": s.mode, + "running": s.running, "desired": s.desired, "image": s.image, + "healthy": s.running >= s.desired and s.desired > 0, + "degraded": 0 < s.running < s.desired, + "down": s.running == 0 and s.desired > 0, + "placement": placement, + }) + node_rows = sorted( + node_by_id.values(), key=lambda n: (n.role, (n.hostname or n.node_id).lower()) + ) + manager_names = sorted(hosts[m].name if m in hosts else m for m in members) + swarms.append({ + "managers": manager_names, + "services": svc_dicts, + "nodes": node_rows, + }) + + swarms.sort(key=lambda g: g["managers"][0].lower() if g["managers"] else "") + return await render_template("docker/swarm.html", swarms=swarms) @docker_bp.get("/disk") diff --git a/plugins/docker/templates/docker/swarm.html b/plugins/docker/templates/docker/swarm.html index 35f3caf..7fdbf44 100644 --- a/plugins/docker/templates/docker/swarm.html +++ b/plugins/docker/templates/docker/swarm.html @@ -7,12 +7,14 @@

Swarm

-{% if host_groups %} -{% for g in host_groups %} +{% if swarms %} +{% for g in swarms %}
-
- {{ g.host_name }} - manager view +
+ Swarm + + reported by {{ g.managers | length }} manager{{ 's' if g.managers | length != 1 }} · {{ g.managers | join(", ") }} +
{# ── Services ─────────────────────────────────────────────────────────── #} @@ -39,7 +41,7 @@ {% else %} - No services on this manager. + No services in this swarm. {% endfor %}