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
+12 -3
View File
@@ -611,7 +611,8 @@ def test_disk_usage_persisted(app):
async with s.begin():
await s.execute(text("DELETE FROM docker_images"))
await s.execute(text("DELETE FROM docker_disk_usage"))
h = Host(id=str(uuid.uuid4()), name="dsk", address="10.6.6.6")
hid = str(uuid.uuid4())
h = Host(id=hid, name=f"dsk-{hid[:8]}", address="10.6.6.6")
s.add(h)
await s.flush()
hid = h.id
@@ -631,9 +632,17 @@ def test_disk_usage_persisted(app):
reclaimable2 = (await s.execute(text(
"SELECT images_reclaimable FROM docker_disk_usage WHERE host_id=:h"),
{"h": hid})).scalar()
return summary, img_count, [r[0] for r in after], reclaimable2
# Each report is recorded as metrics too, resource = the host (#5304).
metrics = (await s.execute(text(
"SELECT metric_name, value FROM plugin_metrics WHERE source_module = 'docker' "
"AND resource_name = :r"), {"r": f"dsk-{hid[:8]}"})).all()
return summary, img_count, [r[0] for r in after], reclaimable2, metrics
summary, img_count, after_ids, reclaimable2 = asyncio.run(_go())
summary, img_count, after_ids, reclaimable2, metrics = asyncio.run(_go())
assert {v for m, v in metrics if m == "disk_total_bytes"} == {1155.0} # 1000 + 100 + 40 + 15
assert sorted(v for m, v in metrics if m == "disk_reclaimable_bytes") == [0.0, 700.0]
assert {m for m, _ in metrics} == {"disk_images_bytes", "disk_containers_bytes", "disk_volumes_bytes",
"disk_build_cache_bytes", "disk_total_bytes", "disk_reclaimable_bytes"}
assert summary[0] == 700 and summary[1] == 2 and summary[2] == 15
assert img_count == 2
assert after_ids == ["aaaaaaaaaaaa"] # the unreferenced image pruned on re-report
@@ -0,0 +1,87 @@
"""Integration: the Disk page's hosts side by side with their growth (#5304)
and the overview's disk line (#5306), from the totals ingest records as
metrics. Requires STEWARD_DATABASE_URL."""
from __future__ import annotations
import asyncio
import importlib
import os
import time
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 _docker(sub):
"""The app's loaded docker plugin module (see test_docker_recent_usage)."""
from steward.core.plugin_manager import get_loaded_plugin_modules
loaded = get_loaded_plugin_modules().get("docker")
base = loaded.__name__ if loaded is not None else "plugins.docker"
return importlib.import_module(f"{base}.{sub}")
def test_the_disk_page_says_how_much_each_host_grew(app):
from steward.models.hosts import Host
from steward.models.metrics import PluginMetric
from steward.models.users import User, UserRole
m = _docker("models")
disk_hosts = _docker("service").disk_hosts
tag = uuid.uuid4().hex[:8]
hid, uid = str(uuid.uuid4()), str(uuid.uuid4())
name = f"disk-{tag}"
now = datetime.now(timezone.utc)
gib = 1 << 30
async def go():
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=f"dp-{tag}", email=f"dp-{tag}@test",
password_hash="x", role=UserRole.viewer, is_active=True))
s.add(Host(id=hid, name=name, address="10.9.6.1"))
async with s.begin():
s.add(m.DockerDiskUsage(host_id=hid, images_size=30 * gib, containers_size=2 * gib,
volumes_size=10 * gib, build_cache_size=0, images_reclaimable=5 * gib,
images_total=12, images_active=8, updated_at=now))
s.add(m.DockerImage(host_id=hid, image_id="sha256:" + "a" * 64, repo_tag=f"ghcr.io/x/app{tag}:1",
size=gib, shared_size=0, containers=1, updated_at=now))
s.add(m.DockerImage(host_id=hid, image_id="sha256:" + "b" * 64, repo_tag=f"ghcr.io/x/app{tag}:0",
size=gib, shared_size=0, containers=0, updated_at=now))
s.add_all([PluginMetric(source_module="docker", resource_name=name, metric_name="disk_total_bytes",
value=float(v * gib), recorded_at=now - timedelta(days=d))
for d, v in ((6, 40), (3, 41), (0, 42))])
async with s.begin():
rows = await disk_hosts(s, now - timedelta(days=7))
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role="viewer", username=f"dp-{tag}", login_at=time.time())
frag = await (await client.get("/plugins/docker/disk/hosts?range=7d")).get_data(as_text=True)
page = await (await client.get("/plugins/docker/disk")).get_data(as_text=True)
index = await (await client.get("/plugins/docker/")).get_data(as_text=True)
async with app.db_sessionmaker() as s:
async with s.begin():
await s.delete(await s.get(Host, hid))
await s.delete(await s.get(User, uid))
return rows, frag, page, index
rows, frag, page, index = asyncio.run(go())
(row,) = [r for r in rows if r["host_id"] == hid]
assert row["total"] == 42 * gib and row["reclaimable"] == 5 * gib
assert row["growth"]["change"] == 2 * gib and row["growth"]["from"] == 40 * gib
assert f'<a href="#host-{hid}">{name}</a>' in frag and "+2.0 GiB" in frag and "<polyline" in frag
assert f'id="host-{hid}"' in page and f"ghcr.io/x/app{tag}" in page and "(1 unused)" in page
assert 'hx-get="/plugins/docker/disk/hosts"' in page
assert f'href="/plugins/docker/disk#host-{hid}">{name}</a>' in index and "+2.0 GiB in 7d" in index
+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"')