be4654fc72
- Add HTTP monitor plugin: endpoint checking with status history, latency tracking, time-range views - Add SNMP plugin: OID polling, device management, metric recording - Add Docker plugin: container status, resource usage, widget - Traefik: access log widget, request chart widget, expanded routes - UniFi: clients widget, devices widget, expanded poll routes - UPS: history widget, additional routes - Update plugin index with new entries Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
230 lines
7.6 KiB
Python
230 lines
7.6 KiB
Python
# plugins/http/routes.py
|
|
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 Integer, cast, func, select
|
|
|
|
from fabledscryer.auth.middleware import require_role
|
|
from fabledscryer.models.users import UserRole
|
|
from fabledscryer.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,
|
|
)
|