Files
FabledSteward/plugins/docker/templates/docker/disk.html
T
bvandeusenandClaude Opus 5.5 8566c8f78e
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 54s
CI / unit (push) Successful in 1m21s
CI / publish (push) Skipped
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>
2026-10-07 20:16:13 -04:00

125 lines
7.1 KiB
HTML

{# 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, page_header, stat_tile, status_badge %}
{% block title %}Disk — Docker — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Docker", "/plugins/docker/"), ("Disk", "")]) }}{% endblock %}
{% block content %}
{% 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 %}
<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>
{% 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 class="disk-actions">
{% if ansible_available and g.has_target %}
<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 %}
<span class="field-hint">Cleanup needs the Ansible runner (currently unavailable).</span>
{% else %}
<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 %}
{# ── 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 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 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>
</tbody>
{% endfor %}
</table>
{% else %}
<p class="tone-muted" style="margin:0;padding:1rem;">No images reported.</p>
{% endif %}
</section>
</section>
{% endfor %}
{% else %}
<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 %}