feat(monitors): problems first, a page per monitor, adding on its own page (#5121)
The Monitors list now opens with a count strip (down, up with how many degraded, pending, paused) and sorts down, degraded, pending, up, paused, by name within each. Degraded is up but worth a look: uptime at or below its warning floor, or the latest response past its critical cutoff, both from Settings → Thresholds; a slow-but-tolerable reply is coloured, not sorted. A monitor had no page of its own: every link went to the list. Now /monitors/<id> shows its state and since when, latency, uptime for 24h/7d/ 30d, TLS days, the last checks, recent failures with their errors, and its settings (HTTP header names, never their values). Status links to it, and adding or editing returns to it. Adding is its own page (/monitors/new, form.html shared with edit) behind an "Add monitor" button; a refused add re-renders the form with the error and what was typed instead of a bare 400 text. Row actions are labelled icon buttons, type names read Ping/TCP/DNS/HTTP (|monitor_type), and the heartbeat uses pill-up/pill-down, so HEX_CEILING drops to 56. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+64
-12
@@ -15,7 +15,9 @@ from steward.core import renames
|
||||
from steward.core.audit import audit_request
|
||||
from steward.models.users import UserRole
|
||||
from steward.models.monitors import Monitor, MonitorType, MONITOR_TYPE_VALUES
|
||||
from steward.core.status import _heartbeat_title, streak_start
|
||||
from steward.core.time_range import DEFAULT_RANGE
|
||||
from steward.models.hosts import Host
|
||||
from steward.monitors import service as monitor_svc
|
||||
|
||||
monitors_bp = Blueprint("monitors", __name__, url_prefix="/monitors")
|
||||
@@ -49,7 +51,6 @@ async def index():
|
||||
"monitors/index.html",
|
||||
poll_interval=poll_interval,
|
||||
current_range=request.args.get("range", DEFAULT_RANGE),
|
||||
types=list(MonitorType),
|
||||
)
|
||||
|
||||
|
||||
@@ -57,11 +58,11 @@ async def index():
|
||||
@require_role(UserRole.viewer)
|
||||
async def rows():
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
data, up, down, pending, range_key = await monitor_svc.monitor_rows(
|
||||
db, request.args.get("range", DEFAULT_RANGE))
|
||||
data, counts, range_key = await monitor_svc.monitor_rows(
|
||||
db, request.args.get("range", DEFAULT_RANGE), current_app.config.get("THRESHOLDS", {}))
|
||||
return await render_template(
|
||||
"monitors/rows.html",
|
||||
monitor_data=data, up=up, down=down, pending=pending, range_key=range_key,
|
||||
monitor_data=data, counts=counts, range_key=range_key,
|
||||
)
|
||||
|
||||
|
||||
@@ -76,7 +77,7 @@ async def add_monitor():
|
||||
check_interval_seconds=form.get("check_interval_seconds"),
|
||||
opts=_form_opts(form, mtype))
|
||||
except ValueError as exc:
|
||||
return str(exc), 400
|
||||
return await _new_form(form, mtype, str(exc)), 400
|
||||
|
||||
host_id = form.get("host_id", "").strip() or None
|
||||
monitor = Monitor(id=str(uuid.uuid4()), host_id=host_id, enabled=True,
|
||||
@@ -86,13 +87,58 @@ async def add_monitor():
|
||||
try:
|
||||
await monitor_svc.check_name_free(db, monitor.type, monitor.name)
|
||||
except ValueError as exc:
|
||||
return str(exc), 400
|
||||
return await _new_form(form, mtype, str(exc)), 400
|
||||
db.add(monitor)
|
||||
|
||||
# A host-scoped add returns to that host's hub; otherwise the monitors page.
|
||||
# A host-scoped add returns to that host's hub; otherwise the new monitor's page.
|
||||
if host_id:
|
||||
return redirect(url_for("hosts.host_detail", host_id=host_id))
|
||||
return redirect(url_for("monitors.index"))
|
||||
return redirect(url_for("monitors.detail", monitor_id=monitor.id))
|
||||
|
||||
|
||||
async def _new_form(form, mtype: str, error: str | None = None) -> str:
|
||||
"""The add form, holding what was typed when `error` sends it back."""
|
||||
values = {k: form.get(k, "") for k in ("name", "target", "host_id")}
|
||||
values["check_interval_seconds"] = form.get("check_interval_seconds", 0)
|
||||
return await render_template(
|
||||
"monitors/form.html", monitor=None, values=values,
|
||||
mtype=mtype if mtype in MONITOR_TYPE_VALUES else MonitorType.icmp.value,
|
||||
config=_form_opts(form, mtype) if error and mtype in MONITOR_TYPE_VALUES else {},
|
||||
types=list(MonitorType), error=error,
|
||||
)
|
||||
|
||||
|
||||
@monitors_bp.get("/new")
|
||||
@require_role(UserRole.operator)
|
||||
async def new_form():
|
||||
return await _new_form({"host_id": request.args.get("host_id", "")}, MonitorType.icmp.value)
|
||||
|
||||
|
||||
@monitors_bp.get("/<monitor_id>")
|
||||
@require_role(UserRole.viewer)
|
||||
async def detail(monitor_id: str):
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
m = await db.get(Monitor, monitor_id)
|
||||
if not m:
|
||||
return redirect(url_for("monitors.index"))
|
||||
st = (await monitor_svc.monitor_states(db, [m]))[m.id]
|
||||
failures = await monitor_svc.recent_failures(db, m.id)
|
||||
host = await db.get(Host, m.host_id) if m.host_id else None
|
||||
thresholds = current_app.config.get("THRESHOLDS", {})
|
||||
latest = st["latest"]
|
||||
since, since_at_least = streak_start(st["recent"])
|
||||
if st["down_since"]:
|
||||
since, since_at_least = st["down_since"], False
|
||||
tls_days = None
|
||||
if latest and latest.tls_expires_at:
|
||||
tls_days = (latest.tls_expires_at - datetime.now(timezone.utc)).total_seconds() / 86400.0
|
||||
return await render_template(
|
||||
"monitors/detail.html", monitor=m, host=host, latest=latest, failures=failures,
|
||||
state=monitor_svc.row_state(m, latest, st["uptime"].get("24h"), thresholds),
|
||||
since=since, since_at_least=since_at_least, uptime=st["uptime"], tls_days=tls_days,
|
||||
heartbeat=[{"state": "up" if r.is_up else "down", "title": _heartbeat_title(m.type, r)}
|
||||
for r in st["recent"]],
|
||||
)
|
||||
|
||||
|
||||
@monitors_bp.get("/<monitor_id>/edit")
|
||||
@@ -103,7 +149,8 @@ async def edit_form(monitor_id: str):
|
||||
if not m:
|
||||
return redirect(url_for("monitors.index"))
|
||||
return await render_template(
|
||||
"monitors/edit.html", monitor=m, config=m.config, types=list(MonitorType),
|
||||
"monitors/form.html", monitor=m, values=None, mtype=m.type, config=m.config,
|
||||
types=list(MonitorType), error=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -135,7 +182,7 @@ async def edit_monitor(monitor_id: str):
|
||||
if moved:
|
||||
await audit_request("monitor.updated", "monitor", m.name,
|
||||
{"monitor_id": m.id, "renamed_from": old_name, "carried": moved})
|
||||
return redirect(url_for("monitors.index"))
|
||||
return redirect(url_for("monitors.detail", monitor_id=m.id))
|
||||
|
||||
|
||||
@monitors_bp.post("/<monitor_id>/toggle")
|
||||
@@ -157,7 +204,10 @@ async def delete_monitor(monitor_id: str):
|
||||
m = await db.get(Monitor, monitor_id)
|
||||
if m:
|
||||
await db.delete(m)
|
||||
return redirect(request.referrer or url_for("monitors.index"))
|
||||
referrer = request.referrer or ""
|
||||
if f"/monitors/{monitor_id}" in referrer:
|
||||
return redirect(url_for("monitors.index"))
|
||||
return redirect(referrer or url_for("monitors.index"))
|
||||
|
||||
|
||||
@monitors_bp.get("/widget")
|
||||
@@ -169,7 +219,9 @@ async def widget():
|
||||
widget_id = request.args.get("wid", "0")
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
data, up, down, pending, _ = await monitor_svc.monitor_rows(db, DEFAULT_RANGE)
|
||||
data, counts, _ = await monitor_svc.monitor_rows(
|
||||
db, DEFAULT_RANGE, current_app.config.get("THRESHOLDS", {}))
|
||||
up, down, pending = counts["up"], counts["down"], counts["pending"]
|
||||
if type_filter in MONITOR_TYPE_VALUES:
|
||||
data = [d for d in data if d["monitor"].type == type_filter]
|
||||
if show_down_only:
|
||||
|
||||
+42
-10
@@ -12,6 +12,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from steward.core.formatting import status_class
|
||||
from steward.core.status import _heartbeat_title, _last_n_results, _uptime_by_monitor
|
||||
from steward.core.time_range import parse_range
|
||||
from steward.models.monitors import MONITOR_TYPE_VALUES, Monitor, MonitorResult, MonitorType
|
||||
@@ -128,12 +129,42 @@ async def check_name_free(db, mtype: str, name: str, exclude_id: str | None = No
|
||||
"alert rules go by that name, so pick a different one.")
|
||||
|
||||
|
||||
async def monitor_rows(db, range_key: str):
|
||||
"""Per-monitor state: latest result, heartbeat strip, uptime and TLS days.
|
||||
# Where each state sorts on the Monitors page: what needs looking at first.
|
||||
STATE_ORDER = {"down": 0, "degraded": 1, "pending": 2, "up": 3, "paused": 4}
|
||||
|
||||
Returns (rows, up, down, pending, range_key). `range_key` picks the uptime
|
||||
window surfaced (24h/7d/30d; anything else falls back to 24h) and comes
|
||||
back normalised.
|
||||
|
||||
def row_state(monitor: Monitor, latest, uptime_pct, thresholds: dict) -> str:
|
||||
"""paused, pending (never checked), down, degraded or up.
|
||||
|
||||
Degraded is up but worth a look: uptime over the window at or below its
|
||||
warning cutoff, or the latest response time past its critical one
|
||||
(Settings → Thresholds). A slow-but-tolerable reply is coloured, not sorted.
|
||||
"""
|
||||
if not monitor.enabled:
|
||||
return "paused"
|
||||
if latest is None:
|
||||
return "pending"
|
||||
if not latest.is_up:
|
||||
return "down"
|
||||
if (status_class(uptime_pct, "uptime", thresholds) in ("warn", "crit")
|
||||
or status_class(latest.response_ms, "latency", thresholds) == "crit"):
|
||||
return "degraded"
|
||||
return "up"
|
||||
|
||||
|
||||
def problems_first(rows: list[dict]) -> list[dict]:
|
||||
"""Down, then degraded, then pending, up and paused; by name within each."""
|
||||
return sorted(rows, key=lambda d: (STATE_ORDER[d["state"]], d["monitor"].name.lower()))
|
||||
|
||||
|
||||
async def monitor_rows(db, range_key: str, thresholds: dict):
|
||||
"""Per-monitor state: latest result, heartbeat strip, uptime and TLS days,
|
||||
problems first.
|
||||
|
||||
Returns (rows, counts, range_key). `counts` has up (degraded included),
|
||||
degraded, down, pending and paused. `range_key` picks the uptime window
|
||||
surfaced (24h/7d/30d; anything else falls back to 24h) and comes back
|
||||
normalised.
|
||||
"""
|
||||
_since, range_key = parse_range(range_key)
|
||||
monitors = list((await db.execute(select(Monitor).order_by(Monitor.name))).scalars())
|
||||
@@ -148,21 +179,22 @@ async def monitor_rows(db, range_key: str):
|
||||
for m in monitors:
|
||||
rows = recent.get(m.id, [])
|
||||
latest = rows[-1] if rows else None
|
||||
uptime_pct = uptime.get(m.id, {}).get(bucket)
|
||||
tls_days = None
|
||||
if latest and latest.tls_expires_at:
|
||||
tls_days = (latest.tls_expires_at - now).total_seconds() / 86400.0
|
||||
data.append({
|
||||
"monitor": m,
|
||||
"latest": latest,
|
||||
"state": row_state(m, latest, uptime_pct, thresholds),
|
||||
"heartbeat": [{"state": "up" if r.is_up else "down",
|
||||
"title": _heartbeat_title(m.type, r)} for r in rows],
|
||||
"uptime_pct": uptime.get(m.id, {}).get(bucket),
|
||||
"uptime_pct": uptime_pct,
|
||||
"tls_days": tls_days,
|
||||
})
|
||||
up = sum(1 for d in data if d["latest"] and d["latest"].is_up)
|
||||
down = sum(1 for d in data if d["latest"] and not d["latest"].is_up)
|
||||
pending = sum(1 for d in data if not d["latest"])
|
||||
return data, up, down, pending, range_key
|
||||
counts = {k: sum(d["state"] == k for d in data) for k in STATE_ORDER}
|
||||
counts["up"] += counts["degraded"]
|
||||
return problems_first(data), counts, range_key
|
||||
|
||||
|
||||
async def down_since(db, monitor_id: str):
|
||||
|
||||
Reference in New Issue
Block a user