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
+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)