feat(docker): Busiest Containers shows bars of the last hours beside each figure, scaled to the row's peak with a floor (#5214)
CI / lint (push) Successful in 4s
CI / integration (push) Failing after 59s
CI / unit (push) Failing after 1m27s
CI / publish (push) Skipped

The fill bars were a share of the host's capacity, a sliver on hosts with
80 cores. Each figure now sits beside 24 bars of the last 6 hours (1h/24h
per widget), each row scaled to its own peak but never below 1 core or
1 GiB (configurable per widget), so a 0.05-core container reads flat and
a bar's height means real load. One grouped date_bin query feeds them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 15:05:06 -04:00
co-authored by Claude Opus 5.5
parent 97c864bf42
commit 05eaca3f2f
7 changed files with 242 additions and 36 deletions
+35 -9
View File
@@ -269,10 +269,16 @@ CPU_AVERAGE_MINUTES = 5
async def widget_resources():
"""HTMX dashboard widget: the containers using the most CPU, averaged over
the last few minutes so the order holds still between polls, with their
CPU in cores and memory in bytes."""
limit = max(1, min(20, int(request.args.get("limit", 10) or 10)))
CPU in cores and memory in bytes, each beside bars of the last few hours
scaled to the row's own peak (never below a floor, #5214)."""
limit = max(1, min(20, _arg_number("limit", 10, int)))
hours = max(1, min(24, _arg_number("hours", 6, int)))
cpu_floor = max(0.1, _arg_number("cpu_floor", 1.0, float))
mem_floor = max(0.1, _arg_number("mem_floor", 1.0, float))
widget_id = request.args.get("wid", "0")
since = datetime.now(timezone.utc) - timedelta(minutes=CPU_AVERAGE_MINUTES)
now = datetime.now(timezone.utc)
since = now - timedelta(minutes=CPU_AVERAGE_MINUTES)
slots, width = docker_svc.bar_slots(now, hours)
async with current_app.db_sessionmaker() as db:
# Dedup before the limit, else swarm tasks reported by both managers can
@@ -284,28 +290,48 @@ async def widget_resources():
.where(DockerMetric.scraped_at >= since)
.group_by(DockerMetric.host_id, DockerMetric.container_name)
)).all()}
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})
def cpu_of(c):
v = average.get((c.host_id, c.name))
return c.cpu_pct if v is None else float(v)
def cpu_of(c):
v = average.get((c.host_id, c.name))
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]
history = await docker_svc.recent_usage(
db, {(c.host_id, c.name) for c in busiest}, slots[0], width)
hosts = await docker_svc.host_map(db, {c.host_id for c in busiest})
capacity = await docker_svc.host_capacity(db, {c.host_id for c in busiest})
def bars(c, i, scale, floor):
seen = history.get((c.host_id, c.name), {})
values = [None if seen.get(t) is None or seen[t][i] is None else seen[t][i] * scale
for t in slots]
return docker_svc.scaled_bars(values, floor)
busiest = sorted(running, key=lambda c: (-(cpu_of(c) or 0), c.name))[:limit]
rows_data = [{
"c": c,
"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)),
"cpu_bars": bars(c, 0, 0.01, cpu_floor), # cpu_pct → cores
"mem_bars": bars(c, 1, 1.0, mem_floor * 2**30), # bytes; floor in GiB
} for c in busiest]
return await render_template(
"docker/widget_resources.html",
rows=rows_data,
minutes=CPU_AVERAGE_MINUTES,
hours=hours, cpu_floor=cpu_floor, mem_floor=mem_floor * 2**30,
widget_id=widget_id,
)
def _arg_number(key, default, kind):
"""A numeric query argument, or `default` when missing or not a number."""
try:
return kind(request.args.get(key, default))
except (TypeError, ValueError):
return default
@docker_bp.get("/host/<host_id>")
@require_role(UserRole.viewer)
async def host_panel(host_id: str):
+50
View File
@@ -328,6 +328,56 @@ async def container_points(db, host_id: str, name: str, metric: str, since,
return [(t, float(v)) for t, v in hourly] + [(t, float(v)) for t, v in raw]
# Busiest Containers bars (#5214): the recent past in BARS equal slices.
BARS = 24
def bar_slots(now: datetime, hours: int, bars: int = BARS) -> tuple[list[datetime], int]:
"""The start of each of the last `bars` buckets covering `hours`, oldest
first, aligned as date_bin aligns them (to the epoch), and their width in
seconds. The newest bucket is the one `now` falls in."""
width = max(60, hours * 3600 // bars)
last = int(now.timestamp()) // width * width
return [datetime.fromtimestamp(last - (bars - 1 - i) * width, timezone.utc)
for i in range(bars)], width
async def recent_usage(db, keys, since: datetime, width: int) -> dict:
"""{(host_id, container_name): {bucket start: (cpu_pct, mem_usage_bytes)}}
averaged per bucket since `since`, for the given containers, in one query."""
keys = set(keys)
if not keys:
return {}
bucket = func.date_bin(
func.make_interval(0, 0, 0, 0, 0, 0, int(width)), DockerMetric.scraped_at, func.to_timestamp(0),
).label("bucket")
rows = (await db.execute(
select(DockerMetric.host_id, DockerMetric.container_name, bucket,
func.avg(DockerMetric.cpu_pct), func.avg(DockerMetric.mem_usage_bytes))
.where(DockerMetric.scraped_at >= since,
DockerMetric.host_id.in_({h for h, _ in keys}),
DockerMetric.container_name.in_({n for _, n in keys}))
.group_by(DockerMetric.host_id, DockerMetric.container_name, bucket)
)).all()
out: dict = {}
for host_id, name, at, cpu, mem in rows:
if (host_id, name) in keys:
out.setdefault((host_id, name), {})[at] = (
None if cpu is None else float(cpu), None if mem is None else float(mem))
return out
def scaled_bars(values: list, floor: float) -> dict:
"""Bar heights in % of a roof: the row's own peak, but never below `floor`,
so a container idling at 0.05 cores draws flat and its 0.05 → 0.10 wobble
doesn't look like a 1.5 → 3 core spike. None (no samples) stays a gap."""
present = [v for v in values if v is not None]
peak = max(present) if present else None
roof = max(peak or 0.0, floor)
return {"bars": [None if v is None else round(100.0 * v / roof, 1) for v in values],
"peak": peak, "roof": roof}
async def search_logs(db, *, since, host_id: str | None = None, name: str | None = None,
text: str = "", stream: str = "", before=None, limit: int = 50) -> list:
"""Retained log lines matching a filter, newest first (limit + 1 fetched by callers
@@ -1,20 +1,29 @@
{# 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). #}
(#5253, #5214): a verdict naming the busiest, then the top containers by CPU
(averaged over the last few minutes, so the order holds still). Each figure
carries its unit, beside bars of the last few hours. A row's bars are scaled
to its own peak, but never below a floor (1 core, 1 GiB by default), so a
container idling at 0.05 cores reads flat and a spike means real load. #}
{% macro cores(v) -%}{{ "%.2f"|format(v) if v < 1 else "%.1f"|format(v) }}{%- endmacro %}
{% macro bars(b, peak_text, roof_text) -%}
<span class="w-bars w-wide-only" aria-hidden="true"
title="{% if b.peak is none %}No samples in the last {{ hours }}h{% else %}Peak {{ peak_text }} in the last {{ hours }}h; bars reach the top at {{ roof_text }}{% endif %}">
{%- for h in b.bars %}<i{% if h is none %} class="gap"{% else %} style="height:{{ [h, 4]|max }}%"{% endif %}></i>{% endfor -%}
</span>
{%- endmacro %}
{% if not rows %}
<div class="w-verdict" style="color:var(--text-muted);">No running containers.</div>
{% else %}
{% 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.cores is not none %}{{ cores(top.usage.cores) }}
{%- if top.usage.cpus %} of {{ top.usage.cpus }} cores{% else %} cores{% endif %}{% else %}no CPU reading yet{% endif %}.
</div>
<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 w-text">Host</th><th>CPU</th><th>Memory</th></tr></thead>
<colgroup><col><col class="w-wide-only" style="width:7.5rem"><col class="w-barcol"><col class="w-barcol"></colgroup>
<thead><tr><th>Container</th><th class="w-wide-only w-text">Host</th>
<th title="Cores, averaged over the last {{ minutes }} minutes">CPU</th><th title="In use now">Memory</th></tr></thead>
<tbody>
{% for r in rows %}
{% set u = r.usage %}
@@ -23,19 +32,19 @@
{%- 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 w-text" 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 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 %}
<td class="tone-{{ status_class(u.cpu_share, 'cpu') if u.cpus and u.cores is not none else 'dim' }}"
{% if u.cpus and u.cores is not none %}title="{{ '%.1f'|format(u.cpu_share) }}% of {{ r.host_name }}'s {{ u.cpus }} cores"{% endif %}>
{{- bars(r.cpu_bars, (cores(r.cpu_bars.peak) ~ " cores") if r.cpu_bars.peak is not none else "", cores(r.cpu_bars.roof) ~ " cores") -}}
{%- if u.cores is none %}—{% else %}{{ cores(u.cores) }} cores{% endif -%}
</td>
<td class="tone-{{ status_class(u.mem_pct, 'mem') if u.mem_of and u.mem_bytes is not none else 'dim' }}"
{% if u.mem_pct is not none and u.mem_of and u.mem_bytes is not none %}title="{{ '%.1f'|format(u.mem_pct) }}% of {{ u.mem_of }}"{% endif %}>
{{- bars(r.mem_bars, r.mem_bars.peak|bytes if r.mem_bars.peak is not none else "", r.mem_bars.roof|bytes) -}}
{%- if u.mem_bytes is none %}—{% else %}{{ u.mem_bytes|bytes }}{% endif -%}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="w-note" title="Shading: share of the host's cores, and of the memory limit (or the host's memory when no limit is set).">CPU over the last {{ minutes }} min; shading is the share of the host.</div>
<div class="w-note w-wide-only">Bars: last {{ hours }}h, each row up to its own peak, at least {{ cores(cpu_floor) }} core{{ 's' if cpu_floor != 1 }} and {{ mem_floor|bytes }}.</div>
{% endif %}
+36 -1
View File
@@ -201,7 +201,7 @@ WIDGET_REGISTRY: dict[str, dict] = {
"docker_resources": {
"key": "docker_resources",
"label": "Docker — Busiest containers",
"description": "The containers using the most CPU (cores, 5-minute average) and their memory",
"description": "The containers using the most CPU and memory, with bars of the last few hours",
"hx_url": "/plugins/docker/widget/resources",
"detail_url": "/plugins/docker/",
"plugin": "docker",
@@ -218,6 +218,41 @@ WIDGET_REGISTRY: dict[str, dict] = {
{"value": 20, "label": "20 containers"},
],
},
{
"key": "hours",
"label": "Bars cover",
"type": "select",
"default": 6,
"options": [
{"value": 1, "label": "Last hour"},
{"value": 6, "label": "Last 6 hours"},
{"value": 24, "label": "Last 24 hours"},
],
},
{
"key": "cpu_floor",
"label": "CPU bars scale to at least",
"type": "select",
"default": 1.0, # a float: the add form keeps fractions
"options": [
{"value": 0.5, "label": "0.5 cores"},
{"value": 1.0, "label": "1 core"},
{"value": 2.0, "label": "2 cores"},
{"value": 4.0, "label": "4 cores"},
],
},
{
"key": "mem_floor",
"label": "Memory bars scale to at least",
"type": "select",
"default": 1.0, # GiB
"options": [
{"value": 0.25, "label": "256 MiB"},
{"value": 1.0, "label": "1 GiB"},
{"value": 4.0, "label": "4 GiB"},
{"value": 16.0, "label": "16 GiB"},
],
},
],
},
"snmp_devices": {
+9 -1
View File
@@ -409,7 +409,15 @@ body.dash-editing .widget-drawer { transform:translateY(0); }
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; } }
.w-table col.w-barcol { width:9.5rem; }
/* Bars of a figure's recent past, inline before it (Busiest Containers, #5214):
the row's tone, faint, with the newest bar solid. A gap is a slice with no
samples. Heights are set per bar as a % of the row's roof. */
.w-bars { display:inline-flex; align-items:flex-end; gap:1px; height:0.95rem; width:4.3rem; margin-right:0.45rem; vertical-align:-0.15rem; }
.w-bars i { flex:1; min-height:1px; background:currentColor; opacity:0.4; border-radius:1px 1px 0 0; }
.w-bars i:last-child { opacity:1; }
.w-bars i.gap { background:none; }
@container (max-width: 26rem) { .w-wide-only { display:none; } .w-table col.w-barcol { width:5.5rem; } }
/* Time range selector */
@@ -0,0 +1,49 @@
"""Integration: Busiest Containers bars average each container's samples per
time bucket in one query (#5214). Two samples share a bucket and must merge
(lesson #5262). Requires STEWARD_DATABASE_URL."""
from __future__ import annotations
import asyncio
import os
import uuid
from datetime import datetime, timedelta, timezone
import pytest
pytestmark = pytest.mark.integration
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
def test_recent_usage_averages_each_containers_bucket(app):
from plugins.docker.models import DockerMetric
from plugins.docker.service import bar_slots, recent_usage
from steward.models.hosts import Host
tag = uuid.uuid4().hex[:8]
hid = str(uuid.uuid4())
slots, width = bar_slots(datetime.now(timezone.utc), 6)
at = slots[-2] # a whole bucket, in the past
def m(name, secs, cpu, mem):
return DockerMetric(host_id=hid, container_name=name, scraped_at=at + timedelta(seconds=secs),
cpu_pct=cpu, mem_pct=0.0, mem_usage_bytes=mem)
async def go():
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(Host(id=hid, name=f"dock-{tag}", address="10.9.9.4"))
async with s.begin():
s.add_all([m(f"a-{tag}", 10, 100.0, 1000), m(f"a-{tag}", 70, 300.0, 3000),
m(f"b-{tag}", 10, 50.0, 500)])
async with s.begin():
return await recent_usage(s, {(hid, f"a-{tag}")}, slots[0], width)
got = asyncio.run(go())
assert got == {(hid, f"a-{tag}"): {at: (200.0, 2000.0)}}
+36 -7
View File
@@ -218,23 +218,52 @@ def test_a_container_reads_by_its_service_or_ci_job():
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."""
def _bars(values, floor):
return svc.scaled_bars(values, floor)
async def test_busiest_containers_show_the_number_beside_its_recent_bars(app):
"""#5253, #5214: every figure has its unit; the bars beside it are the last
hours, scaled to the row's own peak; 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}},
"mem_pct": 5.0, "mem_of": "the host's memory", "mem_limit": None},
"cpu_bars": _bars([None, 0.8, 3.2, 1.6], 1.0), "mem_bars": _bars([None] * 4, 2**30)},
{"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}},
"mem_pct": None, "mem_of": None, "mem_limit": None},
"cpu_bars": _bars([0.25] * 4, 1.0), "mem_bars": _bars([None] * 4, 2**30)},
]
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")
rows=rows, minutes=5, hours=6, cpu_floor=1.0,
mem_floor=2**30, 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 ">1.6 cores<" in page and ">0.25 cores<" in page and ">820.0 MiB<" in page and ">—<" in page
# Android peaks at 3.2 cores, so 1.6 is half height; tdarr's 0.25 sits under the 1-core floor.
assert 'class="gap"' in page and "height:50.0%" in page and "height:25.0%" in page
assert "Peak 3.2 cores in the last 6h; bars reach the top at 3.2 cores" in page
assert "Peak 0.25 cores in the last 6h; bars reach the top at 1.0 cores" in page
assert "No samples in the last 6h" in page
assert len(re.findall(r"<th[ >]", page)) == 4
assert ">CI: Android · build<" in page # the long CI name only as the hover title
assert not hex_colours(page), hex_colours(page)
def test_bars_reach_the_peak_but_never_below_the_floor():
"""#5214: a 0.05 → 0.10 wobble stays near the floor; a 1.5 → 3 spike fills."""
idle = svc.scaled_bars([0.05, 0.10, None], 1.0)
assert idle == {"bars": [5.0, 10.0, None], "peak": 0.10, "roof": 1.0}
busy = svc.scaled_bars([1.5, 3.0], 1.0)
assert busy["bars"] == [50.0, 100.0] and busy["roof"] == 3.0
assert svc.scaled_bars([None, None], 1.0) == {"bars": [None, None], "peak": None, "roof": 1.0}
def test_bar_slots_end_at_the_bucket_now_falls_in():
now = datetime(2026, 10, 7, 12, 7, 30, tzinfo=timezone.utc)
slots, width = svc.bar_slots(now, 6)
assert width == 900 and len(slots) == 24
assert slots[-1] == datetime(2026, 10, 7, 12, 0, tzinfo=timezone.utc)
assert slots[0] == datetime(2026, 10, 7, 6, 15, tzinfo=timezone.utc)