From 35f658b573da35179a10f281d4de9e39ae3f371e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 18 Jun 2026 08:56:13 -0400 Subject: [PATCH] feat(monitors): unify ping/dns/http into one Monitor entity + custom targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC --- plugins/http/__init__.py | 28 -- plugins/http/migrations/__init__.py | 0 plugins/http/migrations/env.py | 70 ---- plugins/http/migrations/versions/__init__.py | 0 plugins/http/models.py | 63 ---- plugins/http/plugin.yaml | 19 -- plugins/http/routes.py | 311 ------------------ plugins/http/scheduler.py | 121 ------- plugins/http/templates/http/index.html | 77 ----- plugins/http/templates/http/rows.html | 109 ------ plugins/http/templates/http/widget.html | 56 ---- plugins/index.yaml | 16 - steward/alerts/routes.py | 25 +- steward/app.py | 57 +--- steward/core/cleanup.py | 5 +- steward/core/reports.py | 61 ++-- steward/core/status.py | 174 +++++----- steward/core/widgets.py | 93 +++--- steward/dns/__init__.py | 0 steward/dns/routes.py | 52 --- steward/hosts/routes.py | 182 +++++----- .../versions/0022_unify_monitors.py | 253 ++++++++++++++ .../migrations/versions/http_001_initial.py | 9 +- steward/models/__init__.py | 8 +- steward/models/hosts.py | 17 +- steward/models/monitors.py | 122 +++++-- steward/monitors/dns.py | 66 ++-- .../checker.py => steward/monitors/http.py | 26 +- steward/monitors/ping.py | 52 +-- steward/monitors/routes.py | 215 ++++++++++++ steward/monitors/runner.py | 56 ++++ steward/monitors/scheduler.py | 76 +++++ steward/ping/__init__.py | 0 steward/ping/routes.py | 127 ------- steward/templates/alerts/rules_form.html | 10 +- steward/templates/base.html | 3 +- steward/templates/dns/index.html | 16 - steward/templates/dns/rows.html | 35 -- steward/templates/hosts/detail.html | 128 ++++--- steward/templates/hosts/form.html | 39 +-- steward/templates/hosts/list.html | 57 +--- steward/templates/hosts/overview_widget.html | 13 +- steward/templates/hosts/uptime.html | 19 +- steward/templates/hosts/uptime_widget.html | 2 +- steward/templates/monitors/_fields.html | 64 ++++ steward/templates/monitors/edit.html | 48 +++ steward/templates/monitors/index.html | 60 ++++ steward/templates/monitors/rows.html | 74 +++++ steward/templates/monitors/widget.html | 44 +++ steward/templates/ping/index.html | 47 --- steward/templates/ping/rows.html | 67 ---- tests/core/test_monitors.py | 110 +++++++ tests/integration/test_monitors.py | 85 +++++ 53 files changed, 1628 insertions(+), 1839 deletions(-) delete mode 100644 plugins/http/__init__.py delete mode 100644 plugins/http/migrations/__init__.py delete mode 100644 plugins/http/migrations/env.py delete mode 100644 plugins/http/migrations/versions/__init__.py delete mode 100644 plugins/http/models.py delete mode 100644 plugins/http/plugin.yaml delete mode 100644 plugins/http/routes.py delete mode 100644 plugins/http/scheduler.py delete mode 100644 plugins/http/templates/http/index.html delete mode 100644 plugins/http/templates/http/rows.html delete mode 100644 plugins/http/templates/http/widget.html delete mode 100644 steward/dns/__init__.py delete mode 100644 steward/dns/routes.py create mode 100644 steward/migrations/versions/0022_unify_monitors.py rename {plugins/http => steward}/migrations/versions/http_001_initial.py (82%) rename plugins/http/checker.py => steward/monitors/http.py (80%) create mode 100644 steward/monitors/routes.py create mode 100644 steward/monitors/runner.py create mode 100644 steward/monitors/scheduler.py delete mode 100644 steward/ping/__init__.py delete mode 100644 steward/ping/routes.py delete mode 100644 steward/templates/dns/index.html delete mode 100644 steward/templates/dns/rows.html create mode 100644 steward/templates/monitors/_fields.html create mode 100644 steward/templates/monitors/edit.html create mode 100644 steward/templates/monitors/index.html create mode 100644 steward/templates/monitors/rows.html create mode 100644 steward/templates/monitors/widget.html delete mode 100644 steward/templates/ping/index.html delete mode 100644 steward/templates/ping/rows.html create mode 100644 tests/core/test_monitors.py create mode 100644 tests/integration/test_monitors.py diff --git a/plugins/http/__init__.py b/plugins/http/__init__.py deleted file mode 100644 index ef17129..0000000 --- a/plugins/http/__init__.py +++ /dev/null @@ -1,28 +0,0 @@ -# plugins/http/__init__.py -from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from quart import Quart - -_app: "Quart | None" = None - - -def setup(app: "Quart") -> None: - global _app - _app = app - from .models import HttpMonitor, HttpResult # noqa: F401 - # Contribute HTTP monitors to the core unified Status page. - from steward.core.status import register_status_source - from .routes import http_status_source - register_status_source(http_status_source) - - -def get_scheduled_tasks() -> list: - from .scheduler import make_task - return [make_task(_app)] - - -def get_blueprint(): - from .routes import http_bp - return http_bp diff --git a/plugins/http/migrations/__init__.py b/plugins/http/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/http/migrations/env.py b/plugins/http/migrations/env.py deleted file mode 100644 index 02082a7..0000000 --- a/plugins/http/migrations/env.py +++ /dev/null @@ -1,70 +0,0 @@ -# plugins/http/migrations/env.py -"""Alembic env.py for the HTTP monitoring plugin.""" -import asyncio -import os -import sys -from pathlib import Path -from logging.config import fileConfig - -from sqlalchemy import pool -from sqlalchemy.engine import Connection -from sqlalchemy.ext.asyncio import async_engine_from_config -from alembic import context - -sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) - -from steward.models.base import Base -import steward.models # noqa: F401 -from plugins.http.models import HttpMonitor, HttpResult # noqa: F401 - -config = context.config -if config.config_file_name is not None: - fileConfig(config.config_file_name) - -target_metadata = Base.metadata - - -def _get_url() -> str: - import yaml - cfg_path = os.environ.get("STEWARD_CONFIG", "config.yaml") - try: - with open(cfg_path) as f: - cfg = yaml.safe_load(f) or {} - url = cfg.get("database", {}).get("url", "") - except FileNotFoundError: - url = "" - return os.environ.get("STEWARD_DATABASE__URL", url) - - -def run_migrations_offline() -> None: - context.configure( - url=_get_url(), target_metadata=target_metadata, - literal_binds=True, dialect_opts={"paramstyle": "named"}, - ) - with context.begin_transaction(): - context.run_migrations() - - -def do_run_migrations(connection: Connection) -> None: - context.configure(connection=connection, target_metadata=target_metadata) - with context.begin_transaction(): - context.run_migrations() - - -async def run_async_migrations() -> None: - cfg = config.get_section(config.config_ini_section, {}) - cfg["sqlalchemy.url"] = _get_url() - connectable = async_engine_from_config(cfg, prefix="sqlalchemy.", poolclass=pool.NullPool) - async with connectable.connect() as connection: - await connection.run_sync(do_run_migrations) - await connectable.dispose() - - -def run_migrations_online() -> None: - asyncio.run(run_async_migrations()) - - -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() diff --git a/plugins/http/migrations/versions/__init__.py b/plugins/http/migrations/versions/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/http/models.py b/plugins/http/models.py deleted file mode 100644 index d11cb0c..0000000 --- a/plugins/http/models.py +++ /dev/null @@ -1,63 +0,0 @@ -# plugins/http/models.py -from __future__ import annotations -import uuid -from datetime import datetime, timezone -from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text -from sqlalchemy.orm import Mapped, mapped_column -from steward.models.base import Base - - -class HttpMonitor(Base): - """A configured HTTP endpoint to check periodically.""" - __tablename__ = "http_monitors" - - id: Mapped[str] = mapped_column( - String(36), primary_key=True, default=lambda: str(uuid.uuid4()) - ) - name: Mapped[str] = mapped_column(String(128), nullable=False) - url: Mapped[str] = mapped_column(String(2048), nullable=False) - method: Mapped[str] = mapped_column(String(8), nullable=False, default="GET") - expected_status: Mapped[int] = mapped_column(Integer, nullable=False, default=200) - # Optional substring to find in the response body (empty = skip check) - content_match: Mapped[str] = mapped_column(String(512), nullable=False, default="") - # JSON dict of extra request headers, e.g. {"Authorization": "Bearer ..."} - headers_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") - timeout_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=10) - # 0 = use global plugin check_interval_seconds - check_interval_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=0) - follow_redirects: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) - verify_ssl: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) - enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) - # Updated after each check so the scheduler can compute next-run time efficiently - last_checked_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, - default=lambda: datetime.now(timezone.utc), - ) - - -class HttpResult(Base): - """One check result for an HttpMonitor.""" - __tablename__ = "http_results" - - id: Mapped[str] = mapped_column( - String(36), primary_key=True, default=lambda: str(uuid.uuid4()) - ) - monitor_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) - checked_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, - default=lambda: datetime.now(timezone.utc), - index=True, - ) - status_code: Mapped[int | None] = mapped_column(Integer, nullable=True) - response_ms: Mapped[float | None] = mapped_column(Float, nullable=True) - is_up: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - # None when no content_match is configured; True/False when it is - content_matched: Mapped[bool | None] = mapped_column(Boolean, nullable=True) - error_msg: Mapped[str | None] = mapped_column(String(512), nullable=True) - # TLS expiry if the endpoint is HTTPS; None for HTTP or on error - tls_expires_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) diff --git a/plugins/http/plugin.yaml b/plugins/http/plugin.yaml deleted file mode 100644 index a232c4d..0000000 --- a/plugins/http/plugin.yaml +++ /dev/null @@ -1,19 +0,0 @@ -name: http -version: "1.0.0" -description: "Synthetic HTTP endpoint monitoring — status code, response time, content match, TLS expiry" -author: "Steward" -license: "MIT" -min_app_version: "0.1.0" -repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins" -homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/http" -tags: - - monitoring - - http - - synthetic - - uptime - -config: - check_interval_seconds: 60 - default_timeout_seconds: 10 - follow_redirects: true - verify_ssl: true diff --git a/plugins/http/routes.py b/plugins/http/routes.py deleted file mode 100644 index 6f69d85..0000000 --- a/plugins/http/routes.py +++ /dev/null @@ -1,311 +0,0 @@ -# plugins/http/routes.py -from __future__ import annotations -import uuid -from datetime import datetime, timedelta, timezone -from quart import Blueprint, current_app, render_template, request, redirect, url_for -from sqlalchemy import Integer, and_, case, cast, func, select - -from steward.auth.middleware import require_role -from steward.models.users import UserRole -from steward.core.status import StatusEntry, HEARTBEAT_COUNT -from steward.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds -from .models import HttpMonitor, HttpResult - -http_bp = Blueprint("http", __name__, template_folder="templates") - - -def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str: - if len(values) < 2: - return f'' - mn, mx = min(values), max(values) - if mx == mn: - mx = mn + 1.0 - step = width / (len(values) - 1) - pts = [] - for i, v in enumerate(values): - x = i * step - y = height - (v - mn) / (mx - mn) * (height - 2) - 1 - pts.append(f"{x:.1f},{y:.1f}") - poly = " ".join(pts) - return ( - f'' - f'' - f'' - ) - - -async def _render_rows(range_key: str = DEFAULT_RANGE): - since, range_key = parse_range(range_key) - b_secs = bucket_seconds(since) - bucket_col = ( - cast(func.extract('epoch', HttpResult.checked_at), Integer) / b_secs - ).label("bucket") - - async with current_app.db_sessionmaker() as db: - result = await db.execute( - select(HttpMonitor).order_by(HttpMonitor.created_at) - ) - monitors = list(result.scalars()) - - # Latest result per monitor - latest_map: dict[str, HttpResult] = {} - histories: dict[str, list] = {} - for m in monitors: - r = await db.execute( - select(HttpResult) - .where(HttpResult.monitor_id == m.id) - .order_by(HttpResult.checked_at.desc()) - .limit(1) - ) - latest = r.scalar_one_or_none() - if latest: - latest_map[m.id] = latest - - r2 = await db.execute( - select( - func.avg(HttpResult.response_ms).label("response_ms"), - func.min(HttpResult.is_up.cast(Integer)).label("had_down"), - bucket_col, - ) - .where(HttpResult.monitor_id == m.id) - .where(HttpResult.checked_at >= since) - .group_by(bucket_col) - .order_by(bucket_col) - ) - histories[m.id] = r2.all() - - monitor_data = [] - for m in monitors: - hist = histories.get(m.id, []) - latest = latest_map.get(m.id) - now = datetime.now(timezone.utc) - tls_days = None - if latest and latest.tls_expires_at: - tls_days = (latest.tls_expires_at - now).total_seconds() / 86400.0 - monitor_data.append({ - "monitor": m, - "latest": latest, - "tls_days": tls_days, - "sparkline_ms": _sparkline([r.response_ms or 0 for r in hist]), - }) - - up = sum(1 for d in monitor_data if d["latest"] and d["latest"].is_up) - down = sum(1 for d in monitor_data if d["latest"] and not d["latest"].is_up) - return monitor_data, up, down, range_key - - -async def http_status_source(db) -> list[StatusEntry]: - """Contribute enabled HTTP monitors to the unified Status page. - - Registered with steward.core.status from this plugin's setup() so the core - Status surface can include HTTP without importing plugin tables directly. - """ - monitors = list((await db.execute( - select(HttpMonitor).where(HttpMonitor.enabled == True) # noqa: E712 - .order_by(HttpMonitor.created_at) - )).scalars()) - ids = [m.id for m in monitors] - if not ids: - return [] - - now = datetime.now(timezone.utc) - c30, c7, c24 = now - timedelta(days=30), now - timedelta(days=7), now - timedelta(hours=24) - up_res = await db.execute( - select( - HttpResult.monitor_id, - func.count().label("total_30d"), - func.sum(case((HttpResult.is_up == True, 1), else_=0)).label("up_30d"), # noqa: E712 - func.sum(case((HttpResult.checked_at >= c7, 1), else_=0)).label("total_7d"), - func.sum(case((and_(HttpResult.checked_at >= c7, HttpResult.is_up == True), 1), else_=0)).label("up_7d"), # noqa: E712 - func.sum(case((HttpResult.checked_at >= c24, 1), else_=0)).label("total_24h"), - func.sum(case((and_(HttpResult.checked_at >= c24, HttpResult.is_up == True), 1), else_=0)).label("up_24h"), # noqa: E712 - ) - .where(HttpResult.monitor_id.in_(ids)) - .where(HttpResult.checked_at >= c30) - .group_by(HttpResult.monitor_id) - ) - - def _pct(u, t): - return round(float(u) / float(t) * 100, 2) if t else None - - uptime = {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), - } for r in up_res} - - # Last N results per monitor (one query) for the heartbeat bar + sparkline. - rn = func.row_number().over( - partition_by=HttpResult.monitor_id, order_by=HttpResult.checked_at.desc() - ).label("rn") - subq = select(HttpResult.id, rn).where(HttpResult.monitor_id.in_(ids)).subquery() - recent_res = await db.execute( - select(HttpResult) - .join(subq, HttpResult.id == subq.c.id) - .where(subq.c.rn <= HEARTBEAT_COUNT) - .order_by(HttpResult.monitor_id, HttpResult.checked_at.asc()) - ) - recent: dict[str, list] = {mid: [] for mid in ids} - for row in recent_res.scalars(): - recent[row.monitor_id].append(row) - - entries: list[StatusEntry] = [] - for m in monitors: - rows = recent.get(m.id, []) - latest = rows[-1] if rows else None - heartbeat = [{ - "state": "up" if r.is_up else "down", - "title": ( - f"{r.status_code or '—'} · {r.response_ms:.0f} ms — {r.checked_at:%H:%M:%S} UTC" - if r.is_up and r.response_ms is not None - else f"{'Up' if r.is_up else 'Down'} — {r.checked_at:%H:%M:%S} UTC" - ), - } for r in rows] - 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="http", key=m.id, name=m.name, target=m.url, status=status, - last_checked=latest.checked_at if latest else None, - uptime=uptime.get(m.id, {}), heartbeat=heartbeat, - 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="/plugins/http/", - )) - return entries - - -@http_bp.get("/") -@require_role(UserRole.viewer) -async def index(): - poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60) - current_range = request.args.get("range", DEFAULT_RANGE) - return await render_template( - "http/index.html", - poll_interval=poll_interval, - current_range=current_range, - ) - - -@http_bp.get("/rows") -@require_role(UserRole.viewer) -async def rows(): - """HTMX fragment: monitor list with status + sparklines.""" - monitor_data, up, down, range_key = await _render_rows( - request.args.get("range", DEFAULT_RANGE) - ) - return await render_template( - "http/rows.html", - monitor_data=monitor_data, - up=up, - down=down, - range_key=range_key, - ) - - -@http_bp.post("/add") -@require_role(UserRole.operator) -async def add_monitor(): - """Add a new HTTP monitor.""" - form = await request.form - url = form.get("url", "").strip() - if not url: - return redirect(url_for("http.index")) - if not url.startswith(("http://", "https://")): - url = "https://" + url - - monitor = HttpMonitor( - id=str(uuid.uuid4()), - name=form.get("name", "").strip() or url, - url=url, - method=form.get("method", "GET").upper(), - expected_status=int(form.get("expected_status", 200) or 200), - content_match=form.get("content_match", "").strip(), - headers_json="{}", - timeout_seconds=int(form.get("timeout_seconds", 10) or 10), - check_interval_seconds=int(form.get("check_interval_seconds", 0) or 0), - follow_redirects="follow_redirects" in form, - verify_ssl="verify_ssl" in form, - enabled=True, - created_at=datetime.now(timezone.utc), - ) - async with current_app.db_sessionmaker() as db: - async with db.begin(): - db.add(monitor) - - return redirect(url_for("http.index")) - - -@http_bp.post("//delete") -@require_role(UserRole.operator) -async def delete_monitor(monitor_id: str): - async with current_app.db_sessionmaker() as db: - async with db.begin(): - m = await db.get(HttpMonitor, monitor_id) - if m: - await db.delete(m) - return redirect(url_for("http.index")) - - -@http_bp.post("//toggle") -@require_role(UserRole.operator) -async def toggle_monitor(monitor_id: str): - async with current_app.db_sessionmaker() as db: - async with db.begin(): - m = await db.get(HttpMonitor, monitor_id) - if m: - m.enabled = not m.enabled - return redirect(url_for("http.index")) - - -@http_bp.get("/widget") -@require_role(UserRole.viewer) -async def widget(): - """HTMX dashboard widget: up/down counts + monitor list.""" - show_down_only = request.args.get("show_down_only", "no") == "yes" - limit = max(1, min(20, int(request.args.get("limit", 10) or 10))) - widget_id = request.args.get("wid", "0") - - async with current_app.db_sessionmaker() as db: - result = await db.execute( - select(HttpMonitor) - .where(HttpMonitor.enabled == True) # noqa: E712 - .order_by(HttpMonitor.created_at) - ) - monitors = list(result.scalars()) - - latest_map: dict[str, HttpResult] = {} - for m in monitors: - r = await db.execute( - select(HttpResult) - .where(HttpResult.monitor_id == m.id) - .order_by(HttpResult.checked_at.desc()) - .limit(1) - ) - latest = r.scalar_one_or_none() - if latest: - latest_map[m.id] = latest - - monitor_data = [ - {"monitor": m, "latest": latest_map.get(m.id)} - for m in monitors - ] - - up = sum(1 for d in monitor_data if d["latest"] and d["latest"].is_up) - down = sum(1 for d in monitor_data if d["latest"] and not d["latest"].is_up) - pending = sum(1 for d in monitor_data if not d["latest"]) - - if show_down_only: - monitor_data = [d for d in monitor_data if not (d["latest"] and d["latest"].is_up)] - - return await render_template( - "http/widget.html", - monitor_data=monitor_data[:limit], - up=up, - down=down, - pending=pending, - show_down_only=show_down_only, - widget_id=widget_id, - ) diff --git a/plugins/http/scheduler.py b/plugins/http/scheduler.py deleted file mode 100644 index 424836c..0000000 --- a/plugins/http/scheduler.py +++ /dev/null @@ -1,121 +0,0 @@ -# plugins/http/scheduler.py -from __future__ import annotations -import asyncio -import json -import logging -from datetime import datetime, timezone - -from steward.core.scheduler import ScheduledTask -from steward.core.alerts import record_metric - -logger = logging.getLogger(__name__) - - -def make_task(app) -> ScheduledTask: - interval = int( - app.config["PLUGINS"]["http"].get("check_interval_seconds", 60) - ) - - async def check(): - await _do_checks(app) - - return ScheduledTask( - name="http_check", - coro_factory=check, - interval_seconds=interval, - run_on_startup=True, - ) - - -async def _do_checks(app) -> None: - from sqlalchemy import select - from .models import HttpMonitor, HttpResult - from .checker import run_check - - cfg = app.config["PLUGINS"]["http"] - global_interval = int(cfg.get("check_interval_seconds", 60)) - global_timeout = int(cfg.get("default_timeout_seconds", 10)) - global_follow = bool(cfg.get("follow_redirects", True)) - global_verify = bool(cfg.get("verify_ssl", True)) - - now = datetime.now(timezone.utc) - - async with app.db_sessionmaker() as session: - result = await session.execute( - select(HttpMonitor).where(HttpMonitor.enabled == True) # noqa: E712 - ) - monitors = list(result.scalars()) - - # Filter to monitors whose next check time has passed - due: list[HttpMonitor] = [] - for m in monitors: - effective_interval = m.check_interval_seconds or global_interval - if m.last_checked_at is None: - due.append(m) - elif (now - m.last_checked_at).total_seconds() >= effective_interval: - due.append(m) - - if not due: - return - - async def _check_one(monitor: HttpMonitor) -> tuple[HttpMonitor, dict]: - try: - headers = json.loads(monitor.headers_json or "{}") - except (ValueError, TypeError): - headers = {} - result = await run_check( - url=monitor.url, - method=monitor.method, - expected_status=monitor.expected_status, - content_match=monitor.content_match, - headers=headers, - timeout_seconds=monitor.timeout_seconds or global_timeout, - follow_redirects=monitor.follow_redirects if monitor.follow_redirects is not None else global_follow, - verify_ssl=monitor.verify_ssl if monitor.verify_ssl is not None else global_verify, - ) - return monitor, result - - pairs = await asyncio.gather(*[_check_one(m) for m in due], return_exceptions=True) - - check_time = datetime.now(timezone.utc) - - async with app.db_sessionmaker() as session: - async with session.begin(): - for item in pairs: - if isinstance(item, Exception): - logger.error("HTTP check task error: %s", item) - continue - monitor, res = item - - session.add(HttpResult( - monitor_id=monitor.id, - checked_at=check_time, - status_code=res["status_code"], - response_ms=res["response_ms"], - is_up=res["is_up"], - content_matched=res["content_matched"], - error_msg=res["error_msg"], - tls_expires_at=res["tls_expires_at"], - )) - - # Update monitor's last_checked_at for next-run scheduling - db_monitor = await session.get(HttpMonitor, monitor.id) - if db_monitor: - db_monitor.last_checked_at = check_time - - # Alert pipeline - if res["response_ms"] is not None: - await record_metric( - session=session, - source_module="http", - resource_name=monitor.name, - metric_name="response_ms", - value=res["response_ms"], - ) - await record_metric( - session=session, - source_module="http", - resource_name=monitor.name, - metric_name="is_up", - value=1.0 if res["is_up"] else 0.0, - ) diff --git a/plugins/http/templates/http/index.html b/plugins/http/templates/http/index.html deleted file mode 100644 index a2c7771..0000000 --- a/plugins/http/templates/http/index.html +++ /dev/null @@ -1,77 +0,0 @@ -{% extends "base.html" %} -{% block title %}HTTP Monitors — Steward{% endblock %} -{% block content %} -
-

HTTP Monitors

- {% include "_time_range.html" %} -
- -{# ── Add monitor form ─────────────────────────────────────────────────────── #} -
-
Add Monitor
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
-
-
- -{# ── Monitor list ─────────────────────────────────────────────────────────── #} -
-
Loading...
-
-{% endblock %} diff --git a/plugins/http/templates/http/rows.html b/plugins/http/templates/http/rows.html deleted file mode 100644 index 4666fa3..0000000 --- a/plugins/http/templates/http/rows.html +++ /dev/null @@ -1,109 +0,0 @@ -{# http/rows.html — HTMX fragment for HTTP monitor status list #} - -{# ── Summary ─────────────────────────────────────────────────────────────── #} -
-
-
Up
- {{ up }} -
-
-
Down
- {{ down }} -
-
-
Monitors
- {{ monitor_data | length }} -
-
- -{# ── Monitor list ─────────────────────────────────────────────────────────── #} -{% if monitor_data %} -
- - - - - - - - - - - - - {% for item in monitor_data %} - {% set m = item.monitor %} - {% set latest = item.latest %} - - - - - - - - - {% endfor %} - -
MonitorStatusResponseHistoryTLS expiry
-
{{ m.name }}
-
- {{ m.method }} {{ m.url }} -
- {% if not m.enabled %} - paused - {% endif %} -
- {% if not latest %} - pending - {% elif latest.is_up %} -
- - {{ latest.status_code }} -
- {% else %} -
- - - {{ latest.status_code or latest.error_msg or "error" }} - -
- {% if latest.content_matched == false %} -
content mismatch
- {% endif %} - {% endif %} -
- {% if latest and latest.response_ms is not none %} - - {{ "%.0f" | format(latest.response_ms) }}ms - - {% else %} - - {% endif %} - {{ item.sparkline_ms | safe }} - {% if item.tls_days is not none %} - - {{ item.tls_days | int }}d - - {% elif m.url.startswith('https://') %} - - {% endif %} - -
- -
-
- -
-
-
-{% else %} -
-
- No monitors configured yet. Add one using the form above. -
-
-{% endif %} diff --git a/plugins/http/templates/http/widget.html b/plugins/http/templates/http/widget.html deleted file mode 100644 index 891f40b..0000000 --- a/plugins/http/templates/http/widget.html +++ /dev/null @@ -1,56 +0,0 @@ -{# http/widget.html — dashboard widget: HTTP monitor summary #} -{% if not monitor_data and up == 0 and down == 0 and pending == 0 %} -
- No monitors configured. Add monitors → -
-{% else %} - -
- {% if up %} -
- {{ up }} - up -
- {% endif %} - {% if down %} -
- {{ down }} - down -
- {% endif %} - {% if pending %} -
- {{ pending }} - pending -
- {% endif %} -
- -
- {% for item in monitor_data %} - {% set latest = item.latest %} -
- {% if not latest %} - - {% elif latest.is_up %} - - {% else %} - - {% endif %} - - {{ item.monitor.name }} - - {% if latest and latest.response_ms is not none %} - - {{ "%.0f" | format(latest.response_ms) }}ms - - {% elif latest and not latest.is_up %} - - {{ latest.status_code or "err" }} - - {% endif %} -
- {% endfor %} -
- -{% endif %} diff --git a/plugins/index.yaml b/plugins/index.yaml index 276306d..0198bc3 100644 --- a/plugins/index.yaml +++ b/plugins/index.yaml @@ -13,22 +13,6 @@ updated: "2026-03-22" plugins: - - name: http - version: "1.0.0" - description: "Synthetic HTTP endpoint monitoring — status code, response time, content match, TLS expiry" - author: "Steward" - license: "MIT" - min_app_version: "0.1.0" - repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins" - homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/http" - download_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/http-v1.0.0/http.zip" - checksum_sha256: "" - tags: - - monitoring - - http - - synthetic - - uptime - - name: docker version: "1.0.0" description: "Docker container status, resource usage, and restart tracking via Docker socket" diff --git a/steward/alerts/routes.py b/steward/alerts/routes.py index fd26249..f1396d4 100644 --- a/steward/alerts/routes.py +++ b/steward/alerts/routes.py @@ -6,7 +6,6 @@ from sqlalchemy import select from steward.auth.middleware import require_role from steward.ansible import sources as src_module from steward.models.alerts import AlertRule, AlertState, AlertStateEnum, AlertOperator, MaintenanceWindow -from steward.models.hosts import Host from steward.models.metrics import PluginMetric from steward.models.users import UserRole @@ -14,15 +13,18 @@ alerts_bp = Blueprint("alerts", __name__, url_prefix="/alerts") _OPERATORS = [(op.value, op.value) for op in AlertOperator] -# Static catalog: source_module → available metric names +# Static catalog: source_module → available metric names. +# Unified monitors emit metrics under their TYPE (icmp/tcp/dns/http), each with +# is_up (1.0 up / 0.0 down) and response_ms (where the probe times it). METRIC_CATALOG: dict[str, list[str]] = { - "ping": ["up", "response_time_ms"], - "dns": ["resolved", "ip_changed"], + "icmp": ["is_up", "response_ms"], + "tcp": ["is_up", "response_ms"], + "dns": ["is_up", "response_ms"], + "http": ["is_up", "response_ms"], "traefik": ["request_rate", "error_rate", "latency_p50_ms", "latency_p95_ms", "latency_p99_ms", "response_bytes_rate", "cert_expiry_days"], "unifi": ["is_up", "latency_ms", "total_clients"], "docker": ["cpu_pct", "mem_pct"], - "http": ["is_up", "response_ms"], "host_agent": [ "cpu_pct", "mem_used_pct", "mem_available_bytes", "swap_used_bytes", "disk_used_pct_worst", "load_1m", "load_5m", "load_15m", "uptime_secs", @@ -38,8 +40,8 @@ _SOURCE_MODULES = list(METRIC_CATALOG.keys()) async def _get_resources(db, source_module: str) -> list[str]: """Return sorted distinct resource names for a source module. - For ping/dns, supplements with host names so the list is populated - even before any metrics have been recorded. + For monitor types (icmp/tcp/dns/http), supplements with monitor names so + the list is populated even before any metrics have been recorded. """ result = await db.execute( select(PluginMetric.resource_name) @@ -49,9 +51,12 @@ async def _get_resources(db, source_module: str) -> list[str]: ) resources: set[str] = {r for (r,) in result} - if source_module in ("ping", "dns"): - host_result = await db.execute(select(Host.name).order_by(Host.name)) - for (name,) in host_result: + if source_module in ("icmp", "tcp", "dns", "http"): + from steward.models.monitors import Monitor + mon_result = await db.execute( + select(Monitor.name).where(Monitor.type == source_module).order_by(Monitor.name) + ) + for (name,) in mon_result: resources.add(name) return sorted(resources) diff --git a/steward/app.py b/steward/app.py index 77d640e..7410bbb 100644 --- a/steward/app.py +++ b/steward/app.py @@ -115,8 +115,7 @@ def create_app( from .auth.routes import auth_bp from .dashboard.routes import dashboard_bp from .hosts.routes import hosts_bp - from .ping.routes import ping_bp - from .dns.routes import dns_bp + from .monitors.routes import monitors_bp from .status.routes import status_bp from .alerts.routes import alerts_bp from .ansible.routes import ansible_bp @@ -127,8 +126,7 @@ def create_app( app.register_blueprint(auth_bp) app.register_blueprint(dashboard_bp) app.register_blueprint(hosts_bp) - app.register_blueprint(ping_bp) - app.register_blueprint(dns_bp) + app.register_blueprint(monitors_bp) app.register_blueprint(status_bp) app.register_blueprint(alerts_bp) app.register_blueprint(ansible_bp) @@ -136,11 +134,10 @@ def create_app( app.register_blueprint(settings_bp) app.register_blueprint(audit_bp) - # Register the core (ping/DNS) status sources for the unified Status page. - # Plugins register their own sources from setup() (e.g. http). Idempotent. - from .core.status import register_status_source, ping_status_source, dns_status_source - register_status_source(ping_status_source) - register_status_source(dns_status_source) + # Register the unified Monitor status source for the Status page. Plugins + # may register additional sources from setup(). Idempotent. + from .core.status import register_status_source, monitor_status_source + register_status_source(monitor_status_source) # Publish the Ansible "run a playbook" capability so plugins (e.g. host_agent # auto-deploy) can drive runs without importing the runner. Ansible is core, @@ -230,35 +227,9 @@ def _register_core_tasks(app: Quart) -> None: poll_interval = app.config.get("MONITORS_POLL_INTERVAL", 60) cleanup_interval = 3600 # hourly - async def run_ping_monitors(): - from sqlalchemy import select - from .models.hosts import Host - from .monitors.ping import ping_check - async with app.db_sessionmaker() as session: - async with session.begin(): - result = await session.execute( - select(Host).where(Host.ping_enabled.is_(True)) - ) - for host in result.scalars().all(): - try: - await ping_check(host, session) - except Exception: - app.logger.exception(f"Ping check failed for host {host.name!r}") - - async def run_dns_monitors(): - from sqlalchemy import select - from .models.hosts import Host - from .monitors.dns import dns_check - async with app.db_sessionmaker() as session: - async with session.begin(): - result = await session.execute( - select(Host).where(Host.dns_enabled.is_(True)) - ) - for host in result.scalars().all(): - try: - await dns_check(host, session) - except Exception: - app.logger.exception(f"DNS check failed for host {host.name!r}") + async def run_monitor_checks(): + from .monitors.scheduler import run_due_monitors + await run_due_monitors(app) async def run_cleanup(): from .core.cleanup import run_cleanup as _cleanup @@ -276,14 +247,8 @@ def _register_core_tasks(app: Quart) -> None: run_on_startup=False, ), ScheduledTask( - name="ping_monitor", - coro_factory=run_ping_monitors, - interval_seconds=poll_interval, - run_on_startup=True, - ), - ScheduledTask( - name="dns_monitor", - coro_factory=run_dns_monitors, + name="monitor_check", + coro_factory=run_monitor_checks, interval_seconds=poll_interval, run_on_startup=True, ), diff --git a/steward/core/cleanup.py b/steward/core/cleanup.py index 94ec4d5..0a9e556 100644 --- a/steward/core/cleanup.py +++ b/steward/core/cleanup.py @@ -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), ]: diff --git a/steward/core/reports.py b/steward/core/reports.py index 7c376d0..a862371 100644 --- a/steward/core/reports.py +++ b/steward/core/reports.py @@ -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 ───────────────────────────────────────────────────────── diff --git a/steward/core/status.py b/steward/core/status.py index 7c903be..435a806 100644 --- a/steward/core/status.py +++ b/steward/core/status.py @@ -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 diff --git a/steward/core/widgets.py b/steward/core/widgets.py index 2f9be05..8660d6d 100644 --- a/steward/core/widgets.py +++ b/steward/core/widgets.py @@ -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", diff --git a/steward/dns/__init__.py b/steward/dns/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/steward/dns/routes.py b/steward/dns/routes.py deleted file mode 100644 index 91f30be..0000000 --- a/steward/dns/routes.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations -from quart import Blueprint, current_app, render_template -from sqlalchemy import select, func -from steward.auth.middleware import require_role -from steward.models.users import UserRole -from steward.models.hosts import Host -from steward.models.monitors import DnsResult - -dns_bp = Blueprint("dns", __name__, url_prefix="/dns") - - -async def _latest_dns(db, host_ids: list[str]) -> dict: - if not host_ids: - return {} - subq = ( - select(DnsResult.host_id, func.max(DnsResult.resolved_at).label("max_at")) - .where(DnsResult.host_id.in_(host_ids)) - .group_by(DnsResult.host_id) - .subquery() - ) - pr = await db.execute( - select(DnsResult).join( - subq, - (DnsResult.host_id == subq.c.host_id) & (DnsResult.resolved_at == subq.c.max_at), - ) - ) - return {r.host_id: r for r in pr.scalars()} - - -@dns_bp.get("/") -@require_role(UserRole.viewer) -async def index(): - async with current_app.db_sessionmaker() as db: - result = await db.execute( - select(Host).where(Host.dns_enabled.is_(True)).order_by(Host.name) - ) - hosts = result.scalars().all() - latest = await _latest_dns(db, [h.id for h in hosts]) - poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60) - return await render_template("dns/index.html", hosts=hosts, latest=latest, poll_interval=poll_interval) - - -@dns_bp.get("/rows") -@require_role(UserRole.viewer) -async def rows(): - async with current_app.db_sessionmaker() as db: - result = await db.execute( - select(Host).where(Host.dns_enabled.is_(True)).order_by(Host.name) - ) - hosts = result.scalars().all() - latest = await _latest_dns(db, [h.id for h in hosts]) - return await render_template("dns/rows.html", hosts=hosts, latest=latest) diff --git a/steward/hosts/routes.py b/steward/hosts/routes.py index 6917ccb..1fd430b 100644 --- a/steward/hosts/routes.py +++ b/steward/hosts/routes.py @@ -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("//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("/") @@ -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("//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( diff --git a/steward/migrations/versions/0022_unify_monitors.py b/steward/migrations/versions/0022_unify_monitors.py new file mode 100644 index 0000000..6c6f589 --- /dev/null +++ b/steward/migrations/versions/0022_unify_monitors.py @@ -0,0 +1,253 @@ +"""Unify ping/dns/http into one Monitor entity + monitor_results + +Merges the standalone "http" branch (http_001_initial) back into the core +line and collapses the three former check models into `monitors` + +`monitor_results`: + + * Host ping/dns facets (hosts.ping_enabled/dns_enabled/probe_type/... ) become + Monitor rows linked via host_id. + * http_monitors rows become hostless Monitor rows (type=http). + * ping_results / dns_results / http_results history is copied into + monitor_results, then the old tables + host columns are dropped. + +The http_monitors/http_results tables have no ORM models anymore (the http +plugin was folded into core), so they're read/dropped via raw SQL. + +Revision ID: 0022_unify_monitors +Revises: 0021_dashboard_widget_grid, http_001_initial +Create Date: 2026-06-18 +""" +import json +import uuid +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0022_unify_monitors" +down_revision: Union[str, Sequence[str], None] = ( + "0021_dashboard_widget_grid", "http_001_initial", +) +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _new_id() -> str: + return str(uuid.uuid4()) + + +def upgrade() -> None: + conn = op.get_bind() + + # ── 1. New tables ───────────────────────────────────────────────────────── + op.create_table( + "monitors", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("name", sa.String(128), nullable=False), + sa.Column("type", sa.String(16), nullable=False), + sa.Column("target", sa.String(2048), nullable=False), + sa.Column("host_id", sa.String(36), + sa.ForeignKey("hosts.id", ondelete="CASCADE"), nullable=True), + sa.Column("config_json", sa.Text, nullable=False, server_default="{}"), + sa.Column("enabled", sa.Boolean, nullable=False, server_default="true"), + sa.Column("check_interval_seconds", sa.Integer, nullable=False, server_default="0"), + sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + # rule 36: new monitor types ship via DROP/ADD of this CHECK constraint. + sa.CheckConstraint("type IN ('icmp', 'tcp', 'dns', 'http')", name="ck_monitors_type"), + ) + op.create_index("ix_monitors_host_id", "monitors", ["host_id"]) + + op.create_table( + "monitor_results", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("monitor_id", sa.String(36), + sa.ForeignKey("monitors.id", ondelete="CASCADE"), nullable=False), + sa.Column("checked_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("is_up", sa.Boolean, nullable=False, server_default="false"), + sa.Column("response_ms", sa.Float, nullable=True), + sa.Column("status_code", sa.Integer, nullable=True), + sa.Column("resolved_ip", sa.String(255), nullable=True), + sa.Column("content_matched", sa.Boolean, nullable=True), + sa.Column("tls_expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("error_msg", sa.String(512), nullable=True), + ) + op.create_index("ix_monitor_results_monitor_id", "monitor_results", ["monitor_id"]) + op.create_index("ix_monitor_results_checked_at", "monitor_results", ["checked_at"]) + op.create_index("ix_monitor_results_monitor_checked", "monitor_results", + ["monitor_id", "checked_at"]) + + ins_monitor = sa.text( + "INSERT INTO monitors (id, name, type, target, host_id, config_json, " + "enabled, check_interval_seconds, last_checked_at, created_at) VALUES " + "(:id, :name, :type, :target, :host_id, :cfg, :enabled, :interval, " + ":last, :created)" + ) + + # ── 2. Host ping/dns facets → Monitor rows ──────────────────────────────── + ping_map: dict[str, str] = {} # host_id -> monitor_id + dns_map: dict[str, str] = {} + hosts = conn.execute(sa.text( + "SELECT id, name, address, probe_type, probe_port, ping_enabled, " + "dns_enabled, dns_expected_ip, created_at FROM hosts" + )).mappings().all() + for h in hosts: + if h["ping_enabled"]: + mid = _new_id() + mtype = "icmp" if str(h["probe_type"]) == "icmp" else "tcp" + cfg = {} if mtype == "icmp" else {"port": h["probe_port"] or 80} + conn.execute(ins_monitor, { + "id": mid, "name": h["name"], "type": mtype, "target": h["address"], + "host_id": h["id"], "cfg": json.dumps(cfg), "enabled": True, + "interval": 0, "last": None, "created": h["created_at"], + }) + ping_map[h["id"]] = mid + if h["dns_enabled"]: + mid = _new_id() + cfg = {"expected_ip": h["dns_expected_ip"]} + conn.execute(ins_monitor, { + "id": mid, "name": f"{h['name']} (DNS)", "type": "dns", + "target": h["address"], "host_id": h["id"], "cfg": json.dumps(cfg), + "enabled": True, "interval": 0, "last": None, "created": h["created_at"], + }) + dns_map[h["id"]] = mid + + # ── 3. http_monitors rows → hostless Monitor rows (raw SQL: no ORM) ─────── + http_map: dict[str, str] = {} # old http monitor id -> new monitor id + http_rows = conn.execute(sa.text( + "SELECT id, name, url, method, expected_status, content_match, " + "headers_json, timeout_seconds, check_interval_seconds, follow_redirects, " + "verify_ssl, enabled, last_checked_at, created_at FROM http_monitors" + )).mappings().all() + for m in http_rows: + mid = _new_id() + try: + headers = json.loads(m["headers_json"] or "{}") + except (ValueError, TypeError): + headers = {} + cfg = { + "method": m["method"], "expected_status": m["expected_status"], + "content_match": m["content_match"], "headers": headers, + "timeout_seconds": m["timeout_seconds"], + "follow_redirects": bool(m["follow_redirects"]), + "verify_ssl": bool(m["verify_ssl"]), + } + conn.execute(ins_monitor, { + "id": mid, "name": m["name"], "type": "http", "target": m["url"], + "host_id": None, "cfg": json.dumps(cfg), "enabled": bool(m["enabled"]), + "interval": m["check_interval_seconds"] or 0, + "last": m["last_checked_at"], "created": m["created_at"], + }) + http_map[m["id"]] = mid + + # ── 4. Result history → monitor_results ─────────────────────────────────── + for host_id, mid in ping_map.items(): + conn.execute(sa.text( + "INSERT INTO monitor_results (id, monitor_id, checked_at, is_up, response_ms) " + "SELECT gen_random_uuid()::text, :mid, probed_at, (status = 'up'), response_time_ms " + "FROM ping_results WHERE host_id = :hid" + ), {"mid": mid, "hid": host_id}) + for host_id, mid in dns_map.items(): + conn.execute(sa.text( + "INSERT INTO monitor_results (id, monitor_id, checked_at, is_up, resolved_ip) " + "SELECT gen_random_uuid()::text, :mid, resolved_at, (status = 'resolved'), resolved_ip " + "FROM dns_results WHERE host_id = :hid" + ), {"mid": mid, "hid": host_id}) + for old_id, mid in http_map.items(): + conn.execute(sa.text( + "INSERT INTO monitor_results (id, monitor_id, checked_at, is_up, response_ms, " + "status_code, content_matched, tls_expires_at, error_msg) " + "SELECT gen_random_uuid()::text, :mid, checked_at, is_up, response_ms, " + "status_code, content_matched, tls_expires_at, error_msg " + "FROM http_results WHERE monitor_id = :oid" + ), {"mid": mid, "oid": old_id}) + + # ── 4b. Repoint dashboard widgets (ping/dns/http_monitors → monitors) ───── + # The ping/dns/http_monitors widget keys are retired; one `monitors` widget + # replaces them. Convert ping in place, drop the now-redundant others. + conn.execute(sa.text( + "UPDATE dashboard_widgets SET widget_key = 'monitors' WHERE widget_key = 'ping'" + )) + conn.execute(sa.text( + "DELETE FROM dashboard_widgets WHERE widget_key IN ('dns', 'http_monitors')" + )) + + # ── 5. Drop the old tables, host columns, and orphaned enum types ───────── + op.drop_table("ping_results") + op.drop_table("dns_results") + op.drop_table("http_results") + op.drop_table("http_monitors") + op.drop_column("hosts", "ping_enabled") + op.drop_column("hosts", "dns_enabled") + op.drop_column("hosts", "dns_expected_ip") + op.drop_column("hosts", "probe_type") + op.drop_column("hosts", "probe_port") + op.drop_column("hosts", "poll_interval_seconds") + op.execute("DROP TYPE IF EXISTS pingstatus") + op.execute("DROP TYPE IF EXISTS dnsstatus") + op.execute("DROP TYPE IF EXISTS probetype") + + +def downgrade() -> None: + # Dev-only project (no installs to protect): downgrade restores the table + # shells so the schema is walkable, but does NOT recover migrated history. + op.add_column("hosts", sa.Column("poll_interval_seconds", sa.Integer, nullable=True)) + op.add_column("hosts", sa.Column("probe_port", sa.Integer, nullable=False, server_default="80")) + op.add_column("hosts", sa.Column("probe_type", + sa.Enum("tcp", "icmp", name="probetype"), nullable=False, server_default="tcp")) + op.add_column("hosts", sa.Column("dns_expected_ip", sa.String(255), nullable=True)) + op.add_column("hosts", sa.Column("dns_enabled", sa.Boolean, nullable=False, server_default="false")) + op.add_column("hosts", sa.Column("ping_enabled", sa.Boolean, nullable=False, server_default="true")) + + op.create_table( + "ping_results", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("host_id", sa.String(36), sa.ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False), + sa.Column("probed_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("status", sa.Enum("up", "down", name="pingstatus"), nullable=False), + sa.Column("response_time_ms", sa.Float, nullable=True), + ) + op.create_table( + "dns_results", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("host_id", sa.String(36), sa.ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("status", sa.Enum("resolved", "failed", name="dnsstatus"), nullable=False), + sa.Column("resolved_ip", sa.String(255), nullable=True), + ) + op.create_table( + "http_monitors", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("name", sa.String(128), nullable=False), + sa.Column("url", sa.String(2048), nullable=False), + sa.Column("method", sa.String(8), nullable=False, server_default="GET"), + sa.Column("expected_status", sa.Integer, nullable=False, server_default="200"), + sa.Column("content_match", sa.String(512), nullable=False, server_default=""), + sa.Column("headers_json", sa.Text, nullable=False, server_default="{}"), + sa.Column("timeout_seconds", sa.Integer, nullable=False, server_default="10"), + sa.Column("check_interval_seconds", sa.Integer, nullable=False, server_default="0"), + sa.Column("follow_redirects", sa.Boolean, nullable=False, server_default="1"), + sa.Column("verify_ssl", sa.Boolean, nullable=False, server_default="1"), + sa.Column("enabled", sa.Boolean, nullable=False, server_default="1"), + sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_table( + "http_results", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("monitor_id", sa.String(36), nullable=False, index=True), + sa.Column("checked_at", sa.DateTime(timezone=True), nullable=False, index=True), + sa.Column("status_code", sa.Integer, nullable=True), + sa.Column("response_ms", sa.Float, nullable=True), + sa.Column("is_up", sa.Boolean, nullable=False, server_default="0"), + sa.Column("content_matched", sa.Boolean, nullable=True), + sa.Column("error_msg", sa.String(512), nullable=True), + sa.Column("tls_expires_at", sa.DateTime(timezone=True), nullable=True), + ) + + op.drop_index("ix_monitor_results_monitor_checked", table_name="monitor_results") + op.drop_index("ix_monitor_results_checked_at", table_name="monitor_results") + op.drop_index("ix_monitor_results_monitor_id", table_name="monitor_results") + op.drop_table("monitor_results") + op.drop_index("ix_monitors_host_id", table_name="monitors") + op.drop_table("monitors") diff --git a/plugins/http/migrations/versions/http_001_initial.py b/steward/migrations/versions/http_001_initial.py similarity index 82% rename from plugins/http/migrations/versions/http_001_initial.py rename to steward/migrations/versions/http_001_initial.py index c546023..abf10a9 100644 --- a/plugins/http/migrations/versions/http_001_initial.py +++ b/steward/migrations/versions/http_001_initial.py @@ -1,4 +1,11 @@ -"""HTTP monitoring plugin initial tables +"""HTTP monitoring tables (relocated from the former http plugin) + +This revision originally lived in plugins/http/migrations. The http plugin has +been folded into core (unified Monitor entity), so its one migration is moved +here UNCHANGED — same revision id + "http" branch label — purely so the +revision stays resolvable in existing databases that already stamped it. The +0022_unify_monitors merge revision then absorbs this branch and drops these +tables after migrating their data into `monitor_results`. Revision ID: http_001_initial Revises: (none — branch off core via depends_on) diff --git a/steward/models/__init__.py b/steward/models/__init__.py index 5f84866..189720f 100644 --- a/steward/models/__init__.py +++ b/steward/models/__init__.py @@ -1,7 +1,7 @@ from .base import Base from .users import User -from .hosts import Host, ProbeType -from .monitors import PingResult, DnsResult, PingStatus, DnsStatus +from .hosts import Host +from .monitors import Monitor, MonitorResult, MonitorType from .metrics import PluginMetric from .alerts import AlertRule, AlertState, AlertEvent, AlertOperator, AlertStateEnum from .ansible import AnsibleRun, AnsibleRunStatus @@ -12,8 +12,8 @@ from .dashboard import Dashboard, DashboardWidget, DashboardShareToken __all__ = [ "Base", "User", - "Host", "ProbeType", - "PingResult", "DnsResult", "PingStatus", "DnsStatus", + "Host", + "Monitor", "MonitorResult", "MonitorType", "PluginMetric", "AlertRule", "AlertState", "AlertEvent", "AlertOperator", "AlertStateEnum", "AnsibleRun", "AnsibleRunStatus", diff --git a/steward/models/hosts.py b/steward/models/hosts.py index 61ee641..9573515 100644 --- a/steward/models/hosts.py +++ b/steward/models/hosts.py @@ -1,29 +1,20 @@ from __future__ import annotations -import enum import uuid from datetime import datetime, timezone -from sqlalchemy import Boolean, DateTime, Enum, Integer, String +from sqlalchemy import DateTime, String from sqlalchemy.orm import Mapped, mapped_column from .base import Base -class ProbeType(str, enum.Enum): - tcp = "tcp" - icmp = "icmp" - - class Host(Base): + """A registered host. Reachability/DNS/HTTP checks are now separate Monitor + rows that link back via Monitor.host_id — a Host is just identity + address. + """ __tablename__ = "hosts" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) name: Mapped[str] = mapped_column(String(128), nullable=False) address: Mapped[str] = mapped_column(String(255), nullable=False) - probe_type: Mapped[ProbeType] = mapped_column(Enum(ProbeType), nullable=False, default=ProbeType.tcp) - probe_port: Mapped[int] = mapped_column(Integer, nullable=False, default=80) - ping_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) - dns_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - dns_expected_ip: Mapped[str | None] = mapped_column(String(255), nullable=True) - poll_interval_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) ) diff --git a/steward/models/monitors.py b/steward/models/monitors.py index 0864e85..66bcd75 100644 --- a/steward/models/monitors.py +++ b/steward/models/monitors.py @@ -1,45 +1,117 @@ from __future__ import annotations import enum +import json import uuid from datetime import datetime, timezone -from sqlalchemy import DateTime, Enum, Float, ForeignKey, String +from sqlalchemy import ( + Boolean, CheckConstraint, DateTime, Float, ForeignKey, Index, Integer, String, Text, +) from sqlalchemy.orm import Mapped, mapped_column from .base import Base -class PingStatus(str, enum.Enum): - up = "up" - down = "down" +class MonitorType(str, enum.Enum): + """The kinds of synthetic/uptime check a Monitor can run.""" + icmp = "icmp" # ICMP echo via the system ping binary + tcp = "tcp" # TCP connect to target:port + dns = "dns" # DNS A/AAAA resolution, optional expected-IP assertion + http = "http" # HTTP(S) request — status / content / TLS expiry -class DnsStatus(str, enum.Enum): - resolved = "resolved" - failed = "failed" +# Whitelist for the CHECK constraint. `type` is a plain String + CHECK rather +# than a native Postgres enum so a new monitor type ships as a same-change +# DROP/ADD CONSTRAINT (family rule 36) instead of the heavier ALTER TYPE ADD +# VALUE a native enum would force. +MONITOR_TYPE_VALUES = tuple(t.value for t in MonitorType) +_TYPE_CHECK = "type IN ('icmp', 'tcp', 'dns', 'http')" -class PingResult(Base): - __tablename__ = "ping_results" +class Monitor(Base): + """A single configured uptime/synthetic check of any type. - id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) - host_id: Mapped[str] = mapped_column( - String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False + Unifies what used to be host-coupled ping/DNS facets (columns on `hosts`) + and the standalone HTTP plugin monitor. `host_id` is OPTIONAL: set when the + check belongs to a registered Host (then it surfaces on the host hub), or + NULL for a free-standing custom destination. Type-specific settings live in + `config_json` so adding a new type never widens this table. + """ + __tablename__ = "monitors" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) ) - probed_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) + name: Mapped[str] = mapped_column(String(128), nullable=False) + type: Mapped[str] = mapped_column(String(16), nullable=False) + # Address (icmp/tcp/dns) or URL (http) — free-form, not tied to a Host row. + target: Mapped[str] = mapped_column(String(2048), nullable=False) + host_id: Mapped[str | None] = mapped_column( + String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=True, index=True ) - status: Mapped[PingStatus] = mapped_column(Enum(PingStatus), nullable=False) - response_time_ms: Mapped[float | None] = mapped_column(Float, nullable=True) + # Type-specific config as a JSON object. Keys by type: + # icmp -> {} + # tcp -> {"port": int} + # dns -> {"expected_ip": str | None} + # http -> {"method", "expected_status", "content_match", "headers", + # "timeout_seconds", "follow_redirects", "verify_ssl"} + config_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + # 0 = use the global monitors.poll_interval_seconds setting. + check_interval_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # Updated after each check so the scheduler can compute next-run efficiently. + last_checked_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + + __table_args__ = ( + CheckConstraint(_TYPE_CHECK, name="ck_monitors_type"), + ) + + @property + def config(self) -> dict: + try: + return json.loads(self.config_json or "{}") + except (ValueError, TypeError): + return {} + + def set_config(self, cfg: dict) -> None: + self.config_json = json.dumps(cfg or {}) -class DnsResult(Base): - __tablename__ = "dns_results" +class MonitorResult(Base): + """One check result for a Monitor, across all types. - id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) - host_id: Mapped[str] = mapped_column( - String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False + Superset of the old per-type result columns: response_ms is universal, + while status_code/content_matched/tls_expires_at (http) and resolved_ip + (dns) are populated only for the types that produce them. + """ + __tablename__ = "monitor_results" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) ) - resolved_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) + monitor_id: Mapped[str] = mapped_column( + String(36), ForeignKey("monitors.id", ondelete="CASCADE"), + nullable=False, index=True, + ) + checked_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, + default=lambda: datetime.now(timezone.utc), index=True, + ) + is_up: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + response_ms: Mapped[float | None] = mapped_column(Float, nullable=True) + status_code: Mapped[int | None] = mapped_column(Integer, nullable=True) # http + resolved_ip: Mapped[str | None] = mapped_column(String(255), nullable=True) # dns + content_matched: Mapped[bool | None] = mapped_column(Boolean, nullable=True) # http + tls_expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True # http + ) + error_msg: Mapped[str | None] = mapped_column(String(512), nullable=True) + + __table_args__ = ( + # The status/heartbeat queries page the latest N per monitor by time. + Index("ix_monitor_results_monitor_checked", "monitor_id", "checked_at"), ) - status: Mapped[DnsStatus] = mapped_column(Enum(DnsStatus), nullable=False) - resolved_ip: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/steward/monitors/dns.py b/steward/monitors/dns.py index 2c32aae..55e8b5b 100644 --- a/steward/monitors/dns.py +++ b/steward/monitors/dns.py @@ -1,62 +1,34 @@ +"""DNS resolution check — pure probe returning a result dict.""" from __future__ import annotations import asyncio import logging import socket -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from steward.core.alerts import record_metric -from steward.models.hosts import Host -from steward.models.monitors import DnsResult, DnsStatus - logger = logging.getLogger(__name__) -async def dns_check(host: Host, session: AsyncSession) -> None: - """Resolve host and write dns_result + metrics.""" - result = await _resolve(host.address) +async def dns_check(address: str, expected_ip: str | None = None) -> dict: + """Resolve `address`. Returns {is_up, resolved_ip, error_msg}. - if result["resolved"]: - resolved_ip = result["ip"] # first A/AAAA record (stored in DB) - all_ips = result["all_ips"] + Up = resolution succeeded AND (no expected_ip, or at least one returned + record matches expected_ip). resolved_ip is the first A/AAAA record. + """ + result = await _resolve(address) + if not result["resolved"]: + return {"is_up": False, "resolved_ip": None, "error_msg": "Resolution failed"} - # Check against expected IP: pass only if at least one returned record matches - if host.dns_expected_ip and host.dns_expected_ip not in all_ips: - status = DnsStatus.failed - dns_resolved_metric = 0.0 - else: - status = DnsStatus.resolved - dns_resolved_metric = 1.0 - - # Detect IP change vs most recent successful resolution - prev_result = await session.execute( - select(DnsResult) - .where(DnsResult.host_id == host.id, DnsResult.status == DnsStatus.resolved) - .order_by(DnsResult.resolved_at.desc()) - .limit(1) - ) - prev = prev_result.scalar_one_or_none() - ip_changed = 1.0 if (prev and prev.resolved_ip != resolved_ip) else 0.0 - else: - resolved_ip = None - status = DnsStatus.failed - dns_resolved_metric = 0.0 - ip_changed = 0.0 - - session.add(DnsResult( - host_id=host.id, - status=status, - resolved_ip=resolved_ip, - )) - await session.flush() - - await record_metric(session, "dns", host.name, "resolved", dns_resolved_metric) - await record_metric(session, "dns", host.name, "ip_changed", ip_changed) + resolved_ip = result["ip"] + if expected_ip and expected_ip not in result["all_ips"]: + return { + "is_up": False, + "resolved_ip": resolved_ip, + "error_msg": f"Expected {expected_ip} not in {', '.join(result['all_ips'])}", + } + return {"is_up": True, "resolved_ip": resolved_ip, "error_msg": None} async def _resolve(address: str) -> dict: - """Resolve hostname. Returns all A/AAAA records; stores first as resolved_ip.""" + """Resolve hostname. Returns all A/AAAA records; first is the resolved_ip.""" try: loop = asyncio.get_running_loop() infos = await loop.run_in_executor( @@ -67,5 +39,5 @@ async def _resolve(address: str) -> dict: return {"resolved": True, "ip": all_ips[0], "all_ips": all_ips} return {"resolved": False, "ip": None, "all_ips": []} except Exception as exc: - logger.debug(f"DNS resolution failed for {address}: {exc}") + logger.debug("DNS resolution failed for %s: %s", address, exc) return {"resolved": False, "ip": None, "all_ips": []} diff --git a/plugins/http/checker.py b/steward/monitors/http.py similarity index 80% rename from plugins/http/checker.py rename to steward/monitors/http.py index c898ad6..bf50107 100644 --- a/plugins/http/checker.py +++ b/steward/monitors/http.py @@ -1,5 +1,8 @@ -# plugins/http/checker.py -"""Core HTTP check logic — runs a single monitor check and returns a result dict.""" +"""HTTP(S) check — one synthetic request, returns a normalised result dict. + +Moved into core from the former http plugin (plugins/http/checker.py) when +ping/dns/http were unified under the Monitor entity. +""" from __future__ import annotations import asyncio import logging @@ -14,7 +17,7 @@ logger = logging.getLogger(__name__) async def _get_tls_expiry(hostname: str, port: int) -> datetime | None: - """Attempt a bare TLS handshake to extract the certificate expiry date.""" + """Bare TLS handshake to read the certificate's notAfter date.""" ctx = ssl.create_default_context() try: reader, writer = await asyncio.wait_for( @@ -39,7 +42,7 @@ async def _get_tls_expiry(hostname: str, port: int) -> datetime | None: return None -async def run_check( +async def http_check( url: str, method: str = "GET", expected_status: int = 200, @@ -49,10 +52,7 @@ async def run_check( follow_redirects: bool = True, verify_ssl: bool = True, ) -> dict: - """ - Perform one HTTP check. Returns a dict with: - is_up, status_code, response_ms, content_matched, error_msg, tls_expires_at - """ + """Perform one HTTP check. Returns the MonitorResult field dict.""" result: dict = { "is_up": False, "status_code": None, @@ -72,11 +72,7 @@ async def run_check( verify=verify_ssl, timeout=timeout_seconds, ) as client: - response = await client.request( - method, - url, - headers=headers or {}, - ) + response = await client.request(method, url, headers=headers or {}) result["response_ms"] = round((time.monotonic() - t0) * 1000, 1) result["status_code"] = response.status_code @@ -96,8 +92,8 @@ async def run_check( except Exception as exc: result["error_msg"] = str(exc)[:512] - # TLS expiry — only for HTTPS, and only when the check succeeded or we got a response - if is_https and result["status_code"] is not None: + # TLS expiry — only for HTTPS once we have any response. + if is_https and result["status_code"] is not None and parsed.hostname: port = parsed.port or 443 result["tls_expires_at"] = await _get_tls_expiry(parsed.hostname, port) diff --git a/steward/monitors/ping.py b/steward/monitors/ping.py index 6faf845..62f93a0 100644 --- a/steward/monitors/ping.py +++ b/steward/monitors/ping.py @@ -1,43 +1,17 @@ +"""ICMP / TCP reachability checks — pure probes returning a result dict.""" from __future__ import annotations import asyncio import logging import time -from sqlalchemy.ext.asyncio import AsyncSession - -from steward.core.alerts import record_metric -from steward.models.hosts import Host, ProbeType -from steward.models.monitors import PingResult, PingStatus - logger = logging.getLogger(__name__) TCP_TIMEOUT = 5.0 DEFAULT_TCP_PORT = 80 -async def ping_check(host: Host, session: AsyncSession) -> None: - """Probe a single host and write ping_result + metrics.""" - if host.probe_type == ProbeType.icmp: - result = await _icmp_ping(host.address) - else: - result = await _tcp_ping(host.address, host.probe_port or DEFAULT_TCP_PORT) - - status = PingStatus.up if result["up"] else PingStatus.down - session.add(PingResult( - host_id=host.id, - status=status, - response_time_ms=result.get("response_time_ms"), - )) - await session.flush() - - await record_metric(session, "ping", host.name, "up", 1.0 if result["up"] else 0.0) - await record_metric( - session, "ping", host.name, "response_time_ms", - result.get("response_time_ms") or 0.0, - ) - - -async def _tcp_ping(address: str, port: int) -> dict: +async def tcp_check(address: str, port: int) -> dict: + """TCP connect probe. Returns {is_up, response_ms}.""" start = time.monotonic() try: reader, writer = await asyncio.wait_for( @@ -46,13 +20,17 @@ async def _tcp_ping(address: str, port: int) -> dict: ) writer.close() await writer.wait_closed() - return {"up": True, "response_time_ms": round((time.monotonic() - start) * 1000, 2)} + return {"is_up": True, "response_ms": round((time.monotonic() - start) * 1000, 2)} except Exception: - return {"up": False, "response_time_ms": None} + return {"is_up": False, "response_ms": None} -async def _icmp_ping(address: str) -> dict: - """Use system ping command (setuid binary; no raw socket privilege needed in Python).""" +async def icmp_check(address: str) -> dict: + """ICMP echo via the system ping binary (setuid; no raw-socket privilege). + + Falls back to a TCP connect on the default port if the ping binary is + unavailable or errors, so a host with ICMP filtered still gets a signal. + """ start = time.monotonic() try: proc = await asyncio.create_subprocess_exec( @@ -62,8 +40,8 @@ async def _icmp_ping(address: str) -> dict: ) await asyncio.wait_for(proc.wait(), timeout=5.0) if proc.returncode == 0: - return {"up": True, "response_time_ms": round((time.monotonic() - start) * 1000, 2)} - return {"up": False, "response_time_ms": None} + return {"is_up": True, "response_ms": round((time.monotonic() - start) * 1000, 2)} + return {"is_up": False, "response_ms": None} except Exception as exc: - logger.warning(f"ICMP ping failed for {address}: {exc}, falling back to TCP") - return await _tcp_ping(address, DEFAULT_TCP_PORT) + logger.warning("ICMP ping failed for %s: %s, falling back to TCP", address, exc) + return await tcp_check(address, DEFAULT_TCP_PORT) diff --git a/steward/monitors/routes.py b/steward/monitors/routes.py new file mode 100644 index 0000000..36ea7ad --- /dev/null +++ b/steward/monitors/routes.py @@ -0,0 +1,215 @@ +"""Unified monitors management surface (/monitors) — ping, DNS, HTTP. + +Replaces the former /ping, /dns and /plugins/http pages. One list, one +type-aware add/edit form, one dashboard widget. A monitor may be linked to a +Host (host_id) or watch a free-standing custom destination (host_id = NULL). +""" +from __future__ import annotations +import json +import uuid +from datetime import datetime, timezone + +from quart import Blueprint, current_app, render_template, request, redirect, url_for +from sqlalchemy import select + +from steward.auth.middleware import require_role +from steward.models.users import UserRole +from steward.models.monitors import Monitor, MonitorType, MONITOR_TYPE_VALUES +from steward.core.status import _last_n_results, _uptime_by_monitor, _heartbeat_title +from steward.core.time_range import parse_range, DEFAULT_RANGE + +monitors_bp = Blueprint("monitors", __name__, url_prefix="/monitors") + + +def _build_config(form, mtype: str) -> dict: + """Assemble the type-specific config dict from submitted form fields.""" + if mtype == MonitorType.tcp.value: + return {"port": int(form.get("port") or 80)} + if mtype == MonitorType.dns.value: + return {"expected_ip": (form.get("expected_ip", "").strip() or None)} + if mtype == MonitorType.http.value: + try: + headers = json.loads(form.get("headers_json") or "{}") + except (ValueError, TypeError): + headers = {} + return { + "method": form.get("method", "GET").upper(), + "expected_status": int(form.get("expected_status", 200) or 200), + "content_match": form.get("content_match", "").strip(), + "headers": headers, + "timeout_seconds": int(form.get("timeout_seconds", 10) or 10), + "follow_redirects": "follow_redirects" in form, + "verify_ssl": "verify_ssl" in form, + } + return {} # icmp + + +def _normalise_target(mtype: str, target: str) -> str: + """For http, default a bare host to https://; others pass through.""" + target = target.strip() + if mtype == MonitorType.http.value and target and not target.startswith(("http://", "https://")): + return "https://" + target + return target + + +async def _rows_data(range_key: str): + """Per-monitor display data for the list / widget.""" + since, range_key = parse_range(range_key) + async with current_app.db_sessionmaker() as db: + monitors = list((await db.execute( + select(Monitor).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) + + # Map the selected range to the uptime bucket we surface in the list. + bucket = {"24h": "24h", "7d": "7d", "30d": "30d"}.get(range_key, "24h") + now = datetime.now(timezone.utc) + data = [] + for m in monitors: + rows = recent.get(m.id, []) + latest = rows[-1] if rows else None + 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, + "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), + "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 + + +@monitors_bp.get("/") +@require_role(UserRole.viewer) +async def index(): + poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60) + return await render_template( + "monitors/index.html", + poll_interval=poll_interval, + current_range=request.args.get("range", DEFAULT_RANGE), + types=list(MonitorType), + ) + + +@monitors_bp.get("/rows") +@require_role(UserRole.viewer) +async def rows(): + data, up, down, pending, range_key = await _rows_data( + request.args.get("range", DEFAULT_RANGE) + ) + return await render_template( + "monitors/rows.html", + monitor_data=data, up=up, down=down, pending=pending, range_key=range_key, + ) + + +@monitors_bp.post("/add") +@require_role(UserRole.operator) +async def add_monitor(): + form = await request.form + mtype = form.get("type", "").strip() + target = _normalise_target(mtype, form.get("target", "")) + if mtype not in MONITOR_TYPE_VALUES or not target: + return redirect(url_for("monitors.index")) + + host_id = form.get("host_id", "").strip() or None + monitor = Monitor( + id=str(uuid.uuid4()), + name=form.get("name", "").strip() or target, + type=mtype, + target=target, + host_id=host_id, + config_json=json.dumps(_build_config(form, mtype)), + enabled=True, + check_interval_seconds=int(form.get("check_interval_seconds", 0) or 0), + created_at=datetime.now(timezone.utc), + ) + async with current_app.db_sessionmaker() as db: + async with db.begin(): + db.add(monitor) + + # A host-scoped add returns to that host's hub; otherwise the monitors page. + if host_id: + return redirect(url_for("hosts.host_detail", host_id=host_id)) + return redirect(url_for("monitors.index")) + + +@monitors_bp.get("//edit") +@require_role(UserRole.operator) +async def edit_form(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")) + return await render_template( + "monitors/edit.html", monitor=m, config=m.config, types=list(MonitorType), + ) + + +@monitors_bp.post("//edit") +@require_role(UserRole.operator) +async def edit_monitor(monitor_id: str): + form = await request.form + async with current_app.db_sessionmaker() as db: + async with db.begin(): + m = await db.get(Monitor, monitor_id) + if not m: + return redirect(url_for("monitors.index")) + # Type is immutable on edit (changes the meaning of config/target); + # to switch type, delete and re-add. + m.name = form.get("name", "").strip() or m.target + m.target = _normalise_target(m.type, form.get("target", m.target)) + m.config_json = json.dumps(_build_config(form, m.type)) + m.check_interval_seconds = int(form.get("check_interval_seconds", 0) or 0) + return redirect(url_for("monitors.index")) + + +@monitors_bp.post("//toggle") +@require_role(UserRole.operator) +async def toggle_monitor(monitor_id: str): + async with current_app.db_sessionmaker() as db: + async with db.begin(): + m = await db.get(Monitor, monitor_id) + if m: + m.enabled = not m.enabled + return redirect(request.referrer or url_for("monitors.index")) + + +@monitors_bp.post("//delete") +@require_role(UserRole.operator) +async def delete_monitor(monitor_id: str): + async with current_app.db_sessionmaker() as db: + async with db.begin(): + m = await db.get(Monitor, monitor_id) + if m: + await db.delete(m) + return redirect(request.referrer or url_for("monitors.index")) + + +@monitors_bp.get("/widget") +@require_role(UserRole.viewer) +async def widget(): + type_filter = request.args.get("type_filter", "all") + show_down_only = request.args.get("show_down_only", "no") == "yes" + limit = max(1, min(50, int(request.args.get("limit", 10) or 10))) + widget_id = request.args.get("wid", "0") + + data, up, down, pending, _ = await _rows_data(DEFAULT_RANGE) + if type_filter in MONITOR_TYPE_VALUES: + data = [d for d in data if d["monitor"].type == type_filter] + if show_down_only: + data = [d for d in data if not (d["latest"] and d["latest"].is_up)] + + return await render_template( + "monitors/widget.html", + monitor_data=data[:limit], up=up, down=down, pending=pending, + show_down_only=show_down_only, widget_id=widget_id, + ) diff --git a/steward/monitors/runner.py b/steward/monitors/runner.py new file mode 100644 index 0000000..0a0d446 --- /dev/null +++ b/steward/monitors/runner.py @@ -0,0 +1,56 @@ +"""Unified check dispatcher: Monitor -> normalised MonitorResult fields. + +One entry point, `run_monitor`, dispatches on Monitor.type to the per-type +probes (ping/dns/http) and returns a dict with every MonitorResult field so the +scheduler can persist a row uniformly regardless of type. +""" +from __future__ import annotations +import logging + +from steward.models.monitors import Monitor, MonitorType +from .ping import tcp_check, icmp_check, DEFAULT_TCP_PORT +from .dns import dns_check +from .http import http_check + +logger = logging.getLogger(__name__) + +# Every result carries these keys; type-specific probes fill the relevant ones. +_BLANK = { + "is_up": False, + "response_ms": None, + "status_code": None, + "resolved_ip": None, + "content_matched": None, + "tls_expires_at": None, + "error_msg": None, +} + + +async def run_monitor(monitor: Monitor) -> dict: + """Run one monitor's check and return a full MonitorResult field dict.""" + cfg = monitor.config + result = dict(_BLANK) + try: + if monitor.type == MonitorType.icmp.value: + result.update(await icmp_check(monitor.target)) + elif monitor.type == MonitorType.tcp.value: + result.update(await tcp_check(monitor.target, int(cfg.get("port") or DEFAULT_TCP_PORT))) + elif monitor.type == MonitorType.dns.value: + result.update(await dns_check(monitor.target, cfg.get("expected_ip") or None)) + elif monitor.type == MonitorType.http.value: + result.update(await http_check( + url=monitor.target, + method=cfg.get("method", "GET"), + expected_status=int(cfg.get("expected_status", 200) or 200), + content_match=cfg.get("content_match", "") or "", + headers=cfg.get("headers") or {}, + timeout_seconds=int(cfg.get("timeout_seconds", 10) or 10), + follow_redirects=bool(cfg.get("follow_redirects", True)), + verify_ssl=bool(cfg.get("verify_ssl", True)), + )) + else: + result["error_msg"] = f"Unknown monitor type {monitor.type!r}" + except Exception as exc: # a probe should never take the scheduler down + logger.exception("Monitor %r (%s) check raised", monitor.name, monitor.type) + result["error_msg"] = str(exc)[:512] + return result diff --git a/steward/monitors/scheduler.py b/steward/monitors/scheduler.py new file mode 100644 index 0000000..f5fd1b7 --- /dev/null +++ b/steward/monitors/scheduler.py @@ -0,0 +1,76 @@ +"""Single scheduled task that runs every due Monitor of any type.""" +from __future__ import annotations +import asyncio +import logging +from datetime import datetime, timezone + +from sqlalchemy import select + +from steward.core.alerts import record_metric +from steward.models.monitors import Monitor, MonitorResult +from .runner import run_monitor + +logger = logging.getLogger(__name__) + + +async def run_due_monitors(app) -> None: + """Check every enabled Monitor whose interval has elapsed, persist results. + + Per-monitor `check_interval_seconds` overrides the global + MONITORS_POLL_INTERVAL (0 = use global), mirroring how the scheduler fires + on the global cadence but each monitor decides if it is actually due. + """ + global_interval = int(app.config.get("MONITORS_POLL_INTERVAL", 60)) + now = datetime.now(timezone.utc) + + async with app.db_sessionmaker() as session: + monitors = list((await session.execute( + select(Monitor).where(Monitor.enabled.is_(True)) + )).scalars()) + + due: list[Monitor] = [] + for m in monitors: + interval = m.check_interval_seconds or global_interval + if m.last_checked_at is None or (now - m.last_checked_at).total_seconds() >= interval: + due.append(m) + if not due: + return + + pairs = await asyncio.gather( + *[run_monitor(m) for m in due], return_exceptions=True + ) + check_time = datetime.now(timezone.utc) + + async with app.db_sessionmaker() as session: + async with session.begin(): + for monitor, res in zip(due, pairs): + if isinstance(res, Exception): + logger.error("Monitor %r check errored: %s", monitor.name, res) + continue + session.add(MonitorResult( + monitor_id=monitor.id, + checked_at=check_time, + is_up=res["is_up"], + response_ms=res["response_ms"], + status_code=res["status_code"], + resolved_ip=res["resolved_ip"], + content_matched=res["content_matched"], + tls_expires_at=res["tls_expires_at"], + error_msg=res["error_msg"], + )) + db_monitor = await session.get(Monitor, monitor.id) + if db_monitor: + db_monitor.last_checked_at = check_time + + # Alert pipeline — keyed by monitor type (source) + name. + await record_metric( + session=session, source_module=monitor.type, + resource_name=monitor.name, metric_name="is_up", + value=1.0 if res["is_up"] else 0.0, + ) + if res["response_ms"] is not None: + await record_metric( + session=session, source_module=monitor.type, + resource_name=monitor.name, metric_name="response_ms", + value=res["response_ms"], + ) diff --git a/steward/ping/__init__.py b/steward/ping/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/steward/ping/routes.py b/steward/ping/routes.py deleted file mode 100644 index de1846e..0000000 --- a/steward/ping/routes.py +++ /dev/null @@ -1,127 +0,0 @@ -from __future__ import annotations -import logging -from quart import Blueprint, current_app, render_template, request, redirect, url_for -from sqlalchemy import select, func, case -from steward.auth.middleware import require_role -from steward.models.users import UserRole -from steward.models.hosts import Host -from steward.models.monitors import PingResult -from steward.core.settings import get_all_settings, set_setting, DEFAULTS -from steward.core.time_range import parse_range, DEFAULT_RANGE - -ping_bp = Blueprint("ping", __name__, url_prefix="/ping") -logger = logging.getLogger(__name__) - -PILL_COUNT = 30 # pills always show the last N pings regardless of range - - -async def _last_n_pings(db, host_ids: list[str], n: int = PILL_COUNT) -> dict[str, list]: - """Return {host_id: [PingResult]} for last n pings per host, oldest first.""" - if not host_ids: - return {} - rn = func.row_number().over( - partition_by=PingResult.host_id, - order_by=PingResult.probed_at.desc(), - ).label("rn") - subq = ( - select(PingResult.id, rn) - .where(PingResult.host_id.in_(host_ids)) - .subquery() - ) - pr = await db.execute( - select(PingResult) - .join(subq, PingResult.id == subq.c.id) - .where(subq.c.rn <= n) - .order_by(PingResult.host_id, PingResult.probed_at.asc()) - ) - result: dict[str, list] = {hid: [] for hid in host_ids} - for row in pr.scalars(): - result[row.host_id].append(row) - return result - - -async def _uptime_pcts(db, host_ids: list[str], since) -> dict[str, float | None]: - """Return {host_id: uptime_%} for results since the given datetime.""" - if not host_ids: - return {} - result = await db.execute( - select( - PingResult.host_id, - func.count().label("total"), - func.sum(case((PingResult.status == "up", 1), else_=0)).label("up_count"), - ) - .where(PingResult.host_id.in_(host_ids)) - .where(PingResult.probed_at >= since) - .group_by(PingResult.host_id) - ) - pcts: dict[str, float | None] = {hid: None for hid in host_ids} - for row in result.all(): - if row.total and row.total > 0: - pcts[row.host_id] = (row.up_count or 0) / row.total * 100.0 - return pcts - - -async def _thresholds(db) -> tuple[int, int]: - settings = await get_all_settings(db) - good = int(settings.get("ping.threshold.good_ms", DEFAULTS["ping.threshold.good_ms"])) - warn = int(settings.get("ping.threshold.warn_ms", DEFAULTS["ping.threshold.warn_ms"])) - return good, warn - - -@ping_bp.get("/") -@require_role(UserRole.viewer) -async def index(): - async with current_app.db_sessionmaker() as db: - good_ms, warn_ms = await _thresholds(db) - poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60) - current_range = request.args.get("range", DEFAULT_RANGE) - return await render_template( - "ping/index.html", - good_ms=good_ms, - warn_ms=warn_ms, - poll_interval=poll_interval, - current_range=current_range, - ) - - -@ping_bp.get("/rows") -@require_role(UserRole.viewer) -async def rows(): - """HTMX fragment — host rows with pills + uptime % for selected range.""" - since, range_key = parse_range(request.args.get("range")) - - async with current_app.db_sessionmaker() as db: - result = await db.execute( - select(Host).where(Host.ping_enabled.is_(True)).order_by(Host.name) - ) - hosts = result.scalars().all() - host_ids = [h.id for h in hosts] - pings_by_host = await _last_n_pings(db, host_ids) - uptime_pcts = await _uptime_pcts(db, host_ids, since) - good_ms, warn_ms = await _thresholds(db) - - return await render_template( - "ping/rows.html", - hosts=hosts, - pings_by_host=pings_by_host, - uptime_pcts=uptime_pcts, - range_key=range_key, - good_ms=good_ms, - warn_ms=warn_ms, - ) - - -@ping_bp.post("/settings") -@require_role(UserRole.admin) -async def save_settings(): - form = await request.form - try: - good = max(1, int(form.get("good_ms", 50))) - warn = max(good + 1, int(form.get("warn_ms", 200))) - except (ValueError, TypeError): - good, warn = 50, 200 - async with current_app.db_sessionmaker() as db: - async with db.begin(): - await set_setting(db, "ping.threshold.good_ms", good) - await set_setting(db, "ping.threshold.warn_ms", warn) - return redirect(url_for("ping.index")) diff --git a/steward/templates/alerts/rules_form.html b/steward/templates/alerts/rules_form.html index c44f22d..10b657d 100644 --- a/steward/templates/alerts/rules_form.html +++ b/steward/templates/alerts/rules_form.html @@ -97,10 +97,12 @@
{% set descs = { - "ping": [("up", "1.0 = reachable, 0.0 = unreachable"), - ("response_time_ms", "round-trip latency in milliseconds")], - "dns": [("resolved", "1.0 = resolved, 0.0 = failed"), - ("ip_changed", "1.0 when resolved IP differs from expected")], + "icmp": [("is_up", "1.0 = reachable, 0.0 = unreachable"), + ("response_ms", "round-trip latency in milliseconds")], + "tcp": [("is_up", "1.0 = port open, 0.0 = unreachable"), + ("response_ms", "TCP connect time in milliseconds")], + "dns": [("is_up", "1.0 = resolved (and matches expected IP), 0.0 = failed"), + ("response_ms", "resolution time in milliseconds")], "traefik": [("request_rate", "requests/sec for this router"), ("error_rate", "fraction of 5xx responses (0–1)"), ("latency_p50_ms", "median response time ms"), diff --git a/steward/templates/base.html b/steward/templates/base.html index b6c8012..3a03dfa 100644 --- a/steward/templates/base.html +++ b/steward/templates/base.html @@ -240,8 +240,7 @@ body.dash-editing .widget-drawer { transform:translateY(0); } Dashboard Status Hosts - Ping - DNS + Monitors Alerts Ansible {% if session.user_role == 'admin' %} diff --git a/steward/templates/dns/index.html b/steward/templates/dns/index.html deleted file mode 100644 index 460127a..0000000 --- a/steward/templates/dns/index.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "base.html" %} -{% block title %}DNS Monitor — Steward{% endblock %} -{% block content %} -
-

DNS Monitor

- polling every {{ poll_interval }}s -
-
-
- {% include "dns/rows.html" %} -
-
-{% endblock %} diff --git a/steward/templates/dns/rows.html b/steward/templates/dns/rows.html deleted file mode 100644 index 6df4664..0000000 --- a/steward/templates/dns/rows.html +++ /dev/null @@ -1,35 +0,0 @@ -{% if hosts %} -{% for host in hosts %} -{% set d = latest.get(host.id) %} -
-
- {# Link to the host hub for logged-in users; plain text on the public share view (no auth, no token on the href). #} - {% if session.user_id %} - {{ host.name }} - {% else %} - {{ host.name }} - {% endif %} - {{ host.address }} -
-
- {% if d is none %} - - {% elif d.status.value == 'resolved' %} - - {{ d.resolved_ip or 'resolved' }} - {% else %} - - Failed - {% endif %} -
-
- {% if d %} - {{ d.resolved_at.strftime('%H:%M:%S') }} UTC - {% endif %} -
-
-{% endfor %} -{% else %} -

No DNS checks yet. Enable DNS on a host →

-{% endif %} diff --git a/steward/templates/hosts/detail.html b/steward/templates/hosts/detail.html index 50eddd9..9651b43 100644 --- a/steward/templates/hosts/detail.html +++ b/steward/templates/hosts/detail.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% from "_macros.html" import crumbs %} +{% from "monitors/_fields.html" import type_fields, toggle_script %} {% block title %}{{ host.name }} — Hosts — Steward{% endblock %} {% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), (host.name, "")]) }}{% endblock %} {% block content %} @@ -22,56 +23,97 @@
+{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %} +{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %} + {# ── Monitors ─────────────────────────────────────────────────────────────── #}
-
+

Monitors

- Configure → + {% if uptime %} + + uptime 24h {{ ("%.1f%%"|format(uptime['24h'])) if uptime['24h'] is not none else '—' }} + · 7d {{ ("%.1f%%"|format(uptime['7d'])) if uptime['7d'] is not none else '—' }} + · 30d {{ ("%.1f%%"|format(uptime['30d'])) if uptime['30d'] is not none else '—' }} + + {% endif %}
-
-
-
Ping
- {% if not host.ping_enabled %} -
off
- {% elif ping is none %} -
no data yet
- {% else %} -
- {{ ping.status.value }} -
- {% if ping.response_time_ms is not none %} -
{{ '%.0f'|format(ping.response_time_ms) }} ms
- {% endif %} - {% endif %} + + {% if monitors %} + {% for d in monitors %} + {% set m = d.monitor %} + {% set latest = d.latest %} +
+
+ {{ m.type | upper }} + {{ m.name }} + {{ m.target }}
-
-
DNS
- {% if not host.dns_enabled %} -
off
- {% elif dns is none %} -
no data yet
- {% else %} -
- {{ dns.status.value }} -
- {% if dns.resolved_ip %} -
{{ dns.resolved_ip }}
- {% endif %} - {% endif %} +
+ {% for _ in range([30 - d.heartbeat|length, 0]|max) %} + + {% endfor %} + {% for h in d.heartbeat %} + + {% endfor %}
-
-
Uptime
- {% if uptime %} -
- 24h {{ uptime['24h'] if uptime['24h'] is not none else '—' }}{{ '%' if uptime['24h'] is not none }} - · 7d {{ uptime['7d'] if uptime['7d'] is not none else '—' }}{{ '%' if uptime['7d'] is not none }} - · 30d {{ uptime['30d'] if uptime['30d'] is not none else '—' }}{{ '%' if uptime['30d'] is not none }} -
- {% else %} -
- {% endif %} +
+ {% if latest is none %} + {% elif not latest.is_up %}DOWN + {% elif latest.response_ms is not none %} + {{ "%.0f"|format(latest.response_ms) }} ms + {% else %}UP{% endif %}
+ {% if session.user_role in ['operator', 'admin'] %} +
+ Edit +
+ +
+
+ {% endif %}
+ {% endfor %} + {% else %} +

+ No monitors yet for this host. Add one below. +

+ {% endif %} + + {% if session.user_role in ['operator', 'admin'] %} +
+ + Add monitor for this host +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {{ type_fields() }} +
+
+
+
+ {% endif %}
{# ── Agent (host_agent plugin fragment, embedded across the plugin boundary) ── #} @@ -168,4 +210,6 @@ {% endif %}
+{{ toggle_script() }} + {% endblock %} diff --git a/steward/templates/hosts/form.html b/steward/templates/hosts/form.html index d6124b7..a6dc34e 100644 --- a/steward/templates/hosts/form.html +++ b/steward/templates/hosts/form.html @@ -15,47 +15,16 @@
-
- - -
-
- - -
-
- -
-
- -
-
- - -
- Cancel + Cancel
- {% if host %}

- Agent, Ansible target, and playbook runs live on the - host page. + {% if host %}Monitors, agent, Ansible target, and playbook runs live on the + host page.{% else %} + After adding, attach ping / DNS / HTTP monitors from the host page.{% endif %}

- {% endif %}
{% endblock %} diff --git a/steward/templates/hosts/list.html b/steward/templates/hosts/list.html index dde6693..c7d72ba 100644 --- a/steward/templates/hosts/list.html +++ b/steward/templates/hosts/list.html @@ -18,10 +18,9 @@ Name Address - Probe - Ping + Status Latency - DNS + Monitors 24h 7d 30d @@ -31,65 +30,39 @@ {% for host in hosts %} - {% set ping = latest_pings.get(host.id) %} - {% set dns = latest_dns.get(host.id) %} + {% set hs = host_status.get(host.id) %} {% set ut = uptime.get(host.id, {}) %} {{ host.name }} {{ host.address }} - - {{ host.probe_type.value | upper }}{% if host.probe_type.value == 'tcp' %}:{{ host.probe_port }}{% endif %} - - {% if not host.ping_enabled %} - off - {% elif ping is none %} - - {% elif ping.status.value == 'up' %} + {% if not hs %} + + {% elif hs.state == 'up' %} - {% else %} + {% elif hs.state == 'down' %} + {% else %} + {% endif %} - {% if ping and ping.response_time_ms is not none %} - {% if ping.response_time_ms < 10 %} - {{ ping.response_time_ms | round(1) }} ms - {% elif ping.response_time_ms < 100 %} - {{ ping.response_time_ms | round(1) }} ms - {% else %} - {{ ping.response_time_ms | round(1) }} ms - {% endif %} + {% if hs and hs.latency_ms is not none %} + {{ hs.latency_ms | round(1) }} ms {% else %} {% endif %} - - {% if not host.dns_enabled %} - off - {% elif dns is none %} - - {% elif dns.status.value == 'resolved' %} - - {% else %} - - {% endif %} + + {{ hs.count if hs else 0 }} {% for window in ["24h", "7d", "30d"] %} {% set pct = ut.get(window) %} - {% if not host.ping_enabled %} + {% if pct is none %} - {% elif pct is none %} - - {% elif pct >= 99.9 %} - {{ "%.1f"|format(pct) }}% - {% elif pct >= 99 %} - {{ "%.1f"|format(pct) }}% - {% elif pct >= 95 %} - {{ "%.1f"|format(pct) }}% {% else %} - {{ "%.1f"|format(pct) }}% + {{ "%.1f"|format(pct) }}% {% endif %} {% endfor %} diff --git a/steward/templates/hosts/overview_widget.html b/steward/templates/hosts/overview_widget.html index e92a125..267f8c4 100644 --- a/steward/templates/hosts/overview_widget.html +++ b/steward/templates/hosts/overview_widget.html @@ -4,18 +4,17 @@ {% if hosts %}
{% for host in hosts %} - {% set ping = latest_pings.get(host.id) %} - {% set dns = latest_dns.get(host.id) %} + {% set hs = host_status.get(host.id) %} {% set ut = uptime.get(host.id, {}) %} {% set a = agent.get(host.name, {}) %} - {% set down = host.ping_enabled and ping and ping.status.value != 'up' %} + {% set down = hs and hs.state == 'down' %} {% set spark = cpu_sparks.get(host.name) %}
{# Line 1 — status dot + full host name (own line) + cpu trend #}
- + title="{% if not hs %}no monitors{% else %}{{ hs.state }}{% endif %}"> {% if session.user_id %} {{ host.name }} {% else %} @@ -25,8 +24,8 @@ {# Line 2 — monitors + agent metrics; wraps instead of truncating #}
- {% if host.ping_enabled and ping and ping.response_time_ms is not none %} - ping {{ "%.0f"|format(ping.response_time_ms) }}ms + {% if hs and hs.latency_ms is not none %} + ping {{ "%.0f"|format(hs.latency_ms) }}ms {% endif %} {% if ut.get('24h') is not none %} 24h {{ "%.1f"|format(ut['24h']) }}% diff --git a/steward/templates/hosts/uptime.html b/steward/templates/hosts/uptime.html index 3a408d1..7a7230e 100644 --- a/steward/templates/hosts/uptime.html +++ b/steward/templates/hosts/uptime.html @@ -7,13 +7,10 @@
{# ── Summary pills ──────────────────────────────────────────────────────────── #} -{% set ping_hosts = hosts | selectattr("ping_enabled") | list %} -{% set total = ping_hosts | length %} +{% set total = hosts | length %} {% if total %} -{% set full_30d = ping_hosts | selectattr("id", "in", uptime.keys()) - | selectattr("id", "defined") | list %} {% set healthy = namespace(count=0) %} -{% for h in ping_hosts %} +{% for h in hosts %} {% set pct = uptime.get(h.id, {}).get("30d") %} {% if pct is not none and pct >= 99 %}{% set healthy.count = healthy.count + 1 %}{% endif %} {% endfor %} @@ -50,15 +47,13 @@ {% for host in hosts %} {% set ut = uptime.get(host.id, {}) %} - {{ host.name }} + {{ host.name }} {{ host.address }} {% for window in ["24h", "7d", "30d"] %} {% set pct = ut.get(window) %} - {% if not host.ping_enabled %} - - {% elif pct is none %} + {% if pct is none %} {% elif pct >= 99.9 %} {{ "%.2f"|format(pct) }}% @@ -75,9 +70,7 @@ {# 30-day visual bar #} {% set pct30 = ut.get("30d") %} - {% if not host.ping_enabled %} - ping off - {% elif pct30 is none %} + {% if pct30 is none %} no data {% else %}
@@ -99,6 +92,6 @@ {% endif %}

- Uptime is computed from ping probe results only. Hosts with ping disabled show —. + Uptime aggregates every monitor attached to a host. Hosts with no monitors show —.

{% endblock %} diff --git a/steward/templates/hosts/uptime_widget.html b/steward/templates/hosts/uptime_widget.html index fe31dc2..a9a79c4 100644 --- a/steward/templates/hosts/uptime_widget.html +++ b/steward/templates/hosts/uptime_widget.html @@ -1,7 +1,7 @@ {# hosts/uptime_widget.html — dashboard widget: SLA uptime summary #} {% if not hosts %}
- No ping-enabled hosts configured. + No hosts configured.
{% else %} diff --git a/steward/templates/monitors/_fields.html b/steward/templates/monitors/_fields.html new file mode 100644 index 0000000..d98c6d5 --- /dev/null +++ b/steward/templates/monitors/_fields.html @@ -0,0 +1,64 @@ +{# Shared type-specific monitor form fields. Every field is rendered; a tiny + script (mtoggle) shows only the ones relevant to the selected type. #} +{% macro type_fields(config={}) %} +{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %} +{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %} + +{# tcp #} +
+ + +
+ +{# dns #} +
+ + +
+ +{# http #} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+{% endmacro %} + +{# Show only the fields whose data-types matches the active monitor type. #} +{% macro toggle_script() %} + +{% endmacro %} diff --git a/steward/templates/monitors/edit.html b/steward/templates/monitors/edit.html new file mode 100644 index 0000000..ac383b8 --- /dev/null +++ b/steward/templates/monitors/edit.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% from "monitors/_fields.html" import type_fields, toggle_script %} +{% block title %}Edit Monitor — Steward{% endblock %} +{% block content %} +
+ ← Monitors +

Edit Monitor

+
+ +{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %} +{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %} + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {{ type_fields(config) }} +
+ {% if monitor.host_id %} +

+ Linked to its host. +

+ {% endif %} +
+ + Cancel +
+
+
+ +{{ toggle_script() }} + +{% endblock %} diff --git a/steward/templates/monitors/index.html b/steward/templates/monitors/index.html new file mode 100644 index 0000000..75708cb --- /dev/null +++ b/steward/templates/monitors/index.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% from "monitors/_fields.html" import type_fields, toggle_script %} +{% block title %}Monitors — Steward{% endblock %} +{% block content %} +
+
+

Monitors

+ polling every {{ poll_interval }}s +
+ {% include "_time_range.html" %} +
+ +{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %} +{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %} + +{# ── Add monitor ──────────────────────────────────────────────────────────── #} +
+
Add Monitor
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {{ type_fields() }} +
+
+
+
+ +{# ── Live monitor rows ────────────────────────────────────────────────────── #} +
+
+
Loading…
+
+
+ +{{ toggle_script() }} + +{% endblock %} diff --git a/steward/templates/monitors/rows.html b/steward/templates/monitors/rows.html new file mode 100644 index 0000000..5091180 --- /dev/null +++ b/steward/templates/monitors/rows.html @@ -0,0 +1,74 @@ +{# HTMX fragment — monitor rows (list page). #} +{% if monitor_data %} +{% for d in monitor_data %} +{% set m = d.monitor %} +{% set latest = d.latest %} +
+
+ {{ m.type | upper }} + {% if m.host_id %} + {{ m.name }} + {% else %} + {{ m.name }} + {% endif %} + {{ m.target }} +
+ +
+ {% for _ in range([30 - d.heartbeat|length, 0]|max) %} + + {% endfor %} + {% for h in d.heartbeat %} + + {% endfor %} +
+ +
+ {% if latest is none %} + + {% elif not latest.is_up %} + DOWN + {% elif latest.response_ms is not none %} + + {{ "%.0f"|format(latest.response_ms) }} ms + {% elif m.type == 'dns' and latest.resolved_ip %} + {{ latest.resolved_ip }} + {% else %} + UP + {% endif %} +
+ +
+ {% if d.uptime_pct is not none %}{{ "%.1f"|format(d.uptime_pct) }}%{% else %}—{% endif %} + {{ range_key }} +
+ + {% if d.tls_days is not none %} +
+ cert {{ d.tls_days|round|int }}d +
+ {% endif %} + + {% if session.user_role in ['operator', 'admin'] %} +
+ Edit +
+ +
+
+ +
+
+ {% endif %} +
+{% endfor %} +{% else %} +

+ No monitors yet. Add one above, or attach checks to a host from the + Hosts page. +

+{% endif %} diff --git a/steward/templates/monitors/widget.html b/steward/templates/monitors/widget.html new file mode 100644 index 0000000..aea1b06 --- /dev/null +++ b/steward/templates/monitors/widget.html @@ -0,0 +1,44 @@ +{# monitors/widget.html — dashboard widget: unified monitor summary #} +{% if not monitor_data and up == 0 and down == 0 and pending == 0 %} +
+ No monitors configured. Add monitors → +
+{% else %} + +
+ {% if up %} +
{{ up }} + up
+ {% endif %} + {% if down %} +
{{ down }} + down
+ {% endif %} + {% if pending %} +
{{ pending }} + pending
+ {% endif %} +
+ +
+ {% for d in monitor_data %} + {% set latest = d.latest %} +
+ {% if not latest %} + {% elif latest.is_up %} + {% else %}{% endif %} + {% if d.monitor.host_id %} + {{ d.monitor.name }} + {% else %} + {{ d.monitor.name }} + {% endif %} + {% if latest and latest.response_ms is not none %} + {{ "%.0f"|format(latest.response_ms) }}ms + {% elif latest and not latest.is_up %} + {{ latest.status_code or "down" }} + {% endif %} +
+ {% endfor %} +
+ +{% endif %} diff --git a/steward/templates/ping/index.html b/steward/templates/ping/index.html deleted file mode 100644 index 61776a3..0000000 --- a/steward/templates/ping/index.html +++ /dev/null @@ -1,47 +0,0 @@ -{% extends "base.html" %} -{% block title %}Ping Monitor — Steward{% endblock %} -{% block content %} -
-
-

Ping Monitor

- polling every {{ poll_interval }}s -
- {% include "_time_range.html" %} -
- -{# ── Threshold settings ─────────────────────────────────────────────────── #} -
-

Latency Thresholds

-
-
- - -
-
- - -
- - - Above warn = orange  |  No response = red - -
-
- -{# ── Live host rows ─────────────────────────────────────────────────────── #} -
-
-
-
-{% endblock %} diff --git a/steward/templates/ping/rows.html b/steward/templates/ping/rows.html deleted file mode 100644 index 468fe2e..0000000 --- a/steward/templates/ping/rows.html +++ /dev/null @@ -1,67 +0,0 @@ -{# HTMX fragment — included in both /ping/ page and dashboard widget #} -{% macro pill_bg(p) %} -{%- if p is none or p.status.value == 'down' or p.response_time_ms is none -%} - #6a1515 -{%- elif p.response_time_ms <= good_ms -%} - #1a6632 -{%- elif p.response_time_ms <= warn_ms -%} - #6b5c00 -{%- else -%} - #7a3800 -{%- endif -%} -{% endmacro %} -{% macro pill_title(p) %} -{%- if p is none -%}No data -{%- elif p.status.value == 'down' -%}Down — {{ p.probed_at.strftime('%H:%M:%S') }} UTC -{%- else -%}{{ "%.1f"|format(p.response_time_ms) }} ms — {{ p.probed_at.strftime('%H:%M:%S') }} UTC -{%- endif -%} -{% endmacro %} -{% if hosts %} -{% for host in hosts %} -{% set host_pings = pings_by_host.get(host.id, []) %} -{% set last = host_pings[-1] if host_pings else none %} -
-
- {# Link to the host hub for logged-in users; plain text on the public share view (no auth, no token on the href). #} - {% if session.user_id %} - {{ host.name }} - {% else %} - {{ host.name }} - {% endif %} - {{ host.address }} -
-
- {% for _ in range([30 - host_pings|length, 0]|max) %} - - {% endfor %} - {% for p in host_pings %} - - {% endfor %} -
-
- {% if last is none %} - - {% elif last.status.value == 'down' or last.response_time_ms is none %} - DOWN - {% elif last.response_time_ms <= good_ms %} - {{ "%.1f"|format(last.response_time_ms) }} ms - {% elif last.response_time_ms <= warn_ms %} - {{ "%.1f"|format(last.response_time_ms) }} ms - {% else %} - {{ "%.1f"|format(last.response_time_ms) }} ms - {% endif %} -
- {% if uptime_pcts is defined %} - {% set pct = uptime_pcts.get(host.id) %} - {% set us = threshold_style(pct, 'uptime') if pct is not none else 'color:var(--text-dim);' %} -
- {% if pct is not none %}{{ "%.1f"|format(pct) }}%{% else %}—{% endif %} - {{ range_key }} -
- {% endif %} -
-{% endfor %} -{% else %} -

No ping-enabled hosts. Add hosts →

-{% endif %} diff --git a/tests/core/test_monitors.py b/tests/core/test_monitors.py new file mode 100644 index 0000000..71e10d3 --- /dev/null +++ b/tests/core/test_monitors.py @@ -0,0 +1,110 @@ +"""Unit tests for the unified Monitor: dispatch, config plumbing, form parsing. + +No DB / no network — the per-type probes are monkeypatched so we exercise the +run_monitor dispatcher and the route helpers in isolation. The DB-backed status +source + migration are covered in the integration lane. +""" +import asyncio + +from steward.models.monitors import Monitor, MONITOR_TYPE_VALUES +from steward.monitors import runner as runner_mod +from steward.monitors.routes import _build_config, _normalise_target + + +def _monitor(mtype, target="example.com", cfg=None): + m = Monitor(id="m1", name="t", type=mtype, target=target) + m.set_config(cfg or {}) + return m + + +def test_monitor_type_values(): + assert MONITOR_TYPE_VALUES == ("icmp", "tcp", "dns", "http") + + +def test_config_json_roundtrip(): + m = _monitor("tcp", cfg={"port": 8443}) + assert m.config == {"port": 8443} + m.set_config({"port": 22}) + assert m.config["port"] == 22 + + +def test_config_handles_bad_json(): + m = Monitor(id="x", name="t", type="tcp", target="h", config_json="not json") + assert m.config == {} + + +def test_run_monitor_unknown_type_is_safe(): + m = _monitor("bogus") + res = asyncio.run(runner_mod.run_monitor(m)) + assert res["is_up"] is False + assert "bogus" in res["error_msg"] + + +def test_run_monitor_tcp_uses_config_port(monkeypatch): + seen = {} + + async def fake_tcp(address, port): + seen["address"], seen["port"] = address, port + return {"is_up": True, "response_ms": 12.0} + + monkeypatch.setattr(runner_mod, "tcp_check", fake_tcp) + res = asyncio.run(runner_mod.run_monitor(_monitor("tcp", "db.local", {"port": 5432}))) + assert seen == {"address": "db.local", "port": 5432} + assert res["is_up"] is True and res["response_ms"] == 12.0 + # Untouched fields stay None (full MonitorResult superset is always present). + assert res["status_code"] is None and res["resolved_ip"] is None + + +def test_run_monitor_http_passes_config(monkeypatch): + seen = {} + + async def fake_http(**kwargs): + seen.update(kwargs) + return {"is_up": True, "status_code": 200, "response_ms": 5.0} + + monkeypatch.setattr(runner_mod, "http_check", fake_http) + cfg = {"method": "HEAD", "expected_status": 204, "content_match": "ok", + "headers": {"X": "1"}, "timeout_seconds": 3, + "follow_redirects": False, "verify_ssl": False} + res = asyncio.run(runner_mod.run_monitor(_monitor("http", "https://x.test", cfg))) + assert seen["method"] == "HEAD" and seen["expected_status"] == 204 + assert seen["headers"] == {"X": "1"} and seen["verify_ssl"] is False + assert res["status_code"] == 200 + + +def test_run_monitor_dns_passes_expected_ip(monkeypatch): + seen = {} + + async def fake_dns(address, expected_ip=None): + seen["address"], seen["expected_ip"] = address, expected_ip + return {"is_up": True, "resolved_ip": "192.0.2.1"} + + monkeypatch.setattr(runner_mod, "dns_check", fake_dns) + res = asyncio.run(runner_mod.run_monitor(_monitor("dns", "host.test", {"expected_ip": "192.0.2.1"}))) + assert seen == {"address": "host.test", "expected_ip": "192.0.2.1"} + assert res["resolved_ip"] == "192.0.2.1" + + +# ── route form helpers ──────────────────────────────────────────────────────── + +def test_normalise_target_adds_https_for_bare_http_host(): + assert _normalise_target("http", "example.com") == "https://example.com" + assert _normalise_target("http", "http://x.com") == "http://x.com" + assert _normalise_target("tcp", "example.com") == "example.com" + + +class _Form(dict): + """Minimal stand-in for a Quart form (dict + 'in' membership for checkboxes).""" + + +def test_build_config_per_type(): + assert _build_config(_Form(port="8080"), "tcp") == {"port": 8080} + assert _build_config(_Form(), "tcp") == {"port": 80} + assert _build_config(_Form(expected_ip="10.0.0.1"), "dns") == {"expected_ip": "10.0.0.1"} + assert _build_config(_Form(), "dns") == {"expected_ip": None} + assert _build_config(_Form(), "icmp") == {} + + http = _build_config(_Form(method="get", expected_status="201", follow_redirects="on"), "http") + assert http["method"] == "GET" and http["expected_status"] == 201 + assert http["follow_redirects"] is True # present in form → checked + assert http["verify_ssl"] is False # absent from form → unchecked diff --git a/tests/integration/test_monitors.py b/tests/integration/test_monitors.py new file mode 100644 index 0000000..0e0ebd7 --- /dev/null +++ b/tests/integration/test_monitors.py @@ -0,0 +1,85 @@ +"""Integration: unified Monitor schema + status source against live Postgres. + +Validates the 0022 unification migration applied (monitors/monitor_results +exist, the old per-type tables are gone) and that monitor_status_source rolls +up MonitorResult history into a StatusEntry. Requires STEWARD_DATABASE_URL. +""" +from __future__ import annotations + +import asyncio +import os +import uuid +from datetime import datetime, timezone, timedelta + +import pytest + +pytestmark = pytest.mark.integration + +_NEEDS_DB = pytest.mark.skipif( + not os.environ.get("STEWARD_DATABASE_URL"), + reason="integration test needs a live Postgres (STEWARD_DATABASE_URL)", +) + + +@pytest.fixture +def app(): + if not os.environ.get("STEWARD_DATABASE_URL"): + pytest.skip("needs Postgres") + from steward.app import create_app + return create_app(testing=False) + + +@_NEEDS_DB +def test_old_monitor_tables_dropped(app): + from sqlalchemy import text + + async def _go(): + async with app.db_sessionmaker() as s: + # New tables exist… + await s.execute(text("SELECT COUNT(*) FROM monitors")) + await s.execute(text("SELECT COUNT(*) FROM monitor_results")) + # …old ones are gone (regclass of a missing table is NULL). + for tbl in ("ping_results", "dns_results", "http_monitors", "http_results"): + missing = (await s.execute( + text("SELECT to_regclass(:t)"), {"t": tbl})).scalar() + assert missing is None, f"{tbl} should have been dropped" + + asyncio.run(_go()) + + +@_NEEDS_DB +def test_status_source_rolls_up_results(app): + from sqlalchemy import text + from steward.models.monitors import Monitor, MonitorResult + from steward.core.status import monitor_status_source + + mid = str(uuid.uuid4()) + now = datetime.now(timezone.utc) + + async def _go(): + async with app.db_sessionmaker() as s: + async with s.begin(): + # Clean slate for a deterministic assertion. + await s.execute(text("DELETE FROM monitor_results")) + await s.execute(text("DELETE FROM monitors")) + s.add(Monitor(id=mid, name="custom-http", type="http", + target="https://example.test", host_id=None, + config_json="{}", enabled=True)) + async with s.begin(): + for i, up in enumerate([True, True, False, True]): + s.add(MonitorResult( + id=str(uuid.uuid4()), monitor_id=mid, + checked_at=now - timedelta(minutes=4 - i), + is_up=up, response_ms=10.0 if up else None, + )) + entries = await monitor_status_source(s) + return entries + + entries = asyncio.run(_go()) + assert len(entries) == 1 + e = entries[0] + assert e.kind == "http" and e.name == "custom-http" + assert e.target == "https://example.test" + assert e.status == "up" # latest result is up + assert len(e.heartbeat) == 4 + assert e.uptime["24h"] == 75.0 # 3 of 4 up