diff --git a/plugins/docker/routes.py b/plugins/docker/routes.py index 93d1a68..c426459 100644 --- a/plugins/docker/routes.py +++ b/plugins/docker/routes.py @@ -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) 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 diff --git a/plugins/docker/templates/docker/disk.html b/plugins/docker/templates/docker/disk.html index b84ce15..55aeb14 100644 --- a/plugins/docker/templates/docker/disk.html +++ b/plugins/docker/templates/docker/disk.html @@ -7,20 +7,22 @@

Image & disk usage

- 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 docker system df. + Admins can clean up per host below; each action runs an audited Ansible playbook on that host.

{% if host_groups %} {% for g in host_groups %}
-
{{ g.host_name }}
+
{{ g.host_name }} + {% if g.as_of %}· as of {{ g.as_of|ago }}{% endif %}
{# ── Summary stats ────────────────────────────────────────────────────── #}
Reclaimable
{{ g.summary.images_reclaimable }} +
held by images no container uses
Images
@@ -30,20 +32,22 @@
Stopped
{{ g.stopped }} +
containers not running
-
Writable layers
+
Container files
{{ g.summary.containers_size }} -
{{ g.summary.containers_count }} containers
+
written by {{ g.summary.containers_count }} containers on top of their images
Volumes
{{ g.summary.volumes_size }} -
{{ g.summary.volumes_count }} volumes
+
{{ g.summary.volumes_count }} volumes; never pruned here
Build cache
{{ g.summary.build_cache_size }} +
left by docker build; safe to clear
@@ -63,7 +67,7 @@
{{ 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() }}
@@ -82,21 +86,27 @@ {% endif %} {# ── Per-image table ──────────────────────────────────────────────────── #} +
+ {% if g.images_capped %}The {{ g.images|length }} largest of {{ g.summary.images_total }} images, largest first. + {% else %}Every image, largest first.{% endif %} +
- + + + {% for im in g.images %} - + {% else %} diff --git a/plugins/traefik/routes.py b/plugins/traefik/routes.py index 850c178..85682ea 100644 --- a/plugins/traefik/routes.py +++ b/plugins/traefik/routes.py @@ -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) diff --git a/plugins/traefik/templates/traefik/rows.html b/plugins/traefik/templates/traefik/rows.html index 55cc343..cbdc2e6 100644 --- a/plugins/traefik/templates/traefik/rows.html +++ b/plugins/traefik/templates/traefik/rows.html @@ -105,7 +105,7 @@ - + {% endfor %} diff --git a/plugins/traefik/templates/traefik/traffic.html b/plugins/traefik/templates/traefik/traffic.html index f5f6584..b137754 100644 --- a/plugins/traefik/templates/traefik/traffic.html +++ b/plugins/traefik/templates/traefik/traffic.html @@ -92,9 +92,13 @@ {% else %}
- No access log data yet. {% if not access_log_enabled %} - Enable access_log.enabled: true in your plugin config and bind-mount the Traefik log directory. + The access log is off. Turn it on in Settings → Plugins → Traefik, + 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 %}{{ access_log_path }}{% else %}the log path{% endif %} is mounted into Steward + and that Traefik is writing to it. {% endif %}
{% endif %} diff --git a/plugins/traefik/templates/traefik/widget.html b/plugins/traefik/templates/traefik/widget.html index 8e3021e..f4cfa42 100644 --- a/plugins/traefik/templates/traefik/widget.html +++ b/plugins/traefik/templates/traefik/widget.html @@ -5,7 +5,7 @@
⚠ {{ cert.cn }} - {{ cert.days_remaining | int }}d + {{ cert_left(cert.days_remaining) }}
{% endfor %} diff --git a/plugins/unifi/templates/unifi/dpi.html b/plugins/unifi/templates/unifi/dpi.html index f154d1d..127c2a5 100644 --- a/plugins/unifi/templates/unifi/dpi.html +++ b/plugins/unifi/templates/unifi/dpi.html @@ -11,7 +11,7 @@
Traffic by Category - {{ snapshot.scraped_at.strftime("%H:%M") }} + as of {{ snapshot.scraped_at|ago }}
{% set total_bytes = categories | sum(attribute='rx_bytes') + categories | sum(attribute='tx_bytes') %}
ImageSizeSharedContainers
ImageSizeShared with other imagesUsed by
- {{ im.repo_tag }} - {% if im.reclaimable %}reclaimable{% endif %} + {% if im.repo_tag %}{{ im.repo_tag }}{% else %}untagged {{ im.image_id }}{% endif %} + {% if im.reclaimable %}reclaimable{% endif %} {{ im.size }} {{ im.shared }}{{ im.containers }}{% if im.containers %}{{ im.containers }} container{{ "s" if im.containers != 1 }}{% else %}nothing{% endif %}
No images reported.
{{ cert.cn }} {{ cert.not_after.strftime("%Y-%m-%d") }}{{ cert.days_remaining|int }} days{{ cert_left(cert.days_remaining) }}
diff --git a/plugins/unifi/templates/unifi/events.html b/plugins/unifi/templates/unifi/events.html index 2a82d20..275e799 100644 --- a/plugins/unifi/templates/unifi/events.html +++ b/plugins/unifi/templates/unifi/events.html @@ -25,7 +25,7 @@ {% for ev in events %} {% endfor %} diff --git a/plugins/unifi/templates/unifi/rows.html b/plugins/unifi/templates/unifi/rows.html index 2aa4dc8..aa1e7fb 100644 --- a/plugins/unifi/templates/unifi/rows.html +++ b/plugins/unifi/templates/unifi/rows.html @@ -146,7 +146,7 @@ {% if speedtest.server_name %}
via {{ speedtest.server_name }}
{% endif %} -
{{ speedtest.run_at.strftime("%Y-%m-%d %H:%M") }} UTC
+
{{ speedtest.run_at|ago }}
{% endif %} diff --git a/plugins/unifi/templates/unifi/security.html b/plugins/unifi/templates/unifi/security.html index b854c32..8e5da63 100644 --- a/plugins/unifi/templates/unifi/security.html +++ b/plugins/unifi/templates/unifi/security.html @@ -21,7 +21,7 @@
{{ al.message }}
- {{ al.alarm_time.strftime("%Y-%m-%d %H:%M") }} UTC + {{ al.alarm_time|ago }}
@@ -45,7 +45,7 @@
{{ al.message }}
{% endfor %} diff --git a/steward/app.py b/steward/app.py index 9ed8735..4bd99ef 100644 --- a/steward/app.py +++ b/steward/app.py @@ -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 diff --git a/steward/core/formatting.py b/steward/core/formatting.py index 41747fe..b1e745c 100644 --- a/steward/core/formatting.py +++ b/steward/core/formatting.py @@ -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("_", " ") diff --git a/steward/templates/ai_access/index.html b/steward/templates/ai_access/index.html index 0faea47..e39a199 100644 --- a/steward/templates/ai_access/index.html +++ b/steward/templates/ai_access/index.html @@ -48,7 +48,7 @@
- {{ ev.event_time.strftime("%Y-%m-%d %H:%M:%S") }} + {{ ev.event_time|ago }} {{ ev.category }} diff --git a/plugins/unifi/templates/unifi/inventory.html b/plugins/unifi/templates/unifi/inventory.html index 6e86eb5..b2b021e 100644 --- a/plugins/unifi/templates/unifi/inventory.html +++ b/plugins/unifi/templates/unifi/inventory.html @@ -117,7 +117,7 @@ {{ kc.ip or "—" }} - {% if kc.last_seen %}{{ kc.last_seen.strftime("%m-%d %H:%M") }}{% else %}—{% endif %} + {% if kc.last_seen %}{{ kc.last_seen|ago }}{% else %}—{% endif %}
- {{ al.alarm_time.strftime("%m-%d %H:%M") }} + {{ al.alarm_time|ago }}
{{ t.role.value }} {{ t.created_at.strftime("%Y-%m-%d") }} - {% if t.last_used_at %}{{ t.last_used_at.strftime("%Y-%m-%d %H:%M") }} UTC{% else %}Never{% endif %} + {% if t.last_used_at %}{{ t.last_used_at|ago }}{% else %}Never{% endif %} {% if row.state == 'revoked' %}Revoked diff --git a/steward/templates/settings/_ansible_sources.html b/steward/templates/settings/_ansible_sources.html index ab28c64..1a2356a 100644 --- a/steward/templates/settings/_ansible_sources.html +++ b/steward/templates/settings/_ansible_sources.html @@ -26,7 +26,7 @@ {% if src.type == 'git' %} {{ src.url or '(no URL)' }} branch: {{ src.branch or 'main' }} - {% if src.pull_interval_seconds %}· pull every {{ src.pull_interval_seconds }}s{% endif %} + {% if src.pull_interval_seconds %}· pulled every {{ src.pull_interval_seconds|duration }}{% endif %} {% else %} {{ src.path or '(no path)' }} {% endif %} diff --git a/steward/templates/settings/_tabs.html b/steward/templates/settings/_tabs.html index 490ad20..8cba429 100644 --- a/steward/templates/settings/_tabs.html +++ b/steward/templates/settings/_tabs.html @@ -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/"), ] %} diff --git a/steward/templates/settings/plugin_detail.html b/steward/templates/settings/plugin_detail.html index c41bdc5..2b31d8a 100644 --- a/steward/templates/settings/plugin_detail.html +++ b/steward/templates/settings/plugin_detail.html @@ -72,14 +72,14 @@ background:var(--bg);border-radius:5px;border:1px solid var(--border); font-size:0.82rem;color:var(--text-muted);"> 📋 - {{ cfg_key }} — list config, edit in + {{ field_label(cfg_key) }} — list config, edit in plugin.yaml ({{ cfg_default | length }} item{{ 's' if cfg_default | length != 1 }}). {% elif cfg_default is mapping %}
{{ cfg_key }}
+ letter-spacing:0.05em;margin-bottom:0.6rem;">{{ field_label(cfg_key) }}
{% set sub_cfg = plugin._config.get(cfg_key, cfg_default) if plugin._config.get(cfg_key, cfg_default) is mapping else cfg_default %}
{% 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 %}> + style="margin:0;font-size:0.85rem;color:var(--text);">{{ field_label(sub_key) }}
{% else %}
- + @@ -111,12 +111,12 @@ id="plugin-{{ plugin._dir }}-{{ cfg_key }}" {% if plugin._config.get(cfg_key, cfg_default) %}checked{% endif %}> + style="margin:0;font-size:0.85rem;color:var(--text);">{{ field_label(cfg_key) }}
{% else %}
- + Requires SMTP configured in Notifications. {% 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 %}

diff --git a/tests/core/test_formatting.py b/tests/core/test_formatting.py index 5792924..20a6f31 100644 --- a/tests/core/test_formatting.py +++ b/tests/core/test_formatting.py @@ -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" diff --git a/tests/plugins/docker/test_docker_page.py b/tests/plugins/docker/test_docker_page.py index 5014f54..b016988 100644 --- a/tests/plugins/docker/test_docker_page.py +++ b/tests/plugins/docker/test_docker_page.py @@ -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' in listed + + +async def test_the_disk_page_explains_its_terms_and_names_untagged_images(app): + """#5212: no unexplained jargon, no "" 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 bbbbbbbbbbbb" in page and "<none>" not in page + assert "The 2 largest of 312 images" in page and "as of nothing<" in page + assert "dangling" not in page diff --git a/tests/plugins/traefik/test_traefik_page.py b/tests/plugins/traefik/test_traefik_page.py index 4dd9fd2..cf3bbb4 100644 --- a/tests/plugins/traefik/test_traefik_page.py +++ b/tests/plugins/traefik/test_traefik_page.py @@ -88,3 +88,25 @@ async def test_gone_routers_are_listed_by_when_they_were_last_seen(app): assert "1 router no longer reported" in page assert "old last seen 2h ago" 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