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) 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") @docker_bp.get("/disk")
@require_role(UserRole.viewer) @require_role(UserRole.viewer)
async def disk(): async def disk():
@@ -569,7 +574,8 @@ async def disk():
images_by_host: dict[str, list] = {} images_by_host: dict[str, list] = {}
for im in images: for im in images:
images_by_host.setdefault(im.host_id, []).append({ 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, "shared": human_bytes(im.shared_size), "containers": im.containers,
"reclaimable": im.containers == 0, "reclaimable": im.containers == 0,
}) })
@@ -578,6 +584,7 @@ async def disk():
{ {
"host_id": s.host_id, "host_id": s.host_id,
"host_name": hosts[s.host_id].name if s.host_id in hosts else 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": { "summary": {
"images_total": s.images_total, "images_active": s.images_active, "images_total": s.images_total, "images_active": s.images_active,
"images_size": human_bytes(s.images_size), "images_size": human_bytes(s.images_size),
@@ -591,6 +598,8 @@ async def disk():
}, },
"stopped": stopped_by_host.get(s.host_id, 0), "stopped": stopped_by_host.get(s.host_id, 0),
"images": images_by_host.get(s.host_id, []), "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, "has_target": s.host_id in target_by_host,
} }
for s in summaries 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> <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;"> <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 What Docker is keeping on each host's disk, from <code>docker system df</code>.
per host below; each action runs an audited Ansible playbook on that host. Admins can clean up per host below; each action runs an audited Ansible playbook on that host.
</p> </p>
{% if host_groups %} {% if host_groups %}
{% for g in host_groups %} {% for g in host_groups %}
<div style="margin-bottom:2rem;"> <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 ────────────────────────────────────────────────────── #} {# ── Summary stats ────────────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.75rem;margin-bottom:1rem;"> <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="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Reclaimable</div> <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> <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>
<div class="card" style="margin-bottom:0;"> <div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Images</div> <div class="section-title" style="margin-bottom:0.3rem;">Images</div>
@@ -30,20 +32,22 @@
<div class="card" style="margin-bottom:0;"> <div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Stopped</div> <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> <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>
<div class="card" style="margin-bottom:0;"> <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> <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>
<div class="card" style="margin-bottom:0;"> <div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Volumes</div> <div class="section-title" style="margin-bottom:0.3rem;">Volumes</div>
<span class="stat-val">{{ g.summary.volumes_size }}</span> <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>
<div class="card" style="margin-bottom:0;"> <div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Build cache</div> <div class="section-title" style="margin-bottom:0.3rem;">Build cache</div>
<span class="stat-val">{{ g.summary.build_cache_size }}</span> <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>
</div> </div>
@@ -63,7 +67,7 @@
<button type="submit" class="btn btn-sm">Prune unused images</button> <button type="submit" class="btn btn-sm">Prune unused images</button>
</form> </form>
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;" <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"> <input type="hidden" name="target" value="system">
<button type="submit" class="btn btn-sm btn-danger">System prune…</button> <button type="submit" class="btn btn-sm btn-danger">System prune…</button>
</form> </form>
@@ -82,21 +86,27 @@
{% endif %} {% endif %}
{# ── Per-image table ──────────────────────────────────────────────────── #} {# ── 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"> <div class="card-flush">
<table class="table"> <table class="table">
<thead> <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> </thead>
<tbody> <tbody>
{% for im in g.images %} {% for im in g.images %}
<tr> <tr>
<td style="font-size:0.84rem;font-family:ui-monospace,monospace;max-width:340px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"> <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.repo_tag %}{{ im.repo_tag }}{% else %}<span style="color:var(--text-muted);">untagged</span> {{ im.image_id }}{% endif %}
{% 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.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>
<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;">{{ im.size }}</td>
<td style="font-family:ui-monospace,monospace;font-size:0.86rem;color:var(--text-muted);">{{ im.shared }}</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> </tr>
{% else %} {% 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> <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 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("/") @traefik_bp.get("/")
@require_role(UserRole.viewer) @require_role(UserRole.viewer)
async def index(): async def index():
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60) poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
_al_cfg = current_app.config["PLUGINS"]["traefik"].get("access_log", {}) access_log_enabled = bool(_access_log().get("enabled", False))
if not isinstance(_al_cfg, dict):
_al_cfg = {}
access_log_enabled = _al_cfg.get("enabled", False)
current_range = request.args.get("range", DEFAULT_RANGE) current_range = request.args.get("range", DEFAULT_RANGE)
return await render_template( return await render_template(
"traefik/index.html", "traefik/index.html",
@@ -368,8 +371,10 @@ async def traffic():
rows = result.scalars().all() rows = result.scalars().all()
if not rows: if not rows:
al = _access_log()
return await render_template( 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) total = sum(r.total_requests for r in rows)
+1 -1
View File
@@ -105,7 +105,7 @@
<tr> <tr>
<td style="word-break:break-all;">{{ cert.cn }}</td> <td style="word-break:break-all;">{{ cert.cn }}</td>
<td class="field-hint">{{ cert.not_after.strftime("%Y-%m-%d") }}</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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -92,9 +92,13 @@
{% else %} {% else %}
<div class="card" style="color:var(--text-muted);font-size:0.85rem;"> <div class="card" style="color:var(--text-muted);font-size:0.85rem;">
No access log data yet.
{% if not access_log_enabled %} {% 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 %} {% endif %}
</div> </div>
{% endif %} {% endif %}
@@ -5,7 +5,7 @@
<div style="display:flex;justify-content:space-between;font-size:0.8rem;padding:0.15rem 0; <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 %}"> {% if cert.days_remaining < 7 %}color:var(--red);{% else %}color:var(--yellow);{% endif %}">
<span>&#9888; {{ cert.cn }}</span> <span>&#9888; {{ cert.cn }}</span>
<span>{{ cert.days_remaining | int }}d</span> <span>{{ cert_left(cert.days_remaining) }}</span>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
+1 -1
View File
@@ -11,7 +11,7 @@
<div class="card" style="break-inside:avoid;margin-bottom:1rem;"> <div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;"> <div class="section-title" style="margin-bottom:0.75rem;">
Traffic by Category 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> </div>
{% set total_bytes = categories | sum(attribute='rx_bytes') + categories | sum(attribute='tx_bytes') %} {% set total_bytes = categories | sum(attribute='rx_bytes') + categories | sum(attribute='tx_bytes') %}
<table class="table" style="width:100%;font-size:0.82rem;"> <table class="table" style="width:100%;font-size:0.82rem;">
+1 -1
View File
@@ -25,7 +25,7 @@
{% for ev in events %} {% for ev in events %}
<tr style="{% if loop.index is divisibleby 2 %}background:var(--fs-table-stripe);{% endif %}"> <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;"> <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>
<td style="padding:0.25rem 0.5rem;"> <td style="padding:0.25rem 0.5rem;">
<span class="badge badge-{{ {'wlan': 'info', 'wan': 'ok', 'ids': 'crit'}.get(ev.category, 'dim') }}">{{ ev.category }}</span> <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 "—" }} {{ kc.ip or "—" }}
</td> </td>
<td style="padding:0.2rem 0;text-align:right;color:var(--text-dim);font-size:0.75rem;white-space:nowrap;"> <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> </td>
</tr> </tr>
{% endfor %} {% endfor %}
+1 -1
View File
@@ -146,7 +146,7 @@
{% if speedtest.server_name %} {% if speedtest.server_name %}
<div style="margin-top:0.4rem;color:var(--text-dim);font-size:0.78rem;">via {{ speedtest.server_name }}</div> <div style="margin-top:0.4rem;color:var(--text-dim);font-size:0.78rem;">via {{ speedtest.server_name }}</div>
{% endif %} {% 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> </div>
{% endif %} {% endif %}
+2 -2
View File
@@ -21,7 +21,7 @@
</div> </div>
<div>{{ al.message }}</div> <div>{{ al.message }}</div>
<div style="color:var(--text-dim);font-size:0.75rem;margin-top:0.1rem;"> <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> </div>
</td> </td>
</tr> </tr>
@@ -45,7 +45,7 @@
<div style="color:var(--text-dim);font-size:0.8rem;">{{ al.message }}</div> <div style="color:var(--text-dim);font-size:0.8rem;">{{ al.message }}</div>
</td> </td>
<td style="padding:0.25rem 0;text-align:right;color:var(--text-dim);font-size:0.75rem;white-space:nowrap;"> <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> </td>
</tr> </tr>
{% endfor %} {% endfor %}
+2 -1
View File
@@ -146,7 +146,8 @@ def create_app(
app.jinja_env.globals["status_info"] = _fmt.status_info app.jinja_env.globals["status_info"] = _fmt.status_info
app.jinja_env.globals["required"] = _fmt.required app.jinja_env.globals["required"] = _fmt.required
# A metric and its source in words, a reading in its unit (alerts pages). # A metric and its source in words, a reading in its unit (alerts pages).
for _name in ("metric_label", "metric_about", "metric_unit", "metric_value", "source_label"): for _name in ("metric_label", "metric_about", "metric_unit", "metric_value", "source_label",
"cert_left", "field_label"):
app.jinja_env.globals[_name] = getattr(_fmt, _name) app.jinja_env.globals[_name] = getattr(_fmt, _name)
from .alerts.service import rule_condition as _rule_condition from .alerts.service import rule_condition as _rule_condition
app.jinja_env.globals["rule_condition"] = _rule_condition app.jinja_env.globals["rule_condition"] = _rule_condition
+31
View File
@@ -229,6 +229,37 @@ SOURCES: dict[str, str] = {
} }
def cert_left(days: float | None) -> str:
"""Time left on a certificate: "12 days", "1 day", "today", or "expired
3 days ago" rather than "-3 days"."""
if days is None:
return NONE
whole = int(days)
if days < 0:
gone = int(-days)
return "expired today" if gone == 0 else f"expired {gone} day{'s' if gone != 1 else ''} ago"
if whole == 0:
return "expires today"
return f"{whole} day{'s' if whole != 1 else ''}"
# Words a settings key spells in lower case that read in capitals.
_ACRONYMS = {"url": "URL", "urls": "URLs", "ssl": "SSL", "tls": "TLS", "ip": "IP", "api": "API",
"dns": "DNS", "http": "HTTP", "https": "HTTPS", "snmp": "SNMP", "oid": "OID",
"oids": "OIDs", "id": "ID", "geoip": "GeoIP", "db": "database", "mmdb": "MMDB",
"wan": "WAN", "dpi": "DPI", "smtp": "SMTP"}
def field_label(key: str) -> str:
"""A config key as a form label: "metrics_url" → "Metrics URL",
"scrape_interval_seconds" → "Scrape interval seconds"."""
words = [_ACRONYMS.get(w.lower(), w) for w in str(key or "").replace("-", "_").split("_") if w]
if not words:
return ""
words[0] = words[0][:1].upper() + words[0][1:]
return " ".join(words)
def metric_label(name: str) -> str: def metric_label(name: str) -> str:
entry = METRICS.get(name or "") entry = METRICS.get(name or "")
return entry[0] if entry else (name or "").replace("_", " ") return entry[0] if entry else (name or "").replace("_", " ")
+1 -1
View File
@@ -48,7 +48,7 @@
<td><span class="badge badge-dim">{{ t.role.value }}</span></td> <td><span class="badge badge-dim">{{ t.role.value }}</span></td>
<td style="white-space:nowrap;">{{ t.created_at.strftime("%Y-%m-%d") }}</td> <td style="white-space:nowrap;">{{ t.created_at.strftime("%Y-%m-%d") }}</td>
<td style="white-space:nowrap;"> <td style="white-space:nowrap;">
{% if t.last_used_at %}{{ t.last_used_at.strftime("%Y-%m-%d %H:%M") }} UTC{% else %}<span style="color:var(--text-muted);">Never</span>{% endif %} {% if t.last_used_at %}{{ t.last_used_at|ago }}{% else %}<span style="color:var(--text-muted);">Never</span>{% endif %}
</td> </td>
<td style="white-space:nowrap;"> <td style="white-space:nowrap;">
{% if row.state == 'revoked' %}<span class="badge badge-red">Revoked</span> {% if row.state == 'revoked' %}<span class="badge badge-red">Revoked</span>
@@ -26,7 +26,7 @@
{% if src.type == 'git' %} {% if src.type == 'git' %}
{{ src.url or '(no URL)' }} {{ src.url or '(no URL)' }}
<span style="color:var(--text-dim);margin-left:0.5rem;">branch: {{ src.branch or 'main' }}</span> <span style="color:var(--text-dim);margin-left:0.5rem;">branch: {{ src.branch or 'main' }}</span>
{% if src.pull_interval_seconds %}<span style="color:var(--text-dim);margin-left:0.5rem;">· pull every {{ src.pull_interval_seconds }}s</span>{% endif %} {% if src.pull_interval_seconds %}<span style="color:var(--text-dim);margin-left:0.5rem;">· pulled every {{ src.pull_interval_seconds|duration }}</span>{% endif %}
{% else %} {% else %}
{{ src.path or '(no path)' }} {{ src.path or '(no path)' }}
{% endif %} {% endif %}
+1 -1
View File
@@ -7,7 +7,7 @@
("thresholds", "Thresholds & Retention", "/settings/thresholds/"), ("thresholds", "Thresholds & Retention", "/settings/thresholds/"),
("notifications", "Notifications", "/settings/notifications/"), ("notifications", "Notifications", "/settings/notifications/"),
("reports", "Reports", "/settings/reports/"), ("reports", "Reports", "/settings/reports/"),
("auth", "Auth", "/settings/auth/"), ("auth", "Authentication", "/settings/auth/"),
("ansible", "Ansible", "/settings/ansible/"), ("ansible", "Ansible", "/settings/ansible/"),
("plugins", "Plugins", "/settings/plugins/"), ("plugins", "Plugins", "/settings/plugins/"),
] %} ] %}
@@ -72,14 +72,14 @@
background:var(--bg);border-radius:5px;border:1px solid var(--border); background:var(--bg);border-radius:5px;border:1px solid var(--border);
font-size:0.82rem;color:var(--text-muted);"> font-size:0.82rem;color:var(--text-muted);">
<span style="flex-shrink:0;">📋</span> <span style="flex-shrink:0;">📋</span>
<span><strong style="color:var(--text);">{{ cfg_key }}</strong> — list config, edit in <span><strong style="color:var(--text);">{{ field_label(cfg_key) }}</strong> — list config, edit in
<code>plugin.yaml</code> ({{ cfg_default | length }} item{{ 's' if cfg_default | length != 1 }}).</span> <code>plugin.yaml</code> ({{ cfg_default | length }} item{{ 's' if cfg_default | length != 1 }}).</span>
</div> </div>
{% elif cfg_default is mapping %} {% elif cfg_default is mapping %}
<div style="padding:0.75rem;background:var(--bg);border-radius:5px;border:1px solid var(--border);"> <div style="padding:0.75rem;background:var(--bg);border-radius:5px;border:1px solid var(--border);">
<div style="font-size:0.74rem;color:var(--text-muted);text-transform:uppercase; <div style="font-size:0.74rem;color:var(--text-muted);text-transform:uppercase;
letter-spacing:0.05em;margin-bottom:0.6rem;">{{ cfg_key }}</div> letter-spacing:0.05em;margin-bottom:0.6rem;">{{ field_label(cfg_key) }}</div>
{% set sub_cfg = plugin._config.get(cfg_key, cfg_default) if plugin._config.get(cfg_key, cfg_default) is mapping else cfg_default %} {% set sub_cfg = plugin._config.get(cfg_key, cfg_default) if plugin._config.get(cfg_key, cfg_default) is mapping else cfg_default %}
<div style="display:grid;gap:0.5rem;"> <div style="display:grid;gap:0.5rem;">
{% for sub_key, sub_default in cfg_default.items() %} {% for sub_key, sub_default in cfg_default.items() %}
@@ -90,11 +90,11 @@
id="plugin-{{ plugin._dir }}-{{ cfg_key }}-{{ sub_key }}" id="plugin-{{ plugin._dir }}-{{ cfg_key }}-{{ sub_key }}"
{% if sub_cfg.get(sub_key, sub_default) %}checked{% endif %}> {% if sub_cfg.get(sub_key, sub_default) %}checked{% endif %}>
<label for="plugin-{{ plugin._dir }}-{{ cfg_key }}-{{ sub_key }}" <label for="plugin-{{ plugin._dir }}-{{ cfg_key }}-{{ sub_key }}"
style="margin:0;font-size:0.85rem;color:var(--text);">{{ sub_key }}</label> style="margin:0;font-size:0.85rem;color:var(--text);">{{ field_label(sub_key) }}</label>
</div> </div>
{% else %} {% else %}
<div class="form-group" style="margin:0;"> <div class="form-group" style="margin:0;">
<label style="font-size:0.82rem;">{{ sub_key }}</label> <label style="font-size:0.82rem;">{{ field_label(sub_key) }}</label>
<input type="text" <input type="text"
name="plugin.{{ plugin._dir }}.{{ cfg_key }}.{{ sub_key }}" name="plugin.{{ plugin._dir }}.{{ cfg_key }}.{{ sub_key }}"
value="{{ sub_cfg.get(sub_key, sub_default) }}"> value="{{ sub_cfg.get(sub_key, sub_default) }}">
@@ -111,12 +111,12 @@
id="plugin-{{ plugin._dir }}-{{ cfg_key }}" id="plugin-{{ plugin._dir }}-{{ cfg_key }}"
{% if plugin._config.get(cfg_key, cfg_default) %}checked{% endif %}> {% if plugin._config.get(cfg_key, cfg_default) %}checked{% endif %}>
<label for="plugin-{{ plugin._dir }}-{{ cfg_key }}" <label for="plugin-{{ plugin._dir }}-{{ cfg_key }}"
style="margin:0;font-size:0.85rem;color:var(--text);">{{ cfg_key }}</label> style="margin:0;font-size:0.85rem;color:var(--text);">{{ field_label(cfg_key) }}</label>
</div> </div>
{% else %} {% else %}
<div class="form-group" style="margin:0;"> <div class="form-group" style="margin:0;">
<label style="font-size:0.82rem;">{{ cfg_key }}</label> <label style="font-size:0.82rem;">{{ field_label(cfg_key) }}</label>
<input type="{% if 'password' in cfg_key or 'secret' in cfg_key %}password{% else %}text{% endif %}" <input type="{% if 'password' in cfg_key or 'secret' in cfg_key %}password{% else %}text{% endif %}"
name="plugin.{{ plugin._dir }}.{{ cfg_key }}" name="plugin.{{ plugin._dir }}.{{ cfg_key }}"
value="{% if 'password' in cfg_key or 'secret' in cfg_key %}{% else %}{{ plugin._config.get(cfg_key, cfg_default) }}{% endif %}" value="{% if 'password' in cfg_key or 'secret' in cfg_key %}{% else %}{{ plugin._config.get(cfg_key, cfg_default) }}{% endif %}"
+1 -1
View File
@@ -56,7 +56,7 @@
<p style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;"> <p style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">
Requires SMTP configured in <a href="/settings/notifications/">Notifications</a>. Requires SMTP configured in <a href="/settings/notifications/">Notifications</a>.
{% if settings['reports.last_sent_at'] %} {% if settings['reports.last_sent_at'] %}
Last sent: {{ settings['reports.last_sent_at'][:16] }} UTC. Last sent: {{ settings['reports.last_sent_at'][:16]|replace("T", " ") }} UTC.
{% endif %} {% endif %}
</p> </p>
+21
View File
@@ -102,3 +102,24 @@ async def test_every_template_compiles_with_the_app_filters(app):
except jinja2.TemplateError as exc: except jinja2.TemplateError as exc:
failed[str(path.relative_to(_REPO))] = str(exc) failed[str(path.relative_to(_REPO))] = str(exc)
assert not failed, failed assert not failed, failed
@pytest.mark.parametrize("key, label", [
("metrics_url", "Metrics URL"), ("scrape_interval_seconds", "Scrape interval seconds"),
("geoip_db", "GeoIP database"), ("db", "Database"), ("verify_ssl", "Verify SSL"),
("enabled", "Enabled"), ("", ""),
])
def test_a_settings_key_reads_as_a_label(key, label):
"""#5212: plugin settings are labelled in words, not snake_case."""
from steward.core.formatting import field_label
assert field_label(key) == label
def test_metric_names_and_units_read_in_words():
from steward.core.formatting import metric_label, metric_unit, source_label
assert metric_label("disk_used_pct_worst") == "Fullest disk" and metric_unit("disk_used_pct_worst") == "%"
assert metric_label("hrStorageUsed") == "hrStorageUsed" and metric_unit("hrStorageUsed") == ""
assert metric_label("if_in_octets") == "if in octets"
assert metric_unit("cert_expiry_days") == "days" and metric_unit("seconds_since_seen") == "seconds"
assert metric_unit("is_up") == "" and metric_unit("request_rate") == "req/s"
assert source_label("host_agent") == "Host agent" and source_label("my_plugin") == "my plugin"
+23
View File
@@ -139,3 +139,26 @@ async def test_a_failing_service_leads_and_the_failures_are_listed(app):
assert listed.index("codex_db") < listed.index("backup") # newest first assert listed.index("codex_db") < listed.index("backup") # newest first
assert "9 failures · last: task: non-zero exit (1) · nas" in listed assert "9 failures · last: task: non-zero exit (1) · nas" in listed
assert 'href="/plugins/docker/container/h1/backup">backup</a>' in listed assert 'href="/plugins/docker/container/h1/backup">backup</a>' in listed
async def test_the_disk_page_explains_its_terms_and_names_untagged_images(app):
"""#5212: no unexplained jargon, no "<none>" rows, and a capped list says so."""
summary = {"images_total": 312, "images_active": 40, "images_size": "40 GiB",
"images_reclaimable": "12 GiB", "layers_size": "30 GiB", "containers_count": 9,
"containers_size": "1.2 GiB", "volumes_count": 4, "volumes_size": "3 GiB",
"build_cache_size": "2 GiB"}
images = [{"repo_tag": "nginx:1", "image_id": "aaaaaaaaaaaa", "size": "180 MiB", "shared": "70 MiB",
"containers": 2, "reclaimable": False},
{"repo_tag": None, "image_id": "bbbbbbbbbbbb", "size": "700 MiB", "shared": "0 B",
"containers": 0, "reclaimable": True}]
group = {"host_id": "h1", "host_name": "nas", "as_of": NOW - timedelta(minutes=2), "summary": summary,
"stopped": 1, "images": images, "has_target": True, "images_capped": True}
async with app.test_request_context("/plugins/docker/disk"):
page = await render_plugin_template(app, "docker", "docker/disk.html",
host_groups=[group], ansible_available=True)
assert "untagged</span> bbbbbbbbbbbb" in page and "&lt;none&gt;" not in page
assert "The 2 largest of 312 images" in page and "as of <time" in page
assert "Writable layers" not in page and "Container files" in page
assert "held by images no container uses" in page and "safe to clear" in page
assert "Shared with other images" in page and "2 containers" in page and ">nothing<" in page
assert "dangling" not in page
@@ -88,3 +88,25 @@ async def test_gone_routers_are_listed_by_when_they_were_last_seen(app):
assert "1 router no longer reported</summary>" in page assert "1 router no longer reported</summary>" in page
assert "old</span> <span class=\"problem-detail\">last seen <time" in page and ">2h ago</time>" in page assert "old</span> <span class=\"problem-detail\">last seen <time" in page and ">2h ago</time>" in page
assert "Failing" not in page assert "Failing" not in page
@pytest.mark.parametrize("days, says", [
(12.7, "12 days"), (1.2, "1 day"), (0.4, "expires today"),
(-0.3, "expired today"), (-1.5, "expired 1 day ago"), (-12.0, "expired 12 days ago"), (None, "—"),
])
def test_a_certificate_past_its_date_says_expired(days, says):
"""#5212: "expired 12 days ago", never "-12 days"."""
from steward.core.formatting import cert_left
assert cert_left(days) == says
@pytest.mark.parametrize("enabled, says, not_says", [
(False, "The access log is off", "No requests in the last"),
(True, "No requests in the last 24h", "The access log is off"),
])
async def test_the_traffic_empty_state_knows_whether_the_log_is_on(app, enabled, says, not_says):
async with app.test_request_context("/plugins/traefik/traffic"):
page = await render_plugin_template(app, "traefik", "traefik/traffic.html", summary=None, history=[],
range_key="24h", access_log_enabled=enabled,
access_log_path="/var/log/traefik/access.log")
assert says in page and not_says not in page