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