From 477b05b9d02be93c852588c62ba8af2e49d184bc Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 14:34:21 -0400 Subject: [PATCH] feat(ui): status_badge, status_dot, stat_tile, empty_state, page_header, icon_button and form classes (#5094) #5094 "Status and layout macros, and form classes". - formatting.STATES: one state -> (tone, label) map for up, down, stale, firing, pending, acknowledged, no_data, ok, warn, crit, resolved and inactive; status_info() reads it (an unmapped state is drawn dim under its own name). required() lets a macro refuse a missing argument. - _macros.html: status_badge and status_dot (a dot always carries its label, sr-only unless shown), stat_tile, empty_state, page_header (actions via a call block) and icon_button, which raises without a label. - steward.css: tone classes (badge-/dot-/stat- ok, warn, crit, info, dim), .sr-only, .page-header, .stat-tile, .empty-state, .btn-icon and the form classes .field, .field-label, .field-hint and .input. Info text is a 40% mix (--blue): the house --fs-info-fg falls to 4.18:1 on its tint over a hovered row, 40% holds 5.09:1. - First users: the Alerts list, history and maintenance pages (headers, state badges, empty states) and the alert forms' hints; the inline inp/lbl style sets on the monitor and host forms become .input / .field-label. - tests/core/test_macros.py renders each macro through the app environment. Co-Authored-By: Claude Opus 5.5 --- steward/app.py | 3 + steward/core/formatting.py | 34 ++++++++ steward/static/steward.css | 41 ++++++++++ steward/templates/_macros.html | 70 +++++++++++++++- steward/templates/alerts/_history_rows.html | 11 +-- steward/templates/alerts/_rule_fields.html | 2 +- steward/templates/alerts/history.html | 16 +--- steward/templates/alerts/list.html | 33 +++----- steward/templates/alerts/maintenance.html | 31 ++----- .../templates/alerts/maintenance_form.html | 2 +- steward/templates/alerts/rules_form.html | 10 +-- steward/templates/hosts/detail.html | 18 ++--- steward/templates/monitors/_fields.html | 26 +++--- steward/templates/monitors/edit.html | 18 ++--- steward/templates/monitors/index.html | 18 ++--- tests/core/test_macros.py | 80 +++++++++++++++++++ 16 files changed, 296 insertions(+), 117 deletions(-) create mode 100644 tests/core/test_macros.py diff --git a/steward/app.py b/steward/app.py index 59704a5..0915892 100644 --- a/steward/app.py +++ b/steward/app.py @@ -140,6 +140,9 @@ def create_app( app.jinja_env.globals["status_class"] = ( lambda value, kind: _fmt.status_class(value, kind, app.config.get("THRESHOLDS", {})) ) + # 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 # Forged-request protection: every POST form renders csrf_field(), HTMX and # fetch() send csrf_token() as X-CSRF-Token, and one check refuses any diff --git a/steward/core/formatting.py b/steward/core/formatting.py index 16be5a1..385fb1f 100644 --- a/steward/core/formatting.py +++ b/steward/core/formatting.py @@ -95,3 +95,37 @@ def status_class(value: Any, kind: str, thresholds: dict) -> str: if th.get("dir", "high") == "low": return "crit" if value < crit else "warn" if value < warn else "ok" return "crit" if value >= crit else "warn" if value >= warn else "ok" + + +# Every state a page shows, and the tone it is drawn in: ok (green), warn +# (amber), crit (red), info (blue) or dim. The status_badge and status_dot +# macros read this, so one state looks the same on every page. +STATES: dict[str, tuple[str, str]] = { + "up": ("ok", "Up"), + "ok": ("ok", "OK"), + "resolved": ("ok", "Resolved"), + "down": ("crit", "Down"), + "firing": ("crit", "Firing"), + "crit": ("crit", "Critical"), + "warn": ("warn", "Warning"), + "pending": ("warn", "Pending"), + "stale": ("warn", "Stale"), + "no_data": ("warn", "No data"), + "acknowledged": ("info", "Acknowledged"), + "inactive": ("dim", "Inactive"), + "unknown": ("dim", "Unknown"), +} + + +def status_info(state: Any) -> tuple[str, str]: + """(tone, label) for a state name or enum. A state the map doesn't know + is drawn dim under its own name rather than breaking the page.""" + key = str(getattr(state, "value", state) or "unknown").lower() + return STATES.get(key, ("dim", key.replace("_", " ").capitalize())) + + +def required(value: Any, what: str) -> Any: + """For a macro argument that must be given: raise if it is empty.""" + if value is None or not str(value).strip(): + raise ValueError(f"{what} is required") + return value diff --git a/steward/static/steward.css b/steward/static/steward.css index 0623587..d8c6f72 100644 --- a/steward/static/steward.css +++ b/steward/static/steward.css @@ -31,6 +31,10 @@ --orange-dim: color-mix(in srgb, var(--fs-warning) 20%, transparent); --red: var(--fs-error-fg); --red-dim: color-mix(in srgb, var(--fs-error) 20%, transparent); + /* Info (acknowledged): lighter than the house --fs-info-fg (50%), which falls + to 4.18:1 on its tint over a hovered row; 40% holds 5.09:1 there. */ + --blue: color-mix(in srgb, var(--fs-info) 40%, var(--fs-text-primary)); + --blue-dim: color-mix(in srgb, var(--fs-info) 20%, transparent); --font-serif: var(--fs-font-display); } * { box-sizing: border-box; margin: 0; padding: 0; } @@ -172,11 +176,19 @@ input[type=text], input[type=email], input[type=password], input[type=number], i input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); } select { cursor: pointer; } textarea { resize: vertical; } +/* Form fields: .field wraps a .field-label, the .input and an optional .field-hint. */ +.field { display: flex; flex-direction: column; gap: 0.2rem; margin-bottom: 0.9rem; } +.field-label { display: block; margin-bottom: 0; font-size: 0.75rem; color: var(--text-muted); } +.field-hint { font-size: 0.75rem; color: var(--text-dim); } +.input { width: 100%; padding: 0.4rem 0.65rem; background: var(--bg); border: 1px solid var(--border-mid); border-radius: var(--fs-radius-sm); color: var(--text); font-size: 0.88rem; font-family: inherit; } +.input:focus { outline: none; border-color: var(--accent); } +.input:disabled { opacity: var(--fs-disabled-opacity); } /* Buttons */ .btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.45rem 1rem; background: var(--accent); color: var(--fs-surface-page); border: none; border-radius: 5px; cursor: pointer; font-size: 0.875rem; font-family: inherit; font-weight: 500; transition: background .15s; text-decoration: none; } .btn:hover { background: var(--accent-hover); color: var(--fs-surface-page); } .btn-sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; } +.btn-icon { padding: 0.3rem 0.5rem; line-height: 1; } .btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border-mid); } .btn-ghost:hover { background: var(--bg-elevated); color: var(--text); } .btn-danger { background: var(--fs-action-destructive); color: var(--fs-text-on-action); } @@ -197,9 +209,38 @@ textarea { resize: vertical; } .dot-down { background: var(--red); } .dot-warn { background: var(--yellow); } .dot-dim { background: var(--text-dim); } +/* Tones the status_badge / status_dot macros draw (formatting.STATES). */ +.badge-ok { background: var(--green-dim); color: var(--green); } +.badge-warn { background: var(--yellow-dim); color: var(--yellow); } +.badge-crit { background: var(--red-dim); color: var(--red); } +.badge-info { background: var(--blue-dim); color: var(--blue); } +.dot-ok { background: var(--green); } +.dot-crit { background: var(--red); } +.dot-info { background: var(--blue); } +.dot-label { margin-left: 0.35rem; font-size: 0.85rem; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } /* Empty state */ .empty { color: var(--text-muted); font-size: 0.9rem; padding: 2rem; text-align: center; } +.empty-state { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; color: var(--text-muted); font-size: 0.9rem; padding: 2rem 1rem; text-align: center; } + +/* Page header: title on the left, its actions on the right (page_header macro). */ +.page-header { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem 1rem; margin-bottom: 1.5rem; } +.page-header .page-title { margin-bottom: 0; } +.page-subtitle { margin-top: 0.3rem; color: var(--text-muted); font-size: 0.9rem; } +.page-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; } + +/* Stat tile (stat_tile macro): a label over one headline number. */ +.stat-tile { display: flex; flex-direction: column; gap: 0.25rem; padding: 0.9rem 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--fs-radius-md); color: var(--text); min-width: 8rem; } +a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevated); color: var(--text); } +.stat-tile-label { font-size: var(--fs-size-tiny); font-weight: 500; color: var(--text-muted); text-transform: uppercase; letter-spacing: var(--fs-tracking-tiny); } +.stat-tile-value { font-size: 1.6rem; font-weight: 500; line-height: 1.1; font-variant-numeric: tabular-nums; } +.stat-tile-hint { font-size: 0.78rem; color: var(--text-dim); } +.stat-ok .stat-tile-value { color: var(--green); } +.stat-warn .stat-tile-value { color: var(--yellow); } +.stat-crit .stat-tile-value { color: var(--red); } +.stat-info .stat-tile-value { color: var(--blue); } +.stat-dim .stat-tile-value { color: var(--text-dim); } /* Breadcrumbs */ /* Breadcrumb renders as a kicker line tucked directly above the page header diff --git a/steward/templates/_macros.html b/steward/templates/_macros.html index c97bcdc..3b3b470 100644 --- a/steward/templates/_macros.html +++ b/steward/templates/_macros.html @@ -17,6 +17,70 @@ {%- endmacro %} -{# Metric threshold coloring moved to the `threshold_style(value, kind)` jinja - global (steward/core/settings.py + app.py), which reads operator-configurable - cutoffs from Settings → Thresholds. Call it as threshold_style(v, 'cpu'). #} +{# A state as a labelled badge: up, down, stale, firing, pending, acknowledged, + no_data, ok, warn, crit, resolved, inactive (formatting.STATES). `state` may + be a string or an enum; `label` replaces the standard wording. #} +{% macro status_badge(state, label=none, title=none) -%} +{%- set tone, text = status_info(state) -%} +{{ label or text }} +{%- endmacro %} + +{# A state as a coloured dot. The dot is never the only signal: its label is + read out to a screen reader, and shown beside it when `show_label`. #} +{% macro status_dot(state, label=none, show_label=false) -%} +{%- set tone, text = status_info(state) -%} + +{%- if show_label %}{{ label or text }}{% else %}{{ label or text }}{% endif %} +{%- endmacro %} + +{# Threshold colouring: `threshold_style(value, kind)` (an inline style) and + `status_class(value, kind)` ('ok' / 'warn' / 'crit' / 'unknown') are jinja + globals reading the cutoffs from Settings → Thresholds. #} + +{# One headline number. `state` (a STATES name, or a status_class result) + colours the value; `href` makes the whole tile a link. #} +{% macro stat_tile(label, value, state=none, hint=none, href=none) -%} +{%- set tone = status_info(state)[0] if state else none -%} +{%- set body -%} + {{ label }} + {{ value }} + {%- if hint %}{{ hint }}{% endif %} +{%- endset -%} +{%- if href -%} +{{ body }} +{%- else -%} +
{{ body }}
+{%- endif -%} +{%- endmacro %} + +{# What a list shows when it has nothing in it, with an optional next step. #} +{% macro empty_state(message, action_label=none, action_href=none) -%} +
+

{{ message }}

+ {%- if action_label and action_href %}{{ action_label }}{% endif %} +
+{%- endmacro %} + +{# The page's

, with its actions to the right: + {% call page_header("Alerts") %}New rule{% endcall %} #} +{% macro page_header(title, subtitle=none) -%} + +{%- endmacro %} + +{# A button that shows only an icon. The label is required: it is the button's + accessible name and its tooltip, and rendering one without it raises. #} +{% macro icon_button(icon, label, href=none, cls="btn-ghost") -%} +{%- set name = required(label, "icon_button label") -%} +{%- if href -%} +{{ icon }} +{%- else -%} + +{%- endif -%} +{%- endmacro %} diff --git a/steward/templates/alerts/_history_rows.html b/steward/templates/alerts/_history_rows.html index 1d868b7..e91cff5 100644 --- a/steward/templates/alerts/_history_rows.html +++ b/steward/templates/alerts/_history_rows.html @@ -1,6 +1,5 @@ {# alerts/_history_rows.html — the history table; swapped in on a range or rule change #} -{% set state_badge = {"firing": "badge-red", "pending": "badge-yellow", "acknowledged": "badge-gold", - "resolved": "badge-green", "inactive": "badge-dim"} %} +{% from "_macros.html" import status_badge, empty_state %} {% if events %}
@@ -25,9 +24,9 @@
{{ ev.rule.source_module }}/{{ ev.rule.resource_name }}
@@ -51,17 +49,10 @@
- {{ ev.from_state }} + {{ status_badge(ev.from_state) }} → - {{ ev.to_state }} + {{ status_badge(ev.to_state) }} {% for n in ev.notes %}
{{ n }}
{% endfor %} @@ -69,7 +68,5 @@

{% endif %} {% else %} -

- No alert changed state in the last {{ current_range }}{% if chosen %} for {{ chosen.name }}{% endif %}. -

+
{{ empty_state("No alert changed state in the last " ~ current_range ~ ((" for " ~ chosen.name) if chosen else "") ~ ".") }}
{% endif %} diff --git a/steward/templates/alerts/_rule_fields.html b/steward/templates/alerts/_rule_fields.html index b45ba49..11d506f 100644 --- a/steward/templates/alerts/_rule_fields.html +++ b/steward/templates/alerts/_rule_fields.html @@ -12,7 +12,7 @@ -

+

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

{% endif %} diff --git a/steward/templates/alerts/history.html b/steward/templates/alerts/history.html index e736f88..c79825f 100644 --- a/steward/templates/alerts/history.html +++ b/steward/templates/alerts/history.html @@ -1,12 +1,10 @@ {% extends "base.html" %} -{% from "_macros.html" import crumbs %} +{% from "_macros.html" import crumbs, page_header %} {% block title %}Alert History — Steward{% endblock %} {% block breadcrumb %}{{ crumbs([("Alerts", "/alerts/"), ("History", "")]) }}{% endblock %} {% block content %} -
-

Alert History

-
- {% include "_time_range.html" %} -
-
- -

- Every change of state, newest first: what the value was against the rule's condition, whether - the email and webhook went out, and the Ansible run an action started. -

+{% endcall %}
Alerts +{% call page_header("Alerts") %} + History + Maintenance + New rule +{% endcall %} {% if active %}

Active

@@ -20,18 +25,11 @@ {% for state, rule in active %}
- {% if state.state.value == 'firing' %} - FIRING - {% elif state.state.value == 'acknowledged' %} - ACK - {% elif state.state.value == 'pending' %} - PENDING - {% endif %} + {{ status_badge(state.state) }} {% if state.no_data_since %} - NO DATA + {{ status_badge('no_data', title="No sample since " ~ (state.last_sample_at.strftime('%Y-%m-%d %H:%M UTC') if state.last_sample_at else 'the rule started watching')) }}
- since {{ state.no_data_since.strftime("%Y-%m-%d %H:%M") }} UTC + since {{ state.no_data_since|ago }}
{% endif %}
{% else %} -

No active alerts.

+
{{ empty_state("No active alerts. Every rule is quiet.") }}
{% endif %} -
-

Rules

-
- History - Maintenance - New Rule -
-
+

Rules

{% if rules %}
@@ -108,6 +99,6 @@
{% else %} -

No alert rules configured.

+
{{ empty_state("No alert rules yet.", "New rule", "/alerts/rules/new") }}
{% endif %} {% endblock %} diff --git a/steward/templates/alerts/maintenance.html b/steward/templates/alerts/maintenance.html index 32bf36e..7f87275 100644 --- a/steward/templates/alerts/maintenance.html +++ b/steward/templates/alerts/maintenance.html @@ -1,20 +1,11 @@ {% extends "base.html" %} -{% from "_macros.html" import crumbs %} +{% from "_macros.html" import crumbs, page_header, status_badge, empty_state %} {% block title %}Maintenance Windows — Steward{% endblock %} {% block breadcrumb %}{{ crumbs([("Alerts", "/alerts/"), ("Maintenance", "")]) }}{% endblock %} {% block content %} -
-

Maintenance Windows

-
- New Window -
-
- -

- 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. -

+{% 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 +{% endcall %} {% if windows %}
@@ -51,13 +42,9 @@ {{ w.end_at.strftime("%Y-%m-%d %H:%M") }} UTC - {% if is_active %} - active - {% elif is_upcoming %} - upcoming - {% else %} - expired - {% endif %} + {% if is_active %}{{ status_badge('warn', 'Active') }} + {% elif is_upcoming %}{{ status_badge('pending', 'Upcoming') }} + {% else %}{{ status_badge('inactive', 'Expired') }}{% endif %}
{{ csrf_field() }} @@ -71,8 +58,6 @@
{% else %} -
-

No maintenance windows configured.

-
+
{{ empty_state("No maintenance windows yet.", "New window", "/alerts/maintenance/new") }}
{% endif %} {% endblock %} diff --git a/steward/templates/alerts/maintenance_form.html b/steward/templates/alerts/maintenance_form.html index 7870ecc..3849bd4 100644 --- a/steward/templates/alerts/maintenance_form.html +++ b/steward/templates/alerts/maintenance_form.html @@ -49,7 +49,7 @@ -

+

Leave blank to mute all resources within the selected source.

diff --git a/steward/templates/alerts/rules_form.html b/steward/templates/alerts/rules_form.html index b9a3022..89e6391 100644 --- a/steward/templates/alerts/rules_form.html +++ b/steward/templates/alerts/rules_form.html @@ -47,7 +47,7 @@ -

+

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

{% endif %} @@ -94,7 +94,7 @@ -

+

A firing or acknowledged alert resolves only after this many checks in a row are back within the threshold. An acknowledgement holds until then.

@@ -105,7 +105,7 @@ {% if not rule or rule.notify_no_data %}checked{% endif %}> Notify when this metric stops reporting -

+

Silence is always shown as NO DATA on the Alerts page: no sample for three of the metric's usual gaps (at least 5 minutes). This also sends a notification when it starts and when data resumes. A firing alert that goes silent stays firing.

@@ -147,7 +147,7 @@ } %} {% for src, pairs in descs.items() %}
-
{{ src }}
+
{{ src }}
{% for metric, desc in pairs %}
{{ metric }} @@ -163,7 +163,7 @@ {% if session.get("user_role") == "admin" %} {% set act = rule.ansible_action if rule and rule.ansible_action else None %}
-
-{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %} -{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %} {# ── Live vitals strip (host_agent fragment) — full width on top, polled ────── #}
- - {% for t in monitor_types %} {% endfor %}
- - + +
- - + +
- - + +
{{ type_fields() }}
diff --git a/steward/templates/monitors/_fields.html b/steward/templates/monitors/_fields.html index d98c6d5..5e3a1de 100644 --- a/steward/templates/monitors/_fields.html +++ b/steward/templates/monitors/_fields.html @@ -1,45 +1,43 @@ {# Shared type-specific monitor form fields. Every field is rendered; a tiny script (mtoggle) shows only the ones relevant to the selected type. #} {% macro type_fields(config={}) %} -{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %} -{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %} {# tcp #}
- - + +
{# dns #}
- + + placeholder="e.g. 192.0.2.10" autocomplete="off" class="input">
{# http #}
- - {% for opt in ["GET", "HEAD", "POST"] %} {% endfor %}
- + + min="100" max="599" class="input">
- + + min="1" max="60" class="input">
- + + placeholder="optional" autocomplete="off" class="input">
-{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %} -{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %}
{{ csrf_field() }}
- - + +
- - + +
- - + +
- - + +
{{ type_fields(config) }}
diff --git a/steward/templates/monitors/index.html b/steward/templates/monitors/index.html index eca18ba..9876e99 100644 --- a/steward/templates/monitors/index.html +++ b/steward/templates/monitors/index.html @@ -10,8 +10,6 @@ {% include "_time_range.html" %}
-{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %} -{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %} {# ── Add monitor ──────────────────────────────────────────────────────────── #}
@@ -19,24 +17,24 @@ {{ csrf_field() }}
- - {% for t in types %} {% endfor %}
- - + +
- - + +
- - + +
{{ type_fields() }}
diff --git a/tests/core/test_macros.py b/tests/core/test_macros.py new file mode 100644 index 0000000..e4431e5 --- /dev/null +++ b/tests/core/test_macros.py @@ -0,0 +1,80 @@ +"""The status and layout macros in _macros.html (#5094), rendered through the +app's own Jinja environment so its globals (status_info, required) are the +real ones.""" +import enum + +import pytest + +from steward.core.formatting import STATES, status_info + + +async def _render(app, source: str, **ctx) -> str: + async with app.app_context(): + tpl = app.jinja_env.from_string('{% from "_macros.html" import status_badge, status_dot, ' + 'stat_tile, empty_state, page_header, icon_button %}' + source) + out = await tpl.render_async(**ctx) if app.jinja_env.is_async else tpl.render(**ctx) + return " ".join(out.split()) + + +def test_every_state_a_page_shows_has_a_tone_and_a_label(): + named = ["up", "down", "stale", "firing", "pending", "acknowledged", "no_data", "ok", "warn", "crit"] + assert set(named) <= set(STATES) + assert {tone for tone, _ in STATES.values()} == {"ok", "warn", "crit", "info", "dim"} + assert status_info("down")[0] == status_info("firing")[0] == "crit" + + +class _State(enum.Enum): + firing = "firing" + + +@pytest.mark.parametrize("state, tone, label", [ + ("firing", "crit", "Firing"), (_State.firing, "crit", "Firing"), ("UP", "ok", "Up"), + ("no_data", "warn", "No data"), ("acknowledged", "info", "Acknowledged"), + (None, "dim", "Unknown"), ("half_open", "dim", "Half open"), # unmapped: dim, its own name +]) +def test_status_info(state, tone, label): + assert status_info(state) == (tone, label) + + +async def test_status_badge(app): + assert await _render(app, "{{ status_badge('firing') }}") == 'Firing' + out = await _render(app, "{{ status_badge(s, 'Muted', title=t) }}", s=_State.firing, t='a "b"') + assert out == 'Muted' + + +async def test_a_status_dot_always_carries_its_label(app): + out = await _render(app, "{{ status_dot('down') }}") + assert 'class="dot dot-crit" aria-hidden="true"' in out and 'Down' in out + out = await _render(app, "{{ status_dot('up', show_label=true) }}") + assert 'Up' in out and "sr-only" not in out + + +async def test_stat_tile(app): + out = await _render(app, "{{ stat_tile('Firing', 3, state='crit', href='/alerts/?a=1&b=2') }}") + assert out.startswith('') + assert '3' in out and out.endswith("") + out = await _render(app, "{{ stat_tile('', 12, hint='2 stale') }}") + assert out.startswith('
') and "<Hosts>" in out and "2 stale" in out + + +async def test_empty_state(app): + out = await _render(app, "{{ empty_state('No rules yet.', 'New rule', '/alerts/rules/new') }}") + assert "

No rules yet.

" in out and 'href="/alerts/rules/new">New rule' in out + assert "New{% endcall %}") + assert '

Alerts

' in out and '

All rules

' in out + assert '' in out + assert "page-actions" not in await _render(app, "{{ page_header('Alerts') }}") + + +async def test_icon_button_names_itself_and_refuses_to_render_without_a_label(app): + out = await _render(app, "{{ icon_button('×', 'Delete rule web01', type='submit', cls='btn-danger', confirm='Delete?') }}") + assert 'type="submit"' in out and 'aria-label="Delete rule web01"' in out and 'data-confirm="Delete?"' in out + out = await _render(app, "{{ icon_button('✎', 'Edit', href='/x') }}") + assert out.startswith('