feat(monitors): unify ping/dns/http into one Monitor entity + custom targets
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:
+87
-95
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user