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>
This commit is contained in:
@@ -20,6 +20,7 @@ from .swarm_view import (
|
|||||||
build_swarm_services, failing_services, parse_tasks, replaced_tasks, services_problems_first,
|
build_swarm_services, failing_services, parse_tasks, replaced_tasks, services_problems_first,
|
||||||
task_summary, trouble_since,
|
task_summary, trouble_since,
|
||||||
)
|
)
|
||||||
|
from .swarm_nodes import node_cards
|
||||||
from .overview import (
|
from .overview import (
|
||||||
DOCKER_SORTS, DOCKER_STATES, NO_STACK, container_row, filter_rows, service_row, sort_rows,
|
DOCKER_SORTS, DOCKER_STATES, NO_STACK, container_row, filter_rows, service_row, sort_rows,
|
||||||
stack_of,
|
stack_of,
|
||||||
@@ -497,7 +498,9 @@ async def container_detail(host_id: str, name: str):
|
|||||||
@docker_bp.get("/swarm")
|
@docker_bp.get("/swarm")
|
||||||
@require_role(UserRole.viewer)
|
@require_role(UserRole.viewer)
|
||||||
async def swarm():
|
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
|
Swarm services/nodes are cluster-global — every manager's API returns the
|
||||||
same list — but each manager reports them independently (rows keyed by
|
same list — but each manager reports them independently (rows keyed by
|
||||||
@@ -515,6 +518,20 @@ async def swarm():
|
|||||||
DockerSwarmNode.role, DockerSwarmNode.hostname)
|
DockerSwarmNode.role, DockerSwarmNode.hostname)
|
||||||
)).scalars())
|
)).scalars())
|
||||||
hosts = await docker_svc.host_map(db, {s.host_id for s in services} | {n.host_id for n in nodes})
|
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 ──────────
|
# ── 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}
|
manager_ids = {s.host_id for s in services} | {n.host_id for n in nodes}
|
||||||
@@ -586,17 +603,16 @@ async def swarm():
|
|||||||
**summary,
|
**summary,
|
||||||
"trouble_since": trouble_since(tasks, state, summary["crash_looping"], now),
|
"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)
|
manager_names = sorted(hosts[m].name if m in hosts else m for m in members)
|
||||||
swarms.append({
|
swarms.append({
|
||||||
"managers": manager_names,
|
"managers": manager_names,
|
||||||
"services": services_problems_first(svc_dicts),
|
"services_total": len(svc_dicts),
|
||||||
"nodes": node_rows,
|
"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 "")
|
swarms.sort(key=lambda g: g["managers"][0].lower() if g["managers"] else "")
|
||||||
|
|||||||
@@ -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()))
|
||||||
@@ -1,95 +1,103 @@
|
|||||||
{# docker/swarm.html — Swarm topology: services, replica health, nodes, placement.
|
{# docker/swarm.html — the Swarm laid out by node (#5303). A verdict, the
|
||||||
Services in trouble come first (crash loop, down, short), with since-times;
|
services short of replicas, then one card per node: a node that is down or
|
||||||
nodes that are down or not taking work come first. #}
|
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" %}
|
{% 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 title %}Swarm — Docker — Steward{% endblock %}
|
||||||
{% block breadcrumb %}{{ crumbs([("Docker", "/plugins/docker/"), ("Swarm", "")]) }}{% endblock %}
|
{% block breadcrumb %}{{ crumbs([("Docker", "/plugins/docker/"), ("Swarm", "")]) }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<h1 class="page-title" style="margin-bottom:1.5rem;">Swarm</h1>
|
<h1 class="page-title">Swarm</h1>
|
||||||
|
|
||||||
{% if swarms %}
|
{% if swarms %}
|
||||||
{% for g in swarms %}
|
{% for g in swarms %}
|
||||||
<div style="margin-bottom:2rem;">
|
{% set down_nodes = g.nodes|rejectattr("ok")|list %}
|
||||||
<div style="display:flex;align-items:baseline;gap:0.6rem;margin-bottom:0.75rem;flex-wrap:wrap;">
|
<section class="swarm-group" aria-label="Swarm reported by {{ g.managers|join(', ') }}">
|
||||||
<span style="font-weight:500;font-size:0.95rem;">Swarm</span>
|
<p class="docker-verdict">
|
||||||
<span style="font-size:0.78rem;color:var(--text-muted);">
|
{% if down_nodes or g.troubled %}<strong class="tone-crit">{{ down_nodes|length + g.troubled|length }} need{{ "s" if (down_nodes|length + g.troubled|length) == 1 }} attention</strong>
|
||||||
reported by {{ g.managers | length }} manager{{ 's' if g.managers | length != 1 }} · {{ g.managers | join(", ") }}
|
{% else %}<strong class="tone-ok">Every node is up and taking work</strong>{% endif %}
|
||||||
</span>
|
· {{ g.nodes|length }} node{{ "s" if g.nodes|length != 1 }}
|
||||||
</div>
|
· <span class="tone-{{ 'ok' if g.services_healthy == g.services_total else 'warn' }}">{{ g.services_healthy }}/{{ g.services_total }}</span> services healthy
|
||||||
|
<span class="tone-dim">· reported by {{ g.managers|join(", ") }}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
{# ── Services ─────────────────────────────────────────────────────────── #}
|
{% if down_nodes or g.troubled %}
|
||||||
<div class="card-flush" style="margin-bottom:1rem;">
|
<section class="card problem-card" aria-label="Needs attention">
|
||||||
<table class="table">
|
<ul class="problem-list">
|
||||||
<thead>
|
{% for n in down_nodes %}
|
||||||
<tr>
|
<li>
|
||||||
<th>Service</th><th>Mode</th><th>Replicas</th><th>Image</th><th>Placement</th><th>Task failures</th>
|
{{ status_badge('crit', 'Node ' ~ n.status) if n.status != 'ready' else status_badge('warn', n.availability|capitalize) }}
|
||||||
</tr>
|
<span>{{ n.hostname }}</span>
|
||||||
</thead>
|
<span class="problem-detail">{{ n.role }} · {{ n.task_count }} task{{ "s" if n.task_count != 1 }} placed on it</span>
|
||||||
<tbody>
|
</li>
|
||||||
{% for s in g.services %}
|
{% endfor %}
|
||||||
<tr>
|
{% for s in g.troubled %}
|
||||||
<td style="font-weight:500;font-size:0.9rem;">{{ s.name }}</td>
|
<li>
|
||||||
<td style="font-size:0.82rem;color:var(--text-muted);">{{ s.mode }}</td>
|
{% if s.crash_looping %}{{ status_badge('crit', 'Crash-looping') }}{% elif s.state == 'down' %}{{ status_badge('crit', 'Down') }}{% else %}{{ status_badge('warn', 'Degraded') }}{% endif %}
|
||||||
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;">
|
<a href="/plugins/docker/service/{{ s.name }}">{{ s.name }}</a>
|
||||||
<span style="color:{% if s.healthy %}var(--green){% elif s.down %}var(--red){% elif s.degraded %}var(--orange){% else %}var(--text-muted){% endif %};">
|
{% set e = s.last_error %}
|
||||||
{{ s.running }}/{{ s.desired }}
|
<span class="problem-detail">
|
||||||
</span>
|
{{- s.running }} of {{ s.desired }} running
|
||||||
{% if s.state != 'healthy' and s.trouble_since %}<div style="font-family:inherit;font-size:0.72rem;color:var(--text-dim);" title="{{ s.trouble_since }}">since {{ s.trouble_since|ago }}</div>{% endif %}
|
{%- if e %} · last task {{ e.state }}: {{ e.error or e.message or e.state }}{% if e.node %} on {{ e.node }}{% endif %}{% endif %}
|
||||||
</td>
|
{%- if s.trouble_since %} · since {{ s.trouble_since|ago }}{% endif -%}
|
||||||
<td style="font-size:0.8rem;color:var(--text-muted);max-width:220px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ s.image or "—" }}</td>
|
</span>
|
||||||
<td style="font-size:0.78rem;color:var(--text-muted);">
|
</li>
|
||||||
{% for p in s.placement %}{{ p.node }} ({{ p.running }}){% if not loop.last %}, {% endif %}{% else %}—{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</ul>
|
||||||
<td style="max-width:360px;">
|
</section>
|
||||||
{% include "docker/_swarm_failures.html" %}
|
{% endif %}
|
||||||
{%- if not s.tasks_known %}<span style="font-size:0.78rem;color:var(--text-dim);" title="The reporting manager's agent predates task reporting (1.8.0); update it to see task failures.">—</span>
|
|
||||||
{%- elif not (s.crash_looping or s.failures_24h or (s.last_error and s.state != 'healthy')) %}<span style="font-size:0.78rem;color:var(--text-dim);">none in 24h</span>{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr><td colspan="6" style="color:var(--text-muted);font-size:0.85rem;text-align:center;padding:1.5rem;">No services in this swarm.</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# ── Nodes ────────────────────────────────────────────────────────────── #}
|
<div class="node-grid">
|
||||||
<div class="card-flush">
|
{% for n in g.nodes %}
|
||||||
<table class="table">
|
<section class="card node-card{% if not n.ok %} problem-card{% endif %}" aria-labelledby="node-{{ n.node_id }}">
|
||||||
<thead>
|
<h2 id="node-{{ n.node_id }}" class="node-card-head">
|
||||||
<tr><th>Node</th><th>Role</th><th>Availability</th><th>Status</th></tr>
|
{{ status_dot('up' if n.status == 'ready' else 'down') }}
|
||||||
</thead>
|
{% if n.host_id %}<a href="/hosts/{{ n.host_id }}">{{ n.hostname }}</a>{% else %}{{ n.hostname }}{% endif %}
|
||||||
<tbody>
|
<span class="chip">{{ "leader" if n.leader else n.role }}</span>
|
||||||
{% for n in g.nodes %}
|
{% if n.availability != 'active' %}{{ status_badge('warn', n.availability|capitalize, title="Swarm places no new tasks here") }}{% endif %}
|
||||||
<tr>
|
</h2>
|
||||||
<td style="font-weight:500;font-size:0.9rem;">
|
<p class="node-card-figures">
|
||||||
{{ n.hostname or n.node_id }}
|
{% if n.cores_used is not none %}
|
||||||
{% if n.leader %}<span title="cluster leader" style="font-size:0.68rem;color:var(--accent);border:1px solid var(--accent);border-radius:3px;padding:0 0.3rem;margin-left:0.3rem;">leader</span>{% endif %}
|
Containers use <strong>{{ fmt_cores(n.cores_used) }}</strong>{% if n.cores %} of {{ n.cores }}{% endif %}
|
||||||
</td>
|
· <strong>{{ n.mem_used|bytes }}</strong>{% if n.mem %} of {{ n.mem|bytes }}{% endif %}
|
||||||
<td style="font-size:0.82rem;color:var(--text-muted);">{{ n.role }}</td>
|
{% elif n.agent %}
|
||||||
<td style="font-size:0.82rem;color:{% if n.availability == 'active' %}var(--text){% else %}var(--orange){% endif %};">{{ n.availability }}</td>
|
<span class="tone-muted">No swarm task reports figures here yet.</span>
|
||||||
<td style="font-size:0.82rem;">
|
|
||||||
<span class="dot {% if n.status == 'ready' %}dot-up{% else %}dot-down{% endif %}"></span>
|
|
||||||
<span style="color:{% if n.status == 'ready' %}var(--green){% else %}var(--red){% endif %};">{{ n.status }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="4" style="color:var(--text-muted);font-size:0.85rem;text-align:center;padding:1.5rem;">No nodes reported.</td></tr>
|
<span class="tone-muted" title="Install the Steward agent on this node to see what its tasks use">No Steward agent: what runs here is Swarm's placement, without figures.</span>
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
</tbody>
|
</p>
|
||||||
</table>
|
{% if n.tasks %}
|
||||||
|
<table class="table node-tasks">
|
||||||
|
<thead><tr><th>Service</th><th class="num">Tasks</th><th class="num">CPU</th><th class="num">Memory</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in n.tasks %}
|
||||||
|
<tr>
|
||||||
|
<td class="cell-name">
|
||||||
|
{%- if t.state != 'healthy' %}{{ status_dot('down' if t.state == 'down' else 'warn') }} {% endif -%}
|
||||||
|
<a href="/plugins/docker/service/{{ t.name }}" title="{{ t.name }}">{{ t.name }}</a>
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ t.tasks }}</td>
|
||||||
|
<td class="num">{% if t.cores is not none %}{{ fmt_cores(t.cores) }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||||
|
<td class="num">{% if t.mem_bytes is not none %}{{ t.mem_bytes|bytes }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="tone-muted" style="margin:0;">No tasks placed here.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card" style="text-align:center;padding:3rem;">
|
<div class="card empty-state">
|
||||||
<div style="color:var(--text-muted);font-size:0.9rem;">
|
No Swarm topology reported. A Steward host agent running on a Swarm
|
||||||
No Swarm topology reported. A Steward host agent running on a Swarm
|
<strong>manager</strong> reports services, nodes and task placement here;
|
||||||
<strong>manager</strong> reports services, nodes, and task placement here —
|
workers and hosts outside a swarm contribute only their own containers.
|
||||||
workers and non-swarm hosts contribute only their local containers.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -213,6 +213,17 @@ strong, b, th { font-weight: 500; }
|
|||||||
over a line that spans its half (docker/_service_history.html). */
|
over a line that spans its half (docker/_service_history.html). */
|
||||||
.history-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 1.25rem; }
|
.history-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 1.25rem; }
|
||||||
.history-pair .spark { color: var(--blue); margin-top: 0.4rem; }
|
.history-pair .spark { color: var(--blue); margin-top: 0.4rem; }
|
||||||
|
/* The Swarm page (#5303): one card per node, two or three a row on a wide
|
||||||
|
screen; each card's own services table. */
|
||||||
|
.swarm-group { margin-bottom: 2rem; }
|
||||||
|
.node-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 22rem), 1fr)); gap: 1rem; align-items: start; }
|
||||||
|
.node-card { margin-bottom: 0; display: flex; flex-direction: column; gap: 0.6rem; }
|
||||||
|
.node-card-head { display: flex; align-items: center; flex-wrap: wrap; gap: 0.4rem; margin: 0; font-family: var(--fs-font-body); font-size: 1rem; font-weight: 500; }
|
||||||
|
.node-card-head .chip { margin: 0; }
|
||||||
|
.node-card-figures { margin: 0; font-size: 0.85rem; }
|
||||||
|
.node-tasks th, .node-tasks td { padding: 0.35rem 0.5rem; font-size: 0.82rem; }
|
||||||
|
.node-tasks .cell-name { max-width: 12rem; }
|
||||||
|
.node-tasks .cell-name > a { display: inline-block; max-width: 100%; vertical-align: bottom; }
|
||||||
|
|
||||||
/* Forms */
|
/* Forms */
|
||||||
.form-group { margin-bottom: 1rem; }
|
.form-group { margin-bottom: 1rem; }
|
||||||
|
|||||||
@@ -205,3 +205,74 @@ async def test_a_service_log_line_names_its_task(app):
|
|||||||
lines=[], name="web", filtered=False, service=True)
|
lines=[], name="web", filtered=False, service=True)
|
||||||
assert f"1 · {TASK[:10]}</span>" in page and "<b>boom</b>" in page
|
assert f"1 · {TASK[:10]}</span>" in page and "<b>boom</b>" in page
|
||||||
assert "one of its tasks" in empty
|
assert "one of its tasks" in empty
|
||||||
|
|
||||||
|
|
||||||
|
def _node(nid, hostname, role="worker", status="ready", availability="active", leader=False):
|
||||||
|
return NS(node_id=nid, hostname=hostname, role=role, status=status, availability=availability,
|
||||||
|
leader=leader)
|
||||||
|
|
||||||
|
|
||||||
|
def _swarm_svc(name, placement):
|
||||||
|
import json
|
||||||
|
return NS(service_name=name, placement_json=json.dumps(placement))
|
||||||
|
|
||||||
|
|
||||||
|
def _task(name, service, node, cpu, mem, status="running"):
|
||||||
|
return NS(name=name, service_name=service, node_id=node, status=status, cpu_pct=cpu,
|
||||||
|
mem_usage_bytes=mem)
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_node_lists_what_runs_on_it_and_what_that_uses():
|
||||||
|
"""#5303: a node with an agent has figures; one without is Swarm's placement only."""
|
||||||
|
from plugins.docker.swarm_nodes import node_cards
|
||||||
|
nodes = [_node("n1", "docker02", role="manager", leader=True), _node("n2", "edge01"),
|
||||||
|
_node("n3", "docker03", availability="drain")]
|
||||||
|
services = [_swarm_svc("arr_radarr", [{"node_id": "n1", "running": 1}]),
|
||||||
|
_swarm_svc("web", [{"node_id": "n1", "running": 2}, {"node_id": "n2", "running": 1}]),
|
||||||
|
_swarm_svc("db", [{"node_id": "n1", "running": 1}])]
|
||||||
|
containers = [_task("arr_radarr.1.x", "arr_radarr", "n1", 150.0, 300),
|
||||||
|
_task("web.1.x", "web", "n1", 10.0, 100), _task("web.2.x", "web", "n1", 20.0, 100),
|
||||||
|
_task("web.0.old", "web", "n1", 99.0, 999, status="exited"),
|
||||||
|
_task("traefik", None, None, 5.0, 5)]
|
||||||
|
cards = node_cards(nodes, services, containers, node_host={"n1": "h1"}, host_names={"h1": "docker02"},
|
||||||
|
capacity={"h1": (8, 16 << 30)},
|
||||||
|
service_state={"arr_radarr": "healthy", "web": "healthy", "db": "down"})
|
||||||
|
assert [c["hostname"] for c in cards] == ["docker03", "docker02", "edge01"] # drained first, then managers
|
||||||
|
n1 = cards[1]
|
||||||
|
assert n1["host_id"] == "h1" and n1["leader"] and n1["agent"]
|
||||||
|
assert [t["name"] for t in n1["tasks"]] == ["db", "arr_radarr", "web"] # trouble, then busiest
|
||||||
|
assert n1["tasks"][0]["cores"] is None and not n1["tasks"][0]["measured"] # placed, not reported
|
||||||
|
web = n1["tasks"][2]
|
||||||
|
assert web["tasks"] == 2 and web["cores"] == 0.3 and web["mem_bytes"] == 200
|
||||||
|
assert n1["cores_used"] == 1.8 and n1["mem_used"] == 500 and n1["cores"] == 8
|
||||||
|
edge = cards[2]
|
||||||
|
assert not edge["agent"] and edge["cores_used"] is None
|
||||||
|
assert edge["tasks"] == [{"name": "web", "tasks": 1, "cpu_pct": None, "mem_bytes": None, "measured": False,
|
||||||
|
"state": "healthy", "cores": None}]
|
||||||
|
assert not cards[0]["ok"] and cards[0]["tasks"] == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_swarm_page_is_a_card_per_node(app):
|
||||||
|
from plugins.docker.swarm_nodes import node_cards
|
||||||
|
nodes = [_node("n1", "docker02", role="manager", leader=True), _node("n2", "edge01", status="down")]
|
||||||
|
cards = node_cards(nodes, [_swarm_svc("web", [{"node_id": "n2", "running": 1}])],
|
||||||
|
[_task("arr.1.x", "arr", "n1", 50.0, 1 << 30)], node_host={"n1": "h1"},
|
||||||
|
host_names={"h1": "docker02"}, capacity={"h1": (4, 8 << 30)},
|
||||||
|
service_state={"arr": "healthy", "web": "degraded"})
|
||||||
|
troubled = [{"name": "web", "running": 1, "desired": 2, "state": "degraded", "crash_looping": False,
|
||||||
|
"trouble_since": None, "last_error": {"state": "failed", "error": "no suitable node", "node": None}}]
|
||||||
|
async with app.test_request_context("/plugins/docker/swarm"):
|
||||||
|
session["user_role"] = "viewer"
|
||||||
|
page = await render_plugin_template(app, "docker", "docker/swarm.html", swarms=[{
|
||||||
|
"managers": ["docker02"], "services_total": 2, "services_healthy": 1, "troubled": troubled,
|
||||||
|
"nodes": cards}])
|
||||||
|
page = re.sub(r">\s+<", "><", " ".join(page.split()))
|
||||||
|
assert "2 need attention" in page and '<span class="tone-warn">1/2</span> services healthy' in page
|
||||||
|
problems = page[page.index('class="problem-list"'):]
|
||||||
|
assert "Node down" in problems and "edge01" in problems
|
||||||
|
assert '<a href="/plugins/docker/service/web">web</a>' in problems and "last task failed: no suitable node" in problems
|
||||||
|
assert page.index('id="node-n2"') < page.index('id="node-n1"') # the down node first
|
||||||
|
assert '<a href="/hosts/h1">docker02</a><span class="chip">leader</span>' in page
|
||||||
|
assert "Containers use <strong>0.50 cores</strong> of 4 · <strong>1.0 GiB</strong> of 8.0 GiB" in page
|
||||||
|
assert "No Steward agent" in page
|
||||||
|
assert '<a href="/plugins/docker/service/arr" title="arr">arr</a>' in page
|
||||||
|
|||||||
Reference in New Issue
Block a user