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):
+54
View File
@@ -276,3 +276,57 @@ async def test_the_swarm_page_is_a_card_per_node(app):
assert "Containers use <strong>0.50 cores</strong> of 4 · <strong>1.0 GiB</strong> of 8.0 GiB" in page
assert "No Steward agent" in page
assert '<a href="/plugins/docker/service/arr" title="arr">arr</a>' in page
def test_an_image_reference_splits_into_repository_and_tag():
from plugins.docker.disk_view import image_repo
assert image_repo("nginx:1.27") == ("nginx", "1.27")
assert image_repo("ghcr.io/x/app:2026.10.07") == ("ghcr.io/x/app", "2026.10.07")
assert image_repo("reg.local:5000/app") == ("reg.local:5000/app", None) # a port is not a tag
assert image_repo("reg.local:5000/app:3") == ("reg.local:5000/app", "3")
assert image_repo("app@sha256:" + "f" * 64) == ("app", "sha256:ffffffffffff")
assert image_repo("<none>") == (None, None) and image_repo("<none>:<none>") == (None, None)
assert image_repo(None) == (None, None)
def test_images_fold_under_their_repository_by_the_space_they_hold():
from plugins.docker.disk_view import group_images
repos = group_images([
{"repo_tag": "app:1", "image_id": "a", "size": 500, "shared_size": 100, "containers": 1},
{"repo_tag": "app:0", "image_id": "b", "size": 450, "shared_size": 100, "containers": 0},
{"repo_tag": "<none>", "image_id": "c", "size": 9000, "shared_size": 0, "containers": 0},
{"repo_tag": "db:16", "image_id": "d", "size": 300, "shared_size": 0, "containers": 1}])
assert [r["repo"] for r in repos] == ["app", "db", None] # untagged last
app_ = repos[0]
assert app_["own"] == 750 and app_["reclaimable"] == 350 and app_["unused"] == 1
assert [v["tag"] for v in app_["versions"]] == ["1", "0"] and app_["containers"] == 1
def test_disk_metrics_and_growth():
from plugins.docker.disk_view import disk_metrics, growth
got = disk_metrics({"images_size": 100, "containers_size": 10, "volumes_size": 5,
"build_cache_size": 1, "images_reclaimable": 40, "layers_size": 999})
assert got["disk_total_bytes"] == 116.0 and got["disk_reclaimable_bytes"] == 40.0
assert growth([(NOW, 1.0)]) is None
g = growth([(NOW - timedelta(days=7), 100.0), (NOW - timedelta(days=1), 90.0), (NOW, 125.0)])
assert g["change"] == 25.0 and g["pct"] == 25.0 and g["since"] == NOW - timedelta(days=7)
assert growth([(NOW, 0.0), (NOW, 5.0)])["pct"] is None
async def test_the_overview_has_one_disk_line_per_host(app):
gib = 1 << 30
disk = [{"host_id": "h1", "host_name": "docker02", "total": 42 * gib, "reclaimable": 5 * gib,
"growth": {"change": 2 * gib, "pct": 5.0, "since": NOW - timedelta(days=7)}},
{"host_id": "h2", "host_name": "util01", "total": gib, "reclaimable": 0, "growth": None}]
async with app.test_request_context("/plugins/docker/"):
session["user_role"] = "viewer"
page = await render_plugin_template(
app, "docker", "docker/index.html", poll_interval=10, has_swarm=False, has_disk=True, disk=disk,
f={"q": "", "stack": "", "host": "", "state": "", "sort": "status", "dir": "asc"},
choices={"stacks": [], "hosts": []}, states={}, no_stack="__none__")
page = re.sub(r">\s+<", "><", " ".join(page.split()))
line = page[page.index('class="docker-disk-line"'):]
line = line[:line.index("</p>")]
assert '<a href="/plugins/docker/disk#host-h1">docker02</a> 42.0 GiB' in line and "+2.0 GiB in 7d" in line
assert "5.0 GiB reclaimable" in line and '<a href="/plugins/docker/disk#host-h2">util01</a> 1.0 GiB' in line
assert page.index('id="docker-rows"') < page.index('class="docker-disk-line"')