feat(status): what is wrong first — down monitors and active alerts, and since when (#5120)
#5120 "Status page: what is wrong first". - A "Needs attention" card heads the Status board: each down monitor with how long it has been down, and each active alert (firing, acknowledged, pending, no data) with its condition and how long, linking to its history. - StatusEntry.since / since_at_least: when the current up/down run began, from the recent checks (streak_start); "for at least" when every check we have agrees. - Counts as stat tiles (Down, Active alerts, Pending, Up); names wrap instead of being cut off; dots carry their label. - Uptime and TLS are coloured by status_class against Settings → Thresholds instead of literal cutoffs; heartbeat pills and DOWN/UP use tokens and status_badge rather than hex and bold. - tests/core/test_status_page.py: streak_start cases, and the rows render with the problem card before the list, each since-time, threshold tones and no hex colour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,38 +1,52 @@
|
||||
{# status/rows.html — unified monitor list + summary, refreshed on poll #}
|
||||
{% macro uptime_cell(pct) %}
|
||||
{%- if pct is none -%}<span style="color:var(--text-dim);">—</span>
|
||||
{%- elif pct >= 99.9 -%}<span style="color:var(--green);">{{ "%.2f"|format(pct) }}%</span>
|
||||
{%- elif pct >= 99 -%}<span style="color:var(--yellow);">{{ "%.2f"|format(pct) }}%</span>
|
||||
{%- elif pct >= 95 -%}<span style="color:var(--orange);">{{ "%.2f"|format(pct) }}%</span>
|
||||
{%- else -%}<span style="color:var(--red);">{{ "%.2f"|format(pct) }}%</span>
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
{# status/rows.html — what is wrong first (down monitors, active alerts), then
|
||||
every monitor; refreshed on poll #}
|
||||
{% from "_macros.html" import status_badge, status_dot, stat_tile, empty_state %}
|
||||
{% macro uptime_cell(pct) -%}
|
||||
{%- if pct is none -%}<span class="tone-unknown">—</span>
|
||||
{%- else -%}<span class="tone-{{ status_class(pct, 'uptime') }}">{{ "%.2f"|format(pct) }}%</span>{%- endif -%}
|
||||
{%- endmacro %}
|
||||
{% macro since(e) -%}
|
||||
{%- if e.since -%}{{ "for at least " if e.since_at_least else "since " }}{{ e.since|ago }}{%- endif -%}
|
||||
{%- endmacro %}
|
||||
|
||||
{# ── Summary ─────────────────────────────────────────────────────────────── #}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:0.75rem;margin-bottom:1rem;">
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.3rem;">Up</div>
|
||||
<span class="stat-val" style="color:var(--green);">{{ up }}</span>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.3rem;">Down</div>
|
||||
<span class="stat-val" style="color:{% if down %}var(--red){% else %}var(--text-dim){% endif %};">{{ down }}</span>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.3rem;">Pending</div>
|
||||
<span class="stat-val" style="color:{% if pending %}var(--yellow){% else %}var(--text-dim){% endif %};">{{ pending }}</span>
|
||||
</div>
|
||||
{# ── What is wrong ───────────────────────────────────────────────────────── #}
|
||||
{% if problems or alerts %}
|
||||
<section class="card problem-card" aria-labelledby="status-problems">
|
||||
<h2 id="status-problems" class="section-title" style="margin-bottom:0.6rem;">Needs attention</h2>
|
||||
<ul class="problem-list">
|
||||
{% for e in problems %}
|
||||
<li>
|
||||
{{ status_badge('down') }}
|
||||
{% if e.detail_url %}<a href="{{ e.detail_url }}">{{ e.name }}</a>{% else %}<strong>{{ e.name }}</strong>{% endif %}
|
||||
<span class="problem-detail">{{ e.kind|upper }} · {{ e.target }} · down {{ since(e) }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% for state, rule in alerts %}
|
||||
<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>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{# ── Counts ──────────────────────────────────────────────────────────────── #}
|
||||
<div class="stat-strip">
|
||||
{{ stat_tile("Down", down, state="down" if down else "inactive") }}
|
||||
{{ stat_tile("Active alerts", alerts|length, state="firing" if alerts else "inactive", href="/alerts/") }}
|
||||
{{ stat_tile("Pending", pending, state="pending" if pending else "inactive") }}
|
||||
{{ stat_tile("Up", up, state="up") }}
|
||||
</div>
|
||||
|
||||
{% if not entries %}
|
||||
<div class="card empty">
|
||||
No monitors configured yet. Add <a href="/hosts/">hosts</a> (ping / DNS) or HTTP monitors to populate the board.
|
||||
</div>
|
||||
<div class="card">{{ empty_state("No monitors yet. Add a host (ping or DNS) or an HTTP monitor to fill the board.", "Add a monitor", "/monitors/") }}</div>
|
||||
{% else %}
|
||||
<div class="card-flush">
|
||||
{# header row #}
|
||||
<div style="display:flex;align-items:center;gap:1rem;padding:0.55rem 1rem;border-bottom:1px solid var(--border-mid);font-size:0.72rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.04em;">
|
||||
<span style="width:130px;flex-shrink:0;">Monitor</span>
|
||||
<span style="width:11rem;flex-shrink:0;">Monitor</span>
|
||||
<span style="flex:1;min-width:0;">Recent checks</span>
|
||||
<span style="width:92px;text-align:right;flex-shrink:0;">Latest</span>
|
||||
<span style="width:54px;text-align:center;flex-shrink:0;">24h</span>
|
||||
@@ -44,13 +58,13 @@
|
||||
{% for e in entries %}
|
||||
<div style="display:flex;align-items:center;gap:1rem;padding:0.55rem 1rem;border-bottom:1px solid var(--border);">
|
||||
{# name + kind #}
|
||||
<div style="width:130px;flex-shrink:0;overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;gap:0.4rem;">
|
||||
<span class="dot {% if e.status == 'up' %}dot-up{% elif e.status == 'down' %}dot-down{% else %}dot-dim{% endif %}"></span>
|
||||
{% if e.detail_url %}<a href="{{ e.detail_url }}" style="font-size:0.85rem;font-weight:500;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ e.name }}</a>
|
||||
{% else %}<span style="font-size:0.85rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ e.name }}</span>{% endif %}
|
||||
<div style="width:11rem;flex-shrink:0;min-width:0;">
|
||||
<div style="display:flex;align-items:baseline;gap:0.4rem;">
|
||||
{{ status_dot(e.status) }}
|
||||
{% if e.detail_url %}<a href="{{ e.detail_url }}" style="font-size:0.85rem;font-weight:500;color:var(--text);overflow-wrap:anywhere;">{{ e.name }}</a>
|
||||
{% else %}<span style="font-size:0.85rem;font-weight:500;overflow-wrap:anywhere;">{{ e.name }}</span>{% endif %}
|
||||
</div>
|
||||
<div style="color:var(--text-dim);font-size:0.7rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-left:0.85rem;">
|
||||
<div style="color:var(--text-dim);font-size:0.7rem;overflow-wrap:anywhere;padding-left:0.85rem;">
|
||||
<span style="text-transform:uppercase;letter-spacing:0.04em;">{{ e.kind }}</span> · {{ e.target }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,17 +74,17 @@
|
||||
{% set pad = 30 - e.heartbeat|length %}
|
||||
{% if pad > 0 %}{% for _ in range(pad) %}<span class="pill pill-empty" title="No data"></span>{% endfor %}{% endif %}
|
||||
{% for hb in e.heartbeat %}
|
||||
<span class="pill" style="background:{% if hb.state == 'down' %}#6a1515{% else %}#1a6632{% endif %};" title="{{ hb.title }}"></span>
|
||||
<span class="pill pill-{{ 'down' if hb.state == 'down' else 'up' }}" title="{{ hb.title }}"></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# latest latency + sparkline #}
|
||||
<div style="width:92px;text-align:right;flex-shrink:0;font-size:0.8rem;font-variant-numeric:tabular-nums;">
|
||||
{% if e.status == 'down' %}<span style="color:var(--red);font-weight:bold;">DOWN</span>
|
||||
{% if e.status == 'down' %}{{ status_badge('down') }}
|
||||
{% elif e.latency_ms is not none %}
|
||||
{% if e.spark_svg %}<span style="opacity:0.8;">{{ e.spark_svg|safe }}</span><br>{% endif %}
|
||||
<span style="color:var(--text-muted);">{{ "%.0f"|format(e.latency_ms) }} ms</span>
|
||||
{% elif e.status == 'up' %}<span style="color:var(--green);">UP</span>
|
||||
{% elif e.status == 'up' %}{{ status_badge('up') }}
|
||||
{% else %}<span style="color:var(--text-dim);">—</span>{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -81,11 +95,9 @@
|
||||
|
||||
{# TLS expiry countdown #}
|
||||
<span style="width:64px;text-align:right;flex-shrink:0;font-size:0.78rem;font-variant-numeric:tabular-nums;">
|
||||
{% if e.tls_days is none %}<span style="color:var(--text-dim);">—</span>
|
||||
{% elif e.tls_days < 0 %}<span style="color:var(--red);font-weight:bold;" title="Certificate expired">expired</span>
|
||||
{% elif e.tls_days < 14 %}<span style="color:var(--red);" title="TLS certificate expiry">{{ e.tls_days|round|int }}d</span>
|
||||
{% elif e.tls_days < 30 %}<span style="color:var(--yellow);" title="TLS certificate expiry">{{ e.tls_days|round|int }}d</span>
|
||||
{% else %}<span style="color:var(--text-muted);" title="TLS certificate expiry">{{ e.tls_days|round|int }}d</span>{% endif %}
|
||||
{% if e.tls_days is none %}<span class="tone-unknown">—</span>
|
||||
{% elif e.tls_days < 0 %}<span class="tone-crit" title="Certificate expired">expired</span>
|
||||
{% else %}<span class="tone-{{ status_class(e.tls_days, 'tls_days') }}" title="TLS certificate expires in {{ e.tls_days|round|int }} days">{{ e.tls_days|round|int }}d</span>{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
Reference in New Issue
Block a user