feat(ui): host page leads with its alerts; old readings say how old (#5210)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 50s
CI / unit (push) Successful in 1m20s
CI / publish (push) Successful in 1m13s

#5210 "Problems first, with since-time, across every list" (milestone 477),
second batch.

- Host page: an "Alerts on this host" strip first (rules on its agent
  series, its containers or its monitors: alert_svc.watches_host); its
  monitors problems first with "Down 12m · reason"; Docker above Ansible.
- Uptime page and widget: worst 24h uptime first, then 7d and 30d; hosts
  with no uptime yet last.
- Agent panel and fleet widget: last seen as "3m ago", not a clock time.
- SNMP: a device is "Reachable" only while it answered within the last 3
  polls; older readings say "No answer" with their age, and every device
  says when it was last read (was: reachable whenever any reading existed).
- Traefik: a router missing from 3 scrapes in a row is listed apart as no
  longer reported, by when it was last seen, instead of keeping its last
  state (a removed router stayed Failing).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 00:02:20 -04:00
co-authored by Claude Opus 5.5
parent 5a14e7c693
commit 8e890c6914
15 changed files with 231 additions and 31 deletions
+2 -2
View File
@@ -4,9 +4,9 @@
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:0.75rem;gap:1rem;flex-wrap:wrap;"> <div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:0.75rem;gap:1rem;flex-wrap:wrap;">
<h3 class="section-title" style="margin-bottom:0;">Agent</h3> <h3 class="section-title" style="margin-bottom:0;">Agent</h3>
{% if reporting %} {% if reporting %}
<span style="font-size:0.78rem;color:{{ 'var(--yellow)' if stale else 'var(--green)' }};"> <span style="font-size:0.78rem;" class="tone-{{ 'warn' if stale else 'ok' }}">
{{ 'stale' if stale else 'reporting' }}{% if reg.agent_version %} · v{{ reg.agent_version }}{% endif %} {{ 'stale' if stale else 'reporting' }}{% if reg.agent_version %} · v{{ reg.agent_version }}{% endif %}
· last seen {{ reg.last_seen_at.strftime('%Y-%m-%d %H:%M') }} · last seen {{ reg.last_seen_at|ago }}
</span> </span>
{% elif reg %} {% elif reg %}
<span style="font-size:0.78rem;color:var(--text-muted);">pending — no check-in yet</span> <span style="font-size:0.78rem;color:var(--text-muted);">pending — no check-in yet</span>
@@ -39,7 +39,7 @@
<span style="font-weight:500;font-size:0.85rem;overflow-wrap:anywhere;">{{ r.host.name }}</span> <span style="font-weight:500;font-size:0.85rem;overflow-wrap:anywhere;">{{ r.host.name }}</span>
{% endif %} {% endif %}
<span style="display:block;font-size:0.7rem;color:var(--text-dim);"> <span style="display:block;font-size:0.7rem;color:var(--text-dim);">
{{ r.reg.last_seen_at.strftime("%H:%M:%S") if r.reg.last_seen_at else "never" }} {% if r.reg.last_seen_at %}seen {{ r.reg.last_seen_at|ago }}{% else %}never seen{% endif %}
</span> </span>
</span> </span>
</div> </div>
+31 -6
View File
@@ -13,10 +13,32 @@ from steward.models.metrics import PluginMetric
snmp_bp = Blueprint("snmp", __name__, template_folder="templates") snmp_bp = Blueprint("snmp", __name__, template_folder="templates")
async def _latest_readings(db, device_names: list[str]) -> dict[str, dict[str, float]]: async def _latest_readings(db, device_names: list[str]) -> dict[str, dict]:
"""Return {device_name: {label: latest_value}} for all configured devices.""" """{device_name: {"readings": {label: latest_value}, "at": newest reading's
time}} for the devices that have any readings."""
latest = await latest_by_resource(db, "snmp", device_names) latest = await latest_by_resource(db, "snmp", device_names)
return {name: {m: r.value for m, r in metrics.items()} for name, metrics in latest.items()} return {name: {"readings": {m: r.value for m, r in metrics.items()},
"at": max(r.recorded_at for r in metrics.values())}
for name, metrics in latest.items() if metrics}
# A device whose newest reading is older than this many poll intervals is not
# shown as reachable: the poller has stopped getting answers from it.
STALE_POLLS = 3
def _device_state(entry: dict | None, poll_seconds: int, now: datetime) -> dict:
"""What the page says about a device's readings: {"readings", "at",
"reach"}, reach being "up" (answered within STALE_POLLS polls), "stale"
(has readings, but none recent) or "pending" (never answered)."""
if not entry:
return {"readings": {}, "at": None, "reach": "pending"}
fresh = (now - entry["at"]).total_seconds() <= STALE_POLLS * max(int(poll_seconds), 1)
return {**entry, "reach": "up" if fresh else "stale"}
def _poll_seconds() -> int:
return int(current_app.config["PLUGINS"]["snmp"].get("poll_interval_seconds", 60))
async def _history(db, device_name: str, hours: int = 24) -> dict[str, list]: async def _history(db, device_name: str, hours: int = 24) -> dict[str, list]:
@@ -49,6 +71,7 @@ async def index():
async with current_app.db_sessionmaker() as db: async with current_app.db_sessionmaker() as db:
latest = await _latest_readings(db, device_names) latest = await _latest_readings(db, device_names)
now = datetime.now(timezone.utc)
# Merge config + latest readings for the template # Merge config + latest readings for the template
devices = [] devices = []
@@ -58,7 +81,7 @@ async def index():
"name": name, "name": name,
"host": cfg.get("host", ""), "host": cfg.get("host", ""),
"oids": cfg.get("oids", []), "oids": cfg.get("oids", []),
"readings": latest.get(name, {}), **_device_state(latest.get(name), _poll_seconds(), now),
}) })
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60) poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
@@ -124,12 +147,13 @@ async def host_panel(host_id: str):
return "" return ""
names = [d.get("name") or d.get("host", "?") for d in matched] names = [d.get("name") or d.get("host", "?") for d in matched]
latest = await _latest_readings(db, names) latest = await _latest_readings(db, names)
now = datetime.now(timezone.utc)
devices = [{ devices = [{
"name": d.get("name") or d.get("host", "?"), "name": d.get("name") or d.get("host", "?"),
"host": d.get("host", ""), "host": d.get("host", ""),
"oids": d.get("oids", []), "oids": d.get("oids", []),
"readings": latest.get(d.get("name") or d.get("host", "?"), {}), **_device_state(latest.get(d.get("name") or d.get("host", "?")), _poll_seconds(), now),
"bound": bool(str(d.get("host_id", "")).strip() "bound": bool(str(d.get("host_id", "")).strip()
or str(d.get("steward_host", "")).strip()), or str(d.get("steward_host", "")).strip()),
} for d in matched] } for d in matched]
@@ -144,6 +168,7 @@ async def widget():
async with current_app.db_sessionmaker() as db: async with current_app.db_sessionmaker() as db:
latest = await _latest_readings(db, device_names[:10]) latest = await _latest_readings(db, device_names[:10])
now = datetime.now(timezone.utc)
devices = [] devices = []
for cfg in devices_cfg[:10]: for cfg in devices_cfg[:10]:
@@ -151,7 +176,7 @@ async def widget():
devices.append({ devices.append({
"name": name, "name": name,
"oids": cfg.get("oids", []), "oids": cfg.get("oids", []),
"readings": latest.get(name, {}), **_device_state(latest.get(name), _poll_seconds(), now),
}) })
return await render_template("snmp/widget.html", return await render_template("snmp/widget.html",
+4 -2
View File
@@ -15,8 +15,10 @@
<span title="Explicitly bound to this host (host_id / steward_host)" <span title="Explicitly bound to this host (host_id / steward_host)"
style="font-size:0.68rem;color:var(--text-muted);background:var(--bg-elevated);border:1px solid var(--border);border-radius:3px;padding:0.05rem 0.4rem;">bound</span> style="font-size:0.68rem;color:var(--text-muted);background:var(--bg-elevated);border:1px solid var(--border);border-radius:3px;padding:0.05rem 0.4rem;">bound</span>
{% endif %} {% endif %}
{% if device.readings %} {% if device.reach == 'up' %}
<span style="font-size:0.74rem;color:var(--green);">&#9679; reachable</span> <span style="font-size:0.74rem;" class="tone-ok">&#9679; reachable · read {{ device.at|ago }}</span>
{% elif device.reach == 'stale' %}
<span style="font-size:0.74rem;" class="tone-warn" title="The values below are from then">&#9679; no answer since {{ device.at|ago }}</span>
{% else %} {% else %}
<span style="font-size:0.74rem;color:var(--text-dim);">&#9675; no data yet</span> <span style="font-size:0.74rem;color:var(--text-dim);">&#9675; no data yet</span>
{% endif %} {% endif %}
+6 -1
View File
@@ -12,9 +12,14 @@
{% for device in devices %} {% for device in devices %}
<section class="card" aria-labelledby="snmp-{{ loop.index }}"> <section class="card" aria-labelledby="snmp-{{ loop.index }}">
<div class="host-health-head"> <div class="host-health-head">
{{ status_badge('up', 'Reachable') if device.readings else status_badge('pending', 'No data yet') }} {#- reach: answered within the last few polls (up), only long ago (stale),
or never (pending); routes._device_state. #}
{% if device.reach == 'up' %}{{ status_badge('up', 'Reachable') }}
{% elif device.reach == 'stale' %}{{ status_badge('stale', 'No answer', title="The last reading is from " ~ device.at.strftime('%Y-%m-%d %H:%M UTC') ~ "; the values below are from then") }}
{% else %}{{ status_badge('pending', 'No data yet') }}{% endif %}
<h2 id="snmp-{{ loop.index }}" class="section-title" style="margin:0;">{{ device.name }}</h2> <h2 id="snmp-{{ loop.index }}" class="section-title" style="margin:0;">{{ device.name }}</h2>
<span class="field-hint mono">{{ device.host }}</span> <span class="field-hint mono">{{ device.host }}</span>
{% if device.at %}<span class="field-hint">read {{ device.at|ago }}</span>{% endif %}
<a href="/plugins/snmp/device/{{ device.name }}" class="btn btn-sm btn-ghost" style="margin-left:auto;">History</a> <a href="/plugins/snmp/device/{{ device.name }}" class="btn btn-sm btn-ghost" style="margin-left:auto;">History</a>
</div> </div>
+4 -2
View File
@@ -6,8 +6,10 @@
<span style="font-size:0.82rem;font-weight:500;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"> <span style="font-size:0.82rem;font-weight:500;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
{{ device.name }} {{ device.name }}
</span> </span>
{% if device.readings %} {% if device.reach == 'up' %}
<span style="font-size:0.7rem;color:var(--green);">&#9679;</span> <span style="font-size:0.7rem;" class="tone-ok" title="Reachable · read {{ device.at.strftime('%H:%M UTC') }}">&#9679;</span>
{% elif device.reach == 'stale' %}
<span style="font-size:0.7rem;" class="tone-warn">no answer since {{ device.at|ago }}</span>
{% else %} {% else %}
<span style="font-size:0.7rem;color:var(--text-dim);">&#9675;</span> <span style="font-size:0.7rem;color:var(--text-dim);">&#9675;</span>
{% endif %} {% endif %}
+23 -2
View File
@@ -1,7 +1,7 @@
# plugins/traefik/routes.py # plugins/traefik/routes.py
from __future__ import annotations from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from quart import Blueprint, current_app, render_template, request from quart import Blueprint, current_app, render_template, request
from sqlalchemy import Integer, cast, func, select from sqlalchemy import Integer, cast, func, select
import json import json
@@ -58,6 +58,24 @@ def router_state(latest, thresholds: dict) -> tuple[str, str | None]:
return "ok", None return "ok", None
# A router missing from this many scrapes in a row is no longer reported:
# Traefik dropped it (a removed service), so its last state is history.
GONE_AFTER_SCRAPES = 3
def gone_routers(routers: list[dict], scrape_seconds: int) -> tuple[list[dict], list[dict]]:
"""(current, gone): a router whose newest row trails the newest scrape of
any router by GONE_AFTER_SCRAPES intervals is gone, newest-seen first."""
if not routers:
return [], []
newest = max(r["latest"].scraped_at for r in routers)
cutoff = newest - timedelta(seconds=GONE_AFTER_SCRAPES * max(int(scrape_seconds), 1))
current = [r for r in routers if r["latest"].scraped_at >= cutoff]
gone = sorted((r for r in routers if r["latest"].scraped_at < cutoff),
key=lambda r: r["latest"].scraped_at, reverse=True)
return current, gone
def split_routers(routers: list[dict]) -> tuple[list[dict], list[dict]]: def split_routers(routers: list[dict]) -> tuple[list[dict], list[dict]]:
"""(problems, healthy): failing routers before slow or noisy ones, each """(problems, healthy): failing routers before slow or noisy ones, each
group by name; healthy routers by name.""" group by name; healthy routers by name."""
@@ -171,11 +189,14 @@ async def rows():
"days_remaining": days, "days_remaining": days,
}) })
problems, healthy = split_routers(router_data) current, gone = gone_routers(
router_data, current_app.config["PLUGINS"]["traefik"]["scrape_interval_seconds"])
problems, healthy = split_routers(current)
return await render_template( return await render_template(
"traefik/rows.html", "traefik/rows.html",
problems=problems, problems=problems,
healthy=healthy, healthy=healthy,
gone=gone,
error_4xx=ERROR_4XX_PCT, error_4xx=ERROR_4XX_PCT,
error_5xx=ERROR_5XX_PCT, error_5xx=ERROR_5XX_PCT,
global_stat=global_stat, global_stat=global_stat,
+17 -1
View File
@@ -37,7 +37,7 @@
</div> </div>
{% endmacro %} {% endmacro %}
{% if problems or healthy or certs %} {% if problems or healthy or gone or certs %}
{# ── Routers that need attention ─────────────────────────────────────────── #} {# ── Routers that need attention ─────────────────────────────────────────── #}
{% if problems %} {% if problems %}
<div style="column-width:340px;column-gap:1rem;"> <div style="column-width:340px;column-gap:1rem;">
@@ -67,6 +67,22 @@
</table> </table>
</div> </div>
</details> </details>
{% endif %}
{# ── Routers Traefik no longer reports: their last numbers are history, not a
state, so they are listed by when they were last seen and nothing else. ── #}
{% if gone %}
<details class="card" style="margin-bottom:1rem;">
<summary class="section-title" style="cursor:pointer;">{{ gone|length }} router{{ 's' if gone|length != 1 }} no longer reported</summary>
<p class="field-hint" style="margin:0.5rem 0;">Traefik stopped reporting these, usually because the service behind them was removed.</p>
<ul class="problem-list">
{% for r in gone %}
<li><span style="word-break:break-all;">{{ r.name }}</span> <span class="problem-detail">last seen {{ r.latest.scraped_at|ago }}</span></li>
{% endfor %}
</ul>
</details>
{% endif %}
{% if healthy %}
<script> <script>
// The fragment is re-polled; keep the healthy list as the reader left it. // The fragment is re-polled; keep the healthy list as the reader left it.
(function () { (function () {
+12
View File
@@ -215,6 +215,18 @@ async def active_rows(db, now: datetime) -> list[dict]:
return rows return rows
def watches_host(rule: AlertRule, host_name: str, monitor_keys: set) -> bool:
"""Whether a rule watches something on this host: its agent series
("<host>" or "<host>:<part>"), one of its containers ("<host>/<name>"),
or one of its monitors ((type, name) in monitor_keys)."""
source, resource = rule.source_module, rule.resource_name
if source == "host_agent":
return resource == host_name or resource.startswith(host_name + ":")
if source == "docker":
return resource.startswith(host_name + "/")
return (source, resource) in monitor_keys
async def active_count(db) -> int: async def active_count(db) -> int:
"""How many alerts need attention: the sidebar's number.""" """How many alerts need attention: the sidebar's number."""
from sqlalchemy import func from sqlalchemy import func
+9 -3
View File
@@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import uuid import uuid
from datetime import datetime, timezone
from quart import Blueprint, render_template, request, redirect, url_for, current_app, session from quart import Blueprint, render_template, request, redirect, url_for, current_app, session
from sqlalchemy import select from sqlalchemy import select
from steward.ansible import executor, sources as ansible_src from steward.ansible import executor, sources as ansible_src
from steward.alerts import service as alert_svc
from steward.auth.middleware import require_role from steward.auth.middleware import require_role
from steward.core import renames from steward.core import renames
from steward.core.audit import audit_request, log_audit from steward.core.audit import audit_request, log_audit
@@ -78,6 +80,10 @@ async def host_detail(host_id: str):
return "Not found", 404 return "Not found", 404
monitors = await host_svc.host_monitors(db, host_id) monitors = await host_svc.host_monitors(db, host_id)
uptime = (await host_svc.compute_uptime(db)).get(host_id) uptime = (await host_svc.compute_uptime(db)).get(host_id)
monitor_keys = {(getattr(d["monitor"].type, "value", d["monitor"].type), d["monitor"].name)
for d in monitors}
alerts = [r for r in await alert_svc.active_rows(db, datetime.now(timezone.utc))
if alert_svc.watches_host(r["rule"], host.name, monitor_keys)]
linked_target = (await db.execute( linked_target = (await db.execute(
select(AnsibleTarget).where(AnsibleTarget.host_id == host_id) select(AnsibleTarget).where(AnsibleTarget.host_id == host_id)
.options(selectinload(AnsibleTarget.groups)))).scalar_one_or_none() .options(selectinload(AnsibleTarget.groups)))).scalar_one_or_none()
@@ -88,7 +94,7 @@ async def host_detail(host_id: str):
from steward.models.monitors import MonitorType from steward.models.monitors import MonitorType
return await render_template( return await render_template(
"hosts/detail.html", "hosts/detail.html",
host=host, monitors=monitors, uptime=uptime, host=host, monitors=monitors, uptime=uptime, alerts=alerts,
monitor_types=list(MonitorType), monitor_types=list(MonitorType),
linked_target=linked_target, linkable_targets=linkable_targets, linked_target=linked_target, linkable_targets=linkable_targets,
ansible_sources=_ansible_source_names(), ansible_sources=_ansible_source_names(),
@@ -305,8 +311,8 @@ async def delete_host(host_id: str):
async def uptime_page(): async def uptime_page():
async with current_app.db_sessionmaker() as db: async with current_app.db_sessionmaker() as db:
result = await db.execute(select(Host).order_by(Host.name)) result = await db.execute(select(Host).order_by(Host.name))
hosts = result.scalars().all()
uptime = await host_svc.compute_uptime(db) uptime = await host_svc.compute_uptime(db)
hosts = host_svc.worst_uptime_first(result.scalars().all(), uptime)
return await render_template("hosts/uptime.html", hosts=hosts, uptime=uptime) return await render_template("hosts/uptime.html", hosts=hosts, uptime=uptime)
@@ -315,8 +321,8 @@ async def uptime_page():
async def uptime_widget(): async def uptime_widget():
async with current_app.db_sessionmaker() as db: async with current_app.db_sessionmaker() as db:
result = await db.execute(select(Host).order_by(Host.name)) result = await db.execute(select(Host).order_by(Host.name))
hosts = result.scalars().all()
uptime = await host_svc.compute_uptime(db) uptime = await host_svc.compute_uptime(db)
hosts = host_svc.worst_uptime_first(result.scalars().all(), uptime)
return await render_template( return await render_template(
"hosts/uptime_widget.html", "hosts/uptime_widget.html",
hosts=hosts, hosts=hosts,
+20 -2
View File
@@ -113,6 +113,17 @@ async def host_status(db, host_ids: list[str]) -> dict[str, dict]:
return out return out
def worst_uptime_first(hosts, uptime: dict) -> list:
"""Hosts by their 24h uptime, worst first, then 7d and 30d to break ties;
hosts with no uptime yet (no monitors) last, by name."""
def key(h):
u = uptime.get(h.id) or {}
windows = [u.get(w) for w in ("24h", "7d", "30d")]
known = [w for w in windows if w is not None]
return (not known, *[101.0 if w is None else w for w in windows], h.name.lower())
return sorted(hosts, key=key)
def host_rank(status: dict | None, agent: dict | None) -> int: def host_rank(status: dict | None, agent: dict | None) -> int:
"""Where a host sorts in the Hosts list: a down monitor first, then an """Where a host sorts in the Hosts list: a down monitor first, then an
agent gone quiet, then monitors not yet checked, then the healthy; a host agent gone quiet, then monitors not yet checked, then the healthy; a host
@@ -137,16 +148,23 @@ async def host_monitors(db, host_id: str) -> list[dict]:
recent = await _last_n_results(db, ids) recent = await _last_n_results(db, ids)
uptime = await _uptime_by_monitor(db, ids) uptime = await _uptime_by_monitor(db, ids)
data = [] data = []
from steward.monitors.service import down_reason, down_since
for m in monitors: for m in monitors:
mrows = recent.get(m.id, []) mrows = recent.get(m.id, [])
latest = mrows[-1] if mrows else None latest = mrows[-1] if mrows else None
state = ("paused" if not m.enabled else "pending" if latest is None
else "up" if latest.is_up else "down")
data.append({ data.append({
"monitor": m, "latest": latest, "monitor": m, "latest": latest, "state": state,
"heartbeat": [{"state": "up" if r.is_up else "down", "heartbeat": [{"state": "up" if r.is_up else "down",
"title": _heartbeat_title(m.type, r)} for r in mrows], "title": _heartbeat_title(m.type, r)} for r in mrows],
"uptime_pct": uptime.get(m.id, {}).get("24h"), "uptime_pct": uptime.get(m.id, {}).get("24h"),
"down_since": await down_since(db, m.id) if state == "down" else None,
"down_reason": down_reason(latest) if state == "down" else None,
}) })
return data # Problems first: down, then not yet checked, then up, paused last.
order = {"down": 0, "pending": 1, "up": 2, "paused": 3}
return sorted(data, key=lambda d: (order[d["state"]], d["monitor"].name.lower()))
async def agent_overview_by_host( async def agent_overview_by_host(
+33 -6
View File
@@ -24,6 +24,27 @@
</div> </div>
{# ── Alerts on this host: what is breaching, before anything else ─────────── #}
{% if alerts %}
<section class="card problem-card" aria-labelledby="host-alerts" style="margin-bottom:1rem;">
<h2 id="host-alerts" class="section-title" style="margin-bottom:0.6rem;">Alerts on this host</h2>
<ul class="problem-list">
{% for row in alerts %}
<li>
{{ status_badge(row.label) }}
<span class="problem-detail">
{{- row.says.lead -}}
{%- if row.url %}<a href="{{ row.url }}">{{ row.says.resource }}</a>{% else %}<strong>{{ row.says.resource }}</strong>{% endif -%}
{{- row.says.tail -}}
{%- if row.label == 'acknowledged' and row.acked_by %} · acknowledged by {{ row.acked_by }}{% endif -%}
</span>
<a href="/alerts/history?rule={{ row.rule.id }}" class="field-hint" style="margin-left:0.4rem;">{{ row.rule.name }}</a>
</li>
{% endfor %}
</ul>
</section>
{% endif %}
{# ── Live vitals strip (host_agent fragment) — full width on top, polled ────── #} {# ── Live vitals strip (host_agent fragment) — full width on top, polled ────── #}
<div id="hv-strip" <div id="hv-strip"
hx-get="/plugins/host_agent/vitals/{{ host.id }}" hx-get="/plugins/host_agent/vitals/{{ host.id }}"
@@ -52,10 +73,15 @@
<div class="ping-row{% if not m.enabled %} ping-row-paused{% endif %}"> <div class="ping-row{% if not m.enabled %} ping-row-paused{% endif %}">
<div class="ping-meta" style="min-width:150px;max-width:230px;"> <div class="ping-meta" style="min-width:150px;max-width:230px;">
<div style="display:flex;align-items:baseline;gap:0.4rem;"> <div style="display:flex;align-items:baseline;gap:0.4rem;">
{{ status_dot('paused' if not m.enabled else ('pending' if latest is none else ('up' if latest.is_up else 'down'))) }} {{ status_dot(d.state) }}
<a class="ping-name" href="/monitors/{{ m.id }}">{{ m.name }}</a> <a class="ping-name" href="/monitors/{{ m.id }}">{{ m.name }}</a>
</div> </div>
<span class="ping-addr" title="{{ m.target }}">{{ m.type|monitor_type }} · {{ m.target }}</span> <span class="ping-addr" title="{{ m.target }}">{{ m.type|monitor_type }} · {{ m.target }}</span>
{% if d.state == 'down' %}
<span class="tone-crit" style="font-size:0.75rem;overflow-wrap:anywhere;">
Down{% if d.down_since %} {{ d.down_since|ago|replace(' ago', '') }}{% endif %}{% if d.down_reason %} · {{ d.down_reason }}{% endif %}
</span>
{% endif %}
</div> </div>
<div class="ping-pills"> <div class="ping-pills">
{% for _ in range([30 - d.heartbeat|length, 0]|max) %}<span class="pill pill-empty" title="No data"></span>{% endfor %} {% for _ in range([30 - d.heartbeat|length, 0]|max) %}<span class="pill pill-empty" title="No data"></span>{% endfor %}
@@ -124,6 +150,12 @@
</div> </div>
</div>{# Monitors + Agent grid #} </div>{# Monitors + Agent grid #}
{# ── Docker (docker plugin fragment; renders nothing if the host has none);
above Ansible: containers are state to read, Ansible is something to do ──── #}
{% if "docker" in enabled_plugins %}
<div hx-get="/plugins/docker/host/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML"></div>
{% endif %}
{# ── Ansible (full width) ─────────────────────────────────────────────────── #} {# ── Ansible (full width) ─────────────────────────────────────────────────── #}
<div class="card"> <div class="card">
<h3 class="section-title">Ansible</h3> <h3 class="section-title">Ansible</h3>
@@ -211,11 +243,6 @@
{% endif %} {% endif %}
</div>{# Ansible card #} </div>{# Ansible card #}
{# ── Docker (docker plugin fragment; renders nothing if the host has none) ──── #}
{% if "docker" in enabled_plugins %}
<div hx-get="/plugins/docker/host/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML"></div>
{% endif %}
{# ── SNMP (renders nothing unless a configured SNMP device maps to this host) ── #} {# ── SNMP (renders nothing unless a configured SNMP device maps to this host) ── #}
{% if "snmp" in enabled_plugins %} {% if "snmp" in enabled_plugins %}
<div hx-get="/plugins/snmp/host/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML"></div> <div hx-get="/plugins/snmp/host/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML"></div>
+24
View File
@@ -29,3 +29,27 @@ def test_schedules_sort_failing_first_and_paused_last():
_sched("busy", last_error="the last run is still going", status="success")] _sched("busy", last_error="the last run is still going", status="success")]
assert [r["s"].name for r in sorted(rows, key=_schedule_order)] == [ assert [r["s"].name for r in sorted(rows, key=_schedule_order)] == [
"backup", "broken", "cut", "deploy", "busy", "nightly", "paused"] "backup", "broken", "cut", "deploy", "busy", "nightly", "paused"]
def test_a_host_page_shows_only_the_alerts_on_that_host():
from steward.alerts.service import watches_host
def rule(source, resource):
return NS(source_module=source, resource_name=resource)
mine = {("http", "nas web")}
assert watches_host(rule("host_agent", "nas"), "nas", mine)
assert watches_host(rule("host_agent", "nas:/"), "nas", mine)
assert not watches_host(rule("host_agent", "nas2"), "nas", mine) # a name that merely starts alike
assert watches_host(rule("docker", "nas/plex"), "nas", mine)
assert not watches_host(rule("docker", "nas2/plex"), "nas", mine)
assert watches_host(rule("http", "nas web"), "nas", mine)
assert not watches_host(rule("http", "other web"), "nas", mine)
def test_uptime_lists_the_worst_first_and_the_unmeasured_last():
from steward.hosts.service import worst_uptime_first
hosts = [NS(id=i, name=n) for i, n in [("a", "alpha"), ("b", "bravo"), ("c", "charlie"), ("d", "delta")]]
uptime = {"a": {"24h": 100.0, "7d": 99.0, "30d": 99.5},
"b": {"24h": 92.0, "7d": 98.0, "30d": 99.0},
"c": {"24h": 100.0, "7d": 95.0, "30d": 97.0}}
assert [h.name for h in worst_uptime_first(hosts, uptime)] == ["bravo", "charlie", "alpha", "delta"]
+19 -2
View File
@@ -1,5 +1,8 @@
"""The SNMP list says each device's state with the shared badge and shows its """The SNMP list says each device's state with the shared badge and shows its
readings as stat tiles (#5126).""" readings as stat tiles (#5126)."""
from datetime import datetime, timedelta, timezone
from plugins.snmp.routes import _device_state
from tests._templates import render_plugin_template from tests._templates import render_plugin_template
from tests.test_style_tokens import hex_colours from tests.test_style_tokens import hex_colours
@@ -8,15 +11,29 @@ async def test_devices_wear_a_status_badge_and_readings_are_tiles(app):
devices = [ devices = [
{"name": "ups", "host": "10.0.0.9", "oids": [{"oid": "1.3.6", "label": "battery_charge_pct"}, {"name": "ups", "host": "10.0.0.9", "oids": [{"oid": "1.3.6", "label": "battery_charge_pct"},
{"oid": "1.3.7", "label": "in_octets"}], {"oid": "1.3.7", "label": "in_octets"}],
"readings": {"battery_charge_pct": 97.0, "in_octets": 2_500_000.0}}, "readings": {"battery_charge_pct": 97.0, "in_octets": 2_500_000.0}, "reach": "up",
{"name": "printer", "host": "10.0.0.10", "oids": [], "readings": {}}, "at": datetime.now(timezone.utc) - timedelta(seconds=30)},
{"name": "switch", "host": "10.0.0.11", "oids": [], "readings": {"x": 1.0}, "reach": "stale",
"at": datetime.now(timezone.utc) - timedelta(hours=3)},
{"name": "printer", "host": "10.0.0.10", "oids": [], "readings": {}, "reach": "pending", "at": None},
] ]
async with app.test_request_context("/plugins/snmp/"): async with app.test_request_context("/plugins/snmp/"):
page = await render_plugin_template(app, "snmp", "snmp/index.html", devices=devices, poll_interval=60) page = await render_plugin_template(app, "snmp", "snmp/index.html", devices=devices, poll_interval=60)
assert '<span class="badge badge-ok">Reachable</span>' in page assert '<span class="badge badge-ok">Reachable</span>' in page
assert '<span class="badge badge-warn">No data yet</span>' in page assert '<span class="badge badge-warn">No data yet</span>' in page
# Old readings are not "reachable": they say how old they are (#5210).
assert 'badge badge-warn" title="The last reading is from' in page and ">No answer</span>" in page
assert ">3h ago</time>" in page
assert '<span class="stat-tile-value">97</span>' in page and '<span class="stat-tile-value">2.50M</span>' in page assert '<span class="stat-tile-value">97</span>' in page and '<span class="stat-tile-value">2.50M</span>' in page
assert 'href="/plugins/snmp/device/ups"' in page assert 'href="/plugins/snmp/device/ups"' in page
main = page[page.index("<main"):] main = page[page.index("<main"):]
assert "font-weight:600" not in main and not hex_colours(main), hex_colours(main) assert "font-weight:600" not in main and not hex_colours(main), hex_colours(main)
def test_a_device_is_reachable_only_while_its_readings_are_recent():
now = datetime.now(timezone.utc)
entry = {"readings": {"x": 1.0}, "at": now - timedelta(seconds=150)}
assert _device_state(entry, 60, now)["reach"] == "up" # within 3 polls
assert _device_state(entry, 30, now)["reach"] == "stale" # 5 polls without an answer
assert _device_state(None, 60, now) == {"readings": {}, "at": None, "reach": "pending"}
+26 -1
View File
@@ -5,7 +5,7 @@ from types import SimpleNamespace as NS
import pytest import pytest
from plugins.traefik.routes import router_state, split_routers from plugins.traefik.routes import gone_routers, router_state, split_routers
from steward.core.settings import to_thresholds_cfg from steward.core.settings import to_thresholds_cfg
from tests._templates import render_plugin_template from tests._templates import render_plugin_template
from tests.test_style_tokens import hex_colours from tests.test_style_tokens import hex_colours
@@ -63,3 +63,28 @@ async def test_the_page_leads_with_problems_and_folds_the_healthy(app):
assert page.index(">docs</td>") < page.index(">web</td>") assert page.index(">docs</td>") < page.index(">web</td>")
assert 'class="tone-crit">2 days</td>' in page and 'class="tone-ok">60 days</td>' in page assert 'class="tone-crit">2 days</td>' in page and 'class="tone-ok">60 days</td>' in page
assert "font-weight:600" not in page and not hex_colours(page), hex_colours(page) assert "font-weight:600" not in page and not hex_colours(page), hex_colours(page)
def test_a_router_traefik_stopped_reporting_is_set_apart_not_failing():
live = _router("api", _latest())
removed = _router("old", _latest(e5=9.0))
removed["latest"].scraped_at = live["latest"].scraped_at - timedelta(minutes=10)
recent = _router("blip", _latest())
recent["latest"].scraped_at = live["latest"].scraped_at - timedelta(seconds=60)
current, gone = gone_routers([live, removed, recent], 30) # 3 scrapes = 90 s
assert [r["name"] for r in current] == ["api", "blip"]
assert [r["name"] for r in gone] == ["old"]
assert gone_routers([], 30) == ([], [])
async def test_gone_routers_are_listed_by_when_they_were_last_seen(app):
removed = _router("old", _latest(e5=9.0))
removed["latest"].scraped_at = datetime.now(timezone.utc) - timedelta(hours=2)
async with app.test_request_context("/plugins/traefik/rows"):
page = await render_plugin_template(app, "traefik", "traefik/rows.html", problems=[], healthy=[],
gone=[removed], global_stat=None, certs=[], range_key="24h",
error_4xx=1.0, error_5xx=0.1)
page = " ".join(page.split())
assert "1 router no longer reported</summary>" in page
assert "old</span> <span class=\"problem-detail\">last seen <time" in page and ">2h ago</time>" in page
assert "Failing" not in page