a7a281cb11
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>
374 lines
13 KiB
Python
374 lines
13 KiB
Python
# plugins/traefik/routes.py
|
|
from __future__ import annotations
|
|
from collections import defaultdict
|
|
from datetime import datetime, timezone
|
|
from quart import Blueprint, current_app, render_template, request
|
|
from sqlalchemy import Integer, cast, func, select
|
|
import json
|
|
|
|
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, subsample
|
|
from .models import TraefikAccessSummary, TraefikCert, TraefikGlobalStat, TraefikMetric
|
|
|
|
traefik_bp = Blueprint("traefik", __name__, template_folder="templates")
|
|
|
|
|
|
def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str:
|
|
"""Generate an inline SVG sparkline from a list of float values."""
|
|
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>'
|
|
)
|
|
|
|
|
|
@traefik_bp.get("/")
|
|
@require_role(UserRole.viewer)
|
|
async def index():
|
|
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
|
|
_al_cfg = current_app.config["PLUGINS"]["traefik"].get("access_log", {})
|
|
if not isinstance(_al_cfg, dict):
|
|
_al_cfg = {}
|
|
access_log_enabled = _al_cfg.get("enabled", False)
|
|
current_range = request.args.get("range", DEFAULT_RANGE)
|
|
return await render_template(
|
|
"traefik/index.html",
|
|
poll_interval=poll_interval,
|
|
access_log_enabled=access_log_enabled,
|
|
current_range=current_range,
|
|
)
|
|
|
|
|
|
@traefik_bp.get("/rows")
|
|
@require_role(UserRole.viewer)
|
|
async def rows():
|
|
"""HTMX fragment: service cards with sparklines scoped to selected time range."""
|
|
since, range_key = parse_range(request.args.get("range"))
|
|
now = datetime.now(timezone.utc)
|
|
|
|
async with current_app.db_sessionmaker() as db:
|
|
# All known routers
|
|
result = await db.execute(
|
|
select(TraefikMetric.router_name)
|
|
.distinct()
|
|
.order_by(TraefikMetric.router_name)
|
|
)
|
|
routers = [row[0] for row in result.all()]
|
|
|
|
b_secs = bucket_seconds(since)
|
|
bucket_col = (
|
|
cast(func.extract('epoch', TraefikMetric.scraped_at), Integer) / b_secs
|
|
).label("bucket")
|
|
|
|
router_data = []
|
|
for router in routers:
|
|
# Bucketed history for sparklines — always ~80 points regardless of range
|
|
result = await db.execute(
|
|
select(
|
|
func.avg(TraefikMetric.request_rate).label("request_rate"),
|
|
func.avg(TraefikMetric.latency_p95_ms).label("latency_p95_ms"),
|
|
func.avg(TraefikMetric.error_rate_5xx_pct).label("error_rate_5xx_pct"),
|
|
func.min(TraefikMetric.scraped_at).label("scraped_at"),
|
|
bucket_col,
|
|
)
|
|
.where(TraefikMetric.router_name == router)
|
|
.where(TraefikMetric.scraped_at >= since)
|
|
.group_by(bucket_col)
|
|
.order_by(bucket_col)
|
|
)
|
|
history = result.all()
|
|
|
|
# Latest value — always the most recent raw row
|
|
result = await db.execute(
|
|
select(TraefikMetric)
|
|
.where(TraefikMetric.router_name == router)
|
|
.order_by(TraefikMetric.scraped_at.desc())
|
|
.limit(1)
|
|
)
|
|
latest = result.scalar_one_or_none()
|
|
if not latest:
|
|
continue
|
|
|
|
router_data.append({
|
|
"name": router,
|
|
"latest": latest,
|
|
"sparkline_req": _sparkline([r.request_rate for r in history]),
|
|
"sparkline_p95": _sparkline([r.latency_p95_ms for r in history]),
|
|
"sparkline_5xx": _sparkline([r.error_rate_5xx_pct for r in history]),
|
|
})
|
|
|
|
# Latest global stat (always most recent, not range-filtered)
|
|
result = await db.execute(
|
|
select(TraefikGlobalStat)
|
|
.order_by(TraefikGlobalStat.scraped_at.desc())
|
|
.limit(1)
|
|
)
|
|
global_stat = result.scalar_one_or_none()
|
|
|
|
# All certs, sorted by soonest expiry
|
|
result = await db.execute(
|
|
select(TraefikCert).order_by(TraefikCert.not_after)
|
|
)
|
|
raw_certs = result.scalars().all()
|
|
|
|
certs = []
|
|
for c in raw_certs:
|
|
days = (c.not_after - now).total_seconds() / 86400.0
|
|
certs.append({
|
|
"serial": c.serial,
|
|
"cn": c.cn or c.serial,
|
|
"sans": c.sans,
|
|
"not_after": c.not_after,
|
|
"days_remaining": days,
|
|
})
|
|
|
|
return await render_template(
|
|
"traefik/rows.html",
|
|
router_data=router_data,
|
|
global_stat=global_stat,
|
|
certs=certs,
|
|
range_key=range_key,
|
|
)
|
|
|
|
|
|
@traefik_bp.get("/widget")
|
|
@require_role(UserRole.viewer)
|
|
async def widget():
|
|
"""HTMX dashboard widget fragment."""
|
|
now = datetime.now(timezone.utc)
|
|
|
|
async with current_app.db_sessionmaker() as db:
|
|
result = await db.execute(
|
|
select(TraefikMetric.router_name).distinct().order_by(TraefikMetric.router_name)
|
|
)
|
|
routers = [row[0] for row in result.all()]
|
|
router_data = []
|
|
for router in routers:
|
|
result = await db.execute(
|
|
select(TraefikMetric)
|
|
.where(TraefikMetric.router_name == router)
|
|
.order_by(TraefikMetric.scraped_at.desc())
|
|
.limit(1)
|
|
)
|
|
latest = result.scalar_one_or_none()
|
|
if latest:
|
|
router_data.append({"name": router, "latest": latest})
|
|
|
|
router_data.sort(key=lambda r: (
|
|
-(1 if r["latest"].error_rate_5xx_pct > 0.1 else 0),
|
|
-(1 if r["latest"].error_rate_4xx_pct > 5.0 else 0),
|
|
-r["latest"].request_rate,
|
|
))
|
|
total_routers = len(router_data)
|
|
router_data = router_data[:5]
|
|
|
|
result = await db.execute(
|
|
select(TraefikCert).order_by(TraefikCert.not_after)
|
|
)
|
|
raw_certs = result.scalars().all()
|
|
|
|
expiring_certs = []
|
|
for c in raw_certs:
|
|
days = (c.not_after - now).total_seconds() / 86400.0
|
|
if days < 30:
|
|
expiring_certs.append({"cn": c.cn or c.serial, "days_remaining": days})
|
|
|
|
return await render_template(
|
|
"traefik/widget.html",
|
|
router_data=router_data,
|
|
expiring_certs=expiring_certs,
|
|
total_routers=total_routers,
|
|
)
|
|
|
|
|
|
@traefik_bp.get("/widget/access_log")
|
|
@require_role(UserRole.viewer)
|
|
async def widget_access_log():
|
|
"""HTMX dashboard widget: traffic origin summary with IP filter."""
|
|
from collections import defaultdict
|
|
ip_filter = request.args.get("ip_filter", "all")
|
|
limit = max(1, min(50, 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(TraefikAccessSummary)
|
|
.order_by(TraefikAccessSummary.period_end.desc())
|
|
.limit(12) # last ~1 hour of 5-min windows
|
|
)
|
|
rows = result.scalars().all()
|
|
|
|
if not rows:
|
|
return await render_template("traefik/widget_access_log.html",
|
|
summary=None, widget_id=widget_id)
|
|
|
|
total = sum(r.total_requests for r in rows)
|
|
internal = sum(r.internal_requests for r in rows)
|
|
external = sum(r.external_requests for r in rows)
|
|
|
|
ip_counts: dict = defaultdict(lambda: {"count": 0, "internal": True})
|
|
for row in rows:
|
|
for entry in json.loads(row.top_ips_json):
|
|
k = entry["ip"]
|
|
ip_counts[k]["count"] += entry["count"]
|
|
ip_counts[k]["internal"] = entry.get("internal", True)
|
|
|
|
all_ips = sorted(
|
|
[{"ip": k, **v} for k, v in ip_counts.items()],
|
|
key=lambda x: x["count"], reverse=True,
|
|
)
|
|
if ip_filter == "internal":
|
|
all_ips = [e for e in all_ips if e["internal"]]
|
|
elif ip_filter == "external":
|
|
all_ips = [e for e in all_ips if not e["internal"]]
|
|
|
|
summary = {
|
|
"total": total,
|
|
"internal": internal,
|
|
"external": external,
|
|
"external_pct": (external / total * 100.0) if total else 0.0,
|
|
"top_ips": all_ips[:limit],
|
|
"ip_filter": ip_filter,
|
|
}
|
|
return await render_template("traefik/widget_access_log.html",
|
|
summary=summary, widget_id=widget_id)
|
|
|
|
|
|
@traefik_bp.get("/widget/request_chart")
|
|
@require_role(UserRole.viewer)
|
|
async def widget_request_chart():
|
|
"""HTMX dashboard widget: Chart.js request rate + error % over time."""
|
|
from datetime import timedelta
|
|
hours = max(1, min(24, int(request.args.get("hours", 6) or 6)))
|
|
widget_id = request.args.get("wid", "0")
|
|
|
|
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
|
b_secs = max(60, (hours * 3600) // 80) # ~80 buckets
|
|
|
|
bucket_col = (
|
|
cast(func.extract('epoch', TraefikMetric.scraped_at), Integer) / b_secs
|
|
).label("bucket")
|
|
|
|
async with current_app.db_sessionmaker() as db:
|
|
result = await db.execute(
|
|
select(
|
|
func.avg(TraefikMetric.request_rate).label("req_rate"),
|
|
func.avg(TraefikMetric.error_rate_5xx_pct).label("err_pct"),
|
|
func.min(TraefikMetric.scraped_at).label("scraped_at"),
|
|
bucket_col,
|
|
)
|
|
.where(TraefikMetric.scraped_at >= since)
|
|
.group_by(bucket_col)
|
|
.order_by(bucket_col)
|
|
)
|
|
history = result.all()
|
|
|
|
labels = list(range(len(history)))
|
|
req_rates = [round(r.req_rate or 0, 3) for r in history]
|
|
err_pcts = [round(r.err_pct or 0, 2) for r in history]
|
|
|
|
return await render_template(
|
|
"traefik/widget_request_chart.html",
|
|
labels_json=json.dumps(labels),
|
|
req_rate_json=json.dumps(req_rates),
|
|
error_rate_json=json.dumps(err_pcts),
|
|
widget_id=widget_id,
|
|
hours=hours,
|
|
)
|
|
|
|
|
|
@traefik_bp.get("/traffic")
|
|
@require_role(UserRole.viewer)
|
|
async def traffic():
|
|
"""HTMX fragment: traffic origin summary scoped to selected time range."""
|
|
since, range_key = parse_range(request.args.get("range"))
|
|
|
|
async with current_app.db_sessionmaker() as db:
|
|
result = await db.execute(
|
|
select(TraefikAccessSummary)
|
|
.where(TraefikAccessSummary.period_end >= since)
|
|
.order_by(TraefikAccessSummary.period_end.asc())
|
|
# No LIMIT — all summaries in range needed for accurate totals;
|
|
# sparkline subsamples to 80 points afterwards
|
|
)
|
|
rows = result.scalars().all()
|
|
|
|
if not rows:
|
|
return await render_template(
|
|
"traefik/traffic.html", summary=None, history=[], range_key=range_key
|
|
)
|
|
|
|
total = sum(r.total_requests for r in rows)
|
|
internal = sum(r.internal_requests for r in rows)
|
|
external = sum(r.external_requests for r in rows)
|
|
total_bytes = sum(r.total_bytes for r in rows)
|
|
|
|
ip_counts: dict = defaultdict(lambda: {"count": 0, "internal": True, "country": None})
|
|
country_counts: dict[str, int] = defaultdict(int)
|
|
router_counts: dict[str, int] = defaultdict(int)
|
|
|
|
for row in rows:
|
|
for entry in json.loads(row.top_ips_json):
|
|
k = entry["ip"]
|
|
ip_counts[k]["count"] += entry["count"]
|
|
ip_counts[k]["internal"] = entry["internal"]
|
|
ip_counts[k]["country"] = entry.get("country")
|
|
for entry in json.loads(row.top_countries_json):
|
|
country_counts[entry["country"]] += entry["count"]
|
|
for entry in json.loads(row.top_routers_json):
|
|
router_counts[entry["router"]] += entry["count"]
|
|
|
|
top_ips = sorted(
|
|
[{"ip": k, **v} for k, v in ip_counts.items()],
|
|
key=lambda x: x["count"], reverse=True
|
|
)[:10]
|
|
top_countries = sorted(
|
|
[{"country": k, "count": v} for k, v in country_counts.items()],
|
|
key=lambda x: x["count"], reverse=True
|
|
)[:10]
|
|
top_routers = sorted(
|
|
[{"router": k, "count": v} for k, v in router_counts.items()],
|
|
key=lambda x: x["count"], reverse=True
|
|
)[:10]
|
|
|
|
history = [
|
|
{
|
|
"period_end": r.period_end,
|
|
"external_pct": (r.external_requests / r.total_requests * 100.0)
|
|
if r.total_requests else 0.0,
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
summary = {
|
|
"total": total,
|
|
"internal": internal,
|
|
"external": external,
|
|
"total_bytes": total_bytes,
|
|
"external_pct": (external / total * 100.0) if total else 0.0,
|
|
"top_ips": top_ips,
|
|
"top_countries": top_countries,
|
|
"top_routers": top_routers,
|
|
"sparkline_external": _sparkline(
|
|
[h["external_pct"] for h in subsample(history, 80)]
|
|
),
|
|
}
|
|
|
|
return await render_template(
|
|
"traefik/traffic.html", summary=summary, history=history, range_key=range_key
|
|
)
|