feat(monitors): unify ping/dns/http into one Monitor entity + custom targets
Collapse the three former check types into a single core `Monitor` entity
with one management surface (/monitors), one result table (monitor_results),
and a single scheduled task. Every type can now watch a free-standing custom
destination (optional host_id) — not just a registered Host.
- models: Monitor + MonitorResult replace PingResult/DnsResult; Host loses its
ping/dns facet columns (now Monitor rows linked by host_id).
- checks: monitors/{ping,dns,http}.py pure probes + runner.run_monitor
dispatcher; one monitor_check scheduler with a per-monitor due-filter.
- status: single monitor_status_source replaces the three sources.
- UI: /monitors blueprint (type-aware add/edit/list/widget); host hub shows a
host's linked monitors + "add monitor for this host"; nav + widget registry
+ alert metric catalog rewired. http plugin folded into core and removed.
- migration 0022 merges the http branch, data-migrates host facets +
http_monitors + all three result histories, drops the old tables/columns.
Resolves the per-host ping/dns auto-attach issue (#275): monitors are now
explicit, never auto-added to every host.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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")
|
||||
+8
-1
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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"))
|
||||
@@ -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 (0–1)"),
|
||||
("latency_p50_ms", "median response time ms"),
|
||||
|
||||
@@ -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' %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 | 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user