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