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
+2 -3
View File
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
from sqlalchemy import delete
from steward.models.monitors import DnsResult, PingResult
from steward.models.monitors import MonitorResult
from steward.models.metrics import PluginMetric
from steward.models.ansible import AnsibleRun
@@ -23,8 +23,7 @@ async def run_cleanup(app: "Quart") -> None:
async with app.db_sessionmaker() as session:
async with session.begin():
for model, ts_col in [
(PingResult, PingResult.probed_at),
(DnsResult, DnsResult.resolved_at),
(MonitorResult, MonitorResult.checked_at),
(PluginMetric, PluginMetric.recorded_at),
(AnsibleRun, AnsibleRun.started_at),
]:
+28 -33
View File
@@ -12,9 +12,8 @@ from email.message import EmailMessage
from sqlalchemy import and_, case, func, select
from steward.models.alerts import AlertRule, AlertState, AlertStateEnum, AlertEvent
from steward.models.hosts import Host
from steward.models.metrics import PluginMetric
from steward.models.monitors import PingResult, PingStatus
from steward.models.monitors import Monitor, MonitorResult
logger = logging.getLogger(__name__)
@@ -28,32 +27,28 @@ async def build_report_data(app) -> dict:
cutoff_24h = now - timedelta(hours=24)
async with app.db_sessionmaker() as db:
# ── Uptime summary (7d) ───────────────────────────────────────────────
host_result = await db.execute(
select(Host).where(Host.ping_enabled.is_(True)).order_by(Host.name)
)
hosts = host_result.scalars().all()
# ── Uptime summary (7d) — per enabled monitor of any type ─────────────
monitors = (await db.execute(
select(Monitor).where(Monitor.enabled.is_(True)).order_by(Monitor.name)
)).scalars().all()
uptime_rows = await db.execute(
select(
PingResult.host_id,
func.count(PingResult.id).label("total"),
func.sum(case((PingResult.status == PingStatus.up, 1), else_=0)).label("up"),
MonitorResult.monitor_id,
func.count(MonitorResult.id).label("total"),
func.sum(case((MonitorResult.is_up.is_(True), 1), else_=0)).label("up"),
)
.where(PingResult.probed_at >= cutoff_7d)
.group_by(PingResult.host_id)
.where(MonitorResult.checked_at >= cutoff_7d)
.group_by(MonitorResult.monitor_id)
)
uptime_map: dict[str, float | None] = {}
for row in uptime_rows:
pct = round(float(row.up) / float(row.total) * 100, 2) if row.total else None
uptime_map[row.host_id] = pct
uptime_map[row.monitor_id] = pct
uptime_summary = []
for h in hosts:
uptime_summary.append({
"name": h.name,
"pct_7d": uptime_map.get(h.id),
})
uptime_summary = [
{"name": m.name, "pct_7d": uptime_map.get(m.id)} for m in monitors
]
# ── Active alerts ─────────────────────────────────────────────────────
active_result = await db.execute(
@@ -112,27 +107,27 @@ async def build_report_data(app) -> dict:
for row in top_routers_result
]
# ── Hosts currently down ──────────────────────────────────────────────
latest_ping_subq = (
select(PingResult.host_id, func.max(PingResult.probed_at).label("max_at"))
.group_by(PingResult.host_id)
# ── Monitors currently down (latest result is_up = false) ─────────────
latest_subq = (
select(MonitorResult.monitor_id, func.max(MonitorResult.checked_at).label("max_at"))
.group_by(MonitorResult.monitor_id)
.subquery()
)
down_result = await db.execute(
select(Host.name)
.join(PingResult, PingResult.host_id == Host.id)
select(Monitor.name)
.join(MonitorResult, MonitorResult.monitor_id == Monitor.id)
.join(
latest_ping_subq,
latest_subq,
and_(
PingResult.host_id == latest_ping_subq.c.host_id,
PingResult.probed_at == latest_ping_subq.c.max_at,
MonitorResult.monitor_id == latest_subq.c.monitor_id,
MonitorResult.checked_at == latest_subq.c.max_at,
),
)
.where(
Host.ping_enabled.is_(True),
PingResult.status == PingStatus.down,
Monitor.enabled.is_(True),
MonitorResult.is_up.is_(False),
)
.order_by(Host.name)
.order_by(Monitor.name)
)
hosts_down = [row.name for row in down_result]
@@ -157,7 +152,7 @@ def _render_report_text(data: dict) -> str:
# ── Hosts currently down ──────────────────────────────────────────────────
down = data["hosts_down"]
if down:
lines.append(f"{len(down)} host(s) currently DOWN: {', '.join(down)}")
lines.append(f"{len(down)} monitor(s) currently DOWN: {', '.join(down)}")
lines.append("")
# ── Uptime summary ────────────────────────────────────────────────────────
@@ -168,7 +163,7 @@ def _render_report_text(data: dict) -> str:
pct_str = f"{pct:.2f}%" if pct is not None else "no data"
lines.append(f" {entry['name']:<32} {pct_str}")
if not data["uptime_summary"]:
lines.append(" (no ping-enabled hosts)")
lines.append(" (no monitors configured)")
lines.append("")
# ── Active alerts ─────────────────────────────────────────────────────────
+74 -100
View File
@@ -2,12 +2,11 @@
"""Unified monitor-status aggregation.
A single readable "is everything up?" surface (the Status page + dashboard
widget) is assembled from heterogeneous monitor types — core ping/DNS, plus
any plugin that contributes (e.g. the http plugin). Rather than have the core
status page import plugin tables (plugins are optional and loaded late), each
monitor type registers a *status source*: an async callable(db) that returns a
list of normalised StatusEntry objects. Core registers its ping/DNS sources in
create_app; a plugin registers its own from setup().
widget) is assembled from heterogeneous monitors. The core unified Monitor
entity (ping/dns/http) contributes via `monitor_status_source`; plugins may
register their own sources from setup(). Each source is an async callable(db)
returning normalised StatusEntry objects so the Status page never imports
plugin tables directly.
"""
from __future__ import annotations
@@ -26,8 +25,8 @@ HEARTBEAT_COUNT = 30 # number of recent checks shown in the heartbeat bar
@dataclass
class StatusEntry:
"""One monitored thing, normalised across monitor types for display."""
kind: str # "ping" | "dns" | "http" | ...
key: str # unique within kind (host_id / monitor_id)
kind: str # "icmp" | "tcp" | "dns" | "http" | ...
key: str # unique within kind (monitor id)
name: str
target: str = "" # address / URL shown as subtitle
status: str = "pending" # "up" | "down" | "pending"
@@ -98,49 +97,52 @@ def sparkline_svg(values: list[float], width: int = 80, height: int = 20,
)
# ── Shared query helpers (host-keyed result tables: ping, dns) ────────────────
# ── Unified monitor status source ─────────────────────────────────────────────
async def _last_n_by_host(db, model, ts_col, host_ids: list[str],
async def _last_n_results(db, monitor_ids: list[str],
n: int = HEARTBEAT_COUNT) -> dict[str, list]:
"""Return {host_id: [rows]} of the last n results per host, oldest-first."""
if not host_ids:
"""Return {monitor_id: [MonitorResult]} of the last n results, oldest-first."""
from steward.models.monitors import MonitorResult
if not monitor_ids:
return {}
rn = func.row_number().over(
partition_by=model.host_id, order_by=ts_col.desc()
partition_by=MonitorResult.monitor_id, order_by=MonitorResult.checked_at.desc()
).label("rn")
subq = select(model.id, rn).where(model.host_id.in_(host_ids)).subquery()
subq = select(MonitorResult.id, rn).where(
MonitorResult.monitor_id.in_(monitor_ids)
).subquery()
res = await db.execute(
select(model)
.join(subq, model.id == subq.c.id)
select(MonitorResult)
.join(subq, MonitorResult.id == subq.c.id)
.where(subq.c.rn <= n)
.order_by(model.host_id, ts_col.asc())
.order_by(MonitorResult.monitor_id, MonitorResult.checked_at.asc())
)
out: dict[str, list] = {hid: [] for hid in host_ids}
out: dict[str, list] = {mid: [] for mid in monitor_ids}
for row in res.scalars():
out[row.host_id].append(row)
out[row.monitor_id].append(row)
return out
async def _uptime_by_host(db, model, ts_col, status_col, up_value,
host_ids: list[str]) -> dict[str, dict]:
"""Per-host uptime % over 24h/7d/30d in one grouped query."""
if not host_ids:
async def _uptime_by_monitor(db, monitor_ids: list[str]) -> dict[str, dict]:
"""Per-monitor uptime % over 24h/7d/30d in one grouped query."""
from steward.models.monitors import MonitorResult
if not monitor_ids:
return {}
now = datetime.now(timezone.utc)
c30, c7, c24 = now - timedelta(days=30), now - timedelta(days=7), now - timedelta(hours=24)
res = await db.execute(
select(
model.host_id,
MonitorResult.monitor_id,
func.count().label("total_30d"),
func.sum(case((status_col == up_value, 1), else_=0)).label("up_30d"),
func.sum(case((ts_col >= c7, 1), else_=0)).label("total_7d"),
func.sum(case((and_(ts_col >= c7, status_col == up_value), 1), else_=0)).label("up_7d"),
func.sum(case((ts_col >= c24, 1), else_=0)).label("total_24h"),
func.sum(case((and_(ts_col >= c24, status_col == up_value), 1), else_=0)).label("up_24h"),
func.sum(case((MonitorResult.is_up.is_(True), 1), else_=0)).label("up_30d"),
func.sum(case((MonitorResult.checked_at >= c7, 1), else_=0)).label("total_7d"),
func.sum(case((and_(MonitorResult.checked_at >= c7, MonitorResult.is_up.is_(True)), 1), else_=0)).label("up_7d"),
func.sum(case((MonitorResult.checked_at >= c24, 1), else_=0)).label("total_24h"),
func.sum(case((and_(MonitorResult.checked_at >= c24, MonitorResult.is_up.is_(True)), 1), else_=0)).label("up_24h"),
)
.where(model.host_id.in_(host_ids))
.where(ts_col >= c30)
.group_by(model.host_id)
.where(MonitorResult.monitor_id.in_(monitor_ids))
.where(MonitorResult.checked_at >= c30)
.group_by(MonitorResult.monitor_id)
)
def _pct(up, total):
@@ -148,7 +150,7 @@ async def _uptime_by_host(db, model, ts_col, status_col, up_value,
out: dict[str, dict] = {}
for r in res:
out[r.host_id] = {
out[r.monitor_id] = {
"24h": _pct(r.up_24h, r.total_24h),
"7d": _pct(r.up_7d, r.total_7d),
"30d": _pct(r.up_30d, r.total_30d),
@@ -156,78 +158,50 @@ async def _uptime_by_host(db, model, ts_col, status_col, up_value,
return out
# ── Core status sources: ping + DNS ───────────────────────────────────────────
def _heartbeat_title(mtype: str, r) -> str:
"""Type-aware tooltip for one heartbeat cell."""
ts = f"{r.checked_at:%H:%M:%S} UTC"
if not r.is_up:
return f"{(r.error_msg or 'Down')}{ts}"
if mtype == "http":
code = r.status_code or ""
ms = f"{r.response_ms:.0f} ms" if r.response_ms is not None else ""
return f"{code} · {ms}{ts}".replace(" · — ", "")
if mtype == "dns":
return f"{r.resolved_ip or 'resolved'}{ts}"
if r.response_ms is not None:
return f"{r.response_ms:.0f} ms — {ts}"
return f"Up — {ts}"
async def ping_status_source(db) -> list[StatusEntry]:
from steward.models.hosts import Host
from steward.models.monitors import PingResult, PingStatus
hosts = (await db.execute(
select(Host).where(Host.ping_enabled.is_(True)).order_by(Host.name)
)).scalars().all()
ids = [h.id for h in hosts]
recent = await _last_n_by_host(db, PingResult, PingResult.probed_at, ids)
uptime = await _uptime_by_host(
db, PingResult, PingResult.probed_at, PingResult.status, PingStatus.up, ids
)
async def monitor_status_source(db) -> list[StatusEntry]:
"""Contribute every enabled Monitor (all types) to the Status page."""
from steward.models.monitors import Monitor
monitors = list((await db.execute(
select(Monitor).where(Monitor.enabled.is_(True)).order_by(Monitor.name)
)).scalars())
ids = [m.id for m in monitors]
recent = await _last_n_results(db, ids)
uptime = await _uptime_by_monitor(db, ids)
now = datetime.now(timezone.utc)
entries: list[StatusEntry] = []
for h in hosts:
rows = recent.get(h.id, [])
for m in monitors:
rows = recent.get(m.id, [])
latest = rows[-1] if rows else None
heartbeat = [{
"state": "down" if r.status == PingStatus.down else "up",
"title": (
f"Down — {r.probed_at:%H:%M:%S} UTC" if r.status == PingStatus.down
else f"{r.response_time_ms:.0f} ms — {r.probed_at:%H:%M:%S} UTC"
if r.response_time_ms is not None else f"Up — {r.probed_at:%H:%M:%S} UTC"
),
} for r in rows]
status = "pending" if latest is None else (
"down" if latest.status == PingStatus.down else "up"
)
status = "pending" if latest is None else ("up" if latest.is_up else "down")
tls_days = None
if latest and latest.tls_expires_at:
tls_days = (latest.tls_expires_at - now).total_seconds() / 86400.0
entries.append(StatusEntry(
kind="ping", key=h.id, name=h.name, target=h.address, status=status,
last_checked=latest.probed_at if latest else None,
uptime=uptime.get(h.id, {}), heartbeat=heartbeat,
latency_ms=latest.response_time_ms if latest else None,
spark=[r.response_time_ms for r in rows if r.response_time_ms is not None],
detail_url="/ping/",
))
return entries
async def dns_status_source(db) -> list[StatusEntry]:
from steward.models.hosts import Host
from steward.models.monitors import DnsResult, DnsStatus
hosts = (await db.execute(
select(Host).where(Host.dns_enabled.is_(True)).order_by(Host.name)
)).scalars().all()
ids = [h.id for h in hosts]
recent = await _last_n_by_host(db, DnsResult, DnsResult.resolved_at, ids)
uptime = await _uptime_by_host(
db, DnsResult, DnsResult.resolved_at, DnsResult.status, DnsStatus.resolved, ids
)
entries: list[StatusEntry] = []
for h in hosts:
rows = recent.get(h.id, [])
latest = rows[-1] if rows else None
heartbeat = [{
"state": "up" if r.status == DnsStatus.resolved else "down",
"title": (
f"{r.resolved_ip}{r.resolved_at:%H:%M:%S} UTC" if r.status == DnsStatus.resolved
else f"Failed — {r.resolved_at:%H:%M:%S} UTC"
),
} for r in rows]
status = "pending" if latest is None else (
"up" if latest.status == DnsStatus.resolved else "down"
)
entries.append(StatusEntry(
kind="dns", key=h.id, name=h.name, target=h.address, status=status,
last_checked=latest.resolved_at if latest else None,
uptime=uptime.get(h.id, {}), heartbeat=heartbeat,
detail_url="/dns/",
kind=m.type, key=m.id, name=m.name, target=m.target, status=status,
last_checked=latest.checked_at if latest else None,
uptime=uptime.get(m.id, {}),
heartbeat=[{"state": "up" if r.is_up else "down",
"title": _heartbeat_title(m.type, r)} for r in rows],
latency_ms=latest.response_ms if latest else None,
spark=[r.response_ms for r in rows if r.response_ms is not None],
tls_days=tls_days, detail_url="/monitors/",
))
return entries
+43 -50
View File
@@ -12,15 +12,50 @@
from __future__ import annotations
WIDGET_REGISTRY: dict[str, dict] = {
"ping": {
"key": "ping",
"label": "Ping",
"description": "Live ping status and latency history for all monitored hosts",
"hx_url": "/ping/rows",
"detail_url": "/ping/",
"monitors": {
"key": "monitors",
"label": "Monitors",
"description": "Uptime checks of every type — ping, DNS, and HTTP — with up/down status and latency",
"hx_url": "/monitors/widget",
"detail_url": "/monitors/",
"plugin": None,
"poll": True,
"params": [],
"params": [
{
"key": "type_filter",
"label": "Type",
"type": "select",
"default": "all",
"options": [
{"value": "all", "label": "All types"},
{"value": "icmp", "label": "Ping (ICMP)"},
{"value": "tcp", "label": "Ping (TCP)"},
{"value": "dns", "label": "DNS"},
{"value": "http", "label": "HTTP"},
],
},
{
"key": "show_down_only",
"label": "Display filter",
"type": "select",
"default": "no",
"options": [
{"value": "no", "label": "All monitors"},
{"value": "yes", "label": "Failing only"},
],
},
{
"key": "limit",
"label": "Max shown",
"type": "select",
"default": 10,
"options": [
{"value": 5, "label": "5 monitors"},
{"value": 10, "label": "10 monitors"},
{"value": 20, "label": "20 monitors"},
],
},
],
},
"status_overview": {
"key": "status_overview",
@@ -52,16 +87,6 @@ WIDGET_REGISTRY: dict[str, dict] = {
"poll": False,
"params": [],
},
"dns": {
"key": "dns",
"label": "DNS",
"description": "DNS resolution status for all monitored hosts",
"hx_url": "/dns/rows",
"detail_url": "/dns/",
"plugin": None,
"poll": True,
"params": [],
},
"traefik_routers": {
"key": "traefik_routers",
"label": "Traefik — Routers",
@@ -203,38 +228,6 @@ WIDGET_REGISTRY: dict[str, dict] = {
},
],
},
"http_monitors": {
"key": "http_monitors",
"label": "HTTP Monitors",
"description": "Synthetic endpoint checks — up/down status and response times",
"hx_url": "/plugins/http/widget",
"detail_url": "/plugins/http/",
"plugin": "http",
"poll": True,
"params": [
{
"key": "show_down_only",
"label": "Display filter",
"type": "select",
"default": "no",
"options": [
{"value": "no", "label": "All monitors"},
{"value": "yes", "label": "Failing only"},
],
},
{
"key": "limit",
"label": "Max shown",
"type": "select",
"default": 10,
"options": [
{"value": 5, "label": "5 monitors"},
{"value": 10, "label": "10 monitors"},
{"value": 20, "label": "20 monitors"},
],
},
],
},
"docker_resources": {
"key": "docker_resources",
"label": "Docker — Resources",
@@ -312,7 +305,7 @@ WIDGET_REGISTRY: dict[str, dict] = {
# Group each widget for the picker (Core monitors / Monitoring capabilities /
# Integrations), mirroring the Settings → Plugins taxonomy. Capability plugins
# are host/monitoring facets; traefik/unifi are discrete vendor integrations.
_CORE_WIDGETS = {"ping", "dns", "status_overview", "uptime_summary", "hosts_overview"}
_CORE_WIDGETS = {"monitors", "status_overview", "uptime_summary", "hosts_overview"}
_INTEGRATION_PLUGINS = {"traefik", "unifi"}
GROUP_LABELS = {
"core": "Core monitors",