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)
- #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:
+32
-20
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user