feat(dashboard): an Alerts widget beside the Overview; the Overview's overflow links to Alerts and Monitors (#5510)
The Alerts widget (/alerts/widget) is shaped like every widget: a verdict
("2 firing, 1 silent." / "Nothing firing: all 12 rules are quiet."), then
what is firing or silent with its reading now, what the rule fires on and
since when, then what someone acknowledged and who; a line when alert
email failed in the last 7 days or is retrying (outbox.alert_trouble),
linking admins to Settings -> System; the quiet rules counted, a covering
rule once. It sits beside the Overview in the recommended layout (6x4
each).
The Overview's "+N more" line pointed at the removed /status/; alerts now
link to /alerts/ and monitors to /monitors/.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -232,6 +232,17 @@ host. Each channel's result shows under the button. Nothing fires, the rule's
|
||||
history doesn't change, and the test is recorded in the audit log as
|
||||
`alert_rule.tested`.
|
||||
|
||||
### On the dashboard
|
||||
|
||||
The **Alerts** widget (`/alerts/widget`) sits beside the Overview in the
|
||||
recommended layout. It opens with a verdict ("2 firing, 1 silent." or
|
||||
"Nothing firing: all 12 rules are quiet."). Below that it lists what is
|
||||
firing or silent: each rule, what it watches, its reading now against what
|
||||
it fires on, and since when. Acknowledged alerts follow, with who
|
||||
acknowledged them and when. A line appears when alert email has failed in
|
||||
the last 7 days or is still retrying, linking admins to Settings → System.
|
||||
The quiet rules are counted at the end.
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
|
||||
@@ -178,6 +178,19 @@ async def active_fragment():
|
||||
rules_off=sum(1 for r in rules if not r.enabled))
|
||||
|
||||
|
||||
@alerts_bp.get("/widget")
|
||||
@require_role(UserRole.viewer)
|
||||
async def widget():
|
||||
"""The dashboard Alerts widget (#5510): a verdict, what is firing or
|
||||
silent, then what someone has taken, a line when alert email is failing,
|
||||
and the quiet rules counted."""
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
active = await alert_svc.active_rows(db, datetime.now(timezone.utc))
|
||||
view = alert_svc.widget_view(active, await alert_svc.list_rules(db))
|
||||
email = await outbox.alert_trouble(db)
|
||||
return await render_template("alerts/widget.html", **view, email=email)
|
||||
|
||||
|
||||
def _delivery(label: str, sent: bool | None, error: str | None) -> dict | None:
|
||||
"""A channel's outcome as recorded on the event; None when not tried."""
|
||||
if sent is None:
|
||||
|
||||
@@ -231,11 +231,13 @@ async def active_rows(db, now: datetime) -> list[dict]:
|
||||
since = started.get(rule.id, state.last_transition_at)
|
||||
else:
|
||||
since = state.last_transition_at
|
||||
value = await latest_value(db, rule)
|
||||
rows.append({
|
||||
"state": state, "rule": rule, "label": label, "since": since,
|
||||
"breach_since": started.get(rule.id) if label != "no_data" else None,
|
||||
"acked_by": names.get(state.acknowledged_by, state.acknowledged_by),
|
||||
"says": alert_sentence(rule, label, await latest_value(db, rule), since, now),
|
||||
"value": value,
|
||||
"says": alert_sentence(rule, label, value, since, now),
|
||||
"url": await resource_url(db, rule),
|
||||
})
|
||||
epoch = datetime.max.replace(tzinfo=timezone.utc)
|
||||
@@ -243,6 +245,19 @@ async def active_rows(db, now: datetime) -> list[dict]:
|
||||
return rows
|
||||
|
||||
|
||||
def widget_view(active: list[dict], rules: list[AlertRule]) -> dict:
|
||||
"""The dashboard Alerts widget (#5510) from active_rows and every rule:
|
||||
the alerts in that order, each with what its rule fires on, and how many
|
||||
enabled rules, as people made them, are quiet (a covering rule counts
|
||||
once, and is quiet only while every host under it is).
|
||||
{"rows", "quiet", "total"}."""
|
||||
busy = {r["rule"].id for r in active}
|
||||
busy |= {r["rule"].parent_id for r in active if r["rule"].parent_id}
|
||||
made = [r for r in rules if r.enabled and not r.parent_id]
|
||||
return {"rows": [{**r, "condition": rule_condition(r["rule"])} for r in active],
|
||||
"quiet": sum(1 for r in made if r.id not in busy), "total": len(made)}
|
||||
|
||||
|
||||
def watches_host(rule: AlertRule, host_name: str, monitor_keys: set) -> bool:
|
||||
"""Whether a rule watches something on this host: its agent series
|
||||
("<host>" or "<host>:<part>"), one of its containers ("<host>/<name>"),
|
||||
|
||||
+25
-1
@@ -35,7 +35,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from email.message import EmailMessage
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
from sqlalchemy import insert, or_, select, text, update
|
||||
from sqlalchemy import and_, insert, or_, select, text, update
|
||||
|
||||
from steward.models.outbox import EmailOutbox, email_outbox_events
|
||||
|
||||
@@ -434,3 +434,27 @@ async def outgoing(db, now: datetime | None = None, limit: int = 20) -> dict:
|
||||
shown = sum(counts.get(s, 0) for s in open_states)
|
||||
return {"rows": rows, "more": max(shown - len(rows), 0), "sent": counts.get("sent", 0),
|
||||
"problems": problems}
|
||||
|
||||
|
||||
async def alert_trouble(db, now: datetime | None = None) -> dict:
|
||||
"""The dashboard Alerts widget's delivery line (#5510): alert emails of
|
||||
the last SHOWN_DAYS that failed, and those still retrying (tried, not yet
|
||||
accepted), and the line that says so ("" when neither).
|
||||
{"failed": n, "retrying": n, "said": str}."""
|
||||
from sqlalchemy import func
|
||||
now = now or datetime.now(timezone.utc)
|
||||
rows = dict((await db.execute(
|
||||
select(EmailOutbox.state, func.count())
|
||||
.where(EmailOutbox.kind == "alert", EmailOutbox.created_at >= now - timedelta(days=SHOWN_DAYS),
|
||||
or_(EmailOutbox.state == "failed",
|
||||
and_(EmailOutbox.state.in_(("pending", "sending")), EmailOutbox.attempts > 0)))
|
||||
.group_by(EmailOutbox.state))).all())
|
||||
failed = rows.get("failed", 0)
|
||||
retrying = rows.get("pending", 0) + rows.get("sending", 0)
|
||||
said = []
|
||||
if failed:
|
||||
said.append(f"{failed} alert email{'s' if failed != 1 else ''} failed in the last {SHOWN_DAYS} days")
|
||||
if retrying:
|
||||
said.append(f"{retrying} still retrying" if failed
|
||||
else f"{retrying} alert email{'s' if retrying != 1 else ''} still retrying")
|
||||
return {"failed": failed, "retrying": retrying, "said": "; ".join(said)}
|
||||
|
||||
+15
-3
@@ -70,6 +70,16 @@ WIDGET_REGISTRY: dict[str, dict] = {
|
||||
"poll": True,
|
||||
"params": [],
|
||||
},
|
||||
"alerts": {
|
||||
"key": "alerts",
|
||||
"label": "Alerts",
|
||||
"description": "What is firing or silent, its reading and since when, then what someone has acknowledged; says when alert email is failing",
|
||||
"hx_url": "/alerts/widget",
|
||||
"detail_url": "/alerts/",
|
||||
"plugin": None,
|
||||
"poll": True,
|
||||
"params": [],
|
||||
},
|
||||
"hosts_overview": {
|
||||
"key": "hosts_overview",
|
||||
"label": "Hosts — Overview",
|
||||
@@ -305,13 +315,15 @@ RETIRED = {
|
||||
"uptime_summary": "monitors", # Monitors shows 24h/7d/30d uptime per monitor (#5250)
|
||||
}
|
||||
|
||||
# The recommended dashboard (#5213): what needs attention first, then the
|
||||
# The recommended dashboard (#5213): what needs attention first (the
|
||||
# Overview, and beside it the Alerts, #5510), then the
|
||||
# fleet, then each integration. (key, width, height, config) on the 12-column
|
||||
# grid, one cell 70px tall; a widget whose plugin is off is skipped and the
|
||||
# rest close up. Seeded into the first dashboard and offered by
|
||||
# "Reset to the recommended layout" in edit mode.
|
||||
RECOMMENDED: list[tuple[str, int, int, dict]] = [
|
||||
("status_overview", 12, 3, {}),
|
||||
("status_overview", 6, 4, {}),
|
||||
("alerts", 6, 4, {}),
|
||||
("hosts_overview", 6, 7, {}),
|
||||
("monitors", 6, 7, {"show_down_only": "no", "limit": 10}),
|
||||
("docker_containers", 6, 6, {}),
|
||||
@@ -342,7 +354,7 @@ def recommended_layout(plugins_cfg: dict) -> list[dict]:
|
||||
# Group each widget for the picker (Core monitors / Monitoring capabilities /
|
||||
# Integrations), mirroring the Settings → Plugins taxonomy. Capability plugins
|
||||
# are host/monitoring facets; traefik/unifi are discrete vendor integrations.
|
||||
_CORE_WIDGETS = {"monitors", "status_overview", "hosts_overview"}
|
||||
_CORE_WIDGETS = {"monitors", "status_overview", "alerts", "hosts_overview"}
|
||||
_INTEGRATION_PLUGINS = {"traefik", "unifi"}
|
||||
GROUP_LABELS = {
|
||||
"core": "Core monitors",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
{# alerts/widget.html — the dashboard Alerts widget (#5510), in the widget
|
||||
shape (snippet #5259): a verdict; what is firing or silent, its reading
|
||||
now against what the rule fires on, and since when; then what someone has
|
||||
acknowledged, by whom and when; a line when alert email is failing or
|
||||
retrying; the quiet rules counted. Rows come from alert_svc.active_rows,
|
||||
so they read in the Alerts page's order. #}
|
||||
{% from "_macros.html" import status_dot %}
|
||||
{% set firing = rows|selectattr("label", "equalto", "firing")|list|length %}
|
||||
{% if not total and not rows %}
|
||||
<div class="w-verdict" style="color:var(--text-muted);">
|
||||
No alert rules yet.{% if session.user_role in ('operator', 'admin') %} <a href="/alerts/rules/new">Add a rule →</a>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="w-verdict {{ 'tone-crit' if firing else ('tone-warn' if rows else 'tone-ok') }}">
|
||||
{%- if firing %}{{ firing }} firing{% else %}Nothing firing{% endif %}
|
||||
{%- for key, word in (("no_data", "silent"), ("acknowledged", "acknowledged"), ("pending", "pending")) %}
|
||||
{%- set n = rows|selectattr("label", "equalto", key)|list|length %}
|
||||
{%- if n %}, {{ n }} {{ word }}{% endif %}
|
||||
{%- endfor %}
|
||||
{%- if not rows %}: {{ "the rule is" if total == 1 else "all " ~ total ~ " rules are" }} quiet{% endif %}.
|
||||
</div>
|
||||
{% if rows %}
|
||||
<table class="w-table">
|
||||
<colgroup><col><col style="width:5rem"><col style="width:5.5rem"></colgroup>
|
||||
<thead><tr><th>Alert</th><th>Now</th><th>Since</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
{% set rule = r.rule %}
|
||||
<tr>
|
||||
<td class="w-name" title="{{ rule.name }} · {{ rule.resource_name }}">
|
||||
{{- status_dot(r.label) -}}
|
||||
{% if session.user_id %}<a href="/alerts/history?rule={{ rule.id }}">{{ rule.name }}</a>{% else %}{{ rule.name }}{% endif %}
|
||||
{%- if r.label == "acknowledged" %}
|
||||
<span class="w-sub">{{ rule.resource_name }} · acknowledged{% if r.acked_by %} by {{ r.acked_by }}{% endif %}{% if r.state.acknowledged_at %} {{ r.state.acknowledged_at|ago }}{% endif %}</span>
|
||||
{%- elif r.label == "no_data" %}
|
||||
<span class="w-sub tone-warn">{{ rule.resource_name }} · no reading</span>
|
||||
{%- else %}
|
||||
<span class="w-sub">{{ rule.resource_name }} · fires on {{ r.condition }}</span>
|
||||
{%- endif %}
|
||||
</td>
|
||||
<td class="{{ 'tone-crit' if r.label == 'firing' else ('tone-warn' if r.label == 'pending') }}">
|
||||
{%- if r.label == "no_data" %}—{% else %}{{ metric_value(rule.metric_name, r.value) }}{% endif -%}
|
||||
</td>
|
||||
<td>{% if r.since %}{{ r.since|ago }}{% else %}—{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% if email.said %}
|
||||
<div class="{{ 'tone-crit' if email.failed else 'tone-warn' }}" style="font-size:0.75rem;margin-top:0.4rem;">
|
||||
{{- email.said }}{% if session.user_role == "admin" %} — <a href="/settings/system/">Outgoing email</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if rows and quiet %}
|
||||
<div class="w-more">+{{ quiet }} rule{{ "s" if quiet != 1 }} quiet{% if session.user_id %} — <a href="/alerts/">Alerts</a>{% endif %}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -54,11 +54,13 @@
|
||||
{% if e.since %}<span style="color:var(--text-dim);font-size:0.75rem;white-space:nowrap;">{% if e.since_at_least %}over {% endif %}{{ e.since|ago }}</span>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if alerts|length > 6 or problems|length > 8 %}
|
||||
{# What didn't fit, each linking to its own list (#5510). #}
|
||||
{% set more_alerts, more_monitors = alerts|length - 6, problems|length - 8 %}
|
||||
{% if more_alerts > 0 or more_monitors > 0 %}
|
||||
<div style="color:var(--text-dim);font-size:0.75rem;padding-top:0.2rem;">
|
||||
{% if alerts|length > 6 %}+{{ alerts|length - 6 }} more alerts{% endif %}
|
||||
{% if problems|length > 8 %}{% if alerts|length > 6 %} · {% endif %}+{{ problems|length - 8 }} more monitors{% endif %}
|
||||
{% if session.user_id %} — <a href="/status/">Status</a>{% endif %}
|
||||
{%- if more_alerts > 0 %}{% if session.user_id %}<a href="/alerts/">+{{ more_alerts }} more alerts</a>{% else %}+{{ more_alerts }} more alerts{% endif %}{% endif %}
|
||||
{%- if more_alerts > 0 and more_monitors > 0 %} · {% endif %}
|
||||
{%- if more_monitors > 0 %}{% if session.user_id %}<a href="/monitors/">+{{ more_monitors }} more monitors</a>{% else %}+{{ more_monitors }} more monitors{% endif %}{% endif -%}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""The dashboard Alerts widget (#5510): a verdict, what is firing or silent
|
||||
with its reading and since when, then what someone has acknowledged, a line
|
||||
when alert email is failing, and the quiet rules counted."""
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from quart import render_template, session
|
||||
|
||||
from steward.alerts.service import widget_view
|
||||
from steward.core.widgets import WIDGET_REGISTRY
|
||||
from steward.models.alerts import AlertOperator
|
||||
from tests.test_style_tokens import hex_colours
|
||||
|
||||
NOW = datetime.now(timezone.utc)
|
||||
QUIET_EMAIL = {"failed": 0, "retrying": 0, "said": ""}
|
||||
|
||||
|
||||
def _rule(rid, name, resource="web-1", metric="cpu_pct", op=">", threshold=90.0,
|
||||
parent_id=None, enabled=True):
|
||||
return SimpleNamespace(id=rid, name=name, resource_name=resource, metric_name=metric,
|
||||
operator=AlertOperator(op), threshold=threshold,
|
||||
parent_id=parent_id, enabled=enabled)
|
||||
|
||||
|
||||
def _row(rule, label, value=None, since=None, acked_by=None, acked_at=None):
|
||||
return {"rule": rule, "label": label, "value": value, "since": since, "acked_by": acked_by,
|
||||
"state": SimpleNamespace(acknowledged_at=acked_at)}
|
||||
|
||||
|
||||
async def _render(app, view, email=QUIET_EMAIL, role="admin"):
|
||||
async with app.test_request_context("/alerts/widget"):
|
||||
if role:
|
||||
session["user_id"] = "u1"
|
||||
session["user_role"] = role
|
||||
return await render_template("alerts/widget.html", **view, email=email)
|
||||
|
||||
|
||||
def test_the_widget_is_registered_and_points_at_alerts():
|
||||
w = WIDGET_REGISTRY["alerts"]
|
||||
assert w["hx_url"] == "/alerts/widget" and w["detail_url"] == "/alerts/" and w["plugin"] is None
|
||||
|
||||
|
||||
def test_a_covering_rule_counts_once_and_is_quiet_only_when_all_its_hosts_are():
|
||||
cover = _rule("c", "disk on every host", resource="*")
|
||||
kid_a = _rule("c-a", "disk on every host", resource="nas", parent_id="c")
|
||||
kid_b = _rule("c-b", "disk on every host", resource="web-1", parent_id="c")
|
||||
lone = _rule("l", "web up", metric="is_up", op="<", threshold=0.5)
|
||||
off = _rule("o", "old rule", enabled=False)
|
||||
view = widget_view([_row(kid_a, "firing", 91.0, NOW)], [cover, kid_a, kid_b, lone, off])
|
||||
assert view["total"] == 2 and view["quiet"] == 1 # "web up"; the covering rule is busy
|
||||
assert view["rows"][0]["condition"] == "CPU > 90%"
|
||||
assert widget_view([], [cover, kid_a, kid_b, lone, off])["quiet"] == 2
|
||||
|
||||
|
||||
async def test_firing_and_silent_come_first_then_who_acknowledged(app):
|
||||
hot = _rule("r1", "web-1 CPU high")
|
||||
gone = _rule("r2", "nas agent", resource="nas")
|
||||
taken = _rule("r3", "db disk", resource="db-1:/var", metric="disk_used_pct", op=">=", threshold=85.0)
|
||||
rows = [_row(hot, "firing", 94.0, NOW - timedelta(minutes=12)),
|
||||
_row(gone, "no_data", None, NOW - timedelta(hours=2)),
|
||||
_row(taken, "acknowledged", 88.0, NOW - timedelta(hours=3), acked_by="bryan",
|
||||
acked_at=NOW - timedelta(minutes=40))]
|
||||
page = await _render(app, widget_view(rows, [hot, gone, taken, _rule("r4", "quiet one")]))
|
||||
assert re.search(r'w-verdict tone-crit">\s*1 firing, 1 silent, 1 acknowledged\.', page)
|
||||
assert page.index("web-1 CPU high") < page.index("nas agent") < page.index("db disk")
|
||||
assert 'href="/alerts/history?rule=r1"' in page
|
||||
assert "web-1 · fires on CPU > 90%" in page and "94%" in page and "12m ago" in page
|
||||
assert "nas · no reading" in page
|
||||
assert "db-1:/var · acknowledged by bryan" in page and "40m ago" in page
|
||||
assert "+1 rule quiet" in page and 'href="/alerts/"' in page
|
||||
assert len(re.findall(r"<th[ >]", page)) == 3
|
||||
assert not hex_colours(page), hex_colours(page)
|
||||
|
||||
|
||||
async def test_all_quiet_is_the_verdict_alone(app):
|
||||
rules = [_rule("r1", "a"), _rule("r2", "b")]
|
||||
page = await _render(app, widget_view([], rules))
|
||||
assert re.search(r'w-verdict tone-ok">\s*Nothing firing: all 2 rules are quiet\.', page)
|
||||
assert "<table" not in page and "rule quiet" not in page and "rules quiet" not in page
|
||||
page = await _render(app, widget_view([], rules[:1]))
|
||||
assert "Nothing firing: the rule is quiet." in page
|
||||
|
||||
|
||||
async def test_no_rules_offers_to_add_one(app):
|
||||
page = await _render(app, widget_view([], []), role="operator")
|
||||
assert "No alert rules yet." in page and 'href="/alerts/rules/new"' in page
|
||||
page = await _render(app, widget_view([], []), role="viewer")
|
||||
assert "No alert rules yet." in page and "/alerts/rules/new" not in page
|
||||
|
||||
|
||||
async def test_failing_alert_email_gets_a_line(app):
|
||||
email = {"failed": 2, "retrying": 1, "said": "2 alert emails failed in the last 7 days; 1 still retrying"}
|
||||
page = await _render(app, widget_view([], [_rule("r1", "a")]), email=email)
|
||||
assert "2 alert emails failed in the last 7 days; 1 still retrying" in page
|
||||
assert 'href="/settings/system/"' in page and "tone-crit" in page
|
||||
page = await _render(app, widget_view([], [_rule("r1", "a")]), email=email, role="viewer")
|
||||
assert "1 still retrying" in page and "/settings/system/" not in page
|
||||
|
||||
|
||||
async def test_the_share_view_shows_the_alerts_without_links(app):
|
||||
hot = _rule("r1", "web-1 CPU high")
|
||||
page = await _render(app, widget_view([_row(hot, "firing", 94.0, NOW)], [hot]), role=None)
|
||||
assert "web-1 CPU high" in page and "<a " not in page
|
||||
@@ -28,8 +28,11 @@ def test_retired_widgets_point_at_the_one_that_replaced_them():
|
||||
def test_the_recommended_layout_fills_rows_without_overlap():
|
||||
items = recommended_layout(ALL_ON)
|
||||
assert [i["key"] for i in items] == [key for key, *_ in RECOMMENDED]
|
||||
assert items[0] == {"key": "status_overview", "x": 0, "y": 0, "w": 12, "h": 3, "config": {}}
|
||||
assert (items[1]["x"], items[1]["y"], items[2]["x"], items[2]["y"]) == (0, 3, 6, 3)
|
||||
# The Overview and the Alerts side by side first (#5510), then the fleet.
|
||||
assert items[0] == {"key": "status_overview", "x": 0, "y": 0, "w": 6, "h": 4, "config": {}}
|
||||
assert items[1] == {"key": "alerts", "x": 6, "y": 0, "w": 6, "h": 4, "config": {}}
|
||||
assert [(i["key"], i["x"], i["y"]) for i in items[2:4]] == [
|
||||
("hosts_overview", 0, 4), ("monitors", 6, 4)]
|
||||
for i, a in enumerate(items):
|
||||
assert a["x"] + a["w"] <= 12
|
||||
assert not any(_overlap(a, b) for b in items[i + 1:]), a
|
||||
@@ -37,8 +40,8 @@ def test_the_recommended_layout_fills_rows_without_overlap():
|
||||
|
||||
def test_the_recommended_layout_skips_what_is_not_enabled():
|
||||
items = recommended_layout({})
|
||||
assert [i["key"] for i in items] == ["status_overview", "hosts_overview", "monitors"]
|
||||
assert [(i["x"], i["y"]) for i in items] == [(0, 0), (0, 3), (6, 3)]
|
||||
assert [i["key"] for i in items] == ["status_overview", "alerts", "hosts_overview", "monitors"]
|
||||
assert [(i["x"], i["y"]) for i in items] == [(0, 0), (6, 0), (0, 4), (6, 4)]
|
||||
|
||||
|
||||
def test_overview_alerts_put_the_breaching_first():
|
||||
@@ -77,6 +80,26 @@ async def test_the_overview_labels_its_counts_and_lists_what_is_wrong(app):
|
||||
assert not hex_colours(page), hex_colours(page)
|
||||
|
||||
|
||||
async def test_the_overviews_overflow_links_to_alerts_and_to_monitors(app):
|
||||
"""#5510: what doesn't fit goes to its own list; /status/ is gone."""
|
||||
now = datetime.now(timezone.utc)
|
||||
alerts = [{"rule": SimpleNamespace(id=f"r{i}", name=f"rule {i}"), "label": "firing", "since": now}
|
||||
for i in range(7)]
|
||||
entries = [StatusEntry(kind="tcp", key=str(i), name=f"m{i}", status="down", detail_url=f"/monitors/{i}")
|
||||
for i in range(10)]
|
||||
async with app.test_request_context("/status/widget"):
|
||||
session["user_id"] = "u1"
|
||||
page = await render_template("status/widget.html", entries=entries, up=0, down=10, pending=0,
|
||||
total_hosts=1, alerts=alerts, widget_id="1")
|
||||
assert '<a href="/alerts/">+1 more alerts</a> · <a href="/monitors/">+2 more monitors</a>' in page
|
||||
assert "/status/" not in page
|
||||
async with app.test_request_context("/status/widget"):
|
||||
page = await render_template("status/widget.html", entries=entries[:3], up=0, down=3, pending=0,
|
||||
total_hosts=1, alerts=alerts, widget_id="1")
|
||||
assert "+1 more alerts" in page and "more monitors" not in page
|
||||
assert 'href="/alerts/"' not in page # the share view has no links
|
||||
|
||||
|
||||
async def test_a_quiet_overview_says_so(app):
|
||||
async with app.test_request_context("/status/widget"):
|
||||
page = await render_template("status/widget.html", entries=[], up=0, down=0, pending=0,
|
||||
|
||||
@@ -96,4 +96,8 @@ def test_an_alerts_email_reads_gathering_then_failed_in_history_and_on_system(ap
|
||||
assert "failed in the last 7 days" in verdict
|
||||
assert extra in system and "550 mailbox gone" in system
|
||||
|
||||
# The dashboard's Alerts widget names the rule and the failed email (#5510).
|
||||
widget = " ".join((await (await client.get("/alerts/widget")).get_data(as_text=True)).split())
|
||||
assert "failed in the last 7 days" in widget and 'href="/settings/system/"' in widget
|
||||
|
||||
asyncio.run(_go())
|
||||
|
||||
Reference in New Issue
Block a user