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 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user