af60ca446d
The fabledscryer->steward rename had only ever reached host_agent. The other five bundled plugins (http, snmp, traefik, unifi, docker) still imported `from fabledscryer.*` (package no longer exists) and read FABLEDSCRYER_* env vars — so every one of them was broken at import since the original rebrand. CI stayed green only because none are enabled by default and migrations don't import plugin modules. Now that they version in-tree, complete the rename: - fabledscryer.* -> steward.* imports across all five plugins - FABLEDSCRYER_* -> STEWARD_* in plugin migration env.py files - author/repository/homepage + user-facing 'Fabled Scryer' strings -> Steward - snmp/scheduler.py: also drop dead `now`/datetime; record_metric from steward Adds tests/test_no_legacy_names.py — fails if 'scryer'/'roundtable' ever reappear in shipped code (the drift bit twice; this stops a third time). Also clears pre-existing ruff lint debt (unused imports, semicolon statements, mid-file import) surfaced by the new lint lane. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
229 lines
7.5 KiB
Python
229 lines
7.5 KiB
Python
# plugins/http/routes.py
|
|
from __future__ import annotations
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from quart import Blueprint, current_app, render_template, request, redirect, url_for
|
|
from sqlalchemy import Integer, cast, func, select
|
|
|
|
from steward.auth.middleware import require_role
|
|
from steward.models.users import UserRole
|
|
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
|
|
|
|
|
|
@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,
|
|
)
|