diff --git a/plugins/traefik/routes.py b/plugins/traefik/routes.py index d67fc25..dc374ca 100644 --- a/plugins/traefik/routes.py +++ b/plugins/traefik/routes.py @@ -42,24 +42,29 @@ def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str: ) -# A router serving any real share of 5xx is failing; a 4xx share above this -# is worth a look (clients hitting missing routes, auth failures). -ERROR_5XX_PCT = 0.1 -ERROR_4XX_PCT = 1.0 _GRADE_ORDER = {"crit": 0, "warn": 1, "ok": 2} +def error_cutoffs(thresholds: dict) -> tuple[float, float]: + """(5xx, 4xx): the error shares, in percent, past which a router is failing + or worth a look. Settings → Thresholds, Traefik routers (#5380).""" + return (float(thresholds.get("error_5xx", {}).get("crit", 0.1)), + float(thresholds.get("error_4xx", {}).get("warn", 1.0))) + + def router_state(reading, thresholds: dict, over: str = "") -> tuple[str, str | None]: """(grade, why) for a router's reading (recent_window's, or one scrape's): - crit while it serves 5xx, warn on a p95 past the latency critical cutoff - (Settings → Thresholds) or a high 4xx share, otherwise ok with nothing to - say. `over` names the span the reading covers, for the why.""" - if (reading.error_rate_5xx_pct or 0) > ERROR_5XX_PCT: + crit while it serves 5xx past its cutoff, warn on a p95 past the latency + critical cutoff or a 4xx share past its cutoff (all Settings → Thresholds), + otherwise ok with nothing to say. `over` names the span the reading + covers, for the why.""" + error_5xx, error_4xx = error_cutoffs(thresholds) + if (reading.error_rate_5xx_pct or 0) > error_5xx: return "crit", f"{reading.error_rate_5xx_pct:.1f}% of responses are 5xx{over}" p95 = reading.latency_p95_ms if p95 is not None and status_class(p95, "latency", thresholds) == "crit": return "warn", f"p95 latency is {p95:.0f} ms{over}" - if (reading.error_rate_4xx_pct or 0) > ERROR_4XX_PCT: + if (reading.error_rate_4xx_pct or 0) > error_4xx: return "warn", f"{reading.error_rate_4xx_pct:.1f}% of responses are 4xx{over}" return "ok", None @@ -298,13 +303,14 @@ async def rows(): old_certs = [cert_row(c) for c in old] problems, healthy = split_routers(current) phases.mark("cert_rows") + error_5xx, error_4xx = error_cutoffs(thresholds) page = await render_template( "traefik/rows.html", problems=problems, healthy=healthy, gone=gone, - error_4xx=ERROR_4XX_PCT, - error_5xx=ERROR_5XX_PCT, + error_4xx=error_4xx, + error_5xx=error_5xx, global_stat=global_stat, certs=certs, old_certs=old_certs, diff --git a/plugins/traefik/templates/traefik/index.html b/plugins/traefik/templates/traefik/index.html index 18f50f0..ce44ceb 100644 --- a/plugins/traefik/templates/traefik/index.html +++ b/plugins/traefik/templates/traefik/index.html @@ -7,7 +7,12 @@

Traefik Services

polling every {{ poll_interval }}s - {% include "_time_range.html" %} +
+ {% if session.user_role == 'admin' %} + Thresholds + {% endif %} + {% include "_time_range.html" %} +
tuple[bool, str]: return False, f"Send failed: {exc}" +def report_due(now: datetime, day: int, hour: int, tz) -> bool: + """Whether `now` falls in the scheduled hour, read in the schedule's zone.""" + local = now.astimezone(tz) + return local.weekday() == day and local.hour == hour + + +def schedule_in(day: int, hour: int, saved_tz, view_tz, now: datetime | None = None) -> tuple[int, int]: + """The schedule's (weekday, hour) as a reader in `view_tz` sees it: the next + time it falls due in `saved_tz`, read on their clock.""" + local = (now or datetime.now(timezone.utc)).astimezone(saved_tz) + due = (local + timedelta(days=(day - local.weekday()) % 7)).replace( + hour=hour, minute=0, second=0, microsecond=0) + seen = due.astimezone(view_tz) + return seen.weekday(), seen.hour + + async def check_and_send(app) -> None: """Hourly scheduler hook: send if day/hour match and not sent recently.""" + from steward.core.formatting import zone from steward.core.settings import get_setting, set_setting async with app.db_sessionmaker() as db: @@ -224,10 +241,11 @@ async def check_and_send(app) -> None: schedule_day = int(await get_setting(db, "reports.schedule_day") or 6) schedule_hour = int(await get_setting(db, "reports.schedule_hour") or 8) + schedule_tz = zone(await get_setting(db, "reports.schedule_tz")) last_sent_str = await get_setting(db, "reports.last_sent_at") or "" now = datetime.now(timezone.utc) - if now.weekday() != schedule_day or now.hour != schedule_hour: + if not report_due(now, schedule_day, schedule_hour, schedule_tz): return if last_sent_str: diff --git a/steward/core/settings.py b/steward/core/settings.py index 06a7cf4..a15e08f 100644 --- a/steward/core/settings.py +++ b/steward/core/settings.py @@ -83,6 +83,10 @@ DEFAULTS: dict[str, Any] = { "ansible.max_concurrent_runs": 3, "ping.threshold.good_ms": 50, "ping.threshold.warn_ms": 200, + # A Traefik router serving more than this share of 5xx is failing; more + # than this share of 4xx is worth a look (#5380). + "traefik.threshold.error_5xx_pct": 0.1, + "traefik.threshold.error_4xx_pct": 1.0, # Degraded/critical cutoffs for metric coloring (warn=amber, crit=red). # cpu/mem/disk/load are percentages (load is load-per-core %); temp in °C. # uptime is "higher is better" so its warn/crit are floors. Ping latency @@ -156,7 +160,10 @@ DEFAULTS: dict[str, Any] = { # Scheduled reports "reports.enabled": False, "reports.schedule_day": 6, # 0=Monday … 6=Sunday - "reports.schedule_hour": 8, # UTC hour + "reports.schedule_hour": 8, # an hour in reports.schedule_tz + # The zone of whoever last saved the schedule (an IANA name; blank is UTC), + # so "Sunday 08:00" means 08:00 where they are (#5378). + "reports.schedule_tz": "", "reports.last_sent_at": "", } @@ -452,6 +459,11 @@ def to_thresholds_cfg(settings: dict[str, Any]) -> dict: "tls_days": {"warn": g("thresholds.tls_warn_days", 14), "crit": g("thresholds.tls_crit_days", 3), "dir": "low"}, "clock": {"warn": g("thresholds.clock_warn_s", 30), "crit": g("thresholds.clock_crit_s", 300), "dir": "high"}, "crash_loop": {"warn": g("thresholds.crash_loop_deaths", 3), "crit": g("thresholds.crash_loop_deaths", 3), "dir": "high"}, + # One cutoff each: a router's 5xx share past it is crit, a 4xx share warn. + "error_5xx": {"warn": g("traefik.threshold.error_5xx_pct", 0.1), + "crit": g("traefik.threshold.error_5xx_pct", 0.1), "dir": "high"}, + "error_4xx": {"warn": g("traefik.threshold.error_4xx_pct", 1.0), + "crit": g("traefik.threshold.error_4xx_pct", 1.0), "dir": "high"}, } diff --git a/steward/settings/routes.py b/steward/settings/routes.py index 95445ea..2daad8f 100644 --- a/steward/settings/routes.py +++ b/steward/settings/routes.py @@ -2,6 +2,7 @@ from __future__ import annotations import html import logging +from datetime import datetime from pathlib import Path from quart import Blueprint, current_app, render_template, request, redirect, url_for, session @@ -140,6 +141,8 @@ _THRESHOLD_FIELDS = [ ("tls_warn", "thresholds.tls_warn_days", False), ("tls_crit", "thresholds.tls_crit_days", False), ("clock_warn", "thresholds.clock_warn_s", False), ("clock_crit", "thresholds.clock_crit_s", False), ("crash_loop_deaths", "thresholds.crash_loop_deaths", False), + ("error_5xx_pct", "traefik.threshold.error_5xx_pct", True), + ("error_4xx_pct", "traefik.threshold.error_4xx_pct", True), ] # Docker time-series retention windows (days). Read fresh by the cleanup task, @@ -159,7 +162,9 @@ _RETENTION_FIELDS = [ async def thresholds(): async with current_app.db_sessionmaker() as db: settings = await get_all_settings(db) - return await render_template("settings/thresholds.html", settings=settings) + from steward.core.plugin_manager import _LOADED_PLUGINS + return await render_template("settings/thresholds.html", settings=settings, + traefik_on="traefik" in _LOADED_PLUGINS) @settings_bp.post("/thresholds/") @@ -568,17 +573,36 @@ async def save_auth_settings(): # ── Reports ─────────────────────────────────────────────────────────────────── +async def _reports_page(settings: dict, flash): + """Settings → Reports, with the schedule and the last send in the reader's + zone: the hour is kept in the zone of whoever saved it (#5378).""" + from steward.core.formatting import zone + from steward.core.reports import schedule_in + shown_day, shown_hour = schedule_in( + int(settings.get("reports.schedule_day", 6)), int(settings.get("reports.schedule_hour", 8)), + zone(settings.get("reports.schedule_tz")), zone(session.get("tz"))) + raw = settings.get("reports.last_sent_at") or "" + try: + last_sent = datetime.fromisoformat(raw) if raw else None + except ValueError: + last_sent = None + return await render_template("settings/reports.html", settings=settings, flash=flash, + shown_day=shown_day, shown_hour=shown_hour, last_sent=last_sent) + + @settings_bp.get("/reports/") @require_role(UserRole.admin) async def reports(): async with current_app.db_sessionmaker() as db: settings = await get_all_settings(db) - return await render_template("settings/reports.html", settings=settings, flash=None) + return await _reports_page(settings, None) @settings_bp.post("/reports/") @require_role(UserRole.admin) async def save_reports(): + """The day and hour are what the reader picked on their own clock, so the + schedule is kept in their zone.""" form = await request.form async with current_app.db_sessionmaker() as db: async with db.begin(): @@ -587,6 +611,7 @@ async def save_reports(): int(form.get("reports.schedule_day", 6))) await set_setting(db, "reports.schedule_hour", int(form.get("reports.schedule_hour", 8))) + await set_setting(db, "reports.schedule_tz", session.get("tz") or "") return redirect(url_for("settings.reports")) @@ -597,11 +622,7 @@ async def send_report_now(): async with current_app.db_sessionmaker() as db: settings = await get_all_settings(db) ok, message = await send_report(current_app._get_current_object()) - return await render_template( - "settings/reports.html", - settings=settings, - flash={"ok": ok, "message": message}, - ) + return await _reports_page(settings, {"ok": ok, "message": message}) def _build_plugin_cfg_from_form(plugin: dict, form) -> dict: @@ -612,9 +633,7 @@ def _build_plugin_cfg_from_form(plugin: dict, form) -> dict: for cfg_key in plugin.get("config", {}).keys(): field_name = f"plugin.{name}.{cfg_key}" default_val = plugin["config"][cfg_key] - if isinstance(default_val, list): - pass # list configs must be set in plugin.yaml - elif isinstance(default_val, dict): + if isinstance(default_val, dict): sub_dict = {} for sub_key, sub_default in default_val.items(): sub_field = f"plugin.{name}.{cfg_key}.{sub_key}" diff --git a/steward/templates/settings/_tabs.html b/steward/templates/settings/_tabs.html index 8cba429..af050a4 100644 --- a/steward/templates/settings/_tabs.html +++ b/steward/templates/settings/_tabs.html @@ -1,20 +1,13 @@ -{# settings/_tabs.html — include at top of each settings section. - No section

: the breadcrumb kicker ("Settings › …") names the section and - the tab strip is the visual header, so we don't stack a redundant title. #} -
- {% set tabs = [ - ("general", "General", "/settings/general/"), - ("thresholds", "Thresholds & Retention", "/settings/thresholds/"), - ("notifications", "Notifications", "/settings/notifications/"), - ("reports", "Reports", "/settings/reports/"), - ("auth", "Authentication", "/settings/auth/"), - ("ansible", "Ansible", "/settings/ansible/"), - ("plugins", "Plugins", "/settings/plugins/"), - ] %} - {% for key, label, href in tabs %} - - {{ label }} - - {% endfor %} -
+{# settings/_tabs.html — the Settings section's pages as one strip of tabs, in + each page's section_tabs block above the trail, as Ansible's are (#5377). + `active` names the page or the one it sits under. #} +{% from "_macros.html" import section_tabs %} +{% macro settings_tabs(active) -%} +{{ section_tabs([("general", "General", "/settings/general/"), + ("thresholds", "Thresholds & Retention", "/settings/thresholds/"), + ("notifications", "Notifications", "/settings/notifications/"), + ("reports", "Reports", "/settings/reports/"), + ("auth", "Authentication", "/settings/auth/"), + ("ansible", "Ansible", "/settings/ansible/"), + ("plugins", "Plugins", "/settings/plugins/")], active, "Settings") }} +{%- endmacro %} diff --git a/steward/templates/settings/ansible.html b/steward/templates/settings/ansible.html index 48990f8..876dc65 100644 --- a/steward/templates/settings/ansible.html +++ b/steward/templates/settings/ansible.html @@ -2,10 +2,10 @@ {% extends "base.html" %} {% from "_macros.html" import crumbs %} {% block title %}Settings — Ansible — Steward{% endblock %} +{% from "settings/_tabs.html" import settings_tabs %} +{% block section_tabs %}{{ settings_tabs("ansible") }}{% endblock %} {% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Ansible", "")]) }}{% endblock %} {% block content %} -{% set active_tab = "ansible" %} -{% include "settings/_tabs.html" %}
diff --git a/steward/templates/settings/auth.html b/steward/templates/settings/auth.html index 0a66232..503448a 100644 --- a/steward/templates/settings/auth.html +++ b/steward/templates/settings/auth.html @@ -2,10 +2,10 @@ {% extends "base.html" %} {% from "_macros.html" import crumbs %} {% block title %}Settings — Auth — Steward{% endblock %} +{% from "settings/_tabs.html" import settings_tabs %} +{% block section_tabs %}{{ settings_tabs("auth") }}{% endblock %} {% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Authentication", "")]) }}{% endblock %} {% block content %} -{% set active_tab = "auth" %} -{% include "settings/_tabs.html" %}
{{ csrf_field() }}
diff --git a/steward/templates/settings/general.html b/steward/templates/settings/general.html index 68b9478..e6a4fee 100644 --- a/steward/templates/settings/general.html +++ b/steward/templates/settings/general.html @@ -2,10 +2,10 @@ {% extends "base.html" %} {% from "_macros.html" import crumbs %} {% block title %}Settings — General — Steward{% endblock %} +{% from "settings/_tabs.html" import settings_tabs %} +{% block section_tabs %}{{ settings_tabs("general") }}{% endblock %} {% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("General", "")]) }}{% endblock %} {% block content %} -{% set active_tab = "general" %} -{% include "settings/_tabs.html" %} {{ csrf_field() }}
@@ -72,7 +72,7 @@ -
Requires restart to take effect.
+
How often each monitor is checked, unless it sets its own. Takes effect on the next check.
diff --git a/steward/templates/settings/notifications.html b/steward/templates/settings/notifications.html index 131caf4..360ef2f 100644 --- a/steward/templates/settings/notifications.html +++ b/steward/templates/settings/notifications.html @@ -2,10 +2,10 @@ {% extends "base.html" %} {% from "_macros.html" import crumbs %} {% block title %}Settings — Notifications — Steward{% endblock %} +{% from "settings/_tabs.html" import settings_tabs %} +{% block section_tabs %}{{ settings_tabs("notifications") }}{% endblock %} {% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Notifications", "")]) }}{% endblock %} {% block content %} -{% set active_tab = "notifications" %} -{% include "settings/_tabs.html" %} {{ csrf_field() }}
diff --git a/steward/templates/settings/plugin_detail.html b/steward/templates/settings/plugin_detail.html index 2b31d8a..af6f346 100644 --- a/steward/templates/settings/plugin_detail.html +++ b/steward/templates/settings/plugin_detail.html @@ -2,10 +2,10 @@ {% extends "base.html" %} {% from "_macros.html" import crumbs %} {% block title %}{{ plugin.get('name', plugin._dir) }} Settings — Steward{% endblock %} +{% from "settings/_tabs.html" import settings_tabs %} +{% block section_tabs %}{{ settings_tabs("plugins") }}{% endblock %} {% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Plugins", "/settings/plugins/"), (plugin.get('name', plugin._dir), "")]) }}{% endblock %} {% block content %} -{% set active_tab = "plugins" %} -{% include "settings/_tabs.html" %}

{{ plugin.get('name', plugin._dir) }}

@@ -18,7 +18,7 @@ background:color-mix(in srgb,var(--green) 12%,var(--bg));color:var(--green);">Loaded {% elif plugin._enabled %} Enabled (restart required) + background:var(--bg-elevated);color:var(--text-muted);">Enabled, not loaded {% else %} Disabled @@ -55,7 +55,7 @@
- Enable/disable changes take effect after a restart. + Enabling loads the plugin straight away. Disabling takes effect after a restart.
@@ -67,16 +67,7 @@ {% for cfg_key, cfg_default in plugin.config.items() %} - {% if cfg_default is iterable and cfg_default is not string and cfg_default is not mapping %} -
- 📋 - {{ 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 %} + {% if cfg_default is mapping %}
{{ field_label(cfg_key) }}
diff --git a/steward/templates/settings/plugins.html b/steward/templates/settings/plugins.html index dce0d4d..773b232 100644 --- a/steward/templates/settings/plugins.html +++ b/steward/templates/settings/plugins.html @@ -2,10 +2,10 @@ {% extends "base.html" %} {% from "_macros.html" import crumbs %} {% block title %}Settings — Plugins — Steward{% endblock %} +{% from "settings/_tabs.html" import settings_tabs %} +{% block section_tabs %}{{ settings_tabs("plugins") }}{% endblock %} {% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Plugins", "")]) }}{% endblock %} {% block content %} -{% set active_tab = "plugins" %} -{% include "settings/_tabs.html" %} {# ── Restart banner ────────────────────────────────────────────────────────── #}
diff --git a/steward/templates/settings/reports.html b/steward/templates/settings/reports.html index 2d4df83..d18a6b1 100644 --- a/steward/templates/settings/reports.html +++ b/steward/templates/settings/reports.html @@ -2,10 +2,10 @@ {% extends "base.html" %} {% from "_macros.html" import crumbs %} {% block title %}Settings — Reports — Steward{% endblock %} +{% from "settings/_tabs.html" import settings_tabs %} +{% block section_tabs %}{{ settings_tabs("reports") }}{% endblock %} {% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Reports", "")]) }}{% endblock %} {% block content %} -{% set active_tab = "reports" %} -{% include "settings/_tabs.html" %} {% if flash %}
{% for i, day in [(0,"Monday"),(1,"Tuesday"),(2,"Wednesday"),(3,"Thursday"),(4,"Friday"),(5,"Saturday"),(6,"Sunday")] %} - {% endfor %}
- + +
Above this share, a router is failing (red).
+
+
+ + +
Above this share, a router is worth a look (amber): missing routes, failed sign-ins.
+
+
+{% endif %} + {% macro days(label, field, key, hint) %}
diff --git a/tests/core/test_report_schedule.py b/tests/core/test_report_schedule.py new file mode 100644 index 0000000..3ab5ecf --- /dev/null +++ b/tests/core/test_report_schedule.py @@ -0,0 +1,31 @@ +"""The weekly report's hour is kept in the zone of whoever saved it, and a +reader sees it on their own clock (#5378).""" +from datetime import datetime, timezone +from zoneinfo import ZoneInfo + +from steward.core.reports import report_due, schedule_in + +NY = ZoneInfo("America/New_York") +BERLIN = ZoneInfo("Europe/Berlin") + + +def test_sunday_eight_in_new_york_is_due_at_noon_utc_in_october(): + sunday_noon_utc = datetime(2026, 10, 11, 12, 30, tzinfo=timezone.utc) # 08:30 EDT + assert report_due(sunday_noon_utc, 6, 8, NY) + assert not report_due(sunday_noon_utc, 6, 8, timezone.utc) + assert not report_due(datetime(2026, 10, 11, 8, 30, tzinfo=timezone.utc), 6, 8, NY) + + +def test_a_reader_sees_the_saved_hour_on_their_own_clock(): + now = datetime(2026, 10, 8, 15, 0, tzinfo=timezone.utc) + assert schedule_in(6, 8, NY, NY, now) == (6, 8) + assert schedule_in(6, 8, NY, BERLIN, now) == (6, 14) + assert schedule_in(6, 8, timezone.utc, NY, now) == (6, 4) # an old UTC schedule, read in New York + assert schedule_in(0, 1, timezone.utc, NY, now) == (6, 21) # Monday 01:00 UTC is Sunday evening there + + +def test_what_a_reader_picks_and_saves_is_what_they_see_again(): + now = datetime(2026, 10, 8, 15, 0, tzinfo=timezone.utc) + for day in range(7): + for hour in (0, 8, 23): + assert schedule_in(day, hour, BERLIN, BERLIN, now) == (day, hour) diff --git a/tests/core/test_settings_pages.py b/tests/core/test_settings_pages.py new file mode 100644 index 0000000..2d3e2b9 --- /dev/null +++ b/tests/core/test_settings_pages.py @@ -0,0 +1,42 @@ +"""Settings pages say what Steward does now, in the reader's zone (milestone 493).""" +from quart import render_template, session + +from steward.core.settings import DEFAULTS + + +def _settings(**over): + return {**DEFAULTS, **over} + + +async def _render(app, name, path, tz="America/New_York", **ctx): + async with app.test_request_context(path): + session["user_role"] = "admin" + session["tz"] = tz + return await render_template(name, **ctx) + + +async def test_reports_name_the_readers_zone_and_what_the_report_holds(app): + page = await _render(app, "settings/reports.html", "/settings/reports/", settings=_settings(), + flash=None, shown_day=6, shown_hour=4, last_sent=None) + assert "UTC" not in page[page.index("' in page.replace("\n", "").replace(" ", "") + assert "ping-enabled" not in page and "Monitors down now" in page + + +async def test_the_traefik_group_shows_only_while_the_plugin_runs(app): + on = await _render(app, "settings/thresholds.html", "/settings/thresholds/", settings=_settings(), + traefik_on=True) + off = await _render(app, "settings/thresholds.html", "/settings/thresholds/", settings=_settings(), + traefik_on=False) + assert 'id="traefik"' in on and 'name="error_5xx_pct"' in on and 'value="0.1"' in on + assert 'id="traefik"' not in off + assert "Ping widget" not in on and "Response time" in on + + +async def test_settings_tabs_sit_above_the_trail(app): + page = await _render(app, "settings/thresholds.html", "/settings/thresholds/", settings=_settings(), + traefik_on=False) + tabs, trail = page.index('aria-label="Settings"'), page.index('aria-label="Breadcrumb"') + assert tabs < trail + assert '' in page diff --git a/tests/plugins/traefik/test_traefik_page.py b/tests/plugins/traefik/test_traefik_page.py index d6dde8d..43fbfdf 100644 --- a/tests/plugins/traefik/test_traefik_page.py +++ b/tests/plugins/traefik/test_traefik_page.py @@ -291,3 +291,25 @@ async def test_the_config_tile_says_when_it_last_reloaded(app): error_4xx=1.0, error_5xx=0.1) assert "Config reloaded" in page and "3m ago" in page and "343 reloads" in page assert "Reload failed" not in page + + +# ── Error cutoffs are settings, reachable from the page (#5380) ───────────── + +def test_router_state_reads_the_error_cutoffs_from_settings(): + strict = to_thresholds_cfg({"traefik.threshold.error_5xx_pct": 0.0, "traefik.threshold.error_4xx_pct": 0.2}) + lenient = to_thresholds_cfg({"traefik.threshold.error_5xx_pct": 5.0, "traefik.threshold.error_4xx_pct": 10.0}) + assert router_state(_latest(e5=0.05), strict)[0] == "crit" + assert router_state(_latest(e5=0.05), TH)[0] == "ok" # under the 0.1% default + assert router_state(_latest(e5=2.5), lenient)[0] == "ok" + assert router_state(_latest(e4=0.5), strict)[0] == "warn" + assert router_state(_latest(e4=5.0), lenient)[0] == "ok" + + +@pytest.mark.parametrize("role, linked", [("admin", True), ("viewer", False)]) +async def test_the_page_links_an_admin_to_the_router_thresholds(app, role, linked): + from quart import session + async with app.test_request_context("/plugins/traefik/"): + session["user_role"] = role + page = await render_plugin_template(app, "traefik", "traefik/index.html", poll_interval=30, + access_log_enabled=False, current_range="1h") + assert ('href="/settings/thresholds/#traefik"' in page) is linked diff --git a/tests/test_breadcrumbs.py b/tests/test_breadcrumbs.py index 4af568d..0b6eaff 100644 --- a/tests/test_breadcrumbs.py +++ b/tests/test_breadcrumbs.py @@ -80,6 +80,22 @@ def test_section_tabs_sit_above_the_trail(): assert base.index("{% block section_tabs %}") < base.index("{% block breadcrumb %}") pages = [p for p in template_files() if _EXTENDS_BASE.search(p.read_text())] tabbed = [p for p in pages if _TABS_CALL.search(p.read_text())] - assert len(tabbed) >= 10, [str(p) for p in tabbed] # the ten Ansible pages, at least + assert len(tabbed) >= 18, [str(p) for p in tabbed] # the ten Ansible pages and eight Settings pages wrong = sorted(str(p.relative_to(REPO)) for p in tabbed if tabs_outside_their_block(p.read_text())) assert not wrong, f"move the tabs into {{% block section_tabs %}}: {wrong}" + + +# A tab strip pulled in with {% include %} lands wherever the include sits, which +# is how Settings drew its tabs under the trail unnoticed (#5377). Tabs come only +# from a *_tabs() macro call, which the guard above places. +_TABS_INCLUDE = re.compile(r"\{%-?\s*include\s+[\"'][^\"']*_tabs\.html") + + +def test_the_include_guard_can_fail(): + assert _TABS_INCLUDE.search('{% include "settings/_tabs.html" %}') + assert not _TABS_INCLUDE.search('{% from "settings/_tabs.html" import settings_tabs %}') + + +def test_no_page_includes_a_tab_strip(): + included = sorted(str(p.relative_to(REPO)) for p in template_files() if _TABS_INCLUDE.search(p.read_text())) + assert not included, f"call the section's *_tabs() macro in {{% block section_tabs %}}: {included}"