feat(docker): Docker disk is recorded over time per host, the Disk page shows each host's growth and folds images under their repository, and the overview has one disk line per host (#5304–#5306)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 54s
CI / unit (push) Successful in 1m21s
CI / publish (push) Skipped

- #5304 "Docker disk totals recorded over time per host, with a growth chart and alertable metrics"
- #5305 "Disk page: images grouped by repository, versions folded under it"
- #5306 "Docker overview: one disk line per host linking to the Disk page"

Each `docker system df` report is now also recorded as metrics, with the host
as the resource: disk_total_bytes, its parts, and disk_reclaimable_bytes. An
alert rule can watch them. The Disk page starts with the hosts side by side,
showing how much each grew over the selected range with a line of it, then
gives each host its own section. An image's versions fold under its
repository, which is charged only for the layers its versions don't share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 20:16:13 -04:00
co-authored by Claude Opus 5.5
parent 0b5cd23d38
commit 8566c8f78e
14 changed files with 513 additions and 143 deletions
+47 -19
View File
@@ -206,27 +206,55 @@ async def test_the_docker_page_has_a_filter_bar_the_rows_read(app):
assert 'hx-include="#docker-filters"' in page and "change from:#docker-filters" in page
async def test_the_disk_page_explains_its_terms_and_names_untagged_images(app):
"""#5212: no unexplained jargon, no "<none>" rows, and a capped list says so."""
summary = {"images_total": 312, "images_active": 40, "images_size": "40 GiB",
"images_reclaimable": "12 GiB", "layers_size": "30 GiB", "containers_count": 9,
"containers_size": "1.2 GiB", "volumes_count": 4, "volumes_size": "3 GiB",
"build_cache_size": "2 GiB"}
images = [{"repo_tag": "nginx:1", "image_id": "aaaaaaaaaaaa", "size": "180 MiB", "shared": "70 MiB",
"containers": 2, "reclaimable": False},
{"repo_tag": None, "image_id": "bbbbbbbbbbbb", "size": "700 MiB", "shared": "0 B",
"containers": 0, "reclaimable": True}]
async def test_the_disk_page_explains_its_terms_and_folds_images_by_repository(app):
"""#5212, #5305: no unexplained jargon, no "<none>" rows, a capped list says
so; an image's versions sit under its repository."""
from quart import session
from plugins.docker.disk_view import group_images
gib = 1 << 30
summary = NS(images_total=312, images_active=40, images_size=40 * gib, images_reclaimable=12 * gib,
containers_count=9, containers_size=gib, volumes_count=4, volumes_size=3 * gib,
build_cache_size=2 * gib)
repos = group_images([
{"repo_tag": "nginx:1.27", "image_id": "sha256:" + "a" * 64, "size": 180 << 20, "shared_size": 70 << 20, "containers": 2},
{"repo_tag": "nginx:1.25", "image_id": "sha256:" + "c" * 64, "size": 170 << 20, "shared_size": 70 << 20, "containers": 0},
{"repo_tag": "<none>", "image_id": "sha256:" + "b" * 64, "size": 700 << 20, "shared_size": 0, "containers": 0}])
group = {"host_id": "h1", "host_name": "nas", "as_of": NOW - timedelta(minutes=2), "summary": summary,
"stopped": 1, "images": images, "has_target": True, "images_capped": True}
"total": 46 * gib, "stopped": 1, "repos": repos, "images_listed": 3, "has_target": True,
"images_capped": True}
async with app.test_request_context("/plugins/docker/disk"):
page = await render_plugin_template(app, "docker", "docker/disk.html",
host_groups=[group], ansible_available=True)
assert "untagged</span> bbbbbbbbbbbb" in page and "&lt;none&gt;" not in page
assert "The 2 largest of 312 images" in page and "as of <time" in page
assert "Writable layers" not in page and "Container files" in page
assert "held by images no container uses" in page and "safe to clear" in page
assert "Shared with other images" in page and "2 containers" in page and ">nothing<" in page
assert "dangling" not in page
session["user_role"] = "admin"
page = await render_plugin_template(app, "docker", "docker/disk.html", host_groups=[group],
ansible_available=True, current_range="7d")
page = re.sub(r">\s+<", "><", " ".join(page.split()))
assert 'hx-get="/plugins/docker/disk/hosts"' in page and 'id="time-range"' in page
assert 'id="host-h1"' in page and "as of <time" in page
assert '<span class="tone-muted">untagged</span>' in page and "&lt;none&gt;" not in page
assert "the 3 largest of 312 images" in page
assert '<span class="mono">nginx</span>' in page and ">1.27<" in page and ">1.25<" in page
assert "(1 unused)" in page and ">2 containers<" in page and ">nothing<" in page
assert "Container files" in page and "held by images no container uses" in page and "safe to clear" in page
assert page.count('action="/plugins/docker/disk/h1/prune"') == 3
assert "Writable layers" not in page and "dangling" not in page
async def test_the_disk_hosts_say_how_much_each_grew(app):
gib = 1 << 30
rows = [{"host_id": "h1", "host_name": "docker02", "total": 42 * gib, "reclaimable": 5 * gib,
"parts": {"disk_images_bytes": 30 * gib, "disk_volumes_bytes": 10 * gib,
"disk_containers_bytes": 2 * gib, "disk_build_cache_bytes": 0},
"growth": {"change": 6 * gib, "since": NOW - timedelta(days=7), "from": 36 * gib, "to": 42 * gib,
"pct": 16.7}, "spark": "<svg></svg>"},
{"host_id": "h2", "host_name": "util01", "total": gib, "reclaimable": 0,
"parts": {"disk_images_bytes": gib, "disk_volumes_bytes": 0, "disk_containers_bytes": 0,
"disk_build_cache_bytes": 0}, "growth": None, "spark": "<svg></svg>"}]
async with app.test_request_context("/plugins/docker/disk/hosts"):
page = await render_plugin_template(app, "docker", "docker/_disk_hosts.html", rows=rows, range_key="7d")
page = re.sub(r">\s+<", "><", " ".join(page.split()))
assert '<a href="#host-h1">docker02</a>' in page and "<strong>42.0 GiB</strong>" in page
assert 'class="tone-warn"' in page and ">+6.0 GiB<" in page and "Change (7d)" in page
assert "growth shows once there are two readings" in page
assert not hex_colours(page), hex_colours(page)
def _widget_svc(name, state="healthy", running=1, desired=1, hosts=("VDNT-Docker02",), **kw):