| Monitor | -Status | -Response | -History | -TLS 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 %} - | -- - - | -
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 @@+ No monitors yet for this host. Add one below. +
+ {% endif %} + + {% if session.user_role in ['operator', 'admin'] %} +- 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 %}- 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 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 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