feat(traefik): Routers widget shows current routers only, one header row, certs on the routers they serve (#5254)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 52s
CI / unit (push) Failing after 1m17s
CI / publish (push) Skipped

#5254 "Traefik — Routers: current routers only, header row, certs on the router they serve".

- Router hosts come from Traefik's API (/api/http|tcp/routers), read
  best-effort on each scrape. An optional api_url overrides it; the default
  is /api on the metrics host. A certificate's expiry then shows in a TLS
  column on the router whose host it covers. An expiring one makes that
  router a problem, graded by Settings → Thresholds.
- Only current: routers and certificates must have appeared in the last
  three scrapes. Certificates are upserted by serial and never deleted, so
  renewed or retired ones lingered on the widget.
- Widget order: verdict, problem routers with the reason, then the busiest
  healthy routers under one header (Router / Req/s / Bandwidth / p95 / TLS).
  A missing value shows "—".
- If the API is unreachable, expiring certificates are listed after the
  routers, not above them.
- The page splits certificates into served (with the routers using each,
  plus SANs) and "No longer served".
- Latest row per router: an index walk (recursive CTE + LATERAL) replaces
  DISTINCT plus a query per router.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 13:25:11 -04:00
co-authored by Claude Opus 5.5
parent d72a1991a7
commit ea5531ac8f
8 changed files with 468 additions and 128 deletions
+130 -66
View File
@@ -3,14 +3,16 @@ from __future__ import annotations
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from quart import Blueprint, current_app, render_template, request
from sqlalchemy import Integer, cast, func, select
from sqlalchemy import Integer, cast, func, select, text
import json
from steward.auth.middleware import require_role
from steward.models.users import UserRole
from steward.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds, subsample
from steward.core.formatting import status_class
from steward.core.formatting import cert_left, status_class
from .models import TraefikAccessSummary, TraefikCert, TraefikGlobalStat, TraefikMetric
from .scheduler import router_domains
from .scraper import cert_names, covers
traefik_bp = Blueprint("traefik", __name__, template_folder="templates")
@@ -85,6 +87,71 @@ def split_routers(routers: list[dict]) -> tuple[list[dict], list[dict]]:
return problems, healthy
# Newest row per router. Postgres has no skip scan, so DISTINCT or max() GROUP
# BY reads every row in retention; this walks the (router_name, scraped_at)
# index one router at a time instead: a step per router, not per row.
_LATEST_PER_ROUTER = text("""
WITH RECURSIVE names AS (
SELECT min(router_name) AS name FROM traefik_metrics
UNION ALL
SELECT (SELECT min(router_name) FROM traefik_metrics WHERE router_name > names.name)
FROM names WHERE names.name IS NOT NULL
)
SELECT m.* FROM names
JOIN LATERAL (
SELECT * FROM traefik_metrics WHERE router_name = names.name
ORDER BY scraped_at DESC LIMIT 1
) m ON true
""")
async def latest_per_router(db) -> list:
return list((await db.execute(
select(TraefikMetric).from_statement(_LATEST_PER_ROUTER))).scalars().all())
def current_certs(certs, scrape_seconds: int) -> tuple[list, list]:
"""(served, old): a certificate is served while Traefik still reports it.
Rows are upserted by serial and never deleted, so a renewed or retired
certificate lingers; one missing from the last GONE_AFTER_SCRAPES
scrapes is old (#5254)."""
certs = list(certs)
if not certs:
return [], []
newest = max(c.scraped_at for c in certs)
cutoff = newest - timedelta(seconds=GONE_AFTER_SCRAPES * max(int(scrape_seconds), 1))
return ([c for c in certs if c.scraped_at >= cutoff],
sorted((c for c in certs if c.scraped_at < cutoff), key=lambda c: c.scraped_at, reverse=True))
def router_hosts(name: str, domains: dict[str, list[str]]) -> list[str]:
"""The hosts a metrics router name serves. The API names a router
"app@docker"; metrics may carry it as "websecure-app@docker", one per
entrypoint, so a name not found as-is is tried without that prefix."""
if name in domains:
return domains[name]
for api_name, hosts in domains.items():
if name.endswith("-" + api_name):
return hosts
return []
def certs_by_router(routers: list[str], domains: dict[str, list[str]], certs, now) -> dict[str, dict]:
"""{router: {"cn", "days"}}: the soonest-expiring served certificate valid
for one of the router's hosts. A router with no hosts, or none covered,
is absent."""
out: dict[str, dict] = {}
names = [(c, cert_names(c.cn, c.sans)) for c in certs]
for router in routers:
for host in router_hosts(router, domains):
for c, cn in names:
if covers(cn, host):
days = (c.not_after - now).total_seconds() / 86400.0
if router not in out or days < out[router]["days"]:
out[router] = {"cn": c.cn or host, "days": days}
return out
def _access_log() -> dict:
"""The plugin's access_log settings ({} when unset or malformed)."""
cfg = current_app.config["PLUGINS"]["traefik"].get("access_log", {})
@@ -114,13 +181,9 @@ async def rows():
thresholds = current_app.config.get("THRESHOLDS", {})
async with current_app.db_sessionmaker() as db:
# All known routers
result = await db.execute(
select(TraefikMetric.router_name)
.distinct()
.order_by(TraefikMetric.router_name)
)
routers = [row[0] for row in result.all()]
# Every known router, with its most recent raw row
newest = {m.router_name: m for m in await latest_per_router(db)}
routers = sorted(newest)
b_secs = bucket_seconds(since)
bucket_col = (
@@ -145,16 +208,7 @@ async def rows():
)
history = result.all()
# Latest value — always the most recent raw row
result = await db.execute(
select(TraefikMetric)
.where(TraefikMetric.router_name == router)
.order_by(TraefikMetric.scraped_at.desc())
.limit(1)
)
latest = result.scalar_one_or_none()
if not latest:
continue
latest = newest[router]
state, why = router_state(latest, thresholds)
router_data.append({
@@ -181,19 +235,28 @@ async def rows():
)
raw_certs = result.scalars().all()
certs = []
for c in raw_certs:
days = (c.not_after - now).total_seconds() / 86400.0
certs.append({
scrape = current_app.config["PLUGINS"]["traefik"]["scrape_interval_seconds"]
current, gone = gone_routers(router_data, scrape)
served, old = current_certs(raw_certs, scrape)
domains = router_domains()
def cert_row(c):
names = cert_names(c.cn, c.sans)
return {
"serial": c.serial,
"cn": c.cn or c.serial,
"sans": c.sans,
"sans": [n for n in names if n != c.cn],
"not_after": c.not_after,
"days_remaining": days,
})
"days_remaining": (c.not_after - now).total_seconds() / 86400.0,
# The routers whose hosts it covers; None when Traefik's API
# couldn't say which hosts each router serves.
"routers": None if domains is None else sorted(
{r["name"].split("@")[0] for r in current
for h in router_hosts(r["name"], domains) if covers(names, h)}),
}
current, gone = gone_routers(
router_data, current_app.config["PLUGINS"]["traefik"]["scrape_interval_seconds"])
certs = [cert_row(c) for c in served]
old_certs = [cert_row(c) for c in old]
problems, healthy = split_routers(current)
return await render_template(
"traefik/rows.html",
@@ -204,6 +267,7 @@ async def rows():
error_5xx=ERROR_5XX_PCT,
global_stat=global_stat,
certs=certs,
old_certs=old_certs,
range_key=range_key,
)
@@ -211,53 +275,53 @@ async def rows():
@traefik_bp.get("/widget")
@require_role(UserRole.viewer)
async def widget():
"""HTMX dashboard widget fragment."""
"""The dashboard Routers widget (#5254), in the widget-shape order: a
verdict, routers in trouble with why, then the busiest healthy ones under
one header. Only routers and certificates Traefik still reports. A
certificate's expiry shows on the router it serves, and makes that router
a problem once it is inside the Settings → Thresholds TLS window."""
now = datetime.now(timezone.utc)
thresholds = current_app.config.get("THRESHOLDS", {})
scrape = current_app.config["PLUGINS"]["traefik"]["scrape_interval_seconds"]
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(TraefikMetric.router_name).distinct().order_by(TraefikMetric.router_name)
)
routers = [row[0] for row in result.all()]
router_data = []
for router in routers:
result = await db.execute(
select(TraefikMetric)
.where(TraefikMetric.router_name == router)
.order_by(TraefikMetric.scraped_at.desc())
.limit(1)
)
latest = result.scalar_one_or_none()
if latest:
router_data.append({"name": router, "latest": latest})
router_data.sort(key=lambda r: (
-(1 if r["latest"].error_rate_5xx_pct > 0.1 else 0),
-(1 if r["latest"].error_rate_4xx_pct > 5.0 else 0),
-r["latest"].request_rate,
))
total_routers = len(router_data)
router_data = router_data[:5]
result = await db.execute(
select(TraefikCert).order_by(TraefikCert.not_after)
)
raw_certs = result.scalars().all()
expiring_certs = []
for c in raw_certs:
days = (c.not_after - now).total_seconds() / 86400.0
if days < 30:
expiring_certs.append({"cn": c.cn or c.serial, "days_remaining": days})
latest = await latest_per_router(db)
certs = (await db.execute(select(TraefikCert))).scalars().all()
current, _gone = gone_routers([{"name": m.router_name, "latest": m} for m in latest], scrape)
served, _old = current_certs(certs, scrape)
domains = router_domains()
by_router = certs_by_router([r["name"] for r in current], domains or {}, served, now)
for r in current:
r["state"], r["why"] = router_state(r["latest"], thresholds)
r["cert"] = by_router.get(r["name"])
if r["cert"]:
grade = status_class(r["cert"]["days"], "tls_days", thresholds)
r["cert"]["grade"] = grade
if _GRADE_ORDER.get(grade, 2) < _GRADE_ORDER[r["state"]]:
left = cert_left(r["cert"]["days"]) # "12 days", "expires today", "expired …"
r["state"], r["why"] = grade, (
f"certificate expires in {left}" if left[0].isdigit() else f"certificate {left}")
problems, healthy = split_routers(current)
healthy.sort(key=lambda r: -(r["latest"].request_rate or 0))
# With no router domains to go on, certificates can't sit on their
# routers: the expiring ones are listed after the routers instead.
loose = [] if domains is not None else sorted(
({"cn": c.cn or c.serial, "days": (c.not_after - now).total_seconds() / 86400.0} for c in served),
key=lambda c: c["days"])
loose = [c for c in loose if status_class(c["days"], "tls_days", thresholds) in ("warn", "crit")]
return await render_template(
"traefik/widget.html",
router_data=router_data,
expiring_certs=expiring_certs,
total_routers=total_routers,
problems=problems, healthy=healthy[:ROUTERS_SHOWN],
healthy_more=max(0, len(healthy) - ROUTERS_SHOWN),
total=len(current), loose_certs=loose, domains_known=domains is not None,
)
# Healthy routers on the dashboard widget (the busiest); the page lists all.
ROUTERS_SHOWN = 6
@traefik_bp.get("/widget/access_log")
@require_role(UserRole.viewer)
async def widget_access_log():