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:
@@ -284,7 +284,6 @@ async def widget_resources():
|
||||
.where(DockerMetric.scraped_at >= since)
|
||||
.group_by(DockerMetric.host_id, DockerMetric.container_name)
|
||||
)).all()}
|
||||
nodes = list((await db.execute(select(DockerSwarmNode))).scalars())
|
||||
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})
|
||||
|
||||
@@ -293,10 +292,9 @@ async def widget_resources():
|
||||
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]
|
||||
node_hostname = {n.node_id: n.hostname for n in nodes if n.hostname}
|
||||
rows_data = [{
|
||||
"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,
|
||||
"usage": docker_svc.usage(c, *capacity.get(c.host_id, (None, None)), cpu_pct=cpu_of(c)),
|
||||
} for c in busiest]
|
||||
@@ -304,7 +302,6 @@ async def widget_resources():
|
||||
"docker/widget_resources.html",
|
||||
rows=rows_data,
|
||||
minutes=CPU_AVERAGE_MINUTES,
|
||||
multi_host=len({c.host_id for c in busiest}) > 1,
|
||||
widget_id=widget_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ Quart's pipeline) call the same code.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
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
|
||||
|
||||
|
||||
_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:
|
||||
"""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
|
||||
most CPU (averaged over the last few minutes, so the order holds still),
|
||||
each with its CPU in cores and memory in bytes. The CPU bar is a share of
|
||||
the host's CPUs; the memory bar a share of the container's limit, or of
|
||||
the host's memory when no limit is set. #}
|
||||
{% from "docker/_usage.html" import cpu, mem %}
|
||||
{# docker/widget_resources.html — the dashboard Busiest Containers widget
|
||||
(#5253): a verdict naming the busiest, then a table of the top containers
|
||||
by CPU (averaged over the last few minutes, so the order holds still). Each
|
||||
figure carries its unit; the shaded bar behind it is its share of the
|
||||
host's cores, or of the memory limit (the host's memory when none is set). #}
|
||||
{% 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 %}
|
||||
{% macro bar(label, pct, tone, text, title) %}
|
||||
<div style="display:flex;align-items:center;gap:0.45rem;margin-bottom:2px;" title="{{ title }}">
|
||||
<span style="font-size:0.62rem;color:var(--text-dim);width:1.9rem;flex-shrink:0;letter-spacing:0.03em;">{{ label }}</span>
|
||||
<div style="flex:1;height:6px;background:var(--bg-elevated);border-radius:3px;overflow:hidden;">
|
||||
{% if pct is not none %}
|
||||
<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>
|
||||
<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>
|
||||
{% set top = rows[0] %}
|
||||
<div class="w-verdict">
|
||||
Busiest: <strong>{{ top.label }}</strong> on {{ top.host_name }},
|
||||
{% if top.usage.cores is not none %}{{ "%.2f"|format(top.usage.cores) if top.usage.cores < 1 else "%.1f"|format(top.usage.cores) }}
|
||||
{%- if top.usage.cpus %} of {{ top.usage.cpus }} cores{% else %} cores{% endif %}{% else %}no CPU reading yet{% endif %}.
|
||||
</div>
|
||||
{% endmacro %}
|
||||
<div style="font-size:0.7rem;color:var(--text-dim);margin-bottom:0.45rem;">Busiest by CPU, {{ minutes }}-minute average</div>
|
||||
<div style="display:grid;gap:0.6rem;">
|
||||
<table class="w-table">
|
||||
<colgroup><col><col class="w-wide-only" style="width:7.5rem"><col style="width:5.5rem"><col style="width:6.5rem"></colgroup>
|
||||
<thead><tr><th>Container</th><th class="w-wide-only">Host</th><th>CPU</th><th>Memory</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
{% set u = r.usage %}
|
||||
<div>
|
||||
<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 }}">
|
||||
<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 %}
|
||||
</div>
|
||||
{% if u.cores is not none %}
|
||||
{{ bar("CPU", u.cpu_share, status_class(u.cpu_share, 'cpu'), cpu(u),
|
||||
("%.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") }}
|
||||
<tr>
|
||||
<td class="w-name" title="{{ r.c.name }}">
|
||||
{%- if session.user_id %}<a href="/plugins/docker/container/{{ r.c.host_id }}/{{ r.c.name }}">{{ r.label }}</a>{% else %}{{ r.label }}{% endif -%}
|
||||
</td>
|
||||
<td class="w-wide-only" title="{{ r.host_name }}">{{ r.host_name }}</td>
|
||||
{% if u.cores is none %}<td>—</td>{% else %}
|
||||
<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 %}
|
||||
{% if u.mem_bytes is not none %}
|
||||
{{ 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),
|
||||
("%.1f%% of %s" % (u.mem_pct, u.mem_of)) if u.mem_of and u.mem_pct is not none else "") }}
|
||||
{% if u.mem_bytes is none %}<td>—</td>{% else %}
|
||||
<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 }}%"
|
||||
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 %}
|
||||
</div>
|
||||
</tr>
|
||||
{% 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 %}
|
||||
|
||||
@@ -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 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; }
|
||||
/* 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 */
|
||||
|
||||
@@ -208,3 +208,33 @@ async def test_the_services_widget_says_when_all_is_well(app):
|
||||
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 "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)
|
||||
|
||||
Reference in New Issue
Block a user