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:
+303
@@ -0,0 +1,303 @@
|
||||
# plugins/unifi/routes.py
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from quart import Blueprint, current_app, render_template, request
|
||||
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, subsample
|
||||
from .models import (
|
||||
UnifiAlarm, UnifiClientSnapshot, UnifiDevice, UnifiDpiSnapshot,
|
||||
UnifiEvent, UnifiFwRule, UnifiKnownClient, UnifiNetwork,
|
||||
UnifiPortForward, UnifiSpeedtestResult, UnifiWanStat, UnifiWlanStat,
|
||||
)
|
||||
|
||||
unifi_bp = Blueprint("unifi", __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>'
|
||||
)
|
||||
|
||||
|
||||
@unifi_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(
|
||||
"unifi/index.html",
|
||||
poll_interval=poll_interval,
|
||||
current_range=current_range,
|
||||
)
|
||||
|
||||
|
||||
@unifi_bp.get("/rows")
|
||||
@require_role(UserRole.viewer)
|
||||
async def rows():
|
||||
"""HTMX fragment: WAN stats, devices, clients, WLAN — scoped to selected range."""
|
||||
since, range_key = parse_range(request.args.get("range"))
|
||||
|
||||
b_secs = bucket_seconds(since)
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
# WAN history — bucketed for accurate full-range sparklines
|
||||
wan_bucket = (
|
||||
cast(func.strftime('%s', UnifiWanStat.scraped_at), Integer) / b_secs
|
||||
).label("bucket")
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.avg(UnifiWanStat.latency_ms).label("latency_ms"),
|
||||
func.avg(UnifiWanStat.rx_bytes_rate).label("rx_bytes_rate"),
|
||||
func.avg(UnifiWanStat.tx_bytes_rate).label("tx_bytes_rate"),
|
||||
func.min(UnifiWanStat.scraped_at).label("scraped_at"),
|
||||
wan_bucket,
|
||||
)
|
||||
.where(UnifiWanStat.scraped_at >= since)
|
||||
.group_by(wan_bucket)
|
||||
.order_by(wan_bucket)
|
||||
)
|
||||
wan_history = result.all()
|
||||
|
||||
# Latest WAN — always most recent raw row for current displayed values
|
||||
result = await db.execute(
|
||||
select(UnifiWanStat).order_by(UnifiWanStat.scraped_at.desc()).limit(1)
|
||||
)
|
||||
latest_wan = result.scalar_one_or_none()
|
||||
|
||||
# Latest client snapshot (always most recent)
|
||||
result = await db.execute(
|
||||
select(UnifiClientSnapshot)
|
||||
.order_by(UnifiClientSnapshot.scraped_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
latest_clients = result.scalar_one_or_none()
|
||||
|
||||
# All devices
|
||||
result = await db.execute(
|
||||
select(UnifiDevice).order_by(UnifiDevice.state.desc(), UnifiDevice.name)
|
||||
)
|
||||
devices = result.scalars().all()
|
||||
|
||||
# Client count history — bucketed
|
||||
cs_bucket = (
|
||||
cast(func.strftime('%s', UnifiClientSnapshot.scraped_at), Integer) / b_secs
|
||||
).label("bucket")
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.avg(UnifiClientSnapshot.total_clients).label("total_clients"),
|
||||
func.min(UnifiClientSnapshot.scraped_at).label("scraped_at"),
|
||||
cs_bucket,
|
||||
)
|
||||
.where(UnifiClientSnapshot.scraped_at >= since)
|
||||
.group_by(cs_bucket)
|
||||
.order_by(cs_bucket)
|
||||
)
|
||||
client_history = result.all()
|
||||
|
||||
# Latest WLAN stats (always most recent per SSID)
|
||||
result = await db.execute(
|
||||
select(UnifiWlanStat.ssid).distinct().order_by(UnifiWlanStat.ssid)
|
||||
)
|
||||
ssids = [row[0] for row in result.all()]
|
||||
wlan_latest: list[UnifiWlanStat] = []
|
||||
for ssid in ssids:
|
||||
result = await db.execute(
|
||||
select(UnifiWlanStat)
|
||||
.where(UnifiWlanStat.ssid == ssid)
|
||||
.order_by(UnifiWlanStat.scraped_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
w = result.scalar_one_or_none()
|
||||
if w:
|
||||
wlan_latest.append(w)
|
||||
|
||||
# Latest speedtest
|
||||
result = await db.execute(
|
||||
select(UnifiSpeedtestResult)
|
||||
.order_by(UnifiSpeedtestResult.run_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
speedtest = result.scalar_one_or_none()
|
||||
|
||||
top_clients = json.loads(latest_clients.top_clients_json) if latest_clients else []
|
||||
|
||||
sparkline_latency = _sparkline([r.latency_ms or 0 for r in wan_history])
|
||||
sparkline_rx = _sparkline([r.rx_bytes_rate or 0 for r in wan_history])
|
||||
sparkline_tx = _sparkline([r.tx_bytes_rate or 0 for r in wan_history])
|
||||
sparkline_clients = _sparkline([r.total_clients for r in client_history])
|
||||
|
||||
return await render_template(
|
||||
"unifi/rows.html",
|
||||
latest_wan=latest_wan,
|
||||
latest_clients=latest_clients,
|
||||
devices=devices,
|
||||
top_clients=top_clients,
|
||||
wlan_latest=wlan_latest,
|
||||
speedtest=speedtest,
|
||||
sparkline_latency=sparkline_latency,
|
||||
sparkline_rx=sparkline_rx,
|
||||
sparkline_tx=sparkline_tx,
|
||||
sparkline_clients=sparkline_clients,
|
||||
range_key=range_key,
|
||||
)
|
||||
|
||||
|
||||
@unifi_bp.get("/events")
|
||||
@require_role(UserRole.viewer)
|
||||
async def events():
|
||||
"""HTMX fragment: site events within selected range."""
|
||||
since, range_key = parse_range(request.args.get("range"))
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(
|
||||
select(UnifiEvent)
|
||||
.where(UnifiEvent.event_time >= since)
|
||||
.order_by(UnifiEvent.event_time.desc())
|
||||
.limit(500)
|
||||
)
|
||||
event_rows = result.scalars().all()
|
||||
|
||||
return await render_template(
|
||||
"unifi/events.html", events=event_rows, range_key=range_key
|
||||
)
|
||||
|
||||
|
||||
@unifi_bp.get("/dpi")
|
||||
@require_role(UserRole.viewer)
|
||||
async def dpi():
|
||||
"""HTMX fragment: DPI traffic breakdown (latest snapshot)."""
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(
|
||||
select(UnifiDpiSnapshot)
|
||||
.order_by(UnifiDpiSnapshot.scraped_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
snapshot = result.scalar_one_or_none()
|
||||
|
||||
categories = []
|
||||
top_clients = []
|
||||
if snapshot:
|
||||
categories = json.loads(snapshot.by_category_json)
|
||||
top_clients = json.loads(snapshot.by_client_json)
|
||||
|
||||
return await render_template(
|
||||
"unifi/dpi.html", categories=categories, top_clients=top_clients, snapshot=snapshot
|
||||
)
|
||||
|
||||
|
||||
@unifi_bp.get("/security")
|
||||
@require_role(UserRole.viewer)
|
||||
async def security():
|
||||
"""HTMX fragment: active and archived alarms."""
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(
|
||||
select(UnifiAlarm)
|
||||
.where(UnifiAlarm.archived == False) # noqa: E712
|
||||
.order_by(UnifiAlarm.alarm_time.desc())
|
||||
)
|
||||
active_alarms = result.scalars().all()
|
||||
|
||||
result = await db.execute(
|
||||
select(UnifiAlarm)
|
||||
.where(UnifiAlarm.archived == True) # noqa: E712
|
||||
.order_by(UnifiAlarm.alarm_time.desc())
|
||||
.limit(20)
|
||||
)
|
||||
archived_alarms = result.scalars().all()
|
||||
|
||||
return await render_template(
|
||||
"unifi/security.html",
|
||||
active_alarms=active_alarms,
|
||||
archived_alarms=archived_alarms,
|
||||
)
|
||||
|
||||
|
||||
@unifi_bp.get("/inventory")
|
||||
@require_role(UserRole.viewer)
|
||||
async def inventory():
|
||||
"""HTMX fragment: known clients, networks, port forwards, firewall rules."""
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(
|
||||
select(UnifiKnownClient)
|
||||
.order_by(UnifiKnownClient.last_seen.desc().nullslast())
|
||||
)
|
||||
known_clients = result.scalars().all()
|
||||
|
||||
result = await db.execute(
|
||||
select(UnifiNetwork).order_by(UnifiNetwork.name)
|
||||
)
|
||||
networks = result.scalars().all()
|
||||
|
||||
result = await db.execute(
|
||||
select(UnifiPortForward).order_by(UnifiPortForward.name)
|
||||
)
|
||||
port_forwards = result.scalars().all()
|
||||
|
||||
result = await db.execute(
|
||||
select(UnifiFwRule)
|
||||
.order_by(UnifiFwRule.ruleset, UnifiFwRule.rule_index)
|
||||
)
|
||||
fw_rules = result.scalars().all()
|
||||
|
||||
return await render_template(
|
||||
"unifi/inventory.html",
|
||||
known_clients=known_clients,
|
||||
networks=networks,
|
||||
port_forwards=port_forwards,
|
||||
fw_rules=fw_rules,
|
||||
)
|
||||
|
||||
|
||||
@unifi_bp.get("/widget")
|
||||
@require_role(UserRole.viewer)
|
||||
async def widget():
|
||||
"""HTMX dashboard widget fragment."""
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(
|
||||
select(UnifiWanStat).order_by(UnifiWanStat.scraped_at.desc()).limit(1)
|
||||
)
|
||||
latest_wan = result.scalar_one_or_none()
|
||||
|
||||
result = await db.execute(
|
||||
select(UnifiClientSnapshot).order_by(UnifiClientSnapshot.scraped_at.desc()).limit(1)
|
||||
)
|
||||
latest_clients = result.scalar_one_or_none()
|
||||
|
||||
result = await db.execute(select(UnifiDevice))
|
||||
devices = result.scalars().all()
|
||||
|
||||
result = await db.execute(
|
||||
select(UnifiAlarm).where(UnifiAlarm.archived == False) # noqa: E712
|
||||
)
|
||||
active_alarms = result.scalars().all()
|
||||
|
||||
offline_devices = [d for d in devices if d.state != 1]
|
||||
top_clients = json.loads(latest_clients.top_clients_json)[:5] if latest_clients else []
|
||||
|
||||
return await render_template(
|
||||
"unifi/widget.html",
|
||||
latest_wan=latest_wan,
|
||||
latest_clients=latest_clients,
|
||||
offline_devices=offline_devices,
|
||||
active_alarms=active_alarms,
|
||||
top_clients=top_clients,
|
||||
)
|
||||
Reference in New Issue
Block a user