feat(traefik): each router problem says what it means, whether it's real, and what to do (#5712)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m22s
CI / unit (push) Failing after 1m33s
CI / publish (push) Skipped

#5712 "Each router problem says what it means, whether it's real, and what
to do". A problem card now names its kind and reads its codes:
- Server errors: 500 means the app is failing; 502/503/504 means Traefik
  couldn't reach it or it timed out.
- Client errors: 404 scanners, a 401/403 login wall, 429 rate limiting, or
  499 clients giving up on a slow app.
- Slow: p95 against p50.
Each card says whether the problem is likely real, and ends with one next
step. A fault links to the swarm service behind the router, matched by
name, and traffic links to Settings → Thresholds. The wording lives in
plugins/traefik/meaning.py.

The plugin manager now hands out a loaded plugin's submodule
(loaded_plugin_submodule), so Traefik and host_agent reach Docker's
models the same way (#5775).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 21:04:39 -04:00
co-authored by Claude Opus 5.5
parent 31a6cf09a9
commit 685850e681
7 changed files with 307 additions and 13 deletions
+27
View File
@@ -16,6 +16,7 @@ from steward.core.formatting import cert_left, status_class
from steward.core.norms import norms_at
from steward.core.settings import floor_of
from steward.models.metrics import PluginMetricLatest
from . import meaning
from .models import TraefikAccessSummary, TraefikCert, TraefikGlobalStat, TraefikMetric, TraefikStatusCode
from .scheduler import router_domains
from .scraper import cert_names, covers
@@ -183,6 +184,30 @@ def grade_routers(routers: list[dict], window: dict, thresholds: dict, norms: di
reading, own = window.get(r["name"]), norms.get(r["name"])
r["state"], r["why"] = (router_state(reading, thresholds, STATE_WINDOW_WORDS, own) if reading
else router_state(r["latest"], thresholds, norms=own))
r["kind"] = meaning.problem_kind(r["state"], r["why"])
r["normed"] = bool(own) and {"slow": "latency_p95_ms", "4xx": "error_rate_4xx_pct"}.get(
r["kind"], "") in own
async def swarm_service_names(db) -> list[str]:
"""Every swarm service the Docker plugin has stored; [] without it."""
from steward.core.plugin_manager import loaded_plugin_submodule
models = loaded_plugin_submodule("docker", "models")
if models is None:
return []
return list((await db.execute(select(models.DockerSwarmService.service_name).distinct())).scalars())
def explain_problems(problems: list[dict], services: list[str]) -> None:
"""Give each problem card what it means and its one next step (#5712):
`meaning` (meaning.explain's) and `service`, the swarm service behind
the router when Steward can tell which."""
for r in problems:
r["meaning"] = meaning.explain(r["kind"], r.get("codes", []),
getattr(r["latest"], "latency_p50_ms", None),
getattr(r["latest"], "latency_p95_ms", None),
r.get("normed", False))
r["service"] = meaning.swarm_service(r["name"], services)
# A router missing from this many scrapes in a row is no longer reported:
@@ -362,6 +387,7 @@ async def rows():
# old, a router with none listed may simply predate the recording.
first_code = await db.scalar(select(func.min(TraefikStatusCode.scraped_at)))
codes_known = first_code is not None and first_code <= now - STATE_WINDOW
services = await swarm_service_names(db) if any(r["state"] != "ok" for r in current) else []
phases.mark("codes")
# Latest global stat (always most recent, not range-filtered)
@@ -400,6 +426,7 @@ async def rows():
certs = [cert_row(c) for c in served]
old_certs = [cert_row(c) for c in old]
problems, healthy = split_routers(current)
explain_problems(problems, services)
phases.mark("cert_rows")
error_5xx, error_4xx = error_cutoffs(thresholds)
page = await render_template(