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
+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(),
)