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 %}
| When (UTC) | +When | Rule | Change | -Value | +Reading | 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') %} + {% endif %} | {% endfor %} @@ -76,6 +79,6 @@ {% endif %} {% else %} -
|---|
| {% 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 %} @@ -69,6 +71,7 @@ data-confirm="Delete schedule {{ s.name }}?">{{ csrf_field() }} + {% endif %} | {% 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 %}|||||
| {{ 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 | User | -Action | -Entity | +Did | +What | Detail |
|---|