feat(ui): audit, alert rules and the rule form say things in words (#5212)
#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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user