feat: initial release — traefik, unifi, ups plugins

First-party plugins for Fabled Scryer (https://github.com/bvandeusen/fabledscryer).

traefik: Prometheus metrics scraping, stats history, access log parsing (GeoIP optional)
unifi:   UniFi controller integration — WAN health, devices, clients, DPI, events
ups:     UPS monitoring via NUT (Network UPS Tools), Ansible shutdown automation

Each plugin follows the Fabled Scryer plugin contract:
  setup(app), get_scheduled_tasks(), get_blueprint()

index.yaml lists all three plugins in the catalog format. Populate
checksum_sha256 entries once release zips are published via GitHub Releases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 18:28:43 -04:00
commit ad4fcd1cfd
50 changed files with 4808 additions and 0 deletions
+29
View File
@@ -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
+59
View File
@@ -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
+30
View File
@@ -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
+199
View File
@@ -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
View File
+75
View File
@@ -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()
@@ -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")
@@ -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")
@@ -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")
+67
View File
@@ -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)
+21
View File
@@ -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
+277
View File
@@ -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'<svg width="{width}" height="{height}"></svg>'
mn, mx = min(values), max(values)
if mx == mn:
mx = mn + 1.0
step = width / (len(values) - 1)
pts = []
for i, v in enumerate(values):
x = i * step
y = height - (v - mn) / (mx - mn) * (height - 2) - 1
pts.append(f"{x:.1f},{y:.1f}")
poly = " ".join(pts)
return (
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" '
f'style="vertical-align:middle;">'
f'<polyline points="{poly}" fill="none" stroke="#6060c0" stroke-width="1.5"/>'
f'</svg>'
)
@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
)
+204
View File
@@ -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"],
)
+276
View File
@@ -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)
+31
View File
@@ -0,0 +1,31 @@
{# plugins/traefik/templates/traefik/index.html #}
{% extends "base.html" %}
{% block title %}Traefik — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.75rem;margin-bottom:1.5rem;">
<div style="display:flex;align-items:baseline;gap:1rem;">
<h1 class="page-title" style="margin-bottom:0;">Traefik Services</h1>
<span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span>
</div>
{% include "_time_range.html" %}
</div>
<div id="traefik-rows"
hx-get="/plugins/traefik/rows"
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
hx-include="#time-range"
hx-swap="innerHTML">
</div>
{% if access_log_enabled %}
<div style="margin-top:1.5rem;">
<h2 class="section-title" style="margin-bottom:1rem;">Traffic Origin</h2>
<div id="traefik-traffic"
hx-get="/plugins/traefik/traffic"
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
hx-include="#time-range"
hx-swap="innerHTML">
</div>
</div>
{% endif %}
{% endblock %}
+127
View File
@@ -0,0 +1,127 @@
{# plugins/traefik/templates/traefik/rows.html #}
{# ── Global stats bar ──────────────────────────────────────────────────────── #}
{% if global_stat %}
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;margin-bottom:1.25rem;padding:0.75rem 1rem;background:var(--card-bg);border:1px solid var(--border);border-radius:6px;font-size:0.85rem;font-variant-numeric:tabular-nums;">
{% if global_stat.open_conns_total is not none %}
<span>
<span style="color:var(--text-muted);">Open conns</span>
<span style="margin-left:0.4rem;font-weight:600;">{{ global_stat.open_conns_total | int }}</span>
</span>
{% endif %}
{% if global_stat.config_last_reload_success is not none %}
<span>
<span style="color:var(--text-muted);">Config</span>
{% if global_stat.config_last_reload_success == 1.0 %}
<span style="margin-left:0.4rem;color:var(--green);">&#10003; OK</span>
{% else %}
<span style="margin-left:0.4rem;color:var(--red);">&#10007; failed</span>
{% endif %}
{% if global_stat.config_reloads_total is not none %}
<span style="color:var(--text-dim);margin-left:0.3rem;">({{ global_stat.config_reloads_total | int }} reloads)</span>
{% endif %}
</span>
{% endif %}
{% if global_stat.process_memory_bytes is not none %}
<span>
<span style="color:var(--text-muted);">Memory</span>
{% set mb = (global_stat.process_memory_bytes / 1048576) %}
<span style="margin-left:0.4rem;">
{% if mb >= 1024 %}{{ "%.1f"|format(mb / 1024) }} GB{% else %}{{ "%.0f"|format(mb) }} MB{% endif %}
</span>
</span>
{% endif %}
<span style="margin-left:auto;color:var(--text-dim);font-size:0.78rem;">
{{ global_stat.scraped_at.strftime("%H:%M:%S") }} UTC
</span>
</div>
{% endif %}
{# ── Per-service cards + cert card in shared columns layout ───────────────── #}
{% if router_data or certs %}
<div style="column-width:340px;column-count:3;column-gap:1rem;">
{% for r in router_data %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<h3 class="section-title" style="margin-bottom:0.75rem;word-break:break-all;">{{ r.name }}</h3>
<table class="table" style="font-size:0.85rem;">
<tr>
<td style="color:var(--text-muted);padding:0.2rem 0;">Req/s</td>
<td style="padding:0.2rem 0.5rem;">{{ "%.2f"|format(r.latest.request_rate) }}</td>
<td style="padding:0.2rem 0;">{{ r.sparkline_req | safe }}</td>
</tr>
<tr>
<td style="color:var(--text-muted);padding:0.2rem 0;">Bandwidth</td>
<td style="padding:0.2rem 0.5rem;">
{% 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 %}
</td>
<td style="padding:0.2rem 0;"></td>
</tr>
<tr>
<td style="color:var(--text-muted);padding:0.2rem 0;">p95 ms</td>
<td style="padding:0.2rem 0.5rem;">{{ "%.1f"|format(r.latest.latency_p95_ms) }}</td>
<td style="padding:0.2rem 0;">{{ r.sparkline_p95 | safe }}</td>
</tr>
<tr>
<td style="color:var(--text-muted);padding:0.2rem 0;">p99 ms</td>
<td style="padding:0.2rem 0.5rem;">{{ "%.1f"|format(r.latest.latency_p99_ms) }}</td>
<td style="padding:0.2rem 0;"></td>
</tr>
<tr>
<td style="color:var(--text-muted);padding:0.2rem 0;">4xx %</td>
<td style="color:{% if r.latest.error_rate_4xx_pct > 1 %}var(--yellow){% else %}var(--green){% endif %};padding:0.2rem 0.5rem;">
{{ "%.1f"|format(r.latest.error_rate_4xx_pct) }}%
</td>
<td style="padding:0.2rem 0;"></td>
</tr>
<tr>
<td style="color:var(--text-muted);padding:0.2rem 0;">5xx %</td>
<td style="color:{% if r.latest.error_rate_5xx_pct > 0.1 %}var(--red){% else %}var(--green){% endif %};padding:0.2rem 0.5rem;">
{{ "%.1f"|format(r.latest.error_rate_5xx_pct) }}%
</td>
<td style="padding:0.2rem 0;">{{ r.sparkline_5xx | safe }}</td>
</tr>
</table>
<div style="color:var(--text-dim);font-size:0.75rem;margin-top:0.5rem;">
Last scraped {{ r.latest.scraped_at.strftime("%H:%M:%S") }} UTC
</div>
</div>
{% endfor %}
{% if certs %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">TLS Certificates</div>
<table class="table" style="font-size:0.83rem;width:100%;">
{% for cert in certs %}
<tr>
<td style="padding:0.2rem 0 0.2rem 0;word-break:break-all;">{{ cert.cn }}</td>
<td style="text-align:right;padding:0.2rem 0;white-space:nowrap;font-weight:600;
{% if cert.days_remaining < 7 %}color:var(--red);
{% elif cert.days_remaining < 30 %}color:var(--yellow);
{% else %}color:var(--green);{% endif %}">
{{ cert.days_remaining | int }}d
</td>
</tr>
<tr>
<td colspan="2" style="padding:0 0 0.4rem 0;color:var(--text-dim);font-size:0.75rem;">
{{ cert.not_after.strftime("%Y-%m-%d") }}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
</div>
{% else %}
<div class="card">
<p style="color:#606080;">No Traefik metrics yet. Configure <code>metrics_url</code> in <code>config.yaml</code> under <code>plugins.traefik</code>.</p>
</div>
{% endif %}
+101
View File
@@ -0,0 +1,101 @@
{# plugins/traefik/templates/traefik/traffic.html #}
{% if summary %}
{# ── Overview bar ─────────────────────────────────────────────────────────── #}
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;margin-bottom:1.25rem;padding:0.75rem 1rem;background:var(--card-bg);border:1px solid var(--border);border-radius:6px;font-size:0.85rem;font-variant-numeric:tabular-nums;align-items:center;">
<span>
<span style="color:var(--text-muted);">Total</span>
<span style="margin-left:0.4rem;font-weight:600;">{{ summary.total }}</span>
</span>
<span>
<span style="color:var(--text-muted);">Internal</span>
<span style="margin-left:0.4rem;color:var(--green);font-weight:600;">{{ summary.internal }}</span>
</span>
<span>
<span style="color:var(--text-muted);">External</span>
<span style="margin-left:0.4rem;color:{% if summary.external_pct > 50 %}var(--yellow){% else %}var(--green){% endif %};font-weight:600;">
{{ summary.external }} ({{ "%.1f"|format(summary.external_pct) }}%)
</span>
</span>
<span>
<span style="color:var(--text-muted);">Bytes</span>
<span style="margin-left:0.4rem;">
{% set mb = summary.total_bytes / 1048576 %}
{% if mb >= 1024 %}{{ "%.1f"|format(mb / 1024) }} GB{% else %}{{ "%.1f"|format(mb) }} MB{% endif %}
</span>
</span>
{# External % sparkline #}
<span style="margin-left:auto;">
<span style="color:var(--text-muted);font-size:0.78rem;margin-right:0.4rem;">ext % over time</span>
{{ summary.sparkline_external | safe }}
</span>
</div>
{# ── Internal/external fill bar ───────────────────────────────────────────── #}
{% set ext_pct = summary.external_pct %}
<div style="height:6px;border-radius:3px;background:var(--border);margin-bottom:1.25rem;overflow:hidden;">
<div style="height:100%;width:{{ ext_pct | round(1) }}%;background:var(--yellow);border-radius:3px;transition:width 0.4s;"></div>
</div>
{# ── Detail cards in columns ──────────────────────────────────────────────── #}
<div style="column-width:300px;column-count:3;column-gap:1rem;">
{# Top source IPs #}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Top Source IPs</div>
<table class="table" style="font-size:0.82rem;width:100%;">
{% for entry in summary.top_ips %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;font-family:monospace;">{{ entry.ip }}</td>
{% if entry.country %}
<td style="padding:0.2rem 0.3rem;color:var(--text-muted);">{{ entry.country }}</td>
{% endif %}
<td style="text-align:right;padding:0.2rem 0;">
<span style="color:{% if entry.internal %}var(--text-muted){% else %}var(--yellow){% endif %};">
{{ entry.count }}
</span>
</td>
</tr>
{% endfor %}
</table>
</div>
{# Top routers #}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Top Services Hit</div>
<table class="table" style="font-size:0.82rem;width:100%;">
{% for entry in summary.top_routers %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;word-break:break-all;">{{ entry.router }}</td>
<td style="text-align:right;padding:0.2rem 0;">{{ entry.count }}</td>
</tr>
{% endfor %}
</table>
</div>
{# Top countries (only shown if GeoIP is active) #}
{% if summary.top_countries %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">External Traffic by Country</div>
<table class="table" style="font-size:0.82rem;width:100%;">
{% for entry in summary.top_countries %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;">{{ entry.country }}</td>
<td style="text-align:right;padding:0.2rem 0;">{{ entry.count }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
</div>
{% else %}
<div class="card" style="color:var(--text-muted);font-size:0.85rem;">
No access log data yet.
{% if not access_log_enabled %}
Enable <code>access_log.enabled: true</code> in your plugin config and bind-mount the Traefik log directory.
{% endif %}
</div>
{% endif %}
+56
View File
@@ -0,0 +1,56 @@
{# plugins/traefik/templates/traefik/widget.html #}
{% if expiring_certs %}
<div style="margin-bottom:0.6rem;">
{% for cert in expiring_certs %}
<div style="display:flex;justify-content:space-between;font-size:0.8rem;padding:0.15rem 0;
{% if cert.days_remaining < 7 %}color:var(--red);{% else %}color:var(--yellow);{% endif %}">
<span>&#9888; {{ cert.cn }}</span>
<span>{{ cert.days_remaining | int }}d</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if router_data %}
{% for r in router_data %}
<div class="ping-row">
<div class="ping-meta">
<span class="ping-name" style="font-size:0.8rem;word-break:break-all;">{{ r.name }}</span>
</div>
<div style="flex:1;display:flex;gap:1.5rem;font-size:0.82rem;font-variant-numeric:tabular-nums;">
<span title="Requests/s">
<span style="color:var(--text-muted);">req/s</span>
<span style="margin-left:0.3rem;">{{ "%.2f"|format(r.latest.request_rate) }}</span>
</span>
<span title="Bandwidth">
<span style="color:var(--text-muted);">bw</span>
<span style="margin-left:0.3rem;">
{% 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 %}
</span>
</span>
<span title="p95 latency">
<span style="color:var(--text-muted);">p95</span>
<span style="margin-left:0.3rem;
{% if r.latest.latency_p95_ms > 500 %}color:var(--red){% elif r.latest.latency_p95_ms > 200 %}color:var(--yellow){% else %}color:var(--green){% endif %};">
{{ "%.0f"|format(r.latest.latency_p95_ms) }}ms
</span>
</span>
{% if r.latest.error_rate_5xx_pct > 0 %}
<span title="5xx error rate">
<span style="color:var(--red);">{{ "%.1f"|format(r.latest.error_rate_5xx_pct) }}% 5xx</span>
</span>
{% endif %}
</div>
</div>
{% endfor %}
{% if total_routers > 5 %}
<div style="color:var(--text-dim);font-size:0.75rem;padding:0.25rem 0;">
+{{ total_routers - 5 }} more — <a href="/plugins/traefik/">view all →</a>
</div>
{% endif %}
{% else %}
<p class="empty" style="padding:0.5rem 0;font-size:0.85rem;">No Traefik data yet.</p>
{% endif %}
+28
View File
@@ -0,0 +1,28 @@
# plugins/unifi/__init__.py
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from quart import Quart
_app: "Quart | None" = None
def setup(app: "Quart") -> None:
global _app
_app = app
from .models import ( # noqa: F401
UnifiWanStat, UnifiClientSnapshot, UnifiWlanStat, UnifiDpiSnapshot,
UnifiDevice, UnifiKnownClient, UnifiEvent, UnifiAlarm,
UnifiSpeedtestResult, UnifiNetwork, UnifiPortForward, UnifiFwRule,
)
def get_scheduled_tasks() -> list:
from .scheduler import make_poll_task
return [make_poll_task(_app)]
def get_blueprint():
from .routes import unifi_bp
return unifi_bp
+170
View File
@@ -0,0 +1,170 @@
# plugins/unifi/client.py
"""Async UniFi Network controller API client.
Supports UDM/UDM-Pro (firmware 2+) using the /proxy/network/ path prefix
and Bearer-token auth via the /api/auth/login endpoint.
On 401, re-authenticates automatically once before raising.
"""
from __future__ import annotations
import logging
import httpx
logger = logging.getLogger(__name__)
class UnifiClient:
def __init__(
self,
host: str,
username: str,
password: str,
site: str = "default",
verify_ssl: bool = False,
) -> None:
self._host = host.rstrip("/")
self._username = username
self._password = password
self._site = site
self._verify_ssl = verify_ssl
self._http: httpx.AsyncClient | None = None
self._csrf_token: str = ""
# ── Lifecycle ─────────────────────────────────────────────────────────────
async def _ensure_http(self) -> None:
if self._http is None:
self._http = httpx.AsyncClient(
verify=self._verify_ssl,
timeout=15.0,
follow_redirects=True,
)
async def login(self) -> None:
await self._ensure_http()
resp = await self._http.post(
f"{self._host}/api/auth/login",
json={"username": self._username, "password": self._password, "rememberMe": False},
)
resp.raise_for_status()
self._csrf_token = resp.headers.get("X-CSRF-Token", "")
logger.debug("UniFi login OK (csrf=%s…)", self._csrf_token[:8] if self._csrf_token else "none")
async def close(self) -> None:
if self._http:
await self._http.aclose()
self._http = None
# ── API helpers ───────────────────────────────────────────────────────────
def _api_url(self, path: str) -> str:
return f"{self._host}/proxy/network/api/s/{self._site}{path}"
def _headers(self) -> dict:
return {"X-CSRF-Token": self._csrf_token} if self._csrf_token else {}
async def _get(self, path: str, params: dict | None = None) -> list | dict:
await self._ensure_http()
resp = await self._http.get(self._api_url(path), headers=self._headers(), params=params)
if resp.status_code == 401:
await self.login()
resp = await self._http.get(self._api_url(path), headers=self._headers(), params=params)
resp.raise_for_status()
data = resp.json()
return data.get("data", data)
# ── Core monitoring ───────────────────────────────────────────────────────
async def get_health(self) -> list[dict]:
"""Subsystem health (wan, wlan, lan, www)."""
result = await self._get("/stat/health")
return result if isinstance(result, list) else []
async def get_active_clients(self) -> list[dict]:
"""Currently connected clients."""
result = await self._get("/stat/sta")
return result if isinstance(result, list) else []
async def get_devices(self) -> list[dict]:
"""Managed network devices (APs, switches, gateways) with full detail."""
result = await self._get("/stat/device")
return result if isinstance(result, list) else []
# ── Events & alarms ───────────────────────────────────────────────────────
async def get_events(self, limit: int = 200) -> list[dict]:
"""Recent site events (client connects, IDS alerts, device state changes, etc.)."""
result = await self._get("/stat/event", params={"_limit": limit, "_sort": "-time"})
return result if isinstance(result, list) else []
async def get_alarms(self, archived: bool = False) -> list[dict]:
"""Active (or archived) alarms."""
result = await self._get("/stat/alarm")
if not isinstance(result, list):
return []
return [a for a in result if a.get("archived", False) == archived]
# ── Traffic & DPI ─────────────────────────────────────────────────────────
async def get_dpi_stats(self) -> list[dict]:
"""DPI per-client application/category traffic stats (cumulative counters)."""
result = await self._get("/stat/dpi")
return result if isinstance(result, list) else []
async def get_dpi_site_stats(self) -> list[dict]:
"""DPI site-level category totals (not per-client)."""
result = await self._get("/dpi")
return result if isinstance(result, list) else []
async def get_speedtest_status(self) -> dict | None:
"""Most recent WAN speed test result."""
result = await self._get("/stat/speedtest-status")
if isinstance(result, list) and result:
return result[0]
if isinstance(result, dict):
return result
return None
# ── Network inventory ─────────────────────────────────────────────────────
async def get_known_clients(self) -> list[dict]:
"""All historically seen clients (not just currently connected)."""
result = await self._get("/rest/user")
return result if isinstance(result, list) else []
async def get_networks(self) -> list[dict]:
"""Network/VLAN configurations."""
result = await self._get("/rest/networkconf")
return result if isinstance(result, list) else []
async def get_wlan_configs(self) -> list[dict]:
"""WLAN (SSID) configurations."""
result = await self._get("/rest/wlanconf")
return result if isinstance(result, list) else []
async def get_port_forwards(self) -> list[dict]:
"""Port forwarding rules."""
result = await self._get("/rest/portforwarding")
return result if isinstance(result, list) else []
async def get_firewall_rules(self) -> list[dict]:
"""Firewall rules."""
result = await self._get("/rest/firewallrule")
return result if isinstance(result, list) else []
async def get_firewall_groups(self) -> list[dict]:
"""Firewall groups (IP sets / port sets referenced by rules)."""
result = await self._get("/rest/firewallgroup")
return result if isinstance(result, list) else []
# ── System ────────────────────────────────────────────────────────────────
async def get_sysinfo(self) -> dict | None:
"""Controller system information (version, uptime, hostname)."""
result = await self._get("/stat/sysinfo")
if isinstance(result, list) and result:
return result[0]
if isinstance(result, dict):
return result
return None
View File
+72
View File
@@ -0,0 +1,72 @@
# plugins/unifi/migrations/env.py
"""Alembic env.py for the UniFi plugin (standalone dev use).
At app startup, migration_runner.py uses the core env.py with version_locations.
"""
import asyncio
import os
import sys
from pathlib import Path
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from fabledscryer.models.base import Base
import fabledscryer.models # noqa: F401
from plugins.unifi.models import UnifiWanStat, UnifiClientSnapshot, UnifiDevice # noqa: F401
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def _get_url() -> str:
import yaml
cfg_path = os.environ.get("FABLEDSCRYER_CONFIG", "config.yaml")
try:
with open(cfg_path) as f:
cfg = yaml.safe_load(f) or {}
url = cfg.get("database", {}).get("url", "")
except FileNotFoundError:
url = ""
return os.environ.get("FABLEDSCRYER_DATABASE__URL", url)
def run_migrations_offline() -> None:
context.configure(
url=_get_url(), target_metadata=target_metadata,
literal_binds=True, dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
cfg = config.get_section(config.config_ini_section, {})
cfg["sqlalchemy.url"] = _get_url()
connectable = async_engine_from_config(cfg, prefix="sqlalchemy.", poolclass=pool.NullPool)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -0,0 +1,62 @@
"""UniFi plugin initial tables
Revision ID: unifi_001_initial
Revises: (none — branch off core via depends_on)
Create Date: 2026-03-20
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "unifi_001_initial"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = "unifi"
depends_on: Union[str, Sequence[str], None] = ("0004_app_settings",)
def upgrade() -> None:
op.create_table(
"unifi_wan_stats",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("is_up", sa.Boolean, nullable=False, server_default="false"),
sa.Column("wan_ip", sa.String(64), nullable=True),
sa.Column("latency_ms", sa.Float, nullable=True),
sa.Column("rx_bytes_rate", sa.Float, nullable=True),
sa.Column("tx_bytes_rate", sa.Float, nullable=True),
sa.Column("uptime_seconds", sa.Integer, nullable=True),
)
op.create_index("ix_unifi_wan_stats_scraped", "unifi_wan_stats", ["scraped_at"])
op.create_table(
"unifi_client_snapshots",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("total_clients", sa.Integer, nullable=False, server_default="0"),
sa.Column("wireless_clients", sa.Integer, nullable=False, server_default="0"),
sa.Column("wired_clients", sa.Integer, nullable=False, server_default="0"),
sa.Column("guest_clients", sa.Integer, nullable=False, server_default="0"),
sa.Column("top_clients_json", sa.Text, nullable=False, server_default="[]"),
)
op.create_index("ix_unifi_client_snapshots_scraped", "unifi_client_snapshots", ["scraped_at"])
op.create_table(
"unifi_devices",
sa.Column("mac", sa.String(32), primary_key=True),
sa.Column("name", sa.String(255), nullable=True),
sa.Column("model", sa.String(64), nullable=True),
sa.Column("device_type", sa.String(16), nullable=True),
sa.Column("ip", sa.String(64), nullable=True),
sa.Column("state", sa.Integer, nullable=False, server_default="0"),
sa.Column("uptime_seconds", sa.Integer, nullable=True),
sa.Column("last_seen", sa.DateTime(timezone=True), nullable=True),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
)
def downgrade() -> None:
op.drop_table("unifi_devices")
op.drop_index("ix_unifi_client_snapshots_scraped", "unifi_client_snapshots")
op.drop_table("unifi_client_snapshots")
op.drop_index("ix_unifi_wan_stats_scraped", "unifi_wan_stats")
op.drop_table("unifi_wan_stats")
@@ -0,0 +1,156 @@
"""UniFi expanded tables: wlan stats, dpi, events, alarms, known clients,
speedtest results, networks, port forwards, firewall rules.
Also adds missing 'version' column to unifi_devices.
Revision ID: unifi_002_expanded
Revises: unifi_001_initial
Create Date: 2026-03-20
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "unifi_002_expanded"
down_revision: Union[str, None] = "unifi_001_initial"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ── Patch unifi_devices to add missing version column ─────────────────────
op.add_column("unifi_devices", sa.Column("version", sa.String(64), nullable=True))
# ── WLAN stats (time-series per SSID+radio) ───────────────────────────────
op.create_table(
"unifi_wlan_stats",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ssid", sa.String(255), nullable=False),
sa.Column("radio", sa.String(8), nullable=True),
sa.Column("num_sta", sa.Integer, nullable=False, server_default="0"),
sa.Column("tx_bytes", sa.Float, nullable=True),
sa.Column("rx_bytes", sa.Float, nullable=True),
sa.Column("channel", sa.Integer, nullable=True),
sa.Column("satisfaction", sa.Integer, nullable=True),
)
op.create_index("ix_unifi_wlan_stats_scraped", "unifi_wlan_stats", ["scraped_at"])
# ── DPI snapshots (site-level category totals) ────────────────────────────
op.create_table(
"unifi_dpi_snapshots",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("by_category_json", sa.Text, nullable=False, server_default="[]"),
sa.Column("by_client_json", sa.Text, nullable=False, server_default="[]"),
)
op.create_index("ix_unifi_dpi_scraped", "unifi_dpi_snapshots", ["scraped_at"])
# ── Known clients (upserted by MAC) ───────────────────────────────────────
op.create_table(
"unifi_known_clients",
sa.Column("mac", sa.String(32), primary_key=True),
sa.Column("hostname", sa.String(255), nullable=True),
sa.Column("name", sa.String(255), nullable=True),
sa.Column("ip", sa.String(64), nullable=True),
sa.Column("mac_vendor", sa.String(128), nullable=True),
sa.Column("note", sa.Text, nullable=True),
sa.Column("is_blocked", sa.Boolean, nullable=False, server_default="false"),
sa.Column("first_seen", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_seen", sa.DateTime(timezone=True), nullable=True),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
)
# ── Events (upserted by UniFi event ID) ───────────────────────────────────
op.create_table(
"unifi_events",
sa.Column("unifi_id", sa.String(64), primary_key=True),
sa.Column("event_time", sa.DateTime(timezone=True), nullable=False),
sa.Column("event_key", sa.String(64), nullable=False),
sa.Column("category", sa.String(16), nullable=False),
sa.Column("message", sa.Text, nullable=False),
sa.Column("source_mac", sa.String(32), nullable=True),
sa.Column("source_ip", sa.String(64), nullable=True),
sa.Column("details_json", sa.Text, nullable=False, server_default="{}"),
)
op.create_index("ix_unifi_events_time", "unifi_events", ["event_time"])
# ── Alarms (upserted by UniFi alarm ID) ───────────────────────────────────
op.create_table(
"unifi_alarms",
sa.Column("unifi_id", sa.String(64), primary_key=True),
sa.Column("alarm_time", sa.DateTime(timezone=True), nullable=False),
sa.Column("alarm_key", sa.String(64), nullable=False),
sa.Column("message", sa.Text, nullable=False),
sa.Column("archived", sa.Boolean, nullable=False, server_default="false"),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_unifi_alarms_time", "unifi_alarms", ["alarm_time"])
# ── Speedtest results (upserted by run timestamp) ─────────────────────────
op.create_table(
"unifi_speedtest_results",
sa.Column("run_at", sa.DateTime(timezone=True), primary_key=True),
sa.Column("download_mbps", sa.Float, nullable=True),
sa.Column("upload_mbps", sa.Float, nullable=True),
sa.Column("latency_ms", sa.Float, nullable=True),
sa.Column("server_name", sa.String(255), nullable=True),
)
# ── Networks/VLANs (upserted by UniFi ID) ────────────────────────────────
op.create_table(
"unifi_networks",
sa.Column("unifi_id", sa.String(64), primary_key=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("purpose", sa.String(32), nullable=True),
sa.Column("vlan", sa.Integer, nullable=True),
sa.Column("ip_subnet", sa.String(64), nullable=True),
sa.Column("dhcp_enabled", sa.Boolean, nullable=False, server_default="false"),
sa.Column("is_guest", sa.Boolean, nullable=False, server_default="false"),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
)
# ── Port forwards (upserted by UniFi ID) ─────────────────────────────────
op.create_table(
"unifi_port_forwards",
sa.Column("unifi_id", sa.String(64), primary_key=True),
sa.Column("name", sa.String(255), nullable=True),
sa.Column("proto", sa.String(8), nullable=False, server_default="tcp"),
sa.Column("dst_port", sa.String(32), nullable=False),
sa.Column("fwd_ip", sa.String(64), nullable=False),
sa.Column("fwd_port", sa.String(32), nullable=False),
sa.Column("src_filter", sa.String(255), nullable=True),
sa.Column("enabled", sa.Boolean, nullable=False, server_default="true"),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
)
# ── Firewall rules (upserted by UniFi ID) ────────────────────────────────
op.create_table(
"unifi_fw_rules",
sa.Column("unifi_id", sa.String(64), primary_key=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("ruleset", sa.String(16), nullable=False),
sa.Column("rule_index", sa.Integer, nullable=True),
sa.Column("action", sa.String(16), nullable=False),
sa.Column("protocol", sa.String(16), nullable=True),
sa.Column("enabled", sa.Boolean, nullable=False, server_default="true"),
sa.Column("src_address", sa.String(255), nullable=True),
sa.Column("dst_address", sa.String(255), nullable=True),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
)
def downgrade() -> None:
op.drop_table("unifi_fw_rules")
op.drop_table("unifi_port_forwards")
op.drop_table("unifi_networks")
op.drop_table("unifi_speedtest_results")
op.drop_index("ix_unifi_alarms_time", "unifi_alarms")
op.drop_table("unifi_alarms")
op.drop_index("ix_unifi_events_time", "unifi_events")
op.drop_table("unifi_events")
op.drop_table("unifi_known_clients")
op.drop_index("ix_unifi_dpi_scraped", "unifi_dpi_snapshots")
op.drop_table("unifi_dpi_snapshots")
op.drop_index("ix_unifi_wlan_stats_scraped", "unifi_wlan_stats")
op.drop_table("unifi_wlan_stats")
op.drop_column("unifi_devices", "version")
+182
View File
@@ -0,0 +1,182 @@
# plugins/unifi/models.py
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledscryer.models.base import Base
# ── Time-series (one row per scrape) ──────────────────────────────────────────
class UnifiWanStat(Base):
"""WAN interface health — one row per scrape."""
__tablename__ = "unifi_wan_stats"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
is_up: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
wan_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
latency_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
rx_bytes_rate: Mapped[float | None] = mapped_column(Float, nullable=True)
tx_bytes_rate: Mapped[float | None] = mapped_column(Float, nullable=True)
uptime_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
class UnifiClientSnapshot(Base):
"""Aggregated client counts + top talkers — one row per scrape."""
__tablename__ = "unifi_client_snapshots"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
total_clients: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
wireless_clients: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
wired_clients: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
guest_clients: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
top_clients_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
# JSON: [{"mac","hostname","ip","type","tx_rate","rx_rate","signal","essid"}]
class UnifiWlanStat(Base):
"""Per-SSID/radio stats — one row per scrape per SSID+radio combination."""
__tablename__ = "unifi_wlan_stats"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
ssid: Mapped[str] = mapped_column(String(255), nullable=False)
radio: Mapped[str | None] = mapped_column(String(8), nullable=True) # ng=2.4GHz, na=5GHz, 6e=6GHz
num_sta: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
tx_bytes: Mapped[float | None] = mapped_column(Float, nullable=True)
rx_bytes: Mapped[float | None] = mapped_column(Float, nullable=True)
channel: Mapped[int | None] = mapped_column(Integer, nullable=True)
satisfaction: Mapped[int | None] = mapped_column(Integer, nullable=True) # 0-100
class UnifiDpiSnapshot(Base):
"""DPI application/category traffic snapshot — one row per scrape (JSON blob)."""
__tablename__ = "unifi_dpi_snapshots"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
# JSON: [{"cat_id", "cat_name", "rx_bytes", "tx_bytes"}] aggregated across all clients
by_category_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
# JSON: [{"mac", "hostname", "top_cats": [{"cat_name", "bytes"}]}]
by_client_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
# ── Upserted inventory (keyed by UniFi ID or MAC) ─────────────────────────────
class UnifiDevice(Base):
"""Network infrastructure devices — upserted by MAC each scrape."""
__tablename__ = "unifi_devices"
mac: Mapped[str] = mapped_column(String(32), primary_key=True)
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
model: Mapped[str | None] = mapped_column(String(64), nullable=True)
device_type: Mapped[str | None] = mapped_column(String(16), nullable=True)
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
state: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
uptime_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
version: Mapped[str | None] = mapped_column(String(64), nullable=True)
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
class UnifiKnownClient(Base):
"""All historically seen network clients — upserted by MAC."""
__tablename__ = "unifi_known_clients"
mac: Mapped[str] = mapped_column(String(32), primary_key=True)
hostname: Mapped[str | None] = mapped_column(String(255), nullable=True)
name: Mapped[str | None] = mapped_column(String(255), nullable=True) # user-set alias
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
mac_vendor: Mapped[str | None] = mapped_column(String(128), nullable=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
is_blocked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
first_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
class UnifiEvent(Base):
"""Site event log — upserted by UniFi event ID to avoid duplicates."""
__tablename__ = "unifi_events"
unifi_id: Mapped[str] = mapped_column(String(64), primary_key=True)
event_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
event_key: Mapped[str] = mapped_column(String(64), nullable=False) # e.g. EVT_WU_Connected
category: Mapped[str] = mapped_column(String(16), nullable=False) # wlan, wired, ap, ids, wan, system
message: Mapped[str] = mapped_column(Text, nullable=False)
source_mac: Mapped[str | None] = mapped_column(String(32), nullable=True)
source_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
details_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
class UnifiAlarm(Base):
"""Active alarms — upserted by UniFi alarm ID."""
__tablename__ = "unifi_alarms"
unifi_id: Mapped[str] = mapped_column(String(64), primary_key=True)
alarm_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
alarm_key: Mapped[str] = mapped_column(String(64), nullable=False)
message: Mapped[str] = mapped_column(Text, nullable=False)
archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
class UnifiSpeedtestResult(Base):
"""WAN speed test results — upserted by run timestamp."""
__tablename__ = "unifi_speedtest_results"
run_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), primary_key=True)
download_mbps: Mapped[float | None] = mapped_column(Float, nullable=True)
upload_mbps: Mapped[float | None] = mapped_column(Float, nullable=True)
latency_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
server_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
# ── Config inventory (upserted by UniFi ID) ───────────────────────────────────
class UnifiNetwork(Base):
"""VLAN/network configurations — upserted by UniFi ID."""
__tablename__ = "unifi_networks"
unifi_id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
purpose: Mapped[str | None] = mapped_column(String(32), nullable=True) # corporate, guest, vlan-only
vlan: Mapped[int | None] = mapped_column(Integer, nullable=True)
ip_subnet: Mapped[str | None] = mapped_column(String(64), nullable=True)
dhcp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_guest: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
class UnifiPortForward(Base):
"""Port forwarding rules — upserted by UniFi ID."""
__tablename__ = "unifi_port_forwards"
unifi_id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
proto: Mapped[str] = mapped_column(String(8), nullable=False, default="tcp")
dst_port: Mapped[str] = mapped_column(String(32), nullable=False)
fwd_ip: Mapped[str] = mapped_column(String(64), nullable=False)
fwd_port: Mapped[str] = mapped_column(String(32), nullable=False)
src_filter: Mapped[str | None] = mapped_column(String(255), nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
class UnifiFwRule(Base):
"""Firewall rules — upserted by UniFi ID."""
__tablename__ = "unifi_fw_rules"
unifi_id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
ruleset: Mapped[str] = mapped_column(String(16), nullable=False) # WAN_IN, LAN_IN, etc.
rule_index: Mapped[int | None] = mapped_column(Integer, nullable=True)
action: Mapped[str] = mapped_column(String(16), nullable=False) # accept, drop, reject
protocol: Mapped[str | None] = mapped_column(String(16), nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
src_address: Mapped[str | None] = mapped_column(String(255), nullable=True)
dst_address: Mapped[str | None] = mapped_column(String(255), nullable=True)
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
+21
View File
@@ -0,0 +1,21 @@
name: unifi
version: "1.0.0"
description: "UniFi Network controller integration — WAN health, devices, clients, DPI"
author: "FabledScryer"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://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
+303
View File
@@ -0,0 +1,303 @@
# plugins/unifi/routes.py
from __future__ import annotations
import json
from datetime import datetime, timezone
from quart import Blueprint, current_app, render_template, request
from sqlalchemy import Integer, cast, func, select
from fabledscryer.auth.middleware import require_role
from fabledscryer.models.users import UserRole
from fabledscryer.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds, subsample
from .models import (
UnifiAlarm, UnifiClientSnapshot, UnifiDevice, UnifiDpiSnapshot,
UnifiEvent, UnifiFwRule, UnifiKnownClient, UnifiNetwork,
UnifiPortForward, UnifiSpeedtestResult, UnifiWanStat, UnifiWlanStat,
)
unifi_bp = Blueprint("unifi", __name__, template_folder="templates")
def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str:
if len(values) < 2:
return f'<svg width="{width}" height="{height}"></svg>'
mn, mx = min(values), max(values)
if mx == mn:
mx = mn + 1.0
step = width / (len(values) - 1)
pts = []
for i, v in enumerate(values):
x = i * step
y = height - (v - mn) / (mx - mn) * (height - 2) - 1
pts.append(f"{x:.1f},{y:.1f}")
poly = " ".join(pts)
return (
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" '
f'style="vertical-align:middle;">'
f'<polyline points="{poly}" fill="none" stroke="#6060c0" stroke-width="1.5"/>'
f'</svg>'
)
@unifi_bp.get("/")
@require_role(UserRole.viewer)
async def index():
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
current_range = request.args.get("range", DEFAULT_RANGE)
return await render_template(
"unifi/index.html",
poll_interval=poll_interval,
current_range=current_range,
)
@unifi_bp.get("/rows")
@require_role(UserRole.viewer)
async def rows():
"""HTMX fragment: WAN stats, devices, clients, WLAN — scoped to selected range."""
since, range_key = parse_range(request.args.get("range"))
b_secs = bucket_seconds(since)
async with current_app.db_sessionmaker() as db:
# WAN history — bucketed for accurate full-range sparklines
wan_bucket = (
cast(func.strftime('%s', UnifiWanStat.scraped_at), Integer) / b_secs
).label("bucket")
result = await db.execute(
select(
func.avg(UnifiWanStat.latency_ms).label("latency_ms"),
func.avg(UnifiWanStat.rx_bytes_rate).label("rx_bytes_rate"),
func.avg(UnifiWanStat.tx_bytes_rate).label("tx_bytes_rate"),
func.min(UnifiWanStat.scraped_at).label("scraped_at"),
wan_bucket,
)
.where(UnifiWanStat.scraped_at >= since)
.group_by(wan_bucket)
.order_by(wan_bucket)
)
wan_history = result.all()
# Latest WAN — always most recent raw row for current displayed values
result = await db.execute(
select(UnifiWanStat).order_by(UnifiWanStat.scraped_at.desc()).limit(1)
)
latest_wan = result.scalar_one_or_none()
# Latest client snapshot (always most recent)
result = await db.execute(
select(UnifiClientSnapshot)
.order_by(UnifiClientSnapshot.scraped_at.desc())
.limit(1)
)
latest_clients = result.scalar_one_or_none()
# All devices
result = await db.execute(
select(UnifiDevice).order_by(UnifiDevice.state.desc(), UnifiDevice.name)
)
devices = result.scalars().all()
# Client count history — bucketed
cs_bucket = (
cast(func.strftime('%s', UnifiClientSnapshot.scraped_at), Integer) / b_secs
).label("bucket")
result = await db.execute(
select(
func.avg(UnifiClientSnapshot.total_clients).label("total_clients"),
func.min(UnifiClientSnapshot.scraped_at).label("scraped_at"),
cs_bucket,
)
.where(UnifiClientSnapshot.scraped_at >= since)
.group_by(cs_bucket)
.order_by(cs_bucket)
)
client_history = result.all()
# Latest WLAN stats (always most recent per SSID)
result = await db.execute(
select(UnifiWlanStat.ssid).distinct().order_by(UnifiWlanStat.ssid)
)
ssids = [row[0] for row in result.all()]
wlan_latest: list[UnifiWlanStat] = []
for ssid in ssids:
result = await db.execute(
select(UnifiWlanStat)
.where(UnifiWlanStat.ssid == ssid)
.order_by(UnifiWlanStat.scraped_at.desc())
.limit(1)
)
w = result.scalar_one_or_none()
if w:
wlan_latest.append(w)
# Latest speedtest
result = await db.execute(
select(UnifiSpeedtestResult)
.order_by(UnifiSpeedtestResult.run_at.desc())
.limit(1)
)
speedtest = result.scalar_one_or_none()
top_clients = json.loads(latest_clients.top_clients_json) if latest_clients else []
sparkline_latency = _sparkline([r.latency_ms or 0 for r in wan_history])
sparkline_rx = _sparkline([r.rx_bytes_rate or 0 for r in wan_history])
sparkline_tx = _sparkline([r.tx_bytes_rate or 0 for r in wan_history])
sparkline_clients = _sparkline([r.total_clients for r in client_history])
return await render_template(
"unifi/rows.html",
latest_wan=latest_wan,
latest_clients=latest_clients,
devices=devices,
top_clients=top_clients,
wlan_latest=wlan_latest,
speedtest=speedtest,
sparkline_latency=sparkline_latency,
sparkline_rx=sparkline_rx,
sparkline_tx=sparkline_tx,
sparkline_clients=sparkline_clients,
range_key=range_key,
)
@unifi_bp.get("/events")
@require_role(UserRole.viewer)
async def events():
"""HTMX fragment: site events within selected range."""
since, range_key = parse_range(request.args.get("range"))
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(UnifiEvent)
.where(UnifiEvent.event_time >= since)
.order_by(UnifiEvent.event_time.desc())
.limit(500)
)
event_rows = result.scalars().all()
return await render_template(
"unifi/events.html", events=event_rows, range_key=range_key
)
@unifi_bp.get("/dpi")
@require_role(UserRole.viewer)
async def dpi():
"""HTMX fragment: DPI traffic breakdown (latest snapshot)."""
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(UnifiDpiSnapshot)
.order_by(UnifiDpiSnapshot.scraped_at.desc())
.limit(1)
)
snapshot = result.scalar_one_or_none()
categories = []
top_clients = []
if snapshot:
categories = json.loads(snapshot.by_category_json)
top_clients = json.loads(snapshot.by_client_json)
return await render_template(
"unifi/dpi.html", categories=categories, top_clients=top_clients, snapshot=snapshot
)
@unifi_bp.get("/security")
@require_role(UserRole.viewer)
async def security():
"""HTMX fragment: active and archived alarms."""
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(UnifiAlarm)
.where(UnifiAlarm.archived == False) # noqa: E712
.order_by(UnifiAlarm.alarm_time.desc())
)
active_alarms = result.scalars().all()
result = await db.execute(
select(UnifiAlarm)
.where(UnifiAlarm.archived == True) # noqa: E712
.order_by(UnifiAlarm.alarm_time.desc())
.limit(20)
)
archived_alarms = result.scalars().all()
return await render_template(
"unifi/security.html",
active_alarms=active_alarms,
archived_alarms=archived_alarms,
)
@unifi_bp.get("/inventory")
@require_role(UserRole.viewer)
async def inventory():
"""HTMX fragment: known clients, networks, port forwards, firewall rules."""
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(UnifiKnownClient)
.order_by(UnifiKnownClient.last_seen.desc().nullslast())
)
known_clients = result.scalars().all()
result = await db.execute(
select(UnifiNetwork).order_by(UnifiNetwork.name)
)
networks = result.scalars().all()
result = await db.execute(
select(UnifiPortForward).order_by(UnifiPortForward.name)
)
port_forwards = result.scalars().all()
result = await db.execute(
select(UnifiFwRule)
.order_by(UnifiFwRule.ruleset, UnifiFwRule.rule_index)
)
fw_rules = result.scalars().all()
return await render_template(
"unifi/inventory.html",
known_clients=known_clients,
networks=networks,
port_forwards=port_forwards,
fw_rules=fw_rules,
)
@unifi_bp.get("/widget")
@require_role(UserRole.viewer)
async def widget():
"""HTMX dashboard widget fragment."""
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(UnifiWanStat).order_by(UnifiWanStat.scraped_at.desc()).limit(1)
)
latest_wan = result.scalar_one_or_none()
result = await db.execute(
select(UnifiClientSnapshot).order_by(UnifiClientSnapshot.scraped_at.desc()).limit(1)
)
latest_clients = result.scalar_one_or_none()
result = await db.execute(select(UnifiDevice))
devices = result.scalars().all()
result = await db.execute(
select(UnifiAlarm).where(UnifiAlarm.archived == False) # noqa: E712
)
active_alarms = result.scalars().all()
offline_devices = [d for d in devices if d.state != 1]
top_clients = json.loads(latest_clients.top_clients_json)[:5] if latest_clients else []
return await render_template(
"unifi/widget.html",
latest_wan=latest_wan,
latest_clients=latest_clients,
offline_devices=offline_devices,
active_alarms=active_alarms,
top_clients=top_clients,
)
+522
View File
@@ -0,0 +1,522 @@
# plugins/unifi/scheduler.py
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from fabledscryer.core.scheduler import ScheduledTask
if TYPE_CHECKING:
from quart import Quart
logger = logging.getLogger(__name__)
_client = None # UnifiClient instance, initialised on first scrape
def make_poll_task(app: "Quart") -> ScheduledTask:
cfg = app.config["PLUGINS"]["unifi"]
interval = int(cfg.get("poll_interval_seconds", 60))
async def poll() -> None:
await _do_poll(app)
return ScheduledTask(
name="unifi_poll",
coro_factory=poll,
interval_seconds=interval,
run_on_startup=True,
)
async def _do_poll(app: "Quart") -> None:
global _client
from .client import UnifiClient
from .models import UnifiClientSnapshot, UnifiDevice, UnifiWanStat
from fabledscryer.core.alerts import record_metric
cfg = app.config["PLUGINS"]["unifi"]
if _client is None:
_client = UnifiClient(
host=cfg["host"],
username=cfg["username"],
password=cfg["password"],
site=cfg.get("site", "default"),
verify_ssl=cfg.get("verify_ssl", False),
)
try:
await _client.login()
except Exception:
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),
)
+65
View File
@@ -0,0 +1,65 @@
{# plugins/unifi/templates/unifi/dpi.html #}
{% if not snapshot %}
<div class="card">
<p style="color:var(--text-muted);">No DPI data yet. DPI must be enabled on the UniFi controller.</p>
</div>
{% else %}
<div style="column-width:340px;column-count:3;column-gap:1rem;">
{# Category breakdown #}
{% if categories %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">
Traffic by Category
<span style="float:right;font-weight:normal;font-size:0.78rem;color:var(--text-dim);">{{ snapshot.scraped_at.strftime("%H:%M") }}</span>
</div>
{% set total_bytes = categories | sum(attribute='rx_bytes') + categories | sum(attribute='tx_bytes') %}
<table class="table" style="width:100%;font-size:0.82rem;">
{% for cat in categories[:20] %}
{% set bytes = cat.rx_bytes + cat.tx_bytes %}
{% set pct = (bytes / total_bytes * 100) if total_bytes else 0 %}
<tr>
<td style="padding:0.25rem 0.5rem 0.25rem 0;">{{ cat.cat_name or cat.cat_id }}</td>
<td style="padding:0.25rem 0.3rem;text-align:right;color:var(--text-muted);font-size:0.78rem;white-space:nowrap;">
{% if bytes >= 1073741824 %}{{ "%.1f"|format(bytes / 1073741824) }} GB
{% elif bytes >= 1048576 %}{{ "%.1f"|format(bytes / 1048576) }} MB
{% elif bytes >= 1024 %}{{ "%.0f"|format(bytes / 1024) }} KB
{% else %}{{ bytes }} B{% endif %}
</td>
<td style="padding:0.25rem 0;width:4rem;">
<div style="background:var(--border);border-radius:2px;height:6px;overflow:hidden;">
<div style="background:var(--accent);height:100%;width:{{ "%.1f"|format(pct) }}%;"></div>
</div>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{# Top clients by DPI #}
{% if top_clients %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Top Clients by Traffic</div>
<table class="table" style="width:100%;font-size:0.82rem;">
{% for c in top_clients %}
<tr>
<td style="padding:0.25rem 0.5rem 0.25rem 0;word-break:break-all;">
{{ c.hostname or c.mac }}
<span style="color:var(--text-dim);font-size:0.75rem;font-family:monospace;margin-left:0.3rem;">{{ c.mac }}</span>
</td>
<td style="padding:0.25rem 0;text-align:right;font-size:0.78rem;color:var(--text-muted);white-space:nowrap;">
{% set bytes = c.bytes %}
{% if bytes >= 1073741824 %}{{ "%.1f"|format(bytes / 1073741824) }} GB
{% elif bytes >= 1048576 %}{{ "%.1f"|format(bytes / 1048576) }} MB
{% elif bytes >= 1024 %}{{ "%.0f"|format(bytes / 1024) }} KB
{% else %}{{ bytes }} B{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
</div>
{% endif %}
+52
View File
@@ -0,0 +1,52 @@
{# plugins/unifi/templates/unifi/events.html #}
{% if not events %}
<div class="card">
<p style="color:var(--text-muted);">No events in the last {{ range_key }}.</p>
</div>
{% else %}
<div class="card">
<div class="section-title" style="margin-bottom:0.75rem;">
Site Events
<span style="float:right;font-weight:normal;font-size:0.8rem;color:var(--text-dim);">{{ events|length }} in {{ range_key }}</span>
</div>
<table class="table" style="width:100%;font-size:0.82rem;">
<colgroup>
<col style="width:12rem;">
<col style="width:5rem;">
<col style="width:5rem;">
<col>
</colgroup>
<tr style="color:var(--text-muted);font-size:0.78rem;">
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.4rem 0;">Time</th>
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.4rem;">Category</th>
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.4rem;">Key</th>
<th style="text-align:left;font-weight:normal;padding:0 0 0.4rem;">Message</th>
</tr>
{% for ev in events %}
<tr style="{% if loop.index is divisibleby 2 %}background:var(--row-alt,rgba(255,255,255,0.02));{% endif %}">
<td style="padding:0.25rem 0.5rem 0.25rem 0;color:var(--text-dim);font-size:0.77rem;white-space:nowrap;">
{{ ev.event_time.strftime("%Y-%m-%d %H:%M:%S") }}
</td>
<td style="padding:0.25rem 0.5rem;">
<span style="font-size:0.75rem;padding:0.1rem 0.4rem;border-radius:3px;background:
{% if ev.category == 'wlan' %}rgba(96,96,192,0.25)
{% elif ev.category == 'wan' %}rgba(96,160,96,0.25)
{% elif ev.category == 'ids' %}rgba(192,64,64,0.25)
{% else %}rgba(128,128,128,0.15){% endif %};">
{{ ev.category }}
</span>
</td>
<td style="padding:0.25rem 0.5rem;color:var(--text-dim);font-family:monospace;font-size:0.75rem;">
{{ ev.event_key.replace("EVT_", "") if ev.event_key else "" }}
</td>
<td style="padding:0.25rem 0;color:var(--text);">
{{ ev.message }}
{% if ev.source_mac %}
<span style="color:var(--text-dim);font-size:0.75rem;font-family:monospace;margin-left:0.4rem;">{{ ev.source_mac }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
+63
View File
@@ -0,0 +1,63 @@
{# plugins/unifi/templates/unifi/index.html #}
{% extends "base.html" %}
{% block title %}UniFi — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.75rem;margin-bottom:1.25rem;">
<div style="display:flex;align-items:baseline;gap:1rem;">
<h1 class="page-title" style="margin-bottom:0;">UniFi Network</h1>
<span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span>
</div>
{% include "_time_range.html" %}
</div>
{# Tab nav #}
<div style="display:flex;gap:0.25rem;margin-bottom:1.25rem;border-bottom:1px solid var(--border);padding-bottom:0;">
{% set tabs = [
("overview", "Overview", "/plugins/unifi/rows"),
("events", "Events", "/plugins/unifi/events"),
("dpi", "Traffic", "/plugins/unifi/dpi"),
("security", "Security", "/plugins/unifi/security"),
("inventory", "Inventory", "/plugins/unifi/inventory"),
] %}
{% for tab_id, label, url in tabs %}
<button
class="tab-btn{% if loop.first %} tab-active{% endif %}"
data-tab="{{ tab_id }}"
data-url="{{ url }}"
onclick="setUnifiTab(this)"
style="background:none;border:none;border-bottom:2px solid {% if loop.first %}var(--accent){% else %}transparent{% endif %};padding:0.4rem 0.9rem;cursor:pointer;font-size:0.88rem;color:{% if loop.first %}var(--text){% else %}var(--text-muted){% endif %};margin-bottom:-1px;">
{{ label }}
</button>
{% endfor %}
</div>
<div id="unifi-content"
hx-get="/plugins/unifi/rows"
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
hx-include="#time-range"
hx-swap="innerHTML">
</div>
<script>
function setUnifiTab(btn) {
document.querySelectorAll('.tab-btn').forEach(function(b) {
b.style.borderBottomColor = 'transparent';
b.style.color = 'var(--text-muted)';
});
btn.style.borderBottomColor = 'var(--accent)';
btn.style.color = 'var(--text)';
var el = document.getElementById('unifi-content');
el.setAttribute('hx-get', btn.dataset.url);
// Remove auto-poll on non-overview tabs (inventory/security don't change rapidly)
var isLive = btn.dataset.tab === 'overview' || btn.dataset.tab === 'events';
el.setAttribute('hx-trigger',
isLive
? 'tabActivated, every {{ poll_interval }}s, rangeChange from:body'
: 'tabActivated, rangeChange from:body'
);
htmx.process(el);
htmx.trigger(el, 'tabActivated');
}
</script>
{% endblock %}
+146
View File
@@ -0,0 +1,146 @@
{# plugins/unifi/templates/unifi/inventory.html #}
<div style="column-width:360px;column-count:3;column-gap:1rem;">
{# Networks / VLANs #}
{% if networks %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Networks / VLANs</div>
<table class="table" style="width:100%;font-size:0.82rem;">
<tr style="color:var(--text-muted);font-size:0.78rem;">
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.3rem 0;">Name</th>
<th style="text-align:right;font-weight:normal;padding:0 0.5rem 0.3rem;">VLAN</th>
<th style="text-align:left;font-weight:normal;padding:0 0 0.3rem;">Subnet</th>
</tr>
{% for net in networks %}
<tr>
<td style="padding:0.25rem 0.5rem 0.25rem 0;">
{{ net.name }}
{% if net.is_guest %}<span style="font-size:0.72rem;color:var(--text-dim);"> guest</span>{% endif %}
</td>
<td style="padding:0.25rem 0.5rem;text-align:right;color:var(--text-muted);font-family:monospace;font-size:0.78rem;">
{{ net.vlan or "—" }}
</td>
<td style="padding:0.25rem 0;color:var(--text-dim);font-family:monospace;font-size:0.78rem;">
{{ net.ip_subnet or "—" }}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{# Port forwards #}
{% if port_forwards %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Port Forwards</div>
<table class="table" style="width:100%;font-size:0.82rem;">
{% for pf in port_forwards %}
<tr>
<td style="padding:0.25rem 0.5rem 0.25rem 0;">
<span style="{% if not pf.enabled %}color:var(--text-dim);{% endif %}">
{{ pf.name or "—" }}
</span>
{% if not pf.enabled %}<span style="font-size:0.72rem;color:var(--text-dim);"> disabled</span>{% endif %}
</td>
<td style="padding:0.25rem 0;font-family:monospace;font-size:0.78rem;color:var(--text-muted);white-space:nowrap;">
{{ pf.proto }}:{{ pf.dst_port }} → {{ pf.fwd_ip }}:{{ pf.fwd_port }}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{# Firewall rules, grouped by ruleset #}
{% if fw_rules %}
{% set ns = namespace(current_rs=None) %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Firewall Rules</div>
<table class="table" style="width:100%;font-size:0.82rem;">
{% for rule in fw_rules %}
{% if rule.ruleset != ns.current_rs %}
{% set ns.current_rs = rule.ruleset %}
<tr>
<td colspan="3" style="padding:0.4rem 0 0.2rem;color:var(--text-muted);font-size:0.75rem;font-weight:600;letter-spacing:0.04em;text-transform:uppercase;">
{{ rule.ruleset }}
</td>
</tr>
{% endif %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;">
<span style="{% if not rule.enabled %}color:var(--text-dim);{% endif %}">
{{ rule.name }}
</span>
</td>
<td style="padding:0.2rem 0.3rem;white-space:nowrap;">
<span style="font-size:0.75rem;padding:0.1rem 0.35rem;border-radius:3px;background:
{% if rule.action == 'accept' %}rgba(64,160,64,0.2)
{% elif rule.action == 'drop' %}rgba(192,64,64,0.2)
{% else %}rgba(192,128,64,0.2){% endif %};">
{{ rule.action }}
</span>
</td>
<td style="padding:0.2rem 0;color:var(--text-dim);font-size:0.76rem;">
{% if rule.protocol %}{{ rule.protocol }}{% endif %}
{% if rule.src_address %} src:{{ rule.src_address }}{% endif %}
{% if rule.dst_address %} dst:{{ rule.dst_address }}{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{# Known clients #}
{% if known_clients %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">
Known Clients
<span style="float:right;font-weight:normal;font-size:0.78rem;color:var(--text-dim);">{{ known_clients|length }} total</span>
</div>
<table class="table" style="width:100%;font-size:0.82rem;">
<tr style="color:var(--text-muted);font-size:0.78rem;">
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.3rem 0;">Name / MAC</th>
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.3rem;">IP</th>
<th style="text-align:right;font-weight:normal;padding:0 0 0.3rem;">Last Seen</th>
</tr>
{% for kc in known_clients[:50] %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;">
<div style="{% if kc.is_blocked %}color:var(--red);{% endif %}">
{{ kc.name or kc.hostname or kc.mac }}
{% if kc.is_blocked %}<span style="font-size:0.72rem;"> blocked</span>{% endif %}
</div>
{% if kc.name or kc.hostname %}
<div style="color:var(--text-dim);font-size:0.74rem;font-family:monospace;">{{ kc.mac }}</div>
{% endif %}
{% if kc.mac_vendor %}
<div style="color:var(--text-dim);font-size:0.73rem;">{{ kc.mac_vendor }}</div>
{% endif %}
</td>
<td style="padding:0.2rem 0.3rem;color:var(--text-muted);font-family:monospace;font-size:0.78rem;">
{{ kc.ip or "—" }}
</td>
<td style="padding:0.2rem 0;text-align:right;color:var(--text-dim);font-size:0.75rem;white-space:nowrap;">
{% if kc.last_seen %}{{ kc.last_seen.strftime("%m-%d %H:%M") }}{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
{% if known_clients|length > 50 %}
<tr>
<td colspan="3" style="padding:0.4rem 0;color:var(--text-dim);font-size:0.78rem;text-align:center;">
+{{ known_clients|length - 50 }} more
</td>
</tr>
{% endif %}
</table>
</div>
{% endif %}
{% if not networks and not port_forwards and not fw_rules and not known_clients %}
<div class="card">
<p style="color:var(--text-muted);">No inventory data yet. This populates on the next poll cycle.</p>
</div>
{% endif %}
</div>
+203
View File
@@ -0,0 +1,203 @@
{# plugins/unifi/templates/unifi/rows.html #}
{# ── WAN health bar ───────────────────────────────────────────────────────── #}
{% if latest_wan %}
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;margin-bottom:1.25rem;padding:0.75rem 1rem;background:var(--card-bg);border:1px solid var(--border);border-radius:6px;font-size:0.85rem;font-variant-numeric:tabular-nums;align-items:center;">
<span>
<span style="color:var(--text-muted);">WAN</span>
<span style="margin-left:0.4rem;font-weight:600;color:{% if latest_wan.is_up %}var(--green){% else %}var(--red){% endif %};">
{% if latest_wan.is_up %}&#10003; Up{% else %}&#10007; Down{% endif %}
</span>
{% if latest_wan.wan_ip %}
<span style="color:var(--text-dim);margin-left:0.4rem;font-family:monospace;font-size:0.8rem;">{{ latest_wan.wan_ip }}</span>
{% endif %}
</span>
{% if latest_wan.latency_ms is not none %}
<span>
<span style="color:var(--text-muted);">Latency</span>
<span style="margin-left:0.4rem;
{% if latest_wan.latency_ms > 100 %}color:var(--red){% elif latest_wan.latency_ms > 50 %}color:var(--yellow){% else %}color:var(--green){% endif %};">
{{ "%.1f"|format(latest_wan.latency_ms) }}ms
</span>
<span style="margin-left:0.3rem;">{{ sparkline_latency | safe }}</span>
</span>
{% endif %}
{% if latest_wan.rx_bytes_rate is not none %}
<span>
<span style="color:var(--text-muted);">&#8595; RX</span>
<span style="margin-left:0.3rem;">
{% set bps = latest_wan.rx_bytes_rate %}
{% if bps >= 1048576 %}{{ "%.1f"|format(bps / 1048576) }} MB/s
{% elif bps >= 1024 %}{{ "%.0f"|format(bps / 1024) }} KB/s
{% else %}{{ "%.0f"|format(bps) }} B/s{% endif %}
</span>
<span style="margin-left:0.3rem;">{{ sparkline_rx | safe }}</span>
</span>
<span>
<span style="color:var(--text-muted);">&#8593; TX</span>
<span style="margin-left:0.3rem;">
{% set bps = latest_wan.tx_bytes_rate %}
{% if bps >= 1048576 %}{{ "%.1f"|format(bps / 1048576) }} MB/s
{% elif bps >= 1024 %}{{ "%.0f"|format(bps / 1024) }} KB/s
{% else %}{{ "%.0f"|format(bps) }} B/s{% endif %}
</span>
<span style="margin-left:0.3rem;">{{ sparkline_tx | safe }}</span>
</span>
{% endif %}
{% if latest_wan.uptime_seconds is not none %}
<span style="color:var(--text-dim);font-size:0.8rem;">
up {{ (latest_wan.uptime_seconds // 3600) }}h {{ ((latest_wan.uptime_seconds % 3600) // 60) }}m
</span>
{% endif %}
</div>
{% endif %}
{# ── Content columns ──────────────────────────────────────────────────────── #}
<div style="column-width:340px;column-count:3;column-gap:1rem;">
{# Client summary card #}
{% if latest_clients %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">
Clients
<span style="float:right;font-weight:normal;color:var(--text-muted);font-size:0.82rem;">{{ sparkline_clients | safe }}</span>
</div>
<div style="display:flex;gap:1.5rem;font-size:0.9rem;margin-bottom:0.75rem;font-variant-numeric:tabular-nums;">
<span>
<span style="font-size:1.4rem;font-weight:700;color:var(--text);">{{ latest_clients.total_clients }}</span>
<span style="color:var(--text-muted);font-size:0.8rem;margin-left:0.2rem;">total</span>
</span>
<span style="color:var(--text-muted);font-size:0.85rem;align-self:flex-end;">
{{ latest_clients.wireless_clients }}&#x2197; wireless &nbsp;
{{ latest_clients.wired_clients }}&#x2194; wired
{% if latest_clients.guest_clients %}&nbsp; {{ latest_clients.guest_clients }} guest{% endif %}
</span>
</div>
</div>
{% endif %}
{# Infrastructure devices card #}
{% if devices %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Infrastructure Devices</div>
<table class="table" style="font-size:0.83rem;width:100%;">
{% for d in devices %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;">
<span style="color:{% if d.state == 1 %}var(--green){% else %}var(--red){% endif %};">&#9679;</span>
<span style="margin-left:0.3rem;">{{ d.name or d.mac }}</span>
</td>
<td style="padding:0.2rem 0.3rem;color:var(--text-muted);font-size:0.78rem;">{{ d.model or d.device_type or "" }}</td>
<td style="text-align:right;padding:0.2rem 0;color:var(--text-dim);font-size:0.78rem;font-family:monospace;">{{ d.ip or "" }}</td>
</tr>
{% if d.state == 1 and d.uptime_seconds %}
<tr>
<td colspan="3" style="padding:0 0 0.3rem 1.2rem;color:var(--text-dim);font-size:0.75rem;">
up {{ (d.uptime_seconds // 86400) }}d {{ ((d.uptime_seconds % 86400) // 3600) }}h
</td>
</tr>
{% endif %}
{% endfor %}
</table>
</div>
{% endif %}
{# Top talkers card #}
{% if top_clients %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Top Talkers</div>
<table class="table" style="font-size:0.82rem;width:100%;">
{% for c in top_clients %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;word-break:break-all;">
{{ c.hostname }}
{% if c.type == "wireless" and c.signal %}
<span style="color:var(--text-dim);font-size:0.75rem;"> {{ c.signal }}dBm</span>
{% endif %}
</td>
<td style="text-align:right;padding:0.2rem 0;white-space:nowrap;font-size:0.78rem;">
{% set rx = c.rx_rate %}{% set tx = c.tx_rate %}
<span style="color:var(--text-muted);">&#8595;</span>
{% if rx >= 1048576 %}{{ "%.1f"|format(rx/1048576) }}M
{% elif rx >= 1024 %}{{ "%.0f"|format(rx/1024) }}K
{% else %}{{ "%.0f"|format(rx) }}B{% endif %}
<span style="color:var(--text-muted);margin-left:0.3rem;">&#8593;</span>
{% if tx >= 1048576 %}{{ "%.1f"|format(tx/1048576) }}M
{% elif tx >= 1024 %}{{ "%.0f"|format(tx/1024) }}K
{% else %}{{ "%.0f"|format(tx) }}B{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{# WLAN SSIDs card #}
{% if wlan_latest %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Wireless Networks</div>
<table class="table" style="font-size:0.83rem;width:100%;">
<tr style="color:var(--text-muted);font-size:0.78rem;">
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.3rem 0;">SSID</th>
<th style="text-align:right;font-weight:normal;padding:0 0.3rem 0.3rem;">Clients</th>
<th style="text-align:right;font-weight:normal;padding:0 0 0.3rem;">Score</th>
</tr>
{% for w in wlan_latest %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;">{{ w.ssid }}</td>
<td style="text-align:right;padding:0.2rem 0.3rem;">{{ w.num_sta }}</td>
<td style="text-align:right;padding:0.2rem 0;
{% if w.satisfaction is not none %}
{% if w.satisfaction >= 80 %}color:var(--green){% elif w.satisfaction >= 60 %}color:var(--yellow){% else %}color:var(--red){% endif %}
{% endif %};">
{% if w.satisfaction is not none %}{{ w.satisfaction }}%{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{# Speedtest card #}
{% if speedtest %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Last Speed Test</div>
<div style="display:flex;gap:1.5rem;font-size:0.88rem;font-variant-numeric:tabular-nums;">
{% if speedtest.download_mbps is not none %}
<span>
<span style="color:var(--text-muted);font-size:0.8rem;">&#8595; DL</span>
<span style="font-weight:600;margin-left:0.3rem;">{{ "%.1f"|format(speedtest.download_mbps) }}</span>
<span style="color:var(--text-muted);font-size:0.78rem;">Mbps</span>
</span>
{% endif %}
{% if speedtest.upload_mbps is not none %}
<span>
<span style="color:var(--text-muted);font-size:0.8rem;">&#8593; UL</span>
<span style="font-weight:600;margin-left:0.3rem;">{{ "%.1f"|format(speedtest.upload_mbps) }}</span>
<span style="color:var(--text-muted);font-size:0.78rem;">Mbps</span>
</span>
{% endif %}
{% if speedtest.latency_ms is not none %}
<span>
<span style="color:var(--text-muted);font-size:0.8rem;">Latency</span>
<span style="margin-left:0.3rem;">{{ "%.0f"|format(speedtest.latency_ms) }}ms</span>
</span>
{% endif %}
</div>
{% if speedtest.server_name %}
<div style="margin-top:0.4rem;color:var(--text-dim);font-size:0.78rem;">via {{ speedtest.server_name }}</div>
{% endif %}
<div style="margin-top:0.25rem;color:var(--text-dim);font-size:0.75rem;">{{ speedtest.run_at.strftime("%Y-%m-%d %H:%M") }} UTC</div>
</div>
{% endif %}
</div>
{% if not latest_wan and not devices %}
<div class="card">
<p style="color:var(--text-muted);">No UniFi data yet. Check your <code>host</code>, <code>username</code>, and <code>password</code> in the plugin config.</p>
</div>
{% endif %}
+56
View File
@@ -0,0 +1,56 @@
{# plugins/unifi/templates/unifi/security.html #}
<div style="column-width:340px;column-count:2;column-gap:1rem;">
{# Active alarms #}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">
Active Alarms
{% if active_alarms %}
<span style="float:right;background:var(--red);color:#fff;font-size:0.72rem;padding:0.1rem 0.45rem;border-radius:10px;font-weight:600;">{{ active_alarms|length }}</span>
{% endif %}
</div>
{% if not active_alarms %}
<p style="color:var(--green);font-size:0.88rem;">&#10003; No active alarms</p>
{% else %}
<table class="table" style="width:100%;font-size:0.82rem;">
{% for al in active_alarms %}
<tr>
<td style="padding:0.3rem 0.5rem 0.3rem 0;">
<div style="color:var(--red);font-size:0.78rem;margin-bottom:0.1rem;">
&#9888; {{ al.alarm_key }}
</div>
<div>{{ al.message }}</div>
<div style="color:var(--text-dim);font-size:0.75rem;margin-top:0.1rem;">
{{ al.alarm_time.strftime("%Y-%m-%d %H:%M") }} UTC
</div>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
</div>
{# Recent archived alarms #}
{% if archived_alarms %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">
Recent Archived Alarms
<span style="float:right;font-weight:normal;font-size:0.78rem;color:var(--text-dim);">last {{ archived_alarms|length }}</span>
</div>
<table class="table" style="width:100%;font-size:0.82rem;">
{% for al in archived_alarms %}
<tr>
<td style="padding:0.25rem 0.5rem 0.25rem 0;">
<div style="color:var(--text-muted);font-size:0.78rem;">{{ al.alarm_key }}</div>
<div style="color:var(--text-dim);font-size:0.8rem;">{{ al.message }}</div>
</td>
<td style="padding:0.25rem 0;text-align:right;color:var(--text-dim);font-size:0.75rem;white-space:nowrap;">
{{ al.alarm_time.strftime("%m-%d %H:%M") }}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
</div>
+95
View File
@@ -0,0 +1,95 @@
{# plugins/unifi/templates/unifi/widget.html #}
{# Active alarms #}
{% for al in active_alarms %}
<div style="display:flex;justify-content:space-between;font-size:0.8rem;color:var(--red);padding:0.15rem 0;">
<span>&#9888; {{ al.alarm_key }}</span>
<span style="color:var(--text-dim);font-size:0.75rem;">alarm</span>
</div>
{% endfor %}
{# Offline device alerts #}
{% for d in offline_devices %}
<div style="display:flex;justify-content:space-between;font-size:0.8rem;color:var(--red);padding:0.15rem 0;">
<span>&#9888; {{ d.name or d.mac }}</span>
<span>offline</span>
</div>
{% endfor %}
{% if active_alarms or offline_devices %}<div style="height:0.4rem;"></div>{% endif %}
{% if latest_wan %}
<div class="ping-row">
<div class="ping-meta">
<span class="ping-name" style="font-size:0.8rem;">WAN</span>
</div>
<div style="flex:1;display:flex;gap:1.5rem;font-size:0.82rem;font-variant-numeric:tabular-nums;">
<span style="color:{% if latest_wan.is_up %}var(--green){% else %}var(--red){% endif %};">
{% if latest_wan.is_up %}Up{% else %}Down{% endif %}
</span>
{% if latest_wan.latency_ms is not none %}
<span>
<span style="color:var(--text-muted);">lat</span>
<span style="margin-left:0.2rem;{% if latest_wan.latency_ms > 100 %}color:var(--red){% elif latest_wan.latency_ms > 50 %}color:var(--yellow){% endif %};">
{{ "%.0f"|format(latest_wan.latency_ms) }}ms
</span>
</span>
{% endif %}
{% if latest_wan.rx_bytes_rate is not none %}
<span>
<span style="color:var(--text-muted);">&#8595;</span>
{% set bps = latest_wan.rx_bytes_rate %}
{% if bps >= 1048576 %}{{ "%.1f"|format(bps / 1048576) }}MB/s
{% elif bps >= 1024 %}{{ "%.0f"|format(bps / 1024) }}KB/s
{% else %}{{ "%.0f"|format(bps) }}B/s{% endif %}
</span>
<span>
<span style="color:var(--text-muted);">&#8593;</span>
{% set bps = latest_wan.tx_bytes_rate %}
{% if bps >= 1048576 %}{{ "%.1f"|format(bps / 1048576) }}MB/s
{% elif bps >= 1024 %}{{ "%.0f"|format(bps / 1024) }}KB/s
{% else %}{{ "%.0f"|format(bps) }}B/s{% endif %}
</span>
{% endif %}
</div>
</div>
{% endif %}
{% if latest_clients %}
<div class="ping-row">
<div class="ping-meta">
<span class="ping-name" style="font-size:0.8rem;">Clients</span>
</div>
<div style="flex:1;display:flex;gap:1rem;font-size:0.82rem;">
<span style="font-weight:600;">{{ latest_clients.total_clients }}</span>
<span style="color:var(--text-muted);">{{ latest_clients.wireless_clients }}w / {{ latest_clients.wired_clients }}e</span>
</div>
</div>
{% endif %}
{% if top_clients %}
<div style="margin-top:0.4rem;border-top:1px solid var(--border);padding-top:0.4rem;">
{% for c in top_clients %}
<div class="ping-row" style="padding:0.1rem 0;">
<div class="ping-meta" style="min-width:0;flex:1;">
<span style="font-size:0.78rem;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ c.hostname }}</span>
</div>
<div style="font-size:0.78rem;font-variant-numeric:tabular-nums;white-space:nowrap;color:var(--text-muted);">
{% set total_bps = c.tx_rate + c.rx_rate %}
<span style="color:var(--text-muted);">&#8595;</span>
{% set rx = c.rx_rate %}
{% if rx >= 1048576 %}{{ "%.1f"|format(rx / 1048576) }}M
{% elif rx >= 1024 %}{{ "%.0f"|format(rx / 1024) }}K
{% else %}{{ "%.0f"|format(rx) }}B{% endif %}
<span style="color:var(--text-muted);margin-left:0.4rem;">&#8593;</span>
{% set tx = c.tx_rate %}
{% if tx >= 1048576 %}{{ "%.1f"|format(tx / 1048576) }}M
{% elif tx >= 1024 %}{{ "%.0f"|format(tx / 1024) }}K
{% else %}{{ "%.0f"|format(tx) }}B{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if not latest_wan and not latest_clients and not active_alarms and not offline_devices %}
<p class="empty" style="padding:0.5rem 0;font-size:0.85rem;">No UniFi data yet.</p>
{% endif %}
+24
View File
@@ -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
+141
View File
@@ -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 <upsname> <varname> "<value>"
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"),
}
View File
+72
View File
@@ -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()
View File
@@ -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")
+30
View File
@@ -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)
+24
View File
@@ -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
+125
View File
@@ -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'<svg width="{width}" height="{height}"></svg>'
mn, mx = min(values), max(values)
if mx == mn:
mx = mn + 1.0
step = width / (len(values) - 1)
pts = []
for i, v in enumerate(values):
x = i * step
y = height - (v - mn) / (mx - mn) * (height - 2) - 1
pts.append(f"{x:.1f},{y:.1f}")
poly = " ".join(pts)
return (
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" '
f'style="vertical-align:middle;">'
f'<polyline points="{poly}" fill="none" stroke="#6060c0" stroke-width="1.5"/>'
f'</svg>'
)
@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,
)
+185
View File
@@ -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")
+19
View File
@@ -0,0 +1,19 @@
{# plugins/ups/templates/ups/index.html #}
{% extends "base.html" %}
{% block title %}UPS — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.75rem;margin-bottom:1.5rem;">
<div style="display:flex;align-items:baseline;gap:1rem;">
<h1 class="page-title" style="margin-bottom:0;">UPS Monitor</h1>
<span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span>
</div>
{% include "_time_range.html" %}
</div>
<div id="ups-rows"
hx-get="/plugins/ups/rows"
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
hx-include="#time-range"
hx-swap="innerHTML">
</div>
{% endblock %}
+185
View File
@@ -0,0 +1,185 @@
{# plugins/ups/templates/ups/rows.html #}
{# ── On-battery alert banner ──────────────────────────────────────────────── #}
{% if latest and latest.on_battery %}
<div style="background:var(--red-dim);border:1px solid var(--red);border-radius:6px;padding:0.75rem 1rem;margin-bottom:1.25rem;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.5rem;">
<span style="color:var(--red);font-weight:600;font-size:0.95rem;">
&#9888; On Battery Power
{% if on_battery_since %}
— {{ ((latest.scraped_at - on_battery_since).total_seconds() / 60) | int }}m elapsed
{% endif %}
</span>
{% if shutdown_triggered %}
<span style="color:var(--red);font-size:0.85rem;font-weight:600;">Shutdown sequence triggered</span>
{% elif latest.low_battery %}
<span style="color:var(--red);font-size:0.85rem;">Low battery — shutdown imminent</span>
{% endif %}
</div>
{% endif %}
{% if not latest %}
<div class="card">
<p style="color:var(--text-muted);">No UPS data yet. Check <code>nut_host</code>, <code>nut_port</code>, and <code>ups_name</code> in plugin settings.</p>
</div>
{% else %}
<div style="column-width:340px;column-count:3;column-gap:1rem;">
{# ── Current status card ──────────────────────────────────────────────── #}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">
Status
{% if latest.manufacturer or latest.model %}
<span style="float:right;font-weight:normal;font-size:0.78rem;color:var(--text-dim);">
{{ latest.manufacturer or "" }} {{ latest.model or "" }}
</span>
{% endif %}
</div>
{# Status pill #}
<div style="margin-bottom:0.75rem;">
<span style="padding:0.25rem 0.7rem;border-radius:4px;font-size:0.88rem;font-weight:600;
{% if latest.on_battery %}background:var(--red-dim);color:var(--red);
{% else %}background:var(--green-dim);color:var(--green);{% endif %}">
{% if latest.on_battery %}On Battery{% else %}Online{% endif %}
</span>
{% if latest.low_battery %}
<span style="margin-left:0.5rem;padding:0.2rem 0.6rem;border-radius:4px;font-size:0.8rem;background:var(--red-dim);color:var(--red);">Low Battery</span>
{% endif %}
<span style="margin-left:0.5rem;color:var(--text-dim);font-size:0.78rem;font-family:monospace;">{{ latest.raw_status }}</span>
</div>
{# Battery charge bar #}
{% if latest.battery_charge_pct is not none %}
<div style="margin-bottom:0.6rem;">
<div style="display:flex;justify-content:space-between;font-size:0.8rem;margin-bottom:0.25rem;">
<span style="color:var(--text-muted);">Battery</span>
<span style="font-weight:600;color:
{% if latest.battery_charge_pct >= 80 %}var(--green)
{% elif latest.battery_charge_pct >= 40 %}var(--yellow)
{% else %}var(--red){% endif %};">
{{ "%.0f"|format(latest.battery_charge_pct) }}%
</span>
</div>
<div style="background:var(--border);border-radius:3px;height:8px;overflow:hidden;">
<div style="height:100%;border-radius:3px;width:{{ "%.1f"|format(latest.battery_charge_pct) }}%;background:
{% if latest.battery_charge_pct >= 80 %}var(--green)
{% elif latest.battery_charge_pct >= 40 %}var(--yellow)
{% else %}var(--red){% endif %};transition:width 0.3s;"></div>
</div>
</div>
{% endif %}
{# Key metrics #}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;font-size:0.83rem;">
{% if latest.battery_runtime_secs is not none %}
<div>
<div style="color:var(--text-muted);font-size:0.75rem;">Runtime</div>
<div style="font-weight:600;">
{% set mins = latest.battery_runtime_secs // 60 %}
{% if mins >= 60 %}{{ mins // 60 }}h {{ mins % 60 }}m
{% else %}{{ mins }}m{% endif %}
</div>
</div>
{% endif %}
{% if latest.load_pct is not none %}
<div>
<div style="color:var(--text-muted);font-size:0.75rem;">Load</div>
<div style="font-weight:600;color:
{% if latest.load_pct >= 80 %}var(--red)
{% elif latest.load_pct >= 60 %}var(--yellow)
{% else %}var(--text){% endif %};">
{{ "%.0f"|format(latest.load_pct) }}%
</div>
</div>
{% endif %}
{% if latest.input_voltage is not none %}
<div>
<div style="color:var(--text-muted);font-size:0.75rem;">Input V</div>
<div>{{ "%.1f"|format(latest.input_voltage) }}V</div>
</div>
{% endif %}
{% if latest.output_voltage is not none %}
<div>
<div style="color:var(--text-muted);font-size:0.75rem;">Output V</div>
<div>{{ "%.1f"|format(latest.output_voltage) }}V</div>
</div>
{% endif %}
{% if latest.battery_voltage is not none %}
<div>
<div style="color:var(--text-muted);font-size:0.75rem;">Batt V</div>
<div>{{ "%.1f"|format(latest.battery_voltage) }}V</div>
</div>
{% endif %}
{% if latest.temperature is not none %}
<div>
<div style="color:var(--text-muted);font-size:0.75rem;">Temp</div>
<div>{{ "%.1f"|format(latest.temperature) }}°C</div>
</div>
{% endif %}
</div>
<div style="margin-top:0.5rem;color:var(--text-dim);font-size:0.74rem;">
Last updated {{ latest.scraped_at.strftime("%H:%M:%S") }} UTC
</div>
</div>
{# ── Trend charts ─────────────────────────────────────────────────────── #}
{% if history %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">
Trends
<span style="float:right;font-weight:normal;font-size:0.78rem;color:var(--text-dim);">{{ range_key }}</span>
</div>
<div style="display:flex;flex-direction:column;gap:0.75rem;font-size:0.82rem;">
<div>
<div style="display:flex;justify-content:space-between;color:var(--text-muted);font-size:0.75rem;margin-bottom:0.2rem;">
<span>Battery %</span>
{% if latest.battery_charge_pct is not none %}<span>{{ "%.0f"|format(latest.battery_charge_pct) }}%</span>{% endif %}
</div>
{{ sparkline_charge | safe }}
</div>
<div>
<div style="display:flex;justify-content:space-between;color:var(--text-muted);font-size:0.75rem;margin-bottom:0.2rem;">
<span>Load %</span>
{% if latest.load_pct is not none %}<span>{{ "%.0f"|format(latest.load_pct) }}%</span>{% endif %}
</div>
{{ sparkline_load | safe }}
</div>
<div>
<div style="display:flex;justify-content:space-between;color:var(--text-muted);font-size:0.75rem;margin-bottom:0.2rem;">
<span>Input Voltage</span>
{% if latest.input_voltage is not none %}<span>{{ "%.1f"|format(latest.input_voltage) }}V</span>{% endif %}
</div>
{{ sparkline_voltage | safe }}
</div>
</div>
</div>
{% endif %}
{# ── Battery event log ────────────────────────────────────────────────── #}
{% if battery_events %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">
Battery Events
<span style="float:right;font-weight:normal;font-size:0.78rem;color:var(--text-dim);">{{ range_key }}</span>
</div>
<table class="table" style="font-size:0.81rem;width:100%;">
{% for ev in battery_events %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;white-space:nowrap;color:var(--text-dim);font-size:0.76rem;">
{{ ev.scraped_at.strftime("%m-%d %H:%M") }}
</td>
<td style="padding:0.2rem 0.3rem;">
<span style="color:var(--red);font-size:0.78rem;">{{ ev.raw_status }}</span>
</td>
<td style="padding:0.2rem 0;text-align:right;color:var(--text-muted);font-size:0.76rem;">
{% if ev.battery_charge_pct is not none %}{{ "%.0f"|format(ev.battery_charge_pct) }}%{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
</div>
{% endif %}
+90
View File
@@ -0,0 +1,90 @@
{# plugins/ups/templates/ups/widget.html #}
{% if not latest %}
<p style="color:var(--text-muted);font-size:0.85rem;">No UPS data yet.</p>
{% else %}
{# On-battery alert #}
{% if latest.on_battery %}
<div style="background:var(--red-dim);border:1px solid var(--red);border-radius:5px;padding:0.5rem 0.75rem;margin-bottom:0.75rem;display:flex;align-items:center;justify-content:space-between;gap:0.5rem;">
<span style="color:var(--red);font-weight:600;font-size:0.85rem;">
&#9888; On Battery
{% if on_battery_since %}
— {{ ((latest.scraped_at - on_battery_since).total_seconds() / 60) | int }}m
{% endif %}
</span>
{% if shutdown_triggered %}
<span style="color:var(--red);font-size:0.78rem;font-weight:600;">Shutdown triggered</span>
{% elif latest.low_battery %}
<span style="color:var(--red);font-size:0.78rem;">Low battery</span>
{% endif %}
</div>
{% endif %}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;font-size:0.83rem;margin-bottom:0.5rem;">
<div>
<div style="color:var(--text-muted);font-size:0.75rem;">Status</div>
<div style="font-weight:600;">
<span style="padding:0.15rem 0.5rem;border-radius:4px;font-size:0.8rem;
{% if latest.on_battery %}background:var(--red-dim);color:var(--red);
{% else %}background:var(--green-dim);color:var(--green);{% endif %}">
{% if latest.on_battery %}Battery{% else %}Online{% endif %}
</span>
</div>
</div>
{% if latest.battery_runtime_secs is not none %}
<div>
<div style="color:var(--text-muted);font-size:0.75rem;">Runtime</div>
<div style="font-weight:600;">
{% set mins = latest.battery_runtime_secs // 60 %}
{% if mins >= 60 %}{{ mins // 60 }}h {{ mins % 60 }}m
{% else %}{{ mins }}m{% endif %}
</div>
</div>
{% endif %}
{% if latest.load_pct is not none %}
<div>
<div style="color:var(--text-muted);font-size:0.75rem;">Load</div>
<div style="font-weight:600;color:
{% if latest.load_pct >= 80 %}var(--red)
{% elif latest.load_pct >= 60 %}var(--yellow)
{% else %}var(--text){% endif %};">
{{ "%.0f"|format(latest.load_pct) }}%
</div>
</div>
{% endif %}
{% if latest.input_voltage is not none %}
<div>
<div style="color:var(--text-muted);font-size:0.75rem;">Input V</div>
<div>{{ "%.0f"|format(latest.input_voltage) }}V</div>
</div>
{% endif %}
</div>
{# Battery charge bar #}
{% if latest.battery_charge_pct is not none %}
<div>
<div style="display:flex;justify-content:space-between;font-size:0.75rem;margin-bottom:0.2rem;">
<span style="color:var(--text-muted);">Battery</span>
<span style="font-weight:600;color:
{% if latest.battery_charge_pct >= 80 %}var(--green)
{% elif latest.battery_charge_pct >= 40 %}var(--yellow)
{% else %}var(--red){% endif %};">
{{ "%.0f"|format(latest.battery_charge_pct) }}%
</span>
</div>
<div style="background:var(--border);border-radius:3px;height:6px;overflow:hidden;">
<div style="height:100%;border-radius:3px;width:{{ "%.1f"|format(latest.battery_charge_pct) }}%;background:
{% if latest.battery_charge_pct >= 80 %}var(--green)
{% elif latest.battery_charge_pct >= 40 %}var(--yellow)
{% else %}var(--red){% endif %};transition:width 0.3s;"></div>
</div>
</div>
{% endif %}
<div style="margin-top:0.5rem;color:var(--text-dim);font-size:0.73rem;">
{% if latest.manufacturer or latest.model %}
{{ latest.manufacturer or "" }} {{ latest.model or "" }} &middot;
{% endif %}
{{ latest.scraped_at.strftime("%H:%M:%S") }} UTC
</div>
{% endif %}