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
+22 -4
View File
@@ -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(),
)
+19 -4
View File
@@ -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}"}
+5
View File
@@ -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())
+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
+82
View File
@@ -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 ""
+5 -7
View File
@@ -3,12 +3,10 @@
{% from "_macros.html" import status_badge, scroll_more, scroll_end %}
{% for ev in events %}
<tr>
<td style="font-family:ui-monospace,monospace;font-size:0.82rem;white-space:nowrap;">
{{ ev.at.strftime("%Y-%m-%d %H:%M:%S") }}
</td>
<td style="font-size:0.82rem;white-space:nowrap;">{{ ev.at|ago }}</td>
<td>
<a href="/alerts/history?rule={{ ev.rule.id }}&range={{ current_range }}">{{ ev.rule.name }}</a>
<div style="font-size:0.78rem;color:var(--text-dim);">{{ ev.rule.source_module }}/{{ ev.rule.resource_name }}</div>
<div style="font-size:0.78rem;color:var(--text-dim);">{{ source_label(ev.rule.source_module) }} · {{ ev.rule.resource_name }}</div>
</td>
<td style="white-space:nowrap;">
{{ status_badge(ev.from_state) }}
@@ -18,9 +16,9 @@
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;white-space:normal;">{{ n }}</div>
{% endfor %}
</td>
<td style="font-family:ui-monospace,monospace;font-size:0.85rem;white-space:nowrap;">
{{ ev.rule.metric_name }} = {{ ev.value }}
<div style="font-size:0.75rem;color:var(--text-dim);">rule: {{ ev.condition }}</div>
<td style="font-size:0.85rem;white-space:nowrap;">
{{ ev.value }}
<div style="font-size:0.75rem;color:var(--text-dim);">fires when {{ ev.condition }}</div>
</td>
<td style="font-size:0.82rem;">
{% if ev.muted_by %}
+2 -2
View File
@@ -6,10 +6,10 @@
<table class="table">
<thead>
<tr>
<th>When (UTC)</th>
<th>When</th>
<th>Rule</th>
<th>Change</th>
<th>Value</th>
<th>Reading</th>
<th>Notified</th>
<th>Action</th>
</tr>
+12 -7
View File
@@ -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. #}
<div class="form-group">
<label>Resource Name</label>
<label>Resource</label>
{% if resources %}
<select name="resource_name" required>
{% if not current_resource %}<option value="" disabled selected>— pick a resource —</option>{% endif %}
@@ -10,20 +11,24 @@
</select>
{% else %}
<select name="resource_name" required disabled>
<option value="">No data yet — run monitors first</option>
<option value="">Nothing has reported from this source yet</option>
</select>
<p class="field-hint">
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.
</p>
{% endif %}
</div>
<div class="form-group">
<label>Metric Name</label>
<select name="metric_name" required>
<label>Metric</label>
<select name="metric_name" required data-metric-picker>
{% if not current_metric %}<option value="" disabled selected>— pick a metric —</option>{% endif %}
{% for m in metrics %}
<option value="{{ m }}" {% if m == current_metric %}selected{% endif %}>{{ m }}</option>
<option value="{{ m }}" data-unit="{{ metric_unit(m) }}" data-about="{{ metric_about(m) }}" {% if m in ("is_up", "is_healthy") %}data-binary{% endif %}
{% if m == current_metric %}selected{% endif %}>{{ metric_label(m) }}</option>
{% endfor %}
</select>
{% if current_metric and metric_about(current_metric) %}
<p class="field-hint" data-metric-about>{{ metric_about(current_metric) }}</p>
{% else %}<p class="field-hint" data-metric-about hidden></p>{% endif %}
</div>
+2 -4
View File
@@ -44,10 +44,8 @@
{%- else %}<span class="tone-ok" style="font-size:0.8rem;" title="Within its threshold">quiet</span>{% endif -%}
</td>
<td style="color:var(--text-muted);font-size:0.85rem;">
<span style="color:var(--text-dim);">{{ rule.source_module }}/</span>{{ rule.resource_name }}
<span style="font-family:ui-monospace,monospace;font-size:0.8rem;">
:{{ rule.metric_name }} {{ rule.operator.value }} {{ rule.threshold }}
</span>
{{ rule.resource_name }} {{ rule_condition(rule) }}
<div style="font-size:0.75rem;color:var(--text-dim);">{{ source_label(rule.source_module) }}</div>
</td>
<td style="color:var(--text-muted);text-align:center;">{{ rule.consecutive_failures_required }} / {{ rule.consecutive_ok_required }}</td>
<td class="td-actions">
+7 -4
View File
@@ -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.") %}
<a class="btn" href="/alerts/maintenance/new">New window</a>
{% if can_edit %}<a class="btn" href="/alerts/maintenance/new">New window</a>{% endif %}
{% endcall %}
{% macro window_table(rows, kind) %}
@@ -28,9 +29,9 @@
{% if w.scope_source is none %}
<span title="All alert rules">All alerts</span>
{% elif w.scope_resource is none %}
<span>{{ w.scope_source }}/*</span>
<span>Every alert on {{ source_label(w.scope_source) }}</span>
{% else %}
<span>{{ w.scope_source }}/{{ w.scope_resource }}</span>
<span>{{ w.scope_resource }}</span> <span class="field-hint">{{ source_label(w.scope_source) }}</span>
{% endif %}
</td>
<td style="font-size:0.85rem;font-family:ui-monospace,monospace;">
@@ -45,10 +46,12 @@
{% else %}{{ status_badge('inactive', 'Expired') }}<div class="field-hint">ended {{ w.end_at|ago }}</div>{% endif %}
</td>
<td class="td-actions">
{% if session.user_role in ('operator', 'admin') %}
<form method="post" action="/alerts/maintenance/{{ w.id }}/delete" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger"
data-confirm="Delete window &quot;{{ w.name }}&quot;?">Delete</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
@@ -76,6 +79,6 @@
</details>
{% endif %}
{% else %}
<div class="card">{{ empty_state("No maintenance windows yet.", "New window", "/alerts/maintenance/new") }}</div>
<div class="card">{% if can_edit %}{{ empty_state("No maintenance windows yet.", "New window", "/alerts/maintenance/new") }}{% else %}{{ empty_state("No maintenance windows yet.") }}{% endif %}</div>
{% endif %}
{% endblock %}
+48 -85
View File
@@ -24,68 +24,49 @@
hx-target="#rule-fields"
hx-include="[name='source_module']">
{% for src in source_modules %}
<option value="{{ src }}" {% if src == initial_source %}selected{% endif %}>
{{ src }}
</option>
<option value="{{ src }}" {% if src == initial_source %}selected{% endif %}>{{ source_label(src) }}</option>
{% endfor %}
</select>
</div>
{# ── Dynamic resource + metric ────────────────────────────────────────── #}
<div id="rule-fields">
{# Inline the same partial logic so the page is fully usable without JS #}
<div class="form-group">
<label>Resource Name</label>
{% if resources %}
<select name="resource_name" required>
{% if not (rule and rule.resource_name) %}<option value="" disabled selected>— pick a resource —</option>{% endif %}
{% for r in resources %}
<option value="{{ r }}" {% if rule and r == rule.resource_name %}selected{% endif %}>{{ r }}</option>
{% endfor %}
</select>
{% else %}
<select name="resource_name" required disabled>
<option value="">No data yet — run monitors first</option>
</select>
<p class="field-hint">
Resource names populate from live metric data. Start the scheduler and try again.
</p>
{% endif %}
</div>
<div class="form-group">
<label>Metric Name</label>
<select name="metric_name" required>
{% if not (rule and rule.metric_name) %}<option value="" disabled selected>— pick a metric —</option>{% endif %}
{% for m in metrics %}
<option value="{{ m }}" {% if rule and m == rule.metric_name %}selected{% endif %}>{{ m }}</option>
{% endfor %}
</select>
</div>
{% with current_resource = rule.resource_name if rule else "", current_metric = rule.metric_name if rule else "" %}
{% include "alerts/_rule_fields.html" %}
{% endwith %}
</div>
{# ── Condition ───────────────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
{# ── 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 ("<", "<=") %}
<div data-cond-binary {% if not binary %}hidden{% endif %} class="form-group">
<label>Fires when it is</label>
<select name="binary_when" {% if not binary %}disabled{% endif %}>
<option value="down" {% if fires_down %}selected{% endif %}>down (unhealthy)</option>
<option value="up" {% if not fires_down %}selected{% endif %}>up (healthy)</option>
</select>
</div>
<div data-cond-numeric {% if binary %}hidden{% endif %} style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
<div class="form-group">
<label>Operator</label>
<select name="operator">
<label>Fires when the reading is</label>
<select name="operator" {% if binary %}disabled{% endif %}>
{% for val, label in operators %}
<option value="{{ val }}" {% if rule and rule.operator.value == val %}selected{% endif %}>
{{ label }}
</option>
<option value="{{ val }}" {% if rule and rule.operator.value == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Threshold</label>
<input type="number" name="threshold" step="any" required
value="{{ rule.threshold if rule else '' }}"
placeholder="0.5">
<label>Threshold <span data-threshold-unit style="color:var(--text-muted);font-weight:normal;">{% if metric_unit(m0) %}({{ metric_unit(m0) }}){% endif %}</span></label>
<input type="number" name="threshold" step="any" required {% if binary %}disabled{% endif %}
value="{{ rule.threshold if rule and not binary else '' }}" placeholder="90">
</div>
</div>
<div class="form-group">
<label>Consecutive failures before FIRING</label>
<label>Breaching checks in a row before it fires</label>
<input type="number" name="consecutive_failures_required" min="1"
value="{{ rule.consecutive_failures_required if rule else 1 }}">
</div>
@@ -114,44 +95,16 @@
{# ── Metric reference ────────────────────────────────────────────────── #}
<details style="margin-bottom:1rem;">
<summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);user-select:none;">
Metric reference
What each metric measures
</summary>
<div style="margin-top:0.75rem;display:grid;gap:0.5rem;">
{% 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 %}
<div>
<div style="font-size:0.78rem;font-weight:500;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.2rem;">{{ src }}</div>
{% for metric, desc in pairs %}
<div style="display:grid;grid-template-columns:160px 1fr;gap:0.5rem;font-size:0.78rem;padding:0.15rem 0;">
<code>{{ metric }}</code>
<span style="color:var(--text-muted);">{{ desc }}</span>
<div style="font-size:0.78rem;font-weight:500;color:var(--text-dim);margin-bottom:0.2rem;">{{ source_label(src) }}</div>
{% for m in names %}
<div style="display:grid;grid-template-columns:200px 1fr;gap:0.5rem;font-size:0.78rem;padding:0.15rem 0;">
<span>{{ metric_label(m) }}{% if metric_unit(m) %} <span style="color:var(--text-dim);">({{ metric_unit(m) }})</span>{% endif %}</span>
<span style="color:var(--text-muted);">{{ metric_about(m) }}</span>
</div>
{% endfor %}
</div>
@@ -210,19 +163,29 @@
</div>
{% endif %}
{% if not rule %}
<script>
// "Agent offline" is seconds_since_seen > 180 (the agent's stale window).
// The condition follows the metric: an up/down metric asks which word
// fires, any other a threshold in its unit. "Agent offline" is
// seconds_since_seen > 180 (the agent's stale window).
document.addEventListener("change", function (e) {
if (e.target.name !== "metric_name" || e.target.value !== "seconds_since_seen") return;
var form = e.target.form, threshold = form.elements["threshold"];
if (threshold.value === "") {
if (e.target.name !== "metric_name") return;
var form = e.target.form, opt = e.target.selectedOptions[0];
var binary = opt.hasAttribute("data-binary"), unit = opt.dataset.unit || "";
form.querySelector("[data-cond-binary]").hidden = !binary;
form.querySelector("[data-cond-numeric]").hidden = binary;
form.elements["binary_when"].disabled = !binary;
form.elements["operator"].disabled = binary;
form.elements["threshold"].disabled = binary;
form.querySelector("[data-threshold-unit]").textContent = unit ? "(" + unit + ")" : "";
var about = form.querySelector("[data-metric-about]");
if (about) { about.textContent = opt.dataset.about || ""; about.hidden = !opt.dataset.about; }
var threshold = form.elements["threshold"];
if (opt.value === "seconds_since_seen" && threshold.value === "") {
form.elements["operator"].value = ">";
threshold.value = "180";
}
});
</script>
{% endif %}
<div style="display:flex;gap:1rem;margin-top:1.5rem;">
<button type="submit" class="btn">{% if rule %}Save Changes{% else %}Create Rule{% endif %}</button>
+10 -5
View File
@@ -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') %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.25rem;gap:1rem;flex-wrap:wrap;">
<h1 class="page-title" style="margin-bottom:0;">Ansible Schedules</h1>
<div style="display:flex;gap:0.5rem;">
@@ -19,7 +20,7 @@
{# ── Existing schedules ───────────────────────────────────────────────────── #}
{% if not rows %}
<div class="card empty">No schedules yet. Create one below to run a playbook on a recurring interval.</div>
<div class="card empty">No schedules yet.{% if can_edit %} Create one below to run a playbook on a recurring interval.{% endif %}</div>
{% else %}
<div class="card-flush">
<table class="table">
@@ -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. #}
<div style="color:{{ 'var(--text-muted)' if row.last_run else 'var(--red)' }};">
{{ s.last_error }} · {{ s.last_run_at.strftime("%m-%d %H:%M") }}
{{ s.last_error }} · {{ s.last_run_at|ago }}
</div>
{% elif not row.last_run %}
<span style="color:var(--text-dim);">never</span>
@@ -54,10 +55,11 @@
</td>
<td style="font-size:0.82rem;color:var(--text-muted);">
{% if not s.enabled %}<span style="color:var(--text-dim);">paused</span>
{% elif row.next_run %}{{ row.next_run.strftime("%m-%d %H:%M") }}
{% elif row.next_run %}{{ row.next_run|ago }}
{% else %}due now{% endif %}
</td>
<td class="td-actions" style="white-space:nowrap;">
{% if can_edit %}
<form method="post" action="/ansible/schedules/{{ s.id }}/run-now" style="display:inline;">{{ csrf_field() }}
<button class="btn btn-sm btn-ghost" type="submit">Run now</button>
</form>
@@ -69,6 +71,7 @@
data-confirm="Delete schedule {{ s.name }}?">{{ csrf_field() }}
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
@@ -77,7 +80,8 @@
</div>
{% 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 @@
</div>
</form>
</div>
{% endif %}
{% endblock %}
+4 -7
View File
@@ -3,11 +3,10 @@
{% from "_macros.html" import scroll_more, scroll_end %}
{% for item in events %}
{% set e = item.event %}
{% set d = item.detail %}
<tr>
<td style="white-space:nowrap;" title="{{ e.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') }}">{{ e.timestamp|ago }}</td>
<td style="font-size:0.85rem;">{{ e.username }}</td>
<td><span class="mono">{{ e.action }}</span></td>
<td title="{{ e.action }}">{{ item.action or e.action }}</td>
<td>
{% if item.entity %}
<span class="field-hint">{{ e.entity_type|replace('_', ' ') }}</span>
@@ -16,13 +15,11 @@
{% elif e.entity_type %}<span class="field-hint">{{ e.entity_type|replace('_', ' ') }}</span>{% endif %}
</td>
<td style="font-size:0.8rem;color:var(--text-muted);max-width:340px;">
{% if d %}
{% for k, v in d.items() %}
<span style="color:var(--text-dim);">{{ k }}:</span>
<span class="mono">{{ v }}</span>
{% for f in item.facts %}
<span style="color:var(--text-dim);">{{ f.label }}</span>
{% if f.url %}<a href="{{ f.url }}">{{ f.text }}</a>{% else %}{{ f.text }}{% endif %}
{% if not loop.last %} &nbsp;·&nbsp; {% endif %}
{% endfor %}
{% endif %}
</td>
</tr>
{% endfor %}
+9 -9
View File
@@ -4,13 +4,13 @@
{% block content %}
{% call page_header("Audit log", "Who changed what, newest first.") %}
<form method="get" style="display:flex;gap:0.5rem;align-items:center;">
<input type="text" name="action" value="{{ action_filter }}"
placeholder="Filter by action…" aria-label="Filter by action"
style="width:200px;font-size:0.85rem;">
<button type="submit" class="btn btn-ghost btn-sm">Filter</button>
{% if action_filter %}
<a href="/audit/" class="btn btn-ghost btn-sm">Clear</a>
{% endif %}
<select name="action" aria-label="Show only" style="font-size:0.85rem;" onchange="this.form.submit()">
<option value="">Everything</option>
{% for prefix, label in areas %}
<option value="{{ prefix }}" {% if action_filter == prefix %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<noscript><button type="submit" class="btn btn-ghost btn-sm">Filter</button></noscript>
</form>
{% endcall %}
@@ -21,8 +21,8 @@
<tr>
<th>When</th>
<th>User</th>
<th>Action</th>
<th>Entity</th>
<th>Did</th>
<th>What</th>
<th>Detail</th>
</tr>
</thead>
+1 -1
View File
@@ -25,7 +25,7 @@
<li>
{{ status_badge('no_data' if state.no_data_since and state.state.value == 'inactive' else state.state) }}
<a href="/alerts/history?rule={{ rule.id }}">{{ rule.name }}</a>
<span class="problem-detail">{{ 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 }}</span>
<span class="problem-detail">{{ rule.resource_name }} {{ rule_condition(rule) }} · since {{ (state.no_data_since if state.state.value == 'inactive' else state.last_transition_at)|ago }}</span>
</li>
{% endfor %}
</ul>
+51 -11
View File
@@ -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 <a href="/hosts/h1">web-1</a> is 94% (&gt; 90%), firing for 12m' in page
assert 'CPU on <a href="/hosts/h1">web-1</a> is 94% (&gt; 90%), firing for 12m' in page
assert '<span class="badge badge-crit">Firing</span>' 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 <strong>web-1</strong>" in page # nothing to link to: no bare href
assert "CPU on <strong>web-1</strong>" 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</span>' in rules
assert '<span class="tone-dim" style="font-size:0.8rem;">off</span>' 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</option>" in page and ">Host agent</option>" in page
assert ">Up or down</option>" in page and 'data-unit="ms"' in page
assert '<option value="down" selected>' in page # the binary choice, shown
assert 'name="threshold" step="any" required disabled' in page # the raw threshold, off
assert ">is_up<" not in page and "SNMP" not in page # no raw key; empty source skipped
+30
View File
@@ -56,3 +56,33 @@ async def test_the_log_continues_itself_instead_of_a_load_more_link(app):
'&amp;action=host."') in more
assert "Load more" not in more
assert "data-scroll-more" not in last and "End of the list." in last
def test_an_entry_reads_as_a_sentence_with_its_detail_in_words():
"""#5212: no action codes, Python reprs or raw scopes on the page."""
from steward.audit.service import action_text, detail_facts
assert action_text("monitor.disabled") == "paused monitor"
assert action_text("some.new_thing") == "some.new_thing" # unknown: its code
facts = detail_facts({"rule_id": "r1", "check": False, "vars": ["a", "b"], "secret_vars": [],
"scope": "steward:group:g1", "interval_seconds": 7200,
"section": "ansible.credentials", "run_id": "abc", "limit": None,
"carried": {"monitor_results": 12}},
{"steward:group:g1": "group Docker Swarm"})
said = {f["label"]: f["text"] for f in facts}
assert said == {"dry run": "no", "variables": "a, b", "secret variables": "none",
"scope": "group Docker Swarm", "every": "2h", "section": "Ansible: credentials",
"run": "open", "history carried over": "monitor results 12"}
assert [f["url"] for f in facts if f["label"] == "run"] == ["/ansible/runs/abc"]
async def test_the_row_says_what_was_done_not_its_code(app):
events = [{"event": _ev("monitor", "m1", "monitor.disabled"), "detail": {},
"entity": {"label": "api check", "url": "/monitors/m1", "gone": False},
"action": "paused monitor", "facts": [{"label": "dry run", "text": "no", "url": None}]}]
async with app.test_request_context("/audit/"):
from steward.audit.service import AREAS
page = await render_template("audit/list.html", events=events, after=None,
action_filter="monitor.", areas=AREAS)
assert 'title="monitor.disabled">paused monitor</td>' in page
assert "dry run</span>" in page and "False" not in page
assert '<option value="monitor." selected>Monitors</option>' in page
+1 -1
View File
@@ -55,7 +55,7 @@ async def test_the_problems_come_first_and_say_since_when(app):
problem_card = page[attention:page.index("</section>")]
assert "shop-api" in problem_card and "nas" not in problem_card
assert "down since <time" in problem_card and ">12m ago</time>" in problem_card
assert "CPU high" in problem_card and "cpu_pct &gt; 90" in problem_card and ">4m ago</time>" in problem_card
assert "CPU high" in problem_card and "web01 CPU &gt; 90%" in problem_card and ">4m ago</time>" in problem_card
assert '<span class="badge badge-crit">Firing</span>' in problem_card
# Uptime and TLS coloured by Settings → Thresholds, not by literals.
assert '<span class="tone-crit">93.00%</span>' in page and '<span class="tone-ok">100.00%</span>' in page
+43
View File
@@ -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</span>" 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</time>" in page or ">in 1h 59m</time>" in page
for control in ("Run now", "Pause", "?edit=", "/delete", "New schedule"):
assert control not in page, control
+3 -3
View File
@@ -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: &gt; 90" in page
assert "CPU 98%" in page and "fires when CPU &gt; 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 "<table" not in older
assert "cpu_pct = 97.5" in older and f"old-{tag}" in older and f"patch-{tag}" not in older
assert "CPU 98%" in older and f"old-{tag}" in older and f"patch-{tag}" not in older
assert "End of the list." in older and "data-scroll-more" not in older
# The rules list links each rule to its history.