feat(mcp): Traefik routers, certificates and traffic (#5838)
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m37s
CI / publish (push) Successful in 20s

list_routers grades every router as the Traefik page does (the last 15
minutes, 5xx on its cutoff, 4xx and p95 against the router's normal) and
gives a troubled one its status codes, what they mean and the swarm
service behind it. get_router adds its history, codes, normal and
anomalies; list_certificates the served certificates and the routers they
cover; get_traffic the access log's clients, countries and routers.
get_overview names a router answering 5xx and a certificate near expiry.

The access-log aggregation moves into routes.summarize_access, shared by
the traffic page and get_traffic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 13:26:34 -04:00
co-authored by Claude Opus 5.5
parent 0945d78290
commit 684a47b3eb
5 changed files with 427 additions and 45 deletions
+38 -45
View File
@@ -709,6 +709,42 @@ async def widget_request_chart():
)
def summarize_access(rows, top: int = 10) -> dict:
"""The access-log summaries in a range added up: totals, the internal and
external split, and the top client IPs, countries and routers."""
total = sum(r.total_requests for r in rows)
external = sum(r.external_requests for r in rows)
ip_counts: dict = defaultdict(lambda: {"count": 0, "internal": True, "country": None})
country_counts: dict[str, int] = defaultdict(int)
router_counts: dict[str, int] = defaultdict(int)
for row in rows:
for entry in json.loads(row.top_ips_json):
k = entry["ip"]
ip_counts[k]["count"] += entry["count"]
ip_counts[k]["internal"] = entry["internal"]
ip_counts[k]["country"] = entry.get("country")
for entry in json.loads(row.top_countries_json):
country_counts[entry["country"]] += entry["count"]
for entry in json.loads(row.top_routers_json):
router_counts[entry["router"]] += entry["count"]
def ranked(counts: dict, label: str) -> list[dict]:
return sorted(({label: k, "count": v} for k, v in counts.items()),
key=lambda x: x["count"], reverse=True)[:top]
return {
"total": total,
"internal": sum(r.internal_requests for r in rows),
"external": external,
"total_bytes": sum(r.total_bytes for r in rows),
"external_pct": (external / total * 100.0) if total else 0.0,
"top_ips": sorted(({"ip": k, **v} for k, v in ip_counts.items()),
key=lambda x: x["count"], reverse=True)[:top],
"top_countries": ranked(country_counts, "country"),
"top_routers": ranked(router_counts, "router"),
}
@traefik_bp.get("/traffic")
@require_role(UserRole.viewer)
async def traffic():
@@ -732,38 +768,7 @@ async def traffic():
access_log_enabled=bool(al.get("enabled", False)), access_log_path=al.get("path", ""),
)
total = sum(r.total_requests for r in rows)
internal = sum(r.internal_requests for r in rows)
external = sum(r.external_requests for r in rows)
total_bytes = sum(r.total_bytes for r in rows)
ip_counts: dict = defaultdict(lambda: {"count": 0, "internal": True, "country": None})
country_counts: dict[str, int] = defaultdict(int)
router_counts: dict[str, int] = defaultdict(int)
for row in rows:
for entry in json.loads(row.top_ips_json):
k = entry["ip"]
ip_counts[k]["count"] += entry["count"]
ip_counts[k]["internal"] = entry["internal"]
ip_counts[k]["country"] = entry.get("country")
for entry in json.loads(row.top_countries_json):
country_counts[entry["country"]] += entry["count"]
for entry in json.loads(row.top_routers_json):
router_counts[entry["router"]] += entry["count"]
top_ips = sorted(
[{"ip": k, **v} for k, v in ip_counts.items()],
key=lambda x: x["count"], reverse=True
)[:10]
top_countries = sorted(
[{"country": k, "count": v} for k, v in country_counts.items()],
key=lambda x: x["count"], reverse=True
)[:10]
top_routers = sorted(
[{"router": k, "count": v} for k, v in router_counts.items()],
key=lambda x: x["count"], reverse=True
)[:10]
summary = summarize_access(rows)
history = [
{
@@ -774,19 +779,7 @@ async def traffic():
for r in rows
]
summary = {
"total": total,
"internal": internal,
"external": external,
"total_bytes": total_bytes,
"external_pct": (external / total * 100.0) if total else 0.0,
"top_ips": top_ips,
"top_countries": top_countries,
"top_routers": top_routers,
"sparkline_external": _sparkline(
[h["external_pct"] for h in subsample(history, 80)]
),
}
summary["sparkline_external"] = _sparkline([h["external_pct"] for h in subsample(history, 80)])
return await render_template(
"traefik/traffic.html", summary=summary, history=history, range_key=range_key