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>
+10 -5
View File
@@ -85,14 +85,17 @@ def split_routers(routers: list[dict]) -> tuple[list[dict], list[dict]]:
return problems, healthy
def _access_log() -> dict:
"""The plugin's access_log settings ({} when unset or malformed)."""
cfg = current_app.config["PLUGINS"]["traefik"].get("access_log", {})
return cfg if isinstance(cfg, dict) else {}
@traefik_bp.get("/")
@require_role(UserRole.viewer)
async def index():
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
_al_cfg = current_app.config["PLUGINS"]["traefik"].get("access_log", {})
if not isinstance(_al_cfg, dict):
_al_cfg = {}
access_log_enabled = _al_cfg.get("enabled", False)
access_log_enabled = bool(_access_log().get("enabled", False))
current_range = request.args.get("range", DEFAULT_RANGE)
return await render_template(
"traefik/index.html",
@@ -368,8 +371,10 @@ async def traffic():
rows = result.scalars().all()
if not rows:
al = _access_log()
return await render_template(
"traefik/traffic.html", summary=None, history=[], range_key=range_key
"traefik/traffic.html", summary=None, history=[], range_key=range_key,
access_log_enabled=bool(al.get("enabled", False)), access_log_path=al.get("path", ""),
)
total = sum(r.total_requests for r in rows)
+1 -1
View File
@@ -105,7 +105,7 @@
<tr>
<td style="word-break:break-all;">{{ cert.cn }}</td>
<td class="field-hint">{{ cert.not_after.strftime("%Y-%m-%d") }}</td>
<td style="text-align:right;white-space:nowrap;" class="tone-{{ status_info(status_class(cert.days_remaining, 'tls_days'))[0] }}">{{ cert.days_remaining|int }} days</td>
<td style="text-align:right;white-space:nowrap;" class="tone-{{ status_info(status_class(cert.days_remaining, 'tls_days'))[0] }}">{{ cert_left(cert.days_remaining) }}</td>
</tr>
{% endfor %}
</tbody>
@@ -92,9 +92,13 @@
{% else %}
<div class="card" style="color:var(--text-muted);font-size:0.85rem;">
No access log data yet.
{% if not access_log_enabled %}
Enable <code>access_log.enabled: true</code> in your plugin config and bind-mount the Traefik log directory.
The access log is off. Turn it on in <a href="/settings/plugins/traefik/">Settings → Plugins → Traefik</a>,
and mount Traefik's log directory into Steward so it can read the file.
{% else %}
No requests in the last {{ range_key }}. The access log is on; if this stays empty, check that
{% if access_log_path %}<code>{{ access_log_path }}</code>{% else %}the log path{% endif %} is mounted into Steward
and that Traefik is writing to it.
{% endif %}
</div>
{% endif %}
@@ -5,7 +5,7 @@
<div style="display:flex;justify-content:space-between;font-size:0.8rem;padding:0.15rem 0;
{% if cert.days_remaining < 7 %}color:var(--red);{% else %}color:var(--yellow);{% endif %}">
<span>&#9888; {{ cert.cn }}</span>
<span>{{ cert.days_remaining | int }}d</span>
<span>{{ cert_left(cert.days_remaining) }}</span>
</div>
{% endfor %}
</div>
+1 -1
View File
@@ -11,7 +11,7 @@
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">
Traffic by Category
<span style="float:right;font-weight:normal;font-size:0.78rem;color:var(--text-dim);">{{ snapshot.scraped_at.strftime("%H:%M") }}</span>
<span style="float:right;font-weight:normal;font-size:0.78rem;color:var(--text-dim);">as of {{ snapshot.scraped_at|ago }}</span>
</div>
{% set total_bytes = categories | sum(attribute='rx_bytes') + categories | sum(attribute='tx_bytes') %}
<table class="table" style="width:100%;font-size:0.82rem;">
+1 -1
View File
@@ -25,7 +25,7 @@
{% for ev in events %}
<tr style="{% if loop.index is divisibleby 2 %}background:var(--fs-table-stripe);{% endif %}">
<td style="padding:0.25rem 0.5rem 0.25rem 0;color:var(--text-dim);font-size:0.77rem;white-space:nowrap;">
{{ ev.event_time.strftime("%Y-%m-%d %H:%M:%S") }}
{{ ev.event_time|ago }}
</td>
<td style="padding:0.25rem 0.5rem;">
<span class="badge badge-{{ {'wlan': 'info', 'wan': 'ok', 'ids': 'crit'}.get(ev.category, 'dim') }}">{{ ev.category }}</span>
+1 -1
View File
@@ -117,7 +117,7 @@
{{ kc.ip or "—" }}
</td>
<td style="padding:0.2rem 0;text-align:right;color:var(--text-dim);font-size:0.75rem;white-space:nowrap;">
{% if kc.last_seen %}{{ kc.last_seen.strftime("%m-%d %H:%M") }}{% else %}—{% endif %}
{% if kc.last_seen %}{{ kc.last_seen|ago }}{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
+1 -1
View File
@@ -146,7 +146,7 @@
{% if speedtest.server_name %}
<div style="margin-top:0.4rem;color:var(--text-dim);font-size:0.78rem;">via {{ speedtest.server_name }}</div>
{% endif %}
<div style="margin-top:0.25rem;color:var(--text-dim);font-size:0.75rem;">{{ speedtest.run_at.strftime("%Y-%m-%d %H:%M") }} UTC</div>
<div style="margin-top:0.25rem;color:var(--text-dim);font-size:0.75rem;">{{ speedtest.run_at|ago }}</div>
</div>
{% endif %}
+2 -2
View File
@@ -21,7 +21,7 @@
</div>
<div>{{ al.message }}</div>
<div style="color:var(--text-dim);font-size:0.75rem;margin-top:0.1rem;">
{{ al.alarm_time.strftime("%Y-%m-%d %H:%M") }} UTC
{{ al.alarm_time|ago }}
</div>
</td>
</tr>
@@ -45,7 +45,7 @@
<div style="color:var(--text-dim);font-size:0.8rem;">{{ al.message }}</div>
</td>
<td style="padding:0.25rem 0;text-align:right;color:var(--text-dim);font-size:0.75rem;white-space:nowrap;">
{{ al.alarm_time.strftime("%m-%d %H:%M") }}
{{ al.alarm_time|ago }}
</td>
</tr>
{% endfor %}