From d19df51ffb578238ae18163a7cdfedfefbaf4422 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 9 Oct 2026 09:54:08 -0400 Subject: [PATCH] feat(alerts): pick the host, then its part, and see what the metric reads (#5475) #5475 "Rule form: pick the host, then its part; metrics that part reports; a link to groups; a preview of the metric", from the operator's review of the New rule page: hosts and their parts shared one long picker. - Host agent rules pick a Host, then a Part: "The whole host", then its mounts, CPU cores, disks, interfaces and sensors under a heading each, cores in number order. A part that reports nothing a rule can watch (an interface's traffic) isn't offered. - The Metric list holds what the picked resource reports, in catalog order. - Under it, a preview: the reading now, and the last 24 h low to high with an hourly sparkline drawn from zero (UI ruling 8), read from one series. - The group picker links to Ansible > Inventory to make a group, in a new tab, and re-reads the groups when the tab comes back into view. - Picking a host or part swaps the pickers alone (/alerts/api/rule_target); picking a metric swaps the preview (/alerts/api/metric_preview). - A rule's own resource and metric stay on offer after they go quiet. Co-Authored-By: Claude Opus 5.5 --- docs/core/alerting.md | 9 ++ steward/alerts/routes.py | 86 +++++++++---- steward/alerts/service.py | 118 ++++++++++++++++++ steward/static/steward.css | 5 + steward/templates/alerts/_cover_groups.html | 9 ++ steward/templates/alerts/_metric_preview.html | 18 +++ steward/templates/alerts/_rule_fields.html | 62 +++------ steward/templates/alerts/_rule_target.html | 72 +++++++++++ steward/templates/alerts/rules_form.html | 2 - tests/core/test_alert_rule_target.py | 65 ++++++++++ tests/core/test_alerts_page.py | 13 +- tests/integration/test_alert_rule_form.py | 67 ++++++++++ 12 files changed, 453 insertions(+), 73 deletions(-) create mode 100644 steward/templates/alerts/_cover_groups.html create mode 100644 steward/templates/alerts/_metric_preview.html create mode 100644 steward/templates/alerts/_rule_target.html create mode 100644 tests/core/test_alert_rule_target.py create mode 100644 tests/integration/test_alert_rule_form.py diff --git a/docs/core/alerting.md b/docs/core/alerting.md index cf461f3..99ce1d3 100644 --- a/docs/core/alerting.md +++ b/docs/core/alerting.md @@ -75,6 +75,15 @@ Alert rules are created in the UI at `/alerts/`. Each rule requires: - **Threshold** — numeric value - **Consecutive failures required** — how many consecutive breaches before FIRING (default 1) +The form offers only what has reported, read from `plugin_metric_latest`. +For the host agent, a **Host** is picked first, then a **Part** of it: "The +whole host", then its mounts, CPU cores, disks and so on, under a heading for +each kind. A part that reports nothing a rule can watch, such as an interface's +traffic, isn't listed. The **Metric** list holds only what the picked resource +reports. Under it, a preview shows the metric's current reading and its last +24 hours (lowest to highest, with an hourly sparkline), so the threshold can be +set against real numbers. + ### Rules that cover hosts A rule on `host_agent` can cover **every host**, or **every host in an diff --git a/steward/alerts/routes.py b/steward/alerts/routes.py index 8084621..7e7945b 100644 --- a/steward/alerts/routes.py +++ b/steward/alerts/routes.py @@ -256,21 +256,65 @@ HISTORY_SUMMARY_ROWS = 2000 @alerts_bp.get("/api/rule_fields") @require_role(UserRole.viewer) async def rule_fields_api(): + """Everything the rule watches, for a newly picked source.""" source = request.args.get("source_module", "") - current_resource = request.args.get("current_resource", "") - current_metric = request.args.get("current_metric", "") async with current_app.db_sessionmaker() as db: - resources = await alert_svc.resources_for(db, source) if source else [] - metrics = await alert_svc.metrics_for(db, source) - cover = await _cover_choices(db, source) - return await render_template( - "alerts/_rule_fields.html", - resources=resources, - metrics=metrics, - current_resource=current_resource, - current_metric=current_metric, - **cover, - ) + ctx = {**await _cover_choices(db, source), **await _target(db, source)} + return await render_template("alerts/_rule_fields.html", **ctx) + + +@alerts_bp.get("/api/rule_target") +@require_role(UserRole.viewer) +async def rule_target_api(): + """The host, part and metric pickers and the preview, after a host or a + part is picked (#5475).""" + async with current_app.db_sessionmaker() as db: + ctx = await _target_from_args(db) + return await render_template("alerts/_rule_target.html", **ctx) + + +@alerts_bp.get("/api/metric_preview") +@require_role(UserRole.viewer) +async def metric_preview_api(): + """What the picked metric reads now and over the last day (#5475).""" + async with current_app.db_sessionmaker() as db: + ctx = await _target_from_args(db) + return await render_template("alerts/_metric_preview.html", **ctx) + + +@alerts_bp.get("/api/cover_groups") +@require_role(UserRole.viewer) +async def cover_groups_api(): + """The inventory groups, re-read when the reader comes back from making one.""" + from steward.hosts.service import group_names + async with current_app.db_sessionmaker() as db: + groups = await group_names(db) + return await render_template("alerts/_cover_groups.html", groups=groups, + current_group=request.args.get("cover_group", ""), + current_covers=request.args.get("covers", "resource")) + + +async def _target(db, source: str, *, host: str = "", resource: str = "", metric: str = "", + covers: str = "resource") -> dict: + """The rule form's target pickers and the chosen metric's preview (#5475).""" + from steward.core.status import sparkline_svg + target = await alert_svc.rule_target(db, source, host=host, resource=resource, metric=metric) + preview = None + if target["resource"] and target["metric"]: + preview = await alert_svc.metric_preview(db, source, target["resource"], target["metric"]) + if preview: + # From zero to the series' own peak (UI & Design ruling 8), so a + # disk at 61-62% reads flat rather than as a cliff. + preview["spark"] = sparkline_svg(preview["series"], width=120, height=20, + stroke="currentColor", floor=1.0) + return {**target, "preview": preview, "current_covers": covers} + + +async def _target_from_args(db) -> dict: + a = request.args + return await _target(db, a.get("source_module", ""), host=a.get("pick_host", ""), + resource=a.get("resource_name", ""), metric=a.get("metric_name", ""), + covers=a.get("covers", "resource")) async def _cover_choices(db, source: str, rule: AlertRule | None = None) -> dict: @@ -290,16 +334,13 @@ async def _cover_choices(db, source: str, rule: AlertRule | None = None) -> dict async def new_rule(): first_source = alert_svc.source_modules()[0] async with current_app.db_sessionmaker() as db: - resources = await alert_svc.resources_for(db, first_source) - cover = await _cover_choices(db, first_source) + ctx = {**await _cover_choices(db, first_source), **await _target(db, first_source)} return await render_template( "alerts/rules_form.html", - rule=None, **cover, + rule=None, **ctx, operators=_OPERATORS, source_modules=alert_svc.source_modules(), initial_source=first_source, - resources=resources, - metrics=alert_svc.metric_catalog().get(first_source, []), catalog=alert_svc.metric_catalog(), ansible_sources=_ansible_source_names(), notify=await _notify(), @@ -317,8 +358,9 @@ async def edit_rule(rule_id: str): if rule.parent_id: # A host's rule under a covering rule is changed through it. return redirect(f"/alerts/rules/{rule.parent_id}/edit") - resources = await alert_svc.resources_for(db, rule.source_module) - cover = await _cover_choices(db, rule.source_module, rule) + ctx = {**await _cover_choices(db, rule.source_module, rule), + **await _target(db, rule.source_module, resource=rule.resource_name, + metric=rule.metric_name, covers=rule.covers)} hosts = sorted((c.resource_name for c in (await db.execute( select(AlertRule).where(AlertRule.parent_id == rule.id))).scalars()), key=str.lower) history_count = (await db.execute( @@ -327,12 +369,10 @@ async def edit_rule(rule_id: str): return await render_template( "alerts/rules_form.html", rule=rule, history_count=history_count, fired=fired, notify=await _notify(), - covered=hosts, **cover, + covered=hosts, **ctx, operators=_OPERATORS, source_modules=alert_svc.source_modules(), initial_source=rule.source_module, - resources=resources, - metrics=alert_svc.metric_catalog().get(rule.source_module, []), catalog=alert_svc.metric_catalog(), ansible_sources=_ansible_source_names(), ) diff --git a/steward/alerts/service.py b/steward/alerts/service.py index af6a794..eda3c90 100644 --- a/steward/alerts/service.py +++ b/steward/alerts/service.py @@ -358,6 +358,124 @@ async def resources_for(db, source_module: str) -> list[str]: return sorted(resources) +# A host's parts, by the shape of the name after ":" (#5475): the order +# and heading each kind takes in the rule form's Part picker. +PART_KINDS = (("mount", "Mounts"), ("core", "CPU cores"), ("disk", "Disks"), + ("net", "Interfaces"), ("temp", "Temperatures"), ("other", "Other")) + + +def part_kind(part: str) -> tuple[str, str]: + """(kind, label) for a host part: "/var" is a mount, "core3" a core, + "diskio:sda" a disk, "net:eth0" an interface, "temp:Core 0" a sensor.""" + if part.startswith("/"): + return "mount", part + if part.startswith("core") and part[4:].isdigit(): + return "core", f"core {part[4:]}" + for prefix, kind in (("diskio:", "disk"), ("net:", "net"), ("temp:", "temp")): + if part.startswith(prefix): + return kind, part[len(prefix):] + return "other", part + + +def _part_sort(part: str): + kind, label = part_kind(part) + return (int(part[4:]), "") if kind == "core" else (0, label.lower()) + + +def alertable(source_module: str, reported: set[str]) -> list[str]: + """The metrics a rule on a resource can watch, from what it reports: the + source's catalog, in its order, narrowed to those; every reported name + for a source with no catalog (snmp).""" + catalog = metric_catalog().get(source_module, []) + return [m for m in catalog if m in reported] if catalog else sorted(reported) + + +async def rule_target(db, source_module: str, *, host: str = "", resource: str = "", + metric: str = "") -> dict: + """What the rule form's target pickers offer (#5475), from the latest-value + table: the resources that report something a rule can watch, and for the + chosen one the metrics it reports. For a host source the resources split + into a Host picker and a Part picker ("the whole host", then the host's + mounts, cores and so on). A resource or metric already chosen stays on + offer even when it has gone quiet, so editing a rule never drops them. + + Returns host_source, hosts, host, parts [(heading, [(value, label)])], + resources, resource, metrics, metric.""" + catalog = metric_catalog().get(source_module, []) + seen: dict[str, set[str]] = {} + for res, name in (await db.execute( + select(PluginMetricLatest.resource_name, PluginMetricLatest.metric_name) + .where(PluginMetricLatest.source_module == source_module))).all(): + seen.setdefault(res, set()).add(name) + pickable = {r: ms for r, names in seen.items() if (ms := alertable(source_module, names))} + if source_module in MONITOR_TYPE_VALUES: + for (name,) in (await db.execute(select(Monitor.name).where(Monitor.type == source_module))).all(): + pickable.setdefault(name, list(catalog)) + if resource and resource not in pickable: + pickable[resource] = list(catalog) or ([metric] if metric else []) + + out: dict = {"host_source": source_module in HOST_SOURCES, "hosts": [], "host": "", + "parts": [], "resources": [], "resource": resource} + if out["host_source"]: + hosts = sorted({r.split(":", 1)[0] for r in pickable}, key=str.lower) + host = host or resource.split(":", 1)[0] + if host not in hosts: + host = hosts[0] if hosts else "" + mine = [r for r in pickable if r == host or r.startswith(host + ":")] + if resource not in mine: + resource = host if host in pickable else (sorted(mine)[0] if mine else "") + by_kind: dict[str, list] = {} + for r in sorted((r for r in mine if r != host), key=lambda r: _part_sort(r[len(host) + 1:])): + kind, label = part_kind(r[len(host) + 1:]) + by_kind.setdefault(kind, []).append((r, label)) + parts = [("", [(host, "The whole host")])] if host in pickable else [] + parts += [(heading, by_kind[k]) for k, heading in PART_KINDS if k in by_kind] + out.update(hosts=hosts, host=host, parts=parts, resource=resource) + else: + out["resources"] = sorted(pickable, key=str.lower) + metrics = list(pickable.get(out["resource"]) or catalog) + if metric and metric not in metrics and (metric in catalog or not catalog): + metrics.append(metric) + out.update(metrics=metrics, metric=metric if metric in metrics else "") + return out + + +# How far back the rule form's metric preview looks: a day shows the daily +# swing (backups, batch jobs) that a threshold has to sit above. +PREVIEW_HOURS = 24 + + +async def metric_preview(db, source_module: str, resource: str, metric: str, + now: datetime | None = None) -> dict | None: + """What the chosen metric reads (#5475), so a threshold is set against real + numbers: the latest reading and when, and the last PREVIEW_HOURS as hourly + averages with the lowest and highest. Reads one series: the latest-value + row, then a recorded_at range on plugin_metrics' series index. None when + it has no reading.""" + from datetime import timedelta + from steward.models.metrics import PluginMetric + latest = (await db.execute(select(PluginMetricLatest).where( + PluginMetricLatest.source_module == source_module, + PluginMetricLatest.resource_name == resource, + PluginMetricLatest.metric_name == metric))).scalar_one_or_none() + if latest is None: + return None + now = now or datetime.now(timezone.utc) + hour = func.date_trunc("hour", PluginMetric.recorded_at) + rows = (await db.execute( + select(hour, func.avg(PluginMetric.value), func.min(PluginMetric.value), + func.max(PluginMetric.value)) + .where(PluginMetric.source_module == source_module, + PluginMetric.resource_name == resource, + PluginMetric.metric_name == metric, + PluginMetric.recorded_at >= now - timedelta(hours=PREVIEW_HOURS)) + .group_by(hour).order_by(hour))).all() + return {"now": latest.value, "at": latest.recorded_at, + "series": [avg for _, avg, _, _ in rows], + "low": min((lo for _, _, lo, _ in rows), default=None), + "high": max((hi for _, _, _, hi in rows), default=None)} + + async def metrics_for(db, source_module: str) -> list[str]: """The metric names a rule on this source can watch: the static catalog, or for dynamic sources (snmp) the names recorded so far.""" diff --git a/steward/static/steward.css b/steward/static/steward.css index 7ddabf9..336e8c4 100644 --- a/steward/static/steward.css +++ b/steward/static/steward.css @@ -219,6 +219,11 @@ strong, b, th { font-weight: 500; } /* A covering alert rule's per-host rules (#5464), indented under it. */ .table tr.row-child > td { border-top-style: dashed; } .table tr.row-child .cell-name { padding-left: 2rem; } +/* The rule form's metric preview (#5475): now, then the last day beside its sparkline. */ +.metric-preview { display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem 1rem; + margin-top: 0.4rem; font-size: 0.85rem; } +.metric-preview-day { display: inline-flex; align-items: center; gap: 0.4rem; } +.metric-preview-spark { color: var(--text-muted); line-height: 0; } /* A row that opens as a whole: its one .row-target link stretches over the row, and the actions cell sits above it so its buttons keep their own clicks. One real link per row, so keyboard and screen readers meet it once. */ diff --git a/steward/templates/alerts/_cover_groups.html b/steward/templates/alerts/_cover_groups.html new file mode 100644 index 0000000..cb35e1e --- /dev/null +++ b/steward/templates/alerts/_cover_groups.html @@ -0,0 +1,9 @@ +{# alerts/_cover_groups.html — the inventory groups a rule can cover (#5464), + re-read when the reader returns from making one (#5475). #} + diff --git a/steward/templates/alerts/_metric_preview.html b/steward/templates/alerts/_metric_preview.html new file mode 100644 index 0000000..59455dc --- /dev/null +++ b/steward/templates/alerts/_metric_preview.html @@ -0,0 +1,18 @@ +{# alerts/_metric_preview.html — what the chosen metric reads (#5475), so a + threshold is set against real numbers: now, and the last day's hourly + averages with the lowest and highest reading. A rule covering hosts + previews on the host picked above it, or none. #} +{% if preview %} +
+ Now {{ metric_value(metric, preview.now) }} + {{ preview.at|ago }} + {% if preview.series|length > 1 %} + + last 24 h {{ metric_value(metric, preview.low) }} – {{ metric_value(metric, preview.high) }} + + + {% endif %} +
+{% elif metric and resource %} +

No reading of this yet from {{ resource }}.

+{% endif %} diff --git a/steward/templates/alerts/_rule_fields.html b/steward/templates/alerts/_rule_fields.html index 7f8e11d..3e88dd9 100644 --- a/steward/templates/alerts/_rule_fields.html +++ b/steward/templates/alerts/_rule_fields.html @@ -1,8 +1,10 @@ -{# alerts/_rule_fields.html — the resource and metric pickers for one source; - the rule form includes it and swaps it in again when the source changes. - For a source whose resources are hosts, the rule can instead cover every - host, or every host in an inventory group (#5464): Steward then keeps one - rule per host under it. What a rule covers is fixed once it exists. #} +{# alerts/_rule_fields.html — what a rule watches, for one source; the rule + form includes it and swaps it in again when the source changes. For a + source whose resources are hosts, the rule can instead cover every host, + or every host in an inventory group (#5464): Steward then keeps one rule + per host under it. What a rule covers is fixed once it exists. The host, + part and metric pickers are alerts/_rule_target.html, swapped on their + own as a host or part is picked (#5475). #} {% set covers = current_covers|default("resource") %} {% set one = covers == "resource" %} {% if host_source|default(false) %} @@ -16,7 +18,7 @@ {% endif %}

Covering hosts keeps one alert per host under this rule, each named @@ -25,43 +27,17 @@

- -

A host is in a group through its linked Ansible target (Ansible → Inventory).

+ {# Re-read when the reader comes back to this tab, so a group just made in + Inventory is here without losing what was typed. #} +
+ {% include "alerts/_cover_groups.html" %} +
+

A host is in a group through its linked Ansible target. + Make a group, or change who is in one, in Ansible → Inventory; + it shows here when you come back.

{% endif %} -
- - {% if resources %} - - {% else %} - -

- The list fills from readings this source has recorded. Come back once it has reported. -

- {% endif %} -
- -
- - - {% if current_metric and metric_about(current_metric) %} -

{{ metric_about(current_metric) }}

- {% else %}{% endif %} +
+{% include "alerts/_rule_target.html" %}
diff --git a/steward/templates/alerts/_rule_target.html b/steward/templates/alerts/_rule_target.html new file mode 100644 index 0000000..354778e --- /dev/null +++ b/steward/templates/alerts/_rule_target.html @@ -0,0 +1,72 @@ +{# alerts/_rule_target.html — what one rule watches (#5475). For a host + source: the Host, then its Part ("the whole host", then mounts, cores and + so on), so a host's parts never crowd the list of hosts. Any other source: + one Resource picker. Then the metrics that resource reports, and a preview + of the chosen one. Picking a host or part swaps this block alone + (/alerts/api/rule_target); picking a metric swaps the preview. #} +{% set one = current_covers|default("resource") == "resource" %} +
+ {% if host_source %} +
+
+ + {% if hosts %} + + {% else %} + + {% endif %} +
+
+ + +
+
+ {% if not hosts %}

The list fills once an agent reports.

{% endif %} + {% else %} +
+ + {% if resources %} + + {% else %} + +

The list fills from readings this source has recorded. Come back once it has reported.

+ {% endif %} +
+ {% endif %} +
+ +
+ + + {% if metric and metric_about(metric) %} +

{{ metric_about(metric) }}

+ {% else %}{% endif %} +
{% include "alerts/_metric_preview.html" %}
+
diff --git a/steward/templates/alerts/rules_form.html b/steward/templates/alerts/rules_form.html index 82bf25d..634c6c6 100644 --- a/steward/templates/alerts/rules_form.html +++ b/steward/templates/alerts/rules_form.html @@ -56,9 +56,7 @@ {# ── Dynamic resource + metric ────────────────────────────────────────── #}
- {% with current_resource = rule.resource_name if rule else "", current_metric = rule.metric_name if rule else "" %} {% include "alerts/_rule_fields.html" %} - {% endwith %}
{# ── Metric reference ────────────────────────────────────────────────── #}
diff --git a/tests/core/test_alert_rule_target.py b/tests/core/test_alert_rule_target.py new file mode 100644 index 0000000..1ac0d51 --- /dev/null +++ b/tests/core/test_alert_rule_target.py @@ -0,0 +1,65 @@ +"""The rule form's target pickers (#5475): for a host source, the hosts in +one picker and the chosen host's parts in another, grouped by kind, each +offering only the metrics it reports that a rule can watch; a part that +reports nothing a rule can watch (an interface's traffic) is left out.""" +from types import SimpleNamespace + +import pytest + +from steward.alerts import service as alert_svc + +CATALOG = {"host_agent": ["cpu_pct", "disk_used_pct", "uptime_secs", "seconds_since_seen"]} +REPORTED = [("h1", "cpu_pct"), ("h1", "uptime_secs"), ("h1", "seconds_since_seen"), + ("h1:/", "disk_used_pct"), ("h1:/var", "disk_used_pct"), + ("h1:core10", "cpu_pct"), ("h1:core2", "cpu_pct"), + ("h1:net:eth0", "net_rx_bps"), ("h1:temp:Core 0", "temp_c"), + ("h2", "cpu_pct")] + + +@pytest.fixture(autouse=True) +def _catalog(monkeypatch): + monkeypatch.setattr(alert_svc, "metric_catalog", lambda: CATALOG) + + +def _db(rows=REPORTED): + async def execute(stmt): + return SimpleNamespace(all=lambda: list(rows)) + return SimpleNamespace(execute=execute) + + +@pytest.mark.parametrize("part, kind, label", [ + ("/", "mount", "/"), ("/nfs/media", "mount", "/nfs/media"), ("core7", "core", "core 7"), + ("diskio:sda", "disk", "sda"), ("net:bond0", "net", "bond0"), + ("temp:Package id 0", "temp", "Package id 0"), ("corex", "other", "corex")]) +def test_a_part_is_known_by_its_name(part, kind, label): + assert alert_svc.part_kind(part) == (kind, label) + + +async def test_hosts_and_their_parts_are_picked_apart(): + got = await alert_svc.rule_target(_db(), "host_agent") + assert got["hosts"] == ["h1", "h2"] and (got["host"], got["resource"]) == ("h1", "h1") + assert got["parts"] == [ + ("", [("h1", "The whole host")]), + ("Mounts", [("h1:/", "/"), ("h1:/var", "/var")]), + ("CPU cores", [("h1:core2", "core 2"), ("h1:core10", "core 10")])] + assert got["metrics"] == ["cpu_pct", "uptime_secs", "seconds_since_seen"] # catalog order + + +async def test_a_part_offers_only_what_it_reports(): + got = await alert_svc.rule_target(_db(), "host_agent", resource="h1:/var", metric="cpu_pct") + assert (got["host"], got["resource"]) == ("h1", "h1:/var") + assert got["metrics"] == ["disk_used_pct"] and got["metric"] == "" # cpu_pct isn't a mount's + + +async def test_picking_another_host_starts_at_the_whole_host(): + got = await alert_svc.rule_target(_db(), "host_agent", host="h2", resource="h1:/var") + assert (got["host"], got["resource"]) == ("h2", "h2") + assert got["parts"] == [("", [("h2", "The whole host")])] + + +async def test_a_rule_s_own_resource_stays_on_offer_after_it_goes_quiet(): + got = await alert_svc.rule_target(_db(), "host_agent", resource="h3:/data", + metric="disk_used_pct") + assert "h3" in got["hosts"] and got["host"] == "h3" + assert got["parts"] == [("Mounts", [("h3:/data", "/data")])] + assert got["metric"] == "disk_used_pct" diff --git a/tests/core/test_alerts_page.py b/tests/core/test_alerts_page.py index 419ab26..cc0ec1d 100644 --- a/tests/core/test_alerts_page.py +++ b/tests/core/test_alerts_page.py @@ -119,7 +119,8 @@ async def test_deleting_a_rule_is_on_its_own_page_and_says_what_goes(app): listing = await render_template("alerts/list.html", active=[], rules=[rule], operators=[]) page = await render_template( "alerts/rules_form.html", rule=rule, operators=[], source_modules=["host_agent"], - initial_source="host_agent", resources=["web-1"], metrics=["cpu_pct"], ansible_sources=[], + initial_source="host_agent", resources=["web-1"], resource="web-1", metrics=["cpu_pct"], + metric="cpu_pct", ansible_sources=[], catalog={}, history_count=12, fired=None, notify=[]) assert "/delete" not in listing assert 'action="/alerts/rules/r1/delete"' in page @@ -138,7 +139,8 @@ async def test_the_rule_page_is_named_for_the_rule(app): session["user_role"] = "admin" page = await render_template( "alerts/rules_form.html", rule=rule, operators=[(">", ">")], source_modules=["host_agent"], - initial_source="host_agent", resources=["VDNT-Docker02"], metrics=["seconds_since_seen"], + initial_source="host_agent", resources=["VDNT-Docker02"], resource="VDNT-Docker02", + metrics=["seconds_since_seen"], metric="seconds_since_seen", ansible_sources=[], catalog={}, history_count=10, fired=datetime.now(timezone.utc) - timedelta(hours=6), notify=["email to me@example.com"]) page = " ".join(page.split()) @@ -265,8 +267,8 @@ async def test_the_rule_form_speaks_in_names_and_units(app): session["user_role"] = "operator" page = await render_template( "alerts/rules_form.html", rule=rule, operators=_OPERATORS, - source_modules=["http", "host_agent"], initial_source="http", resources=["nas"], - metrics=["is_up", "response_ms"], ansible_sources=[], + source_modules=["http", "host_agent"], initial_source="http", resources=["nas"], resource="nas", + metrics=["is_up", "response_ms"], metric="is_up", ansible_sources=[], catalog={"http": ["is_up", "response_ms"], "snmp": []}) assert ">HTTP monitors" in page and ">Host agent" in page assert ">Up or down" in page and 'data-unit="ms"' in page @@ -294,7 +296,8 @@ async def test_a_rule_covering_hosts_lists_its_hosts_under_it(app): page = await render_template( "alerts/rules_form.html", rule=parent, operators=[], source_modules=["host_agent"], initial_source="host_agent", resources=[], metrics=["seconds_since_seen"], - ansible_sources=[], catalog={}, history_count=0, fired=None, notify=[], + metric="seconds_since_seen", ansible_sources=[], catalog={}, history_count=0, + fired=None, notify=[], covered=["node-a", "node-b"], host_source=True, groups=["swarm"], current_covers="group", current_group="swarm", covers_locked=True) listing, page = " ".join(listing.split()), " ".join(page.split()) diff --git a/tests/integration/test_alert_rule_form.py b/tests/integration/test_alert_rule_form.py new file mode 100644 index 0000000..dc27a54 --- /dev/null +++ b/tests/integration/test_alert_rule_form.py @@ -0,0 +1,67 @@ +"""Integration: the rule form's host and part pickers and the metric preview +(#5475), through their routes against Postgres. A host's parts come grouped +under it, and a picked metric shows what it reads now and over the last day.""" +from __future__ import annotations + +import asyncio +import os +import time +import uuid +from datetime import datetime, timedelta, timezone + +import pytest + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def app(): + if not os.environ.get("STEWARD_DATABASE_URL"): + pytest.skip("needs Postgres") + from steward.app import create_app + return create_app(testing=False) + + +def test_the_pickers_and_the_preview_read_what_the_host_reports(app): + from steward.core.alerts import record_metrics + from steward.models.metrics import PluginMetric + from steward.models.users import User, UserRole + + async def _go(): + tag = uuid.uuid4().hex[:8] + host = f"rf-{tag}" + now = datetime.now(timezone.utc) + async with app.db_sessionmaker() as s: + async with s.begin(): + user = User(username=f"rf-{tag}", email=f"rf-{tag}@test", password_hash="x", + role=UserRole.operator, is_active=True) + s.add(user) + # Disk use climbing over the last few hours, then cpu and a mount. + await record_metrics(s, [ + PluginMetric(source_module="host_agent", resource_name=f"{host}:/data", + metric_name="disk_used_pct", value=v, recorded_at=now - timedelta(hours=h)) + for h, v in ((5, 61.0), (3, 70.0), (1, 82.0))]) + await record_metrics(s, [ + PluginMetric(source_module="host_agent", resource_name=host, + metric_name="cpu_pct", value=12.0, recorded_at=now)]) + uid = user.id + client = app.test_client() + async with client.session_transaction() as sess: + sess.update(user_id=uid, user_role="operator", username=f"rf-{tag}", + _csrf="test-token", login_at=time.time()) + + resp = await client.get("/alerts/api/rule_target", query_string={ + "source_module": "host_agent", "pick_host": host}) + page = " ".join((await resp.get_data(as_text=True)).split()) + assert resp.status_code == 200 + assert f'' in page + assert f' ' in page + + resp = await client.get("/alerts/api/metric_preview", query_string={ + "source_module": "host_agent", "pick_host": host, "resource_name": f"{host}:/data", + "metric_name": "disk_used_pct"}) + page = " ".join((await resp.get_data(as_text=True)).split()) + assert "Now 82%" in page + assert "last 24 h 61% – 82%" in page and "