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 %}
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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("_", " ")
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 "<none>" 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
|
||||
|
||||
Reference in New Issue
Block a user