feat: initial release — traefik, unifi, ups plugins
First-party plugins for Fabled Scryer (https://github.com/bvandeusen/fabledscryer). traefik: Prometheus metrics scraping, stats history, access log parsing (GeoIP optional) unifi: UniFi controller integration — WAN health, devices, clients, DPI, events ups: UPS monitoring via NUT (Network UPS Tools), Ansible shutdown automation Each plugin follows the Fabled Scryer plugin contract: setup(app), get_scheduled_tasks(), get_blueprint() index.yaml lists all three plugins in the catalog format. Populate checksum_sha256 entries once release zips are published via GitHub Releases. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
# 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.strftime('%s', 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("/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
|
||||
)
|
||||
Reference in New Issue
Block a user