feat(widgets): Routers and Hosts problem rows say since when (#5810)

Routers: a router in trouble reads 'started 2h ago' or 'all of the last
24 h', from problem_since over the day's history, read for the troubled
routers only (a certificate's problem carries its own date). Hosts: a
down host reads 'a monitor is down for 3h'; host_glance keeps the
down_since host_status already had.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 10:08:38 -04:00
co-authored by Claude Opus 5.5
parent 423493e2bf
commit c113cadbdd
6 changed files with 47 additions and 5 deletions
+13
View File
@@ -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")
@@ -24,7 +24,8 @@
<td class="w-name" title="{{ r.name }}">
{{- status_dot(r.state) -}}
{{ r.name.split('@')[0] }}
{%- if r.why and r.state != "ok" %}<span class="w-sub tone-{{ r.state }}">{{ r.why }}</span>{% endif %}
{%- if r.why and r.state != "ok" %}<span class="w-sub tone-{{ r.state }}">{{ r.why }}
{%- if r.since_before %}, all of the last 24 h{% elif r.since %}, started {{ r.since|ago }}{% endif %}</span>{% endif %}
</td>
<td>{% if m.request_rate is none %}—{% else %}{{ "%.2f"|format(m.request_rate) }}{% endif %}</td>
<td class="w-wide-only">{% if m.response_bytes_rate is none %}—{% else %}{{ m.response_bytes_rate|rate }}{% endif %}</td>
+3 -2
View File
@@ -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()))
+1 -1
View File
@@ -27,7 +27,7 @@
<td class="w-name" title="{{ r.host.name }}">
{{- status_dot(r.state) -}}
{% if session.user_id %}<a href="/hosts/{{ r.host.id }}">{{ r.host.name }}</a>{% else %}{{ r.host.name }}{% endif %}
{%- if r.state == 'down' %}<span class="w-sub tone-crit">a monitor is down</span>
{%- if r.state == 'down' %}<span class="w-sub tone-crit">a monitor is down{% if r.down_since %} for {{ r.down_since|ago|replace(' ago', '') }}{% endif %}</span>
{%- elif r.state == 'stale' %}<span class="w-sub tone-warn">agent last reported {{ a.last_at|ago }}; figures are from then</span>
{%- elif not a %}<span class="w-sub">no agent</span>{% endif %}
</td>
+13 -1
View File
@@ -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):
@@ -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"):