feat(ui): Disk, Traefik, UniFi and Settings say things plainly (#5212)
#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:
@@ -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
|
||||
|
||||
@@ -7,20 +7,22 @@
|
||||
|
||||
<h1 class="page-title" style="margin-bottom:0.4rem;">Image & 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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>⚠ {{ cert.cn }}</span>
|
||||
<span>{{ cert.days_remaining | int }}d</span>
|
||||
<span>{{ cert_left(cert.days_remaining) }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user