diff --git a/plugins/docker/routes.py b/plugins/docker/routes.py index d8c8bae..c6d8007 100644 --- a/plugins/docker/routes.py +++ b/plugins/docker/routes.py @@ -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, ) diff --git a/plugins/docker/service.py b/plugins/docker/service.py index adcd022..d4d03c9 100644 --- a/plugins/docker/service.py +++ b/plugins/docker/service.py @@ -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.+?)-JOB-(?P.+?)(?:-[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: · ", 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. diff --git a/plugins/docker/templates/docker/widget_resources.html b/plugins/docker/templates/docker/widget_resources.html index 8d792fd..33e0ba4 100644 --- a/plugins/docker/templates/docker/widget_resources.html +++ b/plugins/docker/templates/docker/widget_resources.html @@ -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 %} -
No running containers.
+
No running containers.
{% else %} -{% macro bar(label, pct, tone, text, title) %} -
- {{ label }} -
- {% if pct is not none %} -
- {% endif %} -
- {{ text }} +{% set top = rows[0] %} +
+ Busiest: {{ top.label }} 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 %}.
-{% endmacro %} -
Busiest by CPU, {{ minutes }}-minute average
-
+ + + + {% for r in rows %} {% set u = r.usage %} -
-
- {{ r.label }}{% if multi_host %} · {{ r.host_name }}{% endif %} -
- {% 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") }} +
+ + + {% if u.cores is none %}{% else %} + {% 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 %}{% else %} + {% endif %} - + {% endfor %} - + +
ContainerHostCPUMemory
+ {%- if session.user_id %}{{ r.label }}{% else %}{{ r.label }}{% endif -%} + {{ r.host_name }}— + {{- "%.2f"|format(u.cores) if u.cores < 1 else "%.1f"|format(u.cores) }} cores— + {{- u.mem_bytes|bytes }}
+
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).
{% endif %} diff --git a/steward/static/steward.css b/steward/static/steward.css index a4461d3..e61cdb6 100644 --- a/steward/static/steward.css +++ b/steward/static/steward.css @@ -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 */ diff --git a/tests/plugins/docker/test_docker_page.py b/tests/plugins/docker/test_docker_page.py index 19614c0..ffeabf4 100644 --- a/tests/plugins/docker/test_docker_page.py +++ b/tests/plugins/docker/test_docker_page.py @@ -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: CI: Android · build 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("CI: Android · build<" in page # the long CI name only as the hover title + assert not hex_colours(page), hex_colours(page)