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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
2026-06-18 08:56:13 -04:00
parent 591706bd39
commit 35f658b573
53 changed files with 1628 additions and 1839 deletions
-28
View File
@@ -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
View File
-70
View File
@@ -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()
-63
View File
@@ -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
)
-19
View File
@@ -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
-311
View File
@@ -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'<svg width="{width}" height="{height}"></svg>'
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'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" '
f'style="vertical-align:middle;">'
f'<polyline points="{poly}" fill="none" stroke="#6060c0" stroke-width="1.5"/>'
f'</svg>'
)
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("/<monitor_id>/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("/<monitor_id>/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,
)
-121
View File
@@ -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,
)
-77
View File
@@ -1,77 +0,0 @@
{% extends "base.html" %}
{% block title %}HTTP Monitors — Steward{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem;flex-wrap:wrap;">
<h1 class="page-title" style="margin-bottom:0;">HTTP Monitors</h1>
{% include "_time_range.html" %}
</div>
{# ── Add monitor form ─────────────────────────────────────────────────────── #}
<div class="card" style="margin-bottom:1.25rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Add Monitor</div>
<form method="post" action="/plugins/http/add" style="display:grid;gap:0.6rem;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.6rem;">
<div>
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">Name</label>
<input type="text" name="name" placeholder="My Service" autocomplete="off"
style="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;">
</div>
<div>
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">URL <span style="color:var(--red);">*</span></label>
<input type="text" name="url" placeholder="https://example.com" autocomplete="off" required
style="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;">
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:0.6rem;align-items:end;">
<div>
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">Method</label>
<select name="method" style="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;">
<option value="GET">GET</option>
<option value="HEAD">HEAD</option>
<option value="POST">POST</option>
</select>
</div>
<div>
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">Expected status</label>
<input type="number" name="expected_status" value="200" min="100" max="599"
style="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;">
</div>
<div>
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">Timeout (s)</label>
<input type="number" name="timeout_seconds" value="10" min="1" max="60"
style="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;">
</div>
<div>
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">Interval (s, 0=global)</label>
<input type="number" name="check_interval_seconds" value="0" min="0"
style="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;">
</div>
<div>
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">Content match (substring)</label>
<input type="text" name="content_match" placeholder="optional"
style="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;">
</div>
<div style="display:flex;flex-direction:column;gap:0.3rem;padding-top:0.8rem;">
<label style="display:flex;align-items:center;gap:0.4rem;font-size:0.82rem;cursor:pointer;">
<input type="checkbox" name="follow_redirects" checked> Follow redirects
</label>
<label style="display:flex;align-items:center;gap:0.4rem;font-size:0.82rem;cursor:pointer;">
<input type="checkbox" name="verify_ssl" checked> Verify SSL
</label>
</div>
</div>
<div>
<button type="submit" class="btn btn-sm">Add Monitor</button>
</div>
</form>
</div>
{# ── Monitor list ─────────────────────────────────────────────────────────── #}
<div id="http-rows"
hx-get="/plugins/http/rows"
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
hx-include="#time-range"
hx-swap="innerHTML">
<div style="color:var(--text-muted);font-size:0.9rem;padding:2rem;">Loading...</div>
</div>
{% endblock %}
-109
View File
@@ -1,109 +0,0 @@
{# http/rows.html — HTMX fragment for HTTP monitor status list #}
{# ── Summary ─────────────────────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:0.75rem;margin-bottom:1.25rem;">
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Up</div>
<span class="stat-val" style="color:var(--green);">{{ up }}</span>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Down</div>
<span class="stat-val" style="color:{% if down %}var(--red){% else %}var(--text-muted){% endif %};">{{ down }}</span>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Monitors</div>
<span class="stat-val">{{ monitor_data | length }}</span>
</div>
</div>
{# ── Monitor list ─────────────────────────────────────────────────────────── #}
{% if monitor_data %}
<div class="card-flush">
<table class="table">
<thead>
<tr>
<th>Monitor</th>
<th>Status</th>
<th>Response</th>
<th style="min-width:90px;">History</th>
<th>TLS expiry</th>
<th style="width:1%;"></th>
</tr>
</thead>
<tbody>
{% for item in monitor_data %}
{% set m = item.monitor %}
{% set latest = item.latest %}
<tr>
<td>
<div style="font-weight:500;font-size:0.9rem;">{{ m.name }}</div>
<div style="font-size:0.75rem;color:var(--text-muted);font-family:ui-monospace,monospace;word-break:break-all;">
<span style="color:var(--text-dim);">{{ m.method }}</span> {{ m.url }}
</div>
{% if not m.enabled %}
<span style="font-size:0.72rem;color:var(--text-dim);">paused</span>
{% endif %}
</td>
<td>
{% if not latest %}
<span style="color:var(--text-dim);font-size:0.82rem;">pending</span>
{% elif latest.is_up %}
<div style="display:flex;align-items:center;gap:0.4rem;">
<span class="dot dot-up"></span>
<span style="font-size:0.82rem;color:var(--green);">{{ latest.status_code }}</span>
</div>
{% else %}
<div style="display:flex;align-items:center;gap:0.4rem;">
<span class="dot dot-down"></span>
<span style="font-size:0.82rem;color:var(--red);">
{{ latest.status_code or latest.error_msg or "error" }}
</span>
</div>
{% if latest.content_matched == false %}
<div style="font-size:0.72rem;color:var(--orange);">content mismatch</div>
{% endif %}
{% endif %}
</td>
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;">
{% if latest and latest.response_ms is not none %}
<span style="color:{% if latest.response_ms > 2000 %}var(--red){% elif latest.response_ms > 500 %}var(--orange){% else %}var(--text){% endif %};">
{{ "%.0f" | format(latest.response_ms) }}ms
</span>
{% else %}
<span style="color:var(--text-dim);"></span>
{% endif %}
</td>
<td>{{ item.sparkline_ms | safe }}</td>
<td style="font-size:0.82rem;">
{% if item.tls_days is not none %}
<span style="color:{% if item.tls_days < 14 %}var(--red){% elif item.tls_days < 30 %}var(--orange){% else %}var(--text-muted){% endif %};">
{{ item.tls_days | int }}d
</span>
{% elif m.url.startswith('https://') %}
<span style="color:var(--text-dim);"></span>
{% endif %}
</td>
<td style="white-space:nowrap;">
<form method="post" action="/plugins/http/{{ m.id }}/toggle" style="display:inline;margin:0;">
<button type="submit" class="btn btn-ghost btn-sm" style="font-size:0.72rem;">
{% if m.enabled %}Pause{% else %}Resume{% endif %}
</button>
</form>
<form method="post" action="/plugins/http/{{ m.id }}/delete"
style="display:inline;margin:0;"
onsubmit="return confirm('Delete {{ m.name }}?')">
<button type="submit" class="btn btn-danger btn-sm" style="font-size:0.72rem;">Del</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card" style="text-align:center;padding:3rem;">
<div style="color:var(--text-muted);font-size:0.9rem;">
No monitors configured yet. Add one using the form above.
</div>
</div>
{% endif %}
-56
View File
@@ -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 %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
No monitors configured. <a href="/plugins/http/">Add monitors →</a>
</div>
{% else %}
<div style="display:flex;gap:1rem;margin-bottom:0.65rem;flex-wrap:wrap;">
{% if up %}
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--green);">{{ up }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">up</span>
</div>
{% endif %}
{% if down %}
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--red);">{{ down }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">down</span>
</div>
{% endif %}
{% if pending %}
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--text-dim);">{{ pending }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">pending</span>
</div>
{% endif %}
</div>
<div style="display:grid;gap:3px;">
{% for item in monitor_data %}
{% set latest = item.latest %}
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.82rem;padding:0.15rem 0;">
{% if not latest %}
<span class="dot dot-dim"></span>
{% elif latest.is_up %}
<span class="dot dot-up"></span>
{% else %}
<span class="dot dot-down"></span>
{% endif %}
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
{{ item.monitor.name }}
</span>
{% if latest and latest.response_ms is not none %}
<span style="font-size:0.72rem;font-family:ui-monospace,monospace;color:var(--text-muted);flex-shrink:0;">
{{ "%.0f" | format(latest.response_ms) }}ms
</span>
{% elif latest and not latest.is_up %}
<span style="font-size:0.72rem;color:var(--red);flex-shrink:0;">
{{ latest.status_code or "err" }}
</span>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
-16
View File
@@ -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"
+15 -10
View File
@@ -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)
+11 -46
View File
@@ -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,
),
+2 -3
View File
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
from sqlalchemy import delete
from steward.models.monitors import DnsResult, PingResult
from steward.models.monitors import MonitorResult
from steward.models.metrics import PluginMetric
from steward.models.ansible import AnsibleRun
@@ -23,8 +23,7 @@ async def run_cleanup(app: "Quart") -> None:
async with app.db_sessionmaker() as session:
async with session.begin():
for model, ts_col in [
(PingResult, PingResult.probed_at),
(DnsResult, DnsResult.resolved_at),
(MonitorResult, MonitorResult.checked_at),
(PluginMetric, PluginMetric.recorded_at),
(AnsibleRun, AnsibleRun.started_at),
]:
+28 -33
View File
@@ -12,9 +12,8 @@ from email.message import EmailMessage
from sqlalchemy import and_, case, func, select
from steward.models.alerts import AlertRule, AlertState, AlertStateEnum, AlertEvent
from steward.models.hosts import Host
from steward.models.metrics import PluginMetric
from steward.models.monitors import PingResult, PingStatus
from steward.models.monitors import Monitor, MonitorResult
logger = logging.getLogger(__name__)
@@ -28,32 +27,28 @@ async def build_report_data(app) -> dict:
cutoff_24h = now - timedelta(hours=24)
async with app.db_sessionmaker() as db:
# ── Uptime summary (7d) ───────────────────────────────────────────────
host_result = await db.execute(
select(Host).where(Host.ping_enabled.is_(True)).order_by(Host.name)
)
hosts = host_result.scalars().all()
# ── Uptime summary (7d) — per enabled monitor of any type ─────────────
monitors = (await db.execute(
select(Monitor).where(Monitor.enabled.is_(True)).order_by(Monitor.name)
)).scalars().all()
uptime_rows = await db.execute(
select(
PingResult.host_id,
func.count(PingResult.id).label("total"),
func.sum(case((PingResult.status == PingStatus.up, 1), else_=0)).label("up"),
MonitorResult.monitor_id,
func.count(MonitorResult.id).label("total"),
func.sum(case((MonitorResult.is_up.is_(True), 1), else_=0)).label("up"),
)
.where(PingResult.probed_at >= cutoff_7d)
.group_by(PingResult.host_id)
.where(MonitorResult.checked_at >= cutoff_7d)
.group_by(MonitorResult.monitor_id)
)
uptime_map: dict[str, float | None] = {}
for row in uptime_rows:
pct = round(float(row.up) / float(row.total) * 100, 2) if row.total else None
uptime_map[row.host_id] = pct
uptime_map[row.monitor_id] = pct
uptime_summary = []
for h in hosts:
uptime_summary.append({
"name": h.name,
"pct_7d": uptime_map.get(h.id),
})
uptime_summary = [
{"name": m.name, "pct_7d": uptime_map.get(m.id)} for m in monitors
]
# ── Active alerts ─────────────────────────────────────────────────────
active_result = await db.execute(
@@ -112,27 +107,27 @@ async def build_report_data(app) -> dict:
for row in top_routers_result
]
# ── Hosts currently down ──────────────────────────────────────────────
latest_ping_subq = (
select(PingResult.host_id, func.max(PingResult.probed_at).label("max_at"))
.group_by(PingResult.host_id)
# ── Monitors currently down (latest result is_up = false) ─────────────
latest_subq = (
select(MonitorResult.monitor_id, func.max(MonitorResult.checked_at).label("max_at"))
.group_by(MonitorResult.monitor_id)
.subquery()
)
down_result = await db.execute(
select(Host.name)
.join(PingResult, PingResult.host_id == Host.id)
select(Monitor.name)
.join(MonitorResult, MonitorResult.monitor_id == Monitor.id)
.join(
latest_ping_subq,
latest_subq,
and_(
PingResult.host_id == latest_ping_subq.c.host_id,
PingResult.probed_at == latest_ping_subq.c.max_at,
MonitorResult.monitor_id == latest_subq.c.monitor_id,
MonitorResult.checked_at == latest_subq.c.max_at,
),
)
.where(
Host.ping_enabled.is_(True),
PingResult.status == PingStatus.down,
Monitor.enabled.is_(True),
MonitorResult.is_up.is_(False),
)
.order_by(Host.name)
.order_by(Monitor.name)
)
hosts_down = [row.name for row in down_result]
@@ -157,7 +152,7 @@ def _render_report_text(data: dict) -> str:
# ── Hosts currently down ──────────────────────────────────────────────────
down = data["hosts_down"]
if down:
lines.append(f"{len(down)} host(s) currently DOWN: {', '.join(down)}")
lines.append(f"{len(down)} monitor(s) currently DOWN: {', '.join(down)}")
lines.append("")
# ── Uptime summary ────────────────────────────────────────────────────────
@@ -168,7 +163,7 @@ def _render_report_text(data: dict) -> str:
pct_str = f"{pct:.2f}%" if pct is not None else "no data"
lines.append(f" {entry['name']:<32} {pct_str}")
if not data["uptime_summary"]:
lines.append(" (no ping-enabled hosts)")
lines.append(" (no monitors configured)")
lines.append("")
# ── Active alerts ─────────────────────────────────────────────────────────
+74 -100
View File
@@ -2,12 +2,11 @@
"""Unified monitor-status aggregation.
A single readable "is everything up?" surface (the Status page + dashboard
widget) is assembled from heterogeneous monitor types — core ping/DNS, plus
any plugin that contributes (e.g. the http plugin). Rather than have the core
status page import plugin tables (plugins are optional and loaded late), each
monitor type registers a *status source*: an async callable(db) that returns a
list of normalised StatusEntry objects. Core registers its ping/DNS sources in
create_app; a plugin registers its own from setup().
widget) is assembled from heterogeneous monitors. The core unified Monitor
entity (ping/dns/http) contributes via `monitor_status_source`; plugins may
register their own sources from setup(). Each source is an async callable(db)
returning normalised StatusEntry objects so the Status page never imports
plugin tables directly.
"""
from __future__ import annotations
@@ -26,8 +25,8 @@ HEARTBEAT_COUNT = 30 # number of recent checks shown in the heartbeat bar
@dataclass
class StatusEntry:
"""One monitored thing, normalised across monitor types for display."""
kind: str # "ping" | "dns" | "http" | ...
key: str # unique within kind (host_id / monitor_id)
kind: str # "icmp" | "tcp" | "dns" | "http" | ...
key: str # unique within kind (monitor id)
name: str
target: str = "" # address / URL shown as subtitle
status: str = "pending" # "up" | "down" | "pending"
@@ -98,49 +97,52 @@ def sparkline_svg(values: list[float], width: int = 80, height: int = 20,
)
# ── Shared query helpers (host-keyed result tables: ping, dns) ────────────────
# ── Unified monitor status source ─────────────────────────────────────────────
async def _last_n_by_host(db, model, ts_col, host_ids: list[str],
async def _last_n_results(db, monitor_ids: list[str],
n: int = HEARTBEAT_COUNT) -> dict[str, list]:
"""Return {host_id: [rows]} of the last n results per host, oldest-first."""
if not host_ids:
"""Return {monitor_id: [MonitorResult]} of the last n results, oldest-first."""
from steward.models.monitors import MonitorResult
if not monitor_ids:
return {}
rn = func.row_number().over(
partition_by=model.host_id, order_by=ts_col.desc()
partition_by=MonitorResult.monitor_id, order_by=MonitorResult.checked_at.desc()
).label("rn")
subq = select(model.id, rn).where(model.host_id.in_(host_ids)).subquery()
subq = select(MonitorResult.id, rn).where(
MonitorResult.monitor_id.in_(monitor_ids)
).subquery()
res = await db.execute(
select(model)
.join(subq, model.id == subq.c.id)
select(MonitorResult)
.join(subq, MonitorResult.id == subq.c.id)
.where(subq.c.rn <= n)
.order_by(model.host_id, ts_col.asc())
.order_by(MonitorResult.monitor_id, MonitorResult.checked_at.asc())
)
out: dict[str, list] = {hid: [] for hid in host_ids}
out: dict[str, list] = {mid: [] for mid in monitor_ids}
for row in res.scalars():
out[row.host_id].append(row)
out[row.monitor_id].append(row)
return out
async def _uptime_by_host(db, model, ts_col, status_col, up_value,
host_ids: list[str]) -> dict[str, dict]:
"""Per-host uptime % over 24h/7d/30d in one grouped query."""
if not host_ids:
async def _uptime_by_monitor(db, monitor_ids: list[str]) -> dict[str, dict]:
"""Per-monitor uptime % over 24h/7d/30d in one grouped query."""
from steward.models.monitors import MonitorResult
if not monitor_ids:
return {}
now = datetime.now(timezone.utc)
c30, c7, c24 = now - timedelta(days=30), now - timedelta(days=7), now - timedelta(hours=24)
res = await db.execute(
select(
model.host_id,
MonitorResult.monitor_id,
func.count().label("total_30d"),
func.sum(case((status_col == up_value, 1), else_=0)).label("up_30d"),
func.sum(case((ts_col >= c7, 1), else_=0)).label("total_7d"),
func.sum(case((and_(ts_col >= c7, status_col == up_value), 1), else_=0)).label("up_7d"),
func.sum(case((ts_col >= c24, 1), else_=0)).label("total_24h"),
func.sum(case((and_(ts_col >= c24, status_col == up_value), 1), else_=0)).label("up_24h"),
func.sum(case((MonitorResult.is_up.is_(True), 1), else_=0)).label("up_30d"),
func.sum(case((MonitorResult.checked_at >= c7, 1), else_=0)).label("total_7d"),
func.sum(case((and_(MonitorResult.checked_at >= c7, MonitorResult.is_up.is_(True)), 1), else_=0)).label("up_7d"),
func.sum(case((MonitorResult.checked_at >= c24, 1), else_=0)).label("total_24h"),
func.sum(case((and_(MonitorResult.checked_at >= c24, MonitorResult.is_up.is_(True)), 1), else_=0)).label("up_24h"),
)
.where(model.host_id.in_(host_ids))
.where(ts_col >= c30)
.group_by(model.host_id)
.where(MonitorResult.monitor_id.in_(monitor_ids))
.where(MonitorResult.checked_at >= c30)
.group_by(MonitorResult.monitor_id)
)
def _pct(up, total):
@@ -148,7 +150,7 @@ async def _uptime_by_host(db, model, ts_col, status_col, up_value,
out: dict[str, dict] = {}
for r in res:
out[r.host_id] = {
out[r.monitor_id] = {
"24h": _pct(r.up_24h, r.total_24h),
"7d": _pct(r.up_7d, r.total_7d),
"30d": _pct(r.up_30d, r.total_30d),
@@ -156,78 +158,50 @@ async def _uptime_by_host(db, model, ts_col, status_col, up_value,
return out
# ── Core status sources: ping + DNS ───────────────────────────────────────────
def _heartbeat_title(mtype: str, r) -> str:
"""Type-aware tooltip for one heartbeat cell."""
ts = f"{r.checked_at:%H:%M:%S} UTC"
if not r.is_up:
return f"{(r.error_msg or 'Down')}{ts}"
if mtype == "http":
code = r.status_code or ""
ms = f"{r.response_ms:.0f} ms" if r.response_ms is not None else ""
return f"{code} · {ms}{ts}".replace(" · — ", "")
if mtype == "dns":
return f"{r.resolved_ip or 'resolved'}{ts}"
if r.response_ms is not None:
return f"{r.response_ms:.0f} ms — {ts}"
return f"Up — {ts}"
async def ping_status_source(db) -> list[StatusEntry]:
from steward.models.hosts import Host
from steward.models.monitors import PingResult, PingStatus
hosts = (await db.execute(
select(Host).where(Host.ping_enabled.is_(True)).order_by(Host.name)
)).scalars().all()
ids = [h.id for h in hosts]
recent = await _last_n_by_host(db, PingResult, PingResult.probed_at, ids)
uptime = await _uptime_by_host(
db, PingResult, PingResult.probed_at, PingResult.status, PingStatus.up, ids
)
async def monitor_status_source(db) -> list[StatusEntry]:
"""Contribute every enabled Monitor (all types) to the Status page."""
from steward.models.monitors import Monitor
monitors = list((await db.execute(
select(Monitor).where(Monitor.enabled.is_(True)).order_by(Monitor.name)
)).scalars())
ids = [m.id for m in monitors]
recent = await _last_n_results(db, ids)
uptime = await _uptime_by_monitor(db, ids)
now = datetime.now(timezone.utc)
entries: list[StatusEntry] = []
for h in hosts:
rows = recent.get(h.id, [])
for m in monitors:
rows = recent.get(m.id, [])
latest = rows[-1] if rows else None
heartbeat = [{
"state": "down" if r.status == PingStatus.down else "up",
"title": (
f"Down — {r.probed_at:%H:%M:%S} UTC" if r.status == PingStatus.down
else f"{r.response_time_ms:.0f} ms — {r.probed_at:%H:%M:%S} UTC"
if r.response_time_ms is not None else f"Up — {r.probed_at:%H:%M:%S} UTC"
),
} for r in rows]
status = "pending" if latest is None else (
"down" if latest.status == PingStatus.down else "up"
)
status = "pending" if latest is None else ("up" if latest.is_up else "down")
tls_days = None
if latest and latest.tls_expires_at:
tls_days = (latest.tls_expires_at - now).total_seconds() / 86400.0
entries.append(StatusEntry(
kind="ping", key=h.id, name=h.name, target=h.address, status=status,
last_checked=latest.probed_at if latest else None,
uptime=uptime.get(h.id, {}), heartbeat=heartbeat,
latency_ms=latest.response_time_ms if latest else None,
spark=[r.response_time_ms for r in rows if r.response_time_ms is not None],
detail_url="/ping/",
))
return entries
async def dns_status_source(db) -> list[StatusEntry]:
from steward.models.hosts import Host
from steward.models.monitors import DnsResult, DnsStatus
hosts = (await db.execute(
select(Host).where(Host.dns_enabled.is_(True)).order_by(Host.name)
)).scalars().all()
ids = [h.id for h in hosts]
recent = await _last_n_by_host(db, DnsResult, DnsResult.resolved_at, ids)
uptime = await _uptime_by_host(
db, DnsResult, DnsResult.resolved_at, DnsResult.status, DnsStatus.resolved, ids
)
entries: list[StatusEntry] = []
for h in hosts:
rows = recent.get(h.id, [])
latest = rows[-1] if rows else None
heartbeat = [{
"state": "up" if r.status == DnsStatus.resolved else "down",
"title": (
f"{r.resolved_ip}{r.resolved_at:%H:%M:%S} UTC" if r.status == DnsStatus.resolved
else f"Failed — {r.resolved_at:%H:%M:%S} UTC"
),
} for r in rows]
status = "pending" if latest is None else (
"up" if latest.status == DnsStatus.resolved else "down"
)
entries.append(StatusEntry(
kind="dns", key=h.id, name=h.name, target=h.address, status=status,
last_checked=latest.resolved_at if latest else None,
uptime=uptime.get(h.id, {}), heartbeat=heartbeat,
detail_url="/dns/",
kind=m.type, key=m.id, name=m.name, target=m.target, status=status,
last_checked=latest.checked_at if latest else None,
uptime=uptime.get(m.id, {}),
heartbeat=[{"state": "up" if r.is_up else "down",
"title": _heartbeat_title(m.type, r)} for r in rows],
latency_ms=latest.response_ms if latest else None,
spark=[r.response_ms for r in rows if r.response_ms is not None],
tls_days=tls_days, detail_url="/monitors/",
))
return entries
+43 -50
View File
@@ -12,15 +12,50 @@
from __future__ import annotations
WIDGET_REGISTRY: dict[str, dict] = {
"ping": {
"key": "ping",
"label": "Ping",
"description": "Live ping status and latency history for all monitored hosts",
"hx_url": "/ping/rows",
"detail_url": "/ping/",
"monitors": {
"key": "monitors",
"label": "Monitors",
"description": "Uptime checks of every type — ping, DNS, and HTTP — with up/down status and latency",
"hx_url": "/monitors/widget",
"detail_url": "/monitors/",
"plugin": None,
"poll": True,
"params": [],
"params": [
{
"key": "type_filter",
"label": "Type",
"type": "select",
"default": "all",
"options": [
{"value": "all", "label": "All types"},
{"value": "icmp", "label": "Ping (ICMP)"},
{"value": "tcp", "label": "Ping (TCP)"},
{"value": "dns", "label": "DNS"},
{"value": "http", "label": "HTTP"},
],
},
{
"key": "show_down_only",
"label": "Display filter",
"type": "select",
"default": "no",
"options": [
{"value": "no", "label": "All monitors"},
{"value": "yes", "label": "Failing only"},
],
},
{
"key": "limit",
"label": "Max shown",
"type": "select",
"default": 10,
"options": [
{"value": 5, "label": "5 monitors"},
{"value": 10, "label": "10 monitors"},
{"value": 20, "label": "20 monitors"},
],
},
],
},
"status_overview": {
"key": "status_overview",
@@ -52,16 +87,6 @@ WIDGET_REGISTRY: dict[str, dict] = {
"poll": False,
"params": [],
},
"dns": {
"key": "dns",
"label": "DNS",
"description": "DNS resolution status for all monitored hosts",
"hx_url": "/dns/rows",
"detail_url": "/dns/",
"plugin": None,
"poll": True,
"params": [],
},
"traefik_routers": {
"key": "traefik_routers",
"label": "Traefik — Routers",
@@ -203,38 +228,6 @@ WIDGET_REGISTRY: dict[str, dict] = {
},
],
},
"http_monitors": {
"key": "http_monitors",
"label": "HTTP Monitors",
"description": "Synthetic endpoint checks — up/down status and response times",
"hx_url": "/plugins/http/widget",
"detail_url": "/plugins/http/",
"plugin": "http",
"poll": True,
"params": [
{
"key": "show_down_only",
"label": "Display filter",
"type": "select",
"default": "no",
"options": [
{"value": "no", "label": "All monitors"},
{"value": "yes", "label": "Failing only"},
],
},
{
"key": "limit",
"label": "Max shown",
"type": "select",
"default": 10,
"options": [
{"value": 5, "label": "5 monitors"},
{"value": 10, "label": "10 monitors"},
{"value": 20, "label": "20 monitors"},
],
},
],
},
"docker_resources": {
"key": "docker_resources",
"label": "Docker — Resources",
@@ -312,7 +305,7 @@ WIDGET_REGISTRY: dict[str, dict] = {
# Group each widget for the picker (Core monitors / Monitoring capabilities /
# Integrations), mirroring the Settings → Plugins taxonomy. Capability plugins
# are host/monitoring facets; traefik/unifi are discrete vendor integrations.
_CORE_WIDGETS = {"ping", "dns", "status_overview", "uptime_summary", "hosts_overview"}
_CORE_WIDGETS = {"monitors", "status_overview", "uptime_summary", "hosts_overview"}
_INTEGRATION_PLUGINS = {"traefik", "unifi"}
GROUP_LABELS = {
"core": "Core monitors",
View File
-52
View File
@@ -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)
+87 -95
View File
@@ -8,9 +8,10 @@ from steward.ansible import executor, sources as ansible_src
from steward.auth.middleware import require_role
from steward.core.audit import log_audit
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
from steward.models.hosts import Host, ProbeType
from steward.models.monitors import PingResult, DnsResult, PingStatus
from steward.models.hosts import Host
from steward.models.monitors import Monitor, MonitorResult
from steward.models.users import UserRole
from steward.core.status import _last_n_results, _uptime_by_monitor, _heartbeat_title
hosts_bp = Blueprint("hosts", __name__, url_prefix="/hosts")
@@ -23,7 +24,7 @@ def _ansible_source_names() -> list[str]:
async def _compute_uptime(db) -> dict[str, dict]:
"""Return per-host uptime % for 24h, 7d, 30d windows.
"""Per-host uptime % for 24h/7d/30d, aggregated over the host's monitors.
Returns {host_id: {"24h": float|None, "7d": float|None, "30d": float|None}}.
"""
@@ -34,25 +35,23 @@ async def _compute_uptime(db) -> dict[str, dict]:
result = await db.execute(
select(
PingResult.host_id,
# 30d window — all rows in query qualify
func.count(PingResult.id).label("total_30d"),
func.sum(case((PingResult.status == PingStatus.up, 1), else_=0)).label("up_30d"),
# 7d sub-window
func.sum(case((PingResult.probed_at >= cutoff_7d, 1), else_=0)).label("total_7d"),
Monitor.host_id,
func.count(MonitorResult.id).label("total_30d"),
func.sum(case((MonitorResult.is_up.is_(True), 1), else_=0)).label("up_30d"),
func.sum(case((MonitorResult.checked_at >= cutoff_7d, 1), else_=0)).label("total_7d"),
func.sum(case(
(and_(PingResult.probed_at >= cutoff_7d, PingResult.status == PingStatus.up), 1),
(and_(MonitorResult.checked_at >= cutoff_7d, MonitorResult.is_up.is_(True)), 1),
else_=0,
)).label("up_7d"),
# 24h sub-window
func.sum(case((PingResult.probed_at >= cutoff_24h, 1), else_=0)).label("total_24h"),
func.sum(case((MonitorResult.checked_at >= cutoff_24h, 1), else_=0)).label("total_24h"),
func.sum(case(
(and_(PingResult.probed_at >= cutoff_24h, PingResult.status == PingStatus.up), 1),
(and_(MonitorResult.checked_at >= cutoff_24h, MonitorResult.is_up.is_(True)), 1),
else_=0,
)).label("up_24h"),
)
.where(PingResult.probed_at >= cutoff_30d)
.group_by(PingResult.host_id)
.join(Monitor, Monitor.id == MonitorResult.monitor_id)
.where(Monitor.host_id.isnot(None), MonitorResult.checked_at >= cutoff_30d)
.group_by(Monitor.host_id)
)
def _pct(up, total):
@@ -68,6 +67,65 @@ async def _compute_uptime(db) -> dict[str, dict]:
return stats
async def _host_status(db, host_ids: list[str]) -> dict[str, dict]:
"""Per-host status rollup from the latest result of each linked monitor.
{host_id: {"state": "up"|"down"|"pending", "latency_ms": float|None,
"count": int}}. State is down if ANY linked monitor's latest
result is down (worst-case), up if at least one is up, else pending.
"""
if not host_ids:
return {}
rn = func.row_number().over(
partition_by=MonitorResult.monitor_id, order_by=MonitorResult.checked_at.desc()
).label("rn")
subq = (
select(MonitorResult.id, rn)
.join(Monitor, Monitor.id == MonitorResult.monitor_id)
.where(Monitor.host_id.in_(host_ids), Monitor.enabled.is_(True))
.subquery()
)
rows = (await db.execute(
select(MonitorResult, Monitor.host_id)
.join(subq, MonitorResult.id == subq.c.id)
.join(Monitor, Monitor.id == MonitorResult.monitor_id)
.where(subq.c.rn == 1)
)).all()
out: dict[str, dict] = {}
for res, host_id in rows:
d = out.setdefault(host_id, {"state": "pending", "latency_ms": None, "count": 0})
d["count"] += 1
if not res.is_up:
d["state"] = "down"
elif d["state"] != "down":
d["state"] = "up"
if res.is_up and res.response_ms is not None:
d["latency_ms"] = (res.response_ms if d["latency_ms"] is None
else min(d["latency_ms"], res.response_ms))
return out
async def _host_monitors(db, host_id: str) -> list[dict]:
"""Display rows for the monitors linked to one host (host hub section)."""
monitors = (await db.execute(
select(Monitor).where(Monitor.host_id == host_id).order_by(Monitor.name)
)).scalars().all()
ids = [m.id for m in monitors]
recent = await _last_n_results(db, ids)
uptime = await _uptime_by_monitor(db, ids)
data = []
for m in monitors:
mrows = recent.get(m.id, [])
latest = mrows[-1] if mrows else None
data.append({
"monitor": m, "latest": latest,
"heartbeat": [{"state": "up" if r.is_up else "down",
"title": _heartbeat_title(m.type, r)} for r in mrows],
"uptime_pct": uptime.get(m.id, {}).get("24h"),
})
return data
async def _agent_metrics_by_host(db, host_names: list[str]) -> dict[str, dict]:
"""Latest agent cpu/mem per host name from the generic PluginMetric table.
@@ -189,26 +247,7 @@ async def overview_widget():
"""Dashboard widget: unified per-host monitor + agent glance, linking to the hub."""
async with current_app.db_sessionmaker() as db:
hosts = (await db.execute(select(Host).order_by(Host.name))).scalars().all()
latest_ping_subq = (
select(PingResult.host_id, func.max(PingResult.probed_at).label("max_at"))
.group_by(PingResult.host_id).subquery()
)
latest_pings = {r.host_id: r for r in (await db.execute(
select(PingResult).join(
latest_ping_subq,
(PingResult.host_id == latest_ping_subq.c.host_id)
& (PingResult.probed_at == latest_ping_subq.c.max_at),
))).scalars()}
latest_dns_subq = (
select(DnsResult.host_id, func.max(DnsResult.resolved_at).label("max_at"))
.group_by(DnsResult.host_id).subquery()
)
latest_dns = {r.host_id: r for r in (await db.execute(
select(DnsResult).join(
latest_dns_subq,
(DnsResult.host_id == latest_dns_subq.c.host_id)
& (DnsResult.resolved_at == latest_dns_subq.c.max_at),
))).scalars()}
host_status = await _host_status(db, [h.id for h in hosts])
uptime = await _compute_uptime(db)
host_names = [h.name for h in hosts]
agent = await _agent_overview_by_host(db, host_names)
@@ -216,7 +255,7 @@ async def overview_widget():
return await render_template(
"hosts/overview_widget.html",
hosts=hosts, latest_pings=latest_pings, latest_dns=latest_dns,
hosts=hosts, host_status=host_status,
uptime=uptime, agent=agent, cpu_sparks=cpu_sparks,
)
@@ -227,44 +266,14 @@ async def list_hosts():
async with current_app.db_sessionmaker() as db:
result = await db.execute(select(Host).order_by(Host.name))
hosts = result.scalars().all()
# Latest ping result per host
latest_ping_subq = (
select(PingResult.host_id, func.max(PingResult.probed_at).label("max_at"))
.group_by(PingResult.host_id)
.subquery()
)
pr = await db.execute(
select(PingResult).join(
latest_ping_subq,
(PingResult.host_id == latest_ping_subq.c.host_id)
& (PingResult.probed_at == latest_ping_subq.c.max_at),
)
)
latest_pings = {r.host_id: r for r in pr.scalars()}
# Latest DNS result per host
latest_dns_subq = (
select(DnsResult.host_id, func.max(DnsResult.resolved_at).label("max_at"))
.group_by(DnsResult.host_id)
.subquery()
)
dr = await db.execute(
select(DnsResult).join(
latest_dns_subq,
(DnsResult.host_id == latest_dns_subq.c.host_id)
& (DnsResult.resolved_at == latest_dns_subq.c.max_at),
)
)
latest_dns = {r.host_id: r for r in dr.scalars()}
host_status = await _host_status(db, [h.id for h in hosts])
uptime = await _compute_uptime(db)
agent_metrics = await _agent_metrics_by_host(db, [h.name for h in hosts])
return await render_template(
"hosts/list.html",
hosts=hosts,
latest_pings=latest_pings,
latest_dns=latest_dns,
host_status=host_status,
uptime=uptime,
agent_metrics=agent_metrics,
)
@@ -282,12 +291,7 @@ async def host_detail(host_id: str):
select(Host).where(Host.id == host_id))).scalar_one_or_none()
if host is None:
return "Not found", 404
ping = (await db.execute(
select(PingResult).where(PingResult.host_id == host_id)
.order_by(PingResult.probed_at.desc()).limit(1))).scalar_one_or_none()
dns = (await db.execute(
select(DnsResult).where(DnsResult.host_id == host_id)
.order_by(DnsResult.resolved_at.desc()).limit(1))).scalar_one_or_none()
monitors = await _host_monitors(db, host_id)
uptime = (await _compute_uptime(db)).get(host_id)
linked_target = (await db.execute(
select(AnsibleTarget).where(AnsibleTarget.host_id == host_id)
@@ -296,9 +300,11 @@ async def host_detail(host_id: str):
select(AnsibleTarget).where(AnsibleTarget.host_id.is_(None))
.order_by(AnsibleTarget.name))).scalars().all()
from steward.models.monitors import MonitorType
return await render_template(
"hosts/detail.html",
host=host, ping=ping, dns=dns, uptime=uptime,
host=host, monitors=monitors, uptime=uptime,
monitor_types=list(MonitorType),
linked_target=linked_target, linkable_targets=linkable_targets,
ansible_sources=_ansible_source_names(),
)
@@ -307,7 +313,7 @@ async def host_detail(host_id: str):
@hosts_bp.get("/new")
@require_role(UserRole.operator)
async def new_host():
return await render_template("hosts/form.html", host=None, probe_types=list(ProbeType))
return await render_template("hosts/form.html", host=None)
@hosts_bp.post("/")
@@ -317,12 +323,6 @@ async def create_host():
host = Host(
name=form["name"].strip(),
address=form["address"].strip(),
probe_type=ProbeType(form.get("probe_type", "tcp")),
probe_port=int(form.get("probe_port") or 80),
ping_enabled="ping_enabled" in form,
dns_enabled="dns_enabled" in form,
dns_expected_ip=form.get("dns_expected_ip", "").strip() or None,
# poll_interval_seconds not exposed in UI yet; per-host scheduling not implemented
)
async with current_app.db_sessionmaker() as db:
async with db.begin():
@@ -330,7 +330,8 @@ async def create_host():
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"host.created", entity_type="host", entity_id=host.name,
detail={"address": host.address})
return redirect(url_for("hosts.list_hosts"))
# Land on the new host's hub so monitors/agent/ansible can be set up next.
return redirect(url_for("hosts.host_detail", host_id=host.id))
@hosts_bp.get("/<host_id>/edit")
@@ -344,8 +345,7 @@ async def edit_host(host_id: str):
if host is None:
return "Not found", 404
return await render_template(
"hosts/form.html", host=host, probe_types=list(ProbeType))
return await render_template("hosts/form.html", host=host)
@hosts_bp.post("/<host_id>")
@@ -360,13 +360,7 @@ async def update_host(host_id: str):
return "Not found", 404
host.name = form["name"].strip()
host.address = form["address"].strip()
host.probe_type = ProbeType(form.get("probe_type", "tcp"))
host.probe_port = int(form.get("probe_port") or 80)
host.ping_enabled = "ping_enabled" in form
host.dns_enabled = "dns_enabled" in form
host.dns_expected_ip = form.get("dns_expected_ip", "").strip() or None
# poll_interval_seconds not exposed in UI yet
return redirect(url_for("hosts.list_hosts"))
return redirect(url_for("hosts.host_detail", host_id=host_id))
@hosts_bp.post("/<host_id>/ansible-link")
@@ -515,9 +509,7 @@ async def uptime_page():
async def uptime_widget():
share_token = request.args.get("s")
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(Host).where(Host.ping_enabled.is_(True)).order_by(Host.name)
)
result = await db.execute(select(Host).order_by(Host.name))
hosts = result.scalars().all()
uptime = await _compute_uptime(db)
return await render_template(
@@ -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")
@@ -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)
+4 -4
View File
@@ -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",
+4 -13
View File
@@ -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)
)
+97 -25
View File
@@ -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)
+19 -47
View File
@@ -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": []}
@@ -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)
+15 -37
View File
@@ -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)
+215
View File
@@ -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("/<monitor_id>/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("/<monitor_id>/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("/<monitor_id>/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("/<monitor_id>/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,
)
+56
View File
@@ -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
+76
View File
@@ -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"],
)
View File
-127
View File
@@ -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"))
+6 -4
View File
@@ -97,10 +97,12 @@
</summary>
<div style="margin-top:0.75rem;display:grid;gap:0.5rem;">
{% 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 (01)"),
("latency_p50_ms", "median response time ms"),
+1 -2
View File
@@ -240,8 +240,7 @@ body.dash-editing .widget-drawer { transform:translateY(0); }
<a href="/">Dashboard</a>
<a href="/status">Status</a>
<a href="/hosts/">Hosts</a>
<a href="/ping/">Ping</a>
<a href="/dns/">DNS</a>
<a href="/monitors/">Monitors</a>
<a href="/alerts/">Alerts</a>
<a href="/ansible/">Ansible</a>
{% if session.user_role == 'admin' %}
-16
View File
@@ -1,16 +0,0 @@
{% extends "base.html" %}
{% block title %}DNS Monitor — Steward{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">DNS Monitor</h1>
<span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span>
</div>
<div class="card ping-card">
<div id="dns-rows"
hx-get="/dns/rows"
hx-trigger="every {{ poll_interval }}s"
hx-swap="innerHTML">
{% include "dns/rows.html" %}
</div>
</div>
{% endblock %}
-35
View File
@@ -1,35 +0,0 @@
{% if hosts %}
{% for host in hosts %}
{% set d = latest.get(host.id) %}
<div class="ping-row">
<div class="ping-meta">
{# 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 %}
<a class="ping-name" href="/hosts/{{ host.id }}" title="{{ host.name }}"
style="color:inherit;text-decoration:none;">{{ host.name }}</a>
{% else %}
<span class="ping-name" title="{{ host.name }}">{{ host.name }}</span>
{% endif %}
<span class="ping-addr">{{ host.address }}</span>
</div>
<div style="flex:1;">
{% if d is none %}
<span style="color:var(--text-dim);font-size:0.85rem;"></span>
{% elif d.status.value == 'resolved' %}
<span class="dot dot-up" style="margin-right:0.5rem;"></span>
<span style="font-size:0.85rem;color:var(--green);">{{ d.resolved_ip or 'resolved' }}</span>
{% else %}
<span class="dot dot-down" style="margin-right:0.5rem;"></span>
<span style="font-size:0.85rem;color:var(--red);">Failed</span>
{% endif %}
</div>
<div class="ping-cur">
{% if d %}
<span style="color:var(--text-muted);font-size:0.75rem;">{{ d.resolved_at.strftime('%H:%M:%S') }} UTC</span>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<p class="empty" style="padding:0.5rem 0;">No DNS checks yet. <a href="/hosts/">Enable DNS on a host →</a></p>
{% endif %}
+86 -42
View File
@@ -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 @@
</div>
</div>
{% 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 ─────────────────────────────────────────────────────────────── #}
<div class="card">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:0.75rem;">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:0.5rem;">
<h3 class="section-title" style="margin-bottom:0;">Monitors</h3>
<a href="/hosts/{{ host.id }}/edit" class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Configure →</a>
{% if uptime %}
<span style="font-size:0.8rem;color:var(--text-muted);">
uptime 24h <strong>{{ ("%.1f%%"|format(uptime['24h'])) if uptime['24h'] is not none else '—' }}</strong>
· 7d <strong>{{ ("%.1f%%"|format(uptime['7d'])) if uptime['7d'] is not none else '—' }}</strong>
· 30d <strong>{{ ("%.1f%%"|format(uptime['30d'])) if uptime['30d'] is not none else '—' }}</strong>
</span>
{% endif %}
</div>
<div style="display:flex;gap:2rem;flex-wrap:wrap;">
<div>
<div style="font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;">Ping</div>
{% if not host.ping_enabled %}
<div style="color:var(--text-dim);">off</div>
{% elif ping is none %}
<div style="color:var(--text-dim);">no data yet</div>
{% else %}
<div style="color:{{ 'var(--green)' if ping.status.value == 'up' else 'var(--red)' }};font-weight:600;">
{{ ping.status.value }}
</div>
{% if ping.response_time_ms is not none %}
<div style="font-size:0.8rem;color:var(--text-muted);">{{ '%.0f'|format(ping.response_time_ms) }} ms</div>
{% endif %}
{% endif %}
{% if monitors %}
{% for d in monitors %}
{% set m = d.monitor %}
{% set latest = d.latest %}
<div class="ping-row" {% if not m.enabled %}style="opacity:0.5;"{% endif %}>
<div class="ping-meta" style="min-width:150px;max-width:230px;">
<span class="badge" style="font-size:0.62rem;">{{ m.type | upper }}</span>
<span class="ping-name" title="{{ m.name }}">{{ m.name }}</span>
<span class="ping-addr" title="{{ m.target }}">{{ m.target }}</span>
</div>
<div>
<div style="font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;">DNS</div>
{% if not host.dns_enabled %}
<div style="color:var(--text-dim);">off</div>
{% elif dns is none %}
<div style="color:var(--text-dim);">no data yet</div>
{% else %}
<div style="color:{{ 'var(--green)' if dns.status.value == 'resolved' else 'var(--red)' }};font-weight:600;">
{{ dns.status.value }}
</div>
{% if dns.resolved_ip %}
<div style="font-size:0.8rem;color:var(--text-muted);font-family:ui-monospace,monospace;">{{ dns.resolved_ip }}</div>
{% endif %}
{% endif %}
<div class="ping-pills">
{% for _ in range([30 - d.heartbeat|length, 0]|max) %}
<span class="pill pill-empty" title="No data"></span>
{% endfor %}
{% for h in d.heartbeat %}
<span class="pill" title="{{ h.title }}"
style="background:{{ '#1a6632' if h.state == 'up' else '#6a1515' }};"></span>
{% endfor %}
</div>
<div>
<div style="font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;">Uptime</div>
{% if uptime %}
<div style="font-size:0.85rem;">
24h <strong>{{ uptime['24h'] if uptime['24h'] is not none else '—' }}{{ '%' if uptime['24h'] is not none }}</strong>
· 7d <strong>{{ uptime['7d'] if uptime['7d'] is not none else '—' }}{{ '%' if uptime['7d'] is not none }}</strong>
· 30d <strong>{{ uptime['30d'] if uptime['30d'] is not none else '—' }}{{ '%' if uptime['30d'] is not none }}</strong>
</div>
{% else %}
<div style="color:var(--text-dim);"></div>
{% endif %}
<div class="ping-cur">
{% if latest is none %}<span class="ping-cur-nd"></span>
{% elif not latest.is_up %}<span class="ping-cur-down">DOWN</span>
{% elif latest.response_ms is not none %}
<span style="{{ threshold_style(latest.response_ms, 'latency') or 'color:var(--green);' }}">{{ "%.0f"|format(latest.response_ms) }} ms</span>
{% else %}<span class="ping-cur-good">UP</span>{% endif %}
</div>
{% if session.user_role in ['operator', 'admin'] %}
<div style="display:flex;gap:0.35rem;flex-shrink:0;">
<a href="/monitors/{{ m.id }}/edit" class="btn btn-sm btn-ghost" style="padding:0.2rem 0.5rem;font-size:0.72rem;">Edit</a>
<form method="post" action="/monitors/{{ m.id }}/delete" style="display:inline;"
onsubmit="return confirm('Delete monitor {{ m.name }}?');">
<button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;color:var(--red);"></button>
</form>
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<p style="color:var(--text-muted);font-size:0.88rem;margin:0.25rem 0 0.75rem;">
No monitors yet for this host. Add one below.
</p>
{% endif %}
{% if session.user_role in ['operator', 'admin'] %}
<details style="margin-top:0.75rem;">
<summary style="cursor:pointer;font-size:0.85rem;color:var(--text-muted);">+ Add monitor for this host</summary>
<form method="post" action="/monitors/add" style="display:grid;gap:0.6rem;margin-top:0.75rem;">
<input type="hidden" name="host_id" value="{{ host.id }}">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.6rem;align-items:end;">
<div>
<label style="{{ lbl }}">Type</label>
<select name="type" id="mtype" onchange="mtoggle(this.value)" style="{{ inp }}">
{% for t in monitor_types %}
<option value="{{ t.value }}" {% if t.value == 'icmp' %}selected{% endif %}>{{ t.value | upper }}</option>
{% endfor %}
</select>
</div>
<div>
<label style="{{ lbl }}">Name</label>
<input type="text" name="name" placeholder="optional" autocomplete="off" style="{{ inp }}">
</div>
<div>
<label style="{{ lbl }}">Target</label>
<input type="text" name="target" value="{{ host.address }}" autocomplete="off" required style="{{ inp }}">
</div>
<div>
<label style="{{ lbl }}">Interval (s, 0=global)</label>
<input type="number" name="check_interval_seconds" value="0" min="0" style="{{ inp }}">
</div>
{{ type_fields() }}
</div>
<div><button type="submit" class="btn btn-sm">Add Monitor</button></div>
</form>
</details>
{% endif %}
</div>
{# ── Agent (host_agent plugin fragment, embedded across the plugin boundary) ── #}
@@ -168,4 +210,6 @@
{% endif %}
</div>
{{ toggle_script() }}
<script>var _mt=document.getElementById('mtype'); if(_mt) mtoggle(_mt.value);</script>
{% endblock %}
+4 -35
View File
@@ -15,47 +15,16 @@
<label>Address (hostname or IP)</label>
<input type="text" name="address" value="{{ host.address if host else '' }}" required>
</div>
<div class="form-group">
<label>Probe Type</label>
<select name="probe_type">
{% for pt in probe_types %}
<option value="{{ pt.value }}" {% if host and host.probe_type == pt %}selected{% endif %}>
{{ pt.value | upper }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>TCP Port (used for TCP probe; ignored for ICMP)</label>
<input type="number" name="probe_port" value="{{ host.probe_port if host else 80 }}" min="1" max="65535">
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;">
<input type="checkbox" name="ping_enabled" {% if not host or host.ping_enabled %}checked{% endif %}>
Enable ping monitor
</label>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;">
<input type="checkbox" name="dns_enabled" {% if host and host.dns_enabled %}checked{% endif %}>
Enable DNS monitor
</label>
</div>
<div class="form-group">
<label>Expected IP (optional — leave blank to accept any)</label>
<input type="text" name="dns_expected_ip" value="{{ host.dns_expected_ip if host and host.dns_expected_ip else '' }}">
</div>
<div style="display:flex;gap:1rem;margin-top:1.5rem;">
<button type="submit" class="btn">{% if host %}Save{% else %}Add Host{% endif %}</button>
<a href="/hosts/" class="btn btn-ghost">Cancel</a>
<a href="{% if host %}/hosts/{{ host.id }}{% else %}/hosts/{% endif %}" class="btn btn-ghost">Cancel</a>
</div>
</form>
</div>
{% if host %}
<p style="color:var(--text-muted);font-size:0.82rem;margin-top:1rem;text-align:center;">
Agent, Ansible target, and playbook runs live on the
<a href="/hosts/{{ host.id }}">host page</a>.
{% if host %}Monitors, agent, Ansible target, and playbook runs live on the
<a href="/hosts/{{ host.id }}">host page</a>.{% else %}
After adding, attach ping / DNS / HTTP monitors from the host page.{% endif %}
</p>
{% endif %}
</div>
{% endblock %}
+15 -42
View File
@@ -18,10 +18,9 @@
<tr>
<th>Name</th>
<th>Address</th>
<th>Probe</th>
<th style="text-align:center;">Ping</th>
<th style="text-align:center;" title="Worst current status across this host's monitors">Status</th>
<th style="text-align:center;">Latency</th>
<th style="text-align:center;">DNS</th>
<th style="text-align:center;" title="Number of monitors attached">Monitors</th>
<th style="text-align:center;" title="Uptime last 24 hours">24h</th>
<th style="text-align:center;" title="Uptime last 7 days">7d</th>
<th style="text-align:center;" title="Uptime last 30 days">30d</th>
@@ -31,65 +30,39 @@
</thead>
<tbody>
{% 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, {}) %}
<tr>
<td><a href="/hosts/{{ host.id }}" style="font-weight:600;">{{ host.name }}</a></td>
<td style="color:var(--text-muted);">{{ host.address }}</td>
<td style="color:var(--text-muted);font-size:0.85rem;">
{{ host.probe_type.value | upper }}{% if host.probe_type.value == 'tcp' %}:{{ host.probe_port }}{% endif %}
</td>
<td style="text-align:center;">
{% if not host.ping_enabled %}
<span style="color:var(--text-dim);font-size:0.8rem;">off</span>
{% elif ping is none %}
<span style="color:var(--text-muted);font-size:0.8rem;"></span>
{% elif ping.status.value == 'up' %}
{% if not hs %}
<span style="color:var(--text-dim);font-size:0.8rem;" title="No monitors"></span>
{% elif hs.state == 'up' %}
<span class="dot dot-up" title="Up"></span>
{% else %}
{% elif hs.state == 'down' %}
<span class="dot dot-down" title="Down"></span>
{% else %}
<span class="dot dot-dim" title="Pending"></span>
{% endif %}
</td>
<td style="text-align:center;font-size:0.9rem;">
{% if ping and ping.response_time_ms is not none %}
{% if ping.response_time_ms < 10 %}
<span style="color:var(--green);">{{ ping.response_time_ms | round(1) }} ms</span>
{% elif ping.response_time_ms < 100 %}
<span style="color:var(--yellow);">{{ ping.response_time_ms | round(1) }} ms</span>
{% else %}
<span style="color:var(--orange);">{{ ping.response_time_ms | round(1) }} ms</span>
{% endif %}
{% if hs and hs.latency_ms is not none %}
<span style="{{ threshold_style(hs.latency_ms, 'latency') or 'color:var(--green);' }}">{{ hs.latency_ms | round(1) }} ms</span>
{% else %}
<span style="color:var(--text-dim);"></span>
{% endif %}
</td>
<td style="text-align:center;">
{% if not host.dns_enabled %}
<span style="color:var(--text-dim);font-size:0.8rem;">off</span>
{% elif dns is none %}
<span style="color:var(--text-muted);font-size:0.8rem;"></span>
{% elif dns.status.value == 'resolved' %}
<span class="dot dot-up" title="{{ dns.resolved_ip }}"></span>
{% else %}
<span class="dot dot-down" title="Failed"></span>
{% endif %}
<td style="text-align:center;font-size:0.85rem;color:var(--text-muted);">
{{ hs.count if hs else 0 }}
</td>
{% for window in ["24h", "7d", "30d"] %}
{% set pct = ut.get(window) %}
<td style="text-align:center;font-size:0.82rem;font-family:ui-monospace,monospace;">
{% if not host.ping_enabled %}
{% if pct is none %}
<span style="color:var(--text-dim);"></span>
{% elif pct is none %}
<span style="color:var(--text-muted);"></span>
{% elif pct >= 99.9 %}
<span style="color:var(--green);">{{ "%.1f"|format(pct) }}%</span>
{% elif pct >= 99 %}
<span style="color:var(--yellow);">{{ "%.1f"|format(pct) }}%</span>
{% elif pct >= 95 %}
<span style="color:var(--orange);">{{ "%.1f"|format(pct) }}%</span>
{% else %}
<span style="color:var(--red);">{{ "%.1f"|format(pct) }}%</span>
<span style="{{ threshold_style(pct, 'uptime') or 'color:var(--green);' }}">{{ "%.1f"|format(pct) }}%</span>
{% endif %}
</td>
{% endfor %}
+6 -7
View File
@@ -4,18 +4,17 @@
{% if hosts %}
<div class="host-blocks">
{% 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) %}
<div style="padding:0.5rem 0;border-bottom:1px solid var(--border);">
{# Line 1 — status dot + full host name (own line) + cpu trend #}
<div style="display:flex;align-items:center;gap:0.5rem;">
<span class="dot {% if down %}dot-down{% elif host.ping_enabled and ping %}dot-up{% else %}dot-dim{% endif %}"
<span class="dot {% if down %}dot-down{% elif hs and hs.state == 'up' %}dot-up{% else %}dot-dim{% endif %}"
style="flex-shrink:0;"
title="{% if not host.ping_enabled %}ping off{% elif ping %}{{ ping.status.value }}{% else %}no data{% endif %}"></span>
title="{% if not hs %}no monitors{% else %}{{ hs.state }}{% endif %}"></span>
{% if session.user_id %}
<a href="/hosts/{{ host.id }}" style="font-weight:600;font-size:0.85rem;overflow-wrap:anywhere;">{{ host.name }}</a>
{% else %}
@@ -25,8 +24,8 @@
{# Line 2 — monitors + agent metrics; wraps instead of truncating #}
<div style="display:flex;flex-wrap:wrap;gap:0.25rem 0.9rem;margin-top:0.25rem;padding-left:1.1rem;
font-variant-numeric:tabular-nums;color:var(--text-muted);font-size:0.75rem;">
{% if host.ping_enabled and ping and ping.response_time_ms is not none %}
<span title="Ping latency" style="{{ threshold_style(ping.response_time_ms, 'latency') }}"><span style="color:var(--text-dim);">ping</span> {{ "%.0f"|format(ping.response_time_ms) }}ms</span>
{% if hs and hs.latency_ms is not none %}
<span title="Latency" style="{{ threshold_style(hs.latency_ms, 'latency') }}"><span style="color:var(--text-dim);">ping</span> {{ "%.0f"|format(hs.latency_ms) }}ms</span>
{% endif %}
{% if ut.get('24h') is not none %}
<span title="Uptime 24h" style="{{ threshold_style(ut['24h'], 'uptime') }}"><span style="color:var(--text-dim);">24h</span> {{ "%.1f"|format(ut['24h']) }}%</span>
+6 -13
View File
@@ -7,13 +7,10 @@
</div>
{# ── 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, {}) %}
<tr>
<td style="font-weight:500;">{{ host.name }}</td>
<td style="font-weight:500;"><a href="/hosts/{{ host.id }}" style="color:inherit;">{{ host.name }}</a></td>
<td style="color:var(--text-muted);font-size:0.85rem;">{{ host.address }}</td>
{% for window in ["24h", "7d", "30d"] %}
{% set pct = ut.get(window) %}
<td style="text-align:center;font-size:0.88rem;font-family:ui-monospace,monospace;">
{% if not host.ping_enabled %}
<span style="color:var(--text-dim);"></span>
{% elif pct is none %}
{% if pct is none %}
<span style="color:var(--text-muted);" title="No data yet"></span>
{% elif pct >= 99.9 %}
<span style="color:var(--green);">{{ "%.2f"|format(pct) }}%</span>
@@ -75,9 +70,7 @@
{# 30-day visual bar #}
{% set pct30 = ut.get("30d") %}
<td>
{% if not host.ping_enabled %}
<span style="color:var(--text-dim);font-size:0.8rem;">ping off</span>
{% elif pct30 is none %}
{% if pct30 is none %}
<span style="color:var(--text-muted);font-size:0.8rem;">no data</span>
{% else %}
<div style="display:flex;align-items:center;gap:0.5rem;">
@@ -99,6 +92,6 @@
{% endif %}
<p style="color:var(--text-dim);font-size:0.78rem;margin-top:1rem;">
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 —.
</p>
{% endblock %}
+1 -1
View File
@@ -1,7 +1,7 @@
{# hosts/uptime_widget.html — dashboard widget: SLA uptime summary #}
{% if not hosts %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
No ping-enabled hosts configured.
No hosts configured.
</div>
{% else %}
+64
View File
@@ -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 #}
<div class="mfield" data-types="tcp">
<label style="{{ lbl }}">Port</label>
<input type="number" name="port" value="{{ config.get('port', 80) }}" min="1" max="65535" style="{{ inp }}">
</div>
{# dns #}
<div class="mfield" data-types="dns">
<label style="{{ lbl }}">Expected IP <span style="color:var(--text-dim);">(optional)</span></label>
<input type="text" name="expected_ip" value="{{ config.get('expected_ip') or '' }}"
placeholder="e.g. 192.0.2.10" autocomplete="off" style="{{ inp }}">
</div>
{# http #}
<div class="mfield" data-types="http">
<label style="{{ lbl }}">Method</label>
<select name="method" style="{{ inp }}">
{% for opt in ["GET", "HEAD", "POST"] %}
<option value="{{ opt }}" {% if config.get('method', 'GET') == opt %}selected{% endif %}>{{ opt }}</option>
{% endfor %}
</select>
</div>
<div class="mfield" data-types="http">
<label style="{{ lbl }}">Expected status</label>
<input type="number" name="expected_status" value="{{ config.get('expected_status', 200) }}"
min="100" max="599" style="{{ inp }}">
</div>
<div class="mfield" data-types="http">
<label style="{{ lbl }}">Timeout (s)</label>
<input type="number" name="timeout_seconds" value="{{ config.get('timeout_seconds', 10) }}"
min="1" max="60" style="{{ inp }}">
</div>
<div class="mfield" data-types="http">
<label style="{{ lbl }}">Content match <span style="color:var(--text-dim);">(substring)</span></label>
<input type="text" name="content_match" value="{{ config.get('content_match', '') }}"
placeholder="optional" autocomplete="off" style="{{ inp }}">
</div>
<div class="mfield" data-types="http" style="display:flex;flex-direction:column;gap:0.3rem;justify-content:flex-end;">
<label style="display:flex;align-items:center;gap:0.4rem;font-size:0.82rem;cursor:pointer;">
<input type="checkbox" name="follow_redirects" {% if config.get('follow_redirects', True) %}checked{% endif %}> Follow redirects
</label>
<label style="display:flex;align-items:center;gap:0.4rem;font-size:0.82rem;cursor:pointer;">
<input type="checkbox" name="verify_ssl" {% if config.get('verify_ssl', True) %}checked{% endif %}> Verify SSL
</label>
</div>
{% endmacro %}
{# Show only the fields whose data-types matches the active monitor type. #}
{% macro toggle_script() %}
<script>
function mtoggle(t) {
document.querySelectorAll('.mfield').forEach(function (el) {
var types = (el.getAttribute('data-types') || '').split(',');
el.style.display = types.indexOf(t) !== -1 ? '' : 'none';
});
}
</script>
{% endmacro %}
+48
View File
@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% from "monitors/_fields.html" import type_fields, toggle_script %}
{% block title %}Edit Monitor — Steward{% endblock %}
{% block content %}
<div style="margin-bottom:1.25rem;">
<a href="/monitors/" style="color:var(--text-muted);font-size:0.85rem;">← Monitors</a>
<h1 class="page-title" style="margin:0.4rem 0 0;">Edit Monitor</h1>
</div>
{% 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;" %}
<div class="card" style="max-width:760px;">
<form method="post" action="/monitors/{{ monitor.id }}/edit" style="display:grid;gap:0.6rem;">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:0.6rem;align-items:end;">
<div>
<label style="{{ lbl }}">Type</label>
<input type="text" value="{{ monitor.type | upper }}" disabled style="{{ inp }}opacity:0.7;">
</div>
<div>
<label style="{{ lbl }}">Name</label>
<input type="text" name="name" value="{{ monitor.name }}" autocomplete="off" style="{{ inp }}">
</div>
<div>
<label style="{{ lbl }}">Target <span style="color:var(--red);">*</span></label>
<input type="text" name="target" value="{{ monitor.target }}" autocomplete="off" required style="{{ inp }}">
</div>
<div>
<label style="{{ lbl }}">Interval (s, 0=global)</label>
<input type="number" name="check_interval_seconds" value="{{ monitor.check_interval_seconds }}" min="0" style="{{ inp }}">
</div>
{{ type_fields(config) }}
</div>
{% if monitor.host_id %}
<p style="font-size:0.78rem;color:var(--text-dim);margin:0;">
Linked to <a href="/hosts/{{ monitor.host_id }}" style="color:var(--accent);">its host</a>.
</p>
{% endif %}
<div style="display:flex;gap:0.5rem;">
<button type="submit" class="btn btn-sm">Save</button>
<a href="/monitors/" class="btn btn-sm" style="background:transparent;border:1px solid var(--border-mid);">Cancel</a>
</div>
</form>
</div>
{{ toggle_script() }}
<script>mtoggle({{ monitor.type | tojson }});</script>
{% endblock %}
+60
View File
@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% from "monitors/_fields.html" import type_fields, toggle_script %}
{% block title %}Monitors — Steward{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem;flex-wrap:wrap;">
<div style="display:flex;align-items:baseline;gap:1rem;">
<h1 class="page-title" style="margin-bottom:0;">Monitors</h1>
<span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span>
</div>
{% include "_time_range.html" %}
</div>
{% 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 ──────────────────────────────────────────────────────────── #}
<div class="card" style="margin-bottom:1.25rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Add Monitor</div>
<form method="post" action="/monitors/add" style="display:grid;gap:0.6rem;">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:0.6rem;align-items:end;">
<div>
<label style="{{ lbl }}">Type</label>
<select name="type" id="mtype" onchange="mtoggle(this.value)" style="{{ inp }}">
{% for t in types %}
<option value="{{ t.value }}" {% if t.value == 'icmp' %}selected{% endif %}>{{ t.value | upper }}</option>
{% endfor %}
</select>
</div>
<div>
<label style="{{ lbl }}">Name</label>
<input type="text" name="name" placeholder="optional" autocomplete="off" style="{{ inp }}">
</div>
<div>
<label style="{{ lbl }}">Target <span style="color:var(--red);">*</span></label>
<input type="text" name="target" placeholder="host, IP, or URL" autocomplete="off" required style="{{ inp }}">
</div>
<div>
<label style="{{ lbl }}">Interval (s, 0=global)</label>
<input type="number" name="check_interval_seconds" value="0" min="0" style="{{ inp }}">
</div>
{{ type_fields() }}
</div>
<div><button type="submit" class="btn btn-sm">Add Monitor</button></div>
</form>
</div>
{# ── Live monitor rows ────────────────────────────────────────────────────── #}
<div class="card ping-card">
<div id="monitor-rows"
hx-get="/monitors/rows"
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
hx-include="#time-range"
hx-swap="innerHTML">
<div style="color:var(--text-muted);font-size:0.9rem;padding:2rem;">Loading…</div>
</div>
</div>
{{ toggle_script() }}
<script>mtoggle(document.getElementById('mtype').value);</script>
{% endblock %}
+74
View File
@@ -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 %}
<div class="ping-row" {% if not m.enabled %}style="opacity:0.5;"{% endif %}>
<div class="ping-meta" style="min-width:160px;max-width:240px;">
<span class="badge" style="font-size:0.62rem;vertical-align:middle;">{{ m.type | upper }}</span>
{% if m.host_id %}
<a class="ping-name" href="/hosts/{{ m.host_id }}" title="{{ m.name }}"
style="color:inherit;text-decoration:none;">{{ m.name }}</a>
{% else %}
<span class="ping-name" title="{{ m.name }}">{{ m.name }}</span>
{% endif %}
<span class="ping-addr" title="{{ m.target }}">{{ m.target }}</span>
</div>
<div class="ping-pills">
{% for _ in range([30 - d.heartbeat|length, 0]|max) %}
<span class="pill pill-empty" title="No data"></span>
{% endfor %}
{% for h in d.heartbeat %}
<span class="pill" title="{{ h.title }}"
style="background:{{ '#1a6632' if h.state == 'up' else '#6a1515' }};"></span>
{% endfor %}
</div>
<div class="ping-cur">
{% if latest is none %}
<span class="ping-cur-nd"></span>
{% elif not latest.is_up %}
<span class="ping-cur-down">DOWN</span>
{% elif latest.response_ms is not none %}
<span style="{{ threshold_style(latest.response_ms, 'latency') or 'color:var(--green);' }}">
{{ "%.0f"|format(latest.response_ms) }} ms</span>
{% elif m.type == 'dns' and latest.resolved_ip %}
<span class="ping-cur-good" style="font-size:0.72rem;">{{ latest.resolved_ip }}</span>
{% else %}
<span class="ping-cur-good">UP</span>
{% endif %}
</div>
<div style="min-width:4.5rem;text-align:right;font-size:0.78rem;font-variant-numeric:tabular-nums;{{ threshold_style(d.uptime_pct, 'uptime') if d.uptime_pct is not none else 'color:var(--text-dim);' }}">
{% if d.uptime_pct is not none %}{{ "%.1f"|format(d.uptime_pct) }}%{% else %}—{% endif %}
<span style="color:var(--text-dim);font-size:0.7rem;margin-left:0.15rem;">{{ range_key }}</span>
</div>
{% if d.tls_days is not none %}
<div title="TLS certificate expiry" style="min-width:4rem;text-align:right;font-size:0.72rem;{{ 'color:var(--red);' if d.tls_days < 14 else ('color:var(--yellow);' if d.tls_days < 30 else 'color:var(--text-dim);') }}">
cert {{ d.tls_days|round|int }}d
</div>
{% endif %}
{% if session.user_role in ['operator', 'admin'] %}
<div style="display:flex;gap:0.35rem;flex-shrink:0;">
<a href="/monitors/{{ m.id }}/edit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;">Edit</a>
<form method="post" action="/monitors/{{ m.id }}/toggle" style="display:inline;">
<button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;">
{{ "Pause" if m.enabled else "Resume" }}</button>
</form>
<form method="post" action="/monitors/{{ m.id }}/delete" style="display:inline;"
onsubmit="return confirm('Delete monitor {{ m.name }}?');">
<button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;color:var(--red);"></button>
</form>
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<p style="color:var(--text-muted);font-size:0.9rem;padding:1rem 0;">
No monitors yet. Add one above, or attach checks to a host from the
<a href="/hosts/" style="color:var(--accent);">Hosts</a> page.
</p>
{% endif %}
+44
View File
@@ -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 %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
No monitors configured. <a href="/monitors/">Add monitors →</a>
</div>
{% else %}
<div style="display:flex;gap:1rem;margin-bottom:0.65rem;flex-wrap:wrap;">
{% if up %}
<div><span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--green);">{{ up }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">up</span></div>
{% endif %}
{% if down %}
<div><span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--red);">{{ down }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">down</span></div>
{% endif %}
{% if pending %}
<div><span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--text-dim);">{{ pending }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">pending</span></div>
{% endif %}
</div>
<div style="display:grid;gap:3px;">
{% for d in monitor_data %}
{% set latest = d.latest %}
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.82rem;padding:0.15rem 0;">
{% if not latest %}<span class="dot dot-dim"></span>
{% elif latest.is_up %}<span class="dot dot-up"></span>
{% else %}<span class="dot dot-down"></span>{% endif %}
{% if d.monitor.host_id %}
<a href="/hosts/{{ d.monitor.host_id }}" style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:inherit;text-decoration:none;">{{ d.monitor.name }}</a>
{% else %}
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ d.monitor.name }}</span>
{% endif %}
{% if latest and latest.response_ms is not none %}
<span style="font-size:0.72rem;font-family:ui-monospace,monospace;color:var(--text-muted);flex-shrink:0;">{{ "%.0f"|format(latest.response_ms) }}ms</span>
{% elif latest and not latest.is_up %}
<span style="font-size:0.72rem;color:var(--red);flex-shrink:0;">{{ latest.status_code or "down" }}</span>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
-47
View File
@@ -1,47 +0,0 @@
{% extends "base.html" %}
{% block title %}Ping Monitor — Steward{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.75rem;margin-bottom:1.25rem;">
<div style="display:flex;align-items:baseline;gap:1rem;">
<h1 class="page-title" style="margin-bottom:0;">Ping Monitor</h1>
<span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span>
</div>
{% include "_time_range.html" %}
</div>
{# ── Threshold settings ─────────────────────────────────────────────────── #}
<div class="card" style="margin-bottom:1.25rem;">
<h2 class="section-title" style="margin-bottom:1rem;">Latency Thresholds</h2>
<form method="post" action="/ping/settings"
style="display:flex;flex-wrap:wrap;gap:1rem;align-items:flex-end;">
<div class="form-group" style="margin:0;">
<label style="font-size:0.8rem;">
<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#1a6632;vertical-align:middle;margin-right:4px;"></span>
Good below (ms)
</label>
<input type="number" name="good_ms" value="{{ good_ms }}" min="1" max="9999" style="width:100px;">
</div>
<div class="form-group" style="margin:0;">
<label style="font-size:0.8rem;">
<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#6b5c00;vertical-align:middle;margin-right:4px;"></span>
Warn above (ms)
</label>
<input type="number" name="warn_ms" value="{{ warn_ms }}" min="1" max="9999" style="width:100px;">
</div>
<button type="submit" class="btn" style="padding:0.4rem 1rem;">Save</button>
<span style="color:#505070;font-size:0.8rem;align-self:center;">
Above warn = <span style="color:#c06020;"></span> orange&nbsp;&nbsp;|&nbsp;&nbsp;No response = <span style="color:#c03030;"></span> red
</span>
</form>
</div>
{# ── Live host rows ─────────────────────────────────────────────────────── #}
<div class="card ping-card">
<div id="ping-rows"
hx-get="/ping/rows"
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
hx-include="#time-range"
hx-swap="innerHTML">
</div>
</div>
{% endblock %}
-67
View File
@@ -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 %}
<div class="ping-row">
<div class="ping-meta">
{# 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 %}
<a class="ping-name" href="/hosts/{{ host.id }}" title="{{ host.name }}"
style="color:inherit;text-decoration:none;">{{ host.name }}</a>
{% else %}
<span class="ping-name" title="{{ host.name }}">{{ host.name }}</span>
{% endif %}
<span class="ping-addr">{{ host.address }}</span>
</div>
<div class="ping-pills">
{% for _ in range([30 - host_pings|length, 0]|max) %}
<span class="pill pill-empty" title="No data"></span>
{% endfor %}
{% for p in host_pings %}
<span class="pill" style="background:{{ pill_bg(p) }};" title="{{ pill_title(p) }}"></span>
{% endfor %}
</div>
<div class="ping-cur">
{% if last is none %}
<span class="ping-cur-nd"></span>
{% elif last.status.value == 'down' or last.response_time_ms is none %}
<span class="ping-cur-down">DOWN</span>
{% elif last.response_time_ms <= good_ms %}
<span class="ping-cur-good">{{ "%.1f"|format(last.response_time_ms) }} ms</span>
{% elif last.response_time_ms <= warn_ms %}
<span class="ping-cur-warn">{{ "%.1f"|format(last.response_time_ms) }} ms</span>
{% else %}
<span class="ping-cur-bad">{{ "%.1f"|format(last.response_time_ms) }} ms</span>
{% endif %}
</div>
{% 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);' %}
<div style="min-width:4.5rem;text-align:right;font-size:0.78rem;font-variant-numeric:tabular-nums;{{ us if us else 'color:var(--green);' }}">
{% if pct is not none %}{{ "%.1f"|format(pct) }}%{% else %}—{% endif %}
<span style="color:var(--text-dim);font-size:0.7rem;margin-left:0.15rem;">{{ range_key }}</span>
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<p style="color:#505070;font-size:0.85rem;padding:0.5rem 0;">No ping-enabled hosts. <a href="/hosts/" style="color:#7070c0;">Add hosts →</a></p>
{% endif %}
+110
View File
@@ -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
+85
View File
@@ -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