fix(mcp): get_monitor says how often a monitor on the global interval runs (#5433)

check_interval_s was `m.check_interval_seconds or None`, so a monitor
following monitors.poll_interval_seconds read as null and a reader
couldn't tell how often it ran. Monitor results now carry the effective
interval and `interval_from: "monitor" | "global"`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 21:50:20 -04:00
co-authored by Claude Opus 5.5
parent ca12652277
commit 8edc0c30a3
2 changed files with 22 additions and 5 deletions
+16 -4
View File
@@ -6,6 +6,7 @@ import json
from sqlalchemy import select
from steward.core import renames
from steward.mcp._context import mcp_app
from steward.models.hosts import Host
from steward.models.monitors import MONITOR_TYPE_VALUES, Monitor
from steward.models.users import UserRole
@@ -27,6 +28,15 @@ def _state(m: Monitor, latest) -> str:
return "up" if latest.is_up else "down"
def _interval(m: Monitor) -> dict:
"""How often a monitor runs: its own interval, or the global one it
follows when it has none (#5433), and which."""
if m.check_interval_seconds:
return {"check_interval_s": m.check_interval_seconds, "interval_from": "monitor"}
return {"check_interval_s": int(mcp_app().config.get("MONITORS_POLL_INTERVAL", 60)),
"interval_from": "global"}
def _tls_days(latest) -> float | None:
if latest is None or latest.tls_expires_at is None:
return None
@@ -112,8 +122,10 @@ async def get_monitor(monitor: str, failures: int = 10) -> dict:
newest first, each with its error message.
`heartbeat` is its last 30 checks oldest-first as a string, "+" up and "-"
down, to show a flapping check at a glance. Header values in an HTTP
monitor's configuration are redacted.
down, to show a flapping check at a glance. `check_interval_s` is how
often it runs; `interval_from` says whether that is its own setting
("monitor") or the global monitors interval it follows ("global").
Header values in an HTTP monitor's configuration are redacted.
"""
failures = clamp_limit(failures, 10, 50)
async with db_session() as db:
@@ -123,7 +135,7 @@ async def get_monitor(monitor: str, failures: int = 10) -> dict:
failed = await monitor_svc.recent_failures(db, m.id, failures)
return {
**_row(m, st, host_names),
"check_interval_s": m.check_interval_seconds or None,
**_interval(m),
"config": _config(m),
"heartbeat": "".join("+" if r.is_up else "-" for r in st["recent"]),
"recent_failures": [
@@ -156,7 +168,7 @@ def _saved(m: Monitor, host_names: dict[str, str]) -> dict:
"target": redact_url(m.target),
"host_name": host_names.get(m.host_id) if m.host_id else None,
"enabled": m.enabled,
"check_interval_s": m.check_interval_seconds or None,
**_interval(m),
"config": _config(m),
"next": f'get_monitor(monitor="{m.id}")',
}
@@ -76,6 +76,9 @@ def test_monitor_rule_ack_and_maintenance_writes(app):
assert made["target"] == "https://nas.lan" and made["host_name"] == host_name
assert made["config"]["headers"] == {"Authorization": "<redacted>"}
assert made["config"]["verify_ssl"] is True
# No interval of its own: it says which it follows, and how often (#5433).
assert (made["check_interval_s"], made["interval_from"]) == (
int(app.config.get("MONITORS_POLL_INTERVAL", 60)), "global")
# ── rules ──────────────────────────────────────────────────────────
opts = await a.get_alert_rule_options("http")
@@ -100,7 +103,9 @@ def test_monitor_rule_ack_and_maintenance_writes(app):
assert renamed["carried"]["from"] == mon_name and renamed["carried"]["rules"] == 1
async with app.db_sessionmaker() as s:
assert (await s.get(AlertRule, rule["rule_id"])).resource_name == f"{mon_name}-2"
assert "carried" not in await m.update_monitor(f"{mon_name}-2", check_interval_s=30)
own = await m.update_monitor(f"{mon_name}-2", check_interval_s=30)
assert "carried" not in own
assert (own["check_interval_s"], own["interval_from"]) == (30, "monitor")
# Another http monitor may not take the same name: they would share one history.
with pytest.raises(ValueError, match="already named"):
await m.create_monitor(f"{mon_name}-2", "http", "other.lan")