- #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>
125 lines
7.1 KiB
HTML
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 %}
|