feat(docker): Busiest Containers shows bars of the last hours beside each figure, scaled to the row's peak with a floor (#5214)
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:
@@ -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)}}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user