From ad4fcd1cfd1ae4ed9dac42487bfc78b1ce344ebf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 22 Mar 2026 18:28:43 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20release=20=E2=80=94=20traefik?= =?UTF-8?q?,=20unifi,=20ups=20plugins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First-party plugins for Fabled Scryer (https://github.com/bvandeusen/fabledscryer). traefik: Prometheus metrics scraping, stats history, access log parsing (GeoIP optional) unifi: UniFi controller integration — WAN health, devices, clients, DPI, events ups: UPS monitoring via NUT (Network UPS Tools), Ansible shutdown automation Each plugin follows the Fabled Scryer plugin contract: setup(app), get_scheduled_tasks(), get_blueprint() index.yaml lists all three plugins in the catalog format. Populate checksum_sha256 entries once release zips are published via GitHub Releases. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 29 + index.yaml | 59 ++ traefik/__init__.py | 30 + traefik/access_log.py | 199 +++++++ traefik/migrations/__init__.py | 0 traefik/migrations/env.py | 75 +++ traefik/migrations/versions/__init__.py | 0 .../versions/traefik_001_initial.py | 37 ++ .../migrations/versions/traefik_002_stats.py | 48 ++ .../versions/traefik_003_access_log.py | 40 ++ traefik/models.py | 67 +++ traefik/plugin.yaml | 21 + traefik/routes.py | 277 ++++++++++ traefik/scheduler.py | 204 +++++++ traefik/scraper.py | 276 +++++++++ traefik/templates/traefik/index.html | 31 ++ traefik/templates/traefik/rows.html | 127 +++++ traefik/templates/traefik/traffic.html | 101 ++++ traefik/templates/traefik/widget.html | 56 ++ unifi/__init__.py | 28 + unifi/client.py | 170 ++++++ unifi/migrations/__init__.py | 0 unifi/migrations/env.py | 72 +++ unifi/migrations/versions/__init__.py | 0 .../migrations/versions/unifi_001_initial.py | 62 +++ .../migrations/versions/unifi_002_expanded.py | 156 ++++++ unifi/models.py | 182 ++++++ unifi/plugin.yaml | 21 + unifi/routes.py | 303 ++++++++++ unifi/scheduler.py | 522 ++++++++++++++++++ unifi/templates/unifi/dpi.html | 65 +++ unifi/templates/unifi/events.html | 52 ++ unifi/templates/unifi/index.html | 63 +++ unifi/templates/unifi/inventory.html | 146 +++++ unifi/templates/unifi/rows.html | 203 +++++++ unifi/templates/unifi/security.html | 56 ++ unifi/templates/unifi/widget.html | 95 ++++ ups/__init__.py | 24 + ups/client.py | 141 +++++ ups/migrations/__init__.py | 0 ups/migrations/env.py | 72 +++ ups/migrations/versions/__init__.py | 0 ups/migrations/versions/ups_001_initial.py | 40 ++ ups/models.py | 30 + ups/plugin.yaml | 24 + ups/routes.py | 125 +++++ ups/scheduler.py | 185 +++++++ ups/templates/ups/index.html | 19 + ups/templates/ups/rows.html | 185 +++++++ ups/templates/ups/widget.html | 90 +++ 50 files changed, 4808 insertions(+) create mode 100644 .gitignore create mode 100644 index.yaml create mode 100644 traefik/__init__.py create mode 100644 traefik/access_log.py create mode 100644 traefik/migrations/__init__.py create mode 100644 traefik/migrations/env.py create mode 100644 traefik/migrations/versions/__init__.py create mode 100644 traefik/migrations/versions/traefik_001_initial.py create mode 100644 traefik/migrations/versions/traefik_002_stats.py create mode 100644 traefik/migrations/versions/traefik_003_access_log.py create mode 100644 traefik/models.py create mode 100644 traefik/plugin.yaml create mode 100644 traefik/routes.py create mode 100644 traefik/scheduler.py create mode 100644 traefik/scraper.py create mode 100644 traefik/templates/traefik/index.html create mode 100644 traefik/templates/traefik/rows.html create mode 100644 traefik/templates/traefik/traffic.html create mode 100644 traefik/templates/traefik/widget.html create mode 100644 unifi/__init__.py create mode 100644 unifi/client.py create mode 100644 unifi/migrations/__init__.py create mode 100644 unifi/migrations/env.py create mode 100644 unifi/migrations/versions/__init__.py create mode 100644 unifi/migrations/versions/unifi_001_initial.py create mode 100644 unifi/migrations/versions/unifi_002_expanded.py create mode 100644 unifi/models.py create mode 100644 unifi/plugin.yaml create mode 100644 unifi/routes.py create mode 100644 unifi/scheduler.py create mode 100644 unifi/templates/unifi/dpi.html create mode 100644 unifi/templates/unifi/events.html create mode 100644 unifi/templates/unifi/index.html create mode 100644 unifi/templates/unifi/inventory.html create mode 100644 unifi/templates/unifi/rows.html create mode 100644 unifi/templates/unifi/security.html create mode 100644 unifi/templates/unifi/widget.html create mode 100644 ups/__init__.py create mode 100644 ups/client.py create mode 100644 ups/migrations/__init__.py create mode 100644 ups/migrations/env.py create mode 100644 ups/migrations/versions/__init__.py create mode 100644 ups/migrations/versions/ups_001_initial.py create mode 100644 ups/models.py create mode 100644 ups/plugin.yaml create mode 100644 ups/routes.py create mode 100644 ups/scheduler.py create mode 100644 ups/templates/ups/index.html create mode 100644 ups/templates/ups/rows.html create mode 100644 ups/templates/ups/widget.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..283024d --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.so +*.egg +*.egg-info/ +dist/ +build/ +.eggs/ + +# Virtual environments +.venv/ +venv/ +env/ + +# Editor +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Release artifacts (generated by publish workflow) +*.zip diff --git a/index.yaml b/index.yaml new file mode 100644 index 0000000..b4f07cc --- /dev/null +++ b/index.yaml @@ -0,0 +1,59 @@ +# fabledscryer-plugins / index.yaml +# +# Plugin catalog for Fabled Scryer. +# Fetched by the app at: Settings → Plugins → Plugin Catalog +# +# After updating an entry, commit and push — changes are live within 5 minutes +# (the app caches this file in-process for 300 seconds). +# +# See docs/plugins/index.yaml.example in the main app repo for the full schema. + +version: 1 +updated: "2026-03-22" + +plugins: + + - name: traefik + version: "1.0.0" + description: "Traefik reverse proxy metrics and access log integration" + author: "FabledScryer" + license: "MIT" + min_app_version: "0.1.0" + repository_url: "https://github.com/bvandeusen/fabledscryer-plugins" + homepage: "https://github.com/bvandeusen/fabledscryer-plugins/tree/main/traefik" + download_url: "https://github.com/bvandeusen/fabledscryer-plugins/releases/download/traefik-v1.0.0/traefik.zip" + checksum_sha256: "" + tags: + - proxy + - metrics + - access-log + + - 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://github.com/bvandeusen/fabledscryer-plugins" + homepage: "https://github.com/bvandeusen/fabledscryer-plugins/tree/main/unifi" + download_url: "https://github.com/bvandeusen/fabledscryer-plugins/releases/download/unifi-v1.0.0/unifi.zip" + checksum_sha256: "" + tags: + - network + - unifi + - ubiquiti + + - name: ups + version: "1.0.0" + description: "UPS monitoring via NUT (Network UPS Tools) with Ansible shutdown automation" + author: "FabledScryer" + license: "MIT" + min_app_version: "0.1.0" + repository_url: "https://github.com/bvandeusen/fabledscryer-plugins" + homepage: "https://github.com/bvandeusen/fabledscryer-plugins/tree/main/ups" + download_url: "https://github.com/bvandeusen/fabledscryer-plugins/releases/download/ups-v1.0.0/ups.zip" + checksum_sha256: "" + tags: + - ups + - power + - nut diff --git a/traefik/__init__.py b/traefik/__init__.py new file mode 100644 index 0000000..f2c7ac7 --- /dev/null +++ b/traefik/__init__.py @@ -0,0 +1,30 @@ +# plugins/traefik/__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 TraefikMetric, TraefikCert, TraefikGlobalStat, TraefikAccessSummary # noqa: F401 + + +def get_scheduled_tasks() -> list: + from .scheduler import make_scrape_task, make_access_log_task + tasks = [make_scrape_task(_app)] + access_log_cfg = _app.config["PLUGINS"]["traefik"].get("access_log", {}) + if not isinstance(access_log_cfg, dict): + access_log_cfg = {} + if access_log_cfg.get("enabled", False): + tasks.append(make_access_log_task(_app)) + return tasks + + +def get_blueprint(): + from .routes import traefik_bp + return traefik_bp diff --git a/traefik/access_log.py b/traefik/access_log.py new file mode 100644 index 0000000..909b493 --- /dev/null +++ b/traefik/access_log.py @@ -0,0 +1,199 @@ +# plugins/traefik/access_log.py +"""Traefik JSON access log tailer and traffic-origin aggregator. + +Reads new lines from Traefik's JSON access log on each scheduler tick, +classifies source IPs as internal (RFC1918) or external, optionally +looks up country codes via MaxMind GeoLite2, and returns an aggregated +summary ready to persist in TraefikAccessSummary. +""" +from __future__ import annotations + +import ipaddress +import json +from collections import defaultdict +from pathlib import Path + +# ── Module-level file-position state ───────────────────────────────────────── +_log_position: int = 0 # byte offset of last read +_log_inode: int = 0 # detect rotation by inode change + +# ── Private IP ranges (RFC1918 + loopback + link-local) ────────────────────── +_PRIVATE_NETS = [ + ipaddress.ip_network("10.0.0.0/8"), + ipaddress.ip_network("172.16.0.0/12"), + ipaddress.ip_network("192.168.0.0/16"), + ipaddress.ip_network("127.0.0.0/8"), + ipaddress.ip_network("169.254.0.0/16"), + ipaddress.ip_network("::1/128"), + ipaddress.ip_network("fc00::/7"), + ipaddress.ip_network("fe80::/10"), +] + + +def _strip_port(raw: str) -> str: + """Strip trailing :port from an IPv4 address string. Leaves IPv6 untouched.""" + if not raw: + return raw + # Bracketed IPv6 with port: [::1]:12345 + if raw.startswith("["): + return raw[1:raw.index("]")] if "]" in raw else raw + # IPv4:port has exactly one colon + if raw.count(":") == 1: + return raw.rsplit(":", 1)[0] + return raw + + +def is_internal(raw_ip: str) -> bool: + """Return True if the address falls within a private/loopback range.""" + ip_str = _strip_port(raw_ip) + try: + addr = ipaddress.ip_address(ip_str) + return any(addr in net for net in _PRIVATE_NETS) + except ValueError: + return False + + +def _country(ip_str: str, geoip_db) -> str | None: + """Return ISO country code via an open maxminddb reader, or None.""" + if geoip_db is None: + return None + clean = _strip_port(ip_str) + try: + record = geoip_db.get(clean) + if record: + return record.get("country", {}).get("iso_code") + except Exception: + pass + return None + + +def read_new_lines(log_path: str) -> list[dict]: + """Return all new JSON-parsed access log entries since the last call. + + Handles log rotation: if the file shrinks or its inode changes, we + reset to the beginning of the new file. + """ + global _log_position, _log_inode + + path = Path(log_path) + if not path.exists(): + return [] + + try: + stat = path.stat() + except OSError: + return [] + + current_inode = stat.st_ino + current_size = stat.st_size + + # Rotation detected + if current_inode != _log_inode or current_size < _log_position: + _log_position = 0 + _log_inode = current_inode + + if current_size == _log_position: + return [] + + entries: list[dict] = [] + try: + with open(log_path, "rb") as f: + f.seek(_log_position) + raw = f.read(current_size - _log_position) + _log_position = current_size + except OSError: + return [] + + for raw_line in raw.split(b"\n"): + raw_line = raw_line.strip() + if not raw_line: + continue + try: + entries.append(json.loads(raw_line)) + except json.JSONDecodeError: + continue + + return entries + + +def aggregate(entries: list[dict], geoip_db=None, top_n: int = 10) -> dict | None: + """Aggregate a batch of access log entries into a summary dict. + + Returns None if there are no entries. + Result keys map directly to TraefikAccessSummary columns (excluding + id and period_start/period_end, which the caller provides). + """ + if not entries: + return None + + total = 0 + internal = 0 + external = 0 + total_bytes = 0.0 + + by_router: dict[str, int] = defaultdict(int) + ip_counts: dict[str, int] = defaultdict(int) + ip_internal: dict[str, bool] = {} + ip_country: dict[str, str | None] = {} + country_counts: dict[str, int] = defaultdict(int) + + for entry in entries: + total += 1 + raw_ip = entry.get("ClientHost", "") + router = entry.get("RouterName") or entry.get("ServiceName") or "unknown" + total_bytes += entry.get("DownstreamContentSize") or 0 + + by_router[router] += 1 + + internal_flag = is_internal(raw_ip) + if internal_flag: + internal += 1 + else: + external += 1 + if raw_ip not in ip_country: + ip_country[raw_ip] = _country(raw_ip, geoip_db) + c = ip_country[raw_ip] + if c: + country_counts[c] += 1 + + ip_counts[raw_ip] += 1 + ip_internal[raw_ip] = internal_flag + + top_ips = [ + { + "ip": ip, + "count": cnt, + "internal": ip_internal[ip], + "country": ip_country.get(ip), + } + for ip, cnt in sorted(ip_counts.items(), key=lambda x: x[1], reverse=True)[:top_n] + ] + top_countries = [ + {"country": c, "count": n} + for c, n in sorted(country_counts.items(), key=lambda x: x[1], reverse=True)[:top_n] + ] + top_routers = [ + {"router": r, "count": n} + for r, n in sorted(by_router.items(), key=lambda x: x[1], reverse=True)[:top_n] + ] + + return { + "total_requests": total, + "internal_requests": internal, + "external_requests": external, + "total_bytes": total_bytes, + "top_ips_json": json.dumps(top_ips), + "top_countries_json": json.dumps(top_countries), + "top_routers_json": json.dumps(top_routers), + } + + +def open_geoip(db_path: str | None): + """Open a MaxMind mmdb file if available. Returns None if not configured or missing.""" + if not db_path: + return None + try: + import maxminddb # type: ignore[import] + return maxminddb.open_database(db_path) + except Exception: + return None diff --git a/traefik/migrations/__init__.py b/traefik/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/traefik/migrations/env.py b/traefik/migrations/env.py new file mode 100644 index 0000000..1919fec --- /dev/null +++ b/traefik/migrations/env.py @@ -0,0 +1,75 @@ +# plugins/traefik/migrations/env.py +"""Alembic env.py for the Traefik plugin. + +For standalone use during plugin development. +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 + +# Make fabledscryer importable +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) + +from fabledscryer.models.base import Base +import fabledscryer.models # noqa: F401 — core models +from plugins.traefik.models import TraefikMetric # noqa: F401 — plugin model + +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() diff --git a/traefik/migrations/versions/__init__.py b/traefik/migrations/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/traefik/migrations/versions/traefik_001_initial.py b/traefik/migrations/versions/traefik_001_initial.py new file mode 100644 index 0000000..aa53f78 --- /dev/null +++ b/traefik/migrations/versions/traefik_001_initial.py @@ -0,0 +1,37 @@ +# plugins/traefik/migrations/versions/traefik_001_initial.py +"""Traefik metrics table + +Revision ID: traefik_001_initial +Revises: (none — this is a branch off the core head via depends_on) +Create Date: 2026-03-17 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "traefik_001_initial" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = "traefik" +depends_on: Union[str, Sequence[str], None] = ("0004_app_settings",) + + +def upgrade() -> None: + op.create_table( + "traefik_metrics", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("router_name", sa.String(255), nullable=False), + sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("request_rate", sa.Float, nullable=False), + sa.Column("error_rate_4xx_pct", sa.Float, nullable=False), + sa.Column("error_rate_5xx_pct", sa.Float, nullable=False), + sa.Column("latency_p50_ms", sa.Float, nullable=False), + sa.Column("latency_p95_ms", sa.Float, nullable=False), + sa.Column("latency_p99_ms", sa.Float, nullable=False), + ) + op.create_index("ix_traefik_metrics_router_scraped", "traefik_metrics", + ["router_name", "scraped_at"]) + + +def downgrade() -> None: + op.drop_index("ix_traefik_metrics_router_scraped", "traefik_metrics") + op.drop_table("traefik_metrics") diff --git a/traefik/migrations/versions/traefik_002_stats.py b/traefik/migrations/versions/traefik_002_stats.py new file mode 100644 index 0000000..8320d53 --- /dev/null +++ b/traefik/migrations/versions/traefik_002_stats.py @@ -0,0 +1,48 @@ +"""Traefik extended stats: bandwidth, certs, global stats + +Revision ID: traefik_002_stats +Revises: traefik_001_initial +Create Date: 2026-03-19 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "traefik_002_stats" +down_revision: Union[str, None] = "traefik_001_initial" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "traefik_metrics", + sa.Column("response_bytes_rate", sa.Float, nullable=False, server_default="0"), + ) + + op.create_table( + "traefik_certs", + sa.Column("serial", sa.String(255), primary_key=True), + sa.Column("cn", sa.String(512), nullable=True), + sa.Column("sans", sa.Text, nullable=True), + sa.Column("not_after", sa.DateTime(timezone=True), nullable=False), + sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False), + ) + + op.create_table( + "traefik_global_stats", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("open_conns_total", sa.Float, nullable=True), + sa.Column("config_reloads_total", sa.Float, nullable=True), + sa.Column("config_last_reload_success", sa.Float, nullable=True), + sa.Column("process_memory_bytes", sa.Float, nullable=True), + ) + op.create_index("ix_traefik_global_stats_scraped", "traefik_global_stats", ["scraped_at"]) + + +def downgrade() -> None: + op.drop_index("ix_traefik_global_stats_scraped", "traefik_global_stats") + op.drop_table("traefik_global_stats") + op.drop_table("traefik_certs") + op.drop_column("traefik_metrics", "response_bytes_rate") diff --git a/traefik/migrations/versions/traefik_003_access_log.py b/traefik/migrations/versions/traefik_003_access_log.py new file mode 100644 index 0000000..fbc8009 --- /dev/null +++ b/traefik/migrations/versions/traefik_003_access_log.py @@ -0,0 +1,40 @@ +"""Traefik access log traffic summaries + +Revision ID: traefik_003_access_log +Revises: traefik_002_stats +Create Date: 2026-03-20 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "traefik_003_access_log" +down_revision: Union[str, None] = "traefik_002_stats" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "traefik_access_summaries", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("period_start", sa.DateTime(timezone=True), nullable=False), + sa.Column("period_end", sa.DateTime(timezone=True), nullable=False), + sa.Column("total_requests", sa.Integer, nullable=False, server_default="0"), + sa.Column("internal_requests", sa.Integer, nullable=False, server_default="0"), + sa.Column("external_requests", sa.Integer, nullable=False, server_default="0"), + sa.Column("total_bytes", sa.Float, nullable=False, server_default="0"), + sa.Column("top_ips_json", sa.Text, nullable=False, server_default="[]"), + sa.Column("top_countries_json", sa.Text, nullable=False, server_default="[]"), + sa.Column("top_routers_json", sa.Text, nullable=False, server_default="[]"), + ) + op.create_index( + "ix_traefik_access_summaries_period_end", + "traefik_access_summaries", + ["period_end"], + ) + + +def downgrade() -> None: + op.drop_index("ix_traefik_access_summaries_period_end", "traefik_access_summaries") + op.drop_table("traefik_access_summaries") diff --git a/traefik/models.py b/traefik/models.py new file mode 100644 index 0000000..1ba9e50 --- /dev/null +++ b/traefik/models.py @@ -0,0 +1,67 @@ +# plugins/traefik/models.py +from __future__ import annotations +import uuid +from datetime import datetime, timezone +from sqlalchemy import DateTime, Float, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column +from fabledscryer.models.base import Base + + +class TraefikMetric(Base): + __tablename__ = "traefik_metrics" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + router_name: Mapped[str] = mapped_column(String(255), nullable=False) + scraped_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) + ) + request_rate: Mapped[float] = mapped_column(Float, nullable=False) + error_rate_4xx_pct: Mapped[float] = mapped_column(Float, nullable=False) + error_rate_5xx_pct: Mapped[float] = mapped_column(Float, nullable=False) + latency_p50_ms: Mapped[float] = mapped_column(Float, nullable=False) + latency_p95_ms: Mapped[float] = mapped_column(Float, nullable=False) + latency_p99_ms: Mapped[float] = mapped_column(Float, nullable=False) + response_bytes_rate: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + + +class TraefikCert(Base): + """TLS certificate expiry tracking — upserted by serial each scrape.""" + __tablename__ = "traefik_certs" + + serial: Mapped[str] = mapped_column(String(255), primary_key=True) + cn: Mapped[str | None] = mapped_column(String(512), nullable=True) + sans: Mapped[str | None] = mapped_column(Text, nullable=True) + not_after: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + scraped_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) + ) + + +class TraefikAccessSummary(Base): + """Aggregated traffic-origin summary — one row per scheduler tick.""" + __tablename__ = "traefik_access_summaries" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + period_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + total_requests: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + internal_requests: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + external_requests: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + total_bytes: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + top_ips_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]") + top_countries_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]") + top_routers_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]") + + +class TraefikGlobalStat(Base): + """One row per scrape — process-level and config stats.""" + __tablename__ = "traefik_global_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) + ) + open_conns_total: Mapped[float | None] = mapped_column(Float, nullable=True) + config_reloads_total: Mapped[float | None] = mapped_column(Float, nullable=True) + config_last_reload_success: Mapped[float | None] = mapped_column(Float, nullable=True) + process_memory_bytes: Mapped[float | None] = mapped_column(Float, nullable=True) diff --git a/traefik/plugin.yaml b/traefik/plugin.yaml new file mode 100644 index 0000000..7696059 --- /dev/null +++ b/traefik/plugin.yaml @@ -0,0 +1,21 @@ +# plugins/traefik/plugin.yaml +name: traefik +version: "1.0.0" +description: "Traefik reverse proxy metrics and access log integration" +author: "FabledScryer" +license: "MIT" +min_app_version: "0.1.0" +repository_url: "https://github.com/bvandeusen/fabledscryer-plugins" +homepage: "https://github.com/bvandeusen/fabledscryer-plugins/tree/main/traefik" +tags: + - proxy + - metrics + - access-log + +config: + metrics_url: "http://localhost:8080/metrics" + scrape_interval_seconds: 60 + access_log: + enabled: false + path: "/var/log/traefik/access.log" + geoip_db: "" # optional: path to MaxMind GeoLite2-Country.mmdb diff --git a/traefik/routes.py b/traefik/routes.py new file mode 100644 index 0000000..6371e35 --- /dev/null +++ b/traefik/routes.py @@ -0,0 +1,277 @@ +# plugins/traefik/routes.py +from __future__ import annotations +from collections import defaultdict +from datetime import datetime, timezone +from quart import Blueprint, current_app, render_template, request +from sqlalchemy import Integer, cast, func, select +import json + +from fabledscryer.auth.middleware import require_role +from fabledscryer.models.users import UserRole +from fabledscryer.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds, subsample +from .models import TraefikAccessSummary, TraefikCert, TraefikGlobalStat, TraefikMetric + +traefik_bp = Blueprint("traefik", __name__, template_folder="templates") + + +def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str: + """Generate an inline SVG sparkline from a list of float values.""" + if len(values) < 2: + return f'' + 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'' + f'' + f'' + ) + + +@traefik_bp.get("/") +@require_role(UserRole.viewer) +async def index(): + poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60) + _al_cfg = current_app.config["PLUGINS"]["traefik"].get("access_log", {}) + if not isinstance(_al_cfg, dict): + _al_cfg = {} + access_log_enabled = _al_cfg.get("enabled", False) + current_range = request.args.get("range", DEFAULT_RANGE) + return await render_template( + "traefik/index.html", + poll_interval=poll_interval, + access_log_enabled=access_log_enabled, + current_range=current_range, + ) + + +@traefik_bp.get("/rows") +@require_role(UserRole.viewer) +async def rows(): + """HTMX fragment: service cards with sparklines scoped to selected time range.""" + since, range_key = parse_range(request.args.get("range")) + now = datetime.now(timezone.utc) + + async with current_app.db_sessionmaker() as db: + # All known routers + result = await db.execute( + select(TraefikMetric.router_name) + .distinct() + .order_by(TraefikMetric.router_name) + ) + routers = [row[0] for row in result.all()] + + b_secs = bucket_seconds(since) + bucket_col = ( + cast(func.strftime('%s', TraefikMetric.scraped_at), Integer) / b_secs + ).label("bucket") + + router_data = [] + for router in routers: + # Bucketed history for sparklines — always ~80 points regardless of range + result = await db.execute( + select( + func.avg(TraefikMetric.request_rate).label("request_rate"), + func.avg(TraefikMetric.latency_p95_ms).label("latency_p95_ms"), + func.avg(TraefikMetric.error_rate_5xx_pct).label("error_rate_5xx_pct"), + func.min(TraefikMetric.scraped_at).label("scraped_at"), + bucket_col, + ) + .where(TraefikMetric.router_name == router) + .where(TraefikMetric.scraped_at >= since) + .group_by(bucket_col) + .order_by(bucket_col) + ) + history = result.all() + + # Latest value — always the most recent raw row + result = await db.execute( + select(TraefikMetric) + .where(TraefikMetric.router_name == router) + .order_by(TraefikMetric.scraped_at.desc()) + .limit(1) + ) + latest = result.scalar_one_or_none() + if not latest: + continue + + router_data.append({ + "name": router, + "latest": latest, + "sparkline_req": _sparkline([r.request_rate for r in history]), + "sparkline_p95": _sparkline([r.latency_p95_ms for r in history]), + "sparkline_5xx": _sparkline([r.error_rate_5xx_pct for r in history]), + }) + + # Latest global stat (always most recent, not range-filtered) + result = await db.execute( + select(TraefikGlobalStat) + .order_by(TraefikGlobalStat.scraped_at.desc()) + .limit(1) + ) + global_stat = result.scalar_one_or_none() + + # All certs, sorted by soonest expiry + result = await db.execute( + select(TraefikCert).order_by(TraefikCert.not_after) + ) + raw_certs = result.scalars().all() + + certs = [] + for c in raw_certs: + days = (c.not_after - now).total_seconds() / 86400.0 + certs.append({ + "serial": c.serial, + "cn": c.cn or c.serial, + "sans": c.sans, + "not_after": c.not_after, + "days_remaining": days, + }) + + return await render_template( + "traefik/rows.html", + router_data=router_data, + global_stat=global_stat, + certs=certs, + range_key=range_key, + ) + + +@traefik_bp.get("/widget") +@require_role(UserRole.viewer) +async def widget(): + """HTMX dashboard widget fragment.""" + now = datetime.now(timezone.utc) + + async with current_app.db_sessionmaker() as db: + result = await db.execute( + select(TraefikMetric.router_name).distinct().order_by(TraefikMetric.router_name) + ) + routers = [row[0] for row in result.all()] + router_data = [] + for router in routers: + result = await db.execute( + select(TraefikMetric) + .where(TraefikMetric.router_name == router) + .order_by(TraefikMetric.scraped_at.desc()) + .limit(1) + ) + latest = result.scalar_one_or_none() + if latest: + router_data.append({"name": router, "latest": latest}) + + router_data.sort(key=lambda r: ( + -(1 if r["latest"].error_rate_5xx_pct > 0.1 else 0), + -(1 if r["latest"].error_rate_4xx_pct > 5.0 else 0), + -r["latest"].request_rate, + )) + total_routers = len(router_data) + router_data = router_data[:5] + + result = await db.execute( + select(TraefikCert).order_by(TraefikCert.not_after) + ) + raw_certs = result.scalars().all() + + expiring_certs = [] + for c in raw_certs: + days = (c.not_after - now).total_seconds() / 86400.0 + if days < 30: + expiring_certs.append({"cn": c.cn or c.serial, "days_remaining": days}) + + return await render_template( + "traefik/widget.html", + router_data=router_data, + expiring_certs=expiring_certs, + total_routers=total_routers, + ) + + +@traefik_bp.get("/traffic") +@require_role(UserRole.viewer) +async def traffic(): + """HTMX fragment: traffic origin summary scoped to selected time range.""" + since, range_key = parse_range(request.args.get("range")) + + async with current_app.db_sessionmaker() as db: + result = await db.execute( + select(TraefikAccessSummary) + .where(TraefikAccessSummary.period_end >= since) + .order_by(TraefikAccessSummary.period_end.asc()) + # No LIMIT — all summaries in range needed for accurate totals; + # sparkline subsamples to 80 points afterwards + ) + rows = result.scalars().all() + + if not rows: + return await render_template( + "traefik/traffic.html", summary=None, history=[], range_key=range_key + ) + + total = sum(r.total_requests for r in rows) + internal = sum(r.internal_requests for r in rows) + external = sum(r.external_requests for r in rows) + total_bytes = sum(r.total_bytes for r in rows) + + ip_counts: dict = defaultdict(lambda: {"count": 0, "internal": True, "country": None}) + country_counts: dict[str, int] = defaultdict(int) + router_counts: dict[str, int] = defaultdict(int) + + for row in rows: + for entry in json.loads(row.top_ips_json): + k = entry["ip"] + ip_counts[k]["count"] += entry["count"] + ip_counts[k]["internal"] = entry["internal"] + ip_counts[k]["country"] = entry.get("country") + for entry in json.loads(row.top_countries_json): + country_counts[entry["country"]] += entry["count"] + for entry in json.loads(row.top_routers_json): + router_counts[entry["router"]] += entry["count"] + + top_ips = sorted( + [{"ip": k, **v} for k, v in ip_counts.items()], + key=lambda x: x["count"], reverse=True + )[:10] + top_countries = sorted( + [{"country": k, "count": v} for k, v in country_counts.items()], + key=lambda x: x["count"], reverse=True + )[:10] + top_routers = sorted( + [{"router": k, "count": v} for k, v in router_counts.items()], + key=lambda x: x["count"], reverse=True + )[:10] + + history = [ + { + "period_end": r.period_end, + "external_pct": (r.external_requests / r.total_requests * 100.0) + if r.total_requests else 0.0, + } + for r in rows + ] + + summary = { + "total": total, + "internal": internal, + "external": external, + "total_bytes": total_bytes, + "external_pct": (external / total * 100.0) if total else 0.0, + "top_ips": top_ips, + "top_countries": top_countries, + "top_routers": top_routers, + "sparkline_external": _sparkline( + [h["external_pct"] for h in subsample(history, 80)] + ), + } + + return await render_template( + "traefik/traffic.html", summary=summary, history=history, range_key=range_key + ) diff --git a/traefik/scheduler.py b/traefik/scheduler.py new file mode 100644 index 0000000..b7eb050 --- /dev/null +++ b/traefik/scheduler.py @@ -0,0 +1,204 @@ +# plugins/traefik/scheduler.py +from __future__ import annotations +import logging +import time +from typing import TYPE_CHECKING + +from datetime import datetime +from fabledscryer.core.scheduler import ScheduledTask + +if TYPE_CHECKING: + from quart import Quart + +logger = logging.getLogger(__name__) + +# Module-level state: previous scrape data for delta computation +_prev_metrics: dict = {} +_prev_time: float = 0.0 + +# Access log state +_geoip_db = None +_access_log_last_tick: "datetime | None" = None + + +def make_scrape_task(app: "Quart") -> ScheduledTask: + """Return a ScheduledTask that scrapes Traefik metrics on the configured interval.""" + interval = app.config["PLUGINS"]["traefik"]["scrape_interval_seconds"] + + async def scrape() -> None: + await _do_scrape(app) + + return ScheduledTask( + name="traefik_scrape", + coro_factory=scrape, + interval_seconds=int(interval), + run_on_startup=True, + ) + + +async def _do_scrape(app: "Quart") -> None: + """Fetch Traefik metrics, write to DB, and emit plugin_metrics.""" + global _prev_metrics, _prev_time + + from datetime import datetime, timezone + from sqlalchemy import select + from .models import TraefikCert, TraefikGlobalStat, TraefikMetric + from .scraper import ( + compute_global_stats, + compute_router_metrics, + extract_certs, + fetch_metrics, + ) + from fabledscryer.core.alerts import record_metric + + metrics_url: str = app.config["PLUGINS"]["traefik"]["metrics_url"] + + try: + current = await fetch_metrics(metrics_url) + except Exception: + logger.exception("Traefik scrape failed (url=%s)", metrics_url) + return + + now = time.monotonic() + elapsed = now - _prev_time if _prev_time else 60.0 + router_metrics = compute_router_metrics(current, _prev_metrics or None, elapsed) + global_stats = compute_global_stats(current, _prev_metrics or None) + cert_list = extract_certs(current) + + _prev_metrics = current + _prev_time = now + + scraped_at = datetime.now(timezone.utc) + + async with app.db_sessionmaker() as session: + async with session.begin(): + # ── Per-service metrics ─────────────────────────────────────────── + for router_name, metrics in router_metrics.items(): + row = TraefikMetric( + router_name=router_name, + scraped_at=scraped_at, + request_rate=metrics["request_rate"], + error_rate_4xx_pct=metrics["error_rate_4xx_pct"], + error_rate_5xx_pct=metrics["error_rate_5xx_pct"], + latency_p50_ms=metrics["latency_p50_ms"], + latency_p95_ms=metrics["latency_p95_ms"], + latency_p99_ms=metrics["latency_p99_ms"], + response_bytes_rate=metrics["response_bytes_rate"], + ) + session.add(row) + + for metric_name, value in [ + ("request_rate", metrics["request_rate"]), + ("error_rate_4xx_pct", metrics["error_rate_4xx_pct"]), + ("error_rate_5xx_pct", metrics["error_rate_5xx_pct"]), + ("latency_p50_ms", metrics["latency_p50_ms"]), + ("latency_p95_ms", metrics["latency_p95_ms"]), + ("latency_p99_ms", metrics["latency_p99_ms"]), + ("response_bytes_rate", metrics["response_bytes_rate"]), + ]: + await record_metric( + session=session, + source_module="traefik", + resource_name=router_name, + metric_name=metric_name, + value=value, + ) + + # ── Global stats ────────────────────────────────────────────────── + session.add(TraefikGlobalStat(scraped_at=scraped_at, **global_stats)) + + # ── TLS certs (upsert by serial) ────────────────────────────────── + for cert in cert_list: + existing = await session.get(TraefikCert, cert["serial"]) + if existing: + existing.cn = cert["cn"] + existing.sans = cert["sans"] + existing.not_after = cert["not_after"] + existing.scraped_at = scraped_at + else: + session.add(TraefikCert( + serial=cert["serial"], + cn=cert["cn"], + sans=cert["sans"], + not_after=cert["not_after"], + scraped_at=scraped_at, + )) + + # Emit cert_expiry_days for alert rules + days_remaining = (cert["not_after"] - scraped_at).total_seconds() / 86400.0 + await record_metric( + session=session, + source_module="traefik", + resource_name=cert["cn"] or cert["serial"], + metric_name="cert_expiry_days", + value=max(0.0, days_remaining), + ) + + logger.debug( + "Traefik scrape: %d router(s), %d cert(s)", + len(router_metrics), + len(cert_list), + ) + + +def make_access_log_task(app: "Quart") -> ScheduledTask: + """Return a ScheduledTask that processes new Traefik access log lines.""" + global _geoip_db + cfg = app.config["PLUGINS"]["traefik"].get("access_log", {}) + if not isinstance(cfg, dict): + cfg = {} + log_path: str = cfg.get("path", "/var/log/traefik/access.log") + geoip_path: str = cfg.get("geoip_db", "") + + from .access_log import open_geoip + _geoip_db = open_geoip(geoip_path or None) + if _geoip_db: + logger.info("Traefik access log: GeoIP database loaded from %s", geoip_path) + else: + logger.info("Traefik access log: GeoIP not configured, country lookup disabled") + + interval = app.config["PLUGINS"]["traefik"]["scrape_interval_seconds"] + + async def process() -> None: + await _do_access_log(app, log_path) + + return ScheduledTask( + name="traefik_access_log", + coro_factory=process, + interval_seconds=int(interval), + run_on_startup=True, + ) + + +async def _do_access_log(app: "Quart", log_path: str) -> None: + """Read new access log lines, aggregate, and persist a summary.""" + global _access_log_last_tick + + from datetime import timezone + from .access_log import read_new_lines, aggregate + from .models import TraefikAccessSummary + + now = datetime.now(timezone.utc) + period_start = _access_log_last_tick or now + _access_log_last_tick = now + + entries = read_new_lines(log_path) + summary = aggregate(entries, geoip_db=_geoip_db) + + if summary is None: + return + + async with app.db_sessionmaker() as session: + async with session.begin(): + session.add(TraefikAccessSummary( + period_start=period_start, + period_end=now, + **summary, + )) + + logger.debug( + "Traefik access log: %d requests (%d internal, %d external)", + summary["total_requests"], + summary["internal_requests"], + summary["external_requests"], + ) diff --git a/traefik/scraper.py b/traefik/scraper.py new file mode 100644 index 0000000..6e06dd2 --- /dev/null +++ b/traefik/scraper.py @@ -0,0 +1,276 @@ +# plugins/traefik/scraper.py +"""Prometheus text-format scraper and per-service metrics calculator for Traefik. + +Traefik exposes Prometheus metrics at /metrics. This module: +1. Fetches the endpoint via httpx +2. Parses the Prometheus text format (counters + histograms) +3. Computes per-service request rate (delta/elapsed), error rates (% 4xx, % 5xx), + latency percentiles (p50, p95, p99) via linear histogram interpolation, + and bandwidth (bytes/sec from response bytes counter delta) +4. Extracts TLS certificate expiry info +5. Computes process-level and config health stats +""" +from __future__ import annotations +import math +import re +from collections import defaultdict +from datetime import datetime, timezone + +import httpx + + +# ────────────────────────────────────────────────────────────────────────────── +# Prometheus text format parser +# ────────────────────────────────────────────────────────────────────────────── + +# Parsed sample: {metric_name: {frozenset(label_items): float}} +ParsedMetrics = dict[str, dict[frozenset, float]] + +_SAMPLE_RE = re.compile( + r'^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{([^}]*)\})?\s+' + r'([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?|[+-]?Inf|NaN)' +) +_LABEL_RE = re.compile(r'([a-zA-Z_][a-zA-Z0-9_]*)="([^"\\]*(?:\\.[^"\\]*)*)"') + + +def parse_prometheus(text: str) -> ParsedMetrics: + """Parse Prometheus text exposition format into nested dicts. + + Returns: {metric_name: {frozenset({(label, value), ...}): float}} + """ + metrics: ParsedMetrics = {} + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + m = _SAMPLE_RE.match(line) + if not m: + continue + name, labels_str, value_str = m.groups() + try: + value = float(value_str) + except ValueError: + continue + labels = frozenset( + (lm.group(1), lm.group(2)) + for lm in _LABEL_RE.finditer(labels_str or "") + ) + metrics.setdefault(name, {})[labels] = value + return metrics + + +# ────────────────────────────────────────────────────────────────────────────── +# Per-router metric computation +# ────────────────────────────────────────────────────────────────────────────── + +def compute_router_metrics( + current: ParsedMetrics, + previous: ParsedMetrics | None, + elapsed_seconds: float, +) -> dict[str, dict[str, float]]: + """Compute per-service derived metrics from two consecutive scrapes. + + Uses traefik_service_* metrics (available by default). + + Returns: + {service_name: { + "request_rate": float, # requests/sec + "error_rate_4xx_pct": float, # % 4xx of total + "error_rate_5xx_pct": float, # % 5xx of total + "latency_p50_ms": float, + "latency_p95_ms": float, + "latency_p99_ms": float, + "response_bytes_rate": float, # bytes/sec + }} + """ + prev = previous or {} + elapsed = max(elapsed_seconds, 1.0) + + # ── Request counters ────────────────────────────────────────────────────── + req_samples = current.get("traefik_service_requests_total", {}) + prev_req = prev.get("traefik_service_requests_total", {}) + + service_total: dict[str, float] = defaultdict(float) + service_4xx: dict[str, float] = defaultdict(float) + service_5xx: dict[str, float] = defaultdict(float) + + for labels, value in req_samples.items(): + label_dict = dict(labels) + service = label_dict.get("service", "") + if not service: + continue + code = label_dict.get("code", "") + prev_value = prev_req.get(labels, value) # no delta on first scrape + delta = max(0.0, value - prev_value) + service_total[service] += delta + if code.startswith("4"): + service_4xx[service] += delta + elif code.startswith("5"): + service_5xx[service] += delta + + # ── Response bytes counters ─────────────────────────────────────────────── + bytes_samples = current.get("traefik_service_responses_bytes_total", {}) + prev_bytes = prev.get("traefik_service_responses_bytes_total", {}) + + service_bytes: dict[str, float] = defaultdict(float) + for labels, value in bytes_samples.items(): + service = dict(labels).get("service", "") + if not service: + continue + prev_value = prev_bytes.get(labels, value) + service_bytes[service] += max(0.0, value - prev_value) + + # ── Histogram buckets ───────────────────────────────────────────────────── + hist_buckets = current.get("traefik_service_request_duration_seconds_bucket", {}) + + # Collect all known services + all_services: set[str] = set(service_total.keys()) | set(service_bytes.keys()) + for labels in hist_buckets: + service = dict(labels).get("service", "") + if service: + all_services.add(service) + + result: dict[str, dict[str, float]] = {} + + for service in all_services: + total = service_total.get(service, 0.0) + result[service] = { + "request_rate": total / elapsed, + "error_rate_4xx_pct": (service_4xx.get(service, 0.0) / total * 100.0) + if total > 0 else 0.0, + "error_rate_5xx_pct": (service_5xx.get(service, 0.0) / total * 100.0) + if total > 0 else 0.0, + "latency_p50_ms": _percentile_ms(hist_buckets, service, 0.50), + "latency_p95_ms": _percentile_ms(hist_buckets, service, 0.95), + "latency_p99_ms": _percentile_ms(hist_buckets, service, 0.99), + "response_bytes_rate": service_bytes.get(service, 0.0) / elapsed, + } + + return result + + +def compute_global_stats( + current: ParsedMetrics, + previous: ParsedMetrics | None, +) -> dict[str, float | None]: + """Extract process-level and config health metrics from a scrape. + + Returns: + { + "open_conns_total": float | None, + "config_reloads_total": float | None, + "config_last_reload_success": float | None, # 1.0 = success, 0.0 = failure + "process_memory_bytes": float | None, + } + """ + stats: dict[str, float | None] = { + "open_conns_total": None, + "config_reloads_total": None, + "config_last_reload_success": None, + "process_memory_bytes": None, + } + + # Open connections — sum across all entrypoints/protocols + open_conns = current.get("traefik_open_connections", {}) + if open_conns: + stats["open_conns_total"] = sum(open_conns.values()) + + # Config reloads — sum across result labels (success + failure) + reloads = current.get("traefik_config_reloads_total", {}) + if reloads: + stats["config_reloads_total"] = sum(reloads.values()) + + # Last reload success gauge + last_success = current.get("traefik_config_last_reload_success", {}) + if last_success: + # Usually a single sample with no labels + stats["config_last_reload_success"] = next(iter(last_success.values())) + + # Process RSS memory + mem = current.get("process_resident_memory_bytes", {}) + if mem: + stats["process_memory_bytes"] = next(iter(mem.values())) + + return stats + + +def extract_certs(current: ParsedMetrics) -> list[dict]: + """Extract TLS certificate expiry info from traefik_tls_certs_not_after. + + Returns a list of dicts: + [{"serial": str, "cn": str, "sans": str, "not_after": datetime}, ...] + """ + certs = [] + samples = current.get("traefik_tls_certs_not_after", {}) + for labels, ts_value in samples.items(): + label_dict = dict(labels) + serial = label_dict.get("serial", "") + if not serial: + continue + try: + not_after = datetime.fromtimestamp(ts_value, tz=timezone.utc) + except (ValueError, OSError, OverflowError): + continue + certs.append({ + "serial": serial, + "cn": label_dict.get("cn") or None, + "sans": label_dict.get("sans") or None, + "not_after": not_after, + }) + return certs + + +def _percentile_ms( + buckets: dict[frozenset, float], + service: str, + pct: float, +) -> float: + """Linear interpolation of a Prometheus histogram percentile, returned in ms.""" + router_buckets: list[tuple[float, float]] = [] + for labels, count in buckets.items(): + label_dict = dict(labels) + if label_dict.get("service") != service: + continue + le_str = label_dict.get("le", "") + try: + le = float("inf") if le_str == "+Inf" else float(le_str) + router_buckets.append((le, count)) + except ValueError: + continue + + if not router_buckets: + return 0.0 + + router_buckets.sort(key=lambda t: t[0]) + total = router_buckets[-1][1] # +Inf bucket count == total requests + if total == 0.0: + return 0.0 + + target = total * pct + for i, (le, count) in enumerate(router_buckets): + if count < target: + continue + if i == 0: + ratio = target / count if count > 0 else 0.0 + return le * ratio * 1000.0 + prev_le, prev_count = router_buckets[i - 1] + if count == prev_count: + return prev_le * 1000.0 + if math.isinf(le): + return prev_le * 1000.0 + fraction = (target - prev_count) / (count - prev_count) + return (prev_le + fraction * (le - prev_le)) * 1000.0 + + return 0.0 + + +# ────────────────────────────────────────────────────────────────────────────── +# HTTP fetch +# ────────────────────────────────────────────────────────────────────────────── + +async def fetch_metrics(metrics_url: str) -> ParsedMetrics: + """Fetch and parse Prometheus metrics from the given URL.""" + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(metrics_url) + response.raise_for_status() + return parse_prometheus(response.text) diff --git a/traefik/templates/traefik/index.html b/traefik/templates/traefik/index.html new file mode 100644 index 0000000..da66fc7 --- /dev/null +++ b/traefik/templates/traefik/index.html @@ -0,0 +1,31 @@ +{# plugins/traefik/templates/traefik/index.html #} +{% extends "base.html" %} +{% block title %}Traefik — Fabled Scryer{% endblock %} +{% block content %} +
+
+

Traefik Services

+ polling every {{ poll_interval }}s +
+ {% include "_time_range.html" %} +
+ +
+
+ +{% if access_log_enabled %} +
+

Traffic Origin

+
+
+
+{% endif %} +{% endblock %} diff --git a/traefik/templates/traefik/rows.html b/traefik/templates/traefik/rows.html new file mode 100644 index 0000000..c02b962 --- /dev/null +++ b/traefik/templates/traefik/rows.html @@ -0,0 +1,127 @@ +{# plugins/traefik/templates/traefik/rows.html #} + +{# ── Global stats bar ──────────────────────────────────────────────────────── #} +{% if global_stat %} +
+ + {% if global_stat.open_conns_total is not none %} + + Open conns + {{ global_stat.open_conns_total | int }} + + {% endif %} + + {% if global_stat.config_last_reload_success is not none %} + + Config + {% if global_stat.config_last_reload_success == 1.0 %} + ✓ OK + {% else %} + ✗ failed + {% endif %} + {% if global_stat.config_reloads_total is not none %} + ({{ global_stat.config_reloads_total | int }} reloads) + {% endif %} + + {% endif %} + + {% if global_stat.process_memory_bytes is not none %} + + Memory + {% set mb = (global_stat.process_memory_bytes / 1048576) %} + + {% if mb >= 1024 %}{{ "%.1f"|format(mb / 1024) }} GB{% else %}{{ "%.0f"|format(mb) }} MB{% endif %} + + + {% endif %} + + + {{ global_stat.scraped_at.strftime("%H:%M:%S") }} UTC + +
+{% endif %} + +{# ── Per-service cards + cert card in shared columns layout ───────────────── #} +{% if router_data or certs %} +
+ +{% for r in router_data %} +
+

{{ r.name }}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Req/s{{ "%.2f"|format(r.latest.request_rate) }}{{ r.sparkline_req | safe }}
Bandwidth + {% set bps = r.latest.response_bytes_rate %} + {% if bps >= 1048576 %}{{ "%.1f"|format(bps / 1048576) }} MB/s + {% elif bps >= 1024 %}{{ "%.1f"|format(bps / 1024) }} KB/s + {% else %}{{ "%.0f"|format(bps) }} B/s{% endif %} +
p95 ms{{ "%.1f"|format(r.latest.latency_p95_ms) }}{{ r.sparkline_p95 | safe }}
p99 ms{{ "%.1f"|format(r.latest.latency_p99_ms) }}
4xx % + {{ "%.1f"|format(r.latest.error_rate_4xx_pct) }}% +
5xx % + {{ "%.1f"|format(r.latest.error_rate_5xx_pct) }}% + {{ r.sparkline_5xx | safe }}
+
+ Last scraped {{ r.latest.scraped_at.strftime("%H:%M:%S") }} UTC +
+
+{% endfor %} + +{% if certs %} +
+
TLS Certificates
+ + {% for cert in certs %} + + + + + + + + {% endfor %} +
{{ cert.cn }} + {{ cert.days_remaining | int }}d +
+ {{ cert.not_after.strftime("%Y-%m-%d") }} +
+
+{% endif %} + +
+{% else %} +
+

No Traefik metrics yet. Configure metrics_url in config.yaml under plugins.traefik.

+
+{% endif %} diff --git a/traefik/templates/traefik/traffic.html b/traefik/templates/traefik/traffic.html new file mode 100644 index 0000000..dcdcb96 --- /dev/null +++ b/traefik/templates/traefik/traffic.html @@ -0,0 +1,101 @@ +{# plugins/traefik/templates/traefik/traffic.html #} +{% if summary %} + +{# ── Overview bar ─────────────────────────────────────────────────────────── #} +
+ + Total + {{ summary.total }} + + + Internal + {{ summary.internal }} + + + External + + {{ summary.external }} ({{ "%.1f"|format(summary.external_pct) }}%) + + + + Bytes + + {% set mb = summary.total_bytes / 1048576 %} + {% if mb >= 1024 %}{{ "%.1f"|format(mb / 1024) }} GB{% else %}{{ "%.1f"|format(mb) }} MB{% endif %} + + + + {# External % sparkline #} + + ext % over time + {{ summary.sparkline_external | safe }} + +
+ +{# ── Internal/external fill bar ───────────────────────────────────────────── #} +{% set ext_pct = summary.external_pct %} +
+
+
+ +{# ── Detail cards in columns ──────────────────────────────────────────────── #} +
+ + {# Top source IPs #} +
+
Top Source IPs
+ + {% for entry in summary.top_ips %} + + + {% if entry.country %} + + {% endif %} + + + {% endfor %} +
{{ entry.ip }}{{ entry.country }} + + {{ entry.count }} + +
+
+ + {# Top routers #} +
+
Top Services Hit
+ + {% for entry in summary.top_routers %} + + + + + {% endfor %} +
{{ entry.router }}{{ entry.count }}
+
+ + {# Top countries (only shown if GeoIP is active) #} + {% if summary.top_countries %} +
+
External Traffic by Country
+ + {% for entry in summary.top_countries %} + + + + + {% endfor %} +
{{ entry.country }}{{ entry.count }}
+
+ {% endif %} + +
+ +{% else %} +
+ No access log data yet. + {% if not access_log_enabled %} + Enable access_log.enabled: true in your plugin config and bind-mount the Traefik log directory. + {% endif %} +
+{% endif %} diff --git a/traefik/templates/traefik/widget.html b/traefik/templates/traefik/widget.html new file mode 100644 index 0000000..aa43085 --- /dev/null +++ b/traefik/templates/traefik/widget.html @@ -0,0 +1,56 @@ +{# plugins/traefik/templates/traefik/widget.html #} +{% if expiring_certs %} +
+{% for cert in expiring_certs %} +
+ ⚠ {{ cert.cn }} + {{ cert.days_remaining | int }}d +
+{% endfor %} +
+{% endif %} + +{% if router_data %} +{% for r in router_data %} +
+
+ {{ r.name }} +
+
+ + req/s + {{ "%.2f"|format(r.latest.request_rate) }} + + + bw + + {% set bps = r.latest.response_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 %} + + + + p95 + + {{ "%.0f"|format(r.latest.latency_p95_ms) }}ms + + + {% if r.latest.error_rate_5xx_pct > 0 %} + + {{ "%.1f"|format(r.latest.error_rate_5xx_pct) }}% 5xx + + {% endif %} +
+
+{% endfor %} +{% if total_routers > 5 %} +
+ +{{ total_routers - 5 }} more — view all → +
+{% endif %} +{% else %} +

No Traefik data yet.

+{% endif %} diff --git a/unifi/__init__.py b/unifi/__init__.py new file mode 100644 index 0000000..4bb589f --- /dev/null +++ b/unifi/__init__.py @@ -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 diff --git a/unifi/client.py b/unifi/client.py new file mode 100644 index 0000000..d18a7d8 --- /dev/null +++ b/unifi/client.py @@ -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 diff --git a/unifi/migrations/__init__.py b/unifi/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/unifi/migrations/env.py b/unifi/migrations/env.py new file mode 100644 index 0000000..56f15d5 --- /dev/null +++ b/unifi/migrations/env.py @@ -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() diff --git a/unifi/migrations/versions/__init__.py b/unifi/migrations/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/unifi/migrations/versions/unifi_001_initial.py b/unifi/migrations/versions/unifi_001_initial.py new file mode 100644 index 0000000..36158a3 --- /dev/null +++ b/unifi/migrations/versions/unifi_001_initial.py @@ -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") diff --git a/unifi/migrations/versions/unifi_002_expanded.py b/unifi/migrations/versions/unifi_002_expanded.py new file mode 100644 index 0000000..7336517 --- /dev/null +++ b/unifi/migrations/versions/unifi_002_expanded.py @@ -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") diff --git a/unifi/models.py b/unifi/models.py new file mode 100644 index 0000000..2ad90e9 --- /dev/null +++ b/unifi/models.py @@ -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)) diff --git a/unifi/plugin.yaml b/unifi/plugin.yaml new file mode 100644 index 0000000..edb8dc2 --- /dev/null +++ b/unifi/plugin.yaml @@ -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://github.com/bvandeusen/fabledscryer-plugins" +homepage: "https://github.com/bvandeusen/fabledscryer-plugins/tree/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 diff --git a/unifi/routes.py b/unifi/routes.py new file mode 100644 index 0000000..38e7489 --- /dev/null +++ b/unifi/routes.py @@ -0,0 +1,303 @@ +# plugins/unifi/routes.py +from __future__ import annotations +import json +from datetime import datetime, timezone +from quart import Blueprint, current_app, render_template, request +from sqlalchemy import Integer, cast, func, select + +from fabledscryer.auth.middleware import require_role +from fabledscryer.models.users import UserRole +from fabledscryer.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds, subsample +from .models import ( + UnifiAlarm, UnifiClientSnapshot, UnifiDevice, UnifiDpiSnapshot, + UnifiEvent, UnifiFwRule, UnifiKnownClient, UnifiNetwork, + UnifiPortForward, UnifiSpeedtestResult, UnifiWanStat, UnifiWlanStat, +) + +unifi_bp = Blueprint("unifi", __name__, template_folder="templates") + + +def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str: + if len(values) < 2: + return f'' + 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'' + f'' + f'' + ) + + +@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, + ) diff --git a/unifi/scheduler.py b/unifi/scheduler.py new file mode 100644 index 0000000..a50efa5 --- /dev/null +++ b/unifi/scheduler.py @@ -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: + logger.exception("UniFi initial login failed") + _client = None + return + + try: + health = await _client.get_health() + clients = await _client.get_active_clients() + devices = await _client.get_devices() + except Exception: + logger.exception("UniFi poll failed — will retry next tick") + _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: + logger.exception("UniFi expanded poll failed") + + +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), + ) diff --git a/unifi/templates/unifi/dpi.html b/unifi/templates/unifi/dpi.html new file mode 100644 index 0000000..6e085b7 --- /dev/null +++ b/unifi/templates/unifi/dpi.html @@ -0,0 +1,65 @@ +{# plugins/unifi/templates/unifi/dpi.html #} +{% if not snapshot %} +
+

No DPI data yet. DPI must be enabled on the UniFi controller.

+
+{% else %} +
+ + {# Category breakdown #} + {% if categories %} +
+
+ Traffic by Category + {{ snapshot.scraped_at.strftime("%H:%M") }} +
+ {% set total_bytes = categories | sum(attribute='rx_bytes') + categories | sum(attribute='tx_bytes') %} + + {% for cat in categories[:20] %} + {% set bytes = cat.rx_bytes + cat.tx_bytes %} + {% set pct = (bytes / total_bytes * 100) if total_bytes else 0 %} + + + + + + {% endfor %} +
{{ cat.cat_name or cat.cat_id }} + {% 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 %} + +
+
+
+
+
+ {% endif %} + + {# Top clients by DPI #} + {% if top_clients %} +
+
Top Clients by Traffic
+ + {% for c in top_clients %} + + + + + {% endfor %} +
+ {{ c.hostname or c.mac }} + {{ c.mac }} + + {% 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 %} +
+
+ {% endif %} + +
+{% endif %} diff --git a/unifi/templates/unifi/events.html b/unifi/templates/unifi/events.html new file mode 100644 index 0000000..79c4a21 --- /dev/null +++ b/unifi/templates/unifi/events.html @@ -0,0 +1,52 @@ +{# plugins/unifi/templates/unifi/events.html #} +{% if not events %} +
+

No events in the last {{ range_key }}.

+
+{% else %} +
+
+ Site Events + {{ events|length }} in {{ range_key }} +
+ + + + + + + + + + + + + + {% for ev in events %} + + + + + + + {% endfor %} +
TimeCategoryKeyMessage
+ {{ ev.event_time.strftime("%Y-%m-%d %H:%M:%S") }} + + + {{ ev.category }} + + + {{ ev.event_key.replace("EVT_", "") if ev.event_key else "" }} + + {{ ev.message }} + {% if ev.source_mac %} + {{ ev.source_mac }} + {% endif %} +
+
+{% endif %} diff --git a/unifi/templates/unifi/index.html b/unifi/templates/unifi/index.html new file mode 100644 index 0000000..b2113b6 --- /dev/null +++ b/unifi/templates/unifi/index.html @@ -0,0 +1,63 @@ +{# plugins/unifi/templates/unifi/index.html #} +{% extends "base.html" %} +{% block title %}UniFi — Fabled Scryer{% endblock %} +{% block content %} +
+
+

UniFi Network

+ polling every {{ poll_interval }}s +
+ {% include "_time_range.html" %} +
+ +{# Tab nav #} +
+ {% 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 %} + + {% endfor %} +
+ +
+
+ + +{% endblock %} diff --git a/unifi/templates/unifi/inventory.html b/unifi/templates/unifi/inventory.html new file mode 100644 index 0000000..62e99f7 --- /dev/null +++ b/unifi/templates/unifi/inventory.html @@ -0,0 +1,146 @@ +{# plugins/unifi/templates/unifi/inventory.html #} +
+ + {# Networks / VLANs #} + {% if networks %} +
+
Networks / VLANs
+ + + + + + + {% for net in networks %} + + + + + + {% endfor %} +
NameVLANSubnet
+ {{ net.name }} + {% if net.is_guest %} guest{% endif %} + + {{ net.vlan or "—" }} + + {{ net.ip_subnet or "—" }} +
+
+ {% endif %} + + {# Port forwards #} + {% if port_forwards %} +
+
Port Forwards
+ + {% for pf in port_forwards %} + + + + + {% endfor %} +
+ + {{ pf.name or "—" }} + + {% if not pf.enabled %} disabled{% endif %} + + {{ pf.proto }}:{{ pf.dst_port }} → {{ pf.fwd_ip }}:{{ pf.fwd_port }} +
+
+ {% endif %} + + {# Firewall rules, grouped by ruleset #} + {% if fw_rules %} + {% set ns = namespace(current_rs=None) %} +
+
Firewall Rules
+ + {% for rule in fw_rules %} + {% if rule.ruleset != ns.current_rs %} + {% set ns.current_rs = rule.ruleset %} + + + + {% endif %} + + + + + + {% endfor %} +
+ {{ rule.ruleset }} +
+ + {{ rule.name }} + + + + {{ rule.action }} + + + {% if rule.protocol %}{{ rule.protocol }}{% endif %} + {% if rule.src_address %} src:{{ rule.src_address }}{% endif %} + {% if rule.dst_address %} dst:{{ rule.dst_address }}{% endif %} +
+
+ {% endif %} + + {# Known clients #} + {% if known_clients %} +
+
+ Known Clients + {{ known_clients|length }} total +
+ + + + + + + {% for kc in known_clients[:50] %} + + + + + + {% endfor %} + {% if known_clients|length > 50 %} + + + + {% endif %} +
Name / MACIPLast Seen
+
+ {{ kc.name or kc.hostname or kc.mac }} + {% if kc.is_blocked %} blocked{% endif %} +
+ {% if kc.name or kc.hostname %} +
{{ kc.mac }}
+ {% endif %} + {% if kc.mac_vendor %} +
{{ kc.mac_vendor }}
+ {% endif %} +
+ {{ kc.ip or "—" }} + + {% if kc.last_seen %}{{ kc.last_seen.strftime("%m-%d %H:%M") }}{% else %}—{% endif %} +
+ +{{ known_clients|length - 50 }} more +
+
+ {% endif %} + + {% if not networks and not port_forwards and not fw_rules and not known_clients %} +
+

No inventory data yet. This populates on the next poll cycle.

+
+ {% endif %} + +
diff --git a/unifi/templates/unifi/rows.html b/unifi/templates/unifi/rows.html new file mode 100644 index 0000000..9de46ce --- /dev/null +++ b/unifi/templates/unifi/rows.html @@ -0,0 +1,203 @@ +{# plugins/unifi/templates/unifi/rows.html #} + +{# ── WAN health bar ───────────────────────────────────────────────────────── #} +{% if latest_wan %} +
+ + WAN + + {% if latest_wan.is_up %}✓ Up{% else %}✗ Down{% endif %} + + {% if latest_wan.wan_ip %} + {{ latest_wan.wan_ip }} + {% endif %} + + + {% if latest_wan.latency_ms is not none %} + + Latency + + {{ "%.1f"|format(latest_wan.latency_ms) }}ms + + {{ sparkline_latency | safe }} + + {% endif %} + + {% if latest_wan.rx_bytes_rate is not none %} + + ↓ RX + + {% 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 %} + + {{ sparkline_rx | safe }} + + + ↑ TX + + {% 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 %} + + {{ sparkline_tx | safe }} + + {% endif %} + + {% if latest_wan.uptime_seconds is not none %} + + up {{ (latest_wan.uptime_seconds // 3600) }}h {{ ((latest_wan.uptime_seconds % 3600) // 60) }}m + + {% endif %} +
+{% endif %} + +{# ── Content columns ──────────────────────────────────────────────────────── #} +
+ + {# Client summary card #} + {% if latest_clients %} +
+
+ Clients + {{ sparkline_clients | safe }} +
+
+ + {{ latest_clients.total_clients }} + total + + + {{ latest_clients.wireless_clients }}↗ wireless   + {{ latest_clients.wired_clients }}↔ wired + {% if latest_clients.guest_clients %}  {{ latest_clients.guest_clients }} guest{% endif %} + +
+
+ {% endif %} + + {# Infrastructure devices card #} + {% if devices %} +
+
Infrastructure Devices
+ + {% for d in devices %} + + + + + + {% if d.state == 1 and d.uptime_seconds %} + + + + {% endif %} + {% endfor %} +
+ + {{ d.name or d.mac }} + {{ d.model or d.device_type or "" }}{{ d.ip or "" }}
+ up {{ (d.uptime_seconds // 86400) }}d {{ ((d.uptime_seconds % 86400) // 3600) }}h +
+
+ {% endif %} + + {# Top talkers card #} + {% if top_clients %} +
+
Top Talkers
+ + {% for c in top_clients %} + + + + + {% endfor %} +
+ {{ c.hostname }} + {% if c.type == "wireless" and c.signal %} + {{ c.signal }}dBm + {% endif %} + + {% set rx = c.rx_rate %}{% set tx = c.tx_rate %} + + {% if rx >= 1048576 %}{{ "%.1f"|format(rx/1048576) }}M + {% elif rx >= 1024 %}{{ "%.0f"|format(rx/1024) }}K + {% else %}{{ "%.0f"|format(rx) }}B{% endif %} + + {% if tx >= 1048576 %}{{ "%.1f"|format(tx/1048576) }}M + {% elif tx >= 1024 %}{{ "%.0f"|format(tx/1024) }}K + {% else %}{{ "%.0f"|format(tx) }}B{% endif %} +
+
+ {% endif %} + + {# WLAN SSIDs card #} + {% if wlan_latest %} +
+
Wireless Networks
+ + + + + + + {% for w in wlan_latest %} + + + + + + {% endfor %} +
SSIDClientsScore
{{ w.ssid }}{{ w.num_sta }} + {% if w.satisfaction is not none %}{{ w.satisfaction }}%{% else %}—{% endif %} +
+
+ {% endif %} + + {# Speedtest card #} + {% if speedtest %} +
+
Last Speed Test
+
+ {% if speedtest.download_mbps is not none %} + + ↓ DL + {{ "%.1f"|format(speedtest.download_mbps) }} + Mbps + + {% endif %} + {% if speedtest.upload_mbps is not none %} + + ↑ UL + {{ "%.1f"|format(speedtest.upload_mbps) }} + Mbps + + {% endif %} + {% if speedtest.latency_ms is not none %} + + Latency + {{ "%.0f"|format(speedtest.latency_ms) }}ms + + {% endif %} +
+ {% if speedtest.server_name %} +
via {{ speedtest.server_name }}
+ {% endif %} +
{{ speedtest.run_at.strftime("%Y-%m-%d %H:%M") }} UTC
+
+ {% endif %} + +
+ +{% if not latest_wan and not devices %} +
+

No UniFi data yet. Check your host, username, and password in the plugin config.

+
+{% endif %} diff --git a/unifi/templates/unifi/security.html b/unifi/templates/unifi/security.html new file mode 100644 index 0000000..f28d4c0 --- /dev/null +++ b/unifi/templates/unifi/security.html @@ -0,0 +1,56 @@ +{# plugins/unifi/templates/unifi/security.html #} +
+ + {# Active alarms #} +
+
+ Active Alarms + {% if active_alarms %} + {{ active_alarms|length }} + {% endif %} +
+ {% if not active_alarms %} +

✓ No active alarms

+ {% else %} + + {% for al in active_alarms %} + + + + {% endfor %} +
+
+ ⚠ {{ al.alarm_key }} +
+
{{ al.message }}
+
+ {{ al.alarm_time.strftime("%Y-%m-%d %H:%M") }} UTC +
+
+ {% endif %} +
+ + {# Recent archived alarms #} + {% if archived_alarms %} +
+
+ Recent Archived Alarms + last {{ archived_alarms|length }} +
+ + {% for al in archived_alarms %} + + + + + {% endfor %} +
+
{{ al.alarm_key }}
+
{{ al.message }}
+
+ {{ al.alarm_time.strftime("%m-%d %H:%M") }} +
+
+ {% endif %} + +
diff --git a/unifi/templates/unifi/widget.html b/unifi/templates/unifi/widget.html new file mode 100644 index 0000000..2993ea6 --- /dev/null +++ b/unifi/templates/unifi/widget.html @@ -0,0 +1,95 @@ +{# plugins/unifi/templates/unifi/widget.html #} + +{# Active alarms #} +{% for al in active_alarms %} +
+ ⚠ {{ al.alarm_key }} + alarm +
+{% endfor %} +{# Offline device alerts #} +{% for d in offline_devices %} +
+ ⚠ {{ d.name or d.mac }} + offline +
+{% endfor %} +{% if active_alarms or offline_devices %}
{% endif %} + +{% if latest_wan %} +
+
+ WAN +
+
+ + {% if latest_wan.is_up %}Up{% else %}Down{% endif %} + + {% if latest_wan.latency_ms is not none %} + + lat + + {{ "%.0f"|format(latest_wan.latency_ms) }}ms + + + {% endif %} + {% if latest_wan.rx_bytes_rate is not none %} + + + {% 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 %} + + + + {% 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 %} + + {% endif %} +
+
+{% endif %} + +{% if latest_clients %} +
+
+ Clients +
+
+ {{ latest_clients.total_clients }} + {{ latest_clients.wireless_clients }}w / {{ latest_clients.wired_clients }}e +
+
+{% endif %} + +{% if top_clients %} +
+ {% for c in top_clients %} +
+
+ {{ c.hostname }} +
+
+ {% set total_bps = c.tx_rate + c.rx_rate %} + + {% 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 %} + + {% 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 %} +
+
+ {% endfor %} +
+{% endif %} + +{% if not latest_wan and not latest_clients and not active_alarms and not offline_devices %} +

No UniFi data yet.

+{% endif %} diff --git a/ups/__init__.py b/ups/__init__.py new file mode 100644 index 0000000..ad3c826 --- /dev/null +++ b/ups/__init__.py @@ -0,0 +1,24 @@ +# plugins/ups/__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 UpsStatus # noqa: F401 + + +def get_scheduled_tasks() -> list: + from .scheduler import make_poll_task + return [make_poll_task(_app)] + + +def get_blueprint(): + from .routes import ups_bp + return ups_bp diff --git a/ups/client.py b/ups/client.py new file mode 100644 index 0000000..cbf38fd --- /dev/null +++ b/ups/client.py @@ -0,0 +1,141 @@ +# plugins/ups/client.py +"""Async NUT (Network UPS Tools) client. + +Connects to NUT's TCP port (default 3493) and reads UPS variables. +Read-only: no write commands (test, shutdown via NUT) are implemented. +""" +from __future__ import annotations +import asyncio +import logging + +logger = logging.getLogger(__name__) + +# NUT status flag meanings +STATUS_FLAGS = { + "OL": "Online", + "OB": "On Battery", + "LB": "Low Battery", + "CHRG": "Charging", + "DISCHRG":"Discharging", + "BYPASS": "Bypass", + "CAL": "Calibrating", + "FSD": "Forced Shutdown", + "RB": "Replace Battery", + "OVER": "Overloaded", + "TRIM": "Trimming", + "BOOST": "Boosting", +} + + +class NutError(Exception): + pass + + +class NutClient: + """Minimal async NUT client — connects, reads vars, disconnects.""" + + def __init__( + self, + host: str = "localhost", + port: int = 3493, + username: str = "", + password: str = "", + timeout: float = 10.0, + ) -> None: + self.host = host + self.port = port + self.username = username + self.password = password + self.timeout = timeout + + async def get_vars(self, ups_name: str) -> dict[str, str]: + """Open a connection, authenticate if needed, fetch all UPS variables, close.""" + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(self.host, self.port), + timeout=self.timeout, + ) + except (OSError, asyncio.TimeoutError) as exc: + raise NutError(f"Cannot connect to NUT at {self.host}:{self.port}: {exc}") from exc + + try: + async def send(cmd: str) -> None: + writer.write((cmd + "\n").encode()) + await writer.drain() + + async def readline() -> str: + line = await asyncio.wait_for(reader.readline(), timeout=self.timeout) + return line.decode(errors="replace").strip() + + # Optional auth + if self.username: + await send(f"USERNAME {self.username}") + resp = await readline() + if not resp.startswith("OK"): + raise NutError(f"NUT auth USERNAME rejected: {resp}") + if self.password: + await send(f"PASSWORD {self.password}") + resp = await readline() + if not resp.startswith("OK"): + raise NutError(f"NUT auth PASSWORD rejected: {resp}") + + # Request variable list + await send(f"LIST VAR {ups_name}") + + vars_: dict[str, str] = {} + while True: + line = await readline() + if line.startswith("ERR"): + raise NutError(f"NUT error for UPS '{ups_name}': {line}") + if line.startswith(f"END LIST VAR {ups_name}"): + break + if line.startswith(f"VAR {ups_name} "): + # Format: VAR "" + rest = line[len(f"VAR {ups_name} "):] + if " " in rest: + varname, raw_val = rest.split(" ", 1) + vars_[varname] = raw_val.strip('"') + + await send("LOGOUT") + return vars_ + + finally: + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + + +def parse_status(vars_: dict[str, str]) -> dict: + """Extract structured fields from raw NUT variable dict.""" + raw_status = vars_.get("ups.status", "") + flags = set(raw_status.upper().split()) + + def _float(key: str) -> float | None: + try: + return float(vars_[key]) + except (KeyError, ValueError): + return None + + def _int(key: str) -> int | None: + try: + return int(float(vars_[key])) + except (KeyError, ValueError): + return None + + return { + "raw_status": raw_status, + "on_battery": "OB" in flags, + "low_battery": "LB" in flags, + "flags": sorted(flags), + "battery_charge_pct": _float("battery.charge"), + "battery_runtime_secs": _int("battery.runtime"), + "battery_voltage": _float("battery.voltage"), + "load_pct": _float("ups.load"), + "input_voltage": _float("input.voltage"), + "output_voltage": _float("output.voltage"), + "temperature": _float("ups.temperature"), + "model": vars_.get("ups.model") or vars_.get("device.model"), + "manufacturer": vars_.get("ups.mfr") or vars_.get("device.mfr"), + } diff --git a/ups/migrations/__init__.py b/ups/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ups/migrations/env.py b/ups/migrations/env.py new file mode 100644 index 0000000..087e9a3 --- /dev/null +++ b/ups/migrations/env.py @@ -0,0 +1,72 @@ +# plugins/ups/migrations/env.py +"""Alembic env.py for the UPS 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.ups.models import UpsStatus # 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() diff --git a/ups/migrations/versions/__init__.py b/ups/migrations/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ups/migrations/versions/ups_001_initial.py b/ups/migrations/versions/ups_001_initial.py new file mode 100644 index 0000000..9b89daa --- /dev/null +++ b/ups/migrations/versions/ups_001_initial.py @@ -0,0 +1,40 @@ +"""UPS plugin initial tables + +Revision ID: ups_001_initial +Revises: (none — branch off core via depends_on) +Create Date: 2026-03-21 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "ups_001_initial" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = "ups" +depends_on: Union[str, Sequence[str], None] = ("0004_app_settings",) + + +def upgrade() -> None: + op.create_table( + "ups_status", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("raw_status", sa.String(64), nullable=False, server_default=""), + sa.Column("on_battery", sa.Boolean, nullable=False, server_default="false"), + sa.Column("low_battery", sa.Boolean, nullable=False, server_default="false"), + sa.Column("battery_charge_pct", sa.Float, nullable=True), + sa.Column("battery_runtime_secs", sa.Integer, nullable=True), + sa.Column("battery_voltage", sa.Float, nullable=True), + sa.Column("load_pct", sa.Float, nullable=True), + sa.Column("input_voltage", sa.Float, nullable=True), + sa.Column("output_voltage", sa.Float, nullable=True), + sa.Column("temperature", sa.Float, nullable=True), + sa.Column("model", sa.String(128), nullable=True), + sa.Column("manufacturer", sa.String(128), nullable=True), + ) + op.create_index("ix_ups_status_scraped", "ups_status", ["scraped_at"]) + + +def downgrade() -> None: + op.drop_index("ix_ups_status_scraped", "ups_status") + op.drop_table("ups_status") diff --git a/ups/models.py b/ups/models.py new file mode 100644 index 0000000..054371f --- /dev/null +++ b/ups/models.py @@ -0,0 +1,30 @@ +# plugins/ups/models.py +from __future__ import annotations +import uuid +from datetime import datetime, timezone +from sqlalchemy import Boolean, DateTime, Float, Integer, String +from sqlalchemy.orm import Mapped, mapped_column +from fabledscryer.models.base import Base + + +class UpsStatus(Base): + __tablename__ = "ups_status" + + 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) + ) + raw_status: Mapped[str] = mapped_column(String(64), nullable=False, default="") + on_battery: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + low_battery: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + battery_charge_pct: Mapped[float | None] = mapped_column(Float, nullable=True) + battery_runtime_secs: Mapped[int | None] = mapped_column(Integer, nullable=True) + battery_voltage: Mapped[float | None] = mapped_column(Float, nullable=True) + load_pct: Mapped[float | None] = mapped_column(Float, nullable=True) + input_voltage: Mapped[float | None] = mapped_column(Float, nullable=True) + output_voltage: Mapped[float | None] = mapped_column(Float, nullable=True) + temperature: Mapped[float | None] = mapped_column(Float, nullable=True) + model: Mapped[str | None] = mapped_column(String(128), nullable=True) + manufacturer: Mapped[str | None] = mapped_column(String(128), nullable=True) diff --git a/ups/plugin.yaml b/ups/plugin.yaml new file mode 100644 index 0000000..4ee1a95 --- /dev/null +++ b/ups/plugin.yaml @@ -0,0 +1,24 @@ +name: ups +version: "1.0.0" +description: "UPS monitoring via NUT (Network UPS Tools) with Ansible shutdown automation" +author: "FabledScryer" +license: "MIT" +min_app_version: "0.1.0" +repository_url: "https://github.com/bvandeusen/fabledscryer-plugins" +homepage: "https://github.com/bvandeusen/fabledscryer-plugins/tree/main/ups" +tags: + - ups + - power + - nut + +config: + nut_host: "localhost" + nut_port: 3493 + ups_name: "ups" + nut_username: "" # leave blank if NUT is configured without auth + nut_password: "" + poll_interval_seconds: 30 + shutdown_after_seconds: 300 # seconds on battery before triggering shutdown (0 = disabled) + shutdown_source: "" # Ansible source name containing the shutdown playbook + shutdown_playbook: "" # relative path within source, e.g. "shutdown.yml" + shutdown_inventory: "hosts" # inventory filename within source root diff --git a/ups/routes.py b/ups/routes.py new file mode 100644 index 0000000..c2d1735 --- /dev/null +++ b/ups/routes.py @@ -0,0 +1,125 @@ +# plugins/ups/routes.py +from __future__ import annotations +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 UpsStatus +from .scheduler import _on_battery_since, _shutdown_triggered + +ups_bp = Blueprint("ups", __name__, template_folder="templates") + + +def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str: + if len(values) < 2: + return f'' + 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'' + f'' + f'' + ) + + +@ups_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( + "ups/index.html", + poll_interval=poll_interval, + current_range=current_range, + ) + + +@ups_bp.get("/rows") +@require_role(UserRole.viewer) +async def rows(): + """HTMX fragment: UPS status, battery trend, history charts.""" + since, range_key = parse_range(request.args.get("range")) + b_secs = bucket_seconds(since) + + bucket_col = ( + cast(func.strftime('%s', UpsStatus.scraped_at), Integer) / b_secs + ).label("bucket") + + async with current_app.db_sessionmaker() as db: + # Latest raw status + result = await db.execute( + select(UpsStatus).order_by(UpsStatus.scraped_at.desc()).limit(1) + ) + latest = result.scalar_one_or_none() + + # Bucketed history for sparklines + result = await db.execute( + select( + func.avg(UpsStatus.battery_charge_pct).label("battery_charge_pct"), + func.avg(UpsStatus.load_pct).label("load_pct"), + func.avg(UpsStatus.input_voltage).label("input_voltage"), + func.avg(UpsStatus.battery_runtime_secs).label("battery_runtime_secs"), + func.min(UpsStatus.scraped_at).label("scraped_at"), + bucket_col, + ) + .where(UpsStatus.scraped_at >= since) + .group_by(bucket_col) + .order_by(bucket_col) + ) + history = result.all() + + # On-battery event log within range + result = await db.execute( + select(UpsStatus) + .where(UpsStatus.scraped_at >= since) + .where(UpsStatus.on_battery == True) # noqa: E712 + .order_by(UpsStatus.scraped_at.desc()) + .limit(50) + ) + battery_events = result.scalars().all() + + sparkline_charge = _sparkline([r.battery_charge_pct or 0 for r in history]) + sparkline_load = _sparkline([r.load_pct or 0 for r in history]) + sparkline_voltage = _sparkline([r.input_voltage or 0 for r in history]) + + return await render_template( + "ups/rows.html", + latest=latest, + history=history, + battery_events=battery_events, + sparkline_charge=sparkline_charge, + sparkline_load=sparkline_load, + sparkline_voltage=sparkline_voltage, + range_key=range_key, + on_battery_since=_on_battery_since, + shutdown_triggered=_shutdown_triggered, + ) + + +@ups_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(UpsStatus).order_by(UpsStatus.scraped_at.desc()).limit(1) + ) + latest = result.scalar_one_or_none() + + return await render_template( + "ups/widget.html", + latest=latest, + on_battery_since=_on_battery_since, + shutdown_triggered=_shutdown_triggered, + ) diff --git a/ups/scheduler.py b/ups/scheduler.py new file mode 100644 index 0000000..9752abf --- /dev/null +++ b/ups/scheduler.py @@ -0,0 +1,185 @@ +# plugins/ups/scheduler.py +from __future__ import annotations +import asyncio +import logging +from datetime import datetime, timezone +from pathlib import Path +from typing import TYPE_CHECKING + +from fabledscryer.core.scheduler import ScheduledTask + +if TYPE_CHECKING: + from quart import Quart + +logger = logging.getLogger(__name__) + +# In-memory shutdown state. +# Limitation: resets on app restart. If the app restarts while on battery, +# the countdown begins again from zero. Given typical UPS runtimes (minutes), +# this is acceptable. A future improvement could persist this in the DB. +_on_battery_since: datetime | None = None +_shutdown_triggered: bool = False + + +def make_poll_task(app: "Quart") -> ScheduledTask: + cfg = app.config["PLUGINS"]["ups"] + interval = int(cfg.get("poll_interval_seconds", 30)) + + async def poll() -> None: + await _do_poll(app) + + return ScheduledTask( + name="ups_poll", + coro_factory=poll, + interval_seconds=interval, + run_on_startup=True, + ) + + +async def _do_poll(app: "Quart") -> None: + global _on_battery_since, _shutdown_triggered + + from .client import NutClient, NutError, parse_status + from .models import UpsStatus + from fabledscryer.core.alerts import record_metric + + cfg = app.config["PLUGINS"]["ups"] + client = NutClient( + host=cfg.get("nut_host", "localhost"), + port=int(cfg.get("nut_port", 3493)), + username=cfg.get("nut_username", ""), + password=cfg.get("nut_password", ""), + ) + + try: + raw_vars = await client.get_vars(cfg.get("ups_name", "ups")) + except NutError: + logger.exception("UPS poll failed") + return + + parsed = parse_status(raw_vars) + scraped_at = datetime.now(timezone.utc) + + status = UpsStatus( + scraped_at=scraped_at, + raw_status=parsed["raw_status"], + on_battery=parsed["on_battery"], + low_battery=parsed["low_battery"], + battery_charge_pct=parsed["battery_charge_pct"], + battery_runtime_secs=parsed["battery_runtime_secs"], + battery_voltage=parsed["battery_voltage"], + load_pct=parsed["load_pct"], + input_voltage=parsed["input_voltage"], + output_voltage=parsed["output_voltage"], + temperature=parsed["temperature"], + model=parsed["model"], + manufacturer=parsed["manufacturer"], + ) + + async with app.db_sessionmaker() as session: + async with session.begin(): + session.add(status) + + if parsed["battery_charge_pct"] is not None: + await record_metric( + session=session, + source_module="ups", + resource_name="ups", + metric_name="battery_charge_pct", + value=parsed["battery_charge_pct"], + ) + await record_metric( + session=session, + source_module="ups", + resource_name="ups", + metric_name="on_battery", + value=1.0 if parsed["on_battery"] else 0.0, + ) + if parsed["battery_runtime_secs"] is not None: + await record_metric( + session=session, + source_module="ups", + resource_name="ups", + metric_name="battery_runtime_secs", + value=float(parsed["battery_runtime_secs"]), + ) + + # ── Shutdown logic ──────────────────────────────────────────────────────── + shutdown_after = int(cfg.get("shutdown_after_seconds", 0)) + + if parsed["on_battery"]: + if _on_battery_since is None: + _on_battery_since = scraped_at + logger.warning("UPS switched to battery at %s", scraped_at.isoformat()) + + elapsed = (scraped_at - _on_battery_since).total_seconds() + logger.debug("UPS on battery for %.0f seconds (shutdown_after=%d)", elapsed, shutdown_after) + + if ( + not _shutdown_triggered + and shutdown_after > 0 + and elapsed >= shutdown_after + ): + logger.warning( + "UPS on battery for %.0fs — triggering shutdown playbook", elapsed + ) + _shutdown_triggered = True + asyncio.create_task(_run_shutdown(app, cfg)) + else: + if _on_battery_since is not None: + logger.info("UPS back on AC power after %.0fs on battery", + (scraped_at - _on_battery_since).total_seconds()) + _on_battery_since = None + _shutdown_triggered = False + + +async def _run_shutdown(app: "Quart", cfg: dict) -> None: + """Locate the configured Ansible playbook and run it via subprocess.""" + source_name = cfg.get("shutdown_source", "") + playbook_rel = cfg.get("shutdown_playbook", "") + inventory_name = cfg.get("shutdown_inventory", "hosts") + + if not source_name or not playbook_rel: + logger.error( + "UPS shutdown triggered but shutdown_source or shutdown_playbook not configured" + ) + return + + # Resolve source path from Ansible config + ansible_cfg = app.config.get("ANSIBLE", {}) + sources = ansible_cfg.get("sources", []) + source = next((s for s in sources if s.get("name") == source_name), None) + if not source: + logger.error("UPS shutdown: Ansible source %r not found", source_name) + return + + source_path = Path(source["path"]) + playbook_path = source_path / playbook_rel + inventory_path = source_path / inventory_name + + if not playbook_path.exists(): + logger.error("UPS shutdown: playbook not found at %s", playbook_path) + return + if not inventory_path.exists(): + logger.error("UPS shutdown: inventory not found at %s", inventory_path) + return + + logger.warning("UPS shutdown: running %s with inventory %s", playbook_path, inventory_path) + + try: + proc = await asyncio.create_subprocess_exec( + "ansible-playbook", str(playbook_path), "-i", str(inventory_path), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + cwd=str(source_path), + ) + assert proc.stdout is not None + async for line in proc.stdout: + logger.info("[ups-shutdown] %s", line.decode(errors="replace").rstrip()) + await proc.wait() + if proc.returncode == 0: + logger.warning("UPS shutdown playbook completed successfully") + else: + logger.error("UPS shutdown playbook exited with code %d", proc.returncode) + except Exception: + logger.exception("UPS shutdown playbook execution failed") diff --git a/ups/templates/ups/index.html b/ups/templates/ups/index.html new file mode 100644 index 0000000..2a0dcbb --- /dev/null +++ b/ups/templates/ups/index.html @@ -0,0 +1,19 @@ +{# plugins/ups/templates/ups/index.html #} +{% extends "base.html" %} +{% block title %}UPS — Fabled Scryer{% endblock %} +{% block content %} +
+
+

UPS Monitor

+ polling every {{ poll_interval }}s +
+ {% include "_time_range.html" %} +
+ +
+
+{% endblock %} diff --git a/ups/templates/ups/rows.html b/ups/templates/ups/rows.html new file mode 100644 index 0000000..4a09ba4 --- /dev/null +++ b/ups/templates/ups/rows.html @@ -0,0 +1,185 @@ +{# plugins/ups/templates/ups/rows.html #} + +{# ── On-battery alert banner ──────────────────────────────────────────────── #} +{% if latest and latest.on_battery %} +
+ + ⚠ On Battery Power + {% if on_battery_since %} + — {{ ((latest.scraped_at - on_battery_since).total_seconds() / 60) | int }}m elapsed + {% endif %} + + {% if shutdown_triggered %} + Shutdown sequence triggered + {% elif latest.low_battery %} + Low battery — shutdown imminent + {% endif %} +
+{% endif %} + +{% if not latest %} +
+

No UPS data yet. Check nut_host, nut_port, and ups_name in plugin settings.

+
+{% else %} + +
+ + {# ── Current status card ──────────────────────────────────────────────── #} +
+
+ Status + {% if latest.manufacturer or latest.model %} + + {{ latest.manufacturer or "" }} {{ latest.model or "" }} + + {% endif %} +
+ + {# Status pill #} +
+ + {% if latest.on_battery %}On Battery{% else %}Online{% endif %} + + {% if latest.low_battery %} + Low Battery + {% endif %} + {{ latest.raw_status }} +
+ + {# Battery charge bar #} + {% if latest.battery_charge_pct is not none %} +
+
+ Battery + + {{ "%.0f"|format(latest.battery_charge_pct) }}% + +
+
+
= 80 %}var(--green) + {% elif latest.battery_charge_pct >= 40 %}var(--yellow) + {% else %}var(--red){% endif %};transition:width 0.3s;">
+
+
+ {% endif %} + + {# Key metrics #} +
+ {% if latest.battery_runtime_secs is not none %} +
+
Runtime
+
+ {% set mins = latest.battery_runtime_secs // 60 %} + {% if mins >= 60 %}{{ mins // 60 }}h {{ mins % 60 }}m + {% else %}{{ mins }}m{% endif %} +
+
+ {% endif %} + {% if latest.load_pct is not none %} +
+
Load
+
+ {{ "%.0f"|format(latest.load_pct) }}% +
+
+ {% endif %} + {% if latest.input_voltage is not none %} +
+
Input V
+
{{ "%.1f"|format(latest.input_voltage) }}V
+
+ {% endif %} + {% if latest.output_voltage is not none %} +
+
Output V
+
{{ "%.1f"|format(latest.output_voltage) }}V
+
+ {% endif %} + {% if latest.battery_voltage is not none %} +
+
Batt V
+
{{ "%.1f"|format(latest.battery_voltage) }}V
+
+ {% endif %} + {% if latest.temperature is not none %} +
+
Temp
+
{{ "%.1f"|format(latest.temperature) }}°C
+
+ {% endif %} +
+
+ Last updated {{ latest.scraped_at.strftime("%H:%M:%S") }} UTC +
+
+ + {# ── Trend charts ─────────────────────────────────────────────────────── #} + {% if history %} +
+
+ Trends + {{ range_key }} +
+
+
+
+ Battery % + {% if latest.battery_charge_pct is not none %}{{ "%.0f"|format(latest.battery_charge_pct) }}%{% endif %} +
+ {{ sparkline_charge | safe }} +
+
+
+ Load % + {% if latest.load_pct is not none %}{{ "%.0f"|format(latest.load_pct) }}%{% endif %} +
+ {{ sparkline_load | safe }} +
+
+
+ Input Voltage + {% if latest.input_voltage is not none %}{{ "%.1f"|format(latest.input_voltage) }}V{% endif %} +
+ {{ sparkline_voltage | safe }} +
+
+
+ {% endif %} + + {# ── Battery event log ────────────────────────────────────────────────── #} + {% if battery_events %} +
+
+ Battery Events + {{ range_key }} +
+ + {% for ev in battery_events %} + + + + + + {% endfor %} +
+ {{ ev.scraped_at.strftime("%m-%d %H:%M") }} + + {{ ev.raw_status }} + + {% if ev.battery_charge_pct is not none %}{{ "%.0f"|format(ev.battery_charge_pct) }}%{% endif %} +
+
+ {% endif %} + +
+{% endif %} diff --git a/ups/templates/ups/widget.html b/ups/templates/ups/widget.html new file mode 100644 index 0000000..fc2c090 --- /dev/null +++ b/ups/templates/ups/widget.html @@ -0,0 +1,90 @@ +{# plugins/ups/templates/ups/widget.html #} +{% if not latest %} +

No UPS data yet.

+{% else %} + +{# On-battery alert #} +{% if latest.on_battery %} +
+ + ⚠ On Battery + {% if on_battery_since %} + — {{ ((latest.scraped_at - on_battery_since).total_seconds() / 60) | int }}m + {% endif %} + + {% if shutdown_triggered %} + Shutdown triggered + {% elif latest.low_battery %} + Low battery + {% endif %} +
+{% endif %} + +
+
+
Status
+
+ + {% if latest.on_battery %}Battery{% else %}Online{% endif %} + +
+
+ {% if latest.battery_runtime_secs is not none %} +
+
Runtime
+
+ {% set mins = latest.battery_runtime_secs // 60 %} + {% if mins >= 60 %}{{ mins // 60 }}h {{ mins % 60 }}m + {% else %}{{ mins }}m{% endif %} +
+
+ {% endif %} + {% if latest.load_pct is not none %} +
+
Load
+
+ {{ "%.0f"|format(latest.load_pct) }}% +
+
+ {% endif %} + {% if latest.input_voltage is not none %} +
+
Input V
+
{{ "%.0f"|format(latest.input_voltage) }}V
+
+ {% endif %} +
+ +{# Battery charge bar #} +{% if latest.battery_charge_pct is not none %} +
+
+ Battery + + {{ "%.0f"|format(latest.battery_charge_pct) }}% + +
+
+
= 80 %}var(--green) + {% elif latest.battery_charge_pct >= 40 %}var(--yellow) + {% else %}var(--red){% endif %};transition:width 0.3s;">
+
+
+{% endif %} + +
+ {% if latest.manufacturer or latest.model %} + {{ latest.manufacturer or "" }} {{ latest.model or "" }} · + {% endif %} + {{ latest.scraped_at.strftime("%H:%M:%S") }} UTC +
+{% endif %}