feat(docker): Busiest Containers shows each figure with its scale; readable names (#5253)

The widget names the busiest container in a verdict ("Busiest: tdarr on
VDNT-Docker03, 1.6 of 8 cores."), then a fixed-layout table (container,
host, CPU in cores, memory in bytes) with each figure's share drawn as
shading behind it: of the host's cores, and of the memory limit or the
host's memory.

The numbers were already rendered, but each row grew as wide as its longest
name. A grid item won't shrink below its content, so Gitea CI container
names pushed the figures out of view and the cell scrolled sideways. Names
now come from display_name(): a swarm task by its service (no slot number),
an Actions job container as "CI: <workflow> · <job>". They cut off with …
and show whole on hover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 13:18:44 -04:00
co-authored by Claude Opus 5.5
parent 213a28c23f
commit d72a1991a7
5 changed files with 87 additions and 37 deletions
+1 -4
View File
@@ -284,7 +284,6 @@ async def widget_resources():
.where(DockerMetric.scraped_at >= since) .where(DockerMetric.scraped_at >= since)
.group_by(DockerMetric.host_id, DockerMetric.container_name) .group_by(DockerMetric.host_id, DockerMetric.container_name)
)).all()} )).all()}
nodes = list((await db.execute(select(DockerSwarmNode))).scalars())
hosts = await docker_svc.host_map(db, {c.host_id for c in running}) hosts = await docker_svc.host_map(db, {c.host_id for c in running})
capacity = await docker_svc.host_capacity(db, {c.host_id for c in running}) capacity = await docker_svc.host_capacity(db, {c.host_id for c in running})
@@ -293,10 +292,9 @@ async def widget_resources():
return c.cpu_pct if v is None else float(v) return c.cpu_pct if v is None else float(v)
busiest = sorted(running, key=lambda c: (-(cpu_of(c) or 0), c.name))[:limit] busiest = sorted(running, key=lambda c: (-(cpu_of(c) or 0), c.name))[:limit]
node_hostname = {n.node_id: n.hostname for n in nodes if n.hostname}
rows_data = [{ rows_data = [{
"c": c, "c": c,
"label": docker_svc.task_label(c, node_hostname), "label": docker_svc.display_name(c),
"host_name": hosts[c.host_id].name if c.host_id in hosts else c.host_id, "host_name": hosts[c.host_id].name if c.host_id in hosts else c.host_id,
"usage": docker_svc.usage(c, *capacity.get(c.host_id, (None, None)), cpu_pct=cpu_of(c)), "usage": docker_svc.usage(c, *capacity.get(c.host_id, (None, None)), cpu_pct=cpu_of(c)),
} for c in busiest] } for c in busiest]
@@ -304,7 +302,6 @@ async def widget_resources():
"docker/widget_resources.html", "docker/widget_resources.html",
rows=rows_data, rows=rows_data,
minutes=CPU_AVERAGE_MINUTES, minutes=CPU_AVERAGE_MINUTES,
multi_host=len({c.host_id for c in busiest}) > 1,
widget_id=widget_id, widget_id=widget_id,
) )
+19
View File
@@ -6,6 +6,7 @@ Quart's pipeline) call the same code.
""" """
from __future__ import annotations from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select from sqlalchemy import func, select
@@ -126,6 +127,24 @@ def task_label(c, node_hostname: dict | None = None) -> str:
return f"{svc} · {host}" if host else svc return f"{svc} · {host}" if host else svc
_CI_JOB = re.compile(
r"^(?:GITEA|FORGEJO)-ACTIONS-TASK-\d+-WORKFLOW-(?P<workflow>.+?)-JOB-(?P<job>.+?)(?:-[0-9a-f]{16,})?$")
def display_name(c) -> str:
"""A container's name as a list can show it in one short line (#5253): a
swarm task by its service (the host says where it runs), a Gitea or
Forgejo Actions job container as "CI: <workflow> · <job>", anything else
by its own name."""
svc = getattr(c, "service_name", None)
if svc:
return svc
m = _CI_JOB.match(c.name or "")
if m:
return f"CI: {m['workflow']} · {m['job']}"
return c.name
def usage(c, cpus: int | None, mem_total: int | None, cpu_pct: float | None = None) -> dict: def usage(c, cpus: int | None, mem_total: int | None, cpu_pct: float | None = None) -> dict:
"""A container's CPU and memory in terms a reader can weigh. """A container's CPU and memory in terms a reader can weigh.
@@ -1,42 +1,41 @@
{# docker/widget_resources.html — dashboard widget: the containers using the {# docker/widget_resources.html — the dashboard Busiest Containers widget
most CPU (averaged over the last few minutes, so the order holds still), (#5253): a verdict naming the busiest, then a table of the top containers
each with its CPU in cores and memory in bytes. The CPU bar is a share of by CPU (averaged over the last few minutes, so the order holds still). Each
the host's CPUs; the memory bar a share of the container's limit, or of figure carries its unit; the shaded bar behind it is its share of the
the host's memory when no limit is set. #} host's cores, or of the memory limit (the host's memory when none is set). #}
{% from "docker/_usage.html" import cpu, mem %}
{% if not rows %} {% if not rows %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">No running containers.</div> <div class="w-verdict" style="color:var(--text-muted);">No running containers.</div>
{% else %} {% else %}
{% macro bar(label, pct, tone, text, title) %} {% set top = rows[0] %}
<div style="display:flex;align-items:center;gap:0.45rem;margin-bottom:2px;" title="{{ title }}"> <div class="w-verdict">
<span style="font-size:0.62rem;color:var(--text-dim);width:1.9rem;flex-shrink:0;letter-spacing:0.03em;">{{ label }}</span> Busiest: <strong>{{ top.label }}</strong> on {{ top.host_name }},
<div style="flex:1;height:6px;background:var(--bg-elevated);border-radius:3px;overflow:hidden;"> {% if top.usage.cores is not none %}{{ "%.2f"|format(top.usage.cores) if top.usage.cores < 1 else "%.1f"|format(top.usage.cores) }}
{% if pct is not none %} {%- if top.usage.cpus %} of {{ top.usage.cpus }} cores{% else %} cores{% endif %}{% else %}no CPU reading yet{% endif %}.
<div style="height:100%;border-radius:3px;width:{{ [[pct, 0]|max, 100]|min }}%;
background:{% if tone == 'crit' %}var(--red){% elif tone == 'warn' %}var(--orange){% else %}var(--accent){% endif %};
transition:width 0.4s;"></div>
{% endif %}
</div> </div>
<span style="font-size:0.7rem;color:var(--text-muted);font-family:ui-monospace,monospace;min-width:4.5rem;text-align:right;flex-shrink:0;white-space:nowrap;">{{ text }}</span> <table class="w-table">
</div> <colgroup><col><col class="w-wide-only" style="width:7.5rem"><col style="width:5.5rem"><col style="width:6.5rem"></colgroup>
{% endmacro %} <thead><tr><th>Container</th><th class="w-wide-only">Host</th><th>CPU</th><th>Memory</th></tr></thead>
<div style="font-size:0.7rem;color:var(--text-dim);margin-bottom:0.45rem;">Busiest by CPU, {{ minutes }}-minute average</div> <tbody>
<div style="display:grid;gap:0.6rem;">
{% for r in rows %} {% for r in rows %}
{% set u = r.usage %} {% set u = r.usage %}
<div> <tr>
<div style="font-size:0.8rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:0.25rem;" title="{{ r.c.name }}"> <td class="w-name" title="{{ r.c.name }}">
<a href="/plugins/docker/container/{{ r.c.host_id }}/{{ r.c.name }}" style="color:inherit;text-decoration:none;">{{ r.label }}</a>{% if multi_host %}<span style="color:var(--text-dim);font-weight:normal;"> · {{ r.host_name }}</span>{% endif %} {%- if session.user_id %}<a href="/plugins/docker/container/{{ r.c.host_id }}/{{ r.c.name }}">{{ r.label }}</a>{% else %}{{ r.label }}{% endif -%}
</div> </td>
{% if u.cores is not none %} <td class="w-wide-only" title="{{ r.host_name }}">{{ r.host_name }}</td>
{{ bar("CPU", u.cpu_share, status_class(u.cpu_share, 'cpu'), cpu(u), {% if u.cores is none %}<td>—</td>{% else %}
("%.1f%% of the host's %d core%s" % (u.cpu_share, u.cpus, "" if u.cpus == 1 else "s")) if u.cpus else "Host core count unknown") }} <td class="w-meter tone-{{ status_class(u.cpu_share, 'cpu') if u.cpus else 'dim' }}" style="--pct:{{ [[u.cpu_share or 0, 0]|max, 100]|min }}%"
title="{% if u.cpus %}{{ '%.1f'|format(u.cpu_share) }}% of {{ r.host_name }}'s {{ u.cpus }} cores{% else %}The host's core count isn't known{% endif %}">
{{- "%.2f"|format(u.cores) if u.cores < 1 else "%.1f"|format(u.cores) }} cores</td>
{% endif %} {% endif %}
{% if u.mem_bytes is not none %} {% if u.mem_bytes is none %}<td>—</td>{% else %}
{{ bar("MEM", u.mem_pct if u.mem_of else none, status_class(u.mem_pct, 'mem') if u.mem_of else 'unknown', mem(u), <td class="w-meter tone-{{ status_class(u.mem_pct, 'mem') if u.mem_of else 'dim' }}" style="--pct:{{ [[u.mem_pct or 0, 0]|max, 100]|min }}%"
("%.1f%% of %s" % (u.mem_pct, u.mem_of)) if u.mem_of and u.mem_pct is not none else "") }} title="{% if u.mem_pct is not none and u.mem_of %}{{ '%.1f'|format(u.mem_pct) }}% of {{ u.mem_of }}{% else %}No memory limit set{% endif %}">
{{- u.mem_bytes|bytes }}</td>
{% endif %} {% endif %}
</div> </tr>
{% endfor %} {% endfor %}
</div> </tbody>
</table>
<div class="w-note">CPU over the last {{ minutes }} minutes. Shading: share of the host's cores, and of the memory limit (or the host's memory when no limit is set).</div>
{% endif %} {% endif %}
+5
View File
@@ -404,6 +404,11 @@ body.dash-editing .widget-drawer { transform:translateY(0); }
.w-item .w-label { flex:1; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .w-item .w-label { flex:1; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.w-item .w-label a { color:inherit; text-decoration:none; } .w-item .w-label a { color:inherit; text-decoration:none; }
.w-item .w-meta { flex-shrink:0; font-size:0.72rem; color:var(--text-muted); font-variant-numeric:tabular-nums; } .w-item .w-meta { flex-shrink:0; font-size:0.72rem; color:var(--text-muted); font-variant-numeric:tabular-nums; }
/* A number with its share drawn behind it (--pct, 0–100), in the number's own
tone: the bar never stands without the figure it measures. */
.w-meter { background:linear-gradient(90deg, color-mix(in srgb, currentColor 20%, transparent) var(--pct, 0%), transparent var(--pct, 0%)); border-radius:3px; }
.w-note { font-size:0.7rem; color:var(--text-dim); margin-top:0.3rem; }
@container (max-width: 26rem) { .w-wide-only { display:none; } }
/* Time range selector */ /* Time range selector */
+30
View File
@@ -208,3 +208,33 @@ async def test_the_services_widget_says_when_all_is_well(app):
healthy=[{"kind": "service", "s": _widget_svc("a")}]) healthy=[{"kind": "service", "s": _widget_svc("a")}])
assert "2/2 services healthy" in page and "nothing failed in 24h" in page and "tone-ok" in page assert "2/2 services healthy" in page and "nothing failed in 24h" in page and "tone-ok" in page
assert "Needs a look" not in page and "Failed in the last 24h" not in page assert "Needs a look" not in page and "Failed in the last 24h" not in page
def test_a_container_reads_by_its_service_or_ci_job():
"""#5253: no task id, no slot number, no forty-character CI container name."""
assert svc.display_name(_c("scribe_app.1.x7q", service_name="scribe_app")) == "scribe_app"
ci = "GITEA-ACTIONS-TASK-32792-WORKFLOW-Desktop-Tauri-JOB-verify-Web-d718b95dad0b02ad3cc1"
assert svc.display_name(_c(ci)) == "CI: Desktop-Tauri · verify-Web"
assert svc.display_name(_c("portainer_agent")) == "portainer_agent"
async def test_busiest_containers_show_the_number_with_its_scale(app):
"""#5253: every bar has its figure; the widget names the busiest."""
rows = [
{"c": _c("GITEA-ACTIONS-TASK-1-WORKFLOW-Android-JOB-build-0123456789abcdef0"),
"label": "CI: Android · build", "host_name": "VDNT-Docker03",
"usage": {"cores": 1.62, "cpus": 8, "cpu_share": 20.25, "mem_bytes": 820 * 2**20,
"mem_pct": 5.0, "mem_of": "the host's memory", "mem_limit": None}},
{"c": _c("tdarr"), "label": "tdarr", "host_name": "VDNT-Docker03",
"usage": {"cores": 0.25, "cpus": 8, "cpu_share": 3.1, "mem_bytes": None,
"mem_pct": None, "mem_of": None, "mem_limit": None}},
]
async with app.test_request_context("/plugins/docker/widget/resources"):
page = await render_plugin_template(app, "docker", "docker/widget_resources.html",
rows=rows, minutes=5, widget_id="1")
assert "Busiest: <strong>CI: Android · build</strong> on VDNT-Docker03" in page and "1.6 of 8 cores" in page
assert ">1.6 cores<" in page and ">0.25 cores<" in page and ">820.0 MiB<" in page
assert "--pct:20.25%" in page and ">—<" in page
assert page.count("<th") == 4
assert ">CI: Android · build<" in page # the long CI name only as the hover title
assert not hex_colours(page), hex_colours(page)