diff --git a/steward/core/status.py b/steward/core/status.py index 435a806..5e69c5b 100644 --- a/steward/core/status.py +++ b/steward/core/status.py @@ -38,6 +38,10 @@ class StatusEntry: spark_svg: str = "" # filled by the route via sparkline_svg() tls_days: float | None = None # days until TLS expiry (http only) detail_url: str | None = None + # When the current up/down state began, as far as the recent checks show; + # since_at_least means every check we have agrees, so it began earlier. + since: datetime | None = None + since_at_least: bool = False StatusSource = Callable[[object], Awaitable[list[StatusEntry]]] @@ -158,6 +162,17 @@ async def _uptime_by_monitor(db, monitor_ids: list[str]) -> dict[str, dict]: return out +def streak_start(results) -> tuple[datetime | None, bool]: + """(checked_at of the first check in the latest run of the same result, + whether that run fills every check given). Results are oldest-first.""" + if not results: + return None, False + i = len(results) - 1 + while i > 0 and results[i - 1].is_up == results[-1].is_up: + i -= 1 + return results[i].checked_at, i == 0 + + def _heartbeat_title(mtype: str, r) -> str: """Type-aware tooltip for one heartbeat cell.""" ts = f"{r.checked_at:%H:%M:%S} UTC" @@ -191,6 +206,7 @@ async def monitor_status_source(db) -> list[StatusEntry]: rows = recent.get(m.id, []) latest = rows[-1] if rows else None status = "pending" if latest is None else ("up" if latest.is_up else "down") + since, since_at_least = streak_start(rows) tls_days = None if latest and latest.tls_expires_at: tls_days = (latest.tls_expires_at - now).total_seconds() / 86400.0 @@ -203,5 +219,6 @@ async def monitor_status_source(db) -> list[StatusEntry]: latency_ms=latest.response_ms if latest else None, spark=[r.response_ms for r in rows if r.response_ms is not None], tls_days=tls_days, detail_url="/monitors/", + since=since, since_at_least=since_at_least, )) return entries diff --git a/steward/static/steward.css b/steward/static/steward.css index daf9315..d753d8f 100644 --- a/steward/static/steward.css +++ b/steward/static/steward.css @@ -228,6 +228,13 @@ textarea { resize: vertical; } .dot-crit { background: var(--red); } .dot-info { background: var(--blue); } .dot-label { margin-left: 0.35rem; font-size: 0.85rem; } +/* Text in a tone: status_class() results ('ok' / 'warn' / 'crit' / 'unknown') + and the STATES tones. */ +.tone-ok { color: var(--green); } +.tone-warn { color: var(--yellow); } +.tone-crit { color: var(--red); } +.tone-info { color: var(--blue); } +.tone-dim, .tone-unknown { color: var(--text-dim); } .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 */ @@ -251,6 +258,13 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat .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); } +.stat-strip { display: grid; grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); gap: 0.75rem; margin-bottom: 1rem; } + +/* "Needs attention": what is wrong, first on a page. */ +.problem-card { border-color: color-mix(in srgb, var(--fs-error) 45%, var(--border)); } +.problem-list { list-style: none; display: flex; flex-direction: column; gap: 0.45rem; } +.problem-list li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.6rem; overflow-wrap: anywhere; } +.problem-detail { color: var(--text-muted); font-size: 0.82rem; } /* Breadcrumbs */ /* Breadcrumb renders as a kicker line tucked directly above the page header @@ -307,6 +321,8 @@ body.dash-editing .widget-drawer { transform:translateY(0); } .pill { display:inline-block; width:7px; height:22px; border-radius:2px; cursor:default; transition:opacity .1s; } .pill:hover { opacity:.75; } .pill-empty { background:var(--bg-elevated); } +.pill-up { background:var(--green); } +.pill-down { background:var(--red); } .ping-cur { min-width:72px; text-align:right; font-size:0.85rem; font-variant-numeric:tabular-nums; } .ping-cur-good { color:var(--green); } .ping-cur-warn { color:var(--yellow); } diff --git a/steward/status/routes.py b/steward/status/routes.py index 202c33b..072d6e1 100644 --- a/steward/status/routes.py +++ b/steward/status/routes.py @@ -2,6 +2,7 @@ from __future__ import annotations from quart import Blueprint, current_app, render_template, request from sqlalchemy import func, select +from steward.alerts.service import active_alerts from steward.auth.middleware import require_role from steward.core.status import collect_status, sparkline_svg from steward.models.hosts import Host @@ -27,15 +28,18 @@ async def index(): @status_bp.get("/rows") @require_role(UserRole.viewer) async def rows(): - """HTMX fragment — the full status list + summary, refreshed on poll.""" + """HTMX fragment — what is wrong (down monitors, active alerts), then the + full status list, refreshed on poll.""" async with current_app.db_sessionmaker() as db: entries = await collect_status(db) + alerts = await active_alerts(db) for e in entries: if e.spark: e.spark_svg = sparkline_svg(e.spark) up, down, pending = _summarize(entries) return await render_template( - "status/rows.html", entries=entries, up=up, down=down, pending=pending + "status/rows.html", entries=entries, up=up, down=down, pending=pending, + alerts=alerts, problems=[e for e in entries if e.status == "down"], ) diff --git a/steward/templates/status/rows.html b/steward/templates/status/rows.html index 152e44e..86dc5a8 100644 --- a/steward/templates/status/rows.html +++ b/steward/templates/status/rows.html @@ -1,38 +1,52 @@ -{# status/rows.html — unified monitor list + summary, refreshed on poll #} -{% macro uptime_cell(pct) %} -{%- if pct is none -%}— -{%- elif pct >= 99.9 -%}{{ "%.2f"|format(pct) }}% -{%- elif pct >= 99 -%}{{ "%.2f"|format(pct) }}% -{%- elif pct >= 95 -%}{{ "%.2f"|format(pct) }}% -{%- else -%}{{ "%.2f"|format(pct) }}% -{%- 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 -%}— +{%- else -%}{{ "%.2f"|format(pct) }}%{%- endif -%} +{%- endmacro %} +{% macro since(e) -%} +{%- if e.since -%}{{ "for at least " if e.since_at_least else "since " }}{{ e.since|ago }}{%- endif -%} +{%- endmacro %} -{# ── Summary ─────────────────────────────────────────────────────────────── #} -