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 %}
|
||||
|
||||
Reference in New Issue
Block a user