feat(ui): Disk, Traefik, UniFi and Settings say things plainly (#5212)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 53s
CI / unit (push) Successful in 1m14s
CI / publish (push) Successful in 1m10s

#5212 "Plain labels: no raw ids, scopes, metric keys or unexplained jargon",
batch 3:

- Disk page: each tile says what it holds. "Writable layers" becomes
  "Container files" and Build cache says it is safe to clear. Shared is
  "Shared with other images" with a tooltip, and "dangling" is gone.
  Untagged images show by id instead of "<none>", a capped list says
  "The 50 largest of N images", and each host shows "as of" its report.
- Traefik: an expired certificate reads "expired 12 days ago", not "-12
  days" (cert_left). The Traffic empty state knows whether the access log
  is on: off points to Settings → Plugins → Traefik; on says to check the
  mounted log path.
- Moments use |ago with an exact UTC tooltip: UniFi last seen, alarms,
  events, speed test and DPI; AI token last used.
- Settings: the Auth tab is "Authentication" like its breadcrumb, plugin
  fields read "Metrics URL" not metrics_url (field_label), a source's pull
  interval is "every 1h", and the last report time drops the ISO "T".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 00:29:30 -04:00
co-authored by Claude Opus 5.5
parent e780932673
commit 5ec930bf76
21 changed files with 164 additions and 38 deletions
+10 -1
View File
@@ -529,6 +529,11 @@ async def swarm():
return await render_template("docker/swarm.html", swarms=swarms)
# How many images a host agent reports, largest first (host_agent agent.py
# _disk_images caps its list here); a list this long may be missing smaller ones.
IMAGES_REPORTED = 50
@docker_bp.get("/disk")
@require_role(UserRole.viewer)
async def disk():
@@ -569,7 +574,8 @@ async def disk():
images_by_host: dict[str, list] = {}
for im in images:
images_by_host.setdefault(im.host_id, []).append({
"repo_tag": im.repo_tag, "size": human_bytes(im.size),
"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,
})
@@ -578,6 +584,7 @@ async def disk():
{
"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),
@@ -591,6 +598,8 @@ async def disk():
},
"stopped": stopped_by_host.get(s.host_id, 0),
"images": 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
+21 -11
View File
@@ -7,20 +7,22 @@
<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;">
Reclaimable = space held by images no container references. Admins can prune
per host below; each action runs an audited Ansible playbook on that host.
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>
{% 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 }}</div>
<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>
{# ── 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>
@@ -30,20 +32,22 @@
<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;">Writable layers</div>
<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);">{{ g.summary.containers_count }} containers</div>
<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</div>
<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>
</div>
@@ -63,7 +67,7 @@
<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, dangling images and build cache.">{{ csrf_field() }}
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>
@@ -82,21 +86,27 @@
{% 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>Shared</th><th>Containers</th></tr>
<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 %}
<tr>
<td style="font-size:0.84rem;font-family:ui-monospace,monospace;max-width:340px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
{{ im.repo_tag }}
{% if im.reclaimable %}<span title="not referenced by any container" 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 %}
{% 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>
<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 %};">{{ im.containers }}</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>
</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>