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>
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
# 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,
|
||||
)
|
||||
Reference in New Issue
Block a user