feat(traefik): Routers widget shows current routers only, one header row, certs on the routers they serve (#5254)
#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:
@@ -48,10 +48,10 @@ async def test_the_page_leads_with_problems_and_folds_the_healthy(app):
|
||||
problems, healthy = split_routers([
|
||||
_router("api", _latest(e5=3.0)), _router("web", _latest()), _router("docs", _latest())])
|
||||
now = datetime.now(timezone.utc)
|
||||
certs = [{"serial": "1", "cn": "soon.example", "sans": [], "not_after": now + timedelta(days=2),
|
||||
"days_remaining": 2.0},
|
||||
{"serial": "2", "cn": "fine.example", "sans": [], "not_after": now + timedelta(days=60),
|
||||
"days_remaining": 60.0}]
|
||||
certs = [{"serial": "1", "cn": "soon.example", "sans": [], "routers": None,
|
||||
"not_after": now + timedelta(days=2), "days_remaining": 2.0},
|
||||
{"serial": "2", "cn": "fine.example", "sans": [], "routers": None,
|
||||
"not_after": now + timedelta(days=60), "days_remaining": 60.0}]
|
||||
async with app.test_request_context("/plugins/traefik/rows"):
|
||||
page = await render_plugin_template(app, "traefik", "traefik/rows.html", problems=problems,
|
||||
healthy=healthy, global_stat=None, certs=certs, range_key="24h",
|
||||
@@ -110,3 +110,123 @@ async def test_the_traffic_empty_state_knows_whether_the_log_is_on(app, enabled,
|
||||
range_key="24h", access_log_enabled=enabled,
|
||||
access_log_path="/var/log/traefik/access.log")
|
||||
assert says in page and not_says not in page
|
||||
|
||||
|
||||
# ── Routers widget: current routers, certs on the router they serve (#5254) ──
|
||||
|
||||
@pytest.mark.parametrize("rule, hosts", [
|
||||
("Host(`app.example.com`)", ["app.example.com"]),
|
||||
("Host(`a.example.com`, `B.example.com`) && PathPrefix(`/api`)", ["a.example.com", "b.example.com"]),
|
||||
("Host(`a.example.com`) || Host(`b.example.com`)", ["a.example.com", "b.example.com"]),
|
||||
("HostSNI(`db.example.com`)", ["db.example.com"]),
|
||||
("HostSNI(`*`)", []),
|
||||
("PathPrefix(`/`)", []),
|
||||
("", []),
|
||||
])
|
||||
def test_a_router_rule_names_its_hosts(rule, hosts):
|
||||
from plugins.traefik.scraper import rule_hosts
|
||||
assert rule_hosts(rule) == hosts
|
||||
|
||||
|
||||
def test_a_certificate_covers_its_names_and_one_wildcard_label():
|
||||
from plugins.traefik.scraper import cert_names, covers
|
||||
names = cert_names("Example.com", "*.example.com, www.other.org")
|
||||
assert names == {"example.com", "*.example.com", "www.other.org"}
|
||||
assert covers(names, "example.com") and covers(names, "app.example.com")
|
||||
assert covers(names, "www.other.org")
|
||||
assert not covers(names, "a.b.example.com") and not covers(names, "other.org")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("metrics_url, api_url, base", [
|
||||
("http://traefik:8080/metrics", "", "http://traefik:8080/api"),
|
||||
("http://traefik:8080/metrics?x=1", "", "http://traefik:8080/api"),
|
||||
("http://traefik:8080/metrics", "http://other:9000/api/", "http://other:9000/api"),
|
||||
])
|
||||
def test_the_api_sits_beside_the_metrics_unless_configured(metrics_url, api_url, base):
|
||||
from plugins.traefik.scraper import api_base
|
||||
assert api_base(metrics_url, api_url) == base
|
||||
|
||||
|
||||
def test_a_metrics_router_finds_its_api_name_with_or_without_the_entrypoint():
|
||||
from plugins.traefik.routes import router_hosts
|
||||
domains = {"app@docker": ["app.example.com"]}
|
||||
assert router_hosts("app@docker", domains) == ["app.example.com"]
|
||||
assert router_hosts("websecure-app@docker", domains) == ["app.example.com"]
|
||||
assert router_hosts("myapp@docker", domains) == []
|
||||
|
||||
|
||||
def _cert(serial, cn, days, seen_ago=30, sans=None):
|
||||
now = datetime.now(timezone.utc)
|
||||
return NS(serial=serial, cn=cn, sans=sans, not_after=now + timedelta(days=days),
|
||||
scraped_at=now - timedelta(seconds=seen_ago))
|
||||
|
||||
|
||||
def test_a_certificate_traefik_stopped_reporting_is_old_not_current():
|
||||
from plugins.traefik.routes import current_certs
|
||||
live, renewed = _cert("1", "a.example", 80), _cert("2", "a.example", 5, seen_ago=3600)
|
||||
served, old = current_certs([live, renewed], 60)
|
||||
assert served == [live] and old == [renewed]
|
||||
assert current_certs([], 60) == ([], [])
|
||||
|
||||
|
||||
def test_each_router_gets_the_soonest_certificate_for_its_hosts():
|
||||
from plugins.traefik.routes import certs_by_router
|
||||
now = datetime.now(timezone.utc)
|
||||
certs = [_cert("1", "*.example.com", 40), _cert("2", "app.example.com", 10), _cert("3", "x.org", 2)]
|
||||
got = certs_by_router(["app@docker", "docs@docker", "plain@docker"],
|
||||
{"app@docker": ["app.example.com"], "docs@docker": ["docs.example.com"]},
|
||||
certs, now)
|
||||
assert got["app@docker"]["cn"] == "app.example.com" and int(got["app@docker"]["days"]) == 9
|
||||
assert got["docs@docker"]["cn"] == "*.example.com"
|
||||
assert "plain@docker" not in got
|
||||
|
||||
|
||||
async def test_the_widget_gives_a_verdict_then_routers_under_one_header(app):
|
||||
slow = _router("api@docker", _latest(p95=450.0))
|
||||
expiring = _router("shop@docker", _latest(req=3.0))
|
||||
expiring.update(state="crit", why="certificate expires in 2 days",
|
||||
cert={"cn": "shop.example", "days": 2.0, "grade": "crit"})
|
||||
fine = _router("web@docker", _latest(req=9.0))
|
||||
fine["cert"] = {"cn": "web.example", "days": 60.0, "grade": "ok"}
|
||||
async with app.test_request_context("/plugins/traefik/widget"):
|
||||
page = await render_plugin_template(
|
||||
app, "traefik", "traefik/widget.html", problems=[expiring, slow], healthy=[fine],
|
||||
healthy_more=4, total=7, loose_certs=[], domains_known=True)
|
||||
page = " ".join(page.split())
|
||||
assert "2 of 7 routers need a look." in page
|
||||
assert page.count("<th>Router</th>") == 1 and "<th>TLS</th>" in page
|
||||
# Problems first, each with why; the cert sits on its router, not above.
|
||||
assert page.index(">shop") < page.index(">api") < page.index(">web")
|
||||
assert "certificate expires in 2 days" in page and "p95 latency is 450 ms" in page
|
||||
assert "60 days" in page and "Certificates expiring" not in page
|
||||
assert '</span>shop<span class="w-sub' in page and ">shop@docker" not in page # provider off the label
|
||||
assert "+4 more healthy" in page
|
||||
|
||||
|
||||
async def test_without_router_domains_expiring_certs_follow_the_routers(app):
|
||||
fine = _router("web@docker", _latest())
|
||||
async with app.test_request_context("/plugins/traefik/widget"):
|
||||
page = await render_plugin_template(
|
||||
app, "traefik", "traefik/widget.html", problems=[], healthy=[fine], healthy_more=0, total=1,
|
||||
loose_certs=[{"cn": "old.example", "days": 5.0}], domains_known=False)
|
||||
page = " ".join(page.split())
|
||||
assert "All 1 router healthy; 1 certificate expiring." in page
|
||||
assert "<th>TLS</th>" not in page
|
||||
assert page.index("<th>Router</th>") < page.index("Certificates expiring") < page.index("old.example")
|
||||
|
||||
|
||||
async def test_the_page_sets_certificates_traefik_no_longer_serves_apart(app):
|
||||
now = datetime.now(timezone.utc)
|
||||
certs = [{"serial": "1", "cn": "app.example", "sans": ["www.app.example"], "routers": ["app"],
|
||||
"not_after": now + timedelta(days=40), "days_remaining": 40.0},
|
||||
{"serial": "2", "cn": "spare.example", "sans": [], "routers": [],
|
||||
"not_after": now + timedelta(days=40), "days_remaining": 40.0}]
|
||||
old = [{"serial": "3", "cn": "gone.example", "sans": [], "routers": [],
|
||||
"not_after": now + timedelta(days=5), "days_remaining": 5.0}]
|
||||
async with app.test_request_context("/plugins/traefik/rows"):
|
||||
page = await render_plugin_template(app, "traefik", "traefik/rows.html", problems=[], healthy=[],
|
||||
gone=[], global_stat=None, certs=certs, old_certs=old,
|
||||
range_key="24h", error_4xx=1.0, error_5xx=0.1)
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user