af60ca446d
The fabledscryer->steward rename had only ever reached host_agent. The other five bundled plugins (http, snmp, traefik, unifi, docker) still imported `from fabledscryer.*` (package no longer exists) and read FABLEDSCRYER_* env vars — so every one of them was broken at import since the original rebrand. CI stayed green only because none are enabled by default and migrations don't import plugin modules. Now that they version in-tree, complete the rename: - fabledscryer.* -> steward.* imports across all five plugins - FABLEDSCRYER_* -> STEWARD_* in plugin migration env.py files - author/repository/homepage + user-facing 'Fabled Scryer' strings -> Steward - snmp/scheduler.py: also drop dead `now`/datetime; record_metric from steward Adds tests/test_no_legacy_names.py — fails if 'scryer'/'roundtable' ever reappear in shipped code (the drift bit twice; this stops a third time). Also clears pre-existing ruff lint debt (unused imports, semicolon statements, mid-file import) surfaced by the new lint lane. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
357 lines
12 KiB
Python
357 lines
12 KiB
Python
# plugins/unifi/routes.py
|
|
from __future__ import annotations
|
|
import json
|
|
from quart import Blueprint, current_app, render_template, request
|
|
from sqlalchemy import Integer, cast, func, select
|
|
|
|
from steward.auth.middleware import require_role
|
|
from steward.models.users import UserRole
|
|
from steward.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds
|
|
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,
|
|
)
|
|
|
|
|
|
@unifi_bp.get("/widget/clients")
|
|
@require_role(UserRole.viewer)
|
|
async def widget_clients():
|
|
"""HTMX dashboard widget: top active clients."""
|
|
limit = max(1, min(20, int(request.args.get("limit", 5) or 5)))
|
|
widget_id = request.args.get("wid", "0")
|
|
|
|
async with current_app.db_sessionmaker() as db:
|
|
result = await db.execute(
|
|
select(UnifiClientSnapshot)
|
|
.order_by(UnifiClientSnapshot.scraped_at.desc())
|
|
.limit(1)
|
|
)
|
|
snapshot = result.scalar_one_or_none()
|
|
|
|
clients = json.loads(snapshot.top_clients_json)[:limit] if snapshot else []
|
|
return await render_template(
|
|
"unifi/widget_clients.html",
|
|
clients=clients,
|
|
snapshot=snapshot,
|
|
widget_id=widget_id,
|
|
)
|
|
|
|
|
|
@unifi_bp.get("/widget/devices")
|
|
@require_role(UserRole.viewer)
|
|
async def widget_devices():
|
|
"""HTMX dashboard widget: infrastructure devices with optional type filter."""
|
|
type_filter = request.args.get("type_filter", "all")
|
|
widget_id = request.args.get("wid", "0")
|
|
|
|
async with current_app.db_sessionmaker() as db:
|
|
result = await db.execute(
|
|
select(UnifiDevice).order_by(UnifiDevice.state.desc(), UnifiDevice.name)
|
|
)
|
|
all_devices = result.scalars().all()
|
|
|
|
if type_filter == "wireless":
|
|
devices = [d for d in all_devices if d.device_type == "uap"]
|
|
elif type_filter == "wired":
|
|
devices = [d for d in all_devices if d.device_type != "uap"]
|
|
elif type_filter == "offline":
|
|
devices = [d for d in all_devices if d.state != 1]
|
|
else:
|
|
devices = list(all_devices)
|
|
|
|
return await render_template(
|
|
"unifi/widget_devices.html",
|
|
devices=devices,
|
|
type_filter=type_filter,
|
|
widget_id=widget_id,
|
|
)
|