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:
@@ -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