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 %}
+2 -1
View File
@@ -146,7 +146,8 @@ def create_app(
app.jinja_env.globals["status_info"] = _fmt.status_info
app.jinja_env.globals["required"] = _fmt.required
# 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)
from .alerts.service import rule_condition as _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:
entry = METRICS.get(name or "")
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 style="white-space:nowrap;">{{ t.created_at.strftime("%Y-%m-%d") }}</td>
<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 style="white-space:nowrap;">
{% if row.state == 'revoked' %}<span class="badge badge-red">Revoked</span>
@@ -26,7 +26,7 @@
{% if src.type == 'git' %}
{{ src.url or '(no URL)' }}
<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 %}
{{ src.path or '(no path)' }}
{% endif %}
+1 -1
View File
@@ -7,7 +7,7 @@
("thresholds", "Thresholds & Retention", "/settings/thresholds/"),
("notifications", "Notifications", "/settings/notifications/"),
("reports", "Reports", "/settings/reports/"),
("auth", "Auth", "/settings/auth/"),
("auth", "Authentication", "/settings/auth/"),
("ansible", "Ansible", "/settings/ansible/"),
("plugins", "Plugins", "/settings/plugins/"),
] %}
@@ -72,14 +72,14 @@
background:var(--bg);border-radius:5px;border:1px solid var(--border);
font-size:0.82rem;color:var(--text-muted);">
<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>
</div>
{% elif cfg_default is mapping %}
<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;
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 %}
<div style="display:grid;gap:0.5rem;">
{% for sub_key, sub_default in cfg_default.items() %}
@@ -90,11 +90,11 @@
id="plugin-{{ plugin._dir }}-{{ cfg_key }}-{{ sub_key }}"
{% if sub_cfg.get(sub_key, sub_default) %}checked{% endif %}>
<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>
{% else %}
<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"
name="plugin.{{ plugin._dir }}.{{ cfg_key }}.{{ sub_key }}"
value="{{ sub_cfg.get(sub_key, sub_default) }}">
@@ -111,12 +111,12 @@
id="plugin-{{ plugin._dir }}-{{ cfg_key }}"
{% if plugin._config.get(cfg_key, cfg_default) %}checked{% endif %}>
<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>
{% else %}
<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 %}"
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 %}"
+1 -1
View File
@@ -56,7 +56,7 @@
<p style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">
Requires SMTP configured in <a href="/settings/notifications/">Notifications</a>.
{% 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 %}
</p>
+21
View File
@@ -102,3 +102,24 @@ async def test_every_template_compiles_with_the_app_filters(app):
except jinja2.TemplateError as exc:
failed[str(path.relative_to(_REPO))] = str(exc)
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 "9 failures · last: task: non-zero exit (1) · nas" 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 "old</span> <span class=\"problem-detail\">last seen <time" in page and ">2h ago</time>" 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