diff --git a/plugins/traefik/routes.py b/plugins/traefik/routes.py index 1abd59f..51c662e 100644 --- a/plugins/traefik/routes.py +++ b/plugins/traefik/routes.py @@ -496,10 +496,21 @@ async def widget(): r["cert"]["grade"] = grade if _GRADE_ORDER.get(grade, 2) < _GRADE_ORDER[r["state"]]: left = cert_left(r["cert"]["days"]) # "12 days", "expires today", "expired …" + r["by_cert"] = True r["state"], r["why"] = grade, ( f"certificate expires in {left}" if left[0].isdigit() else f"certificate {left}") problems, healthy = split_routers(current) healthy.sort(key=lambda r: -(r["latest"].request_rate or 0)) + # Since when, from the day's history, for problems the traffic shows (a + # certificate's says its own date). Only the routers in trouble are read. + traffic = [r for r in problems if not r.get("by_cert")] + if traffic: + since = now - WIDGET_SINCE + async with current_app.db_sessionmaker() as db: + history = await router_history(db, [r["name"] for r in traffic], since, + bucket_seconds(since)) + for r in traffic: + r["since"], r["since_before"] = problem_since(history[r["name"]], thresholds) # With no router domains to go on, certificates can't sit on their # routers: the expiring ones are listed after the routers instead. loose = [] if domains is not None else sorted( @@ -516,6 +527,8 @@ async def widget(): # Healthy routers on the dashboard widget (the busiest); the page lists all. ROUTERS_SHOWN = 6 +# How far back the widget looks for when a router's problem began. +WIDGET_SINCE = timedelta(hours=24) @traefik_bp.get("/widget/access_log") diff --git a/plugins/traefik/templates/traefik/widget.html b/plugins/traefik/templates/traefik/widget.html index 776854c..a312d07 100644 --- a/plugins/traefik/templates/traefik/widget.html +++ b/plugins/traefik/templates/traefik/widget.html @@ -24,7 +24,8 @@ {{- status_dot(r.state) -}} {{ r.name.split('@')[0] }} - {%- if r.why and r.state != "ok" %}{{ r.why }}{% endif %} + {%- if r.why and r.state != "ok" %}{{ r.why }} + {%- if r.since_before %}, all of the last 24 h{% elif r.since %}, started {{ r.since|ago }}{% endif %}{% endif %} {% if m.request_rate is none %}—{% else %}{{ "%.2f"|format(m.request_rate) }}{% endif %} {% if m.response_bytes_rate is none %}—{% else %}{{ m.response_bytes_rate|rate }}{% endif %} diff --git a/steward/hosts/service.py b/steward/hosts/service.py index 3565e90..f2663c5 100644 --- a/steward/hosts/service.py +++ b/steward/hosts/service.py @@ -373,7 +373,7 @@ GLANCE_ORDER = {"down": 0, "crit": 1, "stale": 2, "warn": 3, "up": 4, "unknown": def host_glance(hosts, status: dict, agent: dict, thresholds: dict) -> list[dict]: """The Hosts — Overview rows, worst first (#5251). - Each is {host, state, worst, agent}. State is "down" when a linked monitor + Each is {host, state, worst, agent, down_since}. State is "down" when a linked monitor is down, "stale" when the agent has stopped reporting, else the grade of the reading furthest past its cutoff ("crit" / "warn": CPU, memory, disk / or load per core, against Settings → Thresholds; CPU, memory and load @@ -406,5 +406,6 @@ def host_glance(hosts, status: dict, agent: dict, thresholds: dict) -> list[dict state = "up" else: state = "unknown" - rows.append({"host": h, "state": state, "worst": worst, "agent": a or {}, "grades": grades}) + rows.append({"host": h, "state": state, "worst": worst, "agent": a or {}, "grades": grades, + "down_since": st.get("down_since") if state == "down" else None}) return sorted(rows, key=lambda r: (GLANCE_ORDER[r["state"]], r["host"].name.lower())) diff --git a/steward/templates/hosts/overview_widget.html b/steward/templates/hosts/overview_widget.html index bfb4efb..e7a3930 100644 --- a/steward/templates/hosts/overview_widget.html +++ b/steward/templates/hosts/overview_widget.html @@ -27,7 +27,7 @@ {{- status_dot(r.state) -}} {% if session.user_id %}{{ r.host.name }}{% else %}{{ r.host.name }}{% endif %} - {%- if r.state == 'down' %}a monitor is down + {%- if r.state == 'down' %}a monitor is down{% if r.down_since %} for {{ r.down_since|ago|replace(' ago', '') }}{% endif %} {%- elif r.state == 'stale' %}agent last reported {{ a.last_at|ago }}; figures are from then {%- elif not a %}no agent{% endif %} diff --git a/tests/core/test_dashboard_defaults.py b/tests/core/test_dashboard_defaults.py index 6e4b5eb..cfa4305 100644 --- a/tests/core/test_dashboard_defaults.py +++ b/tests/core/test_dashboard_defaults.py @@ -120,7 +120,8 @@ def test_hosts_sort_worst_first(): th = to_thresholds_cfg({}) hosts = [_host("a", "alpha"), _host("b", "busy"), _host("d", "dead"), _host("q", "quiet"), _host("w", "warm"), _host("n", "nothing")] - status = {"d": {"state": "down"}, "a": {"state": "up"}} + went = datetime(2026, 10, 15, 9, 0, tzinfo=timezone.utc) + status = {"d": {"state": "down", "down_since": went}, "a": {"state": "up", "down_since": None}} agent = {"alpha": {"cpu_pct": 10.0, "fresh": True}, "busy": {"cpu_pct": 99.0, "fresh": True}, "quiet": {"cpu_pct": 99.0, "fresh": False}, "warm": {"cpu_pct": 10.0, "load_per_core": 85, "fresh": True}} @@ -130,6 +131,17 @@ def test_hosts_sort_worst_first(): ("alpha", "up"), ("nothing", "unknown")] assert rows[1]["worst"][0] == "CPU" and rows[3]["worst"][0] == "Load" assert rows[2]["worst"] is None # a quiet agent's old figures aren't graded + assert rows[0]["down_since"] == went and rows[4]["down_since"] is None # #5810 + + +async def test_a_down_host_says_for_how_long(app): + """#5810: the problem row carries its since, as the Hosts list does.""" + rows = [{"host": _host("d", "dead"), "state": "down", "worst": None, "agent": {}, + "down_since": datetime.now(timezone.utc) - timedelta(hours=3)}] + async with app.test_request_context("/hosts/overview/widget"): + page = await render_template("hosts/overview_widget.html", rows=rows) + page = " ".join(page.split()) + assert "a monitor is down for" in page and ">3h<" in page and " ago" not in page async def test_the_hosts_widget_gives_a_verdict_and_load_against_cores(app): diff --git a/tests/plugins/traefik/test_traefik_page.py b/tests/plugins/traefik/test_traefik_page.py index 0489688..44fc139 100644 --- a/tests/plugins/traefik/test_traefik_page.py +++ b/tests/plugins/traefik/test_traefik_page.py @@ -209,6 +209,21 @@ async def test_the_widget_gives_a_verdict_then_routers_under_one_header(app): assert "+4 more healthy" in page +async def test_a_router_in_trouble_says_since_when_on_the_widget(app): + """#5810: the widget's problem rows carry the since the page shows.""" + slow = _router("api@docker", _latest(p95=450.0)) + slow.update(since=datetime.now(timezone.utc) - timedelta(hours=2), since_before=False) + failing = _router("pay@docker", _latest(e5=4.0)) + failing.update(since=datetime.now(timezone.utc) - timedelta(days=1), since_before=True) + async with app.test_request_context("/plugins/traefik/widget"): + page = await render_plugin_template( + app, "traefik", "traefik/widget.html", problems=[failing, slow], healthy=[], + healthy_more=0, total=2, loose_certs=[], domains_known=True) + page = " ".join(page.split()) + assert "all of the last 24 h" in page + assert page.count(", started ") == 1 and page.index("all of the last 24 h") < page.index(", started ") + + 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"):