feat(monitors): unify ping/dns/http into one Monitor entity + custom targets
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 2m17s
CI / publish (push) Successful in 1m10s

Collapse the three former check types into a single core `Monitor` entity
with one management surface (/monitors), one result table (monitor_results),
and a single scheduled task. Every type can now watch a free-standing custom
destination (optional host_id) — not just a registered Host.

- models: Monitor + MonitorResult replace PingResult/DnsResult; Host loses its
  ping/dns facet columns (now Monitor rows linked by host_id).
- checks: monitors/{ping,dns,http}.py pure probes + runner.run_monitor
  dispatcher; one monitor_check scheduler with a per-monitor due-filter.
- status: single monitor_status_source replaces the three sources.
- UI: /monitors blueprint (type-aware add/edit/list/widget); host hub shows a
  host's linked monitors + "add monitor for this host"; nav + widget registry
  + alert metric catalog rewired. http plugin folded into core and removed.
- migration 0022 merges the http branch, data-migrates host facets +
  http_monitors + all three result histories, drops the old tables/columns.

Resolves the per-host ping/dns auto-attach issue (#275): monitors are now
explicit, never auto-added to every host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
2026-06-18 08:56:13 -04:00
parent 591706bd39
commit 35f658b573
53 changed files with 1628 additions and 1839 deletions
+87 -95
View File
@@ -8,9 +8,10 @@ from steward.ansible import executor, sources as ansible_src
from steward.auth.middleware import require_role
from steward.core.audit import log_audit
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
from steward.models.hosts import Host, ProbeType
from steward.models.monitors import PingResult, DnsResult, PingStatus
from steward.models.hosts import Host
from steward.models.monitors import Monitor, MonitorResult
from steward.models.users import UserRole
from steward.core.status import _last_n_results, _uptime_by_monitor, _heartbeat_title
hosts_bp = Blueprint("hosts", __name__, url_prefix="/hosts")
@@ -23,7 +24,7 @@ def _ansible_source_names() -> list[str]:
async def _compute_uptime(db) -> dict[str, dict]:
"""Return per-host uptime % for 24h, 7d, 30d windows.
"""Per-host uptime % for 24h/7d/30d, aggregated over the host's monitors.
Returns {host_id: {"24h": float|None, "7d": float|None, "30d": float|None}}.
"""
@@ -34,25 +35,23 @@ async def _compute_uptime(db) -> dict[str, dict]:
result = await db.execute(
select(
PingResult.host_id,
# 30d window — all rows in query qualify
func.count(PingResult.id).label("total_30d"),
func.sum(case((PingResult.status == PingStatus.up, 1), else_=0)).label("up_30d"),
# 7d sub-window
func.sum(case((PingResult.probed_at >= cutoff_7d, 1), else_=0)).label("total_7d"),
Monitor.host_id,
func.count(MonitorResult.id).label("total_30d"),
func.sum(case((MonitorResult.is_up.is_(True), 1), else_=0)).label("up_30d"),
func.sum(case((MonitorResult.checked_at >= cutoff_7d, 1), else_=0)).label("total_7d"),
func.sum(case(
(and_(PingResult.probed_at >= cutoff_7d, PingResult.status == PingStatus.up), 1),
(and_(MonitorResult.checked_at >= cutoff_7d, MonitorResult.is_up.is_(True)), 1),
else_=0,
)).label("up_7d"),
# 24h sub-window
func.sum(case((PingResult.probed_at >= cutoff_24h, 1), else_=0)).label("total_24h"),
func.sum(case((MonitorResult.checked_at >= cutoff_24h, 1), else_=0)).label("total_24h"),
func.sum(case(
(and_(PingResult.probed_at >= cutoff_24h, PingResult.status == PingStatus.up), 1),
(and_(MonitorResult.checked_at >= cutoff_24h, MonitorResult.is_up.is_(True)), 1),
else_=0,
)).label("up_24h"),
)
.where(PingResult.probed_at >= cutoff_30d)
.group_by(PingResult.host_id)
.join(Monitor, Monitor.id == MonitorResult.monitor_id)
.where(Monitor.host_id.isnot(None), MonitorResult.checked_at >= cutoff_30d)
.group_by(Monitor.host_id)
)
def _pct(up, total):
@@ -68,6 +67,65 @@ async def _compute_uptime(db) -> dict[str, dict]:
return stats
async def _host_status(db, host_ids: list[str]) -> dict[str, dict]:
"""Per-host status rollup from the latest result of each linked monitor.
{host_id: {"state": "up"|"down"|"pending", "latency_ms": float|None,
"count": int}}. State is down if ANY linked monitor's latest
result is down (worst-case), up if at least one is up, else pending.
"""
if not host_ids:
return {}
rn = func.row_number().over(
partition_by=MonitorResult.monitor_id, order_by=MonitorResult.checked_at.desc()
).label("rn")
subq = (
select(MonitorResult.id, rn)
.join(Monitor, Monitor.id == MonitorResult.monitor_id)
.where(Monitor.host_id.in_(host_ids), Monitor.enabled.is_(True))
.subquery()
)
rows = (await db.execute(
select(MonitorResult, Monitor.host_id)
.join(subq, MonitorResult.id == subq.c.id)
.join(Monitor, Monitor.id == MonitorResult.monitor_id)
.where(subq.c.rn == 1)
)).all()
out: dict[str, dict] = {}
for res, host_id in rows:
d = out.setdefault(host_id, {"state": "pending", "latency_ms": None, "count": 0})
d["count"] += 1
if not res.is_up:
d["state"] = "down"
elif d["state"] != "down":
d["state"] = "up"
if res.is_up and res.response_ms is not None:
d["latency_ms"] = (res.response_ms if d["latency_ms"] is None
else min(d["latency_ms"], res.response_ms))
return out
async def _host_monitors(db, host_id: str) -> list[dict]:
"""Display rows for the monitors linked to one host (host hub section)."""
monitors = (await db.execute(
select(Monitor).where(Monitor.host_id == host_id).order_by(Monitor.name)
)).scalars().all()
ids = [m.id for m in monitors]
recent = await _last_n_results(db, ids)
uptime = await _uptime_by_monitor(db, ids)
data = []
for m in monitors:
mrows = recent.get(m.id, [])
latest = mrows[-1] if mrows else None
data.append({
"monitor": m, "latest": latest,
"heartbeat": [{"state": "up" if r.is_up else "down",
"title": _heartbeat_title(m.type, r)} for r in mrows],
"uptime_pct": uptime.get(m.id, {}).get("24h"),
})
return data
async def _agent_metrics_by_host(db, host_names: list[str]) -> dict[str, dict]:
"""Latest agent cpu/mem per host name from the generic PluginMetric table.
@@ -189,26 +247,7 @@ async def overview_widget():
"""Dashboard widget: unified per-host monitor + agent glance, linking to the hub."""
async with current_app.db_sessionmaker() as db:
hosts = (await db.execute(select(Host).order_by(Host.name))).scalars().all()
latest_ping_subq = (
select(PingResult.host_id, func.max(PingResult.probed_at).label("max_at"))
.group_by(PingResult.host_id).subquery()
)
latest_pings = {r.host_id: r for r in (await db.execute(
select(PingResult).join(
latest_ping_subq,
(PingResult.host_id == latest_ping_subq.c.host_id)
& (PingResult.probed_at == latest_ping_subq.c.max_at),
))).scalars()}
latest_dns_subq = (
select(DnsResult.host_id, func.max(DnsResult.resolved_at).label("max_at"))
.group_by(DnsResult.host_id).subquery()
)
latest_dns = {r.host_id: r for r in (await db.execute(
select(DnsResult).join(
latest_dns_subq,
(DnsResult.host_id == latest_dns_subq.c.host_id)
& (DnsResult.resolved_at == latest_dns_subq.c.max_at),
))).scalars()}
host_status = await _host_status(db, [h.id for h in hosts])
uptime = await _compute_uptime(db)
host_names = [h.name for h in hosts]
agent = await _agent_overview_by_host(db, host_names)
@@ -216,7 +255,7 @@ async def overview_widget():
return await render_template(
"hosts/overview_widget.html",
hosts=hosts, latest_pings=latest_pings, latest_dns=latest_dns,
hosts=hosts, host_status=host_status,
uptime=uptime, agent=agent, cpu_sparks=cpu_sparks,
)
@@ -227,44 +266,14 @@ async def list_hosts():
async with current_app.db_sessionmaker() as db:
result = await db.execute(select(Host).order_by(Host.name))
hosts = result.scalars().all()
# Latest ping result per host
latest_ping_subq = (
select(PingResult.host_id, func.max(PingResult.probed_at).label("max_at"))
.group_by(PingResult.host_id)
.subquery()
)
pr = await db.execute(
select(PingResult).join(
latest_ping_subq,
(PingResult.host_id == latest_ping_subq.c.host_id)
& (PingResult.probed_at == latest_ping_subq.c.max_at),
)
)
latest_pings = {r.host_id: r for r in pr.scalars()}
# Latest DNS result per host
latest_dns_subq = (
select(DnsResult.host_id, func.max(DnsResult.resolved_at).label("max_at"))
.group_by(DnsResult.host_id)
.subquery()
)
dr = await db.execute(
select(DnsResult).join(
latest_dns_subq,
(DnsResult.host_id == latest_dns_subq.c.host_id)
& (DnsResult.resolved_at == latest_dns_subq.c.max_at),
)
)
latest_dns = {r.host_id: r for r in dr.scalars()}
host_status = await _host_status(db, [h.id for h in hosts])
uptime = await _compute_uptime(db)
agent_metrics = await _agent_metrics_by_host(db, [h.name for h in hosts])
return await render_template(
"hosts/list.html",
hosts=hosts,
latest_pings=latest_pings,
latest_dns=latest_dns,
host_status=host_status,
uptime=uptime,
agent_metrics=agent_metrics,
)
@@ -282,12 +291,7 @@ async def host_detail(host_id: str):
select(Host).where(Host.id == host_id))).scalar_one_or_none()
if host is None:
return "Not found", 404
ping = (await db.execute(
select(PingResult).where(PingResult.host_id == host_id)
.order_by(PingResult.probed_at.desc()).limit(1))).scalar_one_or_none()
dns = (await db.execute(
select(DnsResult).where(DnsResult.host_id == host_id)
.order_by(DnsResult.resolved_at.desc()).limit(1))).scalar_one_or_none()
monitors = await _host_monitors(db, host_id)
uptime = (await _compute_uptime(db)).get(host_id)
linked_target = (await db.execute(
select(AnsibleTarget).where(AnsibleTarget.host_id == host_id)
@@ -296,9 +300,11 @@ async def host_detail(host_id: str):
select(AnsibleTarget).where(AnsibleTarget.host_id.is_(None))
.order_by(AnsibleTarget.name))).scalars().all()
from steward.models.monitors import MonitorType
return await render_template(
"hosts/detail.html",
host=host, ping=ping, dns=dns, uptime=uptime,
host=host, monitors=monitors, uptime=uptime,
monitor_types=list(MonitorType),
linked_target=linked_target, linkable_targets=linkable_targets,
ansible_sources=_ansible_source_names(),
)
@@ -307,7 +313,7 @@ async def host_detail(host_id: str):
@hosts_bp.get("/new")
@require_role(UserRole.operator)
async def new_host():
return await render_template("hosts/form.html", host=None, probe_types=list(ProbeType))
return await render_template("hosts/form.html", host=None)
@hosts_bp.post("/")
@@ -317,12 +323,6 @@ async def create_host():
host = Host(
name=form["name"].strip(),
address=form["address"].strip(),
probe_type=ProbeType(form.get("probe_type", "tcp")),
probe_port=int(form.get("probe_port") or 80),
ping_enabled="ping_enabled" in form,
dns_enabled="dns_enabled" in form,
dns_expected_ip=form.get("dns_expected_ip", "").strip() or None,
# poll_interval_seconds not exposed in UI yet; per-host scheduling not implemented
)
async with current_app.db_sessionmaker() as db:
async with db.begin():
@@ -330,7 +330,8 @@ async def create_host():
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"host.created", entity_type="host", entity_id=host.name,
detail={"address": host.address})
return redirect(url_for("hosts.list_hosts"))
# Land on the new host's hub so monitors/agent/ansible can be set up next.
return redirect(url_for("hosts.host_detail", host_id=host.id))
@hosts_bp.get("/<host_id>/edit")
@@ -344,8 +345,7 @@ async def edit_host(host_id: str):
if host is None:
return "Not found", 404
return await render_template(
"hosts/form.html", host=host, probe_types=list(ProbeType))
return await render_template("hosts/form.html", host=host)
@hosts_bp.post("/<host_id>")
@@ -360,13 +360,7 @@ async def update_host(host_id: str):
return "Not found", 404
host.name = form["name"].strip()
host.address = form["address"].strip()
host.probe_type = ProbeType(form.get("probe_type", "tcp"))
host.probe_port = int(form.get("probe_port") or 80)
host.ping_enabled = "ping_enabled" in form
host.dns_enabled = "dns_enabled" in form
host.dns_expected_ip = form.get("dns_expected_ip", "").strip() or None
# poll_interval_seconds not exposed in UI yet
return redirect(url_for("hosts.list_hosts"))
return redirect(url_for("hosts.host_detail", host_id=host_id))
@hosts_bp.post("/<host_id>/ansible-link")
@@ -515,9 +509,7 @@ async def uptime_page():
async def uptime_widget():
share_token = request.args.get("s")
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(Host).where(Host.ping_enabled.is_(True)).order_by(Host.name)
)
result = await db.execute(select(Host).order_by(Host.name))
hosts = result.scalars().all()
uptime = await _compute_uptime(db)
return await render_template(