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');