feat(traefik): routers are graded against their own normal (#5741)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m18s
CI / unit (push) Successful in 1m31s
CI / publish (push) Successful in 1m3s

#5741 "Traefik routers are graded against their own normal", milestone 513.

- A router's 4xx share and p95 learn their normal (learn_normal in the
  plugin's setup). Once a router has 5 days of history, a reading over the
  fixed cutoff but inside its normal is healthy, and the healthy table says
  it is usual for that router. One out of its normal is a warning that says
  what is usual, and a metric anomaly.
- The 5xx share is a failure: fixed cutoff always, whatever the history or
  traffic (System 182's ruling).
- A 4xx share over fewer requests than Settings -> Thresholds' new "Fewest
  requests to judge 4xx" (30 per 15 minutes) isn't graded on the page, and
  isn't judged for anomalies.
- Core: learn_normal(gate=(metric, least)) judges a key only while its
  resource's gate metric averages at least `least`; `least` may be a
  function, so it follows a setting. learn_normal(since=...) learns only
  from history after a moment: Traefik latency learns from #5755's
  per-scrape figures only.
- The Traefik page lists the routers' metric anomalies of the last 30 days
  (_anomaly_card.html). The dashboard widget keeps its why to problems.
- Thresholds and docs say when the cutoffs give way to a normal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 15:48:34 -04:00
co-authored by Claude Opus 5.5
parent 98a0ced5eb
commit 9f14620271
12 changed files with 321 additions and 46 deletions
+8 -3
View File
@@ -1,7 +1,8 @@
{# traefik/rows.html — HTMX fragment for the Traefik page: the verdict (swapped
into #traefik-verdict above the request chart, #5710), routers that need
attention (routes.router_state), the proxy's own figures, the healthy
routers folded into one table, then certificates by soonest expiry. #}
attention (routes.router_state), their metric anomalies, the proxy's own
figures, the healthy routers folded into one table (one over a cutoff but
within its normal says so, #5741), then certificates by soonest expiry. #}
{% from "_macros.html" import stat_tile, status_badge %}
{# ── Verdict: what needs a look and since when, then the counts ─────────── #}
@@ -77,6 +78,10 @@
</div>
{% endif %}
{# A router's 4xx share or p95 out of its own normal (#5741), and the last
30 days' anomalies, to review here. #}
{% include "_anomaly_card.html" %}
{{ proxy_figures() }}
{# ── Healthy routers, folded ─────────────────────────────────────────────── #}
@@ -89,7 +94,7 @@
<tbody>
{% for r in healthy %}
<tr>
<td style="word-break:break-all;">{{ r.name }}</td>
<td style="word-break:break-all;">{{ r.name }}{% if r.why %}<div class="field-hint">{{ r.why }}</div>{% endif %}</td>
<td>{{ "%.2f"|format(r.latest.request_rate) }}</td>
<td>{{ r.latest.response_bytes_rate|rate }}</td>
{{ latency_cell(r.latest.latency_p95_ms) }}
@@ -24,7 +24,7 @@
<td class="w-name" title="{{ r.name }}">
{{- status_dot(r.state) -}}
{{ r.name.split('@')[0] }}
{%- if r.why %}<span class="w-sub tone-{{ r.state }}">{{ r.why }}</span>{% endif %}
{%- if r.why and r.state != "ok" %}<span class="w-sub tone-{{ r.state }}">{{ r.why }}</span>{% endif %}
</td>
<td>{% if m.request_rate is none %}—{% else %}{{ "%.2f"|format(m.request_rate) }}{% endif %}</td>
<td class="w-wide-only">{% if m.response_bytes_rate is none %}—{% else %}{{ m.response_bytes_rate|rate }}{% endif %}</td>