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
+17 -11
View File
@@ -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} _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]: 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): """(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 crit while it serves 5xx past its cutoff, warn on a p95 past the latency
(Settings → Thresholds) or a high 4xx share, otherwise ok with nothing to critical cutoff or a 4xx share past its cutoff (all Settings → Thresholds),
say. `over` names the span the reading covers, for the why.""" otherwise ok with nothing to say. `over` names the span the reading
if (reading.error_rate_5xx_pct or 0) > ERROR_5XX_PCT: 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}" return "crit", f"{reading.error_rate_5xx_pct:.1f}% of responses are 5xx{over}"
p95 = reading.latency_p95_ms p95 = reading.latency_p95_ms
if p95 is not None and status_class(p95, "latency", thresholds) == "crit": if p95 is not None and status_class(p95, "latency", thresholds) == "crit":
return "warn", f"p95 latency is {p95:.0f} ms{over}" 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 "warn", f"{reading.error_rate_4xx_pct:.1f}% of responses are 4xx{over}"
return "ok", None return "ok", None
@@ -298,13 +303,14 @@ async def rows():
old_certs = [cert_row(c) for c in old] old_certs = [cert_row(c) for c in old]
problems, healthy = split_routers(current) problems, healthy = split_routers(current)
phases.mark("cert_rows") phases.mark("cert_rows")
error_5xx, error_4xx = error_cutoffs(thresholds)
page = await render_template( page = await render_template(
"traefik/rows.html", "traefik/rows.html",
problems=problems, problems=problems,
healthy=healthy, healthy=healthy,
gone=gone, gone=gone,
error_4xx=ERROR_4XX_PCT, error_4xx=error_4xx,
error_5xx=ERROR_5XX_PCT, error_5xx=error_5xx,
global_stat=global_stat, global_stat=global_stat,
certs=certs, certs=certs,
old_certs=old_certs, old_certs=old_certs,
+6 -1
View File
@@ -7,7 +7,12 @@
<h1 class="page-title" style="margin-bottom:0;">Traefik Services</h1> <h1 class="page-title" style="margin-bottom:0;">Traefik Services</h1>
<span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span> <span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span>
</div> </div>
{% include "_time_range.html" %} <div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;">
{% if session.user_role == 'admin' %}
<a href="/settings/thresholds/#traefik" class="btn btn-ghost btn-sm">Thresholds</a>
{% endif %}
{% include "_time_range.html" %}
</div>
</div> </div>
<div id="traefik-rows" <div id="traefik-rows"
+19 -1
View File
@@ -213,8 +213,25 @@ async def send_report(app) -> tuple[bool, str]:
return False, f"Send failed: {exc}" 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: async def check_and_send(app) -> None:
"""Hourly scheduler hook: send if day/hour match and not sent recently.""" """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 from steward.core.settings import get_setting, set_setting
async with app.db_sessionmaker() as db: 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_day = int(await get_setting(db, "reports.schedule_day") or 6)
schedule_hour = int(await get_setting(db, "reports.schedule_hour") or 8) 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 "" last_sent_str = await get_setting(db, "reports.last_sent_at") or ""
now = datetime.now(timezone.utc) 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 return
if last_sent_str: if last_sent_str:
+13 -1
View File
@@ -83,6 +83,10 @@ DEFAULTS: dict[str, Any] = {
"ansible.max_concurrent_runs": 3, "ansible.max_concurrent_runs": 3,
"ping.threshold.good_ms": 50, "ping.threshold.good_ms": 50,
"ping.threshold.warn_ms": 200, "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). # Degraded/critical cutoffs for metric coloring (warn=amber, crit=red).
# cpu/mem/disk/load are percentages (load is load-per-core %); temp in °C. # 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 # uptime is "higher is better" so its warn/crit are floors. Ping latency
@@ -156,7 +160,10 @@ DEFAULTS: dict[str, Any] = {
# Scheduled reports # Scheduled reports
"reports.enabled": False, "reports.enabled": False,
"reports.schedule_day": 6, # 0=Monday … 6=Sunday "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": "", "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"}, "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"}, "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"}, "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"},
} }
+29 -10
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import html import html
import logging import logging
from datetime import datetime
from pathlib import Path from pathlib import Path
from quart import Blueprint, current_app, render_template, request, redirect, url_for, session 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), ("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), ("clock_warn", "thresholds.clock_warn_s", False), ("clock_crit", "thresholds.clock_crit_s", False),
("crash_loop_deaths", "thresholds.crash_loop_deaths", 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, # Docker time-series retention windows (days). Read fresh by the cleanup task,
@@ -159,7 +162,9 @@ _RETENTION_FIELDS = [
async def thresholds(): async def thresholds():
async with current_app.db_sessionmaker() as db: async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(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/") @settings_bp.post("/thresholds/")
@@ -568,17 +573,36 @@ async def save_auth_settings():
# ── Reports ─────────────────────────────────────────────────────────────────── # ── 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/") @settings_bp.get("/reports/")
@require_role(UserRole.admin) @require_role(UserRole.admin)
async def reports(): async def reports():
async with current_app.db_sessionmaker() as db: async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(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/") @settings_bp.post("/reports/")
@require_role(UserRole.admin) @require_role(UserRole.admin)
async def save_reports(): 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 form = await request.form
async with current_app.db_sessionmaker() as db: async with current_app.db_sessionmaker() as db:
async with db.begin(): async with db.begin():
@@ -587,6 +611,7 @@ async def save_reports():
int(form.get("reports.schedule_day", 6))) int(form.get("reports.schedule_day", 6)))
await set_setting(db, "reports.schedule_hour", await set_setting(db, "reports.schedule_hour",
int(form.get("reports.schedule_hour", 8))) int(form.get("reports.schedule_hour", 8)))
await set_setting(db, "reports.schedule_tz", session.get("tz") or "")
return redirect(url_for("settings.reports")) return redirect(url_for("settings.reports"))
@@ -597,11 +622,7 @@ async def send_report_now():
async with current_app.db_sessionmaker() as db: async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db) settings = await get_all_settings(db)
ok, message = await send_report(current_app._get_current_object()) ok, message = await send_report(current_app._get_current_object())
return await render_template( return await _reports_page(settings, {"ok": ok, "message": message})
"settings/reports.html",
settings=settings,
flash={"ok": ok, "message": message},
)
def _build_plugin_cfg_from_form(plugin: dict, form) -> dict: 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(): for cfg_key in plugin.get("config", {}).keys():
field_name = f"plugin.{name}.{cfg_key}" field_name = f"plugin.{name}.{cfg_key}"
default_val = plugin["config"][cfg_key] default_val = plugin["config"][cfg_key]
if isinstance(default_val, list): if isinstance(default_val, dict):
pass # list configs must be set in plugin.yaml
elif isinstance(default_val, dict):
sub_dict = {} sub_dict = {}
for sub_key, sub_default in default_val.items(): for sub_key, sub_default in default_val.items():
sub_field = f"plugin.{name}.{cfg_key}.{sub_key}" sub_field = f"plugin.{name}.{cfg_key}.{sub_key}"
+13 -20
View File
@@ -1,20 +1,13 @@
{# settings/_tabs.html — include at top of each settings section. {# settings/_tabs.html — the Settings section's pages as one strip of tabs, in
No section <h1>: the breadcrumb kicker ("Settings › …") names the section and each page's section_tabs block above the trail, as Ansible's are (#5377).
the tab strip is the visual header, so we don't stack a redundant title. #} `active` names the page or the one it sits under. #}
<div style="display:flex;gap:0;border-bottom:1px solid var(--border-mid);margin-bottom:1.5rem;"> {% from "_macros.html" import section_tabs %}
{% set tabs = [ {% macro settings_tabs(active) -%}
("general", "General", "/settings/general/"), {{ section_tabs([("general", "General", "/settings/general/"),
("thresholds", "Thresholds & Retention", "/settings/thresholds/"), ("thresholds", "Thresholds & Retention", "/settings/thresholds/"),
("notifications", "Notifications", "/settings/notifications/"), ("notifications", "Notifications", "/settings/notifications/"),
("reports", "Reports", "/settings/reports/"), ("reports", "Reports", "/settings/reports/"),
("auth", "Authentication", "/settings/auth/"), ("auth", "Authentication", "/settings/auth/"),
("ansible", "Ansible", "/settings/ansible/"), ("ansible", "Ansible", "/settings/ansible/"),
("plugins", "Plugins", "/settings/plugins/"), ("plugins", "Plugins", "/settings/plugins/")], active, "Settings") }}
] %} {%- endmacro %}
{% for key, label, href in tabs %}
<a href="{{ href }}"
style="padding:0.6rem 1.1rem;font-size:0.875rem;border-bottom:2px solid {{ 'var(--accent)' if active_tab == key else 'transparent' }};color:{{ 'var(--text)' if active_tab == key else 'var(--text-muted)' }};font-weight:{{ '500' if active_tab == key else 'normal' }};text-decoration:none;">
{{ label }}
</a>
{% endfor %}
</div>
+2 -2
View File
@@ -2,10 +2,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_macros.html" import crumbs %} {% from "_macros.html" import crumbs %}
{% block title %}Settings — Ansible — Steward{% endblock %} {% 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 breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Ansible", "")]) }}{% endblock %}
{% block content %} {% block content %}
{% set active_tab = "ansible" %}
{% include "settings/_tabs.html" %}
<div style="max-width:720px;"> <div style="max-width:720px;">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:0.75rem;"> <div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:0.75rem;">
+2 -2
View File
@@ -2,10 +2,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_macros.html" import crumbs %} {% from "_macros.html" import crumbs %}
{% block title %}Settings — Auth — Steward{% endblock %} {% 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 breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Authentication", "")]) }}{% endblock %}
{% block content %} {% block content %}
{% set active_tab = "auth" %}
{% include "settings/_tabs.html" %}
<form method="post" action="/settings/auth/">{{ csrf_field() }} <form method="post" action="/settings/auth/">{{ csrf_field() }}
<div style="display:grid;gap:1.25rem;max-width:640px;"> <div style="display:grid;gap:1.25rem;max-width:640px;">
+3 -3
View File
@@ -2,10 +2,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_macros.html" import crumbs %} {% from "_macros.html" import crumbs %}
{% block title %}Settings — General — Steward{% endblock %} {% 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 breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("General", "")]) }}{% endblock %}
{% block content %} {% block content %}
{% set active_tab = "general" %}
{% include "settings/_tabs.html" %}
<form method="post" action="/settings/general/">{{ csrf_field() }} <form method="post" action="/settings/general/">{{ csrf_field() }}
<div class="card" style="max-width:640px;"> <div class="card" style="max-width:640px;">
@@ -72,7 +72,7 @@
<label>Monitor poll interval <span style="color:var(--text-muted);font-size:0.8rem;">(seconds)</span></label> <label>Monitor poll interval <span style="color:var(--text-muted);font-size:0.8rem;">(seconds)</span></label>
<input type="number" name="monitors.poll_interval_seconds" min="10" <input type="number" name="monitors.poll_interval_seconds" min="10"
value="{{ settings['monitors.poll_interval_seconds'] }}" style="max-width:120px;"> value="{{ settings['monitors.poll_interval_seconds'] }}" style="max-width:120px;">
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">Requires restart to take effect.</div> <div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">How often each monitor is checked, unless it sets its own. Takes effect on the next check.</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -2,10 +2,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_macros.html" import crumbs %} {% from "_macros.html" import crumbs %}
{% block title %}Settings — Notifications — Steward{% endblock %} {% 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 breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Notifications", "")]) }}{% endblock %}
{% block content %} {% block content %}
{% set active_tab = "notifications" %}
{% include "settings/_tabs.html" %}
<form method="post" action="/settings/notifications/">{{ csrf_field() }} <form method="post" action="/settings/notifications/">{{ csrf_field() }}
<div style="display:grid;gap:1.25rem;max-width:640px;"> <div style="display:grid;gap:1.25rem;max-width:640px;">
+5 -14
View File
@@ -2,10 +2,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_macros.html" import crumbs %} {% from "_macros.html" import crumbs %}
{% block title %}{{ plugin.get('name', plugin._dir) }} Settings — Steward{% endblock %} {% 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 breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Plugins", "/settings/plugins/"), (plugin.get('name', plugin._dir), "")]) }}{% endblock %}
{% block content %} {% block content %}
{% set active_tab = "plugins" %}
{% include "settings/_tabs.html" %}
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;"> <div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">{{ plugin.get('name', plugin._dir) }}</h1> <h1 class="page-title" style="margin-bottom:0;">{{ plugin.get('name', plugin._dir) }}</h1>
@@ -18,7 +18,7 @@
background:color-mix(in srgb,var(--green) 12%,var(--bg));color:var(--green);">Loaded</span> background:color-mix(in srgb,var(--green) 12%,var(--bg));color:var(--green);">Loaded</span>
{% elif plugin._enabled %} {% elif plugin._enabled %}
<span style="font-size:0.75rem;padding:0.15em 0.55em;border-radius:3px; <span style="font-size:0.75rem;padding:0.15em 0.55em;border-radius:3px;
background:var(--bg-elevated);color:var(--text-muted);">Enabled (restart required)</span> background:var(--bg-elevated);color:var(--text-muted);">Enabled, not loaded</span>
{% else %} {% else %}
<span style="font-size:0.75rem;padding:0.15em 0.55em;border-radius:3px; <span style="font-size:0.75rem;padding:0.15em 0.55em;border-radius:3px;
background:var(--bg-elevated);color:var(--text-muted);">Disabled</span> background:var(--bg-elevated);color:var(--text-muted);">Disabled</span>
@@ -55,7 +55,7 @@
</label> </label>
</div> </div>
<div style="font-size:0.8rem;color:var(--text-muted);margin-top:0.4rem;margin-left:1.6rem;"> <div style="font-size:0.8rem;color:var(--text-muted);margin-top:0.4rem;margin-left:1.6rem;">
Enable/disable changes take effect after a restart. Enabling loads the plugin straight away. Disabling takes effect after a restart.
</div> </div>
</div> </div>
@@ -67,16 +67,7 @@
{% for cfg_key, cfg_default in plugin.config.items() %} {% 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 %} {% if cfg_default is mapping %}
<div style="display:flex;align-items:flex-start;gap:0.5rem;padding:0.5rem 0.75rem;
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);">{{ 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="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; <div style="font-size:0.74rem;color:var(--text-muted);text-transform:uppercase;
letter-spacing:0.05em;margin-bottom:0.6rem;">{{ field_label(cfg_key) }}</div> letter-spacing:0.05em;margin-bottom:0.6rem;">{{ field_label(cfg_key) }}</div>
+2 -2
View File
@@ -2,10 +2,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_macros.html" import crumbs %} {% from "_macros.html" import crumbs %}
{% block title %}Settings — Plugins — Steward{% endblock %} {% 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 breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Plugins", "")]) }}{% endblock %}
{% block content %} {% block content %}
{% set active_tab = "plugins" %}
{% include "settings/_tabs.html" %}
{# ── Restart banner ────────────────────────────────────────────────────────── #} {# ── Restart banner ────────────────────────────────────────────────────────── #}
<div id="restart-banner"></div> <div id="restart-banner"></div>
+11 -13
View File
@@ -2,10 +2,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_macros.html" import crumbs %} {% from "_macros.html" import crumbs %}
{% block title %}Settings — Reports — Steward{% endblock %} {% 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 breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Reports", "")]) }}{% endblock %}
{% block content %} {% block content %}
{% set active_tab = "reports" %}
{% include "settings/_tabs.html" %}
{% if flash %} {% if flash %}
<div style="padding:0.75rem 1rem;border-radius:6px;margin-bottom:1.25rem; <div style="padding:0.75rem 1rem;border-radius:6px;margin-bottom:1.25rem;
@@ -35,17 +35,17 @@
<label>Send on day</label> <label>Send on day</label>
<select name="reports.schedule_day"> <select name="reports.schedule_day">
{% for i, day in [(0,"Monday"),(1,"Tuesday"),(2,"Wednesday"),(3,"Thursday"),(4,"Friday"),(5,"Saturday"),(6,"Sunday")] %} {% for i, day in [(0,"Monday"),(1,"Tuesday"),(2,"Wednesday"),(3,"Thursday"),(4,"Friday"),(5,"Saturday"),(6,"Sunday")] %}
<option value="{{ i }}" {% if settings['reports.schedule_day'] == i %}selected{% endif %}> <option value="{{ i }}" {% if shown_day == i %}selected{% endif %}>
{{ day }} {{ day }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>At hour (UTC)</label> <label>At hour ({{ tz_label() }})</label>
<select name="reports.schedule_hour"> <select name="reports.schedule_hour">
{% for h in range(24) %} {% for h in range(24) %}
<option value="{{ h }}" {% if settings['reports.schedule_hour'] == h %}selected{% endif %}> <option value="{{ h }}" {% if shown_hour == h %}selected{% endif %}>
{{ "%02d:00"|format(h) }} {{ "%02d:00"|format(h) }}
</option> </option>
{% endfor %} {% endfor %}
@@ -55,9 +55,7 @@
<p style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;"> <p style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">
Requires SMTP configured in <a href="/settings/notifications/">Notifications</a>. Requires SMTP configured in <a href="/settings/notifications/">Notifications</a>.
{% if settings['reports.last_sent_at'] %} {% if last_sent %}Last sent {{ last_sent|when }}.{% endif %}
Last sent: {{ settings['reports.last_sent_at'][:16]|replace("T", " ") }} UTC.
{% endif %}
</p> </p>
<div style="margin-top:1rem;"> <div style="margin-top:1rem;">
@@ -70,11 +68,11 @@
<div class="card" style="margin-bottom:1.25rem;"> <div class="card" style="margin-bottom:1.25rem;">
<h2 class="section-title" style="margin-bottom:0.75rem;">Report contents</h2> <h2 class="section-title" style="margin-bottom:0.75rem;">Report contents</h2>
<div style="display:grid;gap:0.35rem;font-size:0.85rem;color:var(--text-muted);"> <div style="display:grid;gap:0.35rem;font-size:0.85rem;color:var(--text-muted);">
<div>• <strong style="color:var(--text);">Uptime summary</strong> — 7-day % per ping-enabled host</div> <div>• <strong style="color:var(--text);">Monitors down now</strong> — named at the top, when any are</div>
<div>• <strong style="color:var(--text);">Hosts currently down</strong> — highlighted at top if any</div> <div>• <strong style="color:var(--text);">Uptime</strong> — each enabled monitor's share of checks up over 7 days</div>
<div>• <strong style="color:var(--text);">Active alerts</strong> — firing, acknowledged, pending rules</div> <div>• <strong style="color:var(--text);">Active alerts</strong> — firing, acknowledged and pending rules</div>
<div>• <strong style="color:var(--text);">Alert events (7d)</strong> — fired and resolved counts</div> <div>• <strong style="color:var(--text);">Alert events</strong> — how many fired and resolved over 7 days</div>
<div>• <strong style="color:var(--text);">Top Traefik routers</strong> — avg req/s over last 24h (if plugin enabled)</div> <div>• <strong style="color:var(--text);">Busiest Traefik routers</strong> — average requests per second over 24 hours, while Traefik is on</div>
</div> </div>
</div> </div>
+26 -3
View File
@@ -2,10 +2,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_macros.html" import crumbs %} {% from "_macros.html" import crumbs %}
{% block title %}Settings — Thresholds & Retention — Steward{% endblock %} {% block title %}Settings — Thresholds & Retention — Steward{% endblock %}
{% from "settings/_tabs.html" import settings_tabs %}
{% block section_tabs %}{{ settings_tabs("thresholds") }}{% endblock %}
{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Thresholds & retention", "")]) }}{% endblock %} {% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Thresholds & retention", "")]) }}{% endblock %}
{% block content %} {% block content %}
{% set active_tab = "thresholds" %}
{% include "settings/_tabs.html" %}
{% macro pair(label, warn_field, warn_key, crit_field, crit_key, unit, hint, step="1") %} {% macro pair(label, warn_field, warn_key, crit_field, crit_key, unit, hint, step="1") %}
<div class="form-group" style="margin-bottom:1.1rem;"> <div class="form-group" style="margin-bottom:1.1rem;">
@@ -40,7 +40,7 @@
{{ pair("Disk usage", "disk_warn", "thresholds.disk_warn", "disk_crit", "thresholds.disk_crit", "%", "Filesystem usage.") }} {{ pair("Disk usage", "disk_warn", "thresholds.disk_warn", "disk_crit", "thresholds.disk_crit", "%", "Filesystem usage.") }}
{{ pair("Load", "load_warn", "thresholds.load_warn", "load_crit", "thresholds.load_crit", "% per core", "Load average normalized by core count (100% = one full core per core).") }} {{ pair("Load", "load_warn", "thresholds.load_warn", "load_crit", "thresholds.load_crit", "% per core", "Load average normalized by core count (100% = one full core per core).") }}
{{ pair("Temperature", "temp_warn", "thresholds.temp_warn", "temp_crit", "thresholds.temp_crit", "°C", "Hottest reported sensor per host.") }} {{ pair("Temperature", "temp_warn", "thresholds.temp_warn", "temp_crit", "thresholds.temp_crit", "°C", "Hottest reported sensor per host.") }}
{{ pair("Ping latency", "latency_warn", "ping.threshold.good_ms", "latency_crit", "ping.threshold.warn_ms", "ms", "Round-trip time. Shared with the Ping widget's good/warn colouring.") }} {{ pair("Response time", "latency_warn", "ping.threshold.good_ms", "latency_crit", "ping.threshold.warn_ms", "ms", "How long a monitor's check takes to answer. Also grades Traefik routers' p95 and the UniFi WAN round trip.") }}
{{ pair("Uptime / SLA", "uptime_warn", "thresholds.uptime_warn", "uptime_crit", "thresholds.uptime_crit", "%", "Lower is worse — amber below warn, red below crit.", step="0.1") }} {{ pair("Uptime / SLA", "uptime_warn", "thresholds.uptime_warn", "uptime_crit", "thresholds.uptime_crit", "%", "Lower is worse — amber below warn, red below crit.", step="0.1") }}
{{ pair("TLS certificate expiry", "tls_warn", "thresholds.tls_warn_days", "tls_crit", "thresholds.tls_crit_days", "days left", "HTTPS monitors whose certificate expires within these many days are flagged. Lower is worse.") }} {{ pair("TLS certificate expiry", "tls_warn", "thresholds.tls_warn_days", "tls_crit", "thresholds.tls_crit_days", "days left", "HTTPS monitors whose certificate expires within these many days are flagged. Lower is worse.") }}
{{ pair("Clock drift", "clock_warn", "thresholds.clock_warn_s", "clock_crit", "thresholds.clock_crit_s", "seconds", "How far a host's clock may be from Steward's, either way, before its page and the Hosts list call it out.") }} {{ pair("Clock drift", "clock_warn", "thresholds.clock_warn_s", "clock_crit", "thresholds.clock_crit_s", "seconds", "How far a host's clock may be from Steward's, either way, before its page and the Hosts list call it out.") }}
@@ -57,6 +57,29 @@
</div> </div>
</div> </div>
{# Traefik's router cutoffs, while the plugin runs; its page links here (#5380). #}
{% if traefik_on %}
<div class="card" id="traefik" style="max-width:640px;margin-top:1.25rem;">
<h2 class="section-title" style="margin-bottom:0.5rem;">Traefik routers</h2>
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.25rem;">
How much of a router's traffic, over the last 15 minutes, may end in an error before the
<a href="/plugins/traefik/">Traefik page</a> and the Routers widget list it.
</p>
<div class="form-group" style="margin-bottom:1.1rem;">
<label for="error_5xx_pct">Server errors (5xx) <span style="color:var(--text-muted);font-size:0.8rem;">(% of responses)</span></label>
<input type="number" id="error_5xx_pct" name="error_5xx_pct" min="0" max="100" step="0.01"
value="{{ settings['traefik.threshold.error_5xx_pct'] }}" style="max-width:110px;margin-top:0.25rem;">
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.3rem;">Above this share, a router is failing (red).</div>
</div>
<div class="form-group" style="margin-bottom:0.25rem;">
<label for="error_4xx_pct">Client errors (4xx) <span style="color:var(--text-muted);font-size:0.8rem;">(% of responses)</span></label>
<input type="number" id="error_4xx_pct" name="error_4xx_pct" min="0" max="100" step="0.1"
value="{{ settings['traefik.threshold.error_4xx_pct'] }}" style="max-width:110px;margin-top:0.25rem;">
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.3rem;">Above this share, a router is worth a look (amber): missing routes, failed sign-ins.</div>
</div>
</div>
{% endif %}
{% macro days(label, field, key, hint) %} {% macro days(label, field, key, hint) %}
<div class="form-group" style="margin-bottom:1.1rem;"> <div class="form-group" style="margin-bottom:1.1rem;">
<label>{{ label }} <span style="color:var(--text-muted);font-size:0.8rem;">(days)</span></label> <label>{{ label }} <span style="color:var(--text-muted);font-size:0.8rem;">(days)</span></label>
+31
View File
@@ -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)
+42
View File
@@ -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("<main"):]
assert "At hour (E" in page # EDT or EST, by the date
assert '<option value="4" selected>' 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 '<a href="/settings/thresholds/" aria-current="page">' in page
@@ -291,3 +291,25 @@ async def test_the_config_tile_says_when_it_last_reloaded(app):
error_4xx=1.0, error_5xx=0.1) error_4xx=1.0, error_5xx=0.1)
assert "Config reloaded" in page and "3m ago" in page and "343 reloads" in page assert "Config reloaded" in page and "3m ago" in page and "343 reloads" in page
assert "Reload failed" not 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
+17 -1
View File
@@ -80,6 +80,22 @@ def test_section_tabs_sit_above_the_trail():
assert base.index("{% block section_tabs %}") < base.index("{% block breadcrumb %}") assert base.index("{% block section_tabs %}") < base.index("{% block breadcrumb %}")
pages = [p for p in template_files() if _EXTENDS_BASE.search(p.read_text())] 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())] 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())) 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}" 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}"