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 @@
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" %}