be4654fc72
- Add HTTP monitor plugin: endpoint checking with status history, latency tracking, time-range views - Add SNMP plugin: OID polling, device management, metric recording - Add Docker plugin: container status, resource usage, widget - Traefik: access log widget, request chart widget, expanded routes - UniFi: clients widget, devices widget, expanded poll routes - UPS: history widget, additional routes - Update plugin index with new entries Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
358 lines
12 KiB
Python
358 lines
12 KiB
Python
# 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,
|
|
)
|
|
|
|
|
|
@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,
|
|
)
|