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
+32 -20
View File
@@ -110,11 +110,15 @@ async def index():
has_disk = (await db.execute(
select(DockerDiskUsage.host_id).limit(1))).first() is not None
choices = await _filter_choices(db)
# One line per host under the tables: what Docker keeps on its disk and
# how much that grew in a week (#5306); the Disk page has the rest.
disk = await docker_svc.disk_hosts(
db, datetime.now(timezone.utc) - timedelta(days=7), points=14) if has_disk else []
return await render_template(
"docker/index.html",
poll_interval=poll_interval,
has_swarm=has_swarm,
has_disk=has_disk,
has_disk=has_disk, disk=disk,
f=_filters(request.args), choices=choices, states=DOCKER_STATES, no_stack=NO_STACK,
)
@@ -627,7 +631,9 @@ IMAGES_REPORTED = 50
@docker_bp.get("/disk")
@require_role(UserRole.viewer)
async def disk():
"""Image/disk usage page: reclaimable space, per-image sizes, stopped count.
"""What Docker keeps on each host's disk (#5304, #5305): the hosts side by
side with how much each grew over the selected range (the /disk/hosts
fragment), then each host's parts, cleanup and images by repository.
Admins can reclaim space per host via the prune buttons, which fire the
audited bundled prune playbook through Ansible (m78) — the collection agent
@@ -635,6 +641,7 @@ async def disk():
"""
from steward.core.capabilities import has_capability
from steward.models.ansible_inventory import AnsibleTarget
from .disk_view import group_images
async with current_app.db_sessionmaker() as db:
summaries = list((await db.execute(select(DockerDiskUsage))).scalars())
@@ -664,43 +671,48 @@ async def disk():
images_by_host: dict[str, list] = {}
for im in images:
images_by_host.setdefault(im.host_id, []).append({
"repo_tag": None if im.repo_tag in ("<none>", "", None) else im.repo_tag,
"image_id": im.image_id, "size": human_bytes(im.size),
"shared": human_bytes(im.shared_size), "containers": im.containers,
"reclaimable": im.containers == 0,
})
"repo_tag": im.repo_tag, "image_id": im.image_id, "size": im.size,
"shared_size": im.shared_size, "containers": im.containers})
host_groups = [
{
"host_id": s.host_id,
"host_name": hosts[s.host_id].name if s.host_id in hosts else s.host_id,
"as_of": s.updated_at,
"summary": {
"images_total": s.images_total, "images_active": s.images_active,
"images_size": human_bytes(s.images_size),
"images_reclaimable": human_bytes(s.images_reclaimable),
"layers_size": human_bytes(s.layers_size),
"containers_count": s.containers_count,
"containers_size": human_bytes(s.containers_size),
"volumes_count": s.volumes_count,
"volumes_size": human_bytes(s.volumes_size),
"build_cache_size": human_bytes(s.build_cache_size),
},
"summary": s,
"total": s.images_size + s.containers_size + s.volumes_size + s.build_cache_size,
"stopped": stopped_by_host.get(s.host_id, 0),
"images": images_by_host.get(s.host_id, []),
"repos": group_images(images_by_host.get(s.host_id, [])),
"images_listed": len(images_by_host.get(s.host_id, [])),
# The agent reports the largest IMAGES_REPORTED images (agent._disk_images).
"images_capped": len(images_by_host.get(s.host_id, [])) >= IMAGES_REPORTED,
"has_target": s.host_id in target_by_host,
}
for s in summaries
]
host_groups.sort(key=lambda g: g["host_name"].lower())
host_groups.sort(key=lambda g: (-g["total"], g["host_name"].lower()))
return await render_template(
"docker/disk.html", host_groups=host_groups,
ansible_available=has_capability("ansible.run_playbook"),
current_range=request.args.get("range", "7d"),
)
@docker_bp.get("/disk/hosts")
@require_role(UserRole.viewer)
async def disk_hosts_fragment():
"""HTMX fragment: every Docker host's disk side by side, with how much its
total grew over the selected range and a line of it (#5304)."""
from steward.core.status import sparkline_svg
since, range_key = parse_range(request.args.get("range") or "7d")
async with current_app.db_sessionmaker() as db:
rows = await docker_svc.disk_hosts(db, since)
for r in rows:
r["spark"] = sparkline_svg([v for _, v in r["points"]], width=120, height=24,
stroke="currentColor")
return await render_template("docker/_disk_hosts.html", rows=rows, range_key=range_key)
@docker_bp.post("/disk/<host_id>/prune")
@require_role(UserRole.admin)
async def disk_prune(host_id: str):