feat(traefik): the page leads with its verdict and the request chart (#5710)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m27s
CI / unit (push) Failing after 1m38s
CI / publish (push) Skipped

The Traefik page now reads state first: a verdict naming each router that
needs a look, why, and when it started (from its history buckets over the
page's range), then the request chart following the page's time range, then
the problem cards (which say since when too), the proxy's own figures, and
the healthy rest. The verdict is filled out of band by /rows each poll; the
"Needs attention" and "Healthy routers" tiles it replaces are gone.

The request chart takes ?range= from the page; past a day its times carry the
day, and its clock is 24-hour (hourCycle h23).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 10:50:09 -04:00
co-authored by Claude Opus 5.5
parent 4195b9171a
commit 0bac1d2068
6 changed files with 186 additions and 30 deletions
+30 -5
View File
@@ -137,6 +137,23 @@ def gone_routers(routers: list[dict], scrape_seconds: int) -> tuple[list[dict],
return current, gone
def problem_since(history: list, thresholds: dict) -> tuple[datetime | None, bool]:
"""(start, before_range) of a router's current problem, read from its
history buckets (router_history's, oldest first): the start of the
trailing run of buckets that grade as a problem. (None, False) when the
newest bucket reads ok, as for a problem too recent or too brief for a
bucket to show. `before_range` says the run reaches back to the first
bucket, so the problem began before the range shown."""
start = None
for point in reversed(history):
if router_state(point, thresholds)[0] == "ok":
break
start = point
if start is None:
return None, False
return start.bucket, start is history[0]
def split_routers(routers: list[dict]) -> tuple[list[dict], list[dict]]:
"""(problems, healthy): failing routers before slow or noisy ones, each
group by name; healthy routers by name."""
@@ -259,6 +276,8 @@ async def rows():
phases.mark("window")
for r in current:
points = history[r["name"]]
r["since"], r["since_before"] = (problem_since(points, thresholds) if r["state"] != "ok"
else (None, False))
r["sparkline_req"] = _sparkline([p.request_rate for p in points])
r["sparkline_bytes"] = _sparkline([p.response_bytes_rate for p in points])
r["sparkline_p95"] = _sparkline([p.latency_p95_ms for p in points])
@@ -309,6 +328,7 @@ async def rows():
problems=problems,
healthy=healthy,
gone=gone,
current_range=range_key,
error_4xx=error_4xx,
error_5xx=error_5xx,
global_stat=global_stat,
@@ -524,11 +544,15 @@ async def widget_request_chart():
share of them answered 5xx, over the last few hours (#5256). Each scrape
is summed over routers first (the total), then averaged per bucket; the
5xx share is weighted by each router's traffic, not averaged per router."""
hours = max(1, min(24, int(request.args.get("hours", 6) or 6)))
widget_id = request.args.get("wid", "0")
since = datetime.now(timezone.utc) - timedelta(hours=hours)
b_secs = max(60, (hours * 3600) // 80) # ~80 buckets
if request.args.get("range"):
# The Traefik page: the page's own time range (#5710).
since, span = parse_range(request.args.get("range"))
b_secs = bucket_seconds(since)
else:
hours = max(1, min(24, int(request.args.get("hours", 6) or 6)))
since, span = datetime.now(timezone.utc) - timedelta(hours=hours), f"{hours}h"
b_secs = max(60, (hours * 3600) // 80) # ~80 buckets
async with current_app.db_sessionmaker() as db:
history = await request_history(db, since, b_secs)
@@ -543,7 +567,8 @@ async def widget_request_chart():
req_rate_json=json.dumps(req_rates),
error_rate_json=json.dumps(err_pcts),
widget_id=widget_id,
hours=hours,
span=span,
days=(datetime.now(timezone.utc) - since) > timedelta(hours=24),
now_req=req_rates[-1] if req_rates else None,
peak_req=max(req_rates) if req_rates else None,
peak_err=max(err_pcts) if err_pcts else None,
@@ -15,6 +15,19 @@
</div>
</div>
{# State first (#5710): the verdict, which /rows fills out of band each poll;
then requests and 5xx over the page's range; then the routers. #}
<p class="page-verdict tone-muted" id="traefik-verdict">Reading routers…</p>
<div class="card" style="margin-bottom:1rem;">
<div id="traefik-chart"
hx-get="/plugins/traefik/widget/request_chart?wid=page"
hx-trigger="load, every 300s, rangeChange from:body"
hx-include="#time-range"
hx-swap="innerHTML">
</div>
</div>
<div id="traefik-rows"
hx-get="/plugins/traefik/rows"
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
+32 -16
View File
@@ -1,21 +1,23 @@
{# traefik/rows.html — HTMX fragment for the Traefik page: routers that need
attention first (routes.router_state), the proxy's own counts, the healthy
{# 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. #}
{% from "_macros.html" import stat_tile, status_badge %}
{# ── Counts ───────────────────────────────────────────────────────────────── #}
<div class="stat-strip">
{{ stat_tile("Needs attention", problems|length, state="crit" if problems else "inactive") }}
{{ stat_tile("Healthy routers", healthy|length, state="up" if healthy else "inactive") }}
{% if global_stat %}
{% if global_stat.config_last_reload_at %}
{{ stat_tile("Config reloaded", global_stat.config_last_reload_at|ago,
hint=(global_stat.config_reloads_total|int ~ " reloads") if global_stat.config_reloads_total is not none else none) }}
{% endif %}
{% if global_stat.open_conns_total is not none %}{{ stat_tile("Open connections", global_stat.open_conns_total|int) }}{% endif %}
{% if global_stat.process_memory_bytes is not none %}{{ stat_tile("Memory", global_stat.process_memory_bytes|bytes) }}{% endif %}
{% endif %}
</div>
{# ── Verdict: what needs a look and since when, then the counts ─────────── #}
{% set shown = 3 %}
<p class="page-verdict" id="traefik-verdict" hx-swap-oob="true">
{%- if problems %}<strong class="tone-{{ problems[0].state }}">{{ problems|length }} router{{ "s" if problems|length != 1 }} need{{ "s" if problems|length == 1 }} a look</strong>
{%- for r in problems[:shown] %}{{ ":" if loop.first else ";" }} <span style="word-break:break-all;">{{ r.name }}</span>, {{ r.why }}
{%- if r.since_before %}, all of the last {{ current_range }}{% elif r.since %}, started {{ r.since|ago }}{% endif %}
{%- endfor %}
{%- if problems|length > shown %}; {{ problems|length - shown }} more below{% endif %}.
{%- if healthy %} {{ healthy|length }} healthy.{% endif %}
{%- elif healthy %}<strong class="tone-ok">All {{ healthy|length }} router{{ "s" if healthy|length != 1 }} healthy</strong>
{%- else %}<span class="tone-muted">No routers reported yet.</span>
{%- endif %}
{%- if gone %} <span class="tone-muted">{{ gone|length }} no longer reported.</span>{% endif %}
</p>
{% macro router_card(r) %}
<div class="card">
@@ -23,7 +25,8 @@
{{ status_badge(r.state, "Failing" if r.state == 'crit' else "Warning") }}
<h3 class="section-title" style="margin:0;word-break:break-all;">{{ r.name }}</h3>
</div>
<p class="problem-detail" style="margin-bottom:0.5rem;">{{ r.why }}</p>
<p class="problem-detail" style="margin-bottom:0.5rem;">{{ r.why }}
{%- if r.since_before %}, all of the last {{ current_range }}{% elif r.since %}, started {{ r.since|ago }}{% endif %}</p>
<table class="table" style="font-size:0.85rem;">
<tr><td class="field-hint">Req/s</td><td>{{ "%.2f"|format(r.latest.request_rate) }}</td><td>{{ r.sparkline_req|safe }}</td></tr>
<tr><td class="field-hint">Bandwidth</td><td>{{ r.latest.response_bytes_rate|rate }}</td><td>{{ r.sparkline_bytes|safe }}</td></tr>
@@ -46,6 +49,19 @@
</div>
{% endif %}
{# ── The proxy's own figures ─────────────────────────────────────────────── #}
{% if global_stat and (global_stat.config_last_reload_at or global_stat.open_conns_total is not none
or global_stat.process_memory_bytes is not none) %}
<div class="stat-strip">
{% if global_stat.config_last_reload_at %}
{{ stat_tile("Config reloaded", global_stat.config_last_reload_at|ago,
hint=(global_stat.config_reloads_total|int ~ " reloads") if global_stat.config_reloads_total is not none else none) }}
{% endif %}
{% if global_stat.open_conns_total is not none %}{{ stat_tile("Open connections", global_stat.open_conns_total|int) }}{% endif %}
{% if global_stat.process_memory_bytes is not none %}{{ stat_tile("Memory", global_stat.process_memory_bytes|bytes) }}{% endif %}
</div>
{% endif %}
{# ── Healthy routers, folded ─────────────────────────────────────────────── #}
{% if healthy %}
<details class="card" id="traefik-healthy" style="margin-bottom:1rem;">
@@ -1,16 +1,17 @@
{# traefik/widget_request_chart.html — requests per second across all routers
and the share answered 5xx, over the last few hours (#5256). The verdict
and the share answered 5xx, over the last few hours (#5256), or over the
Traefik page's time range there (#5710). The verdict
carries the figures, so the widget still says something if the chart
can't draw; a chart that fails says why in place of the canvas. #}
{% if not labels_json or labels_json == '[]' %}
<div class="w-verdict" style="color:var(--text-muted);">No Traefik requests recorded in the last {{ hours }}h.</div>
<div class="w-verdict" style="color:var(--text-muted);">No Traefik requests recorded in the last {{ span }}.</div>
{% else %}
<div class="w-verdict {{ 'tone-crit' if peak_err and peak_err >= 1 else ('tone-warn' if peak_err else 'tone-ok') }}">
Now {{ "%.2f"|format(now_req) }} req/s across all routers, peak {{ "%.2f"|format(peak_req) }}.
{%- if peak_err %} 5xx peaked at {{ "%.2f"|format(peak_err) }}% of responses.{% else %} No 5xx.{% endif %}
</div>
<div style="position:relative;height:160px;" id="chart-traefik-req-box-{{ widget_id }}">
<canvas id="chart-traefik-req-{{ widget_id }}" aria-label="Requests per second and 5xx share, last {{ hours }} hours"></canvas>
<canvas id="chart-traefik-req-{{ widget_id }}" aria-label="Requests per second and 5xx share, last {{ span }}"></canvas>
</div>
<script>
(function() {
@@ -27,8 +28,9 @@
var existing = Chart.getChart(ctx);
if (existing) existing.destroy();
var times = {{ labels_json | safe }};
// A 24-hour clock in the reader's zone; past a day, with the day too.
var hhmm = function (ms) {
return new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
return new Date(ms).toLocaleString([], { {% if days %}weekday: "short", day: "numeric", {% endif %}hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
};
var chart = new Chart(ctx.getContext("2d"), {
type: "line",
@@ -96,5 +98,5 @@
}
})();
</script>
<div class="w-note" style="text-align:right;">last {{ hours }}h, your local time</div>
<div class="w-note" style="text-align:right;">last {{ span }}, your local time</div>
{% endif %}