diff --git a/plugins/docker/routes.py b/plugins/docker/routes.py index 95eb331..a9e1050 100644 --- a/plugins/docker/routes.py +++ b/plugins/docker/routes.py @@ -20,6 +20,7 @@ from .swarm_view import ( build_swarm_services, failing_services, parse_tasks, replaced_tasks, services_problems_first, task_summary, trouble_since, ) +from .swarm_nodes import node_cards from .overview import ( DOCKER_SORTS, DOCKER_STATES, NO_STACK, container_row, filter_rows, service_row, sort_rows, stack_of, @@ -497,7 +498,9 @@ async def container_detail(host_id: str, name: str): @docker_bp.get("/swarm") @require_role(UserRole.viewer) async def swarm(): - """Swarm topology page: services with replica health, nodes, placement. + """The Swarm page, laid out by node (#5303): each node's role, whether it + takes work, what its containers use against what the machine has, and the + services placed on it; services short of replicas first. Swarm services/nodes are cluster-global — every manager's API returns the same list — but each manager reports them independently (rows keyed by @@ -515,6 +518,20 @@ async def swarm(): DockerSwarmNode.role, DockerSwarmNode.hostname) )).scalars()) hosts = await docker_svc.host_map(db, {s.host_id for s in services} | {n.host_id for n in nodes}) + containers = await docker_svc.list_containers(db) + # The Steward host on each node: the one whose agent reports its + # containers, else the host named as the node is. + node_host = {c.node_id: c.host_id for c in containers if c.node_id} + unmatched = {(n.hostname or "").lower() for n in nodes if n.node_id not in node_host} - {""} + if unmatched: + by_name = {h.name.lower(): h for h in (await db.execute( + select(Host).where(func.lower(Host.name).in_(unmatched)))).scalars()} + for n in nodes: + h = by_name.get((n.hostname or "").lower()) + if n.node_id not in node_host and h is not None: + node_host[n.node_id] = h.id + hosts.update(await docker_svc.host_map(db, set(node_host.values()) - set(hosts))) + capacity = await docker_svc.host_capacity(db, set(node_host.values())) # ── 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} @@ -586,17 +603,16 @@ async def swarm(): **summary, "trouble_since": trouble_since(tasks, state, summary["crash_looping"], now), }) - # Problems first: a node that is down or not taking work, then by role. - node_rows = sorted( - node_by_id.values(), key=lambda n: ( - n.status == "ready" and n.availability == "active", - 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": services_problems_first(svc_dicts), - "nodes": node_rows, + "services_total": len(svc_dicts), + "services_healthy": sum(d["state"] == "healthy" for d in svc_dicts), + "troubled": failing_services(svc_dicts), + "nodes": node_cards( + node_by_id.values(), svc_by_name.values(), containers, node_host=node_host, + host_names={h.id: h.name for h in hosts.values()}, capacity=capacity, + service_state={d["name"]: d["state"] for d in svc_dicts}), }) swarms.sort(key=lambda g: g["managers"][0].lower() if g["managers"] else "") diff --git a/plugins/docker/swarm_nodes.py b/plugins/docker/swarm_nodes.py new file mode 100644 index 0000000..c8f7a75 --- /dev/null +++ b/plugins/docker/swarm_nodes.py @@ -0,0 +1,71 @@ +# 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())) diff --git a/plugins/docker/templates/docker/swarm.html b/plugins/docker/templates/docker/swarm.html index 4da31c3..9009e02 100644 --- a/plugins/docker/templates/docker/swarm.html +++ b/plugins/docker/templates/docker/swarm.html @@ -1,95 +1,103 @@ -{# docker/swarm.html — Swarm topology: services, replica health, nodes, placement. - Services in trouble come first (crash loop, down, short), with since-times; - nodes that are down or not taking work come first. #} +{# docker/swarm.html — the Swarm laid out by node (#5303). A verdict, the + services short of replicas, then one card per node: a node that is down or + not taking work first, then managers. Each card says what its containers use + against what the machine has, and lists the services placed on it. #} {% extends "base.html" %} -{% from "_macros.html" import crumbs %} +{% from "_macros.html" import crumbs, status_badge, status_dot %} +{% from "docker/_usage.html" import cores as fmt_cores %} {% block title %}Swarm — Docker — Steward{% endblock %} {% block breadcrumb %}{{ crumbs([("Docker", "/plugins/docker/"), ("Swarm", "")]) }}{% endblock %} {% block content %} -
+ {% if down_nodes or g.troubled %}{{ down_nodes|length + g.troubled|length }} need{{ "s" if (down_nodes|length + g.troubled|length) == 1 }} attention + {% else %}Every node is up and taking work{% endif %} + · {{ g.nodes|length }} node{{ "s" if g.nodes|length != 1 }} + · {{ g.services_healthy }}/{{ g.services_total }} services healthy + · reported by {{ g.managers|join(", ") }} +
- {# ── Services ─────────────────────────────────────────────────────────── #} -| Service | Mode | Replicas | Image | Placement | Task failures | -
|---|---|---|---|---|---|
| {{ s.name }} | -{{ s.mode }} | -
-
- {{ s.running }}/{{ s.desired }}
-
- {% if s.state != 'healthy' and s.trouble_since %} since {{ s.trouble_since|ago }} {% endif %}
- |
-
- - {% for p in s.placement %}{{ p.node }} ({{ p.running }}){% if not loop.last %}, {% endif %}{% else %}—{% endfor %} - | -- {% include "docker/_swarm_failures.html" %} - {%- if not s.tasks_known %}— - {%- elif not (s.crash_looping or s.failures_24h or (s.last_error and s.state != 'healthy')) %}none in 24h{% endif %} - | -|
| No services in this swarm. | |||||
| Node | Role | Availability | Status |
|---|---|---|---|
| - {{ n.hostname or n.node_id }} - {% if n.leader %}leader{% endif %} - | -{{ n.role }} | -{{ n.availability }} | -- - {{ n.status }} - | -
| No nodes reported. | |||
| Service | Tasks | CPU | Memory |
|---|---|---|---|
| + {%- if t.state != 'healthy' %}{{ status_dot('down' if t.state == 'down' else 'warn') }} {% endif -%} + {{ t.name }} + | +{{ t.tasks }} | +{% if t.cores is not none %}{{ fmt_cores(t.cores) }}{% else %}—{% endif %} | +{% if t.mem_bytes is not none %}{{ t.mem_bytes|bytes }}{% else %}—{% endif %} | +
No tasks placed here.
+ {% endif %} + + {% endfor %}