feat(plugins): fold first-party plugins in-tree; bundled + external roots

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>
This commit is contained in:
2026-06-01 08:37:24 -04:00
parent 88ab5b917e
commit a7a281cb11
99 changed files with 7931 additions and 52 deletions
+28
View File
@@ -0,0 +1,28 @@
# plugins/unifi/__init__.py
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from quart import Quart
_app: "Quart | None" = None
def setup(app: "Quart") -> None:
global _app
_app = app
from .models import ( # noqa: F401
UnifiWanStat, UnifiClientSnapshot, UnifiWlanStat, UnifiDpiSnapshot,
UnifiDevice, UnifiKnownClient, UnifiEvent, UnifiAlarm,
UnifiSpeedtestResult, UnifiNetwork, UnifiPortForward, UnifiFwRule,
)
def get_scheduled_tasks() -> list:
from .scheduler import make_poll_task
return [make_poll_task(_app)]
def get_blueprint():
from .routes import unifi_bp
return unifi_bp
+170
View File
@@ -0,0 +1,170 @@
# plugins/unifi/client.py
"""Async UniFi Network controller API client.
Supports UDM/UDM-Pro (firmware 2+) using the /proxy/network/ path prefix
and Bearer-token auth via the /api/auth/login endpoint.
On 401, re-authenticates automatically once before raising.
"""
from __future__ import annotations
import logging
import httpx
logger = logging.getLogger(__name__)
class UnifiClient:
def __init__(
self,
host: str,
username: str,
password: str,
site: str = "default",
verify_ssl: bool = False,
) -> None:
self._host = host.rstrip("/")
self._username = username
self._password = password
self._site = site
self._verify_ssl = verify_ssl
self._http: httpx.AsyncClient | None = None
self._csrf_token: str = ""
# ── Lifecycle ─────────────────────────────────────────────────────────────
async def _ensure_http(self) -> None:
if self._http is None:
self._http = httpx.AsyncClient(
verify=self._verify_ssl,
timeout=15.0,
follow_redirects=True,
)
async def login(self) -> None:
await self._ensure_http()
resp = await self._http.post(
f"{self._host}/api/auth/login",
json={"username": self._username, "password": self._password, "rememberMe": False},
)
resp.raise_for_status()
self._csrf_token = resp.headers.get("X-CSRF-Token", "")
logger.debug("UniFi login OK (csrf=%s…)", self._csrf_token[:8] if self._csrf_token else "none")
async def close(self) -> None:
if self._http:
await self._http.aclose()
self._http = None
# ── API helpers ───────────────────────────────────────────────────────────
def _api_url(self, path: str) -> str:
return f"{self._host}/proxy/network/api/s/{self._site}{path}"
def _headers(self) -> dict:
return {"X-CSRF-Token": self._csrf_token} if self._csrf_token else {}
async def _get(self, path: str, params: dict | None = None) -> list | dict:
await self._ensure_http()
resp = await self._http.get(self._api_url(path), headers=self._headers(), params=params)
if resp.status_code == 401:
await self.login()
resp = await self._http.get(self._api_url(path), headers=self._headers(), params=params)
resp.raise_for_status()
data = resp.json()
return data.get("data", data)
# ── Core monitoring ───────────────────────────────────────────────────────
async def get_health(self) -> list[dict]:
"""Subsystem health (wan, wlan, lan, www)."""
result = await self._get("/stat/health")
return result if isinstance(result, list) else []
async def get_active_clients(self) -> list[dict]:
"""Currently connected clients."""
result = await self._get("/stat/sta")
return result if isinstance(result, list) else []
async def get_devices(self) -> list[dict]:
"""Managed network devices (APs, switches, gateways) with full detail."""
result = await self._get("/stat/device")
return result if isinstance(result, list) else []
# ── Events & alarms ───────────────────────────────────────────────────────
async def get_events(self, limit: int = 200) -> list[dict]:
"""Recent site events (client connects, IDS alerts, device state changes, etc.)."""
result = await self._get("/stat/event", params={"_limit": limit, "_sort": "-time"})
return result if isinstance(result, list) else []
async def get_alarms(self, archived: bool = False) -> list[dict]:
"""Active (or archived) alarms."""
result = await self._get("/stat/alarm")
if not isinstance(result, list):
return []
return [a for a in result if a.get("archived", False) == archived]
# ── Traffic & DPI ─────────────────────────────────────────────────────────
async def get_dpi_stats(self) -> list[dict]:
"""DPI per-client application/category traffic stats (cumulative counters)."""
result = await self._get("/stat/dpi")
return result if isinstance(result, list) else []
async def get_dpi_site_stats(self) -> list[dict]:
"""DPI site-level category totals (not per-client)."""
result = await self._get("/dpi")
return result if isinstance(result, list) else []
async def get_speedtest_status(self) -> dict | None:
"""Most recent WAN speed test result."""
result = await self._get("/stat/speedtest-status")
if isinstance(result, list) and result:
return result[0]
if isinstance(result, dict):
return result
return None
# ── Network inventory ─────────────────────────────────────────────────────
async def get_known_clients(self) -> list[dict]:
"""All historically seen clients (not just currently connected)."""
result = await self._get("/rest/user")
return result if isinstance(result, list) else []
async def get_networks(self) -> list[dict]:
"""Network/VLAN configurations."""
result = await self._get("/rest/networkconf")
return result if isinstance(result, list) else []
async def get_wlan_configs(self) -> list[dict]:
"""WLAN (SSID) configurations."""
result = await self._get("/rest/wlanconf")
return result if isinstance(result, list) else []
async def get_port_forwards(self) -> list[dict]:
"""Port forwarding rules."""
result = await self._get("/rest/portforwarding")
return result if isinstance(result, list) else []
async def get_firewall_rules(self) -> list[dict]:
"""Firewall rules."""
result = await self._get("/rest/firewallrule")
return result if isinstance(result, list) else []
async def get_firewall_groups(self) -> list[dict]:
"""Firewall groups (IP sets / port sets referenced by rules)."""
result = await self._get("/rest/firewallgroup")
return result if isinstance(result, list) else []
# ── System ────────────────────────────────────────────────────────────────
async def get_sysinfo(self) -> dict | None:
"""Controller system information (version, uptime, hostname)."""
result = await self._get("/stat/sysinfo")
if isinstance(result, list) and result:
return result[0]
if isinstance(result, dict):
return result
return None
+72
View File
@@ -0,0 +1,72 @@
# plugins/unifi/migrations/env.py
"""Alembic env.py for the UniFi plugin (standalone dev use).
At app startup, migration_runner.py uses the core env.py with version_locations.
"""
import asyncio
import os
import sys
from pathlib import Path
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from fabledscryer.models.base import Base
import fabledscryer.models # noqa: F401
from plugins.unifi.models import UnifiWanStat, UnifiClientSnapshot, UnifiDevice # noqa: F401
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def _get_url() -> str:
import yaml
cfg_path = os.environ.get("FABLEDSCRYER_CONFIG", "config.yaml")
try:
with open(cfg_path) as f:
cfg = yaml.safe_load(f) or {}
url = cfg.get("database", {}).get("url", "")
except FileNotFoundError:
url = ""
return os.environ.get("FABLEDSCRYER_DATABASE__URL", url)
def run_migrations_offline() -> None:
context.configure(
url=_get_url(), target_metadata=target_metadata,
literal_binds=True, dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
cfg = config.get_section(config.config_ini_section, {})
cfg["sqlalchemy.url"] = _get_url()
connectable = async_engine_from_config(cfg, prefix="sqlalchemy.", poolclass=pool.NullPool)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -0,0 +1,62 @@
"""UniFi plugin initial tables
Revision ID: unifi_001_initial
Revises: (none — branch off core via depends_on)
Create Date: 2026-03-20
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "unifi_001_initial"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = "unifi"
depends_on: Union[str, Sequence[str], None] = ("0004_app_settings",)
def upgrade() -> None:
op.create_table(
"unifi_wan_stats",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("is_up", sa.Boolean, nullable=False, server_default="false"),
sa.Column("wan_ip", sa.String(64), nullable=True),
sa.Column("latency_ms", sa.Float, nullable=True),
sa.Column("rx_bytes_rate", sa.Float, nullable=True),
sa.Column("tx_bytes_rate", sa.Float, nullable=True),
sa.Column("uptime_seconds", sa.Integer, nullable=True),
)
op.create_index("ix_unifi_wan_stats_scraped", "unifi_wan_stats", ["scraped_at"])
op.create_table(
"unifi_client_snapshots",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("total_clients", sa.Integer, nullable=False, server_default="0"),
sa.Column("wireless_clients", sa.Integer, nullable=False, server_default="0"),
sa.Column("wired_clients", sa.Integer, nullable=False, server_default="0"),
sa.Column("guest_clients", sa.Integer, nullable=False, server_default="0"),
sa.Column("top_clients_json", sa.Text, nullable=False, server_default="[]"),
)
op.create_index("ix_unifi_client_snapshots_scraped", "unifi_client_snapshots", ["scraped_at"])
op.create_table(
"unifi_devices",
sa.Column("mac", sa.String(32), primary_key=True),
sa.Column("name", sa.String(255), nullable=True),
sa.Column("model", sa.String(64), nullable=True),
sa.Column("device_type", sa.String(16), nullable=True),
sa.Column("ip", sa.String(64), nullable=True),
sa.Column("state", sa.Integer, nullable=False, server_default="0"),
sa.Column("uptime_seconds", sa.Integer, nullable=True),
sa.Column("last_seen", sa.DateTime(timezone=True), nullable=True),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
)
def downgrade() -> None:
op.drop_table("unifi_devices")
op.drop_index("ix_unifi_client_snapshots_scraped", "unifi_client_snapshots")
op.drop_table("unifi_client_snapshots")
op.drop_index("ix_unifi_wan_stats_scraped", "unifi_wan_stats")
op.drop_table("unifi_wan_stats")
@@ -0,0 +1,156 @@
"""UniFi expanded tables: wlan stats, dpi, events, alarms, known clients,
speedtest results, networks, port forwards, firewall rules.
Also adds missing 'version' column to unifi_devices.
Revision ID: unifi_002_expanded
Revises: unifi_001_initial
Create Date: 2026-03-20
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "unifi_002_expanded"
down_revision: Union[str, None] = "unifi_001_initial"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ── Patch unifi_devices to add missing version column ─────────────────────
op.add_column("unifi_devices", sa.Column("version", sa.String(64), nullable=True))
# ── WLAN stats (time-series per SSID+radio) ───────────────────────────────
op.create_table(
"unifi_wlan_stats",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ssid", sa.String(255), nullable=False),
sa.Column("radio", sa.String(8), nullable=True),
sa.Column("num_sta", sa.Integer, nullable=False, server_default="0"),
sa.Column("tx_bytes", sa.Float, nullable=True),
sa.Column("rx_bytes", sa.Float, nullable=True),
sa.Column("channel", sa.Integer, nullable=True),
sa.Column("satisfaction", sa.Integer, nullable=True),
)
op.create_index("ix_unifi_wlan_stats_scraped", "unifi_wlan_stats", ["scraped_at"])
# ── DPI snapshots (site-level category totals) ────────────────────────────
op.create_table(
"unifi_dpi_snapshots",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("by_category_json", sa.Text, nullable=False, server_default="[]"),
sa.Column("by_client_json", sa.Text, nullable=False, server_default="[]"),
)
op.create_index("ix_unifi_dpi_scraped", "unifi_dpi_snapshots", ["scraped_at"])
# ── Known clients (upserted by MAC) ───────────────────────────────────────
op.create_table(
"unifi_known_clients",
sa.Column("mac", sa.String(32), primary_key=True),
sa.Column("hostname", sa.String(255), nullable=True),
sa.Column("name", sa.String(255), nullable=True),
sa.Column("ip", sa.String(64), nullable=True),
sa.Column("mac_vendor", sa.String(128), nullable=True),
sa.Column("note", sa.Text, nullable=True),
sa.Column("is_blocked", sa.Boolean, nullable=False, server_default="false"),
sa.Column("first_seen", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_seen", sa.DateTime(timezone=True), nullable=True),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
)
# ── Events (upserted by UniFi event ID) ───────────────────────────────────
op.create_table(
"unifi_events",
sa.Column("unifi_id", sa.String(64), primary_key=True),
sa.Column("event_time", sa.DateTime(timezone=True), nullable=False),
sa.Column("event_key", sa.String(64), nullable=False),
sa.Column("category", sa.String(16), nullable=False),
sa.Column("message", sa.Text, nullable=False),
sa.Column("source_mac", sa.String(32), nullable=True),
sa.Column("source_ip", sa.String(64), nullable=True),
sa.Column("details_json", sa.Text, nullable=False, server_default="{}"),
)
op.create_index("ix_unifi_events_time", "unifi_events", ["event_time"])
# ── Alarms (upserted by UniFi alarm ID) ───────────────────────────────────
op.create_table(
"unifi_alarms",
sa.Column("unifi_id", sa.String(64), primary_key=True),
sa.Column("alarm_time", sa.DateTime(timezone=True), nullable=False),
sa.Column("alarm_key", sa.String(64), nullable=False),
sa.Column("message", sa.Text, nullable=False),
sa.Column("archived", sa.Boolean, nullable=False, server_default="false"),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_unifi_alarms_time", "unifi_alarms", ["alarm_time"])
# ── Speedtest results (upserted by run timestamp) ─────────────────────────
op.create_table(
"unifi_speedtest_results",
sa.Column("run_at", sa.DateTime(timezone=True), primary_key=True),
sa.Column("download_mbps", sa.Float, nullable=True),
sa.Column("upload_mbps", sa.Float, nullable=True),
sa.Column("latency_ms", sa.Float, nullable=True),
sa.Column("server_name", sa.String(255), nullable=True),
)
# ── Networks/VLANs (upserted by UniFi ID) ────────────────────────────────
op.create_table(
"unifi_networks",
sa.Column("unifi_id", sa.String(64), primary_key=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("purpose", sa.String(32), nullable=True),
sa.Column("vlan", sa.Integer, nullable=True),
sa.Column("ip_subnet", sa.String(64), nullable=True),
sa.Column("dhcp_enabled", sa.Boolean, nullable=False, server_default="false"),
sa.Column("is_guest", sa.Boolean, nullable=False, server_default="false"),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
)
# ── Port forwards (upserted by UniFi ID) ─────────────────────────────────
op.create_table(
"unifi_port_forwards",
sa.Column("unifi_id", sa.String(64), primary_key=True),
sa.Column("name", sa.String(255), nullable=True),
sa.Column("proto", sa.String(8), nullable=False, server_default="tcp"),
sa.Column("dst_port", sa.String(32), nullable=False),
sa.Column("fwd_ip", sa.String(64), nullable=False),
sa.Column("fwd_port", sa.String(32), nullable=False),
sa.Column("src_filter", sa.String(255), nullable=True),
sa.Column("enabled", sa.Boolean, nullable=False, server_default="true"),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
)
# ── Firewall rules (upserted by UniFi ID) ────────────────────────────────
op.create_table(
"unifi_fw_rules",
sa.Column("unifi_id", sa.String(64), primary_key=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("ruleset", sa.String(16), nullable=False),
sa.Column("rule_index", sa.Integer, nullable=True),
sa.Column("action", sa.String(16), nullable=False),
sa.Column("protocol", sa.String(16), nullable=True),
sa.Column("enabled", sa.Boolean, nullable=False, server_default="true"),
sa.Column("src_address", sa.String(255), nullable=True),
sa.Column("dst_address", sa.String(255), nullable=True),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
)
def downgrade() -> None:
op.drop_table("unifi_fw_rules")
op.drop_table("unifi_port_forwards")
op.drop_table("unifi_networks")
op.drop_table("unifi_speedtest_results")
op.drop_index("ix_unifi_alarms_time", "unifi_alarms")
op.drop_table("unifi_alarms")
op.drop_index("ix_unifi_events_time", "unifi_events")
op.drop_table("unifi_events")
op.drop_table("unifi_known_clients")
op.drop_index("ix_unifi_dpi_scraped", "unifi_dpi_snapshots")
op.drop_table("unifi_dpi_snapshots")
op.drop_index("ix_unifi_wlan_stats_scraped", "unifi_wlan_stats")
op.drop_table("unifi_wlan_stats")
op.drop_column("unifi_devices", "version")
+182
View File
@@ -0,0 +1,182 @@
# plugins/unifi/models.py
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledscryer.models.base import Base
# ── Time-series (one row per scrape) ──────────────────────────────────────────
class UnifiWanStat(Base):
"""WAN interface health — one row per scrape."""
__tablename__ = "unifi_wan_stats"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
is_up: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
wan_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
latency_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
rx_bytes_rate: Mapped[float | None] = mapped_column(Float, nullable=True)
tx_bytes_rate: Mapped[float | None] = mapped_column(Float, nullable=True)
uptime_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
class UnifiClientSnapshot(Base):
"""Aggregated client counts + top talkers — one row per scrape."""
__tablename__ = "unifi_client_snapshots"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
total_clients: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
wireless_clients: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
wired_clients: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
guest_clients: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
top_clients_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
# JSON: [{"mac","hostname","ip","type","tx_rate","rx_rate","signal","essid"}]
class UnifiWlanStat(Base):
"""Per-SSID/radio stats — one row per scrape per SSID+radio combination."""
__tablename__ = "unifi_wlan_stats"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
ssid: Mapped[str] = mapped_column(String(255), nullable=False)
radio: Mapped[str | None] = mapped_column(String(8), nullable=True) # ng=2.4GHz, na=5GHz, 6e=6GHz
num_sta: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
tx_bytes: Mapped[float | None] = mapped_column(Float, nullable=True)
rx_bytes: Mapped[float | None] = mapped_column(Float, nullable=True)
channel: Mapped[int | None] = mapped_column(Integer, nullable=True)
satisfaction: Mapped[int | None] = mapped_column(Integer, nullable=True) # 0-100
class UnifiDpiSnapshot(Base):
"""DPI application/category traffic snapshot — one row per scrape (JSON blob)."""
__tablename__ = "unifi_dpi_snapshots"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
# JSON: [{"cat_id", "cat_name", "rx_bytes", "tx_bytes"}] aggregated across all clients
by_category_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
# JSON: [{"mac", "hostname", "top_cats": [{"cat_name", "bytes"}]}]
by_client_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
# ── Upserted inventory (keyed by UniFi ID or MAC) ─────────────────────────────
class UnifiDevice(Base):
"""Network infrastructure devices — upserted by MAC each scrape."""
__tablename__ = "unifi_devices"
mac: Mapped[str] = mapped_column(String(32), primary_key=True)
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
model: Mapped[str | None] = mapped_column(String(64), nullable=True)
device_type: Mapped[str | None] = mapped_column(String(16), nullable=True)
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
state: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
uptime_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
version: Mapped[str | None] = mapped_column(String(64), nullable=True)
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
class UnifiKnownClient(Base):
"""All historically seen network clients — upserted by MAC."""
__tablename__ = "unifi_known_clients"
mac: Mapped[str] = mapped_column(String(32), primary_key=True)
hostname: Mapped[str | None] = mapped_column(String(255), nullable=True)
name: Mapped[str | None] = mapped_column(String(255), nullable=True) # user-set alias
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
mac_vendor: Mapped[str | None] = mapped_column(String(128), nullable=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
is_blocked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
first_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
class UnifiEvent(Base):
"""Site event log — upserted by UniFi event ID to avoid duplicates."""
__tablename__ = "unifi_events"
unifi_id: Mapped[str] = mapped_column(String(64), primary_key=True)
event_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
event_key: Mapped[str] = mapped_column(String(64), nullable=False) # e.g. EVT_WU_Connected
category: Mapped[str] = mapped_column(String(16), nullable=False) # wlan, wired, ap, ids, wan, system
message: Mapped[str] = mapped_column(Text, nullable=False)
source_mac: Mapped[str | None] = mapped_column(String(32), nullable=True)
source_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
details_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
class UnifiAlarm(Base):
"""Active alarms — upserted by UniFi alarm ID."""
__tablename__ = "unifi_alarms"
unifi_id: Mapped[str] = mapped_column(String(64), primary_key=True)
alarm_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
alarm_key: Mapped[str] = mapped_column(String(64), nullable=False)
message: Mapped[str] = mapped_column(Text, nullable=False)
archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
class UnifiSpeedtestResult(Base):
"""WAN speed test results — upserted by run timestamp."""
__tablename__ = "unifi_speedtest_results"
run_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), primary_key=True)
download_mbps: Mapped[float | None] = mapped_column(Float, nullable=True)
upload_mbps: Mapped[float | None] = mapped_column(Float, nullable=True)
latency_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
server_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
# ── Config inventory (upserted by UniFi ID) ───────────────────────────────────
class UnifiNetwork(Base):
"""VLAN/network configurations — upserted by UniFi ID."""
__tablename__ = "unifi_networks"
unifi_id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
purpose: Mapped[str | None] = mapped_column(String(32), nullable=True) # corporate, guest, vlan-only
vlan: Mapped[int | None] = mapped_column(Integer, nullable=True)
ip_subnet: Mapped[str | None] = mapped_column(String(64), nullable=True)
dhcp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_guest: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
class UnifiPortForward(Base):
"""Port forwarding rules — upserted by UniFi ID."""
__tablename__ = "unifi_port_forwards"
unifi_id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
proto: Mapped[str] = mapped_column(String(8), nullable=False, default="tcp")
dst_port: Mapped[str] = mapped_column(String(32), nullable=False)
fwd_ip: Mapped[str] = mapped_column(String(64), nullable=False)
fwd_port: Mapped[str] = mapped_column(String(32), nullable=False)
src_filter: Mapped[str | None] = mapped_column(String(255), nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
class UnifiFwRule(Base):
"""Firewall rules — upserted by UniFi ID."""
__tablename__ = "unifi_fw_rules"
unifi_id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
ruleset: Mapped[str] = mapped_column(String(16), nullable=False) # WAN_IN, LAN_IN, etc.
rule_index: Mapped[int | None] = mapped_column(Integer, nullable=True)
action: Mapped[str] = mapped_column(String(16), nullable=False) # accept, drop, reject
protocol: Mapped[str | None] = mapped_column(String(16), nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
src_address: Mapped[str | None] = mapped_column(String(255), nullable=True)
dst_address: Mapped[str | None] = mapped_column(String(255), nullable=True)
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
+21
View File
@@ -0,0 +1,21 @@
name: unifi
version: "1.0.0"
description: "UniFi Network controller integration — WAN health, devices, clients, DPI"
author: "FabledScryer"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins"
homepage: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/src/branch/main/unifi"
tags:
- network
- unifi
- ubiquiti
config:
host: "https://192.168.1.1"
username: "admin"
password: ""
site: "default"
verify_ssl: false
poll_interval_seconds: 60
top_clients: 10 # number of top-talker clients to store per snapshot
+357
View File
@@ -0,0 +1,357 @@
# 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,
)
+522
View File
@@ -0,0 +1,522 @@
# plugins/unifi/scheduler.py
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from fabledscryer.core.scheduler import ScheduledTask
if TYPE_CHECKING:
from quart import Quart
logger = logging.getLogger(__name__)
_client = None # UnifiClient instance, initialised on first scrape
def make_poll_task(app: "Quart") -> ScheduledTask:
cfg = app.config["PLUGINS"]["unifi"]
interval = int(cfg.get("poll_interval_seconds", 60))
async def poll() -> None:
await _do_poll(app)
return ScheduledTask(
name="unifi_poll",
coro_factory=poll,
interval_seconds=interval,
run_on_startup=True,
)
async def _do_poll(app: "Quart") -> None:
global _client
from .client import UnifiClient
from .models import UnifiClientSnapshot, UnifiDevice, UnifiWanStat
from fabledscryer.core.alerts import record_metric
cfg = app.config["PLUGINS"]["unifi"]
if _client is None:
_client = UnifiClient(
host=cfg["host"],
username=cfg["username"],
password=cfg["password"],
site=cfg.get("site", "default"),
verify_ssl=cfg.get("verify_ssl", False),
)
try:
await _client.login()
except Exception as exc:
logger.warning("UniFi initial login failed: %s", exc)
_client = None
return
try:
health = await _client.get_health()
clients = await _client.get_active_clients()
devices = await _client.get_devices()
except Exception as exc:
logger.warning("UniFi poll failed — will retry next tick: %s", exc)
_client = None # force re-auth on next tick
return
scraped_at = datetime.now(timezone.utc)
top_n = int(cfg.get("top_clients", 10))
# ── WAN stat ──────────────────────────────────────────────────────────────
wan_data = next((h for h in health if h.get("subsystem") == "wan"), None)
wan_stat = UnifiWanStat(scraped_at=scraped_at)
if wan_data:
wan_stat.is_up = wan_data.get("status") == "ok"
wan_stat.wan_ip = wan_data.get("wan_ip")
wan_stat.latency_ms = wan_data.get("latency")
wan_stat.rx_bytes_rate = wan_data.get("rx_bytes-r")
wan_stat.tx_bytes_rate = wan_data.get("tx_bytes-r")
wan_stat.uptime_seconds = wan_data.get("uptime")
# ── Client snapshot ───────────────────────────────────────────────────────
wireless = [c for c in clients if not c.get("is_wired", True)]
wired = [c for c in clients if c.get("is_wired", True)]
guests = [c for c in clients if c.get("is_guest", False)]
top_clients = sorted(
clients,
key=lambda c: (c.get("tx_bytes-r", 0) or 0) + (c.get("rx_bytes-r", 0) or 0),
reverse=True,
)[:top_n]
top_clients_data = [
{
"mac": c.get("mac", ""),
"hostname": c.get("hostname") or c.get("name") or c.get("mac", ""),
"ip": c.get("ip", ""),
"type": "wireless" if not c.get("is_wired", True) else "wired",
"tx_rate": c.get("tx_bytes-r", 0) or 0,
"rx_rate": c.get("rx_bytes-r", 0) or 0,
"signal": c.get("signal"),
"essid": c.get("essid"),
}
for c in top_clients
]
client_snapshot = UnifiClientSnapshot(
scraped_at=scraped_at,
total_clients=len(clients),
wireless_clients=len(wireless),
wired_clients=len(wired),
guest_clients=len(guests),
top_clients_json=json.dumps(top_clients_data),
)
async with app.db_sessionmaker() as session:
async with session.begin():
session.add(wan_stat)
session.add(client_snapshot)
# ── Devices (upsert by MAC) ───────────────────────────────────────
for d in devices:
mac = d.get("mac", "")
if not mac:
continue
last_seen_ts = d.get("last_seen")
last_seen_dt = (
datetime.fromtimestamp(last_seen_ts, tz=timezone.utc)
if last_seen_ts else None
)
existing = await session.get(UnifiDevice, mac)
if existing:
existing.name = d.get("name")
existing.model = d.get("model")
existing.device_type = d.get("type")
existing.ip = d.get("ip")
existing.state = d.get("state", 0)
existing.uptime_seconds = d.get("uptime")
existing.last_seen = last_seen_dt
existing.version = d.get("version")
existing.scraped_at = scraped_at
else:
session.add(UnifiDevice(
mac=mac,
name=d.get("name"),
model=d.get("model"),
device_type=d.get("type"),
ip=d.get("ip"),
state=d.get("state", 0),
uptime_seconds=d.get("uptime"),
last_seen=last_seen_dt,
version=d.get("version"),
scraped_at=scraped_at,
))
# ── Alert metrics ─────────────────────────────────────────────────
if wan_stat.latency_ms is not None:
await record_metric(
session=session,
source_module="unifi",
resource_name="wan",
metric_name="latency_ms",
value=wan_stat.latency_ms,
)
await record_metric(
session=session,
source_module="unifi",
resource_name="wan",
metric_name="is_up",
value=1.0 if wan_stat.is_up else 0.0,
)
await record_metric(
session=session,
source_module="unifi",
resource_name="clients",
metric_name="total_clients",
value=float(len(clients)),
)
logger.debug(
"UniFi poll: wan=%s, clients=%d, devices=%d",
"up" if wan_stat.is_up else "down",
len(clients),
len(devices),
)
# ── Expanded data (best-effort, failures don't abort core poll) ───────────
try:
await _do_expanded(app, scraped_at)
except Exception as exc:
logger.warning("UniFi expanded poll failed: %s", exc)
async def _do_expanded(app: "Quart", scraped_at: datetime) -> None:
"""Fetch supplementary UniFi data: WLAN stats, events, alarms, DPI,
known clients, speedtest, networks, port forwards, firewall rules."""
from .models import (
UnifiAlarm, UnifiDpiSnapshot, UnifiEvent, UnifiFwRule,
UnifiKnownClient, UnifiNetwork, UnifiPortForward, UnifiSpeedtestResult,
UnifiWlanStat,
)
# ── Fetch from controller ─────────────────────────────────────────────────
try:
wlan_configs = await _client.get_wlan_configs()
except Exception:
wlan_configs = []
try:
events = await _client.get_events(limit=200)
except Exception:
events = []
try:
alarms = await _client.get_alarms(archived=False)
except Exception:
alarms = []
try:
dpi_cats = await _client.get_dpi_site_stats()
except Exception:
dpi_cats = []
try:
dpi_clients = await _client.get_dpi_stats()
except Exception:
dpi_clients = []
try:
known_clients = await _client.get_known_clients()
except Exception:
known_clients = []
try:
speedtest = await _client.get_speedtest_status()
except Exception:
speedtest = None
try:
networks = await _client.get_networks()
except Exception:
networks = []
try:
port_forwards = await _client.get_port_forwards()
except Exception:
port_forwards = []
try:
fw_rules = await _client.get_firewall_rules()
except Exception:
fw_rules = []
# ── Derive WLAN stats from device VAP table ───────────────────────────────
# WLAN usage stats live on the device objects' vap_table, not wlan configs.
# We get per-radio SSID data from the active clients grouped by essid.
# Store one row per SSID using wlan_configs for channel/satisfaction.
wlan_rows: list[UnifiWlanStat] = []
for wc in wlan_configs:
ssid = wc.get("name") or wc.get("x_passphrase", "")
if not ssid:
continue
wlan_rows.append(UnifiWlanStat(
scraped_at=scraped_at,
ssid=ssid,
radio=None,
num_sta=wc.get("num_sta", 0) or 0,
tx_bytes=wc.get("tx_bytes"),
rx_bytes=wc.get("rx_bytes"),
channel=None,
satisfaction=wc.get("satisfaction"),
))
# ── Build DPI category summary ────────────────────────────────────────────
cat_summary = []
for entry in dpi_cats:
cat_id = entry.get("cat") or entry.get("cat_id")
cat_name = entry.get("cat_name") or str(cat_id)
rx = entry.get("rx_bytes", 0) or 0
tx = entry.get("tx_bytes", 0) or 0
if rx or tx:
cat_summary.append({"cat_id": cat_id, "cat_name": cat_name, "rx_bytes": rx, "tx_bytes": tx})
cat_summary.sort(key=lambda x: x["rx_bytes"] + x["tx_bytes"], reverse=True)
# Top 10 clients by DPI total
client_dpi: dict[str, dict] = {}
for entry in dpi_clients:
mac = entry.get("mac", "")
if not mac:
continue
if mac not in client_dpi:
client_dpi[mac] = {"mac": mac, "hostname": entry.get("hostname", mac), "bytes": 0, "top_cats": []}
client_dpi[mac]["bytes"] += (entry.get("rx_bytes", 0) or 0) + (entry.get("tx_bytes", 0) or 0)
top_dpi_clients = sorted(client_dpi.values(), key=lambda x: x["bytes"], reverse=True)[:10]
# ── Write to DB ───────────────────────────────────────────────────────────
async with app.db_sessionmaker() as session:
async with session.begin():
# WLAN stats
for row in wlan_rows:
session.add(row)
# DPI snapshot (only if we got data)
if cat_summary or top_dpi_clients:
session.add(UnifiDpiSnapshot(
scraped_at=scraped_at,
by_category_json=json.dumps(cat_summary),
by_client_json=json.dumps(top_dpi_clients),
))
# Events (upsert by _id)
_category_map = {
"wlan": "wlan", "wireless": "wlan",
"wired": "wired",
"ugw": "wan", "wan": "wan",
"ids": "ids",
"system": "system",
"ap": "ap",
}
for ev in events:
uid = ev.get("_id", "")
if not uid:
continue
ts_raw = ev.get("datetime") or ev.get("time")
try:
if isinstance(ts_raw, (int, float)):
event_time = datetime.fromtimestamp(ts_raw, tz=timezone.utc)
else:
event_time = datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00"))
except Exception:
event_time = scraped_at
subsystem = ev.get("subsystem", "system")
category = _category_map.get(subsystem, subsystem[:16])
details = {k: v for k, v in ev.items()
if k not in ("_id", "datetime", "time", "key", "subsystem", "msg", "user", "src_ip")}
existing = await session.get(UnifiEvent, uid)
if not existing:
session.add(UnifiEvent(
unifi_id=uid,
event_time=event_time,
event_key=ev.get("key", ""),
category=category,
message=ev.get("msg", ""),
source_mac=ev.get("user") or ev.get("src_mac"),
source_ip=ev.get("src_ip"),
details_json=json.dumps(details),
))
# Alarms (upsert)
for al in alarms:
uid = al.get("_id", "")
if not uid:
continue
ts_raw = al.get("datetime") or al.get("time")
try:
if isinstance(ts_raw, (int, float)):
alarm_time = datetime.fromtimestamp(ts_raw, tz=timezone.utc)
else:
alarm_time = datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00"))
except Exception:
alarm_time = scraped_at
existing = await session.get(UnifiAlarm, uid)
if existing:
existing.archived = al.get("archived", False)
existing.scraped_at = scraped_at
else:
session.add(UnifiAlarm(
unifi_id=uid,
alarm_time=alarm_time,
alarm_key=al.get("key", ""),
message=al.get("msg", ""),
archived=al.get("archived", False),
scraped_at=scraped_at,
))
# Known clients (upsert by MAC)
for kc in known_clients:
mac = kc.get("mac", "")
if not mac:
continue
def _ts(val):
if not val:
return None
try:
if isinstance(val, (int, float)):
return datetime.fromtimestamp(val, tz=timezone.utc)
return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
except Exception:
return None
existing = await session.get(UnifiKnownClient, mac)
if existing:
existing.hostname = kc.get("hostname")
existing.name = kc.get("name")
existing.ip = kc.get("ip")
existing.mac_vendor = kc.get("oui")
existing.note = kc.get("note")
existing.is_blocked = kc.get("blocked", False)
existing.first_seen = _ts(kc.get("first_seen"))
existing.last_seen = _ts(kc.get("last_seen"))
existing.scraped_at = scraped_at
else:
session.add(UnifiKnownClient(
mac=mac,
hostname=kc.get("hostname"),
name=kc.get("name"),
ip=kc.get("ip"),
mac_vendor=kc.get("oui"),
note=kc.get("note"),
is_blocked=kc.get("blocked", False),
first_seen=_ts(kc.get("first_seen")),
last_seen=_ts(kc.get("last_seen")),
scraped_at=scraped_at,
))
# Speedtest (upsert by run_at)
if speedtest:
ts_raw = speedtest.get("rundate") or speedtest.get("time")
try:
if isinstance(ts_raw, (int, float)):
run_at = datetime.fromtimestamp(ts_raw, tz=timezone.utc)
else:
run_at = datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00"))
except Exception:
run_at = scraped_at
existing = await session.get(UnifiSpeedtestResult, run_at)
if not existing:
session.add(UnifiSpeedtestResult(
run_at=run_at,
download_mbps=speedtest.get("xput_download"),
upload_mbps=speedtest.get("xput_upload"),
latency_ms=speedtest.get("latency"),
server_name=speedtest.get("server_name"),
))
# Networks (upsert by UniFi ID)
for net in networks:
uid = net.get("_id", "")
if not uid:
continue
existing = await session.get(UnifiNetwork, uid)
if existing:
existing.name = net.get("name", "")
existing.purpose = net.get("purpose")
existing.vlan = net.get("vlan")
existing.ip_subnet = net.get("ip_subnet")
existing.dhcp_enabled = net.get("dhcpd_enabled", False)
existing.is_guest = net.get("is_guest", False)
existing.scraped_at = scraped_at
else:
session.add(UnifiNetwork(
unifi_id=uid,
name=net.get("name", ""),
purpose=net.get("purpose"),
vlan=net.get("vlan"),
ip_subnet=net.get("ip_subnet"),
dhcp_enabled=net.get("dhcpd_enabled", False),
is_guest=net.get("is_guest", False),
scraped_at=scraped_at,
))
# Port forwards (upsert by UniFi ID)
for pf in port_forwards:
uid = pf.get("_id", "")
if not uid:
continue
existing = await session.get(UnifiPortForward, uid)
if existing:
existing.name = pf.get("name")
existing.proto = pf.get("proto", "tcp")
existing.dst_port = pf.get("dst_port", "")
existing.fwd_ip = pf.get("fwd", "")
existing.fwd_port = pf.get("fwd_port", "")
existing.src_filter = pf.get("src")
existing.enabled = pf.get("enabled", True)
existing.scraped_at = scraped_at
else:
session.add(UnifiPortForward(
unifi_id=uid,
name=pf.get("name"),
proto=pf.get("proto", "tcp"),
dst_port=pf.get("dst_port", ""),
fwd_ip=pf.get("fwd", ""),
fwd_port=pf.get("fwd_port", ""),
src_filter=pf.get("src"),
enabled=pf.get("enabled", True),
scraped_at=scraped_at,
))
# Firewall rules (upsert by UniFi ID)
for rule in fw_rules:
uid = rule.get("_id", "")
if not uid:
continue
existing = await session.get(UnifiFwRule, uid)
if existing:
existing.name = rule.get("name", "")
existing.ruleset = rule.get("ruleset", "")
existing.rule_index = rule.get("rule_index")
existing.action = rule.get("action", "")
existing.protocol = rule.get("protocol")
existing.enabled = rule.get("enabled", True)
existing.src_address = rule.get("src_address")
existing.dst_address = rule.get("dst_address")
existing.scraped_at = scraped_at
else:
session.add(UnifiFwRule(
unifi_id=uid,
name=rule.get("name", ""),
ruleset=rule.get("ruleset", ""),
rule_index=rule.get("rule_index"),
action=rule.get("action", ""),
protocol=rule.get("protocol"),
enabled=rule.get("enabled", True),
src_address=rule.get("src_address"),
dst_address=rule.get("dst_address"),
scraped_at=scraped_at,
))
logger.debug(
"UniFi expanded: wlans=%d, events=%d, alarms=%d, known_clients=%d, "
"networks=%d, port_fwds=%d, fw_rules=%d",
len(wlan_rows), len(events), len(alarms), len(known_clients),
len(networks), len(port_forwards), len(fw_rules),
)
+65
View File
@@ -0,0 +1,65 @@
{# plugins/unifi/templates/unifi/dpi.html #}
{% if not snapshot %}
<div class="card">
<p style="color:var(--text-muted);">No DPI data yet. DPI must be enabled on the UniFi controller.</p>
</div>
{% else %}
<div style="column-width:340px;column-count:3;column-gap:1rem;">
{# Category breakdown #}
{% if categories %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">
Traffic by Category
<span style="float:right;font-weight:normal;font-size:0.78rem;color:var(--text-dim);">{{ snapshot.scraped_at.strftime("%H:%M") }}</span>
</div>
{% set total_bytes = categories | sum(attribute='rx_bytes') + categories | sum(attribute='tx_bytes') %}
<table class="table" style="width:100%;font-size:0.82rem;">
{% for cat in categories[:20] %}
{% set bytes = cat.rx_bytes + cat.tx_bytes %}
{% set pct = (bytes / total_bytes * 100) if total_bytes else 0 %}
<tr>
<td style="padding:0.25rem 0.5rem 0.25rem 0;">{{ cat.cat_name or cat.cat_id }}</td>
<td style="padding:0.25rem 0.3rem;text-align:right;color:var(--text-muted);font-size:0.78rem;white-space:nowrap;">
{% if bytes >= 1073741824 %}{{ "%.1f"|format(bytes / 1073741824) }} GB
{% elif bytes >= 1048576 %}{{ "%.1f"|format(bytes / 1048576) }} MB
{% elif bytes >= 1024 %}{{ "%.0f"|format(bytes / 1024) }} KB
{% else %}{{ bytes }} B{% endif %}
</td>
<td style="padding:0.25rem 0;width:4rem;">
<div style="background:var(--border);border-radius:2px;height:6px;overflow:hidden;">
<div style="background:var(--accent);height:100%;width:{{ "%.1f"|format(pct) }}%;"></div>
</div>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{# Top clients by DPI #}
{% if top_clients %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Top Clients by Traffic</div>
<table class="table" style="width:100%;font-size:0.82rem;">
{% for c in top_clients %}
<tr>
<td style="padding:0.25rem 0.5rem 0.25rem 0;word-break:break-all;">
{{ c.hostname or c.mac }}
<span style="color:var(--text-dim);font-size:0.75rem;font-family:monospace;margin-left:0.3rem;">{{ c.mac }}</span>
</td>
<td style="padding:0.25rem 0;text-align:right;font-size:0.78rem;color:var(--text-muted);white-space:nowrap;">
{% set bytes = c.bytes %}
{% if bytes >= 1073741824 %}{{ "%.1f"|format(bytes / 1073741824) }} GB
{% elif bytes >= 1048576 %}{{ "%.1f"|format(bytes / 1048576) }} MB
{% elif bytes >= 1024 %}{{ "%.0f"|format(bytes / 1024) }} KB
{% else %}{{ bytes }} B{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
</div>
{% endif %}
+52
View File
@@ -0,0 +1,52 @@
{# plugins/unifi/templates/unifi/events.html #}
{% if not events %}
<div class="card">
<p style="color:var(--text-muted);">No events in the last {{ range_key }}.</p>
</div>
{% else %}
<div class="card">
<div class="section-title" style="margin-bottom:0.75rem;">
Site Events
<span style="float:right;font-weight:normal;font-size:0.8rem;color:var(--text-dim);">{{ events|length }} in {{ range_key }}</span>
</div>
<table class="table" style="width:100%;font-size:0.82rem;">
<colgroup>
<col style="width:12rem;">
<col style="width:5rem;">
<col style="width:5rem;">
<col>
</colgroup>
<tr style="color:var(--text-muted);font-size:0.78rem;">
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.4rem 0;">Time</th>
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.4rem;">Category</th>
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.4rem;">Key</th>
<th style="text-align:left;font-weight:normal;padding:0 0 0.4rem;">Message</th>
</tr>
{% for ev in events %}
<tr style="{% if loop.index is divisibleby 2 %}background:var(--row-alt,rgba(255,255,255,0.02));{% endif %}">
<td style="padding:0.25rem 0.5rem 0.25rem 0;color:var(--text-dim);font-size:0.77rem;white-space:nowrap;">
{{ ev.event_time.strftime("%Y-%m-%d %H:%M:%S") }}
</td>
<td style="padding:0.25rem 0.5rem;">
<span style="font-size:0.75rem;padding:0.1rem 0.4rem;border-radius:3px;background:
{% if ev.category == 'wlan' %}rgba(96,96,192,0.25)
{% elif ev.category == 'wan' %}rgba(96,160,96,0.25)
{% elif ev.category == 'ids' %}rgba(192,64,64,0.25)
{% else %}rgba(128,128,128,0.15){% endif %};">
{{ ev.category }}
</span>
</td>
<td style="padding:0.25rem 0.5rem;color:var(--text-dim);font-family:monospace;font-size:0.75rem;">
{{ ev.event_key.replace("EVT_", "") if ev.event_key else "" }}
</td>
<td style="padding:0.25rem 0;color:var(--text);">
{{ ev.message }}
{% if ev.source_mac %}
<span style="color:var(--text-dim);font-size:0.75rem;font-family:monospace;margin-left:0.4rem;">{{ ev.source_mac }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
+63
View File
@@ -0,0 +1,63 @@
{# plugins/unifi/templates/unifi/index.html #}
{% extends "base.html" %}
{% block title %}UniFi — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.75rem;margin-bottom:1.25rem;">
<div style="display:flex;align-items:baseline;gap:1rem;">
<h1 class="page-title" style="margin-bottom:0;">UniFi Network</h1>
<span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span>
</div>
{% include "_time_range.html" %}
</div>
{# Tab nav #}
<div style="display:flex;gap:0.25rem;margin-bottom:1.25rem;border-bottom:1px solid var(--border);padding-bottom:0;">
{% set tabs = [
("overview", "Overview", "/plugins/unifi/rows"),
("events", "Events", "/plugins/unifi/events"),
("dpi", "Traffic", "/plugins/unifi/dpi"),
("security", "Security", "/plugins/unifi/security"),
("inventory", "Inventory", "/plugins/unifi/inventory"),
] %}
{% for tab_id, label, url in tabs %}
<button
class="tab-btn{% if loop.first %} tab-active{% endif %}"
data-tab="{{ tab_id }}"
data-url="{{ url }}"
onclick="setUnifiTab(this)"
style="background:none;border:none;border-bottom:2px solid {% if loop.first %}var(--accent){% else %}transparent{% endif %};padding:0.4rem 0.9rem;cursor:pointer;font-size:0.88rem;color:{% if loop.first %}var(--text){% else %}var(--text-muted){% endif %};margin-bottom:-1px;">
{{ label }}
</button>
{% endfor %}
</div>
<div id="unifi-content"
hx-get="/plugins/unifi/rows"
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
hx-include="#time-range"
hx-swap="innerHTML">
</div>
<script>
function setUnifiTab(btn) {
document.querySelectorAll('.tab-btn').forEach(function(b) {
b.style.borderBottomColor = 'transparent';
b.style.color = 'var(--text-muted)';
});
btn.style.borderBottomColor = 'var(--accent)';
btn.style.color = 'var(--text)';
var el = document.getElementById('unifi-content');
el.setAttribute('hx-get', btn.dataset.url);
// Remove auto-poll on non-overview tabs (inventory/security don't change rapidly)
var isLive = btn.dataset.tab === 'overview' || btn.dataset.tab === 'events';
el.setAttribute('hx-trigger',
isLive
? 'tabActivated, every {{ poll_interval }}s, rangeChange from:body'
: 'tabActivated, rangeChange from:body'
);
htmx.process(el);
htmx.trigger(el, 'tabActivated');
}
</script>
{% endblock %}
@@ -0,0 +1,146 @@
{# plugins/unifi/templates/unifi/inventory.html #}
<div style="column-width:360px;column-count:3;column-gap:1rem;">
{# Networks / VLANs #}
{% if networks %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Networks / VLANs</div>
<table class="table" style="width:100%;font-size:0.82rem;">
<tr style="color:var(--text-muted);font-size:0.78rem;">
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.3rem 0;">Name</th>
<th style="text-align:right;font-weight:normal;padding:0 0.5rem 0.3rem;">VLAN</th>
<th style="text-align:left;font-weight:normal;padding:0 0 0.3rem;">Subnet</th>
</tr>
{% for net in networks %}
<tr>
<td style="padding:0.25rem 0.5rem 0.25rem 0;">
{{ net.name }}
{% if net.is_guest %}<span style="font-size:0.72rem;color:var(--text-dim);"> guest</span>{% endif %}
</td>
<td style="padding:0.25rem 0.5rem;text-align:right;color:var(--text-muted);font-family:monospace;font-size:0.78rem;">
{{ net.vlan or "—" }}
</td>
<td style="padding:0.25rem 0;color:var(--text-dim);font-family:monospace;font-size:0.78rem;">
{{ net.ip_subnet or "—" }}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{# Port forwards #}
{% if port_forwards %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Port Forwards</div>
<table class="table" style="width:100%;font-size:0.82rem;">
{% for pf in port_forwards %}
<tr>
<td style="padding:0.25rem 0.5rem 0.25rem 0;">
<span style="{% if not pf.enabled %}color:var(--text-dim);{% endif %}">
{{ pf.name or "—" }}
</span>
{% if not pf.enabled %}<span style="font-size:0.72rem;color:var(--text-dim);"> disabled</span>{% endif %}
</td>
<td style="padding:0.25rem 0;font-family:monospace;font-size:0.78rem;color:var(--text-muted);white-space:nowrap;">
{{ pf.proto }}:{{ pf.dst_port }} → {{ pf.fwd_ip }}:{{ pf.fwd_port }}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{# Firewall rules, grouped by ruleset #}
{% if fw_rules %}
{% set ns = namespace(current_rs=None) %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Firewall Rules</div>
<table class="table" style="width:100%;font-size:0.82rem;">
{% for rule in fw_rules %}
{% if rule.ruleset != ns.current_rs %}
{% set ns.current_rs = rule.ruleset %}
<tr>
<td colspan="3" style="padding:0.4rem 0 0.2rem;color:var(--text-muted);font-size:0.75rem;font-weight:600;letter-spacing:0.04em;text-transform:uppercase;">
{{ rule.ruleset }}
</td>
</tr>
{% endif %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;">
<span style="{% if not rule.enabled %}color:var(--text-dim);{% endif %}">
{{ rule.name }}
</span>
</td>
<td style="padding:0.2rem 0.3rem;white-space:nowrap;">
<span style="font-size:0.75rem;padding:0.1rem 0.35rem;border-radius:3px;background:
{% if rule.action == 'accept' %}rgba(64,160,64,0.2)
{% elif rule.action == 'drop' %}rgba(192,64,64,0.2)
{% else %}rgba(192,128,64,0.2){% endif %};">
{{ rule.action }}
</span>
</td>
<td style="padding:0.2rem 0;color:var(--text-dim);font-size:0.76rem;">
{% if rule.protocol %}{{ rule.protocol }}{% endif %}
{% if rule.src_address %} src:{{ rule.src_address }}{% endif %}
{% if rule.dst_address %} dst:{{ rule.dst_address }}{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{# Known clients #}
{% if known_clients %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">
Known Clients
<span style="float:right;font-weight:normal;font-size:0.78rem;color:var(--text-dim);">{{ known_clients|length }} total</span>
</div>
<table class="table" style="width:100%;font-size:0.82rem;">
<tr style="color:var(--text-muted);font-size:0.78rem;">
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.3rem 0;">Name / MAC</th>
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.3rem;">IP</th>
<th style="text-align:right;font-weight:normal;padding:0 0 0.3rem;">Last Seen</th>
</tr>
{% for kc in known_clients[:50] %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;">
<div style="{% if kc.is_blocked %}color:var(--red);{% endif %}">
{{ kc.name or kc.hostname or kc.mac }}
{% if kc.is_blocked %}<span style="font-size:0.72rem;"> blocked</span>{% endif %}
</div>
{% if kc.name or kc.hostname %}
<div style="color:var(--text-dim);font-size:0.74rem;font-family:monospace;">{{ kc.mac }}</div>
{% endif %}
{% if kc.mac_vendor %}
<div style="color:var(--text-dim);font-size:0.73rem;">{{ kc.mac_vendor }}</div>
{% endif %}
</td>
<td style="padding:0.2rem 0.3rem;color:var(--text-muted);font-family:monospace;font-size:0.78rem;">
{{ kc.ip or "—" }}
</td>
<td style="padding:0.2rem 0;text-align:right;color:var(--text-dim);font-size:0.75rem;white-space:nowrap;">
{% if kc.last_seen %}{{ kc.last_seen.strftime("%m-%d %H:%M") }}{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
{% if known_clients|length > 50 %}
<tr>
<td colspan="3" style="padding:0.4rem 0;color:var(--text-dim);font-size:0.78rem;text-align:center;">
+{{ known_clients|length - 50 }} more
</td>
</tr>
{% endif %}
</table>
</div>
{% endif %}
{% if not networks and not port_forwards and not fw_rules and not known_clients %}
<div class="card">
<p style="color:var(--text-muted);">No inventory data yet. This populates on the next poll cycle.</p>
</div>
{% endif %}
</div>
+203
View File
@@ -0,0 +1,203 @@
{# plugins/unifi/templates/unifi/rows.html #}
{# ── WAN health bar ───────────────────────────────────────────────────────── #}
{% if latest_wan %}
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;margin-bottom:1.25rem;padding:0.75rem 1rem;background:var(--card-bg);border:1px solid var(--border);border-radius:6px;font-size:0.85rem;font-variant-numeric:tabular-nums;align-items:center;">
<span>
<span style="color:var(--text-muted);">WAN</span>
<span style="margin-left:0.4rem;font-weight:600;color:{% if latest_wan.is_up %}var(--green){% else %}var(--red){% endif %};">
{% if latest_wan.is_up %}&#10003; Up{% else %}&#10007; Down{% endif %}
</span>
{% if latest_wan.wan_ip %}
<span style="color:var(--text-dim);margin-left:0.4rem;font-family:monospace;font-size:0.8rem;">{{ latest_wan.wan_ip }}</span>
{% endif %}
</span>
{% if latest_wan.latency_ms is not none %}
<span>
<span style="color:var(--text-muted);">Latency</span>
<span style="margin-left:0.4rem;
{% if latest_wan.latency_ms > 100 %}color:var(--red){% elif latest_wan.latency_ms > 50 %}color:var(--yellow){% else %}color:var(--green){% endif %};">
{{ "%.1f"|format(latest_wan.latency_ms) }}ms
</span>
<span style="margin-left:0.3rem;">{{ sparkline_latency | safe }}</span>
</span>
{% endif %}
{% if latest_wan.rx_bytes_rate is not none %}
<span>
<span style="color:var(--text-muted);">&#8595; RX</span>
<span style="margin-left:0.3rem;">
{% set bps = latest_wan.rx_bytes_rate %}
{% if bps >= 1048576 %}{{ "%.1f"|format(bps / 1048576) }} MB/s
{% elif bps >= 1024 %}{{ "%.0f"|format(bps / 1024) }} KB/s
{% else %}{{ "%.0f"|format(bps) }} B/s{% endif %}
</span>
<span style="margin-left:0.3rem;">{{ sparkline_rx | safe }}</span>
</span>
<span>
<span style="color:var(--text-muted);">&#8593; TX</span>
<span style="margin-left:0.3rem;">
{% set bps = latest_wan.tx_bytes_rate %}
{% if bps >= 1048576 %}{{ "%.1f"|format(bps / 1048576) }} MB/s
{% elif bps >= 1024 %}{{ "%.0f"|format(bps / 1024) }} KB/s
{% else %}{{ "%.0f"|format(bps) }} B/s{% endif %}
</span>
<span style="margin-left:0.3rem;">{{ sparkline_tx | safe }}</span>
</span>
{% endif %}
{% if latest_wan.uptime_seconds is not none %}
<span style="color:var(--text-dim);font-size:0.8rem;">
up {{ (latest_wan.uptime_seconds // 3600) }}h {{ ((latest_wan.uptime_seconds % 3600) // 60) }}m
</span>
{% endif %}
</div>
{% endif %}
{# ── Content columns ──────────────────────────────────────────────────────── #}
<div style="column-width:340px;column-count:3;column-gap:1rem;">
{# Client summary card #}
{% if latest_clients %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">
Clients
<span style="float:right;font-weight:normal;color:var(--text-muted);font-size:0.82rem;">{{ sparkline_clients | safe }}</span>
</div>
<div style="display:flex;gap:1.5rem;font-size:0.9rem;margin-bottom:0.75rem;font-variant-numeric:tabular-nums;">
<span>
<span style="font-size:1.4rem;font-weight:700;color:var(--text);">{{ latest_clients.total_clients }}</span>
<span style="color:var(--text-muted);font-size:0.8rem;margin-left:0.2rem;">total</span>
</span>
<span style="color:var(--text-muted);font-size:0.85rem;align-self:flex-end;">
{{ latest_clients.wireless_clients }}&#x2197; wireless &nbsp;
{{ latest_clients.wired_clients }}&#x2194; wired
{% if latest_clients.guest_clients %}&nbsp; {{ latest_clients.guest_clients }} guest{% endif %}
</span>
</div>
</div>
{% endif %}
{# Infrastructure devices card #}
{% if devices %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Infrastructure Devices</div>
<table class="table" style="font-size:0.83rem;width:100%;">
{% for d in devices %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;">
<span style="color:{% if d.state == 1 %}var(--green){% else %}var(--red){% endif %};">&#9679;</span>
<span style="margin-left:0.3rem;">{{ d.name or d.mac }}</span>
</td>
<td style="padding:0.2rem 0.3rem;color:var(--text-muted);font-size:0.78rem;">{{ d.model or d.device_type or "" }}</td>
<td style="text-align:right;padding:0.2rem 0;color:var(--text-dim);font-size:0.78rem;font-family:monospace;">{{ d.ip or "" }}</td>
</tr>
{% if d.state == 1 and d.uptime_seconds %}
<tr>
<td colspan="3" style="padding:0 0 0.3rem 1.2rem;color:var(--text-dim);font-size:0.75rem;">
up {{ (d.uptime_seconds // 86400) }}d {{ ((d.uptime_seconds % 86400) // 3600) }}h
</td>
</tr>
{% endif %}
{% endfor %}
</table>
</div>
{% endif %}
{# Top talkers card #}
{% if top_clients %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Top Talkers</div>
<table class="table" style="font-size:0.82rem;width:100%;">
{% for c in top_clients %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;word-break:break-all;">
{{ c.hostname }}
{% if c.type == "wireless" and c.signal %}
<span style="color:var(--text-dim);font-size:0.75rem;"> {{ c.signal }}dBm</span>
{% endif %}
</td>
<td style="text-align:right;padding:0.2rem 0;white-space:nowrap;font-size:0.78rem;">
{% set rx = c.rx_rate %}{% set tx = c.tx_rate %}
<span style="color:var(--text-muted);">&#8595;</span>
{% if rx >= 1048576 %}{{ "%.1f"|format(rx/1048576) }}M
{% elif rx >= 1024 %}{{ "%.0f"|format(rx/1024) }}K
{% else %}{{ "%.0f"|format(rx) }}B{% endif %}
<span style="color:var(--text-muted);margin-left:0.3rem;">&#8593;</span>
{% if tx >= 1048576 %}{{ "%.1f"|format(tx/1048576) }}M
{% elif tx >= 1024 %}{{ "%.0f"|format(tx/1024) }}K
{% else %}{{ "%.0f"|format(tx) }}B{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{# WLAN SSIDs card #}
{% if wlan_latest %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Wireless Networks</div>
<table class="table" style="font-size:0.83rem;width:100%;">
<tr style="color:var(--text-muted);font-size:0.78rem;">
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.3rem 0;">SSID</th>
<th style="text-align:right;font-weight:normal;padding:0 0.3rem 0.3rem;">Clients</th>
<th style="text-align:right;font-weight:normal;padding:0 0 0.3rem;">Score</th>
</tr>
{% for w in wlan_latest %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;">{{ w.ssid }}</td>
<td style="text-align:right;padding:0.2rem 0.3rem;">{{ w.num_sta }}</td>
<td style="text-align:right;padding:0.2rem 0;
{% if w.satisfaction is not none %}
{% if w.satisfaction >= 80 %}color:var(--green){% elif w.satisfaction >= 60 %}color:var(--yellow){% else %}color:var(--red){% endif %}
{% endif %};">
{% if w.satisfaction is not none %}{{ w.satisfaction }}%{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{# Speedtest card #}
{% if speedtest %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Last Speed Test</div>
<div style="display:flex;gap:1.5rem;font-size:0.88rem;font-variant-numeric:tabular-nums;">
{% if speedtest.download_mbps is not none %}
<span>
<span style="color:var(--text-muted);font-size:0.8rem;">&#8595; DL</span>
<span style="font-weight:600;margin-left:0.3rem;">{{ "%.1f"|format(speedtest.download_mbps) }}</span>
<span style="color:var(--text-muted);font-size:0.78rem;">Mbps</span>
</span>
{% endif %}
{% if speedtest.upload_mbps is not none %}
<span>
<span style="color:var(--text-muted);font-size:0.8rem;">&#8593; UL</span>
<span style="font-weight:600;margin-left:0.3rem;">{{ "%.1f"|format(speedtest.upload_mbps) }}</span>
<span style="color:var(--text-muted);font-size:0.78rem;">Mbps</span>
</span>
{% endif %}
{% if speedtest.latency_ms is not none %}
<span>
<span style="color:var(--text-muted);font-size:0.8rem;">Latency</span>
<span style="margin-left:0.3rem;">{{ "%.0f"|format(speedtest.latency_ms) }}ms</span>
</span>
{% endif %}
</div>
{% if speedtest.server_name %}
<div style="margin-top:0.4rem;color:var(--text-dim);font-size:0.78rem;">via {{ speedtest.server_name }}</div>
{% endif %}
<div style="margin-top:0.25rem;color:var(--text-dim);font-size:0.75rem;">{{ speedtest.run_at.strftime("%Y-%m-%d %H:%M") }} UTC</div>
</div>
{% endif %}
</div>
{% if not latest_wan and not devices %}
<div class="card">
<p style="color:var(--text-muted);">No UniFi data yet. Check your <code>host</code>, <code>username</code>, and <code>password</code> in the plugin config.</p>
</div>
{% endif %}
@@ -0,0 +1,56 @@
{# plugins/unifi/templates/unifi/security.html #}
<div style="column-width:340px;column-count:2;column-gap:1rem;">
{# Active alarms #}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">
Active Alarms
{% if active_alarms %}
<span style="float:right;background:var(--red);color:#fff;font-size:0.72rem;padding:0.1rem 0.45rem;border-radius:10px;font-weight:600;">{{ active_alarms|length }}</span>
{% endif %}
</div>
{% if not active_alarms %}
<p style="color:var(--green);font-size:0.88rem;">&#10003; No active alarms</p>
{% else %}
<table class="table" style="width:100%;font-size:0.82rem;">
{% for al in active_alarms %}
<tr>
<td style="padding:0.3rem 0.5rem 0.3rem 0;">
<div style="color:var(--red);font-size:0.78rem;margin-bottom:0.1rem;">
&#9888; {{ al.alarm_key }}
</div>
<div>{{ al.message }}</div>
<div style="color:var(--text-dim);font-size:0.75rem;margin-top:0.1rem;">
{{ al.alarm_time.strftime("%Y-%m-%d %H:%M") }} UTC
</div>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
</div>
{# Recent archived alarms #}
{% if archived_alarms %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">
Recent Archived Alarms
<span style="float:right;font-weight:normal;font-size:0.78rem;color:var(--text-dim);">last {{ archived_alarms|length }}</span>
</div>
<table class="table" style="width:100%;font-size:0.82rem;">
{% for al in archived_alarms %}
<tr>
<td style="padding:0.25rem 0.5rem 0.25rem 0;">
<div style="color:var(--text-muted);font-size:0.78rem;">{{ al.alarm_key }}</div>
<div style="color:var(--text-dim);font-size:0.8rem;">{{ al.message }}</div>
</td>
<td style="padding:0.25rem 0;text-align:right;color:var(--text-dim);font-size:0.75rem;white-space:nowrap;">
{{ al.alarm_time.strftime("%m-%d %H:%M") }}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
</div>
+95
View File
@@ -0,0 +1,95 @@
{# plugins/unifi/templates/unifi/widget.html #}
{# Active alarms #}
{% for al in active_alarms %}
<div style="display:flex;justify-content:space-between;font-size:0.8rem;color:var(--red);padding:0.15rem 0;">
<span>&#9888; {{ al.alarm_key }}</span>
<span style="color:var(--text-dim);font-size:0.75rem;">alarm</span>
</div>
{% endfor %}
{# Offline device alerts #}
{% for d in offline_devices %}
<div style="display:flex;justify-content:space-between;font-size:0.8rem;color:var(--red);padding:0.15rem 0;">
<span>&#9888; {{ d.name or d.mac }}</span>
<span>offline</span>
</div>
{% endfor %}
{% if active_alarms or offline_devices %}<div style="height:0.4rem;"></div>{% endif %}
{% if latest_wan %}
<div class="ping-row">
<div class="ping-meta">
<span class="ping-name" style="font-size:0.8rem;">WAN</span>
</div>
<div style="flex:1;display:flex;gap:1.5rem;font-size:0.82rem;font-variant-numeric:tabular-nums;">
<span style="color:{% if latest_wan.is_up %}var(--green){% else %}var(--red){% endif %};">
{% if latest_wan.is_up %}Up{% else %}Down{% endif %}
</span>
{% if latest_wan.latency_ms is not none %}
<span>
<span style="color:var(--text-muted);">lat</span>
<span style="margin-left:0.2rem;{% if latest_wan.latency_ms > 100 %}color:var(--red){% elif latest_wan.latency_ms > 50 %}color:var(--yellow){% endif %};">
{{ "%.0f"|format(latest_wan.latency_ms) }}ms
</span>
</span>
{% endif %}
{% if latest_wan.rx_bytes_rate is not none %}
<span>
<span style="color:var(--text-muted);">&#8595;</span>
{% set bps = latest_wan.rx_bytes_rate %}
{% if bps >= 1048576 %}{{ "%.1f"|format(bps / 1048576) }}MB/s
{% elif bps >= 1024 %}{{ "%.0f"|format(bps / 1024) }}KB/s
{% else %}{{ "%.0f"|format(bps) }}B/s{% endif %}
</span>
<span>
<span style="color:var(--text-muted);">&#8593;</span>
{% set bps = latest_wan.tx_bytes_rate %}
{% if bps >= 1048576 %}{{ "%.1f"|format(bps / 1048576) }}MB/s
{% elif bps >= 1024 %}{{ "%.0f"|format(bps / 1024) }}KB/s
{% else %}{{ "%.0f"|format(bps) }}B/s{% endif %}
</span>
{% endif %}
</div>
</div>
{% endif %}
{% if latest_clients %}
<div class="ping-row">
<div class="ping-meta">
<span class="ping-name" style="font-size:0.8rem;">Clients</span>
</div>
<div style="flex:1;display:flex;gap:1rem;font-size:0.82rem;">
<span style="font-weight:600;">{{ latest_clients.total_clients }}</span>
<span style="color:var(--text-muted);">{{ latest_clients.wireless_clients }}w / {{ latest_clients.wired_clients }}e</span>
</div>
</div>
{% endif %}
{% if top_clients %}
<div style="margin-top:0.4rem;border-top:1px solid var(--border);padding-top:0.4rem;">
{% for c in top_clients %}
<div class="ping-row" style="padding:0.1rem 0;">
<div class="ping-meta" style="min-width:0;flex:1;">
<span style="font-size:0.78rem;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ c.hostname }}</span>
</div>
<div style="font-size:0.78rem;font-variant-numeric:tabular-nums;white-space:nowrap;color:var(--text-muted);">
{% set total_bps = c.tx_rate + c.rx_rate %}
<span style="color:var(--text-muted);">&#8595;</span>
{% set rx = c.rx_rate %}
{% if rx >= 1048576 %}{{ "%.1f"|format(rx / 1048576) }}M
{% elif rx >= 1024 %}{{ "%.0f"|format(rx / 1024) }}K
{% else %}{{ "%.0f"|format(rx) }}B{% endif %}
<span style="color:var(--text-muted);margin-left:0.4rem;">&#8593;</span>
{% set tx = c.tx_rate %}
{% if tx >= 1048576 %}{{ "%.1f"|format(tx / 1048576) }}M
{% elif tx >= 1024 %}{{ "%.0f"|format(tx / 1024) }}K
{% else %}{{ "%.0f"|format(tx) }}B{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if not latest_wan and not latest_clients and not active_alarms and not offline_devices %}
<p class="empty" style="padding:0.5rem 0;font-size:0.85rem;">No UniFi data yet.</p>
{% endif %}
@@ -0,0 +1,42 @@
{# unifi/widget_clients.html — dashboard widget: top active clients #}
{% if not snapshot %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">No client data yet.</div>
{% else %}
<div style="display:flex;gap:1rem;margin-bottom:0.75rem;flex-wrap:wrap;">
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;">{{ snapshot.total_clients }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">total</span>
</div>
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--accent);">{{ snapshot.wireless_clients }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">wireless</span>
</div>
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--text-muted);">{{ snapshot.wired_clients }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">wired</span>
</div>
</div>
{% if clients %}
<div style="display:grid;gap:3px;">
{% for c in clients %}
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.82rem;padding:0.15rem 0;">
<span class="dot {% if c.type == 'wireless' %}dot-up{% else %}dot-dim{% endif %}"></span>
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
{{ c.hostname or c.ip or c.mac }}
</span>
{% if c.tx_rate or c.rx_rate %}
<span style="font-size:0.75rem;color:var(--text-muted);font-family:ui-monospace,monospace;flex-shrink:0;">
{% if c.tx_rate %}↑{{ "%.0f" | format(c.tx_rate / 1000) }}k{% endif %}
{% if c.rx_rate %} ↓{{ "%.0f" | format(c.rx_rate / 1000) }}k{% endif %}
</span>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div style="color:var(--text-muted);font-size:0.82rem;">No clients connected.</div>
{% endif %}
{% endif %}
@@ -0,0 +1,36 @@
{# unifi/widget_devices.html — dashboard widget: infrastructure devices #}
{% if not devices %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
{% if type_filter != "all" %}No {{ type_filter }} devices found.{% else %}No devices found.{% endif %}
</div>
{% else %}
{% set online = devices | selectattr("state", "equalto", 1) | list %}
{% set offline = devices | rejectattr("state", "equalto", 1) | list %}
<div style="display:flex;gap:1rem;margin-bottom:0.75rem;">
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--green);">{{ online | length }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">online</span>
</div>
{% if offline %}
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--red);">{{ offline | length }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">offline</span>
</div>
{% endif %}
</div>
<div style="display:grid;gap:3px;">
{% for d in devices %}
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.82rem;padding:0.15rem 0;">
<span class="dot {% if d.state == 1 %}dot-up{% else %}dot-down{% endif %}"></span>
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
{{ d.name or d.mac }}
</span>
<span style="font-size:0.72rem;color:var(--text-dim);flex-shrink:0;">{{ d.model or d.device_type or "" }}</span>
</div>
{% endfor %}
</div>
{% endif %}