Files
FabledSteward/plugins/docker/swarm_nodes.py
T
bvandeusenandClaude Opus 5.5 aae61f1d2c
CI / lint (push) Successful in 5s
CI / integration (push) Failing after 1m27s
CI / unit (push) Successful in 1m40s
CI / publish (push) Skipped
feat(docker): the Swarm page is laid out by node: role, what its containers use, and the services placed on each (#5303)
#5303 "Swarm page laid out by node: role, resources and the tasks placed on each"

A verdict line first, then the nodes that are down or drained and the
services short of replicas, then one card per node. A node with a Steward
agent shows what its swarm tasks use against the machine's cores and memory,
service by service. A node without one lists Swarm's placement, by count.
The services table moved to the Docker overview, where it can be filtered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:11:46 -04:00

72 lines
3.6 KiB
Python

# plugins/docker/swarm_nodes.py
"""The Swarm page laid out by node (#5303): one card per node with its role,
whether it takes work, what its containers use against what the machine has,
and the services Swarm placed on it. Model-free, so it is unit-tested without
a database; routes.swarm() feeds it.
A node with a Steward agent reports its own containers, so its services carry
CPU and memory. A node without one is known only from the managers' placement:
its services are listed by count, with no figures.
"""
from __future__ import annotations
from .swarm_view import _placement
def node_ok(n) -> bool:
"""A node that is up and taking work."""
return n.status == "ready" and n.availability == "active"
def node_cards(nodes, services, containers, *, node_host: dict, host_names: dict,
capacity: dict, service_state: dict) -> list[dict]:
"""One card per node, a node in trouble first, then managers, then by name.
`nodes` and `services` are one swarm's deduplicated rows; `containers`
every container row (only running swarm tasks on these nodes count);
`node_host` maps a node id to the Steward host on it, `capacity` a host id
to (cores, memory bytes), `service_state` a service name to healthy,
degraded or down."""
running_on: dict[str, dict[str, dict]] = {}
for c in containers:
if not (c.node_id and c.service_name and c.status == "running"):
continue
row = running_on.setdefault(c.node_id, {}).setdefault(
c.service_name, {"name": c.service_name, "tasks": 0, "cpu_pct": 0.0, "mem_bytes": 0,
"measured": True})
row["tasks"] += 1
row["cpu_pct"] += c.cpu_pct or 0.0
row["mem_bytes"] += c.mem_usage_bytes or 0
placed: dict[str, dict[str, int]] = {}
for s in services:
for p in _placement(s):
count = int(p.get("running") or 0)
if count > 0 and p.get("node_id"):
placed.setdefault(p["node_id"], {})[s.service_name] = count
cards = []
for n in nodes:
rows = dict(running_on.get(n.node_id, {}))
for name, count in placed.get(n.node_id, {}).items():
rows.setdefault(name, {"name": name, "tasks": count, "cpu_pct": None, "mem_bytes": None,
"measured": False})
for r in rows.values():
r["state"] = service_state.get(r["name"], "healthy")
r["cores"] = r["cpu_pct"] / 100 if r["cpu_pct"] is not None else None
tasks = sorted(rows.values(), key=lambda r: (r["state"] == "healthy", -(r["cores"] or 0.0),
r["name"].lower()))
host_id = node_host.get(n.node_id)
cores, mem = capacity.get(host_id, (None, None)) if host_id else (None, None)
measured = [r for r in tasks if r["measured"]]
cards.append({
"node_id": n.node_id, "hostname": n.hostname or n.node_id[:12], "role": n.role,
"leader": n.leader, "status": n.status, "availability": n.availability, "ok": node_ok(n),
"host_id": host_id, "host_name": host_names.get(host_id) if host_id else None,
"agent": bool(running_on.get(n.node_id)) or host_id is not None,
"tasks": tasks, "task_count": sum(r["tasks"] for r in tasks),
"cores_used": sum(r["cores"] for r in measured) if measured else None,
"mem_used": sum(r["mem_bytes"] for r in measured) if measured else None,
"cores": cores, "mem": mem,
})
return sorted(cards, key=lambda c: (c["ok"], c["role"] != "manager", c["hostname"].lower()))