feat(alerts): pick the host, then its part, and see what the metric reads (#5475)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m8s
CI / unit (push) Failing after 1m29s
CI / publish (push) Skipped

#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 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 09:54:08 -04:00
co-authored by Claude Opus 5.5
parent d5601575d8
commit d19df51ffb
12 changed files with 453 additions and 73 deletions
+9
View File
@@ -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
+63 -23
View File
@@ -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(),
)
+118
View File
@@ -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 "<host>:" (#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."""
+5
View File
@@ -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. */
@@ -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). #}
<select id="rule-group" name="cover_group" {% if current_covers|default("resource") != "group" %}disabled{% endif %}>
{% for g in groups %}
<option value="{{ g }}" {% if g == current_group|default("") %}selected{% endif %}>{{ g }}</option>
{% else %}
<option value="" disabled selected>No inventory groups yet</option>
{% endfor %}
</select>
@@ -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 %}
<div class="metric-preview">
<span>Now <strong>{{ metric_value(metric, preview.now) }}</strong>
<span class="tone-dim" title="{{ preview.at|when }}">{{ preview.at|ago }}</span></span>
{% if preview.series|length > 1 %}
<span class="metric-preview-day">
<span class="tone-dim">last 24 h</span> {{ metric_value(metric, preview.low) }} – {{ metric_value(metric, preview.high) }}
<span class="metric-preview-spark" aria-hidden="true">{{ preview.spark|safe }}</span>
</span>
{% endif %}
</div>
{% elif metric and resource %}
<p class="field-hint">No reading of this yet from {{ resource }}.</p>
{% endif %}
+19 -43
View File
@@ -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 @@
<select id="rule-covers" name="covers" data-covers>
<option value="resource" {% if one %}selected{% endif %}>One host, or one part of a host</option>
<option value="all_hosts" {% if covers == "all_hosts" %}selected{% endif %}>Every host</option>
<option value="group" {% if covers == "group" %}selected{% endif %} {% if not groups %}disabled{% endif %}>Every host in a group{% if not groups %} (no inventory groups yet){% endif %}</option>
<option value="group" {% if covers == "group" %}selected{% endif %}>Every host in a group</option>
</select>
{% endif %}
<p class="field-hint">Covering hosts keeps one alert per host under this rule, each named
@@ -25,43 +27,17 @@
</div>
<div class="form-group" data-cover-group {% if covers != "group" %}hidden{% endif %}>
<label for="rule-group">Group</label>
<select id="rule-group" name="cover_group" {% if covers != "group" %}disabled{% endif %}>
{% for g in groups %}
<option value="{{ g }}" {% if g == current_group|default("") %}selected{% endif %}>{{ g }}</option>
{% endfor %}
</select>
<p class="field-hint">A host is in a group through its linked Ansible target (Ansible → Inventory).</p>
{# Re-read when the reader comes back to this tab, so a group just made in
Inventory is here without losing what was typed. #}
<div id="rule-group-picker" hx-get="/alerts/api/cover_groups" hx-trigger="visibilitychange[document.visibilityState === 'visible'] from:document"
hx-include="[name='cover_group'],[name='covers']" hx-swap="innerHTML">
{% include "alerts/_cover_groups.html" %}
</div>
<p class="field-hint">A host is in a group through its linked Ansible target.
<a href="/ansible/inventory/?add=group" target="_blank" rel="noopener">Make a group, or change who is in one, in Ansible → Inventory</a>;
it shows here when you come back.</p>
</div>
{% endif %}
<div class="form-group" data-cover-resource {% if not one %}hidden{% endif %}>
<label>Resource</label>
{% if resources %}
<select name="resource_name" required {% if not one %}disabled{% endif %}>
{% if not current_resource %}<option value="" disabled selected>— pick a resource —</option>{% endif %}
{% for r in resources %}
<option value="{{ r }}" {% if r == current_resource %}selected{% endif %}>{{ r }}</option>
{% endfor %}
</select>
{% else %}
<select name="resource_name" required disabled data-none="1">
<option value="">Nothing has reported from this source yet</option>
</select>
<p class="field-hint">
The list fills from readings this source has recorded. Come back once it has reported.
</p>
{% endif %}
</div>
<div class="form-group">
<label>Metric</label>
<select name="metric_name" required data-metric-picker>
{% if not current_metric %}<option value="" disabled selected>— pick a metric —</option>{% endif %}
{% for m in metrics %}
<option value="{{ m }}" data-unit="{{ metric_unit(m) }}" data-about="{{ metric_about(m) }}" {% if m in ("is_up", "is_healthy") %}data-binary{% endif %}
{% if m == current_metric %}selected{% endif %}>{{ metric_label(m) }}</option>
{% endfor %}
</select>
{% if current_metric and metric_about(current_metric) %}
<p class="field-hint" data-metric-about>{{ metric_about(current_metric) }}</p>
{% else %}<p class="field-hint" data-metric-about hidden></p>{% endif %}
<div id="rule-target">
{% include "alerts/_rule_target.html" %}
</div>
@@ -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" %}
<div data-cover-resource {% if not one %}hidden{% endif %}>
{% if host_source %}
<div class="form-pair">
<div class="form-group">
<label for="rule-host">Host</label>
{% if hosts %}
<select id="rule-host" name="pick_host" {% if not one %}disabled{% endif %}
hx-get="/alerts/api/rule_target" hx-trigger="change" hx-target="#rule-target"
hx-include="closest form" hx-params="source_module,pick_host,metric_name,covers">
{% for h in hosts %}<option value="{{ h }}" {% if h == host %}selected{% endif %}>{{ h }}</option>{% endfor %}
</select>
{% else %}
<select id="rule-host" disabled data-none="1"><option>No host has reported yet</option></select>
{% endif %}
</div>
<div class="form-group">
<label for="rule-part">Part</label>
<select id="rule-part" name="resource_name" required {% if not one or not parts %}disabled{% endif %} {% if not parts %}data-none="1"{% endif %}
hx-get="/alerts/api/rule_target" hx-trigger="change" hx-target="#rule-target"
hx-include="closest form" hx-params="source_module,pick_host,resource_name,metric_name,covers">
{% for heading, items in parts %}
{% if heading %}<optgroup label="{{ heading }}">{% endif %}
{% for value, label in items %}<option value="{{ value }}" {% if value == resource %}selected{% endif %}>{{ label }}</option>{% endfor %}
{% if heading %}</optgroup>{% endif %}
{% endfor %}
</select>
</div>
</div>
{% if not hosts %}<p class="field-hint" style="margin-top:-0.5rem;">The list fills once an agent reports.</p>{% endif %}
{% else %}
<div class="form-group">
<label for="rule-resource">Resource</label>
{% if resources %}
<select id="rule-resource" name="resource_name" required {% if not one %}disabled{% endif %}
hx-get="/alerts/api/rule_target" hx-trigger="change" hx-target="#rule-target"
hx-include="closest form" hx-params="source_module,resource_name,metric_name,covers">
{% if not resource %}<option value="" disabled selected>— pick a resource —</option>{% endif %}
{% for r in resources %}<option value="{{ r }}" {% if r == resource %}selected{% endif %}>{{ r }}</option>{% endfor %}
</select>
{% else %}
<select id="rule-resource" name="resource_name" required disabled data-none="1">
<option value="">Nothing has reported from this source yet</option>
</select>
<p class="field-hint">The list fills from readings this source has recorded. Come back once it has reported.</p>
{% endif %}
</div>
{% endif %}
</div>
<div class="form-group">
<label for="rule-metric">Metric</label>
<select id="rule-metric" name="metric_name" required data-metric-picker
hx-get="/alerts/api/metric_preview" hx-trigger="change" hx-target="#metric-preview"
hx-include="closest form" hx-params="source_module,resource_name,pick_host,metric_name,covers">
{% if not metric %}<option value="" disabled selected>— pick a metric —</option>{% endif %}
{% for m in metrics %}
<option value="{{ m }}" data-unit="{{ metric_unit(m) }}" data-about="{{ metric_about(m) }}" {% if m in ("is_up", "is_healthy") %}data-binary{% endif %}
{% if m == metric %}selected{% endif %}>{{ metric_label(m) }}</option>
{% endfor %}
</select>
{% if metric and metric_about(metric) %}
<p class="field-hint" data-metric-about>{{ metric_about(metric) }}</p>
{% else %}<p class="field-hint" data-metric-about hidden></p>{% endif %}
<div id="metric-preview" aria-live="polite">{% include "alerts/_metric_preview.html" %}</div>
</div>
-2
View File
@@ -56,9 +56,7 @@
{# ── Dynamic resource + metric ────────────────────────────────────────── #}
<div id="rule-fields">
{% with current_resource = rule.resource_name if rule else "", current_metric = rule.metric_name if rule else "" %}
{% include "alerts/_rule_fields.html" %}
{% endwith %}
</div>
{# ── Metric reference ────────────────────────────────────────────────── #}
<details style="margin-bottom:1rem;">
+65
View File
@@ -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"
+8 -5
View File
@@ -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</option>" in page and ">Host agent</option>" in page
assert ">Up or down</option>" 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())
+67
View File
@@ -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'<option value="{host}" selected>The whole host</option>' in page
assert f'<optgroup label="Mounts"> <option value="{host}:/data" >/data</option>' 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 <strong>82%</strong>" in page
assert "last 24 h</span> 61% – 82%" in page and "<polyline" in page
asyncio.run(_go())