diff --git a/steward/alerts/routes.py b/steward/alerts/routes.py index b104e18..7d6f6e9 100644 --- a/steward/alerts/routes.py +++ b/steward/alerts/routes.py @@ -15,6 +15,16 @@ alerts_bp = Blueprint("alerts", __name__, url_prefix="/alerts") _OPERATORS = [(op.value, op.value) for op in AlertOperator] +def _form_condition(form) -> dict: + """The operator and threshold the form asks for. An up/down metric's form + sends the word it fires on (binary_when) instead: down is < 0.5, up is + >= 0.5, the midpoint of the 1.0 / 0.0 such a metric records.""" + when = form.get("binary_when") + if when in ("down", "up") and form.get("metric_name") in ("is_up", "is_healthy"): + return {"operator": "<" if when == "down" else ">=", "threshold": "0.5"} + return {"operator": form.get("operator", ""), "threshold": form.get("threshold")} + + def _rule_form(form) -> dict: """The rule form's fields, as alert_svc.rule_fields takes them.""" return { @@ -22,8 +32,7 @@ def _rule_form(form) -> dict: "source_module": form.get("source_module", ""), "resource_name": form.get("resource_name", ""), "metric_name": form.get("metric_name", ""), - "operator": form.get("operator", ""), - "threshold": form.get("threshold"), + **_form_condition(form), "consecutive_failures_required": form.get("consecutive_failures_required"), "consecutive_ok_required": form.get("consecutive_ok_required"), "notify_no_data": "notify_no_data" in form, @@ -121,6 +130,7 @@ async def active_fragment(): def _history_row(event, rule) -> dict: """One transition as the history page shows it.""" from steward.core.alerts import MUTED, RELEASED + from steward.core.formatting import metric_label, metric_value notes, muted_by, released = [], None, False for part in (event.note or "").split("; "): if part.startswith(MUTED): @@ -129,11 +139,17 @@ def _history_row(event, rule) -> dict: released = True elif part: notes.append(part) - value = "no data" if event.metric_value is None else f"{event.metric_value:g}" + metric = rule.metric_name + if event.metric_value is None: + value = f"No {metric_label(metric)} reading" + elif metric in ("is_up", "is_healthy"): + value = metric_value(metric, event.metric_value).capitalize() + else: + value = f"{metric_label(metric)} {metric_value(metric, event.metric_value)}" return { "at": event.transitioned_at, "rule": rule, "from_state": event.from_state, "to_state": event.to_state, - "value": value, "condition": f"{rule.operator.value} {rule.threshold:g}", + "value": value, "condition": alert_svc.rule_condition(rule), "notes": notes, "muted_by": muted_by, "released": released, "deliveries": [(label, sent, error) for label, sent, error in ( ("email", event.email_sent, event.email_error), @@ -197,6 +213,7 @@ async def new_rule(): initial_source=first_source, resources=resources, metrics=alert_svc.metric_catalog().get(first_source, []), + catalog=alert_svc.metric_catalog(), ansible_sources=_ansible_source_names(), ) @@ -218,6 +235,7 @@ async def edit_rule(rule_id: str): 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(), ) diff --git a/steward/alerts/service.py b/steward/alerts/service.py index 82dd997..5124932 100644 --- a/steward/alerts/service.py +++ b/steward/alerts/service.py @@ -131,17 +131,32 @@ async def resource_url(db, rule: AlertRule) -> str | None: _BINARY = ("is_up", "is_healthy") +def rule_condition(rule: AlertRule) -> str: + """What a rule fires on, in words: "CPU > 90%", "Fullest disk >= 85%", + or "is down" for an up/down metric rather than "is_up < 0.5".""" + from steward.core.alerts import _is_breached + from steward.core.formatting import metric_label, metric_value + metric = rule.metric_name + if metric in _BINARY: + fires_on = [metric_value(metric, v) for v in (1.0, 0.0) + if _is_breached(v, rule.operator, rule.threshold)] + if len(fires_on) == 1: + return f"is {fires_on[0]}" + return f"{metric_label(metric)} {rule.operator.value} {metric_value(metric, rule.threshold)}" + + def alert_sentence(rule: AlertRule, label: str, value: float | None, since: datetime | None, now: datetime) -> dict: """An alert as one sentence around its resource: {"lead", "resource", - "tail"}, read lead + resource + tail, e.g. "cpu_pct on " + "web-1" + + "tail"}, read lead + resource + tail, e.g. "CPU on " + "web-1" + " is 94% (> 90%), firing for 12m". label is alert_label(state); since is when that label began (no_data_since for no_data).""" - from steward.core.formatting import human_duration, metric_value + from steward.core.formatting import human_duration, metric_label, metric_value metric = rule.metric_name + said = metric_label(metric) held = human_duration((now - since).total_seconds()) if since else None if label == "no_data": - return {"lead": f"No {metric} from ", "resource": rule.resource_name, + return {"lead": f"No {said} reading from ", "resource": rule.resource_name, "tail": f" for {held}" if held else ""} # An acknowledged alert is still breaching: say for how long it has been, # not how long ago someone acknowledged it (the page says that apart). @@ -155,7 +170,7 @@ def alert_sentence(rule: AlertRule, label: str, value: float | None, else: reading = f" is {metric_value(metric, value)}" condition = f" ({rule.operator.value} {metric_value(metric, rule.threshold)})" - return {"lead": f"{metric} on ", "resource": rule.resource_name, + return {"lead": f"{said} on ", "resource": rule.resource_name, "tail": f"{reading}{condition}{for_how_long}"} diff --git a/steward/app.py b/steward/app.py index 9ca2217..9ed8735 100644 --- a/steward/app.py +++ b/steward/app.py @@ -145,6 +145,11 @@ def create_app( # The status and layout macros in _macros.html read these. app.jinja_env.globals["status_info"] = _fmt.status_info app.jinja_env.globals["required"] = _fmt.required + # A metric and its source in words, a reading in its unit (alerts pages). + for _name in ("metric_label", "metric_about", "metric_unit", "metric_value", "source_label"): + app.jinja_env.globals[_name] = getattr(_fmt, _name) + from .alerts.service import rule_condition as _rule_condition + app.jinja_env.globals["rule_condition"] = _rule_condition from .models.monitors import MONITOR_TYPE_LABELS as _type_labels app.jinja_env.filters["monitor_type"] = lambda t: _type_labels.get(t, str(t).upper()) diff --git a/steward/audit/routes.py b/steward/audit/routes.py index 6516fd4..e363345 100644 --- a/steward/audit/routes.py +++ b/steward/audit/routes.py @@ -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) diff --git a/steward/audit/service.py b/steward/audit/service.py index 40d1cb3..66cf5bc 100644 --- a/steward/audit/service.py +++ b/steward/audit/service.py @@ -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 diff --git a/steward/core/formatting.py b/steward/core/formatting.py index 0190aef..41747fe 100644 --- a/steward/core/formatting.py +++ b/steward/core/formatting.py @@ -184,3 +184,85 @@ def metric_value(name: str, value: Any) -> str: if n.endswith("_days"): return f"{value:.0f} days" return f"{value:g}" + + +# What each alertable metric is called on a page, and what it measures. The +# key is the name a source records; a name not listed reads as itself with +# its underscores spaced (SNMP's OID labels are the operator's own words). +METRICS: dict[str, tuple[str, str]] = { + "is_up": ("Up or down", "whether the check passed"), + "is_healthy": ("Health check", "the container's own HEALTHCHECK result"), + "response_ms": ("Response time", "how long the check took"), + "open_fds": ("Open files", "files Steward itself has open"), + "open_fds_pct": ("Open files (% of limit)", "files Steward has open, against its limit"), + "request_rate": ("Requests per second", "requests through this router"), + "error_rate_4xx_pct": ("4xx responses", "share of responses that were client errors"), + "error_rate_5xx_pct": ("5xx responses", "share of responses that were server errors"), + "latency_p50_ms": ("Response time (median)", "half of requests were faster than this"), + "latency_p95_ms": ("Response time (95th percentile)", "95% of requests were faster than this"), + "latency_p99_ms": ("Response time (99th percentile)", "99% of requests were faster than this"), + "response_bytes_rate": ("Response throughput", "bytes per second sent back"), + "cert_expiry_days": ("Certificate expires in", "days until the TLS certificate expires"), + "latency_ms": ("WAN latency", "round trip from the gateway"), + "total_clients": ("Connected clients", "devices on the network"), + "cpu_pct": ("CPU", "CPU in use; for a container, 100% is one core"), + "mem_pct": ("Memory (% of limit)", "memory in use, against the container's limit or the host's RAM"), + "restart_count": ("Restarts", "times the container has restarted, in total"), + "mem_used_pct": ("Memory used", "memory in use"), + "mem_available_bytes": ("Memory available", "memory free for new work"), + "swap_used_bytes": ("Swap used", "memory paged out to disk"), + "disk_used_pct_worst": ("Fullest disk", "the most-full mount on the host"), + "disk_used_pct": ("Disk used", "one mount (resource host:mount)"), + "load_1m": ("Load (1 min)", "runnable processes, averaged over a minute"), + "load_5m": ("Load (5 min)", "runnable processes, averaged over 5 minutes"), + "load_15m": ("Load (15 min)", "runnable processes, averaged over 15 minutes"), + "uptime_secs": ("Uptime", "time since the host booted"), + "seconds_since_seen": ("Time since the agent reported", "over 3 minutes means the agent is offline"), +} + +# Where a rule's metric comes from, as the picker and the rules list say it. +SOURCES: dict[str, str] = { + "icmp": "Ping monitors", "tcp": "TCP monitors", "dns": "DNS monitors", + "http": "HTTP monitors", "steward": "Steward itself", "host_agent": "Host agent", + "docker": "Docker containers", "traefik": "Traefik routers", "unifi": "UniFi", + "snmp": "SNMP devices", +} + + +def metric_label(name: str) -> str: + entry = METRICS.get(name or "") + return entry[0] if entry else (name or "").replace("_", " ") + + +def metric_about(name: str) -> str: + entry = METRICS.get(name or "") + return entry[1] if entry else "" + + +def source_label(source: str) -> str: + return SOURCES.get(source or "", (source or "").replace("_", " ")) + + +def metric_unit(name: str) -> str: + """The unit a threshold for this metric is written in, from the same + suffixes metric_value reads; "" for a count or a plain number.""" + n = name or "" + if n in ("is_up", "is_healthy"): + return "" + if n.endswith("_pct") or "_pct_" in n: + return "%" + if n.endswith("_ms"): + return "ms" + if n.endswith("_bytes"): + return "bytes" + if n.endswith(("_bps", "bytes_rate")): + return "bytes/s" + if n.endswith("_secs") or n.startswith("seconds_"): + return "seconds" + if n.endswith("_c"): + return "°C" + if n.endswith("_days"): + return "days" + if n == "request_rate": + return "req/s" + return "" diff --git a/steward/templates/alerts/_history_page.html b/steward/templates/alerts/_history_page.html index b46349b..5923c27 100644 --- a/steward/templates/alerts/_history_page.html +++ b/steward/templates/alerts/_history_page.html @@ -3,12 +3,10 @@ {% from "_macros.html" import status_badge, scroll_more, scroll_end %} {% for ev in events %} - - {{ ev.at.strftime("%Y-%m-%d %H:%M:%S") }} - + {{ ev.at|ago }} {{ ev.rule.name }} -
{{ ev.rule.source_module }}/{{ ev.rule.resource_name }}
+
{{ source_label(ev.rule.source_module) }} · {{ ev.rule.resource_name }}
{{ status_badge(ev.from_state) }} @@ -18,9 +16,9 @@
{{ n }}
{% endfor %} - - {{ ev.rule.metric_name }} = {{ ev.value }} -
rule: {{ ev.condition }}
+ + {{ ev.value }} +
fires when {{ ev.condition }}
{% if ev.muted_by %} diff --git a/steward/templates/alerts/_history_rows.html b/steward/templates/alerts/_history_rows.html index 12761b1..548a436 100644 --- a/steward/templates/alerts/_history_rows.html +++ b/steward/templates/alerts/_history_rows.html @@ -6,10 +6,10 @@ - + - + diff --git a/steward/templates/alerts/_rule_fields.html b/steward/templates/alerts/_rule_fields.html index 11d506f..f3f3a44 100644 --- a/steward/templates/alerts/_rule_fields.html +++ b/steward/templates/alerts/_rule_fields.html @@ -1,6 +1,7 @@ -{# alerts/_rule_fields.html — HTMX partial: resource + metric selects for a given source_module #} +{# 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. #}
- + {% if resources %} {% else %}

- Resource names populate from live metric data. Start the scheduler and try again. + The list fills from readings this source has recorded. Come back once it has reported.

{% endif %}
- - {% if not current_metric %}{% endif %} {% for m in metrics %} - + {% endfor %} + {% if current_metric and metric_about(current_metric) %} +

{{ metric_about(current_metric) }}

+ {% else %}{% endif %}
diff --git a/steward/templates/alerts/list.html b/steward/templates/alerts/list.html index 61aa742..0b14b82 100644 --- a/steward/templates/alerts/list.html +++ b/steward/templates/alerts/list.html @@ -44,10 +44,8 @@ {%- else %}quiet{% endif -%} {% endfor %} @@ -76,6 +79,6 @@ {% endif %} {% else %} -
{{ empty_state("No maintenance windows yet.", "New window", "/alerts/maintenance/new") }}
+
{% if can_edit %}{{ empty_state("No maintenance windows yet.", "New window", "/alerts/maintenance/new") }}{% else %}{{ empty_state("No maintenance windows yet.") }}{% endif %}
{% endif %} {% endblock %} diff --git a/steward/templates/alerts/rules_form.html b/steward/templates/alerts/rules_form.html index 89e6391..0403e39 100644 --- a/steward/templates/alerts/rules_form.html +++ b/steward/templates/alerts/rules_form.html @@ -24,68 +24,49 @@ hx-target="#rule-fields" hx-include="[name='source_module']"> {% for src in source_modules %} - + {% endfor %} {# ── Dynamic resource + metric ────────────────────────────────────────── #}
- {# Inline the same partial logic so the page is fully usable without JS #} -
- - {% if resources %} - - {% else %} - -

- Resource names populate from live metric data. Start the scheduler and try again. -

- {% endif %} -
- -
- - -
+ {% with current_resource = rule.resource_name if rule else "", current_metric = rule.metric_name if rule else "" %} + {% include "alerts/_rule_fields.html" %} + {% endwith %}
- {# ── Condition ───────────────────────────────────────────────────────── #} -
+ {# ── Condition: a threshold in the metric's unit, or for an up/down metric + the word it fires on (sent as binary_when; the route turns it into + < 0.5 or >= 0.5). The script swaps the two as the metric changes. #} + {% set m0 = rule.metric_name if rule else "" %} + {% set binary = m0 in ("is_up", "is_healthy") %} + {% set fires_down = not rule or rule.operator.value in ("<", "<=") %} +
+ + +
+
- - {% for val, label in operators %} - + {% endfor %}
- - + +
- +
@@ -114,44 +95,16 @@ {# ── Metric reference ────────────────────────────────────────────────── #}
- Metric reference + What each metric measures
- {% set descs = { - "icmp": [("is_up", "1.0 = reachable, 0.0 = unreachable"), - ("response_ms", "round-trip latency in milliseconds")], - "tcp": [("is_up", "1.0 = port open, 0.0 = unreachable"), - ("response_ms", "TCP connect time in milliseconds")], - "dns": [("is_up", "1.0 = resolved (and matches expected IP), 0.0 = failed"), - ("response_ms", "resolution time in milliseconds")], - "traefik": [("request_rate", "requests/sec for this router"), - ("error_rate_4xx_pct", "% of responses that are 4xx"), - ("error_rate_5xx_pct", "% of responses that are 5xx"), - ("latency_p50_ms", "median response time ms"), - ("latency_p95_ms", "p95 response time ms"), - ("latency_p99_ms", "p99 response time ms"), - ("response_bytes_rate", "bytes/sec of responses"), - ("cert_expiry_days", "days until TLS cert expires")], - "unifi": [("is_up", "1.0 = WAN up, 0.0 = down"), - ("latency_ms", "WAN latency in ms"), - ("total_clients", "number of connected clients")], - "docker": [("cpu_pct", "container CPU usage %"), - ("mem_pct", "container memory usage %")], - "http": [("is_up", "1.0 = check passed, 0.0 = failed"), - ("response_ms", "HTTP response time in ms")], - "host_agent": [("seconds_since_seen", "seconds since the agent last reported; > 180 = offline"), - ("cpu_pct", "host CPU usage %"), - ("mem_used_pct", "host memory usage %"), - ("disk_used_pct_worst", "fullest disk, % used"), - ("disk_used_pct", "one mount, % used (resource host:mount)")], - } %} - {% for src, pairs in descs.items() %} + {% for src, names in catalog.items() if names %}
-
{{ src }}
- {% for metric, desc in pairs %} -
- {{ metric }} - {{ desc }} +
{{ source_label(src) }}
+ {% for m in names %} +
+ {{ metric_label(m) }}{% if metric_unit(m) %} ({{ metric_unit(m) }}){% endif %} + {{ metric_about(m) }}
{% endfor %}
@@ -210,19 +163,29 @@
{% endif %} - {% if not rule %} - {% endif %}
diff --git a/steward/templates/ansible/schedules.html b/steward/templates/ansible/schedules.html index 633dfd2..8b7958c 100644 --- a/steward/templates/ansible/schedules.html +++ b/steward/templates/ansible/schedules.html @@ -5,10 +5,11 @@ {% macro fmt_interval(secs) %} {%- set m = {300: "every 5 min", 3600: "hourly", 21600: "every 6h", 43200: "every 12h", 86400: "daily", 604800: "weekly"} -%} -{{ m.get(secs, secs ~ "s") }} +{{ m.get(secs) or ("every " ~ (secs|duration)) }} {% endmacro %} {% block content %} +{% set can_edit = session.user_role in ('operator', 'admin') %}

Ansible Schedules

@@ -19,7 +20,7 @@ {# ── Existing schedules ───────────────────────────────────────────────────── #} {% if not rows %} -
No schedules yet. Create one below to run a playbook on a recurring interval.
+
No schedules yet.{% if can_edit %} Create one below to run a playbook on a recurring interval.{% endif %}
{% else %}
When (UTC)When Rule ChangeValueReading Notified Action
- {{ rule.source_module }}/{{ rule.resource_name }} - - :{{ rule.metric_name }} {{ rule.operator.value }} {{ rule.threshold }} - + {{ rule.resource_name }} {{ rule_condition(rule) }} +
{{ source_label(rule.source_module) }}
{{ rule.consecutive_failures_required }} / {{ rule.consecutive_ok_required }} diff --git a/steward/templates/alerts/maintenance.html b/steward/templates/alerts/maintenance.html index ac767b9..222c44a 100644 --- a/steward/templates/alerts/maintenance.html +++ b/steward/templates/alerts/maintenance.html @@ -3,8 +3,9 @@ {% block title %}Maintenance Windows — Steward{% endblock %} {% block breadcrumb %}{{ crumbs([("Alerts", "/alerts/"), ("Maintenance", "")]) }}{% endblock %} {% block content %} +{% set can_edit = session.user_role in ('operator', 'admin') %} {% call page_header("Maintenance windows", "During an active window, alerts in its scope still change state and show on the Alerts page, but send no notifications and run no Ansible actions. Each event a window mutes says so in the alert's history. An alert that is still firing when the window ends notifies then, and runs its action.") %} - New window + {% if can_edit %}New window{% endif %} {% endcall %} {% macro window_table(rows, kind) %} @@ -28,9 +29,9 @@ {% if w.scope_source is none %} All alerts {% elif w.scope_resource is none %} - {{ w.scope_source }}/* + Every alert on {{ source_label(w.scope_source) }} {% else %} - {{ w.scope_source }}/{{ w.scope_resource }} + {{ w.scope_resource }} {{ source_label(w.scope_source) }} {% endif %} @@ -45,10 +46,12 @@ {% else %}{{ status_badge('inactive', 'Expired') }}
ended {{ w.end_at|ago }}
{% endif %}
+ {% if session.user_role in ('operator', 'admin') %}
{{ csrf_field() }}
+ {% endif %}
@@ -46,7 +47,7 @@ {# With a run beside it, the last firing was skipped because that run was still going (#5158); without one, the firing couldn't start a run. #}
- {{ s.last_error }} · {{ s.last_run_at.strftime("%m-%d %H:%M") }} + {{ s.last_error }} · {{ s.last_run_at|ago }}
{% elif not row.last_run %} never @@ -54,10 +55,11 @@ {% endfor %} @@ -77,7 +80,8 @@ {% endif %} -{# ── Create / edit form ───────────────────────────────────────────────────── #} +{# ── Create / edit form (operators and admins) ───────────────────────────── #} +{% if can_edit %} {% set p = (editing.params or {}) if editing else {} %} {% set saved_vars = p.get('extra_vars_map') or {} %} {% set declared_names = (edit_variables or []) | map(attribute='name') | list %} @@ -174,4 +178,5 @@ +{% endif %} {% endblock %} diff --git a/steward/templates/audit/_rows.html b/steward/templates/audit/_rows.html index b0e78f5..5761813 100644 --- a/steward/templates/audit/_rows.html +++ b/steward/templates/audit/_rows.html @@ -3,11 +3,10 @@ {% from "_macros.html" import scroll_more, scroll_end %} {% for item in events %} {% set e = item.event %} - {% set d = item.detail %} - + {% endfor %} diff --git a/steward/templates/audit/list.html b/steward/templates/audit/list.html index 45fd6a6..0731fad 100644 --- a/steward/templates/audit/list.html +++ b/steward/templates/audit/list.html @@ -4,13 +4,13 @@ {% block content %} {% call page_header("Audit log", "Who changed what, newest first.") %} - - - {% if action_filter %} - Clear - {% endif %} + + {% endcall %} @@ -21,8 +21,8 @@ - - + + diff --git a/steward/templates/status/rows.html b/steward/templates/status/rows.html index 63921f0..9011149 100644 --- a/steward/templates/status/rows.html +++ b/steward/templates/status/rows.html @@ -25,7 +25,7 @@
  • {{ status_badge('no_data' if state.no_data_since and state.state.value == 'inactive' else state.state) }} {{ rule.name }} - {{ rule.resource_name }} · {{ rule.metric_name }} {{ rule.operator.value }} {{ rule.threshold }} · since {{ (state.no_data_since if state.state.value == 'inactive' else state.last_transition_at)|ago }} + {{ rule.resource_name }} {{ rule_condition(rule) }} · since {{ (state.no_data_since if state.state.value == 'inactive' else state.last_transition_at)|ago }}
  • {% endfor %} diff --git a/tests/core/test_alerts_page.py b/tests/core/test_alerts_page.py index 869b586..2f00ad0 100644 --- a/tests/core/test_alerts_page.py +++ b/tests/core/test_alerts_page.py @@ -25,12 +25,12 @@ def _said(says): @pytest.mark.parametrize("op, threshold, value, reads", [ - (AlertOperator.gt, 90.0, 94.0, "cpu_pct on web-1 is 94% (> 90%), firing for 12m"), - (AlertOperator.gte, 90.0, 90.0, "cpu_pct on web-1 is 90% (>= 90%), firing for 12m"), - (AlertOperator.lt, 5.0, 2.5, "cpu_pct on web-1 is 2.5% (< 5.0%), firing for 12m"), - (AlertOperator.lte, 5.0, 5.0, "cpu_pct on web-1 is 5.0% (<= 5.0%), firing for 12m"), - (AlertOperator.eq, 100.0, 100.0, "cpu_pct on web-1 is 100% (== 100%), firing for 12m"), - (AlertOperator.ne, 0.0, 3.0, "cpu_pct on web-1 is 3.0% (!= 0.0%), firing for 12m"), + (AlertOperator.gt, 90.0, 94.0, "CPU on web-1 is 94% (> 90%), firing for 12m"), + (AlertOperator.gte, 90.0, 90.0, "CPU on web-1 is 90% (>= 90%), firing for 12m"), + (AlertOperator.lt, 5.0, 2.5, "CPU on web-1 is 2.5% (< 5.0%), firing for 12m"), + (AlertOperator.lte, 5.0, 5.0, "CPU on web-1 is 5.0% (<= 5.0%), firing for 12m"), + (AlertOperator.eq, 100.0, 100.0, "CPU on web-1 is 100% (== 100%), firing for 12m"), + (AlertOperator.ne, 0.0, 3.0, "CPU on web-1 is 3.0% (!= 0.0%), firing for 12m"), ]) def test_a_firing_alert_reads_as_one_sentence(op, threshold, value, reads): now = datetime.now(timezone.utc) @@ -44,7 +44,7 @@ def test_units_follow_the_metric_name(): now = datetime.now(timezone.utc) says = alert_sentence(_rule("response_ms", threshold=200.0, resource="api"), "pending", 431.0, now - timedelta(seconds=45), now) - assert _said(says) == "response_ms on api is 431 ms (> 200 ms), pending for 45s" + assert _said(says) == "Response time on api is 431 ms (> 200 ms), pending for 45s" def test_an_up_or_down_metric_says_the_word(): @@ -58,13 +58,13 @@ def test_an_up_or_down_metric_says_the_word(): def test_no_reading_yet_is_said_not_shown_as_a_number(): now = datetime.now(timezone.utc) says = alert_sentence(_rule(), "firing", None, now - timedelta(minutes=3), now) - assert _said(says) == "cpu_pct on web-1 has no reading yet (> 90%), firing for 3m" + assert _said(says) == "CPU on web-1 has no reading yet (> 90%), firing for 3m" def test_a_silent_series_says_how_long_it_has_been_quiet(): now = datetime.now(timezone.utc) says = alert_sentence(_rule(), "no_data", 50.0, now - timedelta(minutes=20), now) - assert _said(says) == "No cpu_pct from web-1 for 20m" + assert _said(says) == "No CPU reading from web-1 for 20m" def _row(rule, label, url, state=AlertStateEnum.firing): @@ -82,7 +82,7 @@ async def test_an_operator_sees_the_sentence_linked_and_can_acknowledge(app): page = await render_template("alerts/list.html", active=[_row(rule, "firing", "/hosts/h1")], rules=[rule], operators=[]) - assert 'cpu_pct on web-1 is 94% (> 90%), firing for 12m' in page + assert 'CPU on web-1 is 94% (> 90%), firing for 12m' in page assert 'Firing' in page assert 'action="/alerts/r1/acknowledge"' in page assert 'href="/alerts/rules/new"' in page and 'href="/alerts/rules/r1/edit"' in page @@ -98,7 +98,7 @@ async def test_a_viewer_reads_but_gets_no_actions(app): page = await render_template("alerts/list.html", active=[_row(rule, "firing", None)], rules=[rule], operators=[]) - assert "cpu_pct on web-1" in page # nothing to link to: no bare href + assert "CPU on web-1" in page # nothing to link to: no bare href for action in ("/acknowledge", "/alerts/rules/new", "/edit", "/toggle", "/delete"): assert action not in page, action assert 'href="/alerts/history?rule=r1"' in page @@ -165,3 +165,43 @@ async def test_rules_say_what_each_is_doing_now(app): assert 'title="Within its threshold">quiet' in rules assert 'off' in rules assert 'hx-get="/alerts/active"' in page + + +def test_a_rule_condition_reads_in_words(): + """#5212: "is down", not "is_up < 0.5"; a threshold in its unit.""" + from steward.alerts.service import rule_condition + assert rule_condition(_rule("is_up", AlertOperator.lt, 0.5)) == "is down" + assert rule_condition(_rule("is_healthy", AlertOperator.gte, 0.5)) == "is healthy" + assert rule_condition(_rule("disk_used_pct_worst", AlertOperator.gte, 85.0)) == "Fullest disk >= 85%" + assert rule_condition(_rule("seconds_since_seen", AlertOperator.gt, 180.0)) == \ + "Time since the agent reported > 3m" + assert rule_condition(_rule("hrProcessorLoad", AlertOperator.gt, 3.0)) == "hrProcessorLoad > 3" + + +def test_the_form_turns_up_or_down_into_a_condition(): + from steward.alerts.routes import _form_condition + assert _form_condition({"metric_name": "is_up", "binary_when": "down"}) == \ + {"operator": "<", "threshold": "0.5"} + assert _form_condition({"metric_name": "is_healthy", "binary_when": "up"}) == \ + {"operator": ">=", "threshold": "0.5"} + # Any other metric keeps what was typed, whatever binary_when says. + assert _form_condition({"metric_name": "cpu_pct", "binary_when": "down", "operator": ">", + "threshold": "90"}) == {"operator": ">", "threshold": "90"} + + +async def test_the_rule_form_speaks_in_names_and_units(app): + from steward.alerts.routes import _OPERATORS + rule = _rule("is_up", AlertOperator.lt, 0.5, "nas") + rule.source_module, rule.ansible_action, rule.notify_no_data = "http", None, True + async with app.test_request_context("/alerts/rules/r1/edit"): + 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=[], + catalog={"http": ["is_up", "response_ms"], "snmp": []}) + assert ">HTTP monitors" in page and ">Host agent" in page + assert ">Up or down" in page and 'data-unit="ms"' in page + assert '' in page diff --git a/tests/core/test_status_page.py b/tests/core/test_status_page.py index 5d8d4ff..8071dfe 100644 --- a/tests/core/test_status_page.py +++ b/tests/core/test_status_page.py @@ -55,7 +55,7 @@ async def test_the_problems_come_first_and_say_since_when(app): problem_card = page[attention:page.index("")] assert "shop-api" in problem_card and "nas" not in problem_card assert "down since 12m ago" in problem_card - assert "CPU high" in problem_card and "cpu_pct > 90" in problem_card and ">4m ago" in problem_card + assert "CPU high" in problem_card and "web01 CPU > 90%" in problem_card and ">4m ago" in problem_card assert 'Firing' in problem_card # Uptime and TLS coloured by Settings → Thresholds, not by literals. assert '93.00%' in page and '100.00%' in page diff --git a/tests/core/test_viewer_actions.py b/tests/core/test_viewer_actions.py new file mode 100644 index 0000000..5d7f92e --- /dev/null +++ b/tests/core/test_viewer_actions.py @@ -0,0 +1,43 @@ +"""A viewer is not offered what they cannot do (#5212): Schedules and +Maintenance draw no Run now / Pause / Edit / Delete / New for them, and +say intervals and scopes in words.""" +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace as NS + +from quart import render_template, session + +NOW = datetime.now(timezone.utc) + + +def _window(name, source, resource): + return NS(id=f"w-{name}", name=name, scope_source=source, scope_resource=resource, + start_at=NOW - timedelta(hours=1), end_at=NOW + timedelta(hours=1)) + + +async def _maintenance(app, role): + windows = [_window("patching", "host_agent", None), _window("nas", "http", "nas web")] + async with app.test_request_context("/alerts/maintenance"): + session["user_role"] = role + return await render_template("alerts/maintenance.html", windows=windows, active=windows, + upcoming=[], expired=[]) + + +async def test_maintenance_offers_changes_only_to_those_who_can_make_them(app): + viewer, operator = await _maintenance(app, "viewer"), await _maintenance(app, "operator") + assert "New window" not in viewer and "/delete" not in viewer + assert "New window" in operator and operator.count("/delete") == 2 + assert "Every alert on Host agent" in viewer and "host_agent/*" not in viewer + assert "nas web" in viewer and "HTTP monitors" in viewer + + +async def test_schedules_show_a_viewer_the_list_without_its_controls(app): + s = NS(id="s1", name="nightly", enabled=True, source_name="local", playbook_path="prune.yml", + inventory_scope="steward:all", interval_seconds=7200, last_error=None, last_run_at=None) + rows = [{"s": s, "last_run": None, "next_run": NOW + timedelta(hours=2), "scope_label": "all inventory targets"}] + async with app.test_request_context("/ansible/schedules"): + session["user_role"] = "viewer" + page = await render_template("ansible/schedules.html", rows=rows, editing=None) + assert "nightly" in page and "every 2h" in page and "7200s" not in page + assert ">in 2h" in page or ">in 1h 59m" in page + for control in ("Run now", "Pause", "?edit=", "/delete", "New schedule"): + assert control not in page, control diff --git a/tests/integration/test_alert_history_page.py b/tests/integration/test_alert_history_page.py index bdd85da..b4b2891 100644 --- a/tests/integration/test_alert_history_page.py +++ b/tests/integration/test_alert_history_page.py @@ -68,9 +68,9 @@ def test_a_viewer_reads_the_alert_history(app): page = await resp.get_data(as_text=True) assert "Alert History" in page and 'href="/alerts/"' in page # crumbs back to Alerts assert f"hot {tag}" in page and f"cold-note-{tag}" in page and f"old-{tag}" not in page - assert "cpu_pct = 97.5" in page and "rule: > 90" in page + assert "CPU 98%" in page and "fires when CPU > 90%" in page assert "email failed" in page and f"mx-{tag}: timed out" in page and "webhook sent" in page - assert f"muted · patch-{tag}" in page and "cpu_pct = no data" in page + assert f"muted · patch-{tag}" in page and "No CPU reading" in page resp = await client.get(f"/alerts/history?range=24h&rule={hot_id}", headers={"HX-Request": "true"}) rows = await resp.get_data(as_text=True) @@ -84,7 +84,7 @@ def test_a_viewer_reads_the_alert_history(app): "range": "24h", "rule": hot_id, "before": cursor(now - timedelta(minutes=3), "")}) older = await resp.get_data(as_text=True) assert resp.status_code == 200 and "
    {% if not s.enabled %}paused - {% elif row.next_run %}{{ row.next_run.strftime("%m-%d %H:%M") }} + {% elif row.next_run %}{{ row.next_run|ago }} {% else %}due now{% endif %} + {% if can_edit %}
    {{ csrf_field() }}
    @@ -69,6 +71,7 @@ data-confirm="Delete schedule {{ s.name }}?">{{ csrf_field() }} + {% endif %}
    {{ e.timestamp|ago }} {{ e.username }}{{ e.action }}{{ item.action or e.action }} {% if item.entity %} {{ e.entity_type|replace('_', ' ') }} @@ -16,13 +15,11 @@ {% elif e.entity_type %}{{ e.entity_type|replace('_', ' ') }}{% endif %} - {% if d %} - {% for k, v in d.items() %} - {{ k }}: - {{ v }} + {% for f in item.facts %} + {{ f.label }} + {% if f.url %}{{ f.text }}{% else %}{{ f.text }}{% endif %} {% if not loop.last %}  ·  {% endif %} {% endfor %} - {% endif %}
    When UserActionEntityDidWhat Detail