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
+66
View File
@@ -274,3 +274,69 @@ async def fetch_metrics(metrics_url: str) -> ParsedMetrics:
response = await client.get(metrics_url)
response.raise_for_status()
return parse_prometheus(response.text)
# ──────────────────────────────────────────────────────────────────────────────
# Router domains (Traefik API)
# ──────────────────────────────────────────────────────────────────────────────
#
# The metrics name each router but not the hosts it answers for, so which
# certificate serves which router is only knowable from Traefik's API. Read
# once per scrape; when the API isn't reachable, routers simply have no
# domains and certificates are shown on their own (#5254).
_HOST_MATCHER = re.compile(r"Host(?:SNI)?\(([^)]*)\)")
_QUOTED = re.compile(r"[`\"']([^`\"']+)[`\"']")
def rule_hosts(rule: str) -> list[str]:
"""The host names a router rule matches: Host(`a`, `b`) and HostSNI(`c`)
give ["a", "b", "c"]; HostRegexp and path-only rules give nothing."""
hosts: list[str] = []
for args in _HOST_MATCHER.findall(rule or ""):
hosts.extend(h.strip().lower() for h in _QUOTED.findall(args) if h.strip() != "*")
return hosts
def api_base(metrics_url: str, api_url: str = "") -> str:
"""The Traefik API's base: the configured api_url, else /api on the
metrics URL's own host (the default "traefik" entrypoint serves both)."""
if api_url:
return api_url.rstrip("/")
parts = httpx.URL(metrics_url)
return str(parts.copy_with(path="/api", query=None, fragment=None)).rstrip("/")
async def fetch_router_domains(base: str) -> dict[str, list[str]]:
"""{router name: host names} for every enabled HTTP and TCP router."""
out: dict[str, list[str]] = {}
async with httpx.AsyncClient(timeout=10.0) as client:
for kind in ("http", "tcp"):
response = await client.get(f"{base}/{kind}/routers", params={"per_page": 1000})
if kind == "tcp" and response.status_code == 404:
continue
response.raise_for_status()
for r in response.json():
if r.get("status", "enabled") != "enabled" or not r.get("name"):
continue
hosts = rule_hosts(r.get("rule", ""))
if hosts:
out.setdefault(r["name"], []).extend(hosts)
return out
def cert_names(cn: str | None, sans: str | None) -> set[str]:
"""Every name a certificate covers: its CN and SANs, lower-cased."""
names = {n.strip().lower() for n in (sans or "").split(",") if n.strip()}
if cn:
names.add(cn.strip().lower())
return names
def covers(names: set[str], host: str) -> bool:
"""Whether a certificate with these names is valid for host, a wildcard
"*.example.com" covering one label: "a.example.com", not "a.b.example.com"."""
if host in names:
return True
head, _, rest = host.partition(".")
return bool(rest) and f"*.{rest}" in names