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 -%} +
{{ message }}
+ {%- if action_label and action_href %}{{ action_label }}{% endif %} +{{ subtitle }}
{% endif %} +|
- {{ 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')) }}
|
@@ -51,17 +49,10 @@
- since {{ state.no_data_since.strftime("%Y-%m-%d %H:%M") }} UTC
+ since {{ state.no_data_since|ago }}
{% endif %}
|
No active alerts.
+No alert rules configured.
+- 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 %}