diff --git a/plugins/traefik/mcp_tools.py b/plugins/traefik/mcp_tools.py index 1ed5ec2..ba9d6cf 100644 --- a/plugins/traefik/mcp_tools.py +++ b/plugins/traefik/mcp_tools.py @@ -14,6 +14,7 @@ from sqlalchemy import select from steward.core import anomalies as anom from steward.core.norms import norms_at +from steward.core.time_range import bucket_seconds from steward.mcp._context import mcp_app from steward.mcp.tools._shape import ( age_s, bounded, clamp_limit, db_session, iso, lookback, now_utc, rnd, summarize, @@ -279,7 +280,8 @@ def cert_findings(certs, now, thresholds: dict) -> list[dict]: def router_findings(routers: list[dict]) -> list[dict]: """A router answering 5xx past its cutoff: failing. 4xx and latency are left to the metric anomalies, which judge them against the router's - normal (System 182).""" + normal (System 182). `since` is when the failing began, as the page's + verdict says it (r["since"], None when too recent for a bucket to show).""" out = [] for r in routers: if r["state"] != "crit": @@ -287,14 +289,29 @@ def router_findings(routers: list[dict]) -> list[dict]: said = (r.get("meaning") or {}).get("meaning") out.append(finding("critical", "traefik_router_failing", r["name"], f"Traefik router {r['name']}: {r['why']}" + (f". {said}" if said else ""), - ids={"router": r["name"]}, next=f'get_router(router="{r["name"]}")')) + since=r.get("since"), ids={"router": r["name"]}, + next=f'get_router(router="{r["name"]}")')) return out +async def failing_since(db, routers: list[dict], now, thresholds: dict) -> None: + """Sets r["since"] on each failing router from its last day of history, + as the dashboard widget reads it (#5860).""" + failing = [r["name"] for r in routers if r["state"] == "crit"] + if not failing: + return + since = now - page.WIDGET_SINCE + history = await page.router_history(db, failing, since, bucket_seconds(since)) + for r in routers: + if r["name"] in history: + r["since"], _before = page.problem_since(history[r["name"]], thresholds) + + async def mcp_overview(db) -> list[dict]: thresholds, scrape = _cfg() now = now_utc() current, _gone = await _graded(db, now, thresholds, scrape) + await failing_since(db, current, now, thresholds) served, _old = page.current_certs((await db.execute(select(TraefikCert))).scalars().all(), scrape) return router_findings(current) + cert_findings(served, now, thresholds) diff --git a/tests/plugins/traefik/test_mcp_tools.py b/tests/plugins/traefik/test_mcp_tools.py index 6a13f70..7e9a19e 100644 --- a/tests/plugins/traefik/test_mcp_tools.py +++ b/tests/plugins/traefik/test_mcp_tools.py @@ -37,6 +37,14 @@ def test_only_a_failing_router_is_an_overview_finding(): [f] = t.router_findings(routers) assert f["severity"] == "critical" and f["subject"] == "app@swarm" assert "Mostly 502" in f["summary"] and f["next"] == 'get_router(router="app@swarm")' + assert f["since"] is None + + +def test_a_failing_router_says_since_when(): + began = NOW - timedelta(hours=2) + [f] = t.router_findings([{"name": "app@swarm", "state": "crit", "why": "12.0% of responses are 5xx", + "since": began}]) + assert f["since"] == "2026-10-15T10:00:00Z" and f["age_s"] > 0 def test_a_router_is_found_with_or_without_its_provider():