feat(traefik): request chart shows the total, a time axis and a weighted 5xx share; says why if it can't draw (#5256)
#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:
+43
-21
@@ -375,38 +375,57 @@ async def widget_access_log():
|
||||
summary=summary, widget_id=widget_id)
|
||||
|
||||
|
||||
async def request_history(db, since, bucket_seconds: int) -> list[dict]:
|
||||
"""[{"at", "req_rate", "err_pct"}] per time bucket since `since`: each
|
||||
scrape summed over routers (the total req/s), averaged over the bucket;
|
||||
the 5xx share weighted by each router's traffic, so a quiet router's
|
||||
errors don't count as much as a busy one's."""
|
||||
per_scrape = (
|
||||
select(
|
||||
TraefikMetric.scraped_at.label("at"),
|
||||
func.sum(TraefikMetric.request_rate).label("req"),
|
||||
func.sum(TraefikMetric.request_rate * TraefikMetric.error_rate_5xx_pct).label("err_weighted"),
|
||||
)
|
||||
.where(TraefikMetric.scraped_at >= since)
|
||||
.group_by(TraefikMetric.scraped_at)
|
||||
.subquery()
|
||||
)
|
||||
bucket_col = (cast(func.extract("epoch", per_scrape.c.at), Integer) / bucket_seconds).label("bucket")
|
||||
rows = (await db.execute(
|
||||
select(
|
||||
func.avg(per_scrape.c.req).label("req_rate"),
|
||||
func.sum(per_scrape.c.err_weighted).label("err_weighted"),
|
||||
func.sum(per_scrape.c.req).label("req_sum"),
|
||||
func.min(per_scrape.c.at).label("at"),
|
||||
bucket_col,
|
||||
)
|
||||
.group_by(bucket_col)
|
||||
.order_by(bucket_col)
|
||||
)).all()
|
||||
return [{"at": r.at, "req_rate": round(r.req_rate or 0, 3),
|
||||
"err_pct": round((r.err_weighted or 0) / r.req_sum, 2) if r.req_sum else 0.0}
|
||||
for r in rows]
|
||||
|
||||
|
||||
@traefik_bp.get("/widget/request_chart")
|
||||
@require_role(UserRole.viewer)
|
||||
async def widget_request_chart():
|
||||
"""HTMX dashboard widget: Chart.js request rate + error % over time."""
|
||||
from datetime import timedelta
|
||||
"""HTMX dashboard widget: requests per second across all routers, and the
|
||||
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
|
||||
|
||||
bucket_col = (
|
||||
cast(func.extract('epoch', TraefikMetric.scraped_at), Integer) / b_secs
|
||||
).label("bucket")
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.avg(TraefikMetric.request_rate).label("req_rate"),
|
||||
func.avg(TraefikMetric.error_rate_5xx_pct).label("err_pct"),
|
||||
func.min(TraefikMetric.scraped_at).label("scraped_at"),
|
||||
bucket_col,
|
||||
)
|
||||
.where(TraefikMetric.scraped_at >= since)
|
||||
.group_by(bucket_col)
|
||||
.order_by(bucket_col)
|
||||
)
|
||||
history = result.all()
|
||||
history = await request_history(db, since, b_secs)
|
||||
|
||||
labels = list(range(len(history)))
|
||||
req_rates = [round(r.req_rate or 0, 3) for r in history]
|
||||
err_pcts = [round(r.err_pct or 0, 2) for r in history]
|
||||
labels = [int(r["at"].timestamp() * 1000) for r in history] # epoch ms; the browser shows local time
|
||||
req_rates = [r["req_rate"] for r in history]
|
||||
err_pcts = [r["err_pct"] for r in history]
|
||||
|
||||
return await render_template(
|
||||
"traefik/widget_request_chart.html",
|
||||
@@ -415,6 +434,9 @@ async def widget_request_chart():
|
||||
error_rate_json=json.dumps(err_pcts),
|
||||
widget_id=widget_id,
|
||||
hours=hours,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user