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:
@@ -0,0 +1,82 @@
|
||||
# plugins/docker/disk_view.py
|
||||
"""What Docker keeps on each host's disk, over time and by image repository
|
||||
(#5304–#5306). Model-free, so it is unit-tested without a database.
|
||||
|
||||
The agent reports `docker system df` as current state; ingest.py records its
|
||||
totals as metrics too (DISK_METRICS, resource = the host's name), so each
|
||||
host's growth has a history and an alert rule can watch it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
# metric name → the df summary field it records; "disk_total_bytes" is their sum.
|
||||
DISK_PARTS = {
|
||||
"disk_images_bytes": "images_size",
|
||||
"disk_containers_bytes": "containers_size",
|
||||
"disk_volumes_bytes": "volumes_size",
|
||||
"disk_build_cache_bytes": "build_cache_size",
|
||||
}
|
||||
DISK_METRICS = [*DISK_PARTS, "disk_total_bytes", "disk_reclaimable_bytes"]
|
||||
|
||||
|
||||
def disk_metrics(disk: dict) -> dict[str, float]:
|
||||
"""The metric samples one df report gives: each part, their total, and
|
||||
what unused images hold."""
|
||||
out = {m: float(int(disk.get(f) or 0)) for m, f in DISK_PARTS.items()}
|
||||
out["disk_total_bytes"] = sum(out.values())
|
||||
out["disk_reclaimable_bytes"] = float(int(disk.get("images_reclaimable") or 0))
|
||||
return out
|
||||
|
||||
|
||||
def growth(points: list[tuple[datetime, float]]) -> dict | None:
|
||||
"""How much a total changed over the points given (oldest first): the
|
||||
change, the first point's time and value. None with fewer than two."""
|
||||
if len(points) < 2:
|
||||
return None
|
||||
(first_at, first), (_, last) = points[0], points[-1]
|
||||
return {"change": last - first, "since": first_at, "from": first, "to": last,
|
||||
"pct": (last - first) / first * 100 if first else None}
|
||||
|
||||
|
||||
def image_repo(repo_tag: str | None) -> tuple[str | None, str | None]:
|
||||
"""("ghcr.io/x/app", "1.2") for "ghcr.io/x/app:1.2"; a registry's port is
|
||||
part of the repository ("reg:5000/app" has no tag); a digest reference
|
||||
keeps its digest as the tag; (None, None) for an untagged image."""
|
||||
if not repo_tag or repo_tag == "<none>" or repo_tag.startswith("<none>"):
|
||||
return None, None
|
||||
if "@" in repo_tag:
|
||||
repo, _, digest = repo_tag.partition("@")
|
||||
return repo, digest[:19]
|
||||
head, sep, tag = repo_tag.rpartition(":")
|
||||
if not sep or "/" in tag:
|
||||
return repo_tag, None
|
||||
return head, tag
|
||||
|
||||
|
||||
def group_images(images: list[dict]) -> list[dict]:
|
||||
"""Images folded under their repository, the most space first.
|
||||
|
||||
Each image: {repo_tag, image_id, size, shared_size, containers}. A group's
|
||||
`own` counts each version's size less the layers it shares with other
|
||||
images, so a repository is not charged twice for one base layer;
|
||||
`reclaimable` is that, for the versions no container uses. Untagged images
|
||||
form one group of their own (repo None)."""
|
||||
groups: dict[str | None, dict] = {}
|
||||
for im in images:
|
||||
repo, tag = image_repo(im.get("repo_tag"))
|
||||
g = groups.setdefault(repo, {"repo": repo, "versions": [], "own": 0, "size": 0,
|
||||
"reclaimable": 0, "containers": 0})
|
||||
own = max(int(im.get("size") or 0) - int(im.get("shared_size") or 0), 0)
|
||||
used = int(im.get("containers") or 0)
|
||||
g["versions"].append({"tag": tag, "image_id": im.get("image_id"), "size": int(im.get("size") or 0),
|
||||
"shared": int(im.get("shared_size") or 0), "own": own, "containers": used})
|
||||
g["own"] += own
|
||||
g["size"] = max(g["size"], int(im.get("size") or 0))
|
||||
g["containers"] += used
|
||||
if not used:
|
||||
g["reclaimable"] += own
|
||||
for g in groups.values():
|
||||
g["versions"].sort(key=lambda v: (v["containers"] == 0, -v["size"]))
|
||||
g["unused"] = sum(1 for v in g["versions"] if not v["containers"])
|
||||
return sorted(groups.values(), key=lambda g: (g["repo"] is None, -g["own"], g["repo"] or ""))
|
||||
Reference in New Issue
Block a user