feat(traefik): request chart shows the total, a time axis and a weighted 5xx share; says why if it can't draw (#5256)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 53s
CI / unit (push) Successful in 1m23s
CI / publish (push) Skipped

#5256 "Traefik — Request chart: blank on the live dashboard; time axis and honest labels".

- "Req/s" was the average of the routers, not the total. Each scrape is now
  summed over routers, then averaged per bucket (request_history).
- The 5xx share is now weighted by each router's traffic. The axis is no
  longer pinned at 0–100 (suggestedMax 5).
- The x axis shows times, in the viewer's local time. The tooltip shows
  both series at that time.
- A verdict line comes first: now / peak req/s and the peak 5xx share. The
  figures read even if the chart doesn't.
- The blank live chart's cause is still unknown. Chart.js and
  chart-colors.js both load on the dashboard. A missing library or a thrown
  error is now written into the widget instead of leaving it empty, so the
  cause will show on the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 13:28:55 -04:00
co-authored by Claude Opus 5.5
parent 0d603bc7dd
commit 39b6066f3b
4 changed files with 164 additions and 83 deletions
+25
View File
@@ -44,3 +44,28 @@ def test_each_router_reads_as_its_newest_row(app):
got = asyncio.run(go())
assert got[a].request_rate == 2.0 and got[a].scraped_at == now - timedelta(minutes=1)
assert got[b].request_rate == 4.0
def test_the_request_chart_sums_routers_and_weights_the_5xx_share(app):
"""#5256: two routers in one scrape are 4 req/s in total, not an average
of 2; a 5xx share of 50% on the 3 req/s router is 37.5% of all traffic."""
from plugins.traefik.models import TraefikMetric
from plugins.traefik.routes import request_history
# Far ahead of every other test's rows, so only these fall in the window.
at = datetime(2099, 1, 1, 12, 0, tzinfo=timezone.utc) + timedelta(minutes=uuid.uuid4().int % 10_000)
def row(name, rate, e5):
return TraefikMetric(router_name=name, scraped_at=at, request_rate=rate, error_rate_4xx_pct=0.0,
error_rate_5xx_pct=e5, latency_p50_ms=1.0, latency_p95_ms=2.0,
latency_p99_ms=3.0, response_bytes_rate=0.0)
async def go():
async with app.db_sessionmaker() as s:
async with s.begin():
s.add_all([row("quiet@docker", 1.0, 0.0), row("busy@docker", 3.0, 50.0)])
async with s.begin():
return await request_history(s, at, 60)
got = asyncio.run(go())
assert got == [{"at": at, "req_rate": 4.0, "err_pct": 37.5}]
@@ -230,3 +230,19 @@ async def test_the_page_sets_certificates_traefik_no_longer_serves_apart(app):
page = " ".join(page.split())
assert "also www.app.example" in page and ">app</td>" in page and ">not in use</td>" in page
assert page.index("spare.example") < page.index("No longer served (1)") < page.index("gone.example")
async def test_the_request_chart_says_its_figures_before_the_chart(app):
async with app.test_request_context("/plugins/traefik/widget/request_chart"):
page = await render_plugin_template(
app, "traefik", "traefik/widget_request_chart.html", labels_json="[1, 2]",
req_rate_json="[1.5, 4.25]", error_rate_json="[0, 0.5]", widget_id="7", hours=6,
now_req=4.25, peak_req=4.25, peak_err=0.5)
empty = await render_plugin_template(
app, "traefik", "traefik/widget_request_chart.html", labels_json="[]", hours=6)
page = " ".join(page.split())
assert "Now 4.25 req/s across all routers, peak 4.25. 5xx peaked at 0.50% of responses." in page
assert page.index("w-verdict") < page.index("<canvas")
assert "Requests/s (all routers)" in page and "max: 100" not in page
assert "couldn't be drawn" in page # a failure says why in place of the chart
assert "No Traefik requests recorded in the last 6h." in empty