feat: initial release — traefik, unifi, ups plugins

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 18:28:43 -04:00
commit ad4fcd1cfd
50 changed files with 4808 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# plugins/ups/__init__.py
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from quart import Quart
_app: "Quart | None" = None
def setup(app: "Quart") -> None:
global _app
_app = app
from .models import UpsStatus # noqa: F401
def get_scheduled_tasks() -> list:
from .scheduler import make_poll_task
return [make_poll_task(_app)]
def get_blueprint():
from .routes import ups_bp
return ups_bp
+141
View File
@@ -0,0 +1,141 @@
# plugins/ups/client.py
"""Async NUT (Network UPS Tools) client.
Connects to NUT's TCP port (default 3493) and reads UPS variables.
Read-only: no write commands (test, shutdown via NUT) are implemented.
"""
from __future__ import annotations
import asyncio
import logging
logger = logging.getLogger(__name__)
# NUT status flag meanings
STATUS_FLAGS = {
"OL": "Online",
"OB": "On Battery",
"LB": "Low Battery",
"CHRG": "Charging",
"DISCHRG":"Discharging",
"BYPASS": "Bypass",
"CAL": "Calibrating",
"FSD": "Forced Shutdown",
"RB": "Replace Battery",
"OVER": "Overloaded",
"TRIM": "Trimming",
"BOOST": "Boosting",
}
class NutError(Exception):
pass
class NutClient:
"""Minimal async NUT client — connects, reads vars, disconnects."""
def __init__(
self,
host: str = "localhost",
port: int = 3493,
username: str = "",
password: str = "",
timeout: float = 10.0,
) -> None:
self.host = host
self.port = port
self.username = username
self.password = password
self.timeout = timeout
async def get_vars(self, ups_name: str) -> dict[str, str]:
"""Open a connection, authenticate if needed, fetch all UPS variables, close."""
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(self.host, self.port),
timeout=self.timeout,
)
except (OSError, asyncio.TimeoutError) as exc:
raise NutError(f"Cannot connect to NUT at {self.host}:{self.port}: {exc}") from exc
try:
async def send(cmd: str) -> None:
writer.write((cmd + "\n").encode())
await writer.drain()
async def readline() -> str:
line = await asyncio.wait_for(reader.readline(), timeout=self.timeout)
return line.decode(errors="replace").strip()
# Optional auth
if self.username:
await send(f"USERNAME {self.username}")
resp = await readline()
if not resp.startswith("OK"):
raise NutError(f"NUT auth USERNAME rejected: {resp}")
if self.password:
await send(f"PASSWORD {self.password}")
resp = await readline()
if not resp.startswith("OK"):
raise NutError(f"NUT auth PASSWORD rejected: {resp}")
# Request variable list
await send(f"LIST VAR {ups_name}")
vars_: dict[str, str] = {}
while True:
line = await readline()
if line.startswith("ERR"):
raise NutError(f"NUT error for UPS '{ups_name}': {line}")
if line.startswith(f"END LIST VAR {ups_name}"):
break
if line.startswith(f"VAR {ups_name} "):
# Format: VAR <upsname> <varname> "<value>"
rest = line[len(f"VAR {ups_name} "):]
if " " in rest:
varname, raw_val = rest.split(" ", 1)
vars_[varname] = raw_val.strip('"')
await send("LOGOUT")
return vars_
finally:
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
def parse_status(vars_: dict[str, str]) -> dict:
"""Extract structured fields from raw NUT variable dict."""
raw_status = vars_.get("ups.status", "")
flags = set(raw_status.upper().split())
def _float(key: str) -> float | None:
try:
return float(vars_[key])
except (KeyError, ValueError):
return None
def _int(key: str) -> int | None:
try:
return int(float(vars_[key]))
except (KeyError, ValueError):
return None
return {
"raw_status": raw_status,
"on_battery": "OB" in flags,
"low_battery": "LB" in flags,
"flags": sorted(flags),
"battery_charge_pct": _float("battery.charge"),
"battery_runtime_secs": _int("battery.runtime"),
"battery_voltage": _float("battery.voltage"),
"load_pct": _float("ups.load"),
"input_voltage": _float("input.voltage"),
"output_voltage": _float("output.voltage"),
"temperature": _float("ups.temperature"),
"model": vars_.get("ups.model") or vars_.get("device.model"),
"manufacturer": vars_.get("ups.mfr") or vars_.get("device.mfr"),
}
View File
+72
View File
@@ -0,0 +1,72 @@
# plugins/ups/migrations/env.py
"""Alembic env.py for the UPS plugin (standalone dev use).
At app startup, migration_runner.py uses the core env.py with version_locations.
"""
import asyncio
import os
import sys
from pathlib import Path
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from fabledscryer.models.base import Base
import fabledscryer.models # noqa: F401
from plugins.ups.models import UpsStatus # noqa: F401
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def _get_url() -> str:
import yaml
cfg_path = os.environ.get("FABLEDSCRYER_CONFIG", "config.yaml")
try:
with open(cfg_path) as f:
cfg = yaml.safe_load(f) or {}
url = cfg.get("database", {}).get("url", "")
except FileNotFoundError:
url = ""
return os.environ.get("FABLEDSCRYER_DATABASE__URL", url)
def run_migrations_offline() -> None:
context.configure(
url=_get_url(), target_metadata=target_metadata,
literal_binds=True, dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
cfg = config.get_section(config.config_ini_section, {})
cfg["sqlalchemy.url"] = _get_url()
connectable = async_engine_from_config(cfg, prefix="sqlalchemy.", poolclass=pool.NullPool)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
View File
@@ -0,0 +1,40 @@
"""UPS plugin initial tables
Revision ID: ups_001_initial
Revises: (none — branch off core via depends_on)
Create Date: 2026-03-21
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "ups_001_initial"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = "ups"
depends_on: Union[str, Sequence[str], None] = ("0004_app_settings",)
def upgrade() -> None:
op.create_table(
"ups_status",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("raw_status", sa.String(64), nullable=False, server_default=""),
sa.Column("on_battery", sa.Boolean, nullable=False, server_default="false"),
sa.Column("low_battery", sa.Boolean, nullable=False, server_default="false"),
sa.Column("battery_charge_pct", sa.Float, nullable=True),
sa.Column("battery_runtime_secs", sa.Integer, nullable=True),
sa.Column("battery_voltage", sa.Float, nullable=True),
sa.Column("load_pct", sa.Float, nullable=True),
sa.Column("input_voltage", sa.Float, nullable=True),
sa.Column("output_voltage", sa.Float, nullable=True),
sa.Column("temperature", sa.Float, nullable=True),
sa.Column("model", sa.String(128), nullable=True),
sa.Column("manufacturer", sa.String(128), nullable=True),
)
op.create_index("ix_ups_status_scraped", "ups_status", ["scraped_at"])
def downgrade() -> None:
op.drop_index("ix_ups_status_scraped", "ups_status")
op.drop_table("ups_status")
+30
View File
@@ -0,0 +1,30 @@
# plugins/ups/models.py
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Float, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from fabledscryer.models.base import Base
class UpsStatus(Base):
__tablename__ = "ups_status"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
scraped_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
raw_status: Mapped[str] = mapped_column(String(64), nullable=False, default="")
on_battery: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
low_battery: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
battery_charge_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
battery_runtime_secs: Mapped[int | None] = mapped_column(Integer, nullable=True)
battery_voltage: Mapped[float | None] = mapped_column(Float, nullable=True)
load_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
input_voltage: Mapped[float | None] = mapped_column(Float, nullable=True)
output_voltage: Mapped[float | None] = mapped_column(Float, nullable=True)
temperature: Mapped[float | None] = mapped_column(Float, nullable=True)
model: Mapped[str | None] = mapped_column(String(128), nullable=True)
manufacturer: Mapped[str | None] = mapped_column(String(128), nullable=True)
+24
View File
@@ -0,0 +1,24 @@
name: ups
version: "1.0.0"
description: "UPS monitoring via NUT (Network UPS Tools) with Ansible shutdown automation"
author: "FabledScryer"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://github.com/bvandeusen/fabledscryer-plugins"
homepage: "https://github.com/bvandeusen/fabledscryer-plugins/tree/main/ups"
tags:
- ups
- power
- nut
config:
nut_host: "localhost"
nut_port: 3493
ups_name: "ups"
nut_username: "" # leave blank if NUT is configured without auth
nut_password: ""
poll_interval_seconds: 30
shutdown_after_seconds: 300 # seconds on battery before triggering shutdown (0 = disabled)
shutdown_source: "" # Ansible source name containing the shutdown playbook
shutdown_playbook: "" # relative path within source, e.g. "shutdown.yml"
shutdown_inventory: "hosts" # inventory filename within source root
+125
View File
@@ -0,0 +1,125 @@
# plugins/ups/routes.py
from __future__ import annotations
from quart import Blueprint, current_app, render_template, request
from sqlalchemy import Integer, cast, func, select
from fabledscryer.auth.middleware import require_role
from fabledscryer.models.users import UserRole
from fabledscryer.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds, subsample
from .models import UpsStatus
from .scheduler import _on_battery_since, _shutdown_triggered
ups_bp = Blueprint("ups", __name__, template_folder="templates")
def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str:
if len(values) < 2:
return f'<svg width="{width}" height="{height}"></svg>'
mn, mx = min(values), max(values)
if mx == mn:
mx = mn + 1.0
step = width / (len(values) - 1)
pts = []
for i, v in enumerate(values):
x = i * step
y = height - (v - mn) / (mx - mn) * (height - 2) - 1
pts.append(f"{x:.1f},{y:.1f}")
poly = " ".join(pts)
return (
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" '
f'style="vertical-align:middle;">'
f'<polyline points="{poly}" fill="none" stroke="#6060c0" stroke-width="1.5"/>'
f'</svg>'
)
@ups_bp.get("/")
@require_role(UserRole.viewer)
async def index():
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
current_range = request.args.get("range", DEFAULT_RANGE)
return await render_template(
"ups/index.html",
poll_interval=poll_interval,
current_range=current_range,
)
@ups_bp.get("/rows")
@require_role(UserRole.viewer)
async def rows():
"""HTMX fragment: UPS status, battery trend, history charts."""
since, range_key = parse_range(request.args.get("range"))
b_secs = bucket_seconds(since)
bucket_col = (
cast(func.strftime('%s', UpsStatus.scraped_at), Integer) / b_secs
).label("bucket")
async with current_app.db_sessionmaker() as db:
# Latest raw status
result = await db.execute(
select(UpsStatus).order_by(UpsStatus.scraped_at.desc()).limit(1)
)
latest = result.scalar_one_or_none()
# Bucketed history for sparklines
result = await db.execute(
select(
func.avg(UpsStatus.battery_charge_pct).label("battery_charge_pct"),
func.avg(UpsStatus.load_pct).label("load_pct"),
func.avg(UpsStatus.input_voltage).label("input_voltage"),
func.avg(UpsStatus.battery_runtime_secs).label("battery_runtime_secs"),
func.min(UpsStatus.scraped_at).label("scraped_at"),
bucket_col,
)
.where(UpsStatus.scraped_at >= since)
.group_by(bucket_col)
.order_by(bucket_col)
)
history = result.all()
# On-battery event log within range
result = await db.execute(
select(UpsStatus)
.where(UpsStatus.scraped_at >= since)
.where(UpsStatus.on_battery == True) # noqa: E712
.order_by(UpsStatus.scraped_at.desc())
.limit(50)
)
battery_events = result.scalars().all()
sparkline_charge = _sparkline([r.battery_charge_pct or 0 for r in history])
sparkline_load = _sparkline([r.load_pct or 0 for r in history])
sparkline_voltage = _sparkline([r.input_voltage or 0 for r in history])
return await render_template(
"ups/rows.html",
latest=latest,
history=history,
battery_events=battery_events,
sparkline_charge=sparkline_charge,
sparkline_load=sparkline_load,
sparkline_voltage=sparkline_voltage,
range_key=range_key,
on_battery_since=_on_battery_since,
shutdown_triggered=_shutdown_triggered,
)
@ups_bp.get("/widget")
@require_role(UserRole.viewer)
async def widget():
"""HTMX dashboard widget fragment."""
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(UpsStatus).order_by(UpsStatus.scraped_at.desc()).limit(1)
)
latest = result.scalar_one_or_none()
return await render_template(
"ups/widget.html",
latest=latest,
on_battery_since=_on_battery_since,
shutdown_triggered=_shutdown_triggered,
)
+185
View File
@@ -0,0 +1,185 @@
# plugins/ups/scheduler.py
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING
from fabledscryer.core.scheduler import ScheduledTask
if TYPE_CHECKING:
from quart import Quart
logger = logging.getLogger(__name__)
# In-memory shutdown state.
# Limitation: resets on app restart. If the app restarts while on battery,
# the countdown begins again from zero. Given typical UPS runtimes (minutes),
# this is acceptable. A future improvement could persist this in the DB.
_on_battery_since: datetime | None = None
_shutdown_triggered: bool = False
def make_poll_task(app: "Quart") -> ScheduledTask:
cfg = app.config["PLUGINS"]["ups"]
interval = int(cfg.get("poll_interval_seconds", 30))
async def poll() -> None:
await _do_poll(app)
return ScheduledTask(
name="ups_poll",
coro_factory=poll,
interval_seconds=interval,
run_on_startup=True,
)
async def _do_poll(app: "Quart") -> None:
global _on_battery_since, _shutdown_triggered
from .client import NutClient, NutError, parse_status
from .models import UpsStatus
from fabledscryer.core.alerts import record_metric
cfg = app.config["PLUGINS"]["ups"]
client = NutClient(
host=cfg.get("nut_host", "localhost"),
port=int(cfg.get("nut_port", 3493)),
username=cfg.get("nut_username", ""),
password=cfg.get("nut_password", ""),
)
try:
raw_vars = await client.get_vars(cfg.get("ups_name", "ups"))
except NutError:
logger.exception("UPS poll failed")
return
parsed = parse_status(raw_vars)
scraped_at = datetime.now(timezone.utc)
status = UpsStatus(
scraped_at=scraped_at,
raw_status=parsed["raw_status"],
on_battery=parsed["on_battery"],
low_battery=parsed["low_battery"],
battery_charge_pct=parsed["battery_charge_pct"],
battery_runtime_secs=parsed["battery_runtime_secs"],
battery_voltage=parsed["battery_voltage"],
load_pct=parsed["load_pct"],
input_voltage=parsed["input_voltage"],
output_voltage=parsed["output_voltage"],
temperature=parsed["temperature"],
model=parsed["model"],
manufacturer=parsed["manufacturer"],
)
async with app.db_sessionmaker() as session:
async with session.begin():
session.add(status)
if parsed["battery_charge_pct"] is not None:
await record_metric(
session=session,
source_module="ups",
resource_name="ups",
metric_name="battery_charge_pct",
value=parsed["battery_charge_pct"],
)
await record_metric(
session=session,
source_module="ups",
resource_name="ups",
metric_name="on_battery",
value=1.0 if parsed["on_battery"] else 0.0,
)
if parsed["battery_runtime_secs"] is not None:
await record_metric(
session=session,
source_module="ups",
resource_name="ups",
metric_name="battery_runtime_secs",
value=float(parsed["battery_runtime_secs"]),
)
# ── Shutdown logic ────────────────────────────────────────────────────────
shutdown_after = int(cfg.get("shutdown_after_seconds", 0))
if parsed["on_battery"]:
if _on_battery_since is None:
_on_battery_since = scraped_at
logger.warning("UPS switched to battery at %s", scraped_at.isoformat())
elapsed = (scraped_at - _on_battery_since).total_seconds()
logger.debug("UPS on battery for %.0f seconds (shutdown_after=%d)", elapsed, shutdown_after)
if (
not _shutdown_triggered
and shutdown_after > 0
and elapsed >= shutdown_after
):
logger.warning(
"UPS on battery for %.0fs — triggering shutdown playbook", elapsed
)
_shutdown_triggered = True
asyncio.create_task(_run_shutdown(app, cfg))
else:
if _on_battery_since is not None:
logger.info("UPS back on AC power after %.0fs on battery",
(scraped_at - _on_battery_since).total_seconds())
_on_battery_since = None
_shutdown_triggered = False
async def _run_shutdown(app: "Quart", cfg: dict) -> None:
"""Locate the configured Ansible playbook and run it via subprocess."""
source_name = cfg.get("shutdown_source", "")
playbook_rel = cfg.get("shutdown_playbook", "")
inventory_name = cfg.get("shutdown_inventory", "hosts")
if not source_name or not playbook_rel:
logger.error(
"UPS shutdown triggered but shutdown_source or shutdown_playbook not configured"
)
return
# Resolve source path from Ansible config
ansible_cfg = app.config.get("ANSIBLE", {})
sources = ansible_cfg.get("sources", [])
source = next((s for s in sources if s.get("name") == source_name), None)
if not source:
logger.error("UPS shutdown: Ansible source %r not found", source_name)
return
source_path = Path(source["path"])
playbook_path = source_path / playbook_rel
inventory_path = source_path / inventory_name
if not playbook_path.exists():
logger.error("UPS shutdown: playbook not found at %s", playbook_path)
return
if not inventory_path.exists():
logger.error("UPS shutdown: inventory not found at %s", inventory_path)
return
logger.warning("UPS shutdown: running %s with inventory %s", playbook_path, inventory_path)
try:
proc = await asyncio.create_subprocess_exec(
"ansible-playbook", str(playbook_path), "-i", str(inventory_path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=str(source_path),
)
assert proc.stdout is not None
async for line in proc.stdout:
logger.info("[ups-shutdown] %s", line.decode(errors="replace").rstrip())
await proc.wait()
if proc.returncode == 0:
logger.warning("UPS shutdown playbook completed successfully")
else:
logger.error("UPS shutdown playbook exited with code %d", proc.returncode)
except Exception:
logger.exception("UPS shutdown playbook execution failed")
+19
View File
@@ -0,0 +1,19 @@
{# plugins/ups/templates/ups/index.html #}
{% extends "base.html" %}
{% block title %}UPS — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.75rem;margin-bottom:1.5rem;">
<div style="display:flex;align-items:baseline;gap:1rem;">
<h1 class="page-title" style="margin-bottom:0;">UPS Monitor</h1>
<span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span>
</div>
{% include "_time_range.html" %}
</div>
<div id="ups-rows"
hx-get="/plugins/ups/rows"
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
hx-include="#time-range"
hx-swap="innerHTML">
</div>
{% endblock %}
+185
View File
@@ -0,0 +1,185 @@
{# plugins/ups/templates/ups/rows.html #}
{# ── On-battery alert banner ──────────────────────────────────────────────── #}
{% if latest and latest.on_battery %}
<div style="background:var(--red-dim);border:1px solid var(--red);border-radius:6px;padding:0.75rem 1rem;margin-bottom:1.25rem;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.5rem;">
<span style="color:var(--red);font-weight:600;font-size:0.95rem;">
&#9888; On Battery Power
{% if on_battery_since %}
— {{ ((latest.scraped_at - on_battery_since).total_seconds() / 60) | int }}m elapsed
{% endif %}
</span>
{% if shutdown_triggered %}
<span style="color:var(--red);font-size:0.85rem;font-weight:600;">Shutdown sequence triggered</span>
{% elif latest.low_battery %}
<span style="color:var(--red);font-size:0.85rem;">Low battery — shutdown imminent</span>
{% endif %}
</div>
{% endif %}
{% if not latest %}
<div class="card">
<p style="color:var(--text-muted);">No UPS data yet. Check <code>nut_host</code>, <code>nut_port</code>, and <code>ups_name</code> in plugin settings.</p>
</div>
{% else %}
<div style="column-width:340px;column-count:3;column-gap:1rem;">
{# ── Current status card ──────────────────────────────────────────────── #}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">
Status
{% if latest.manufacturer or latest.model %}
<span style="float:right;font-weight:normal;font-size:0.78rem;color:var(--text-dim);">
{{ latest.manufacturer or "" }} {{ latest.model or "" }}
</span>
{% endif %}
</div>
{# Status pill #}
<div style="margin-bottom:0.75rem;">
<span style="padding:0.25rem 0.7rem;border-radius:4px;font-size:0.88rem;font-weight:600;
{% if latest.on_battery %}background:var(--red-dim);color:var(--red);
{% else %}background:var(--green-dim);color:var(--green);{% endif %}">
{% if latest.on_battery %}On Battery{% else %}Online{% endif %}
</span>
{% if latest.low_battery %}
<span style="margin-left:0.5rem;padding:0.2rem 0.6rem;border-radius:4px;font-size:0.8rem;background:var(--red-dim);color:var(--red);">Low Battery</span>
{% endif %}
<span style="margin-left:0.5rem;color:var(--text-dim);font-size:0.78rem;font-family:monospace;">{{ latest.raw_status }}</span>
</div>
{# Battery charge bar #}
{% if latest.battery_charge_pct is not none %}
<div style="margin-bottom:0.6rem;">
<div style="display:flex;justify-content:space-between;font-size:0.8rem;margin-bottom:0.25rem;">
<span style="color:var(--text-muted);">Battery</span>
<span style="font-weight:600;color:
{% if latest.battery_charge_pct >= 80 %}var(--green)
{% elif latest.battery_charge_pct >= 40 %}var(--yellow)
{% else %}var(--red){% endif %};">
{{ "%.0f"|format(latest.battery_charge_pct) }}%
</span>
</div>
<div style="background:var(--border);border-radius:3px;height:8px;overflow:hidden;">
<div style="height:100%;border-radius:3px;width:{{ "%.1f"|format(latest.battery_charge_pct) }}%;background:
{% if latest.battery_charge_pct >= 80 %}var(--green)
{% elif latest.battery_charge_pct >= 40 %}var(--yellow)
{% else %}var(--red){% endif %};transition:width 0.3s;"></div>
</div>
</div>
{% endif %}
{# Key metrics #}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;font-size:0.83rem;">
{% if latest.battery_runtime_secs is not none %}
<div>
<div style="color:var(--text-muted);font-size:0.75rem;">Runtime</div>
<div style="font-weight:600;">
{% set mins = latest.battery_runtime_secs // 60 %}
{% if mins >= 60 %}{{ mins // 60 }}h {{ mins % 60 }}m
{% else %}{{ mins }}m{% endif %}
</div>
</div>
{% endif %}
{% if latest.load_pct is not none %}
<div>
<div style="color:var(--text-muted);font-size:0.75rem;">Load</div>
<div style="font-weight:600;color:
{% if latest.load_pct >= 80 %}var(--red)
{% elif latest.load_pct >= 60 %}var(--yellow)
{% else %}var(--text){% endif %};">
{{ "%.0f"|format(latest.load_pct) }}%
</div>
</div>
{% endif %}
{% if latest.input_voltage is not none %}
<div>
<div style="color:var(--text-muted);font-size:0.75rem;">Input V</div>
<div>{{ "%.1f"|format(latest.input_voltage) }}V</div>
</div>
{% endif %}
{% if latest.output_voltage is not none %}
<div>
<div style="color:var(--text-muted);font-size:0.75rem;">Output V</div>
<div>{{ "%.1f"|format(latest.output_voltage) }}V</div>
</div>
{% endif %}
{% if latest.battery_voltage is not none %}
<div>
<div style="color:var(--text-muted);font-size:0.75rem;">Batt V</div>
<div>{{ "%.1f"|format(latest.battery_voltage) }}V</div>
</div>
{% endif %}
{% if latest.temperature is not none %}
<div>
<div style="color:var(--text-muted);font-size:0.75rem;">Temp</div>
<div>{{ "%.1f"|format(latest.temperature) }}°C</div>
</div>
{% endif %}
</div>
<div style="margin-top:0.5rem;color:var(--text-dim);font-size:0.74rem;">
Last updated {{ latest.scraped_at.strftime("%H:%M:%S") }} UTC
</div>
</div>
{# ── Trend charts ─────────────────────────────────────────────────────── #}
{% if history %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">
Trends
<span style="float:right;font-weight:normal;font-size:0.78rem;color:var(--text-dim);">{{ range_key }}</span>
</div>
<div style="display:flex;flex-direction:column;gap:0.75rem;font-size:0.82rem;">
<div>
<div style="display:flex;justify-content:space-between;color:var(--text-muted);font-size:0.75rem;margin-bottom:0.2rem;">
<span>Battery %</span>
{% if latest.battery_charge_pct is not none %}<span>{{ "%.0f"|format(latest.battery_charge_pct) }}%</span>{% endif %}
</div>
{{ sparkline_charge | safe }}
</div>
<div>
<div style="display:flex;justify-content:space-between;color:var(--text-muted);font-size:0.75rem;margin-bottom:0.2rem;">
<span>Load %</span>
{% if latest.load_pct is not none %}<span>{{ "%.0f"|format(latest.load_pct) }}%</span>{% endif %}
</div>
{{ sparkline_load | safe }}
</div>
<div>
<div style="display:flex;justify-content:space-between;color:var(--text-muted);font-size:0.75rem;margin-bottom:0.2rem;">
<span>Input Voltage</span>
{% if latest.input_voltage is not none %}<span>{{ "%.1f"|format(latest.input_voltage) }}V</span>{% endif %}
</div>
{{ sparkline_voltage | safe }}
</div>
</div>
</div>
{% endif %}
{# ── Battery event log ────────────────────────────────────────────────── #}
{% if battery_events %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">
Battery Events
<span style="float:right;font-weight:normal;font-size:0.78rem;color:var(--text-dim);">{{ range_key }}</span>
</div>
<table class="table" style="font-size:0.81rem;width:100%;">
{% for ev in battery_events %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;white-space:nowrap;color:var(--text-dim);font-size:0.76rem;">
{{ ev.scraped_at.strftime("%m-%d %H:%M") }}
</td>
<td style="padding:0.2rem 0.3rem;">
<span style="color:var(--red);font-size:0.78rem;">{{ ev.raw_status }}</span>
</td>
<td style="padding:0.2rem 0;text-align:right;color:var(--text-muted);font-size:0.76rem;">
{% if ev.battery_charge_pct is not none %}{{ "%.0f"|format(ev.battery_charge_pct) }}%{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
</div>
{% endif %}
+90
View File
@@ -0,0 +1,90 @@
{# plugins/ups/templates/ups/widget.html #}
{% if not latest %}
<p style="color:var(--text-muted);font-size:0.85rem;">No UPS data yet.</p>
{% else %}
{# On-battery alert #}
{% if latest.on_battery %}
<div style="background:var(--red-dim);border:1px solid var(--red);border-radius:5px;padding:0.5rem 0.75rem;margin-bottom:0.75rem;display:flex;align-items:center;justify-content:space-between;gap:0.5rem;">
<span style="color:var(--red);font-weight:600;font-size:0.85rem;">
&#9888; On Battery
{% if on_battery_since %}
— {{ ((latest.scraped_at - on_battery_since).total_seconds() / 60) | int }}m
{% endif %}
</span>
{% if shutdown_triggered %}
<span style="color:var(--red);font-size:0.78rem;font-weight:600;">Shutdown triggered</span>
{% elif latest.low_battery %}
<span style="color:var(--red);font-size:0.78rem;">Low battery</span>
{% endif %}
</div>
{% endif %}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;font-size:0.83rem;margin-bottom:0.5rem;">
<div>
<div style="color:var(--text-muted);font-size:0.75rem;">Status</div>
<div style="font-weight:600;">
<span style="padding:0.15rem 0.5rem;border-radius:4px;font-size:0.8rem;
{% if latest.on_battery %}background:var(--red-dim);color:var(--red);
{% else %}background:var(--green-dim);color:var(--green);{% endif %}">
{% if latest.on_battery %}Battery{% else %}Online{% endif %}
</span>
</div>
</div>
{% if latest.battery_runtime_secs is not none %}
<div>
<div style="color:var(--text-muted);font-size:0.75rem;">Runtime</div>
<div style="font-weight:600;">
{% set mins = latest.battery_runtime_secs // 60 %}
{% if mins >= 60 %}{{ mins // 60 }}h {{ mins % 60 }}m
{% else %}{{ mins }}m{% endif %}
</div>
</div>
{% endif %}
{% if latest.load_pct is not none %}
<div>
<div style="color:var(--text-muted);font-size:0.75rem;">Load</div>
<div style="font-weight:600;color:
{% if latest.load_pct >= 80 %}var(--red)
{% elif latest.load_pct >= 60 %}var(--yellow)
{% else %}var(--text){% endif %};">
{{ "%.0f"|format(latest.load_pct) }}%
</div>
</div>
{% endif %}
{% if latest.input_voltage is not none %}
<div>
<div style="color:var(--text-muted);font-size:0.75rem;">Input V</div>
<div>{{ "%.0f"|format(latest.input_voltage) }}V</div>
</div>
{% endif %}
</div>
{# Battery charge bar #}
{% if latest.battery_charge_pct is not none %}
<div>
<div style="display:flex;justify-content:space-between;font-size:0.75rem;margin-bottom:0.2rem;">
<span style="color:var(--text-muted);">Battery</span>
<span style="font-weight:600;color:
{% if latest.battery_charge_pct >= 80 %}var(--green)
{% elif latest.battery_charge_pct >= 40 %}var(--yellow)
{% else %}var(--red){% endif %};">
{{ "%.0f"|format(latest.battery_charge_pct) }}%
</span>
</div>
<div style="background:var(--border);border-radius:3px;height:6px;overflow:hidden;">
<div style="height:100%;border-radius:3px;width:{{ "%.1f"|format(latest.battery_charge_pct) }}%;background:
{% if latest.battery_charge_pct >= 80 %}var(--green)
{% elif latest.battery_charge_pct >= 40 %}var(--yellow)
{% else %}var(--red){% endif %};transition:width 0.3s;"></div>
</div>
</div>
{% endif %}
<div style="margin-top:0.5rem;color:var(--text-dim);font-size:0.73rem;">
{% if latest.manufacturer or latest.model %}
{{ latest.manufacturer or "" }} {{ latest.model or "" }} &middot;
{% endif %}
{{ latest.scraped_at.strftime("%H:%M:%S") }} UTC
</div>
{% endif %}