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:
+29
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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"],
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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 %}
|
||||||
@@ -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);">✓ OK</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="margin-left:0.4rem;color:var(--red);">✗ 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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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>⚠ {{ 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 %}
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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))
|
||||||
@@ -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
@@ -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,
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{# plugins/unifi/templates/unifi/index.html #}
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}UniFi — Fabled Scryer{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.75rem;margin-bottom:1.25rem;">
|
||||||
|
<div style="display:flex;align-items:baseline;gap:1rem;">
|
||||||
|
<h1 class="page-title" style="margin-bottom:0;">UniFi Network</h1>
|
||||||
|
<span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span>
|
||||||
|
</div>
|
||||||
|
{% include "_time_range.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Tab nav #}
|
||||||
|
<div style="display:flex;gap:0.25rem;margin-bottom:1.25rem;border-bottom:1px solid var(--border);padding-bottom:0;">
|
||||||
|
{% set tabs = [
|
||||||
|
("overview", "Overview", "/plugins/unifi/rows"),
|
||||||
|
("events", "Events", "/plugins/unifi/events"),
|
||||||
|
("dpi", "Traffic", "/plugins/unifi/dpi"),
|
||||||
|
("security", "Security", "/plugins/unifi/security"),
|
||||||
|
("inventory", "Inventory", "/plugins/unifi/inventory"),
|
||||||
|
] %}
|
||||||
|
{% for tab_id, label, url in tabs %}
|
||||||
|
<button
|
||||||
|
class="tab-btn{% if loop.first %} tab-active{% endif %}"
|
||||||
|
data-tab="{{ tab_id }}"
|
||||||
|
data-url="{{ url }}"
|
||||||
|
onclick="setUnifiTab(this)"
|
||||||
|
style="background:none;border:none;border-bottom:2px solid {% if loop.first %}var(--accent){% else %}transparent{% endif %};padding:0.4rem 0.9rem;cursor:pointer;font-size:0.88rem;color:{% if loop.first %}var(--text){% else %}var(--text-muted){% endif %};margin-bottom:-1px;">
|
||||||
|
{{ label }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="unifi-content"
|
||||||
|
hx-get="/plugins/unifi/rows"
|
||||||
|
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
|
||||||
|
hx-include="#time-range"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function setUnifiTab(btn) {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(function(b) {
|
||||||
|
b.style.borderBottomColor = 'transparent';
|
||||||
|
b.style.color = 'var(--text-muted)';
|
||||||
|
});
|
||||||
|
btn.style.borderBottomColor = 'var(--accent)';
|
||||||
|
btn.style.color = 'var(--text)';
|
||||||
|
|
||||||
|
var el = document.getElementById('unifi-content');
|
||||||
|
el.setAttribute('hx-get', btn.dataset.url);
|
||||||
|
// Remove auto-poll on non-overview tabs (inventory/security don't change rapidly)
|
||||||
|
var isLive = btn.dataset.tab === 'overview' || btn.dataset.tab === 'events';
|
||||||
|
el.setAttribute('hx-trigger',
|
||||||
|
isLive
|
||||||
|
? 'tabActivated, every {{ poll_interval }}s, rangeChange from:body'
|
||||||
|
: 'tabActivated, rangeChange from:body'
|
||||||
|
);
|
||||||
|
htmx.process(el);
|
||||||
|
htmx.trigger(el, 'tabActivated');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
{# plugins/unifi/templates/unifi/inventory.html #}
|
||||||
|
<div style="column-width:360px;column-count:3;column-gap:1rem;">
|
||||||
|
|
||||||
|
{# Networks / VLANs #}
|
||||||
|
{% if networks %}
|
||||||
|
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.75rem;">Networks / VLANs</div>
|
||||||
|
<table class="table" style="width:100%;font-size:0.82rem;">
|
||||||
|
<tr style="color:var(--text-muted);font-size:0.78rem;">
|
||||||
|
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.3rem 0;">Name</th>
|
||||||
|
<th style="text-align:right;font-weight:normal;padding:0 0.5rem 0.3rem;">VLAN</th>
|
||||||
|
<th style="text-align:left;font-weight:normal;padding:0 0 0.3rem;">Subnet</th>
|
||||||
|
</tr>
|
||||||
|
{% for net in networks %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0.25rem 0.5rem 0.25rem 0;">
|
||||||
|
{{ net.name }}
|
||||||
|
{% if net.is_guest %}<span style="font-size:0.72rem;color:var(--text-dim);"> guest</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="padding:0.25rem 0.5rem;text-align:right;color:var(--text-muted);font-family:monospace;font-size:0.78rem;">
|
||||||
|
{{ net.vlan or "—" }}
|
||||||
|
</td>
|
||||||
|
<td style="padding:0.25rem 0;color:var(--text-dim);font-family:monospace;font-size:0.78rem;">
|
||||||
|
{{ net.ip_subnet or "—" }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Port forwards #}
|
||||||
|
{% if port_forwards %}
|
||||||
|
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.75rem;">Port Forwards</div>
|
||||||
|
<table class="table" style="width:100%;font-size:0.82rem;">
|
||||||
|
{% for pf in port_forwards %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0.25rem 0.5rem 0.25rem 0;">
|
||||||
|
<span style="{% if not pf.enabled %}color:var(--text-dim);{% endif %}">
|
||||||
|
{{ pf.name or "—" }}
|
||||||
|
</span>
|
||||||
|
{% if not pf.enabled %}<span style="font-size:0.72rem;color:var(--text-dim);"> disabled</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="padding:0.25rem 0;font-family:monospace;font-size:0.78rem;color:var(--text-muted);white-space:nowrap;">
|
||||||
|
{{ pf.proto }}:{{ pf.dst_port }} → {{ pf.fwd_ip }}:{{ pf.fwd_port }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Firewall rules, grouped by ruleset #}
|
||||||
|
{% if fw_rules %}
|
||||||
|
{% set ns = namespace(current_rs=None) %}
|
||||||
|
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.75rem;">Firewall Rules</div>
|
||||||
|
<table class="table" style="width:100%;font-size:0.82rem;">
|
||||||
|
{% for rule in fw_rules %}
|
||||||
|
{% if rule.ruleset != ns.current_rs %}
|
||||||
|
{% set ns.current_rs = rule.ruleset %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" style="padding:0.4rem 0 0.2rem;color:var(--text-muted);font-size:0.75rem;font-weight:600;letter-spacing:0.04em;text-transform:uppercase;">
|
||||||
|
{{ rule.ruleset }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0.2rem 0.5rem 0.2rem 0;">
|
||||||
|
<span style="{% if not rule.enabled %}color:var(--text-dim);{% endif %}">
|
||||||
|
{{ rule.name }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:0.2rem 0.3rem;white-space:nowrap;">
|
||||||
|
<span style="font-size:0.75rem;padding:0.1rem 0.35rem;border-radius:3px;background:
|
||||||
|
{% if rule.action == 'accept' %}rgba(64,160,64,0.2)
|
||||||
|
{% elif rule.action == 'drop' %}rgba(192,64,64,0.2)
|
||||||
|
{% else %}rgba(192,128,64,0.2){% endif %};">
|
||||||
|
{{ rule.action }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:0.2rem 0;color:var(--text-dim);font-size:0.76rem;">
|
||||||
|
{% if rule.protocol %}{{ rule.protocol }}{% endif %}
|
||||||
|
{% if rule.src_address %} src:{{ rule.src_address }}{% endif %}
|
||||||
|
{% if rule.dst_address %} dst:{{ rule.dst_address }}{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Known clients #}
|
||||||
|
{% if known_clients %}
|
||||||
|
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.75rem;">
|
||||||
|
Known Clients
|
||||||
|
<span style="float:right;font-weight:normal;font-size:0.78rem;color:var(--text-dim);">{{ known_clients|length }} total</span>
|
||||||
|
</div>
|
||||||
|
<table class="table" style="width:100%;font-size:0.82rem;">
|
||||||
|
<tr style="color:var(--text-muted);font-size:0.78rem;">
|
||||||
|
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.3rem 0;">Name / MAC</th>
|
||||||
|
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.3rem;">IP</th>
|
||||||
|
<th style="text-align:right;font-weight:normal;padding:0 0 0.3rem;">Last Seen</th>
|
||||||
|
</tr>
|
||||||
|
{% for kc in known_clients[:50] %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0.2rem 0.5rem 0.2rem 0;">
|
||||||
|
<div style="{% if kc.is_blocked %}color:var(--red);{% endif %}">
|
||||||
|
{{ kc.name or kc.hostname or kc.mac }}
|
||||||
|
{% if kc.is_blocked %}<span style="font-size:0.72rem;"> blocked</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if kc.name or kc.hostname %}
|
||||||
|
<div style="color:var(--text-dim);font-size:0.74rem;font-family:monospace;">{{ kc.mac }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if kc.mac_vendor %}
|
||||||
|
<div style="color:var(--text-dim);font-size:0.73rem;">{{ kc.mac_vendor }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="padding:0.2rem 0.3rem;color:var(--text-muted);font-family:monospace;font-size:0.78rem;">
|
||||||
|
{{ kc.ip or "—" }}
|
||||||
|
</td>
|
||||||
|
<td style="padding:0.2rem 0;text-align:right;color:var(--text-dim);font-size:0.75rem;white-space:nowrap;">
|
||||||
|
{% if kc.last_seen %}{{ kc.last_seen.strftime("%m-%d %H:%M") }}{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if known_clients|length > 50 %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" style="padding:0.4rem 0;color:var(--text-dim);font-size:0.78rem;text-align:center;">
|
||||||
|
+{{ known_clients|length - 50 }} more
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not networks and not port_forwards and not fw_rules and not known_clients %}
|
||||||
|
<div class="card">
|
||||||
|
<p style="color:var(--text-muted);">No inventory data yet. This populates on the next poll cycle.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -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 %}✓ Up{% else %}✗ 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);">↓ 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);">↑ 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 }}↗ wireless
|
||||||
|
{{ latest_clients.wired_clients }}↔ wired
|
||||||
|
{% if latest_clients.guest_clients %} {{ 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 %};">●</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);">↓</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;">↑</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;">↓ 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;">↑ UL</span>
|
||||||
|
<span style="font-weight:600;margin-left:0.3rem;">{{ "%.1f"|format(speedtest.upload_mbps) }}</span>
|
||||||
|
<span style="color:var(--text-muted);font-size:0.78rem;">Mbps</span>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if speedtest.latency_ms is not none %}
|
||||||
|
<span>
|
||||||
|
<span style="color:var(--text-muted);font-size:0.8rem;">Latency</span>
|
||||||
|
<span style="margin-left:0.3rem;">{{ "%.0f"|format(speedtest.latency_ms) }}ms</span>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if speedtest.server_name %}
|
||||||
|
<div style="margin-top:0.4rem;color:var(--text-dim);font-size:0.78rem;">via {{ speedtest.server_name }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div style="margin-top:0.25rem;color:var(--text-dim);font-size:0.75rem;">{{ speedtest.run_at.strftime("%Y-%m-%d %H:%M") }} UTC</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not latest_wan and not devices %}
|
||||||
|
<div class="card">
|
||||||
|
<p style="color:var(--text-muted);">No UniFi data yet. Check your <code>host</code>, <code>username</code>, and <code>password</code> in the plugin config.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{# plugins/unifi/templates/unifi/security.html #}
|
||||||
|
<div style="column-width:340px;column-count:2;column-gap:1rem;">
|
||||||
|
|
||||||
|
{# Active alarms #}
|
||||||
|
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.75rem;">
|
||||||
|
Active Alarms
|
||||||
|
{% if active_alarms %}
|
||||||
|
<span style="float:right;background:var(--red);color:#fff;font-size:0.72rem;padding:0.1rem 0.45rem;border-radius:10px;font-weight:600;">{{ active_alarms|length }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if not active_alarms %}
|
||||||
|
<p style="color:var(--green);font-size:0.88rem;">✓ 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;">
|
||||||
|
⚠ {{ 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>
|
||||||
@@ -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>⚠ {{ 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>⚠ {{ 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);">↓</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);">↑</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);">↓</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;">↑</span>
|
||||||
|
{% set tx = c.tx_rate %}
|
||||||
|
{% if tx >= 1048576 %}{{ "%.1f"|format(tx / 1048576) }}M
|
||||||
|
{% elif tx >= 1024 %}{{ "%.0f"|format(tx / 1024) }}K
|
||||||
|
{% else %}{{ "%.0f"|format(tx) }}B{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not latest_wan and not latest_clients and not active_alarms and not offline_devices %}
|
||||||
|
<p class="empty" style="padding:0.5rem 0;font-size:0.85rem;">No UniFi data yet.</p>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,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
@@ -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"),
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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,
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -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 %}
|
||||||
@@ -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;">
|
||||||
|
⚠ 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 %}
|
||||||
@@ -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;">
|
||||||
|
⚠ 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 "" }} ·
|
||||||
|
{% endif %}
|
||||||
|
{{ latest.scraped_at.strftime("%H:%M:%S") }} UTC
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
Reference in New Issue
Block a user