feat(ui): audit, alert rules and the rule form say things in words (#5212)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 51s
CI / unit (push) Failing after 1m14s
CI / publish (push) Skipped

#5212 "Plain labels: no raw ids, scopes, metric keys or unexplained jargon",
batch 1:

- Audit log: each action reads as a sentence ("paused monitor"), with the
  code as its tooltip. Detail shows flags as yes/no, lists joined, a run's
  scope by name, spans as spans, and no ids the entity column already gives.
  The filter is now a picker of areas.
- Alert rules, history and Status say the condition in words: "is down",
  "Fullest disk >= 85%". Readings use metric_value, and history times use
  |ago.
- Rule form: sources and metrics by name, the threshold's unit, "fires
  when it is down/up" for an up/down metric instead of 0.5, and a metric
  reference built from the catalog.
- Schedules and Maintenance: viewers no longer see Run now, Pause, Edit,
  Delete or New. Intervals and next runs read as "every 2h" and "in 3h";
  a scope reads "Every alert on Host agent".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 00:23:02 -04:00
co-authored by Claude Opus 5.5
parent 7275fad767
commit 3a772716ae
21 changed files with 487 additions and 156 deletions
+7 -2
View File
@@ -2,7 +2,8 @@ from __future__ import annotations
import json
from quart import Blueprint, render_template, request, current_app
from sqlalchemy import select
from steward.audit.service import entity_labels
from steward.ansible.inventory_gen import scope_labels
from steward.audit.service import AREAS, action_text, detail_facts, entity_labels
from steward.auth.middleware import require_role
from steward.core.paging import keyset, next_page
from steward.models.audit import AuditEvent
@@ -36,12 +37,16 @@ async def list_events():
detail = {"raw": e.detail_json}
parsed.append({"event": e, "detail": detail})
labels = await entity_labels(db, parsed)
scopes = await scope_labels(db, [p["detail"].get("scope") for p in parsed
if isinstance(p["detail"].get("scope"), str)])
for item in parsed:
e = item["event"]
item["entity"] = labels.get((e.entity_type, str(e.entity_id))) if e.entity_id else None
item["action"] = action_text(e.action)
item["facts"] = detail_facts(item["detail"], scopes)
ctx = {"events": parsed, "after": after, "action_filter": action_filter}
ctx = {"events": parsed, "after": after, "action_filter": action_filter, "areas": AREAS}
if request.args.get("before"):
return await render_template("audit/_rows.html", **ctx)
return await render_template("audit/list.html", **ctx)
+124
View File
@@ -82,3 +82,127 @@ async def entity_labels(db, items: list[dict]) -> dict[tuple[str, str], dict]:
recorded = _recorded_name(it["detail"] or {})
labels[key] = {"label": recorded or f"{str(e.entity_id)[:8]}", "url": None, "gone": True}
return labels
# What each action did, said as the rest of a sentence that starts with who
# did it: "admin paused monitor api check". An action not listed here is
# shown by its code, which is also every row's tooltip and what the filter
# matches.
ACTIONS = {
"auth.login": "signed in",
"auth.login_failed": "failed to sign in",
"auth.setup": "set up Steward",
"host.created": "added host",
"host.renamed": "renamed host",
"host.deleted": "deleted host",
"monitor.created": "added monitor",
"monitor.updated": "changed monitor",
"monitor.enabled": "resumed monitor",
"monitor.disabled": "paused monitor",
"monitor.deleted": "deleted monitor",
"alert_rule.created": "added alert rule",
"alert_rule.updated": "changed alert rule",
"alert_rule.enabled": "turned on alert rule",
"alert_rule.disabled": "turned off alert rule",
"alert_rule.deleted": "deleted alert rule",
"alert_rule.acknowledged": "acknowledged the alert from",
"alert_rule.action_set": "set the playbook run by alert rule",
"alert_rule.action_cleared": "removed the playbook from alert rule",
"maintenance_window.created": "scheduled maintenance",
"maintenance_window.deleted": "deleted maintenance window",
"maintenance_window.ended": "ended maintenance window",
"maintenance_window.cancelled": "cancelled maintenance window",
"ansible.run.started": "started",
"ansible.run.cancelled": "cancelled",
"ansible.host_run": "ran a playbook on host",
"ansible.schedule.created": "added schedule",
"ansible.schedule.updated": "changed schedule",
"ansible.schedule.enabled": "resumed schedule",
"ansible.schedule.disabled": "paused schedule",
"ansible.schedule.deleted": "deleted schedule",
"ansible.schedule.run_now": "ran schedule now",
"ansible.playbook.created": "added playbook",
"ansible.playbook.updated": "edited playbook",
"ansible.playbook.deleted": "deleted playbook",
"ansible.target.created": "added inventory target",
"ansible.target.updated": "changed inventory target",
"ansible.target.deleted": "deleted inventory target",
"ansible.group.created": "added inventory group",
"ansible.group.updated": "changed inventory group",
"ansible.group.deleted": "deleted inventory group",
"settings.saved": "saved settings",
"settings.notification.tested": "sent a test notification",
"plugin.enabled": "turned on plugin",
"plugin.disabled": "turned off plugin",
"plugin.install": "installed plugin",
"plugin.install_failed": "failed to install plugin",
"plugin.settings_removed": "removed the settings of plugin",
"api_token.create": "created AI access token",
"api_token.revoke": "revoked AI access token",
"docker.prune.started": "started a Docker cleanup on host",
}
# The filter's choices: an area, matched as a prefix of the action code.
AREAS = (("auth.", "Sign-ins"), ("host.", "Hosts"), ("monitor.", "Monitors"),
("alert_rule.", "Alert rules"), ("maintenance_window.", "Maintenance"),
("ansible.run", "Playbook runs"), ("ansible.", "Ansible (all)"),
("settings.", "Settings"), ("plugin.", "Plugins"), ("api_token.", "AI access"),
("docker.", "Docker"))
def action_text(action: str) -> str:
return ACTIONS.get(action, action)
# Detail keys a row does not need to repeat: the entity column already names
# and links the thing, and the scope's own wording is shown in its place.
_SKIP = {"rule_id", "monitor_id", "host_id", "window_id", "schedule_id", "scope_label"}
_LABELS = {"check": "dry run", "interval_seconds": "every", "interval_s": "every",
"bytes": "size", "renamed_from": "was", "token_id": "AI token", "vars": "variables",
"extra_vars": "variables", "secret_vars": "secret variables", "sent": "delivered",
"what": "removing", "address": "address", "carried": "history carried over"}
_METHODS = {"local": "password", "ldap": "LDAP", "oidc": "single sign-on"}
_SECTIONS = {"general": "General", "thresholds": "Thresholds", "notifications": "Notifications",
"ansible.managed_key": "Ansible: managed SSH key",
"ansible.credentials": "Ansible: credentials", "auth": "Authentication"}
def _plain(value) -> str:
if isinstance(value, bool):
return "yes" if value else "no"
if isinstance(value, (list, tuple)):
return ", ".join(_plain(v) for v in value) if value else "none"
if isinstance(value, dict):
return ", ".join(f"{str(k).replace('_', ' ')} {_plain(v)}" for k, v in value.items()) or "none"
return str(value)
def detail_facts(detail: dict, scopes: dict[str, str]) -> list[dict]:
"""An entry's detail as [{"label", "text", "url"}] a reader can take in:
yes/no for flags, lists joined, a run's scope in words (scope_labels),
a span as a span, and nothing the entity column already says."""
from steward.core.formatting import human_bytes, human_duration
facts = []
for key, value in (detail or {}).items():
if key in _SKIP or value is None:
continue
label, url = _LABELS.get(key, key.replace("_", " ")), None
if key == "scope":
text = scopes.get(value, value)
elif key in ("interval_seconds", "interval_s"):
text = human_duration(value)
elif key == "bytes":
text = human_bytes(value)
elif key == "method":
text = _METHODS.get(value, value)
elif key == "section":
text = _SECTIONS.get(value, value)
elif key == "token_id":
text = f"#{value}"
elif key == "run_id":
label, text, url = "run", "open", f"/ansible/runs/{value}"
else:
text = _plain(value)
facts.append({"label": label, "text": text, "url": url})
return facts