feat(settings): Settings brought into line with the rest of Steward (milestone 493)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 56s
CI / unit (push) Successful in 1m24s
CI / publish (push) Successful in 1m9s

The last page of #5214's walk. Settings still described Steward as it was
before monitors were unified and SNMP was rebuilt, drew its tabs below the
breadcrumb, and showed the report schedule in UTC.

- Tabs (#5377): settings/_tabs.html is a settings_tabs() macro called in
  each page's section_tabs block, above the trail, as Ansible's are. The
  breadcrumb guard also fails any page that {% include %}s a tab strip.
- Report time (#5378): reports.schedule_tz keeps the zone of whoever saved
  the schedule; check_and_send compares in it (report_due), and the form shows
  the day and hour on the reader's clock (schedule_in) under their zone's
  name. "Last sent" uses |when.
- Wording (#5379): the report contents list what the report holds (monitors,
  not ping-enabled hosts); "Ping latency" is "Response time" and says what it
  grades; plugin detail says enabling loads now and disabling needs a
  restart; the monitor interval says it applies on the next check (the
  scheduler reads it every pass); the dead list-config branch is gone.
- Traefik (#5380): the 5xx and 4xx cutoffs are settings
  (traefik.threshold.error_5xx_pct 0.1, error_4xx_pct 1.0) in a "Traefik
  routers" group on Thresholds while the plugin runs, read by router_state
  through error_cutoffs(). The Traefik page has a Thresholds button for
  admins, to /settings/thresholds/#traefik.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 13:28:43 -04:00
co-authored by Claude Opus 5.5
parent 263560cc94
commit 24700ee608
18 changed files with 262 additions and 86 deletions
+29 -10
View File
@@ -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}"