Files
FabledSteward/plugins/http/routes.py
T
bvandeusen a7a281cb11 feat(plugins): fold first-party plugins in-tree; bundled + external roots
First-party plugins (host_agent, http, snmp, traefik, unifi, docker) are now
tracked under plugins/ and baked into the image, so they version atomically
with core — ending the cross-repo import drift the roundtable->steward rename
exposed. History for these files is preserved in the archived Roundtable-plugins
repo.

Plugin discovery becomes multi-root: PLUGIN_DIR (single) -> PLUGIN_DIRS
(bundled first, then external) + PLUGIN_INSTALL_DIR. Bundled ships in the image;
third-party plugins still mount at runtime into the external root
(STEWARD_PLUGIN_DIR, default /data/plugins) and downloads/installs land there.
Bundled shadows external on a name collision.

- config.py: load_bootstrap returns plugin_dirs + plugin_install_dir
- app.py: iterate PLUGIN_DIRS at the migration + load sites
- migration_runner.py: discover_all_in() unions every plugin root
- plugin_manager.py: resolve_plugin_path() (pure, first-root-wins); load /
  install / hot-reload span all roots; installs target the external root
- settings/routes.py: _discover_plugins scans all roots, dedup bundled-first
- Dockerfile: COPY plugins/ ; docker-compose: drop host bind, document external
- tests/test_plugin_dirs.py: resolution, multi-root discovery, bootstrap split

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 08:37:24 -04:00

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,
)