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
+4 -2
View File
@@ -67,5 +67,7 @@ def get_nav() -> list[dict]:
def get_alert_metrics() -> dict[str, list[str]]:
"""What ingest.py records per container (resource "<host>/<container>"):
restart_count is cumulative (alert on crash-looping); is_healthy is 1.0 /
0.0 from the container's HEALTHCHECK, only for containers that define one."""
return {"docker": ["cpu_pct", "mem_pct", "restart_count", "is_healthy"]}
0.0 from the container's HEALTHCHECK, only for containers that define one.
Per host (resource "<host>"), what Docker keeps on its disk (#5304)."""
from .disk_view import DISK_METRICS
return {"docker": ["cpu_pct", "mem_pct", "restart_count", "is_healthy", *DISK_METRICS]}
+82
View File
@@ -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 ""))
+8
View File
@@ -240,6 +240,14 @@ async def _persist_disk(session, host, disk: dict) -> None:
summary.containers_count = int(disk.get("containers_count") or 0)
summary.volumes_count = int(disk.get("volumes_count") or 0)
summary.updated_at = now
# The totals as metrics too (#5304): a history of each host's growth, and
# something an alert rule can watch. Resource: the host's name.
from steward.core.alerts import MetricBatch
from .disk_view import disk_metrics
batch = MetricBatch()
for metric, value in disk_metrics(disk).items():
batch.add(source_module="docker", resource_name=host.name, metric_name=metric, value=value)
await batch.record(session)
images = disk.get("images") or []
seen: set[str] = set()
+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):
+22
View File
@@ -683,3 +683,25 @@ def stale_after_seconds(config) -> int:
"""How old a container's last report may be before the page calls it
stale: the host agent's own staleness window, since the agent reports it."""
return int(((config.get("PLUGINS") or {}).get("host_agent") or {}).get("stale_after_seconds", 180))
async def disk_hosts(db, since: datetime, *, points: int = 60) -> list[dict]:
"""Each Docker host's disk as `docker system df` last said, most space
first, with its recorded total since `since` (#5304): the points, oldest
first, and the growth over them (disk_view.growth; None until there are
two). Hosts are named by their Steward host."""
from steward.core import metric_series
from .disk_view import DISK_PARTS, growth
from .models import DockerDiskUsage
summaries = list((await db.execute(select(DockerDiskUsage))).scalars())
hosts = await host_map(db, {s.host_id for s in summaries})
out = []
for s in summaries:
name = hosts[s.host_id].name if s.host_id in hosts else s.host_id
parts = {m: int(getattr(s, f) or 0) for m, f in DISK_PARTS.items()}
series = await metric_series.points(db, "docker", name, "disk_total_bytes", since, target=points)
out.append({"host_id": s.host_id, "host_name": name, "as_of": s.updated_at,
"total": sum(parts.values()), "parts": parts,
"reclaimable": int(s.images_reclaimable or 0), "points": series,
"growth": growth(series)})
return sorted(out, key=lambda h: (-h["total"], h["host_name"].lower()))
@@ -0,0 +1,37 @@
{# docker/_disk_hosts.html — every Docker host's disk side by side (#5304):
what it uses now, how much that grew over the selected range, and a line
of it; the parts after. A host is linked to its own section below. #}
{% if rows %}
<table class="table">
<thead><tr>
<th>Host</th>
<th class="num">Docker uses</th>
<th class="num" title="Change in the total over the last {{ range_key }}, from Steward's own record of it">Change ({{ range_key }})</th>
<th>Over {{ range_key }}</th>
<th class="num">Images</th><th class="num">Volumes</th><th class="num" title="Written by containers on top of their images">Container files</th>
<th class="num">Build cache</th><th class="num" title="Held by images no container uses">Reclaimable</th>
</tr></thead>
<tbody>
{% for r in rows %}
{% set g = r.growth %}
<tr>
<td><a href="#host-{{ r.host_id }}">{{ r.host_name }}</a></td>
<td class="num"><strong>{{ r.total|bytes }}</strong></td>
<td class="num">
{%- if g is none %}<span class="tone-dim" title="Steward records this from now on; growth shows once there are two readings">—</span>
{%- else %}<span class="tone-{{ 'warn' if g.pct is not none and g.pct >= 10 else ('ok' if g.change < 0 else 'muted') }}"
title="{{ g.from|bytes }} → {{ g.to|bytes }} since {{ g.since.strftime('%Y-%m-%d %H:%M UTC') }}">{{ "+" if g.change > 0 else ("−" if g.change < 0 else "±") }}{{ (g.change|abs)|bytes }}</span>{% endif -%}
</td>
<td class="tone-info">{{ r.spark|safe }}</td>
<td class="num">{{ r.parts.disk_images_bytes|bytes }}</td>
<td class="num">{{ r.parts.disk_volumes_bytes|bytes }}</td>
<td class="num">{{ r.parts.disk_containers_bytes|bytes }}</td>
<td class="num">{{ r.parts.disk_build_cache_bytes|bytes }}</td>
<td class="num">{% if r.reclaimable %}<span class="tone-warn">{{ r.reclaimable|bytes }}</span>{% else %}<span class="tone-dim">—</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="tone-muted" style="margin:0;padding:1rem;">No host has reported its Docker disk yet.</p>
{% endif %}
+96 -99
View File
@@ -1,127 +1,124 @@
{# docker/disk.html — image/disk usage: reclaimable space, per-image sizes #}
{# docker/disk.html — what Docker keeps on each host's disk (#5304, #5305):
the hosts side by side with how much each grew, then each host's parts,
its cleanup actions, and its images folded under their repository. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% from "_macros.html" import crumbs, page_header, stat_tile, status_badge %}
{% block title %}Disk — Docker — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Docker", "/plugins/docker/"), ("Image & disk usage", "")]) }}{% endblock %}
{% block breadcrumb %}{{ crumbs([("Docker", "/plugins/docker/"), ("Disk", "")]) }}{% endblock %}
{% block content %}
<h1 class="page-title" style="margin-bottom:0.4rem;">Image &amp; disk usage</h1>
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.5rem;">
What Docker is keeping on each host's disk, from <code>docker system df</code>.
Admins can clean up per host below; each action runs an audited Ansible playbook on that host.
</p>
{% call page_header("Docker disk", "What Docker keeps on each host's disk, from docker system df, and how it has grown.") %}
{% include "_time_range.html" %}
{% endcall %}
{% if host_groups %}
{% for g in host_groups %}
<div style="margin-bottom:2rem;">
<div style="font-weight:500;font-size:0.95rem;margin-bottom:0.75rem;">{{ g.host_name }}
{% if g.as_of %}<span class="field-hint" style="font-weight:normal;">· as of {{ g.as_of|ago }}</span>{% endif %}</div>
<section class="card-flush" aria-labelledby="disk-hosts">
<h2 id="disk-hosts" class="table-caption">By host <span class="tone-muted">most space first</span></h2>
<div hx-get="/plugins/docker/disk/hosts" hx-trigger="load, rangeChange from:body"
hx-include="#time-range" hx-swap="innerHTML">
<p class="field-hint" style="padding:1rem;margin:0;">Loading…</p>
</div>
</section>
{# ── Summary stats ────────────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.75rem;margin-bottom:1rem;">
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Reclaimable</div>
<span class="stat-val" style="color:{% if g.summary.images_reclaimable != '0 B' %}var(--orange){% else %}var(--green){% endif %};">{{ g.summary.images_reclaimable }}</span>
<div style="font-size:0.74rem;color:var(--text-muted);">held by images no container uses</div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Images</div>
<span class="stat-val">{{ g.summary.images_size }}</span>
<div style="font-size:0.74rem;color:var(--text-muted);">{{ g.summary.images_active }}/{{ g.summary.images_total }} in use</div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Stopped</div>
<span class="stat-val" style="{% if g.stopped %}color:var(--text-muted){% endif %};">{{ g.stopped }}</span>
<div style="font-size:0.74rem;color:var(--text-muted);">containers not running</div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Container files</div>
<span class="stat-val">{{ g.summary.containers_size }}</span>
<div style="font-size:0.74rem;color:var(--text-muted);">written by {{ g.summary.containers_count }} containers on top of their images</div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Volumes</div>
<span class="stat-val">{{ g.summary.volumes_size }}</span>
<div style="font-size:0.74rem;color:var(--text-muted);">{{ g.summary.volumes_count }} volumes; never pruned here</div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Build cache</div>
<span class="stat-val">{{ g.summary.build_cache_size }}</span>
<div style="font-size:0.74rem;color:var(--text-muted);">left by <code>docker build</code>; safe to clear</div>
</div>
{% for g in host_groups %}
{% set s = g.summary %}
<section id="host-{{ g.host_id }}" class="disk-host" aria-labelledby="disk-host-{{ g.host_id }}">
<h2 id="disk-host-{{ g.host_id }}" class="disk-host-head">
<a href="/hosts/{{ g.host_id }}">{{ g.host_name }}</a>
<span class="tone-muted">{{ g.total|bytes }}</span>
{% if g.as_of %}<span class="field-hint">as of {{ g.as_of|ago }}</span>{% endif %}
</h2>
<div class="stat-strip">
{{ stat_tile("Reclaimable", s.images_reclaimable|bytes, state="warn" if s.images_reclaimable else "ok", hint="held by images no container uses") }}
{{ stat_tile("Images", s.images_size|bytes, hint=s.images_active ~ " of " ~ s.images_total ~ " in use") }}
{{ stat_tile("Container files", s.containers_size|bytes, hint="written by " ~ s.containers_count ~ " containers on top of their images") }}
{{ stat_tile("Volumes", s.volumes_size|bytes, hint=s.volumes_count ~ " volumes; never pruned here") }}
{{ stat_tile("Build cache", s.build_cache_size|bytes, hint="left by docker build; safe to clear") }}
{{ stat_tile("Stopped", g.stopped, state="dim" if not g.stopped else none, hint="containers not running") }}
</div>
{# ── Cleanup actions (admin only; audited Ansible prune run) ───────────── #}
{% if session.user_role == 'admin' %}
<div style="margin-bottom:1rem;">
<div class="disk-actions">
{% if ansible_available and g.has_target %}
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;">
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
data-confirm="Remove all STOPPED containers on {{ g.host_name }}? This cannot be undone.">{{ csrf_field() }}
<input type="hidden" name="target" value="containers">
<button type="submit" class="btn btn-sm">Prune stopped containers</button>
</form>
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
data-confirm="Remove ALL unused images on {{ g.host_name }} (docker image prune -a)? Any image not used by a container is deleted.">{{ csrf_field() }}
<input type="hidden" name="target" value="images">
<button type="submit" class="btn btn-sm">Prune unused images</button>
</form>
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
data-confirm="Run a full system prune on {{ g.host_name }}? Removes stopped containers, unused networks, untagged images nothing uses, and the build cache.">{{ csrf_field() }}
<input type="hidden" name="target" value="system">
<button type="submit" class="btn btn-sm btn-danger">System prune…</button>
</form>
</div>
<div style="font-size:0.72rem;color:var(--text-muted);margin-top:0.4rem;">
Reclaimed space appears on the next agent sample.
</div>
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune"
data-confirm="Remove all STOPPED containers on {{ g.host_name }}? This cannot be undone.">{{ csrf_field() }}
<input type="hidden" name="target" value="containers">
<button type="submit" class="btn btn-sm">Prune stopped containers</button>
</form>
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune"
data-confirm="Remove ALL unused images on {{ g.host_name }} (docker image prune -a)? Any image not used by a container is deleted.">{{ csrf_field() }}
<input type="hidden" name="target" value="images">
<button type="submit" class="btn btn-sm">Prune unused images</button>
</form>
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune"
data-confirm="Run a full system prune on {{ g.host_name }}? Removes stopped containers, unused networks, untagged images nothing uses, and the build cache.">{{ csrf_field() }}
<input type="hidden" name="target" value="system">
<button type="submit" class="btn btn-sm btn-danger">System prune…</button>
</form>
<span class="field-hint">Each runs an audited Ansible playbook on {{ g.host_name }}; reclaimed space shows on the next agent sample.</span>
{% elif not ansible_available %}
<div style="font-size:0.74rem;color:var(--text-muted);">Cleanup needs the Ansible runner (currently unavailable).</div>
<span class="field-hint">Cleanup needs the Ansible runner (currently unavailable).</span>
{% else %}
<div style="font-size:0.74rem;color:var(--text-muted);">
Link an <a href="/hosts/{{ g.host_id }}">Ansible target</a> to this host to enable prune actions.
</div>
<span class="field-hint">Link an <a href="/hosts/{{ g.host_id }}">Ansible target</a> to this host to enable prune actions.</span>
{% endif %}
</div>
{% endif %}
{# ── Per-image table ──────────────────────────────────────────────────── #}
<div class="field-hint" style="margin-bottom:0.4rem;">
{% if g.images_capped %}The {{ g.images|length }} largest of {{ g.summary.images_total }} images, largest first.
{% else %}Every image, largest first.{% endif %}
</div>
<div class="card-flush">
<table class="table">
<thead>
<tr><th>Image</th><th>Size</th>
<th title="Layers this image has in common with other images: stored once, so removing this image frees its size less this">Shared with other images</th>
<th>Used by</th></tr>
</thead>
<tbody>
{% for im in g.images %}
{# ── Images by repository, versions folded under it (#5305) ─────────── #}
<section class="card-flush" aria-label="Images on {{ g.host_name }}">
<h3 class="table-caption">Images by repository
<span class="tone-muted">{% if g.images_capped %}the {{ g.images_listed }} largest of {{ s.images_total }} images{% else %}{{ g.images_listed }} images{% endif %}</span></h3>
{% if g.repos %}
<table class="table image-repos">
<thead><tr>
<th>Repository</th>
<th class="num">Versions</th>
<th class="num" title="Each version's size less the layers it shares with other images, added up: what removing them all would free, roughly">Own size</th>
<th class="num">Used by</th>
<th class="num" title="Own size of the versions no container uses">Reclaimable</th>
</tr></thead>
{% for r in g.repos %}
<tbody class="image-repo">
<tr>
<td style="font-size:0.84rem;font-family:ui-monospace,monospace;max-width:340px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
{% if im.repo_tag %}{{ im.repo_tag }}{% else %}<span style="color:var(--text-muted);">untagged</span> {{ im.image_id }}{% endif %}
{% if im.reclaimable %}<span title="No container uses this image" style="font-size:0.68rem;color:var(--orange);border:1px solid var(--orange);border-radius:3px;padding:0 0.3rem;margin-left:0.4rem;">reclaimable</span>{% endif %}
<td class="cell-name">
<details>
<summary title="{{ r.repo or 'untagged images' }}">{% if r.repo %}<span class="mono">{{ r.repo }}</span>{% else %}<span class="tone-muted">untagged</span>{% endif %}</summary>
<table class="table image-versions">
<thead><tr><th>Tag</th><th>ID</th><th class="num">Size</th><th class="num" title="Layers in common with other images: stored once">Shared</th><th class="num">Used by</th></tr></thead>
<tbody>
{% for v in r.versions %}
<tr>
<td class="mono">{{ v.tag or "—" }}</td>
<td class="mono tone-muted">{{ (v.image_id or "")|replace("sha256:", "")|truncate(12, true, "") }}</td>
<td class="num">{{ v.size|bytes }}</td>
<td class="num tone-muted">{{ v.shared|bytes }}</td>
<td class="num">{% if v.containers %}{{ v.containers }} container{{ "s" if v.containers != 1 }}{% else %}<span class="tone-warn">nothing</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</details>
</td>
<td style="font-family:ui-monospace,monospace;font-size:0.86rem;">{{ im.size }}</td>
<td style="font-family:ui-monospace,monospace;font-size:0.86rem;color:var(--text-muted);">{{ im.shared }}</td>
<td style="font-size:0.86rem;color:{% if im.containers %}var(--text){% else %}var(--text-muted){% endif %};">{% if im.containers %}{{ im.containers }} container{{ "s" if im.containers != 1 }}{% else %}nothing{% endif %}</td>
<td class="num">{{ r.versions|length }}{% if r.unused %} <span class="tone-dim">({{ r.unused }} unused)</span>{% endif %}</td>
<td class="num">{{ r.own|bytes }}</td>
<td class="num">{% if r.containers %}{{ r.containers }} container{{ "s" if r.containers != 1 }}{% else %}<span class="tone-warn">nothing</span>{% endif %}</td>
<td class="num">{% if r.reclaimable %}<span class="tone-warn">{{ r.reclaimable|bytes }}</span>{% else %}<span class="tone-dim">—</span>{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="4" style="color:var(--text-muted);font-size:0.85rem;text-align:center;padding:1.5rem;">No images reported.</td></tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
</div>
</div>
{% else %}
<p class="tone-muted" style="margin:0;padding:1rem;">No images reported.</p>
{% endif %}
</section>
</section>
{% endfor %}
{% else %}
<div class="card" style="text-align:center;padding:3rem;">
<div style="color:var(--text-muted);font-size:0.9rem;">
No disk usage reported yet. The host agent reports image/disk usage from
<code>docker system df</code> on hosts running Docker.
</div>
<div class="card empty-state">
No disk usage reported yet. The host agent reports what Docker keeps on disk,
from <code>docker system df</code>, on hosts running Docker.
</div>
{% endif %}
{% endblock %}
@@ -41,6 +41,19 @@
hx-swap="innerHTML">
<div class="card"><p class="field-hint">Loading…</p></div>
</div>
{% if disk %}
{# What Docker keeps on each host's disk, and its growth over a week (#5306). #}
<p class="docker-disk-line">
<a href="/plugins/docker/disk" class="section-title">Disk</a>
{% for d in disk %}
<span class="docker-disk-host"><a href="/plugins/docker/disk#host-{{ d.host_id }}">{{ d.host_name }}</a>
{{ d.total|bytes }}
{%- if d.growth and d.growth.change %} <span class="tone-{{ 'warn' if d.growth.pct is not none and d.growth.pct >= 10 else 'muted' }}"
title="Since {{ d.growth.since.strftime('%Y-%m-%d %H:%M UTC') }}">{{ "+" if d.growth.change > 0 else "−" }}{{ (d.growth.change|abs)|bytes }} in 7d</span>{% endif -%}
{%- if d.reclaimable %} <span class="tone-dim">· {{ d.reclaimable|bytes }} reclaimable</span>{% endif %}</span>
{% endfor %}
</p>
{% endif %}
<script>
(function () {
var rows = document.getElementById('docker-rows');
+6
View File
@@ -208,6 +208,12 @@ METRICS: dict[str, tuple[str, str]] = {
"cpu_pct": ("CPU", "CPU in use; for a container, 100% is one core"),
"mem_pct": ("Memory (% of limit)", "memory in use, against the container's limit or the host's RAM"),
"restart_count": ("Restarts", "times the container has restarted, in total"),
"disk_total_bytes": ("Docker disk use", "everything Docker keeps on the host (resource: the host)"),
"disk_images_bytes": ("Docker images", "space Docker's images take on the host"),
"disk_containers_bytes": ("Container files", "what containers wrote on top of their images"),
"disk_volumes_bytes": ("Docker volumes", "space Docker's volumes take on the host"),
"disk_build_cache_bytes": ("Docker build cache", "left by docker build; safe to clear"),
"disk_reclaimable_bytes": ("Reclaimable by Docker", "held by images no container uses"),
"mem_used_pct": ("Memory used", "memory in use"),
"mem_available_bytes": ("Memory available", "memory free for new work"),
"swap_used_bytes": ("Swap used", "memory paged out to disk"),
+13
View File
@@ -224,6 +224,19 @@ strong, b, th { font-weight: 500; }
.node-tasks th, .node-tasks td { padding: 0.35rem 0.5rem; font-size: 0.82rem; }
.node-tasks .cell-name { max-width: 12rem; }
.node-tasks .cell-name > a { display: inline-block; max-width: 100%; vertical-align: bottom; }
/* The Docker overview's one line per host of disk (#5306). */
.docker-disk-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.35rem 1.25rem; margin: 0 0 1rem; font-size: 0.85rem; }
.docker-disk-host { white-space: nowrap; }
/* The Disk page (#5304, #5305): a section per host; images by repository,
each repository's versions folded under it. */
.disk-host { margin: 2rem 0; }
.disk-host-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.6rem; margin: 0 0 0.75rem; font-family: var(--fs-font-body); font-size: 1.05rem; font-weight: 500; }
.disk-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-bottom: 1rem; }
.disk-actions form { margin: 0; }
.image-repos .cell-name { max-width: 28rem; }
.image-repos summary { cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.image-repos details[open] summary { margin-bottom: 0.5rem; }
.image-versions th, .image-versions td { padding: 0.3rem 0.5rem; font-size: 0.8rem; }
/* Forms */
.form-group { margin-bottom: 1rem; }
+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"')