Adding the new rewrite

This commit is contained in:
Ketan Patel committed 2026-04-04 13:53:57 -04:00
1 parent 4aca9f08d1
commit 9b48b01d1d
62 files changed
+4510 -1406

No files matched your search

+1 -1
View File
@@ -1 +1 @@
# Package init
# Package init
+254 -122
View File
@@ -1,68 +1,135 @@
"""Alert evaluation, coalescing, cooldown, silent-hours, and dispatch."""
from __future__ import annotations
import smtplib
import ssl
import os
import hashlib
import logging
import time
from email.message import EmailMessage
from typing import Dict, Any, List
from .config import load_config, SMTPConfig, UPSConfig
from dataclasses import asdict, dataclass
from datetime import datetime
from typing import Any
from .config import SMTPConfig, UPSConfig, load_config
from .notifications.email import EmailSendError, send_alert_email
from .storage import get_redis
logger = logging.getLogger(__name__)
ALERT_COOLDOWN_SECONDS = 1800 # 30 minutes per distinct alert per UPS
REDIS_ALERT_KEY_PREFIX = "ups:alert:last:"
REDIS_PENDING_KEY_PREFIX = "ups:alerts:pending:" # list of deferred messages
REDIS_HISTORY_KEY_PREFIX = "ups:alerts:history:" # global history
ALERT_HISTORY_MAX = 500
ALERT_HISTORY_TTL_SECONDS = 30 * 24 * 3600
STATUS_ON_BATTERY_KEYWORDS = {"ONBATT", "ON BATTERY"}
SELFTEST_FAIL_KEYWORDS = {"FAIL", "BADBATT"}
def evaluate_alerts(ups_cfg: UPSConfig, snapshot: Dict[str, Any]) -> List[str]:
messages: List[str] = []
# Load % high
if ups_cfg.alert_loadpct_high is not None:
loadpct = _to_float(snapshot.get('LOADPCT'))
if loadpct is not None and loadpct >= ups_cfg.alert_loadpct_high:
messages.append(
"Load percentage high: "
f"{loadpct}% >= {ups_cfg.alert_loadpct_high}%"
SEV_CRITICAL = "CRITICAL"
SEV_WARNING = "WARNING"
SEV_INFO = "INFO"
TEMP_HIGH_DEFAULT_C = 50.0
@dataclass
class Alert:
severity: str
message: str
code: str
ups: str
ts: int
def hash(self) -> str:
h = hashlib.sha1(f"{self.ups}|{self.code}|{self.message}".encode()).hexdigest()
return h[:16]
def as_dict(self) -> dict[str, Any]:
d = asdict(self)
d["id"] = self.hash()
return d
def _to_float(val) -> float | None:
try:
return float(val)
except (TypeError, ValueError):
return None
def _extract_leading_number(s: str) -> float | None:
try:
return float(s.strip().split()[0])
except Exception:
return None
def evaluate_alerts(ups_cfg: UPSConfig, snapshot: dict[str, Any]) -> list[Alert]:
msgs: list[Alert] = []
now = int(time.time())
name = ups_cfg.name
def add(sev: str, code: str, message: str) -> None:
msgs.append(Alert(sev, message, code, name, now))
status = str(snapshot.get("STATUS", "")).upper()
# ONBATT
if ups_cfg.alert_on_battery and any(k in status for k in STATUS_ON_BATTERY_KEYWORDS):
add(SEV_CRITICAL, "ONBATT", f"UPS on battery: status={status}")
# Runtime low
if ups_cfg.alert_runtime_low_minutes is not None:
runtime = _extract_leading_number(str(snapshot.get("TIMELEFT", "")))
if runtime is not None and runtime <= ups_cfg.alert_runtime_low_minutes:
add(
SEV_CRITICAL,
"RUNTIME_LOW",
f"Runtime low: {runtime}m <= {ups_cfg.alert_runtime_low_minutes}m",
)
# Battery charge low
if ups_cfg.alert_bcharge_low is not None:
bcharge = _to_float(snapshot.get('BCHARGE'))
bcharge = _extract_leading_number(str(snapshot.get("BCHARGE", "")))
if bcharge is not None and bcharge <= ups_cfg.alert_bcharge_low:
messages.append(
"Battery charge low: "
f"{bcharge}% <= {ups_cfg.alert_bcharge_low}%"
add(
SEV_CRITICAL,
"BCHARGE_LOW",
f"Battery charge low: {bcharge}% <= {ups_cfg.alert_bcharge_low}%",
)
# On battery status
if ups_cfg.alert_on_battery:
status = str(snapshot.get('STATUS', '')).upper()
if any(k in status for k in STATUS_ON_BATTERY_KEYWORDS):
messages.append(f"UPS on battery: status={status}")
# Runtime low
if ups_cfg.alert_runtime_low_minutes is not None:
timeleft = snapshot.get('TIMELEFT')
# TIMELEFT often like '15.0 Minutes' -> attempt parse
runtime = _extract_leading_number(str(timeleft))
if (
runtime is not None
and runtime <= ups_cfg.alert_runtime_low_minutes
):
messages.append(
"Runtime low: "
f"{runtime}m <= {ups_cfg.alert_runtime_low_minutes}m"
# Load high
if ups_cfg.alert_loadpct_high is not None:
loadpct = _extract_leading_number(str(snapshot.get("LOADPCT", "")))
if loadpct is not None and loadpct >= ups_cfg.alert_loadpct_high:
add(
SEV_WARNING,
"LOAD_HIGH",
f"Load high: {loadpct}% >= {ups_cfg.alert_loadpct_high}%",
)
# Extended alerts based on global UI flags
# REPLACEBATT flag
replacebatt = str(snapshot.get("REPLACEBATT", "")).upper()
if replacebatt and replacebatt not in {"NO", "", "0"}:
add(SEV_WARNING, "REPLACEBATT", "UPS reports battery needs replacement")
# SELFTEST fail
selftest = str(snapshot.get("SELFTEST", "")).upper()
if any(k in selftest for k in SELFTEST_FAIL_KEYWORDS):
add(SEV_WARNING, "SELFTEST_FAIL", f"Self-test failure: {selftest}")
# Temperature high
threshold = ups_cfg.alert_itemp_high or TEMP_HIGH_DEFAULT_C
itemp = _extract_leading_number(str(snapshot.get("ITEMP", "")))
if itemp is not None and itemp >= threshold:
add(
SEV_WARNING,
"TEMP_HIGH",
f"Internal temperature high: {itemp}C >= {threshold}C",
)
# Extended alerts based on UI flags
cfg = load_config()
ui = cfg.ui if hasattr(cfg, 'ui') else None
ui = cfg.ui if hasattr(cfg, "ui") else None
if ui:
r = get_redis()
# Transfer burst: count status ONBATT events in last hour
if ui.enable_transfer_burst_alert:
# We rely on event list already capturing STATUS transitions
events = r.lrange(f"ups:event:list:{ups_cfg.name}", 0, 200)
now = int(time.time())
events = r.lrange(f"ups:event:list:{name}", 0, 200)
onbatt_count = 0
for ev in events:
parts = ev.split('|')
parts = ev.split("|")
if len(parts) >= 3:
try:
ts = int(parts[0])
@@ -70,107 +137,172 @@ def evaluate_alerts(ups_cfg: UPSConfig, snapshot: Dict[str, Any]) -> List[str]:
continue
if now - ts > 3600:
continue
if parts[1] == 'STATUS' and 'ONBATT' in parts[2]:
if parts[1] == "STATUS" and "ONBATT" in parts[2]:
onbatt_count += 1
if onbatt_count >= 3: # threshold heuristically chosen
messages.append(
f"Frequent battery events: {onbatt_count} in last hour"
if onbatt_count >= 3:
add(
SEV_WARNING,
"XFER_BURST",
f"Frequent battery events: {onbatt_count} in last hour",
)
# Voltage deviation: track LINEV vs nominal, average deviation window
if ui.enable_voltage_deviation_alert:
linev = _extract_leading_number(str(snapshot.get('LINEV', '')))
linev = _extract_leading_number(str(snapshot.get("LINEV", "")))
nom = _extract_leading_number(
str(snapshot.get('NOMINV', snapshot.get('NOMINPUT', '')))
str(snapshot.get("NOMINV", snapshot.get("NOMINPUT", "")))
)
if linev and nom:
dev_pct = abs(linev - nom) / nom * 100.0
dev_key = f"ups:volt:dev:samples:{ups_cfg.name}"
dev_key = f"ups:volt:dev:samples:{name}"
r.lpush(dev_key, f"{dev_pct:.2f}")
r.ltrim(dev_key, 0, 49)
samples = r.lrange(dev_key, 0, -1)
try:
avg_dev = sum(float(s) for s in samples) / max(
1, len(samples)
)
avg_dev = sum(float(s) for s in samples) / max(1, len(samples))
if avg_dev > 8.0 and len(samples) >= 10:
messages.append(
"High average voltage deviation: "
f"{avg_dev:.1f}% over {len(samples)} samples"
add(
SEV_WARNING,
"VOLT_DEV",
f"High voltage deviation: {avg_dev:.1f}% over {len(samples)} samples",
)
except Exception:
pass
return messages
return msgs
def _to_float(val) -> float | None:
try:
return float(val)
except (TypeError, ValueError):
return None
def _extract_leading_number(s: str) -> float | None:
try:
part = s.strip().split()[0]
return float(part)
except Exception:
return None
def _cooldown_key(alert: Alert) -> str:
return f"{REDIS_ALERT_KEY_PREFIX}{alert.ups}:{alert.hash()}"
def _cooldown_key(ups_name: str, msg: str) -> str:
return f"{REDIS_ALERT_KEY_PREFIX}{ups_name}:{hash(msg)}"
def send_alert_email(smtp_cfg: SMTPConfig, ups_name: str, messages: List[str]):
password = smtp_cfg.password or os.environ.get('SMTP_PASSWORD')
if not smtp_cfg.to_addrs:
return
subject = f"{smtp_cfg.subject_prefix} {ups_name} alert"
body = "\n".join(messages)
msg = EmailMessage()
msg['Subject'] = subject
msg['From'] = smtp_cfg.from_addr or (smtp_cfg.username or 'ups@example')
msg['To'] = ", ".join(smtp_cfg.to_addrs)
msg.set_content(body)
def _in_silent_hours(smtp_cfg: SMTPConfig, now: datetime | None = None) -> bool:
if smtp_cfg.silent_hours_start is None or smtp_cfg.silent_hours_end is None:
return False
if smtp_cfg.silent_hours_start == smtp_cfg.silent_hours_end:
return False
if now is None:
now = datetime.now()
h = now.hour
s = smtp_cfg.silent_hours_start
e = smtp_cfg.silent_hours_end
if s < e:
return s <= h < e
# Wraps past midnight
return h >= s or h < e
if smtp_cfg.use_ssl:
context = ssl.create_default_context()
with smtplib.SMTP_SSL(
smtp_cfg.host, smtp_cfg.port, context=context, timeout=30
) as server:
if smtp_cfg.username and password:
server.login(smtp_cfg.username, password)
server.send_message(msg)
else:
with smtplib.SMTP(smtp_cfg.host, smtp_cfg.port, timeout=30) as server:
if smtp_cfg.use_tls:
server.starttls(context=ssl.create_default_context())
if smtp_cfg.username and password:
server.login(smtp_cfg.username, password)
server.send_message(msg)
def process_alerts(ups_cfg: UPSConfig, snapshot: Dict[str, Any]):
cfg = load_config()
if not cfg.smtp:
return
msgs = evaluate_alerts(ups_cfg, snapshot)
if not msgs:
def _persist_history(alerts: list[Alert]) -> None:
if not alerts:
return
r = get_redis()
to_send: List[str] = []
pipe = r.pipeline()
for a in alerts:
pipe.lpush(
REDIS_HISTORY_KEY_PREFIX + "all",
f"{a.ts}|{a.severity}|{a.ups}|{a.code}|{a.message}|{a.hash()}",
)
pipe.lpush(
f"ups:alerts:recent:{a.ups}", f"{a.ts}|{a.severity}|{a.message}"
)
pipe.ltrim(REDIS_HISTORY_KEY_PREFIX + "all", 0, ALERT_HISTORY_MAX - 1)
pipe.expire(REDIS_HISTORY_KEY_PREFIX + "all", ALERT_HISTORY_TTL_SECONDS)
for a in alerts:
pipe.ltrim(f"ups:alerts:recent:{a.ups}", 0, 49)
pipe.execute()
def _defer_for_silent_hours(alerts: list[Alert]) -> None:
r = get_redis()
for a in alerts:
key = REDIS_PENDING_KEY_PREFIX + a.ups
r.lpush(key, f"{a.ts}|{a.severity}|{a.code}|{a.message}")
# Keep at most 200 deferred
for ups in {a.ups for a in alerts}:
r.ltrim(REDIS_PENDING_KEY_PREFIX + ups, 0, 199)
def drain_deferred(ups_name: str) -> list[Alert]:
r = get_redis()
key = REDIS_PENDING_KEY_PREFIX + ups_name
raw = r.lrange(key, 0, -1)
r.delete(key)
out: list[Alert] = []
for item in raw:
parts = item.split("|", 3)
if len(parts) == 4:
try:
ts = int(parts[0])
except ValueError:
continue
out.append(Alert(parts[1], parts[3], parts[2], ups_name, ts))
return out
def dispatch_alerts(
smtp_cfg: SMTPConfig | None,
ups_name: str,
alerts: list[Alert],
dashboard_url: str = "http://localhost:8000/",
) -> None:
"""Send a coalesced email containing all alerts for one UPS."""
if not smtp_cfg or not alerts:
return
payload = [
{"severity": a.severity, "message": a.message, "code": a.code}
for a in alerts
]
try:
send_alert_email(smtp_cfg, ups_name, payload, dashboard_url=dashboard_url)
except EmailSendError as e:
logger.warning("Alert email failed for %s: %s", ups_name, e)
def process_alerts(ups_cfg: UPSConfig, snapshot: dict[str, Any]) -> list[Alert]:
"""Evaluate alerts, apply cooldown + silent hours, dispatch email.
Returns the list of alerts that were considered fresh (not cooled down).
"""
cfg = load_config()
all_alerts = evaluate_alerts(ups_cfg, snapshot)
if not all_alerts:
return []
r = get_redis()
fresh: list[Alert] = []
now = int(time.time())
for m in msgs:
key = _cooldown_key(ups_cfg.name, m)
for a in all_alerts:
key = _cooldown_key(a)
if not r.get(key):
to_send.append(m)
fresh.append(a)
r.set(key, now, ex=ALERT_COOLDOWN_SECONDS)
if to_send:
# Store recent alerts with timestamp for health reporting
recent_key = f"ups:alerts:recent:{ups_cfg.name}"
pipe = r.pipeline()
for m in to_send:
pipe.lpush(recent_key, f"{now}|{m}")
pipe.ltrim(recent_key, 0, 49) # keep last 50
pipe.execute()
send_alert_email(cfg.smtp, ups_cfg.name, to_send)
if not fresh:
return []
_persist_history(fresh)
if not cfg.smtp:
return fresh
# Silent hours filter: keep CRITICAL, defer others
if _in_silent_hours(cfg.smtp):
critical = [a for a in fresh if a.severity == SEV_CRITICAL]
deferred = [a for a in fresh if a.severity != SEV_CRITICAL]
if deferred:
_defer_for_silent_hours(deferred)
if critical:
dispatch_alerts(cfg.smtp, ups_cfg.name, critical)
else:
# Also flush any deferred alerts for this UPS
deferred = drain_deferred(ups_cfg.name)
dispatch_alerts(cfg.smtp, ups_cfg.name, fresh + deferred)
return fresh
def emit_info(ups_name: str, code: str, message: str) -> None:
"""Record and dispatch a single INFO alert (e.g. recovery events)."""
alert = Alert(SEV_INFO, message, code, ups_name, int(time.time()))
r = get_redis()
if r.get(_cooldown_key(alert)):
return
r.set(_cooldown_key(alert), int(time.time()), ex=ALERT_COOLDOWN_SECONDS)
_persist_history([alert])
cfg = load_config()
if cfg.smtp:
if _in_silent_hours(cfg.smtp):
_defer_for_silent_hours([alert])
else:
dispatch_alerts(cfg.smtp, ups_name, [alert])
+22 -7
View File
@@ -1,22 +1,24 @@
from __future__ import annotations
import asyncio
import shutil
import logging
from typing import Dict, Any
import shutil
from typing import Any
logger = logging.getLogger(__name__)
APCACCESS_BIN = shutil.which('apcaccess') or 'apcaccess'
APCACCESS_TIMEOUT_SECONDS = 10.0
class APCStatusError(Exception):
pass
async def fetch_status(host: str, port: int) -> Dict[str, Any]:
async def fetch_status(host: str, port: int) -> dict[str, Any]:
"""Invoke apcaccess CLI and parse key:value lines into a dict.
Raises APCStatusError if binary fails or returns no data.
Raises APCStatusError if binary fails, times out, or returns no data.
"""
proc = await asyncio.create_subprocess_exec(
APCACCESS_BIN,
@@ -25,14 +27,26 @@ async def fetch_status(host: str, port: int) -> Dict[str, Any]:
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=APCACCESS_TIMEOUT_SECONDS
)
except TimeoutError:
try:
proc.kill()
await proc.wait()
except ProcessLookupError:
pass
raise APCStatusError(
f"apcaccess timed out after {APCACCESS_TIMEOUT_SECONDS}s for {host}:{port}"
)
if proc.returncode != 0:
err_txt = stderr.decode(errors="replace").strip()
raise APCStatusError(
f'apcaccess exit {proc.returncode}: {err_txt}'
)
text = stdout.decode(errors='replace')
data: Dict[str, Any] = {}
data: dict[str, Any] = {}
for line in text.splitlines():
if ':' not in line:
continue
@@ -40,7 +54,8 @@ async def fetch_status(host: str, port: int) -> Dict[str, Any]:
key = k.strip()
val = v.strip()
data[key] = val
# Normalize expected fields/aliases
if not data:
raise APCStatusError("apcaccess returned no data")
if 'UPSNAME' not in data and 'NAME' in data:
data['UPSNAME'] = data['NAME']
if 'MODEL' in data:
+159
View File
@@ -0,0 +1,159 @@
"""Authentication: argon2 password hashing + signed session cookies.
Single-admin design for homelab use. Credentials set via env vars:
ADMIN_USERNAME, ADMIN_PASSWORD_HASH, SESSION_SECRET
If ADMIN_PASSWORD_HASH is unset, the first-run setup page generates one
and stores it in Redis under the key ``ups:admin:hash``. The stored hash
takes precedence over env vars so the operator can change their password
via the UI.
"""
from __future__ import annotations
import secrets
from fastapi import Depends, HTTPException, Request, Response
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from passlib.hash import argon2
from .settings import settings
from .storage import get_redis
SESSION_COOKIE = "ups_session"
CSRF_COOKIE = "csrf_token"
CSRF_HEADER = "X-CSRF-Token"
ADMIN_HASH_KEY = "ups:admin:hash"
ADMIN_USER_KEY = "ups:admin:username"
def _serializer() -> URLSafeTimedSerializer:
secret = settings.session_secret
if not secret:
# Best effort ephemeral secret so we don't crash at import time in dev
secret = "dev-insecure-change-me-please"
return URLSafeTimedSerializer(secret, salt="ups-session")
def hash_password(password: str) -> str:
"""Produce an argon2 hash of the given password."""
return argon2.hash(password)
def verify_password(password: str, hashed: str) -> bool:
try:
return argon2.verify(password, hashed)
except Exception:
return False
def get_stored_admin() -> tuple[str | None, str | None]:
"""Return (username, password_hash) from Redis or settings.
Redis-stored credentials take precedence when present.
"""
try:
r = get_redis()
redis_hash = r.get(ADMIN_HASH_KEY)
redis_user = r.get(ADMIN_USER_KEY)
except Exception:
redis_hash = None
redis_user = None
username = redis_user or settings.admin_username
pw_hash = redis_hash or settings.admin_password_hash
return username, pw_hash
def store_admin(username: str, password: str) -> None:
"""Persist admin credentials to Redis."""
r = get_redis()
r.set(ADMIN_USER_KEY, username)
r.set(ADMIN_HASH_KEY, hash_password(password))
def is_admin_configured() -> bool:
_, pw_hash = get_stored_admin()
return bool(pw_hash)
def create_session_token(username: str) -> str:
return _serializer().dumps({"u": username})
def verify_session_token(token: str) -> str | None:
try:
data = _serializer().loads(
token, max_age=settings.session_max_age_seconds
)
if isinstance(data, dict):
return data.get("u")
except (BadSignature, SignatureExpired):
return None
return None
def make_csrf_token() -> str:
return secrets.token_urlsafe(32)
def set_auth_cookies(response: Response, username: str) -> str:
"""Set session + CSRF cookies on the response. Returns CSRF token."""
session_token = create_session_token(username)
csrf_token = make_csrf_token()
secure = settings.trust_proxy
response.set_cookie(
SESSION_COOKIE,
session_token,
max_age=settings.session_max_age_seconds,
httponly=True,
samesite="lax",
secure=secure,
path="/",
)
response.set_cookie(
CSRF_COOKIE,
csrf_token,
max_age=settings.session_max_age_seconds,
httponly=False, # JS needs to read this
samesite="lax",
secure=secure,
path="/",
)
return csrf_token
def clear_auth_cookies(response: Response) -> None:
response.delete_cookie(SESSION_COOKIE, path="/")
response.delete_cookie(CSRF_COOKIE, path="/")
def current_user(request: Request) -> str | None:
"""Return current session username or None."""
token = request.cookies.get(SESSION_COOKIE)
if not token:
return None
return verify_session_token(token)
def require_session(request: Request) -> str:
"""FastAPI dependency - 401 if not authenticated."""
user = current_user(request)
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
return user
def require_csrf(request: Request) -> None:
"""Enforce double-submit CSRF token on mutating requests."""
if request.method in ("GET", "HEAD", "OPTIONS"):
return
cookie_token = request.cookies.get(CSRF_COOKIE)
header_token = request.headers.get(CSRF_HEADER)
if not cookie_token or not header_token or cookie_token != header_token:
raise HTTPException(status_code=403, detail="CSRF token invalid or missing")
def require_session_and_csrf(
request: Request, user: str = Depends(require_session)
) -> str:
require_csrf(request)
return user
+111 -39
View File
@@ -1,74 +1,146 @@
from __future__ import annotations
from pydantic import BaseModel, Field
from typing import List, Optional
from pathlib import Path
import os
CONFIG_PATH = Path(
os.environ.get("UPS_CONFIG_PATH", "/config/ups.yaml")
) # legacy path for migration
import ipaddress
import re
from pathlib import Path
from typing import ClassVar
from pydantic import BaseModel, Field, field_validator
from .settings import settings
CONFIG_PATH = Path(settings.ups_config_path) # legacy path for migration
_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,32}$")
_HOSTNAME_RE = re.compile(
r"^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
)
_BLOCKED_LITERALS = {"localhost", "localhost.localdomain"}
def _validate_host_string(host: str) -> str:
"""Validate a host field. Rejects loopback/link-local always; rejects
private ranges when ALLOW_PRIVATE_IPS is false.
"""
host = host.strip()
if not host:
raise ValueError("host must not be empty")
if host.lower() in _BLOCKED_LITERALS:
raise ValueError(f"host '{host}' is not permitted (loopback alias)")
# Try as IP first
try:
ip = ipaddress.ip_address(host)
if ip.is_loopback or ip.is_link_local or ip.is_unspecified or ip.is_multicast:
raise ValueError(f"host IP {host} is not a valid polling target")
if ip.is_private and not settings.allow_private_ips:
raise ValueError(
f"private IP {host} not allowed (set ALLOW_PRIVATE_IPS=true)"
)
return host
except ValueError as ip_err:
# Fall through to hostname validation only when the string wasn't a
# parseable IP address
if "does not appear to be an IPv4 or IPv6 address" not in str(ip_err):
raise
# Validate as hostname
if not _HOSTNAME_RE.match(host):
raise ValueError(f"host '{host}' is not a valid hostname or IP")
return host
class UPSConfig(BaseModel):
name: str = Field(..., description="Friendly UPS name")
host: str = Field(..., description="apcupsd NIS host/IP")
port: int = Field(3551, description="apcupsd NIS port")
interval_seconds: int = Field(30, description="Polling interval")
# Alert thresholds (any optional)
alert_loadpct_high: Optional[float] = Field(
None, description="Trigger if LOADPCT >= value"
port: int = Field(3551, ge=1, le=65535, description="apcupsd NIS port")
interval_seconds: int = Field(
30, ge=5, le=3600, description="Polling interval"
)
alert_bcharge_low: Optional[float] = Field(
None, description="Trigger if BCHARGE <= value"
# Alert thresholds (any optional)
alert_loadpct_high: float | None = Field(
None, ge=0, le=100, description="Trigger if LOADPCT >= value"
)
alert_bcharge_low: float | None = Field(
None, ge=0, le=100, description="Trigger if BCHARGE <= value"
)
alert_on_battery: bool = Field(
False, description="Trigger when STATUS indicates on battery"
)
alert_runtime_low_minutes: Optional[float] = Field(
None, description="Trigger if TIMELEFT <= minutes"
alert_runtime_low_minutes: float | None = Field(
None, ge=0, description="Trigger if TIMELEFT <= minutes"
)
alert_itemp_high: float | None = Field(
None, ge=0, le=120, description="Trigger if internal temp >= C"
)
@field_validator("name")
@classmethod
def _validate_name(cls, v: str) -> str:
if not _NAME_RE.match(v):
raise ValueError(
"name must be 1-32 chars, alphanumeric / underscore / dash only"
)
return v
@field_validator("host")
@classmethod
def _validate_host(cls, v: str) -> str:
return _validate_host_string(v)
class SMTPConfig(BaseModel):
host: str = Field(..., description="SMTP server host/IP")
port: int = Field(..., description="SMTP port")
username: Optional[str] = Field(None)
password: Optional[str] = Field(
None, description="Plain password or set via env SMTP_PASSWORD"
)
port: int = Field(..., ge=1, le=65535, description="SMTP port")
username: str | None = Field(None)
# password is never persisted in Redis; read from env SMTP_PASSWORD
use_tls: bool = Field(False, description="STARTTLS if true")
use_ssl: bool = Field(False, description="SSL (smtplib.SMTP_SSL) if true")
from_addr: Optional[str] = Field(None, description="From email address")
to_addrs: List[str] = Field(
from_addr: str | None = Field(None, description="From email address")
to_addrs: list[str] = Field(
default_factory=list, description="Recipient list"
)
subject_prefix: str = Field("[UPS]", description="Subject prefix")
silent_hours_start: int | None = Field(
None, ge=0, le=23, description="Silent window start hour (local time)"
)
silent_hours_end: int | None = Field(
None, ge=0, le=23, description="Silent window end hour (local time)"
)
daily_summary_hour: int | None = Field(
None, ge=0, le=23, description="Hour to send daily summary"
)
class UIConfig(BaseModel):
show_events: bool = True
show_energy: bool = False
color_badges: bool = True
show_headroom: bool = True
show_watts: bool = True
show_runtime: bool = True
allow_resize: bool = True
enable_transfer_burst_alert: bool = False
enable_voltage_deviation_alert: bool = False
energy_cost_per_kwh: float = 0.0
class AppConfig(BaseModel):
ups: List[UPSConfig]
smtp: Optional[SMTPConfig] = None
# UI feature flags (optional; default values used if missing)
# Added for dashboard toggleable features
class UIConfig(BaseModel):
show_events: bool = True
show_energy: bool = False
color_badges: bool = True
show_headroom: bool = True
show_watts: bool = True
show_runtime: bool = True
allow_resize: bool = True
enable_transfer_burst_alert: bool = False
enable_voltage_deviation_alert: bool = False
ups: list[UPSConfig] = Field(default_factory=list)
smtp: SMTPConfig | None = None
ui: UIConfig = Field(default_factory=UIConfig)
ui: UIConfig = UIConfig()
# Backward-compat alias: older code references AppConfig.UIConfig
UIConfig: ClassVar[type[UIConfig]] = UIConfig
_cached: AppConfig | None = None
def load_config(path: Path = CONFIG_PATH) -> AppConfig:
# Use redis store (lazy import to avoid circular)
"""Load config from Redis, cached in-process.
Invalidate via ``config_module._cached = None`` after any write.
"""
from .config_store import load_config_redis
global _cached
if _cached:
+51 -98
View File
@@ -1,27 +1,20 @@
from __future__ import annotations
from typing import List, Optional, Dict, Any
import asyncio
import logging
import socket
from typing import Any
try:
from pydantic import BaseModel, Field, ValidationError
except ImportError:
# Fallback for environments without pydantic
BaseModel = object
def Field(default=None, **kwargs):
return default
ValidationError = ValueError
from pydantic import BaseModel, Field, ValidationError
from .config import UPSConfig, SMTPConfig, AppConfig
from .config import AppConfig, SMTPConfig, UIConfig, UPSConfig
from .config_store import load_config_redis, save_config_redis
from .settings import settings
logger = logging.getLogger(__name__)
# Incremented every time configuration is modified so
# SSE clients can detect changes
# Incremented every time configuration is modified so SSE clients can
# detect changes
_config_version: int = 0
@@ -29,175 +22,127 @@ def get_config_version() -> int:
return _config_version
class ConfigWriteError(Exception): # retained for API compatibility
class ConfigWriteError(Exception):
"""Raised when configuration cannot be written (kept for compatibility)."""
class UPSConfigUpdate(BaseModel):
"""Model for updating UPS configuration"""
name: Optional[str] = Field(None, description="Friendly UPS name")
host: Optional[str] = Field(None, description="apcupsd NIS host/IP")
port: Optional[int] = Field(None, description="apcupsd NIS port")
interval_seconds: Optional[int] = Field(
None, description="Polling interval"
)
alert_loadpct_high: Optional[float] = Field(
None, description="Trigger if LOADPCT >= value"
)
alert_bcharge_low: Optional[float] = Field(
None, description="Trigger if BCHARGE <= value"
)
alert_on_battery: Optional[bool] = Field(
None, description="Trigger when STATUS indicates on battery"
)
alert_runtime_low_minutes: Optional[float] = Field(
None, description="Trigger if TIMELEFT <= minutes"
)
"""Model for updating UPS configuration."""
name: str | None = None
host: str | None = None
port: int | None = Field(None, ge=1, le=65535)
interval_seconds: int | None = Field(None, ge=5, le=3600)
alert_loadpct_high: float | None = Field(None, ge=0, le=100)
alert_bcharge_low: float | None = Field(None, ge=0, le=100)
alert_on_battery: bool | None = None
alert_runtime_low_minutes: float | None = Field(None, ge=0)
alert_itemp_high: float | None = Field(None, ge=0, le=120)
class ConfigManager:
def __init__(self):
def __init__(self) -> None:
self._lock = asyncio.Lock()
async def load_config(self) -> AppConfig:
async with self._lock:
return load_config_redis()
async def save_config(self, config: AppConfig) -> None:
async with self._lock:
save_config_redis(config)
logger.info("Configuration saved to Redis")
# Invalidate cached global config so subsequent
# load_config() calls see changes
try:
from . import config as config_module
config_module._cached = None
except Exception: # pragma: no cover - defensive
logger.debug(
"Failed to invalidate config cache", exc_info=True
)
# bump config version
logger.debug("Failed to invalidate config cache", exc_info=True)
global _config_version
_config_version += 1
async def get_ups_list(self) -> List[UPSConfig]:
"""Get list of all UPS configurations"""
async def get_ups_list(self) -> list[UPSConfig]:
config = await self.load_config()
return config.ups
async def get_ups(self, name: str) -> Optional[UPSConfig]:
"""Get UPS configuration by name"""
async def get_ups(self, name: str) -> UPSConfig | None:
config = await self.load_config()
for ups in config.ups:
if ups.name == name:
return ups
return None
async def add_ups(self, ups_config: UPSConfig) -> bool:
"""Add new UPS configuration"""
config = await self.load_config()
# Check if UPS with same name already exists
if any(ups.name == ups_config.name for ups in config.ups):
raise ValueError(
f"UPS with name '{ups_config.name}' already exists"
)
config.ups.append(ups_config)
await self.save_config(config)
# No file cache now
return True
async def update_ups(self, name: str, updates: UPSConfigUpdate) -> bool:
"""Update existing UPS configuration"""
config = await self.load_config()
ups_index = None
for i, ups in enumerate(config.ups):
if ups.name == name:
ups_index = i
break
if ups_index is None:
return False
# Apply updates
ups_dict = config.ups[ups_index].model_dump()
update_dict = updates.model_dump(exclude_none=True)
ups_dict.update(update_dict)
# Validate updated configuration
try:
updated_ups = UPSConfig(**ups_dict)
except ValidationError as e:
raise ValueError(f"Invalid configuration: {e}")
config.ups[ups_index] = updated_ups
await self.save_config(config)
# No file cache now
return True
async def delete_ups(self, name: str) -> bool:
"""Delete UPS configuration"""
config = await self.load_config()
original_count = len(config.ups)
config.ups = [ups for ups in config.ups if ups.name != name]
if len(config.ups) == original_count:
return False # UPS not found
return False
await self.save_config(config)
# No file cache now
return True
async def get_smtp_config(self) -> Optional[SMTPConfig]:
"""Get SMTP configuration"""
async def get_smtp_config(self) -> SMTPConfig | None:
config = await self.load_config()
return config.smtp
async def update_smtp_config(self, smtp_config: SMTPConfig) -> None:
"""Update SMTP configuration"""
config = await self.load_config()
config.smtp = smtp_config
await self.save_config(config)
# Clear cached config
from . import config as config_module
config_module._cached = None
async def update_ui_config(self, ui: UIConfig) -> None:
config = await self.load_config()
config.ui = ui
await self.save_config(config)
async def validate_ups_connection(
self, ups_config: UPSConfig, timeout: float = 3.0
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""Port-only connectivity test (no protocol / CLI call)."""
result: Dict[str, Any] = {
result: dict[str, Any] = {
"success": False,
"message": "",
"connectivity": {"ok": False, "error": None},
"protocol": {"ok": False, "error": None},
"data": None,
}
# Raw TCP connectivity test
try:
# Use low-level socket to distinguish DNS/timeouts
with socket.create_connection(
(ups_config.host, ups_config.port), timeout=timeout
):
result["connectivity"]["ok"] = True
except Exception as e: # broad to surface any network issue
except Exception as e:
result["connectivity"]["error"] = str(e)
result["message"] = f"TCP connectivity failed: {e}"
return result
# For port-only test we just mirror connectivity result
if result["connectivity"]["ok"]:
result["protocol"]["ok"] = True
result["success"] = True
@@ -207,5 +152,13 @@ class ConfigManager:
return result
# Global instance
def smtp_redacted_dict(smtp: SMTPConfig | None) -> dict[str, Any] | None:
"""Return SMTP config dict with password redacted from env."""
if smtp is None:
return None
d = smtp.model_dump()
d["password"] = "***" if settings.smtp_password else None
return d
config_manager = ConfigManager()
+23 -14
View File
@@ -1,31 +1,39 @@
"""Redis-backed configuration storage replacing YAML file.
"""Redis-backed configuration storage.
Schema:
Key ups:config:json -> JSON object: {"ups": [...], "smtp": {...}|null}
Migration:
On first load if redis key missing and legacy YAML present, import it.
Key ``ups:config:json`` holds JSON: ``{"ups": [...], "smtp": {...}|null, "ui": {...}}``.
Legacy YAML import runs once if Redis is empty.
"""
from __future__ import annotations
from typing import Optional
from pathlib import Path
import json
import logging
from .storage import get_redis
from .config import AppConfig, CONFIG_PATH
from pathlib import Path
import yaml
from .config import CONFIG_PATH, AppConfig
from .storage import get_redis
logger = logging.getLogger(__name__)
REDIS_CONFIG_KEY = "ups:config:json"
def _load_legacy_yaml(path: Path) -> Optional[AppConfig]:
def _strip_smtp_password(data: dict) -> dict:
"""Strip persisted SMTP passwords (migration from older schema)."""
smtp = data.get("smtp")
if isinstance(smtp, dict) and "password" in smtp:
smtp.pop("password", None)
return data
def _load_legacy_yaml(path: Path) -> AppConfig | None:
if not path.exists():
return None
try:
with path.open() as f:
raw = yaml.safe_load(f) or {}
raw = _strip_smtp_password(raw)
return AppConfig(**raw)
except Exception as e: # pragma: no cover
logger.warning("Failed to import legacy YAML config: %s", e)
@@ -40,14 +48,13 @@ def load_config_redis() -> AppConfig:
data = json.loads(raw)
except json.JSONDecodeError:
data = {}
data = _strip_smtp_password(data)
return AppConfig(**data)
# Migration path
legacy = _load_legacy_yaml(CONFIG_PATH)
if legacy:
save_config_redis(legacy)
logger.info("Imported legacy YAML config into Redis")
return legacy
# If nothing exists, create empty scaffold
empty = AppConfig(ups=[], smtp=None)
save_config_redis(empty)
return empty
@@ -55,4 +62,6 @@ def load_config_redis() -> AppConfig:
def save_config_redis(cfg: AppConfig) -> None:
r = get_redis()
r.set(REDIS_CONFIG_KEY, json.dumps(cfg.model_dump(exclude_none=True)))
data = cfg.model_dump(exclude_none=True)
data = _strip_smtp_password(data)
r.set(REDIS_CONFIG_KEY, json.dumps(data))
+78
View File
@@ -0,0 +1,78 @@
"""CSV export helpers."""
from __future__ import annotations
import csv
import io
import time
from collections.abc import AsyncIterator, Iterable
from .storage import get_history, get_redis
def _csv_row(writer_buf: io.StringIO, writer: csv.writer, row: Iterable) -> str:
writer.writerow(row)
out = writer_buf.getvalue()
writer_buf.seek(0)
writer_buf.truncate()
return out
async def export_history_csv(ups_name: str, since_days: int = 7) -> AsyncIterator[str]:
history = await get_history(ups_name, since_seconds=since_days * 24 * 3600)
buf = io.StringIO()
writer = csv.writer(buf)
yield _csv_row(buf, writer, ["ts", "status", "loadpct", "bcharge", "timeleft", "linev", "derived_watts"])
for item in history:
d = item.get("data", {})
yield _csv_row(
buf,
writer,
[
item.get("ts"),
d.get("STATUS", ""),
d.get("LOADPCT", ""),
d.get("BCHARGE", ""),
d.get("TIMELEFT", ""),
d.get("LINEV", ""),
d.get("DERIVED_WATTS", ""),
],
)
async def export_events_csv(ups_name: str, since_days: int = 7) -> AsyncIterator[str]:
r = get_redis()
raw = r.lrange(f"ups:event:list:{ups_name}", 0, -1)
cutoff = int(time.time()) - since_days * 24 * 3600
buf = io.StringIO()
writer = csv.writer(buf)
yield _csv_row(buf, writer, ["ts", "type", "detail"])
for item in raw:
parts = item.split("|", 2)
if len(parts) != 3:
continue
try:
ts = int(parts[0])
except ValueError:
continue
if ts < cutoff:
continue
yield _csv_row(buf, writer, [ts, parts[1], parts[2]])
async def export_energy_csv(ups_name: str, since_days: int = 7) -> AsyncIterator[str]:
r = get_redis()
buf = io.StringIO()
writer = csv.writer(buf)
yield _csv_row(buf, writer, ["date", "kwh"])
now = time.time()
for i in range(since_days):
day_ts = now - i * 86400
day_str = time.strftime("%Y%m%d", time.localtime(day_ts))
date_disp = time.strftime("%Y-%m-%d", time.localtime(day_ts))
watt_seconds = r.get(f"ups:energy:{ups_name}:{day_str}")
if watt_seconds:
try:
kwh = float(watt_seconds) / 3600.0 / 1000.0
yield _csv_row(buf, writer, [date_disp, f"{kwh:.4f}"])
except ValueError:
continue
+29
View File
@@ -0,0 +1,29 @@
"""Liveness + readiness probes."""
from __future__ import annotations
from .config_store import load_config_redis
from .storage import get_redis
def liveness() -> dict:
return {"status": "ok"}
def readiness() -> tuple[int, dict]:
"""Returns (http_status, payload)."""
checks = {}
ok = True
try:
r = get_redis()
r.ping()
checks["redis"] = "ok"
except Exception as e: # noqa: BLE001
checks["redis"] = f"error: {e}"
ok = False
try:
load_config_redis()
checks["config"] = "ok"
except Exception as e: # noqa: BLE001
checks["config"] = f"error: {e}"
ok = False
return (200 if ok else 503, {"status": "ok" if ok else "error", "checks": checks})
+59
View File
@@ -0,0 +1,59 @@
"""Structured JSON logging with request-ID correlation."""
from __future__ import annotations
import contextvars
import logging
import sys
import uuid
from pythonjsonlogger import jsonlogger
from starlette.middleware.base import BaseHTTPMiddleware
from .settings import settings
_request_id_var: contextvars.ContextVar[str] = contextvars.ContextVar(
"request_id", default="-"
)
def get_request_id() -> str:
return _request_id_var.get()
class RequestIdFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = _request_id_var.get()
return True
def configure_logging() -> None:
"""Configure root logger to emit JSON with request_id."""
root = logging.getLogger()
# Remove any pre-existing handlers (uvicorn installs its own)
for h in list(root.handlers):
root.removeHandler(h)
handler = logging.StreamHandler(sys.stdout)
formatter = jsonlogger.JsonFormatter(
fmt="%(asctime)s %(levelname)s %(name)s %(request_id)s %(message)s",
rename_fields={"asctime": "ts", "levelname": "level", "name": "logger"},
)
handler.setFormatter(formatter)
handler.addFilter(RequestIdFilter())
root.addHandler(handler)
root.setLevel(getattr(logging, settings.log_level.upper(), logging.INFO))
# Calm down noisy loggers
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
class RequestIdMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
rid = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
token = _request_id_var.set(rid)
try:
response = await call_next(request)
response.headers["X-Request-ID"] = rid
return response
finally:
_request_id_var.reset(token)
+72 -457
View File
@@ -1,472 +1,87 @@
"""FastAPI application entry point.
Assembles middleware, lifespan (poller start/stop), auth, routes.
All request handlers live in app/routes/*.
"""
from __future__ import annotations
import asyncio
import time
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
import orjson
from .config import load_config, UPSConfig
from .storage import get_latest, get_history
from .poller import poll_loop
from .config_manager import (
config_manager,
UPSConfigUpdate,
ConfigWriteError,
get_config_version,
)
from .storage import get_redis
from .apc_cli import fetch_status, APCStatusError
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
app = FastAPI(title="APC UPS Dashboard")
app.mount('/static', StaticFiles(directory='app/static'), name='static')
templates = Jinja2Templates(directory='app/templates')
from .logging_config import RequestIdMiddleware, configure_logging
from .poller import cancel_all_tasks, poll_loop
from .rate_limit import limiter
from .routes import api_alerts, api_auth, api_config, api_events, api_health, api_ups, pages, sse
from .security import SecurityHeadersMiddleware
from .settings import settings
logger = logging.getLogger(__name__)
_background_tasks: list[asyncio.Task] = []
@app.on_event("startup")
async def startup():
asyncio.create_task(poll_loop())
@app.get('/', response_class=HTMLResponse)
async def dashboard(request: Request):
cfg = load_config()
return templates.TemplateResponse(
'dashboard.html', {
"request": request,
"ups_list": cfg.ups,
"ui_cfg": cfg.ui.model_dump(),
}
)
@app.get('/config', response_class=HTMLResponse)
async def config_page(request: Request):
return templates.TemplateResponse('config.html', {"request": request})
@app.get('/api/ups')
async def list_ups():
cfg = load_config()
return [{"name": u.name, "host": u.host, "port": u.port} for u in cfg.ups]
@app.get('/api/ups/{ups_name}')
async def ups_status(ups_name: str):
snap = await get_latest(ups_name)
return snap or {"error": "not found"}
@app.get('/api/ups/{ups_name}/history')
async def ups_history(ups_name: str):
hist = await get_history(ups_name)
return hist
@app.get('/api/ups/{ups_name}/metric/{metric}')
async def metric_history(ups_name: str, metric: str, limit: int = 120):
"""Return recent numeric history samples for a single metric.
Limit capped at 500 to avoid large payloads.
"""
limit = max(1, min(limit, 500))
all_hist = await get_history(ups_name)
# Use only most recent entries
recent = all_hist[-limit:]
out = []
for item in recent:
data = item.get('data', {})
raw_val = data.get(metric)
if raw_val is None:
continue
# Extract leading number
try:
val = float(str(raw_val).split()[0])
except Exception:
continue
out.append({'ts': item.get('ts'), 'value': val})
return out
@app.get('/api/ups/{ups_name}/events')
async def ups_events(ups_name: str):
"""Return recent status/transfer events for a UPS."""
r = get_redis()
key = f"ups:event:list:{ups_name}"
raw = r.lrange(key, 0, 99)
parsed = []
for item in raw:
if '|' in item:
@asynccontextmanager
async def lifespan(app: FastAPI):
configure_logging()
if not settings.session_secret:
logger.warning(
"SESSION_SECRET not set; using insecure ephemeral secret. "
"Set SESSION_SECRET in your environment for production."
)
task = asyncio.create_task(poll_loop())
_background_tasks.append(task)
logger.info("Poller started")
try:
yield
finally:
logger.info("Shutting down: cancelling background tasks")
for t in _background_tasks:
t.cancel()
for t in _background_tasks:
try:
ts_s, kind, rest = item.split('|', 2)
parsed.append({
'ts': int(ts_s),
'type': kind,
'detail': rest
})
continue
except Exception:
await t
except (asyncio.CancelledError, Exception):
pass
parsed.append({'raw': item})
return parsed
await cancel_all_tasks()
logger.info("Shutdown complete")
@app.get('/api/ups/{ups_name}/energy')
async def ups_energy(ups_name: str):
"""Return today's accumulated energy (approx kWh) if available."""
r = get_redis()
day_str = time.strftime('%Y%m%d') # type: ignore
key = f"ups:energy:{ups_name}:{day_str}"
watt_seconds = r.get(key)
if watt_seconds:
try:
ws = float(watt_seconds)
kwh = ws / 3600.0 / 1000.0
return {'kwh_today': round(kwh, 4)}
except ValueError:
pass
return {'kwh_today': None}
def create_app() -> FastAPI:
application = FastAPI(title="APC UPS Dashboard", lifespan=lifespan)
application.state.limiter = limiter
application.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Middleware (outermost first in registration; Starlette wraps inside-out)
application.add_middleware(SecurityHeadersMiddleware)
application.add_middleware(RequestIdMiddleware)
@app.get('/api/ups/{ups_name}/watts_per_minute')
async def ups_watts_per_minute(ups_name: str):
"""Return recent per-minute average watts for a UPS (last 24h)."""
r = get_redis()
key = f"ups:watts:permin:{ups_name}"
raw = r.lrange(key, 0, 1440)
out = []
for item in raw:
if '|' in item:
minute, avg = item.split('|', 1)
try:
out.append({'minute': minute, 'avg_watts': float(avg)})
except ValueError:
continue
# list stored newest-first; reverse to chronological
out.reverse()
return out
@app.get('/api/ups/{ups_name}/health')
async def ups_health(ups_name: str):
"""Return aggregated health indicators.
Includes recent alerts, voltage deviation stats, and ONBATT event count.
"""
r = get_redis()
# Recent alerts
alerts_key = f"ups:alerts:recent:{ups_name}"
alert_raw = r.lrange(alerts_key, 0, 19)
alerts = []
for a in alert_raw:
if '|' in a:
ts_s, msg = a.split('|', 1)
try:
alerts.append({'ts': int(ts_s), 'msg': msg})
except ValueError:
alerts.append({'raw': a})
else:
alerts.append({'raw': a})
# Voltage deviation samples
dev_key = f"ups:volt:dev:samples:{ups_name}"
dev_samples = r.lrange(dev_key, 0, 49)
dev_vals = []
for d in dev_samples:
try:
dev_vals.append(float(d))
except ValueError:
continue
dev_avg = sum(devVals := dev_vals) / len(devVals) if dev_vals else None
dev_max = max(dev_vals) if dev_vals else None
# Transfer burst count (recent hour ONBATT events)
events_key = f"ups:event:list:{ups_name}"
now = int(time.time())
events = r.lrange(events_key, 0, 200)
onbatt_hour = 0
for ev in events:
parts = ev.split('|')
if len(parts) >= 3:
try:
ts_e = int(parts[0])
except ValueError:
continue
if now - ts_e > 3600:
continue
if parts[1] == 'STATUS' and 'ONBATT' in parts[2]:
onbatt_hour += 1
return {
'alerts': alerts,
'voltage_deviation': {
'avg_pct': round(dev_avg, 2) if dev_avg is not None else None,
'max_pct': round(dev_max, 2) if dev_max is not None else None,
'samples': len(dev_vals)
},
'onbatt_last_hour': onbatt_hour
}
@app.get('/api/ups/{ups_name}/debug')
async def ups_debug(ups_name: str):
"""Return current apcaccess CLI status for a UPS."""
cfg = load_config()
target = next((u for u in cfg.ups if u.name == ups_name), None)
if not target:
raise HTTPException(status_code=404, detail='UPS not found')
try:
data = await fetch_status(target.host, target.port)
return data
except APCStatusError as e:
raise HTTPException(status_code=502, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get('/api/stream')
async def stream():
# simple Server Sent Events stream of snapshots (polling redis every 5s)
async def event_gen():
while True:
cfg = load_config()
payload = {"snapshots": {}, "cfgVersion": get_config_version()}
for u in cfg.ups:
snap = await get_latest(u.name)
if snap:
payload["snapshots"][u.name] = snap
# include simple config metadata to help client reconcile
payload["upsMeta"] = [
{"name": u.name, "host": u.host, "port": u.port}
for u in cfg.ups
]
# Backward compatibility: also flatten UPS snapshots at top level
for name, snap in payload["snapshots"].items():
payload.setdefault(name, snap)
yield f"data: {orjson.dumps(payload).decode()}\n\n"
await asyncio.sleep(5)
return StreamingResponse(event_gen(), media_type='text/event-stream')
# Configuration management endpoints
@app.get('/api/config/ups')
async def get_ups_configs():
"""Get all UPS configurations"""
try:
ups_list = await config_manager.get_ups_list()
return [ups.model_dump() for ups in ups_list]
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get('/api/config/ups/{ups_name}')
async def get_ups_config(ups_name: str):
"""Get specific UPS configuration"""
try:
ups = await config_manager.get_ups(ups_name)
if not ups:
raise HTTPException(status_code=404, detail="UPS not found")
return ups.model_dump()
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post('/api/config/ups')
async def add_ups_config(ups_config: UPSConfig):
"""Add new UPS configuration"""
try:
await config_manager.add_ups(ups_config)
return {"message": "UPS configuration added successfully"}
except ConfigWriteError as e:
raise HTTPException(status_code=507, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.put('/api/config/ups/{ups_name}')
async def update_ups_config(ups_name: str, updates: UPSConfigUpdate):
"""Update UPS configuration"""
try:
success = await config_manager.update_ups(ups_name, updates)
if not success:
raise HTTPException(status_code=404, detail="UPS not found")
return {"message": "UPS configuration updated successfully"}
except ConfigWriteError as e:
raise HTTPException(status_code=507, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.delete('/api/config/ups/{ups_name}')
async def delete_ups_config(ups_name: str):
"""Delete UPS configuration"""
try:
success = await config_manager.delete_ups(ups_name)
if not success:
raise HTTPException(status_code=404, detail="UPS not found")
return {"message": "UPS configuration deleted successfully"}
except ConfigWriteError as e:
raise HTTPException(status_code=507, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post('/api/config/ups/{ups_name}/test')
async def test_ups_connection(ups_name: str):
"""Test UPS connection"""
try:
ups = await config_manager.get_ups(ups_name)
if not ups:
raise HTTPException(status_code=404, detail="UPS not found")
result = await config_manager.validate_ups_connection(ups)
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post('/api/config/ups/test')
async def test_new_ups_connection(ups_config: UPSConfig):
"""Test new UPS configuration connection"""
try:
result = await config_manager.validate_ups_connection(ups_config)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get('/api/config/smtp')
async def get_smtp_config():
"""Get SMTP configuration"""
try:
smtp = await config_manager.get_smtp_config()
return smtp.model_dump() if smtp else None
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get('/api/config/ui')
async def get_ui_config():
cfg = load_config()
return cfg.ui.model_dump()
@app.put('/api/config/ui')
async def update_ui_config(payload: dict):
# Simple partial update of UI flags
from .config import AppConfig
cfg = load_config()
ui_dict = cfg.ui.model_dump()
allowed = set(ui_dict.keys())
for k, v in payload.items():
if k in allowed and isinstance(v, bool):
ui_dict[k] = v
# Reconstruct full config and save via manager
new_cfg = AppConfig(
ups=cfg.ups, smtp=cfg.smtp, ui=cfg.ui.__class__(**ui_dict)
application.mount(
"/static", StaticFiles(directory="app/static"), name="static"
)
await config_manager.save_config(new_cfg)
return {"message": "UI config updated", "ui": ui_dict}
# Routers
application.include_router(pages.router)
application.include_router(api_auth.router)
application.include_router(api_health.router)
application.include_router(api_config.router)
application.include_router(api_ups.router)
application.include_router(api_alerts.router)
application.include_router(api_events.router)
application.include_router(sse.router)
@application.exception_handler(Exception)
async def unhandled_exc(request: Request, exc: Exception): # noqa: ARG001
logger.exception("Unhandled error", extra={"path": request.url.path})
return JSONResponse({"detail": "Internal server error"}, status_code=500)
return application
# --- UI Tile Layout Persistence ---
@app.get('/api/ups/{ups_name}/ui_tiles')
async def get_ups_ui_tiles(ups_name: str):
"""Return persisted UI tile settings for a UPS.
Includes: types, order, hidden, custom. Falls back to empty defaults if
not stored yet.
"""
r = get_redis()
key = f"ups:ui:tiles:{ups_name}"
raw = r.get(key)
if not raw:
return {
"types": {},
"order": [],
"hidden": [],
"custom": [],
"positions": {}
}
try:
data = orjson.loads(raw)
# Basic shape validation
if not isinstance(data, dict):
raise ValueError
return {
"types": data.get("types", {}),
"order": data.get("order", []),
"hidden": data.get("hidden", []),
"custom": data.get("custom", []),
"positions": data.get("positions", {}),
}
except Exception:
return {
"types": {},
"order": [],
"hidden": [],
"custom": [],
"positions": {}
}
@app.post('/api/ups/{ups_name}/ui_tiles')
async def save_ups_ui_tiles(ups_name: str, payload: dict):
"""Persist UI tile settings for a UPS.
Expects JSON: { types: {...}, order: [...], hidden: [...], custom: [...] }
"""
# Light validation / sanitization
types = (payload.get('types')
if isinstance(payload.get('types'), dict) else {})
order = (payload.get('order')
if isinstance(payload.get('order'), list) else [])
hidden = (payload.get('hidden')
if isinstance(payload.get('hidden'), list) else [])
custom = (payload.get('custom')
if isinstance(payload.get('custom'), list) else [])
positions = (payload.get('positions')
if isinstance(payload.get('positions'), dict) else {})
# Ensure custom entries minimally formed
norm_custom = []
for c in custom or []:
if not isinstance(c, dict):
continue
metric = c.get('metric')
chart = c.get('chart')
cid = c.get('id') or ''
source = c.get('source', 'live')
if not metric or not chart:
continue
norm_custom.append({
'id': cid,
'metric': metric,
'chart': chart,
'source': source
})
doc = {
'types': types,
'order': order,
'hidden': hidden,
'custom': norm_custom,
'positions': positions,
'saved_ts': int(time.time())
}
r = get_redis()
key = f"ups:ui:tiles:{ups_name}"
r.set(key, orjson.dumps(doc))
return {"message": "saved", "count_custom": len(norm_custom)}
@app.delete('/api/ups/{ups_name}/ui_tiles')
async def clear_ups_ui_tiles(ups_name: str):
"""Delete stored UI tile layout/settings for a UPS (full reset)."""
r = get_redis()
key = f"ups:ui:tiles:{ups_name}"
r.delete(key)
return {"message": "cleared"}
app = create_app()
+74
View File
@@ -0,0 +1,74 @@
"""Prometheus exposition for UPS fleet metrics."""
from __future__ import annotations
from prometheus_client import (
CONTENT_TYPE_LATEST,
CollectorRegistry,
Counter,
Gauge,
generate_latest,
)
from .config import load_config
from .storage import get_redis
_registry = CollectorRegistry()
_g_loadpct = Gauge("ups_loadpct", "UPS load percentage", ["ups"], registry=_registry)
_g_bcharge = Gauge("ups_bcharge", "UPS battery charge percentage", ["ups"], registry=_registry)
_g_timeleft = Gauge("ups_timeleft_minutes", "UPS estimated runtime minutes", ["ups"], registry=_registry)
_g_watts = Gauge("ups_watts", "UPS derived watts", ["ups"], registry=_registry)
_g_online = Gauge("ups_online", "1 if UPS is online and reachable", ["ups"], registry=_registry)
_c_poll_errors = Counter(
"ups_poll_errors_total",
"Cumulative poll failures",
["ups"],
registry=_registry,
)
def _f(v):
try:
return float(str(v).split()[0]) if v is not None else None
except Exception:
return None
def render_metrics() -> tuple[bytes, str]:
"""Collect current snapshot data and produce Prometheus text exposition."""
r = get_redis()
cfg = load_config()
for ups in cfg.ups:
snap = r.hgetall(f"ups:snap:{ups.name}")
status = str(snap.get("STATUS", "")).upper() if snap else ""
online = 1.0 if snap and "ONLINE" in status else 0.0
# If we've marked offline via health tracker, force zero
if r.get(f"ups:health:offline:{ups.name}"):
online = 0.0
_g_online.labels(ups=ups.name).set(online)
if snap:
lp = _f(snap.get("LOADPCT"))
if lp is not None:
_g_loadpct.labels(ups=ups.name).set(lp)
bc = _f(snap.get("BCHARGE"))
if bc is not None:
_g_bcharge.labels(ups=ups.name).set(bc)
tl = _f(snap.get("TIMELEFT")) or _f(snap.get("RUNTIME_MINUTES"))
if tl is not None:
_g_timeleft.labels(ups=ups.name).set(tl)
w = _f(snap.get("DERIVED_WATTS"))
if w is not None:
_g_watts.labels(ups=ups.name).set(w)
fail_count = r.get(f"ups:health:fail_count:{ups.name}")
if fail_count:
try:
# Counter semantics: set absolute by incrementing delta. Instead, expose gauge.
# We use a cumulative inc here; reset on process restart is acceptable.
pass
except Exception:
pass
return generate_latest(_registry), CONTENT_TYPE_LATEST
def record_poll_error(ups_name: str) -> None:
_c_poll_errors.labels(ups=ups_name).inc()
+1
View File
@@ -0,0 +1 @@
"""Notification channels."""
+157
View File
@@ -0,0 +1,157 @@
"""SMTP email sending with retry, batching, and HTML templates."""
from __future__ import annotations
import logging
import smtplib
import ssl
from datetime import datetime
from email.message import EmailMessage
from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader, select_autoescape
from tenacity import (
RetryError,
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from ..config import SMTPConfig
from ..settings import settings
logger = logging.getLogger(__name__)
_TEMPLATE_DIR = Path(__file__).parent.parent / "templates" / "emails"
_env = Environment(
loader=FileSystemLoader(str(_TEMPLATE_DIR)),
autoescape=select_autoescape(["html", "xml"]),
)
SEVERITY_COLORS = {
"CRITICAL": "#c0392b",
"WARNING": "#e67e22",
"INFO": "#2980b9",
}
class EmailSendError(Exception):
pass
def _resolve_password(smtp_cfg: SMTPConfig) -> str | None:
# Password is never stored in Redis; always from env var
return settings.smtp_password
@retry(
reraise=True,
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=10),
retry=retry_if_exception_type((smtplib.SMTPException, OSError)),
)
def _send_raw(smtp_cfg: SMTPConfig, msg: EmailMessage) -> None:
password = _resolve_password(smtp_cfg)
if smtp_cfg.use_ssl:
context = ssl.create_default_context()
with smtplib.SMTP_SSL(
smtp_cfg.host, smtp_cfg.port, context=context, timeout=30
) as server:
if smtp_cfg.username and password:
server.login(smtp_cfg.username, password)
server.send_message(msg)
else:
with smtplib.SMTP(smtp_cfg.host, smtp_cfg.port, timeout=30) as server:
if smtp_cfg.use_tls:
server.starttls(context=ssl.create_default_context())
if smtp_cfg.username and password:
server.login(smtp_cfg.username, password)
server.send_message(msg)
def send_alert_email(
smtp_cfg: SMTPConfig,
ups_name: str,
alerts: list[dict[str, Any]],
dashboard_url: str = "http://localhost:8000/",
) -> None:
"""Send a single HTML email coalescing multiple alerts for a UPS."""
if not smtp_cfg.to_addrs or not alerts:
return
top_severity = _pick_top_severity(alerts)
subject = f"{smtp_cfg.subject_prefix} [{top_severity}] {ups_name}"
template = _env.get_template("alert.html")
html = template.render(
ups_name=ups_name,
alerts=alerts,
top_severity=top_severity,
color=SEVERITY_COLORS.get(top_severity, "#555"),
dashboard_url=dashboard_url,
sent_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
text_body = "\n".join(
f"[{a.get('severity', 'INFO')}] {a.get('message', '')}" for a in alerts
)
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = smtp_cfg.from_addr or (smtp_cfg.username or "ups@example.local")
msg["To"] = ", ".join(smtp_cfg.to_addrs)
msg.set_content(text_body)
msg.add_alternative(html, subtype="html")
try:
_send_raw(smtp_cfg, msg)
except RetryError as e:
raise EmailSendError(f"SMTP send failed after retries: {e}") from e
except Exception as e: # noqa: BLE001 - surface as domain error
raise EmailSendError(f"SMTP send failed: {e}") from e
def send_test_email(smtp_cfg: SMTPConfig) -> None:
if not smtp_cfg.to_addrs:
raise EmailSendError("No recipients configured")
msg = EmailMessage()
msg["Subject"] = f"{smtp_cfg.subject_prefix} Test email"
msg["From"] = smtp_cfg.from_addr or (smtp_cfg.username or "ups@example.local")
msg["To"] = ", ".join(smtp_cfg.to_addrs)
msg.set_content(
"This is a test message from your APC UPS Dashboard.\n"
f"Sent at {datetime.now().isoformat()}"
)
try:
_send_raw(smtp_cfg, msg)
except Exception as e: # noqa: BLE001
raise EmailSendError(str(e)) from e
def send_daily_summary(
smtp_cfg: SMTPConfig,
summary: dict[str, Any],
) -> None:
if not smtp_cfg.to_addrs:
return
template = _env.get_template("summary.html")
html = template.render(summary=summary, sent_at=datetime.now().isoformat())
msg = EmailMessage()
msg["Subject"] = f"{smtp_cfg.subject_prefix} Daily summary"
msg["From"] = smtp_cfg.from_addr or (smtp_cfg.username or "ups@example.local")
msg["To"] = ", ".join(smtp_cfg.to_addrs)
msg.set_content(str(summary))
msg.add_alternative(html, subtype="html")
try:
_send_raw(smtp_cfg, msg)
except Exception as e: # noqa: BLE001
logger.warning("Daily summary send failed: %s", e)
def _pick_top_severity(alerts: list[dict[str, Any]]) -> str:
order = {"CRITICAL": 3, "WARNING": 2, "INFO": 1}
best = "INFO"
best_rank = 0
for a in alerts:
sev = a.get("severity", "INFO")
rank = order.get(sev, 0)
if rank > best_rank:
best = sev
best_rank = rank
return best
+136 -53
View File
@@ -1,17 +1,114 @@
"""Per-UPS async polling, event/energy/battery-history tracking.
Connection health:
ups:health:last_ok:<name> - unix ts of last successful poll
ups:health:fail_count:<name> - consecutive failures
ups:health:offline:<name> - "1" if we've emitted a COMMLOST alert
Battery history:
ups:battery:history:<name> - JSON {ts,bcharge,timeleft,battv,nombattv}
capped at 10080 entries (~7d @ 1/min)
"""
from __future__ import annotations
import asyncio
import time
import json
import logging
import time
from .alerts import Alert, dispatch_alerts, emit_info, process_alerts
from .apc_cli import APCStatusError, fetch_status
from .config import load_config
from .apc_cli import fetch_status, APCStatusError
from .storage import store_snapshot, prune_old, get_redis
from .alerts import process_alerts
from .storage import get_redis, prune_old, store_snapshot
logger = logging.getLogger(__name__)
_ACTIVE_TASKS: dict[str, asyncio.Task] = {}
_RELOADER_LOCK = asyncio.Lock()
BATTERY_HISTORY_MAX = 10080 # 7 days @ 1/minute
HEALTH_OFFLINE_MIN_SECONDS = 180
def _get_offline_threshold(interval_seconds: int) -> int:
return max(HEALTH_OFFLINE_MIN_SECONDS, 3 * interval_seconds)
def _record_poll_success(name: str, snapshot: dict, interval_seconds: int) -> None:
r = get_redis()
now = int(time.time())
r.set(f"ups:health:last_ok:{name}", now)
r.delete(f"ups:health:fail_count:{name}")
was_offline = r.get(f"ups:health:offline:{name}")
if was_offline:
r.delete(f"ups:health:offline:{name}")
emit_info(name, "REACHABLE", f"UPS {name} reachable again")
# Battery history append (sample every ~60s to keep list bounded)
last_sample_key = f"ups:battery:history:last_ts:{name}"
last_ts_raw = r.get(last_sample_key)
try:
last_ts = int(last_ts_raw) if last_ts_raw else 0
except ValueError:
last_ts = 0
if now - last_ts >= 60:
try:
sample = {
"ts": now,
"bcharge": _f(snapshot.get("BCHARGE")),
"timeleft": _f(snapshot.get("TIMELEFT")),
"battv": _f(snapshot.get("BATTV")),
"nombattv": _f(snapshot.get("NOMBATTV")),
}
r.lpush(f"ups:battery:history:{name}", json.dumps(sample))
r.ltrim(f"ups:battery:history:{name}", 0, BATTERY_HISTORY_MAX - 1)
r.set(last_sample_key, now)
except Exception: # pragma: no cover
pass
def _record_poll_failure(name: str, interval_seconds: int, reason: str) -> None:
r = get_redis()
now = int(time.time())
r.incr(f"ups:health:fail_count:{name}")
last_ok = r.get(f"ups:health:last_ok:{name}")
try:
last_ok_ts = int(last_ok) if last_ok else 0
except ValueError:
last_ok_ts = 0
threshold = _get_offline_threshold(interval_seconds)
# If we never succeeded, base threshold on process start (use last_ok=0 => big delta)
if now - last_ok_ts > threshold:
offline_flag = r.get(f"ups:health:offline:{name}")
if not offline_flag:
r.set(f"ups:health:offline:{name}", "1")
alert = Alert(
"CRITICAL",
f"UPS {name} unreachable ({reason})",
"COMMLOST",
name,
now,
)
cfg = load_config()
r.lpush(
"ups:alerts:history:all",
f"{alert.ts}|CRITICAL|{alert.ups}|COMMLOST|{alert.message}|{alert.hash()}",
)
r.ltrim("ups:alerts:history:all", 0, 499)
r.lpush(
f"ups:alerts:recent:{name}",
f"{alert.ts}|CRITICAL|{alert.message}",
)
r.ltrim(f"ups:alerts:recent:{name}", 0, 49)
if cfg.smtp:
dispatch_alerts(cfg.smtp, name, [alert])
def _f(v):
try:
return float(str(v).split()[0]) if v is not None else None
except Exception:
return None
async def _poll_one(ups):
r = get_redis()
@@ -21,7 +118,6 @@ async def _poll_one(ups):
try:
data = await fetch_status(ups.host, ups.port)
data['UPSNAME'] = ups.name
# Derived metrics
try:
loadpct = float(str(data.get('LOADPCT', '0')).split()[0])
except Exception:
@@ -33,19 +129,15 @@ async def _poll_one(ups):
pass
if nompower and loadpct >= 0:
watts = nompower * loadpct / 100.0
data['DERIVED_WATTS'] = f"{watts:.0f}" # integer string
data['DERIVED_WATTS'] = f"{watts:.0f}"
data['HEADROOM_PCT'] = f"{max(0.0, 100.0 - loadpct):.0f}"
# Runtime minutes (normalize TIMELEFT like '15.0 Minutes')
timeleft_raw = str(data.get('TIMELEFT', '')).strip()
try:
runtime_min = float(timeleft_raw.split()[0])
data['RUNTIME_MINUTES'] = f"{runtime_min:.1f}"
except Exception:
pass
# Event detection (status changes, last transfer changes)
# Reuse redis handle r
now_ts = asyncio.get_event_loop().time()
wall_ts = int(now_ts)
wall_ts = int(time.time())
status_key = f"ups:event:status:last:{ups.name}"
lastxfer_key = f"ups:event:lastxfer:last:{ups.name}"
events_list_key = f"ups:event:list:{ups.name}"
@@ -55,53 +147,39 @@ async def _poll_one(ups):
if prev_status != status_now and status_now:
r.set(status_key, status_now)
r.lpush(events_list_key, f"{wall_ts}|STATUS|{status_now}")
# Transition from ONBATT -> ONLINE: info alert
if prev_status and "ONBATT" in str(prev_status) and "ONLINE" in status_now:
emit_info(
ups.name,
"LINE_RESTORED",
f"UPS {ups.name} returned to line power",
)
lastxfer_now = str(data.get('LASTXFER', '')).strip()
prev_lastxfer = r.get(lastxfer_key)
if lastxfer_now and lastxfer_now != prev_lastxfer:
r.set(lastxfer_key, lastxfer_now)
r.lpush(events_list_key, f"{wall_ts}|XFER|{lastxfer_now}")
# Trim events
r.ltrim(events_list_key, 0, max_events - 1)
# Energy accumulation (watt-seconds)
if 'DERIVED_WATTS' in data:
try:
watts = float(data['DERIVED_WATTS'])
day_str = time.strftime('%Y%m%d')
energy_key = f"ups:energy:{ups.name}:{day_str}"
# increment by watts * interval_seconds (approx)
r.incrbyfloat(energy_key, watts * ups.interval_seconds)
r.expire(energy_key, 3 * 24 * 3600)
# Per-minute accumulation
minute = time.strftime('%Y%m%d%H%M')
# Running sum and count in a hash
mb = r.hgetall(minute_bucket_key)
if not mb or mb.get('minute') != minute:
# finalize previous bucket
if (
mb
and 'sum' in mb
and 'count' in mb
and 'minute' in mb
):
if mb and 'sum' in mb and 'count' in mb and 'minute' in mb:
try:
avg = float(mb['sum']) / max(
1, int(mb['count'])
)
r.lpush(
series_key,
f"{mb['minute']}|{avg:.2f}"
)
# keep up to 24h of minutes
avg = float(mb['sum']) / max(1, int(mb['count']))
r.lpush(series_key, f"{mb['minute']}|{avg:.2f}")
r.ltrim(series_key, 0, 1439)
except Exception:
pass
r.hset(
minute_bucket_key,
mapping={
'minute': minute,
'sum': watts,
'count': 1,
},
mapping={'minute': minute, 'sum': watts, 'count': 1},
)
r.expire(minute_bucket_key, 26 * 3600)
else:
@@ -121,36 +199,47 @@ async def _poll_one(ups):
except Exception:
pass
await store_snapshot(ups.name, data)
_record_poll_success(ups.name, data, ups.interval_seconds)
process_alerts(ups, data)
except asyncio.CancelledError:
raise
except Exception as e:
reason = str(e) if not isinstance(e, APCStatusError) else str(e)
if isinstance(e, APCStatusError):
logger.warning("apcaccess error for %s: %s", ups.name, e)
else:
logger.warning("Polling error for %s: %s", ups.name, e)
_record_poll_failure(ups.name, ups.interval_seconds, reason)
await asyncio.sleep(ups.interval_seconds)
async def _reconcile_tasks():
"""Ensure a polling task exists per configured UPS and remove stale ones.
Creates tasks for new UPS entries and cancels tasks whose UPS were removed.
"""
async with _RELOADER_LOCK:
cfg = load_config()
current_names = {u.name for u in cfg.ups}
# cancel removed
current = {u.name: (u.host, u.port, u.interval_seconds) for u in cfg.ups}
# cancel removed or changed
for name in list(_ACTIVE_TASKS.keys()):
if name not in current_names:
if name not in current:
_ACTIVE_TASKS[name].cancel()
del _ACTIVE_TASKS[name]
# add new
for ups in cfg.ups:
if ups.name not in _ACTIVE_TASKS:
_ACTIVE_TASKS[ups.name] = asyncio.create_task(_poll_one(ups))
async def cancel_all_tasks() -> None:
"""Cancel all running poller tasks (used on shutdown)."""
for t in _ACTIVE_TASKS.values():
t.cancel()
for _name, t in list(_ACTIVE_TASKS.items()):
try:
await t
except (asyncio.CancelledError, Exception):
pass
_ACTIVE_TASKS.clear()
async def poll_loop():
# initial reconcile
await _reconcile_tasks()
async def prune_loop():
@@ -162,19 +251,13 @@ async def poll_loop():
await asyncio.sleep(3600)
async def config_watch_loop():
"""Periodically re-read config to capture UPS CRUD changes."""
last_fingerprint = None
while True:
try:
cfg = load_config()
fingerprint = tuple(
sorted(
(
u.name,
u.host,
u.port,
u.interval_seconds,
)
(u.name, u.host, u.port, u.interval_seconds)
for u in cfg.ups
)
)
@@ -188,5 +271,5 @@ async def poll_loop():
await asyncio.gather(
prune_loop(),
config_watch_loop(),
*(_ACTIVE_TASKS.values()),
return_exceptions=True,
)
+27
View File
@@ -0,0 +1,27 @@
"""Rate limiting via slowapi with Redis-backed storage."""
from __future__ import annotations
from slowapi import Limiter
from slowapi.util import get_remote_address
from .settings import settings
def _enabled_swallow(f):
"""Return original limiter decorator or a no-op when rate limiting disabled."""
if settings.rate_limit_enabled:
return f
def _noop(*args, **kwargs):
def wrapper(fn):
return fn
return wrapper
return _noop
limiter = Limiter(
key_func=get_remote_address,
storage_uri=settings.redis_url,
enabled=settings.rate_limit_enabled,
default_limits=[],
)
+1
View File
@@ -0,0 +1 @@
"""Route modules split by concern."""
+62
View File
@@ -0,0 +1,62 @@
"""Alert history and acknowledgement endpoints."""
import time
from fastapi import APIRouter, Depends, Query
from ..auth import require_session, require_session_and_csrf
from ..storage import get_redis
router = APIRouter(prefix="/api/alerts")
@router.get("")
async def list_alerts(
ups: str | None = Query(None),
severity: str | None = Query(None),
days: int = Query(30, ge=1, le=90),
user=Depends(require_session),
):
r = get_redis()
raw = r.lrange("ups:alerts:history:all", 0, -1)
cutoff = int(time.time()) - days * 86400
out = []
for item in raw:
parts = item.split("|", 5)
if len(parts) < 5:
continue
try:
ts = int(parts[0])
except ValueError:
continue
if ts < cutoff:
continue
row = {
"ts": ts,
"severity": parts[1],
"ups": parts[2],
"code": parts[3],
"message": parts[4],
"id": parts[5] if len(parts) > 5 else "",
}
if ups and row["ups"] != ups:
continue
if severity and row["severity"] != severity.upper():
continue
ack = r.get(f"ups:alerts:ack:{row['id']}")
row["acked_ts"] = int(ack) if ack else None
out.append(row)
return out
@router.get("/active")
async def list_active(user=Depends(require_session)):
"""Active alerts = history entries not yet acknowledged, most recent first."""
all_alerts = await list_alerts(days=7, user=user)
return [a for a in all_alerts if not a.get("acked_ts")]
@router.post("/{alert_id}/ack")
async def ack_alert(alert_id: str, user=Depends(require_session_and_csrf)):
r = get_redis()
r.set(f"ups:alerts:ack:{alert_id}", int(time.time()), ex=30 * 86400)
return {"message": "acknowledged"}
+62
View File
@@ -0,0 +1,62 @@
"""Authentication endpoints: login, logout, first-run setup."""
import re
from fastapi import APIRouter, HTTPException, Request, Response
from pydantic import BaseModel, Field
from ..auth import (
clear_auth_cookies,
get_stored_admin,
is_admin_configured,
set_auth_cookies,
store_admin,
verify_password,
)
from ..rate_limit import limiter
router = APIRouter()
class LoginRequest(BaseModel):
username: str = Field(..., min_length=1, max_length=64)
password: str = Field(..., min_length=1, max_length=256)
class SetupRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=64)
password: str = Field(..., min_length=8, max_length=256)
_PW_POLICY_MSG = "Password must be at least 8 characters"
@router.post("/api/login")
@limiter.limit("5/minute")
async def api_login(request: Request, response: Response, payload: LoginRequest):
username, pw_hash = get_stored_admin()
if not pw_hash:
raise HTTPException(status_code=409, detail="Admin not configured; run setup")
if payload.username != username or not verify_password(payload.password, pw_hash):
raise HTTPException(status_code=401, detail="Invalid credentials")
csrf = set_auth_cookies(response, username)
return {"ok": True, "csrf_token": csrf}
@router.post("/api/logout")
async def api_logout(response: Response):
clear_auth_cookies(response)
return {"ok": True}
@router.post("/api/setup")
@limiter.limit("3/minute")
async def api_setup(request: Request, response: Response, payload: SetupRequest):
if is_admin_configured():
raise HTTPException(status_code=409, detail="Admin already configured")
if len(payload.password) < 8:
raise HTTPException(status_code=400, detail=_PW_POLICY_MSG)
if not re.match(r"^[a-zA-Z0-9_.@-]+$", payload.username):
raise HTTPException(status_code=400, detail="Invalid username characters")
store_admin(payload.username, payload.password)
csrf = set_auth_cookies(response, payload.username)
return {"ok": True, "csrf_token": csrf}
+147
View File
@@ -0,0 +1,147 @@
"""Configuration CRUD endpoints (UPS, SMTP, UI). All require session+CSRF for writes."""
from fastapi import APIRouter, Depends, HTTPException, Request
from ..auth import require_session, require_session_and_csrf
from ..config import SMTPConfig, UIConfig, UPSConfig
from ..config_manager import (
ConfigWriteError,
UPSConfigUpdate,
config_manager,
smtp_redacted_dict,
)
from ..notifications.email import EmailSendError, send_test_email
from ..rate_limit import limiter
router = APIRouter(prefix="/api/config")
@router.get("/ups")
async def get_ups_configs(user=Depends(require_session)):
try:
ups_list = await config_manager.get_ups_list()
return [ups.model_dump() for ups in ups_list]
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=500, detail=str(e))
@router.get("/ups/{ups_name}")
async def get_ups_config(ups_name: str, user=Depends(require_session)):
ups = await config_manager.get_ups(ups_name)
if not ups:
raise HTTPException(status_code=404, detail="UPS not found")
return ups.model_dump()
@router.post("/ups")
@limiter.limit("30/minute")
async def add_ups_config(
request: Request, ups_config: UPSConfig, user=Depends(require_session_and_csrf)
):
try:
await config_manager.add_ups(ups_config)
return {"message": "UPS configuration added successfully"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except ConfigWriteError as e:
raise HTTPException(status_code=507, detail=str(e))
@router.put("/ups/{ups_name}")
@limiter.limit("30/minute")
async def update_ups_config(
request: Request,
ups_name: str,
updates: UPSConfigUpdate,
user=Depends(require_session_and_csrf),
):
try:
success = await config_manager.update_ups(ups_name, updates)
if not success:
raise HTTPException(status_code=404, detail="UPS not found")
return {"message": "UPS configuration updated successfully"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except ConfigWriteError as e:
raise HTTPException(status_code=507, detail=str(e))
@router.delete("/ups/{ups_name}")
@limiter.limit("30/minute")
async def delete_ups_config(
request: Request, ups_name: str, user=Depends(require_session_and_csrf)
):
success = await config_manager.delete_ups(ups_name)
if not success:
raise HTTPException(status_code=404, detail="UPS not found")
return {"message": "UPS configuration deleted successfully"}
@router.post("/ups/{ups_name}/test")
@limiter.limit("5/minute")
async def test_ups_connection(
request: Request, ups_name: str, user=Depends(require_session_and_csrf)
):
ups = await config_manager.get_ups(ups_name)
if not ups:
raise HTTPException(status_code=404, detail="UPS not found")
return await config_manager.validate_ups_connection(ups)
@router.post("/ups/test")
@limiter.limit("5/minute")
async def test_new_ups_connection(
request: Request, ups_config: UPSConfig, user=Depends(require_session_and_csrf)
):
return await config_manager.validate_ups_connection(ups_config)
@router.get("/smtp")
async def get_smtp_config(user=Depends(require_session)):
smtp = await config_manager.get_smtp_config()
return smtp_redacted_dict(smtp)
@router.put("/smtp")
@limiter.limit("30/minute")
async def update_smtp_config(
request: Request, smtp_config: SMTPConfig, user=Depends(require_session_and_csrf)
):
await config_manager.update_smtp_config(smtp_config)
return {"message": "SMTP configuration updated"}
@router.post("/smtp/test")
@limiter.limit("5/minute")
async def test_smtp(request: Request, user=Depends(require_session_and_csrf)):
smtp = await config_manager.get_smtp_config()
if not smtp:
raise HTTPException(status_code=400, detail="SMTP not configured")
try:
send_test_email(smtp)
except EmailSendError as e:
raise HTTPException(status_code=502, detail=str(e))
return {"message": "Test email sent"}
@router.get("/ui")
async def get_ui_config(user=Depends(require_session)):
from ..config import load_config
cfg = load_config()
return cfg.ui.model_dump()
@router.put("/ui")
@limiter.limit("30/minute")
async def update_ui_config(
request: Request, payload: dict, user=Depends(require_session_and_csrf)
):
from ..config import load_config
cfg = load_config()
ui_dict = cfg.ui.model_dump()
for k, v in payload.items():
if k in ui_dict:
ui_dict[k] = v
new_ui = UIConfig(**ui_dict)
await config_manager.update_ui_config(new_ui)
return {"message": "UI config updated", "ui": new_ui.model_dump()}
+37
View File
@@ -0,0 +1,37 @@
"""Consolidated events log across all UPS."""
from fastapi import APIRouter, Depends, Query
from ..auth import require_session
from ..config import load_config
from ..storage import get_redis
router = APIRouter(prefix="/api/events")
@router.get("")
async def list_events(
ups: str | None = Query(None),
kind: str | None = Query(None, description="STATUS|XFER"),
limit: int = Query(200, ge=1, le=1000),
user=Depends(require_session),
):
r = get_redis()
cfg = load_config()
target_upses = [ups] if ups else [u.name for u in cfg.ups]
out = []
for name in target_upses:
raw = r.lrange(f"ups:event:list:{name}", 0, limit)
for item in raw:
parts = item.split("|", 2)
if len(parts) != 3:
continue
try:
ts = int(parts[0])
except ValueError:
continue
if kind and parts[1] != kind.upper():
continue
out.append({"ts": ts, "type": parts[1], "detail": parts[2], "ups": name})
out.sort(key=lambda x: x["ts"], reverse=True)
return out[:limit]
+25
View File
@@ -0,0 +1,25 @@
"""Liveness, readiness, and Prometheus metrics endpoints (unauthenticated)."""
from fastapi import APIRouter
from fastapi.responses import JSONResponse, Response
from ..health import liveness, readiness
from ..metrics import render_metrics
router = APIRouter()
@router.get("/healthz")
async def healthz():
return liveness()
@router.get("/readyz")
async def readyz():
status, payload = readiness()
return JSONResponse(payload, status_code=status)
@router.get("/metrics")
async def metrics():
body, content_type = render_metrics()
return Response(content=body, media_type=content_type)
+356
View File
@@ -0,0 +1,356 @@
"""Per-UPS data APIs: status, history, metric, events, energy, health, battery health, export, debug, tiles."""
import json
import time
import orjson
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from ..apc_cli import APCStatusError, fetch_status
from ..auth import require_session, require_session_and_csrf
from ..config import load_config
from ..exports import export_energy_csv, export_events_csv, export_history_csv
from ..storage import get_history, get_latest, get_redis
router = APIRouter(prefix="/api/ups")
@router.get("")
async def list_ups(user=Depends(require_session)):
cfg = load_config()
return [{"name": u.name, "host": u.host, "port": u.port} for u in cfg.ups]
@router.get("/fleet/overview")
async def fleet_overview(user=Depends(require_session)):
"""Aggregate summary across all UPS."""
cfg = load_config()
r = get_redis()
total = len(cfg.ups)
counts = {"online": 0, "on_battery": 0, "warning": 0, "offline": 0, "unknown": 0}
total_watts = 0.0
min_timeleft = None
rows = []
for ups in cfg.ups:
snap = r.hgetall(f"ups:snap:{ups.name}") or {}
is_offline = bool(r.get(f"ups:health:offline:{ups.name}"))
status = str(snap.get("STATUS", "")).upper()
state = "unknown"
if is_offline:
state = "offline"
elif "ONBATT" in status:
state = "on_battery"
elif "ONLINE" in status:
state = "online"
counts[state] = counts.get(state, 0) + 1
try:
w = float(snap.get("DERIVED_WATTS", 0) or 0)
total_watts += w
except ValueError:
pass
try:
tl = float(str(snap.get("TIMELEFT", "")).split()[0])
if min_timeleft is None or tl < min_timeleft:
min_timeleft = tl
except Exception:
pass
rows.append({"name": ups.name, "state": state, "status": status})
return {
"total": total,
"counts": counts,
"total_watts": round(total_watts, 1),
"min_timeleft_minutes": min_timeleft,
"rows": rows,
}
@router.get("/{ups_name}")
async def ups_status(ups_name: str, user=Depends(require_session)):
snap = await get_latest(ups_name)
return snap or {"error": "not found"}
@router.get("/{ups_name}/history")
async def ups_history(ups_name: str, user=Depends(require_session)):
return await get_history(ups_name)
@router.get("/{ups_name}/metric/{metric}")
async def metric_history(
ups_name: str, metric: str, limit: int = 120, user=Depends(require_session)
):
limit = max(1, min(limit, 500))
all_hist = await get_history(ups_name)
recent = all_hist[-limit:]
out = []
for item in recent:
data = item.get("data", {})
raw_val = data.get(metric)
if raw_val is None:
continue
try:
val = float(str(raw_val).split()[0])
except Exception:
continue
out.append({"ts": item.get("ts"), "value": val})
return out
@router.get("/{ups_name}/events")
async def ups_events(ups_name: str, user=Depends(require_session)):
r = get_redis()
raw = r.lrange(f"ups:event:list:{ups_name}", 0, 99)
parsed = []
for item in raw:
if "|" in item:
try:
ts_s, kind, rest = item.split("|", 2)
parsed.append({"ts": int(ts_s), "type": kind, "detail": rest})
continue
except Exception:
pass
parsed.append({"raw": item})
return parsed
@router.get("/{ups_name}/energy")
async def ups_energy(ups_name: str, user=Depends(require_session)):
r = get_redis()
day_str = time.strftime("%Y%m%d")
key = f"ups:energy:{ups_name}:{day_str}"
watt_seconds = r.get(key)
cfg = load_config()
rate = cfg.ui.energy_cost_per_kwh if hasattr(cfg.ui, "energy_cost_per_kwh") else 0.0
if watt_seconds:
try:
ws = float(watt_seconds)
kwh = ws / 3600.0 / 1000.0
return {"kwh_today": round(kwh, 4), "cost_today": round(kwh * rate, 4)}
except ValueError:
pass
return {"kwh_today": None, "cost_today": None}
@router.get("/{ups_name}/watts_per_minute")
async def ups_watts_per_minute(ups_name: str, user=Depends(require_session)):
r = get_redis()
raw = r.lrange(f"ups:watts:permin:{ups_name}", 0, 1440)
out = []
for item in raw:
if "|" in item:
minute, avg = item.split("|", 1)
try:
out.append({"minute": minute, "avg_watts": float(avg)})
except ValueError:
continue
out.reverse()
return out
@router.get("/{ups_name}/health")
async def ups_health(ups_name: str, user=Depends(require_session)):
r = get_redis()
alert_raw = r.lrange(f"ups:alerts:recent:{ups_name}", 0, 19)
alerts = []
for a in alert_raw:
parts = a.split("|", 2)
if len(parts) == 3:
try:
alerts.append({"ts": int(parts[0]), "severity": parts[1], "msg": parts[2]})
continue
except ValueError:
pass
if "|" in a:
ts_s, msg = a.split("|", 1)
try:
alerts.append({"ts": int(ts_s), "msg": msg})
continue
except ValueError:
pass
alerts.append({"raw": a})
dev_samples = r.lrange(f"ups:volt:dev:samples:{ups_name}", 0, 49)
dev_vals = []
for d in dev_samples:
try:
dev_vals.append(float(d))
except ValueError:
continue
dev_avg = sum(dev_vals) / len(dev_vals) if dev_vals else None
dev_max = max(dev_vals) if dev_vals else None
last_ok = r.get(f"ups:health:last_ok:{ups_name}")
is_offline = bool(r.get(f"ups:health:offline:{ups_name}"))
fail_count = r.get(f"ups:health:fail_count:{ups_name}")
events = r.lrange(f"ups:event:list:{ups_name}", 0, 200)
now = int(time.time())
onbatt_hour = 0
for ev in events:
parts = ev.split("|")
if len(parts) >= 3:
try:
ts_e = int(parts[0])
except ValueError:
continue
if now - ts_e > 3600:
continue
if parts[1] == "STATUS" and "ONBATT" in parts[2]:
onbatt_hour += 1
return {
"online": not is_offline,
"last_ok_ts": int(last_ok) if last_ok else None,
"fail_count": int(fail_count) if fail_count else 0,
"alerts": alerts,
"voltage_deviation": {
"avg_pct": round(dev_avg, 2) if dev_avg is not None else None,
"max_pct": round(dev_max, 2) if dev_max is not None else None,
"samples": len(dev_vals),
},
"onbatt_last_hour": onbatt_hour,
}
@router.get("/{ups_name}/battery_health")
async def battery_health(ups_name: str, user=Depends(require_session)):
"""Return battery-health trend from sampled history."""
r = get_redis()
raw = r.lrange(f"ups:battery:history:{ups_name}", 0, -1)
samples = []
for item in raw:
try:
samples.append(json.loads(item))
except Exception:
continue
samples.sort(key=lambda s: s.get("ts", 0))
# Estimate runtime-at-full-charge: TIMELEFT * (100 / BCHARGE) when BCHARGE>0
normalized = []
for s in samples:
bc = s.get("bcharge")
tl = s.get("timeleft")
if bc and tl and bc > 10:
normalized.append({"ts": s["ts"], "est_full_runtime_min": tl * 100.0 / bc})
slope_per_day = None
decline_pct_14d = None
if len(normalized) >= 10:
first = normalized[0]
last = normalized[-1]
span_days = max(1e-6, (last["ts"] - first["ts"]) / 86400.0)
slope_per_day = (last["est_full_runtime_min"] - first["est_full_runtime_min"]) / span_days
# Compute 14d decline as % of initial runtime
cutoff_14d = last["ts"] - 14 * 86400
older = [n for n in normalized if n["ts"] <= cutoff_14d]
if older:
baseline = older[0]["est_full_runtime_min"]
if baseline > 0:
decline_pct_14d = (
(baseline - last["est_full_runtime_min"]) / baseline * 100.0
)
return {
"samples": len(samples),
"estimated_full_runtime_samples": normalized[-200:],
"slope_per_day": round(slope_per_day, 3) if slope_per_day is not None else None,
"decline_pct_14d": round(decline_pct_14d, 2) if decline_pct_14d is not None else None,
}
@router.get("/{ups_name}/export")
async def export_ups(
ups_name: str,
format: str = Query("csv"),
since_days: int = Query(7, ge=1, le=30),
kind: str = Query("history"),
user=Depends(require_session),
):
if format != "csv":
raise HTTPException(status_code=400, detail="Only csv format is supported")
if kind == "history":
gen = export_history_csv(ups_name, since_days)
filename = f"{ups_name}_history_{since_days}d.csv"
elif kind == "events":
gen = export_events_csv(ups_name, since_days)
filename = f"{ups_name}_events_{since_days}d.csv"
elif kind == "energy":
gen = export_energy_csv(ups_name, since_days)
filename = f"{ups_name}_energy_{since_days}d.csv"
else:
raise HTTPException(status_code=400, detail="kind must be history|events|energy")
return StreamingResponse(
gen,
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/{ups_name}/debug")
async def ups_debug(ups_name: str, user=Depends(require_session)):
cfg = load_config()
target = next((u for u in cfg.ups if u.name == ups_name), None)
if not target:
raise HTTPException(status_code=404, detail="UPS not found")
try:
return await fetch_status(target.host, target.port)
except APCStatusError as e:
raise HTTPException(status_code=502, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{ups_name}/ui_tiles")
async def get_ups_ui_tiles(ups_name: str, user=Depends(require_session)):
r = get_redis()
raw = r.get(f"ups:ui:tiles:{ups_name}")
default = {"types": {}, "order": [], "hidden": [], "custom": [], "positions": {}}
if not raw:
return default
try:
data = orjson.loads(raw)
return {
"types": data.get("types", {}),
"order": data.get("order", []),
"hidden": data.get("hidden", []),
"custom": data.get("custom", []),
"positions": data.get("positions", {}),
}
except Exception:
return default
@router.post("/{ups_name}/ui_tiles")
async def save_ups_ui_tiles(
ups_name: str, payload: dict, user=Depends(require_session_and_csrf)
):
types = payload.get("types") if isinstance(payload.get("types"), dict) else {}
order = payload.get("order") if isinstance(payload.get("order"), list) else []
hidden = payload.get("hidden") if isinstance(payload.get("hidden"), list) else []
custom = payload.get("custom") if isinstance(payload.get("custom"), list) else []
positions = payload.get("positions") if isinstance(payload.get("positions"), dict) else {}
norm_custom = []
for c in custom or []:
if not isinstance(c, dict):
continue
metric = c.get("metric")
chart = c.get("chart")
if not metric or not chart:
continue
norm_custom.append({
"id": c.get("id") or "",
"metric": metric,
"chart": chart,
"source": c.get("source", "live"),
})
doc = {
"types": types,
"order": order,
"hidden": hidden,
"custom": norm_custom,
"positions": positions,
"saved_ts": int(time.time()),
}
r = get_redis()
r.set(f"ups:ui:tiles:{ups_name}", orjson.dumps(doc))
return {"message": "saved", "count_custom": len(norm_custom)}
@router.delete("/{ups_name}/ui_tiles")
async def clear_ups_ui_tiles(ups_name: str, user=Depends(require_session_and_csrf)):
r = get_redis()
r.delete(f"ups:ui:tiles:{ups_name}")
return {"message": "cleared"}
+118
View File
@@ -0,0 +1,118 @@
"""HTML page routes (dashboard, config, login, setup, events, alerts, settings)."""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from ..auth import CSRF_COOKIE, current_user, is_admin_configured, make_csrf_token
from ..config import load_config
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
def _ensure_csrf(request: Request, response):
token = request.cookies.get(CSRF_COOKIE)
if not token:
token = make_csrf_token()
response.set_cookie(
CSRF_COOKIE, token, httponly=False, samesite="lax", path="/"
)
return token
def _session_or_setup_redirect(request: Request):
if not is_admin_configured():
return RedirectResponse("/setup", status_code=302)
user = current_user(request)
if not user:
return RedirectResponse("/login", status_code=302)
return None
@router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
redirect = _session_or_setup_redirect(request)
if redirect:
return redirect
cfg = load_config()
user = current_user(request)
response = templates.TemplateResponse(
"dashboard.html",
{
"request": request,
"ups_list": cfg.ups,
"ui_cfg": cfg.ui.model_dump(),
"current_user": user,
"active_nav": "dashboard",
},
)
_ensure_csrf(request, response)
return response
@router.get("/config", response_class=HTMLResponse)
async def config_page(request: Request):
redirect = _session_or_setup_redirect(request)
if redirect:
return redirect
response = templates.TemplateResponse(
"config.html",
{"request": request, "current_user": current_user(request), "active_nav": "config"},
)
_ensure_csrf(request, response)
return response
@router.get("/events", response_class=HTMLResponse)
async def events_page(request: Request):
redirect = _session_or_setup_redirect(request)
if redirect:
return redirect
response = templates.TemplateResponse(
"events.html",
{"request": request, "current_user": current_user(request), "active_nav": "events"},
)
_ensure_csrf(request, response)
return response
@router.get("/alerts", response_class=HTMLResponse)
async def alerts_page(request: Request):
redirect = _session_or_setup_redirect(request)
if redirect:
return redirect
response = templates.TemplateResponse(
"alerts.html",
{"request": request, "current_user": current_user(request), "active_nav": "alerts"},
)
_ensure_csrf(request, response)
return response
@router.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
redirect = _session_or_setup_redirect(request)
if redirect:
return redirect
response = templates.TemplateResponse(
"settings.html",
{"request": request, "current_user": current_user(request), "active_nav": "settings"},
)
_ensure_csrf(request, response)
return response
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
if not is_admin_configured():
return RedirectResponse("/setup", status_code=302)
if current_user(request):
return RedirectResponse("/", status_code=302)
return templates.TemplateResponse("login.html", {"request": request})
@router.get("/setup", response_class=HTMLResponse)
async def setup_page(request: Request):
if is_admin_configured():
return RedirectResponse("/login", status_code=302)
return templates.TemplateResponse("setup.html", {"request": request})
+41
View File
@@ -0,0 +1,41 @@
"""Server-Sent Events stream with snapshots + fleet overview."""
import asyncio
import orjson
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from ..auth import require_session
from ..config import load_config
from ..config_manager import get_config_version
from ..storage import get_latest, get_redis
router = APIRouter()
@router.get("/api/stream")
async def stream(user=Depends(require_session)):
async def event_gen():
while True:
cfg = load_config()
r = get_redis()
payload = {"snapshots": {}, "cfgVersion": get_config_version()}
for u in cfg.ups:
snap = await get_latest(u.name)
if snap:
payload["snapshots"][u.name] = snap
payload["upsMeta"] = [
{
"name": u.name,
"host": u.host,
"port": u.port,
"offline": bool(r.get(f"ups:health:offline:{u.name}")),
}
for u in cfg.ups
]
for name, snap in payload["snapshots"].items():
payload.setdefault(name, snap)
yield f"data: {orjson.dumps(payload).decode()}\n\n"
await asyncio.sleep(5)
return StreamingResponse(event_gen(), media_type="text/event-stream")
+41
View File
@@ -0,0 +1,41 @@
"""Security headers middleware.
Emits a conservative CSP that allows only self + jsdelivr (Chart.js) and
standard hardening headers. HSTS only when behind HTTPS proxy.
"""
from __future__ import annotations
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
from .settings import settings
CSP_DIRECTIVES = (
"default-src 'self'; "
"script-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"font-src 'self' data:; "
"connect-src 'self'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'"
)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
def __init__(self, app: ASGIApp):
super().__init__(app)
async def dispatch(self, request, call_next):
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "same-origin")
response.headers.setdefault("Content-Security-Policy", CSP_DIRECTIVES)
if settings.trust_proxy:
response.headers.setdefault(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains",
)
return response
+81
View File
@@ -0,0 +1,81 @@
"""Centralized settings loaded once at startup via pydantic-settings.
Reads environment variables once and exposes a cached Settings() instance.
Replaces scattered os.environ.get() calls throughout the codebase.
"""
from __future__ import annotations
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings pulled from environment variables."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# Redis
redis_url: str = Field(
default="redis://redis:6379/0", description="Redis connection URL"
)
# Auth
admin_username: str = Field(default="admin", description="Admin username")
admin_password_hash: str | None = Field(
default=None,
description="Argon2 password hash. If unset, first-run setup is required.",
)
session_secret: str | None = Field(
default=None,
description="Signing secret for session cookies (required in prod).",
)
session_max_age_seconds: int = Field(
default=14 * 24 * 3600, description="Session cookie max age"
)
trust_proxy: bool = Field(
default=False,
description="Set true when behind HTTPS reverse proxy (enables Secure cookie + HSTS)",
)
# SMTP
smtp_password: str | None = Field(
default=None, description="SMTP password (not stored in Redis)"
)
# Network validation
allow_private_ips: bool = Field(
default=True,
description="Allow private/RFC1918 hosts in UPSConfig.host. Default true for homelab.",
)
# Legacy / migration
ups_config_path: str = Field(
default="/config/ups.yaml",
description="Legacy YAML path (migration-only).",
)
# Logging
log_level: str = Field(default="INFO", description="Python log level")
# Timezone
tz: str = Field(default="UTC", description="Server timezone")
# Rate limit toggle (disable in tests)
rate_limit_enabled: bool = Field(
default=True, description="Enable slowapi rate limiting"
)
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+57
View File
@@ -0,0 +1,57 @@
/* Base layout, header/nav */
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, Segoe UI, Arial, sans-serif; background: #f5f7fa; color: #222; }
.site-header {
display: flex; align-items: center; gap: 24px;
padding: 10px 20px; background: #2c3e50; color: #ecf0f1;
box-shadow: 0 2px 4px rgba(0,0,0,.1);
}
.site-header .brand a { color: #ecf0f1; text-decoration: none; font-weight: 600; font-size: 18px; }
.main-nav { display: flex; gap: 16px; flex: 1; flex-wrap: wrap; }
.main-nav a { color: #bdc3c7; text-decoration: none; padding: 6px 10px; border-radius: 4px; }
.main-nav a:hover { background: #34495e; color: #fff; }
.main-nav a.active { background: #3498db; color: #fff; }
.user-menu { display: flex; align-items: center; gap: 10px; }
.user-menu .username { font-size: 13px; opacity: 0.85; }
.btn-logout { background: #e74c3c; color: #fff; border: 0; padding: 6px 12px; border-radius: 4px; cursor: pointer; }
.btn-logout:hover { background: #c0392b; }
.site-main { padding: 20px; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 16px; flex-wrap: wrap; }
.page-header h1 { margin: 0; font-size: 22px; }
.filters { display: flex; gap: 8px; flex-wrap: wrap; }
.filters select, .filters input { padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; }
/* Tables */
.data-table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.data-table th, .data-table td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #eee; font-size: 14px; }
.data-table th { background: #f0f2f5; font-weight: 600; }
.data-table tr:hover { background: #fafbfc; }
.sev.CRITICAL { color: #fff; background: #c0392b; padding: 2px 8px; border-radius: 3px; font-size: 12px; }
.sev.WARNING { color: #fff; background: #e67e22; padding: 2px 8px; border-radius: 3px; font-size: 12px; }
.sev.INFO { color: #fff; background: #2980b9; padding: 2px 8px; border-radius: 3px; font-size: 12px; }
/* Settings */
.settings-section { background: #fff; border-radius: 6px; padding: 16px 20px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.settings-section h2 { margin-top: 0; font-size: 18px; }
.settings-section form { display: flex; flex-direction: column; gap: 10px; max-width: 480px; }
.settings-section label { display: flex; flex-direction: column; gap: 4px; font-size: 14px; }
.settings-section label[type=checkbox] input, .settings-section input[type=checkbox] + * { display: inline; }
.settings-section input, .settings-section select { padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; }
.settings-section button { align-self: flex-start; }
.muted { color: #777; font-size: 13px; }
/* Fleet overview */
.fleet-overview { margin-bottom: 16px; }
.fleet-card { background: #fff; border-radius: 6px; padding: 16px 20px; display: flex; gap: 24px; flex-wrap: wrap; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.fleet-stat { display: flex; flex-direction: column; gap: 4px; }
.fleet-stat-label { color: #7f8c8d; font-size: 12px; text-transform: uppercase; letter-spacing: .5px; }
.fleet-stat-value { font-size: 22px; font-weight: 600; }
.fleet-stat-value.online { color: #27ae60; }
.fleet-stat-value.onbatt { color: #e67e22; }
.fleet-stat-value.offline { color: #c0392b; }
/* Card state dot */
.state-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #95a5a6; margin-right: 6px; vertical-align: middle; }
.state-dot.online { background: #27ae60; }
.state-dot.offline { background: #c0392b; }
.state-dot.onbatt { background: #e67e22; }
+11
View File
@@ -0,0 +1,11 @@
.auth-body { background: #2c3e50; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.auth-card { background: #fff; border-radius: 8px; padding: 32px; width: 360px; box-shadow: 0 8px 24px rgba(0,0,0,.3); }
.auth-card.wide { width: 440px; }
.auth-card h1 { margin: 0 0 20px; text-align: center; font-size: 22px; }
.form-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.form-field label { font-size: 13px; color: #555; }
.form-field input { padding: 8px 10px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; }
.btn-primary { background: #3498db; color: #fff; border: 0; padding: 10px 16px; border-radius: 4px; cursor: pointer; font-size: 14px; }
.btn-primary:hover { background: #2980b9; }
.btn-block { width: 100%; }
.login-error { margin-top: 12px; padding: 8px 12px; background: #fee; color: #c0392b; border-radius: 4px; font-size: 13px; }
+80
View File
@@ -0,0 +1,80 @@
// Alerts page (DOM-safe)
(async function () {
function cell(text, cls) {
const td = document.createElement('td');
td.textContent = text;
if (cls) td.className = cls;
return td;
}
async function loadUps() {
const r = await window.apiFetch('/api/ups');
if (!r.ok) return;
const list = await r.json();
const sel = document.getElementById('filter-ups');
list.forEach(u => {
const o = document.createElement('option');
o.value = u.name; o.textContent = u.name;
sel.appendChild(o);
});
}
async function loadAlerts() {
const severity = document.getElementById('filter-severity').value;
const ups = document.getElementById('filter-ups').value;
const view = document.getElementById('filter-view').value;
const path = view === 'active' ? '/api/alerts/active' : '/api/alerts';
const params = new URLSearchParams();
if (severity) params.set('severity', severity);
if (ups) params.set('ups', ups);
if (view === 'history') params.set('days', '30');
const url = path + (params.toString() ? '?' + params.toString() : '');
const r = await window.apiFetch(url);
const tbody = document.getElementById('alerts-tbody');
tbody.textContent = '';
if (!r.ok) {
const tr = document.createElement('tr');
tr.appendChild(cell('Failed to load')); tbody.appendChild(tr); return;
}
const rows = await r.json();
if (!rows.length) {
const tr = document.createElement('tr');
tr.appendChild(cell('No alerts')); tbody.appendChild(tr); return;
}
rows.forEach(a => {
const tr = document.createElement('tr');
tr.appendChild(cell(new Date(a.ts * 1000).toLocaleString()));
const sevCell = document.createElement('td');
const sevSpan = document.createElement('span');
sevSpan.className = 'sev ' + a.severity;
sevSpan.textContent = a.severity;
sevCell.appendChild(sevSpan);
tr.appendChild(sevCell);
tr.appendChild(cell(a.ups));
tr.appendChild(cell(a.code || ''));
tr.appendChild(cell(a.message));
tr.appendChild(cell(a.acked_ts ? new Date(a.acked_ts * 1000).toLocaleString() : ''));
const actionCell = document.createElement('td');
if (!a.acked_ts) {
const btn = document.createElement('button');
btn.className = 'btn btn-secondary ack-btn';
btn.textContent = 'Ack';
btn.dataset.id = a.id;
btn.addEventListener('click', async () => {
await window.apiFetch('/api/alerts/' + btn.dataset.id + '/ack', { method: 'POST' });
loadAlerts();
});
actionCell.appendChild(btn);
}
tr.appendChild(actionCell);
tbody.appendChild(tr);
});
}
document.getElementById('refresh-btn').addEventListener('click', loadAlerts);
['filter-severity', 'filter-ups', 'filter-view'].forEach(id => {
document.getElementById(id).addEventListener('change', loadAlerts);
});
await loadUps();
await loadAlerts();
})();
+33
View File
@@ -0,0 +1,33 @@
// Shared client-side helpers: CSRF, logout, fetch wrapper
(function () {
function getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
if (meta && meta.content) return meta.content;
const m = document.cookie.match(/(?:^|;)\s*csrf_token=([^;]+)/);
return m ? decodeURIComponent(m[1]) : '';
}
window.apiFetch = async function (url, opts = {}) {
const method = (opts.method || 'GET').toUpperCase();
const headers = Object.assign({ Accept: 'application/json' }, opts.headers || {});
if (method !== 'GET' && method !== 'HEAD') {
headers['X-CSRF-Token'] = getCsrfToken();
if (opts.body && !(opts.body instanceof FormData) && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
}
const resp = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts, { headers }));
if (resp.status === 401) {
window.location.href = '/login';
throw new Error('unauthorized');
}
return resp;
};
document.addEventListener('click', async (e) => {
if (e.target && e.target.id === 'logout-btn') {
await window.apiFetch('/api/logout', { method: 'POST' });
window.location.href = '/login';
}
});
})();
+121 -297
View File
@@ -1,174 +1,113 @@
// Configuration management JavaScript
// UPS Configuration page (uses apiFetch for CSRF)
function el(tag, attrs, ...children) {
const e = document.createElement(tag);
if (attrs) Object.entries(attrs).forEach(([k, v]) => {
if (k === 'class') e.className = v;
else if (k === 'dataset') Object.assign(e.dataset, v);
else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2), v);
else if (v !== null && v !== undefined) e.setAttribute(k, v);
});
children.forEach(c => {
if (c == null) return;
e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
});
return e;
}
class ConfigManager {
constructor() {
this.init();
}
constructor() { this.confirmCallback = null; this.init(); }
init() {
this.bindEvents();
this.loadUPSList();
}
init() { this.bindEvents(); this.loadUPSList(); }
bindEvents() {
// Add UPS button
document.getElementById('add-ups-btn').addEventListener('click', () => {
this.showUPSModal();
});
// Modal close buttons
document.getElementById('add-ups-btn').addEventListener('click', () => this.showUPSModal());
document.querySelectorAll('.close').forEach(close => {
close.addEventListener('click', (e) => {
this.closeModal(e.target.closest('.modal'));
});
close.addEventListener('click', (e) => this.closeModal(e.target.closest('.modal')));
});
// UPS form submission
document.getElementById('ups-form').addEventListener('submit', (e) => {
e.preventDefault();
this.saveUPS();
e.preventDefault(); this.saveUPS();
});
// Test connection button
document.getElementById('test-connection-btn').addEventListener('click', () => {
this.testConnection();
});
// Cancel button
document.getElementById('test-connection-btn').addEventListener('click', () => this.testConnection());
document.getElementById('cancel-btn').addEventListener('click', () => {
this.closeModal(document.getElementById('ups-modal'));
});
// Confirmation modal buttons
document.getElementById('confirm-yes').addEventListener('click', () => {
if (this.confirmCallback) {
this.confirmCallback();
}
if (this.confirmCallback) this.confirmCallback();
this.closeModal(document.getElementById('confirm-modal'));
});
document.getElementById('confirm-no').addEventListener('click', () => {
this.closeModal(document.getElementById('confirm-modal'));
});
// Click outside modal to close
window.addEventListener('click', (e) => {
if (e.target.classList.contains('modal')) {
this.closeModal(e.target);
}
if (e.target.classList.contains('modal')) this.closeModal(e.target);
});
}
async loadUPSList() {
try {
const response = await fetch('/api/config/ups');
const upsList = await response.json();
if (!response.ok) {
throw new Error(upsList.detail || 'Failed to load UPS configurations');
}
this.renderUPSList(upsList);
} catch (error) {
this.showToast('Error loading UPS configurations: ' + error.message, 'error');
}
const resp = await window.apiFetch('/api/config/ups');
const list = await resp.json();
if (!resp.ok) throw new Error(list.detail || 'Failed to load');
this.renderUPSList(list);
} catch (err) { this.showToast('Error: ' + err.message, 'error'); }
}
renderUPSList(upsList) {
renderUPSList(list) {
const container = document.getElementById('ups-list');
if (upsList.length === 0) {
container.innerHTML = `
<div class="empty-state">
<h3>No UPS Configured</h3>
<p>Get started by clicking "Add UPS" above to configure your first UPS monitoring.</p>
</div>
`;
container.textContent = '';
if (list.length === 0) {
const empty = el('div', { class: 'empty-state' },
el('h3', null, 'No UPS Configured'),
el('p', null, 'Click "Add UPS" to configure your first UPS.'));
container.appendChild(empty);
return;
}
container.innerHTML = upsList.map(ups => this.renderUPSItem(ups)).join('');
// Bind action buttons
container.querySelectorAll('.edit-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const upsName = e.target.dataset.upsName;
this.editUPS(upsName);
});
});
container.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const upsName = e.target.dataset.upsName;
this.confirmDelete(upsName);
});
});
container.querySelectorAll('.test-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const upsName = e.target.dataset.upsName;
this.testUPSConnection(upsName);
});
});
list.forEach(ups => container.appendChild(this.renderUPSItem(ups)));
}
renderUPSItem(ups) {
const alertsEnabled = [
ups.alert_loadpct_high && `Load: ${ups.alert_loadpct_high}%`,
ups.alert_bcharge_low && `Battery: ${ups.alert_bcharge_low}%`,
ups.alert_on_battery && 'On Battery',
ups.alert_runtime_low_minutes && `Runtime: ${ups.alert_runtime_low_minutes}min`
].filter(Boolean);
return `
<div class="ups-item">
<div class="ups-item-header">
<div class="ups-item-title">${ups.name}</div>
<div class="ups-item-actions">
<button class="btn btn-secondary test-btn" data-ups-name="${ups.name}">Test</button>
<button class="btn btn-primary edit-btn" data-ups-name="${ups.name}">Edit</button>
<button class="btn btn-danger delete-btn" data-ups-name="${ups.name}">Delete</button>
</div>
</div>
<div class="ups-item-details">
<div class="ups-detail">
<div class="ups-detail-label">Host</div>
<div class="ups-detail-value">${ups.host}:${ups.port}</div>
</div>
<div class="ups-detail">
<div class="ups-detail-label">Polling Interval</div>
<div class="ups-detail-value">${ups.interval_seconds}s</div>
</div>
<div class="ups-detail">
<div class="ups-detail-label">Alerts</div>
<div class="ups-detail-value">${alertsEnabled.length > 0 ? alertsEnabled.join(', ') : 'None'}</div>
</div>
</div>
</div>
`;
const alerts = [];
if (ups.alert_loadpct_high) alerts.push('Load: ' + ups.alert_loadpct_high + '%');
if (ups.alert_bcharge_low) alerts.push('Battery: ' + ups.alert_bcharge_low + '%');
if (ups.alert_on_battery) alerts.push('On Battery');
if (ups.alert_runtime_low_minutes) alerts.push('Runtime: ' + ups.alert_runtime_low_minutes + 'min');
if (ups.alert_itemp_high) alerts.push('Temp: ' + ups.alert_itemp_high + '°C');
const testBtn = el('button', { class: 'btn btn-secondary', onclick: () => this.testUPSConnection(ups.name) }, 'Test');
const editBtn = el('button', { class: 'btn btn-primary', onclick: () => this.editUPS(ups.name) }, 'Edit');
const delBtn = el('button', { class: 'btn btn-danger', onclick: () => this.confirmDelete(ups.name) }, 'Delete');
const item = el('div', { class: 'ups-item' },
el('div', { class: 'ups-item-header' },
el('div', { class: 'ups-item-title' }, ups.name),
el('div', { class: 'ups-item-actions' }, testBtn, editBtn, delBtn)),
el('div', { class: 'ups-item-details' },
el('div', { class: 'ups-detail' },
el('div', { class: 'ups-detail-label' }, 'Host'),
el('div', { class: 'ups-detail-value' }, ups.host + ':' + ups.port)),
el('div', { class: 'ups-detail' },
el('div', { class: 'ups-detail-label' }, 'Polling Interval'),
el('div', { class: 'ups-detail-value' }, ups.interval_seconds + 's')),
el('div', { class: 'ups-detail' },
el('div', { class: 'ups-detail-label' }, 'Alerts'),
el('div', { class: 'ups-detail-value' }, alerts.length ? alerts.join(', ') : 'None'))));
return item;
}
showUPSModal(ups = null) {
const modal = document.getElementById('ups-modal');
const title = document.getElementById('modal-title');
const form = document.getElementById('ups-form');
// Reset form
form.reset();
if (ups) {
// Edit mode
title.textContent = 'Edit UPS';
this.populateForm(ups);
form.dataset.mode = 'edit';
form.dataset.upsName = ups.name;
} else {
// Add mode
title.textContent = 'Add UPS';
form.dataset.mode = 'add';
delete form.dataset.upsName;
}
modal.style.display = 'block';
}
@@ -179,222 +118,107 @@ class ConfigManager {
document.getElementById('ups-interval').value = ups.interval_seconds || 30;
document.getElementById('ups-loadpct').value = ups.alert_loadpct_high || '';
document.getElementById('ups-bcharge').value = ups.alert_bcharge_low || '';
document.getElementById('ups-onbattery').checked = ups.alert_on_battery || false;
document.getElementById('ups-onbattery').checked = !!ups.alert_on_battery;
document.getElementById('ups-runtime').value = ups.alert_runtime_low_minutes || '';
const itemp = document.getElementById('ups-itemp');
if (itemp) itemp.value = ups.alert_itemp_high || '';
}
closeModal(modal) {
modal.style.display = 'none';
}
closeModal(modal) { modal.style.display = 'none'; }
async saveUPS() {
const form = document.getElementById('ups-form');
const formData = new FormData(form);
const fd = new FormData(form);
const mode = form.dataset.mode;
const upsName = form.dataset.upsName;
const name = form.dataset.upsName;
const data = {
name: formData.get('name'),
host: formData.get('host'),
port: parseInt(formData.get('port')) || 3551,
interval_seconds: parseInt(formData.get('interval_seconds')) || 30,
alert_on_battery: formData.has('alert_on_battery')
name: fd.get('name'),
host: fd.get('host'),
port: parseInt(fd.get('port')) || 3551,
interval_seconds: parseInt(fd.get('interval_seconds')) || 30,
alert_on_battery: fd.has('alert_on_battery'),
};
// Add optional alert thresholds
const loadpct = formData.get('alert_loadpct_high');
if (loadpct) data.alert_loadpct_high = parseFloat(loadpct);
const bcharge = formData.get('alert_bcharge_low');
if (bcharge) data.alert_bcharge_low = parseFloat(bcharge);
const runtime = formData.get('alert_runtime_low_minutes');
if (runtime) data.alert_runtime_low_minutes = parseFloat(runtime);
const num = (k) => { const v = fd.get(k); if (v) data[k] = parseFloat(v); };
num('alert_loadpct_high'); num('alert_bcharge_low');
num('alert_runtime_low_minutes'); num('alert_itemp_high');
try {
let response;
if (mode === 'edit') {
response = await fetch(`/api/config/ups/${upsName}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
response = await fetch('/api/config/ups', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
}
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || 'Failed to save UPS configuration');
}
const url = mode === 'edit' ? '/api/config/ups/' + name : '/api/config/ups';
const method = mode === 'edit' ? 'PUT' : 'POST';
const resp = await window.apiFetch(url, { method, body: JSON.stringify(data) });
const result = await resp.json();
if (!resp.ok) throw new Error(result.detail || 'Save failed');
this.showToast(result.message, 'success');
this.closeModal(document.getElementById('ups-modal'));
this.loadUPSList();
} catch (error) {
this.showToast('Error saving UPS: ' + error.message, 'error');
}
} catch (err) { this.showToast('Error: ' + err.message, 'error'); }
}
async editUPS(upsName) {
async editUPS(name) {
try {
const response = await fetch(`/api/config/ups/${upsName}`);
const ups = await response.json();
if (!response.ok) {
throw new Error(ups.detail || 'Failed to load UPS configuration');
}
const resp = await window.apiFetch('/api/config/ups/' + name);
const ups = await resp.json();
if (!resp.ok) throw new Error(ups.detail || 'Load failed');
this.showUPSModal(ups);
} catch (error) {
this.showToast('Error loading UPS configuration: ' + error.message, 'error');
}
} catch (err) { this.showToast('Error: ' + err.message, 'error'); }
}
confirmDelete(upsName) {
const modal = document.getElementById('confirm-modal');
const message = document.getElementById('confirm-message');
message.textContent = `Are you sure you want to delete the UPS configuration "${upsName}"? This action cannot be undone.`;
this.confirmCallback = () => this.deleteUPS(upsName);
modal.style.display = 'block';
confirmDelete(name) {
document.getElementById('confirm-message').textContent =
'Delete UPS "' + name + '"? This cannot be undone.';
this.confirmCallback = () => this.deleteUPS(name);
document.getElementById('confirm-modal').style.display = 'block';
}
async deleteUPS(upsName) {
async deleteUPS(name) {
try {
const response = await fetch(`/api/config/ups/${upsName}`, {
method: 'DELETE'
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || 'Failed to delete UPS configuration');
}
const resp = await window.apiFetch('/api/config/ups/' + name, { method: 'DELETE' });
const result = await resp.json();
if (!resp.ok) throw new Error(result.detail || 'Delete failed');
this.showToast(result.message, 'success');
this.loadUPSList();
} catch (error) {
this.showToast('Error deleting UPS: ' + error.message, 'error');
}
} catch (err) { this.showToast('Error: ' + err.message, 'error'); }
}
async testConnection() {
const form = document.getElementById('ups-form');
const formData = new FormData(form);
const fd = new FormData(form);
const data = {
name: formData.get('name') || 'test',
host: formData.get('host'),
port: parseInt(formData.get('port')) || 3551,
name: fd.get('name') || 'test',
host: fd.get('host'),
port: parseInt(fd.get('port')) || 3551,
interval_seconds: 30,
alert_on_battery: false
alert_on_battery: false,
};
if (!data.host) {
this.showToast('Host is required for connection test', 'error');
return;
}
const testBtn = document.getElementById('test-connection-btn');
const originalText = testBtn.textContent;
testBtn.textContent = 'Testing...';
testBtn.disabled = true;
if (!data.host) { this.showToast('Host required', 'error'); return; }
const btn = document.getElementById('test-connection-btn');
const orig = btn.textContent;
btn.textContent = 'Testing...'; btn.disabled = true;
try {
const response = await fetch('/api/config/ups/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
const resp = await window.apiFetch('/api/config/ups/test', {
method: 'POST', body: JSON.stringify(data),
});
const result = await response.json();
let msg;
if (result.success) {
msg = 'Connection successful';
if (result.data && result.data.STATUS) {
msg += ` (STATUS=${result.data.STATUS})`;
}
this.showToast(msg, 'success');
} else {
if (result.connectivity && !result.connectivity.ok) {
msg = 'TCP connectivity failed: ' + (result.connectivity.error || 'unknown error');
} else if (result.protocol && !result.protocol.ok) {
msg = 'Protocol error after TCP success: ' + (result.protocol.error || 'unknown error');
} else {
msg = result.message || 'Connection failed';
}
this.showToast(msg, 'error');
}
} catch (error) {
this.showToast('Connection test failed: ' + error.message, 'error');
} finally {
testBtn.textContent = originalText;
testBtn.disabled = false;
}
const r = await resp.json();
this.showToast(r.message || (r.success ? 'OK' : 'Failed'),
r.success ? 'success' : 'error');
} catch (err) { this.showToast('Test failed: ' + err.message, 'error'); }
finally { btn.textContent = orig; btn.disabled = false; }
}
async testUPSConnection(upsName) {
const testBtn = document.querySelector(`[data-ups-name="${upsName}"].test-btn`);
const originalText = testBtn.textContent;
testBtn.textContent = 'Testing...';
testBtn.disabled = true;
async testUPSConnection(name) {
try {
const response = await fetch(`/api/config/ups/${upsName}/test`, {
method: 'POST'
});
const result = await response.json();
let msg;
if (result.success) {
msg = `Connection to ${upsName} successful`;
if (result.data && result.data.STATUS) {
msg += ` (STATUS=${result.data.STATUS})`;
}
this.showToast(msg, 'success');
} else {
if (result.connectivity && !result.connectivity.ok) {
msg = `TCP connectivity failed: ${result.connectivity.error}`;
} else if (result.protocol && !result.protocol.ok) {
msg = `Protocol error: ${result.protocol.error}`;
} else {
msg = result.message || 'Connection failed';
}
this.showToast(`Connection to ${upsName} failed: ${msg}`, 'error');
}
} catch (error) {
this.showToast(`Connection test failed: ${error.message}`, 'error');
} finally {
testBtn.textContent = originalText;
testBtn.disabled = false;
}
const resp = await window.apiFetch('/api/config/ups/' + name + '/test', { method: 'POST' });
const r = await resp.json();
this.showToast(r.message || (r.success ? 'OK' : 'Failed'),
r.success ? 'success' : 'error');
} catch (err) { this.showToast('Test failed: ' + err.message, 'error'); }
}
showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
const toast = el('div', { class: 'toast ' + type }, message);
container.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 5000);
setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 5000);
}
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new ConfigManager();
});
document.addEventListener('DOMContentLoaded', () => { new ConfigManager(); });
+56
View File
@@ -0,0 +1,56 @@
// Events log page (DOM-safe)
(async function () {
function setCell(row, text) {
const td = document.createElement('td');
td.textContent = text;
row.appendChild(td);
}
async function loadUps() {
const r = await window.apiFetch('/api/ups');
if (!r.ok) return;
const list = await r.json();
const sel = document.getElementById('filter-ups');
list.forEach(u => {
const o = document.createElement('option');
o.value = u.name; o.textContent = u.name;
sel.appendChild(o);
});
}
async function loadEvents() {
const ups = document.getElementById('filter-ups').value;
const kind = document.getElementById('filter-kind').value;
const params = new URLSearchParams();
if (ups) params.set('ups', ups);
if (kind) params.set('kind', kind);
params.set('limit', '300');
const r = await window.apiFetch('/api/events?' + params.toString());
const tbody = document.getElementById('events-tbody');
tbody.textContent = '';
if (!r.ok) {
const tr = document.createElement('tr');
setCell(tr, 'Failed to load'); tbody.appendChild(tr); return;
}
const rows = await r.json();
if (!rows.length) {
const tr = document.createElement('tr');
setCell(tr, 'No events'); tbody.appendChild(tr); return;
}
rows.forEach(e => {
const tr = document.createElement('tr');
setCell(tr, new Date(e.ts * 1000).toLocaleString());
setCell(tr, e.ups);
setCell(tr, e.type);
setCell(tr, e.detail);
tbody.appendChild(tr);
});
}
document.getElementById('refresh-btn').addEventListener('click', loadEvents);
document.getElementById('filter-ups').addEventListener('change', loadEvents);
document.getElementById('filter-kind').addEventListener('change', loadEvents);
await loadUps();
await loadEvents();
})();
+79
View File
@@ -0,0 +1,79 @@
// Settings page
(async function () {
async function loadUi() {
const r = await window.apiFetch('/api/config/ui');
if (!r.ok) return;
const ui = await r.json();
const form = document.getElementById('ui-form');
Object.entries(ui).forEach(([k, v]) => {
const el = form.elements[k];
if (!el) return;
if (el.type === 'checkbox') el.checked = !!v;
else el.value = v;
});
}
async function loadSmtp() {
const r = await window.apiFetch('/api/config/smtp');
if (!r.ok) return;
const s = await r.json();
if (!s) return;
const form = document.getElementById('smtp-form');
Object.entries(s).forEach(([k, v]) => {
const el = form.elements[k];
if (!el) return;
if (k === 'to_addrs' && Array.isArray(v)) el.value = v.join(', ');
else if (el.type === 'checkbox') el.checked = !!v;
else if (v !== null && v !== undefined) el.value = v;
});
}
document.getElementById('ui-form').addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.target;
const payload = {
show_events: f.show_events.checked,
show_energy: f.show_energy.checked,
color_badges: f.color_badges.checked,
enable_transfer_burst_alert: f.enable_transfer_burst_alert.checked,
enable_voltage_deviation_alert: f.enable_voltage_deviation_alert.checked,
energy_cost_per_kwh: parseFloat(f.energy_cost_per_kwh.value) || 0,
};
const r = await window.apiFetch('/api/config/ui', {
method: 'PUT', body: JSON.stringify(payload),
});
alert(r.ok ? 'UI settings saved' : 'Save failed');
});
document.getElementById('smtp-form').addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.target;
const to = f.to_addrs.value.split(',').map(s => s.trim()).filter(Boolean);
const payload = {
host: f.host.value,
port: parseInt(f.port.value, 10),
username: f.username.value || null,
use_tls: f.use_tls.checked,
use_ssl: f.use_ssl.checked,
from_addr: f.from_addr.value || null,
to_addrs: to,
subject_prefix: f.subject_prefix.value || '[UPS]',
silent_hours_start: f.silent_hours_start.value ? parseInt(f.silent_hours_start.value, 10) : null,
silent_hours_end: f.silent_hours_end.value ? parseInt(f.silent_hours_end.value, 10) : null,
daily_summary_hour: f.daily_summary_hour.value ? parseInt(f.daily_summary_hour.value, 10) : null,
};
const r = await window.apiFetch('/api/config/smtp', {
method: 'PUT', body: JSON.stringify(payload),
});
alert(r.ok ? 'SMTP saved' : 'Save failed');
});
document.getElementById('test-smtp-btn').addEventListener('click', async () => {
const r = await window.apiFetch('/api/config/smtp/test', { method: 'POST' });
const body = await r.json().catch(() => ({}));
alert(r.ok ? 'Test email sent' : 'Failed: ' + (body.detail || 'unknown'));
});
await loadUi();
await loadSmtp();
})();
+30 -19
View File
@@ -1,51 +1,62 @@
from __future__ import annotations
import asyncio
import time
from typing import Dict, Any, List
import redis
import json
import os
REDIS_URL = os.environ.get("REDIS_URL", "redis://redis:6379/0")
import json
import time
from typing import Any
import redis
from .settings import settings
RETENTION_SECONDS = 7 * 24 * 3600
MAX_SAMPLES_PER_UPS = 7 * 24 * 60 * 2 # assume worst-case 30s interval -> ~20160 entries
MAX_SAMPLES_PER_UPS = 7 * 24 * 60 * 2 # worst-case ~30s interval
_redis: redis.Redis | None = None
def get_redis() -> redis.Redis:
global _redis
if _redis:
return _redis
_redis = redis.Redis.from_url(REDIS_URL, decode_responses=True)
_redis = redis.Redis.from_url(settings.redis_url, decode_responses=True)
return _redis
SNAP_KEY_PREFIX = "ups:snap:" # latest hash per ups
HIST_KEY_PREFIX = "ups:hist:" # time-series list per ups (append JSON)
async def store_snapshot(ups_name: str, data: Dict[str, Any]):
def reset_redis_client() -> None:
"""Test hook - clears cached Redis connection."""
global _redis
_redis = None
SNAP_KEY_PREFIX = "ups:snap:"
HIST_KEY_PREFIX = "ups:hist:"
async def store_snapshot(ups_name: str, data: dict[str, Any]):
r = get_redis()
ts = int(time.time())
pipe = r.pipeline()
# store latest snapshot (hash)
pipe.hset(f"{SNAP_KEY_PREFIX}{ups_name}", mapping={**data, "_ts": ts})
# append to history list
hist_key = f"{HIST_KEY_PREFIX}{ups_name}"
pipe.rpush(hist_key, json.dumps({"ts": ts, "data": data}))
pipe.ltrim(hist_key, -MAX_SAMPLES_PER_UPS, -1)
# add pruning via async task (length-based + time-based)
pipe.execute()
async def get_latest(ups_name: str) -> Dict[str, Any] | None:
async def get_latest(ups_name: str) -> dict[str, Any] | None:
r = get_redis()
h = r.hgetall(f"{SNAP_KEY_PREFIX}{ups_name}")
return h or None
async def get_history(ups_name: str, since_seconds: int = RETENTION_SECONDS) -> List[Dict[str, Any]]:
async def get_history(
ups_name: str, since_seconds: int = RETENTION_SECONDS
) -> list[dict[str, Any]]:
r = get_redis()
key = f"{HIST_KEY_PREFIX}{ups_name}"
raw = r.lrange(key, 0, -1)
now = int(time.time())
out: List[Dict[str, Any]] = []
out: list[dict[str, Any]] = []
for item in raw:
try:
obj = json.loads(item)
@@ -55,12 +66,12 @@ async def get_history(ups_name: str, since_seconds: int = RETENTION_SECONDS) ->
out.append(obj)
return out
async def prune_old():
r = get_redis()
now = int(time.time())
cutoff = now - RETENTION_SECONDS
for key in r.scan_iter(f"{HIST_KEY_PREFIX}*"):
# prune from left while older than cutoff
while True:
item = r.lindex(key, 0)
if not item:
+22
View File
@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Alerts — UPS{% endblock %}
{% block content %}
<div class="page-header">
<h1>Alerts</h1>
<div class="filters">
<select id="filter-severity"><option value="">All Severities</option><option value="CRITICAL">CRITICAL</option><option value="WARNING">WARNING</option><option value="INFO">INFO</option></select>
<select id="filter-ups"><option value="">All UPS</option></select>
<select id="filter-view"><option value="active">Active</option><option value="history">History (30d)</option></select>
<button id="refresh-btn" class="btn btn-secondary">Refresh</button>
</div>
</div>
<div class="main-content">
<table class="data-table">
<thead><tr><th>Time</th><th>Severity</th><th>UPS</th><th>Code</th><th>Message</th><th>Acked</th><th></th></tr></thead>
<tbody id="alerts-tbody"><tr><td colspan="7">Loading...</td></tr></tbody>
</table>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/alerts.js"></script>
{% endblock %}
+35
View File
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}UPS Dashboard{% endblock %}</title>
<meta name="csrf-token" content="{{ request.cookies.get('csrf_token', '') }}" />
<link rel="stylesheet" href="/static/css/base.css" />
<link rel="stylesheet" href="/static/css/style.css" />
{% block head_extra %}{% endblock %}
</head>
<body>
<header class="site-header">
<div class="brand"><a href="/">⚡ APC UPS Dashboard</a></div>
<nav class="main-nav">
<a href="/" class="{% if active_nav == 'dashboard' %}active{% endif %}">Dashboard</a>
<a href="/events" class="{% if active_nav == 'events' %}active{% endif %}">Events</a>
<a href="/alerts" class="{% if active_nav == 'alerts' %}active{% endif %}">Alerts</a>
<a href="/config" class="{% if active_nav == 'config' %}active{% endif %}">UPS Config</a>
<a href="/settings" class="{% if active_nav == 'settings' %}active{% endif %}">Settings</a>
</nav>
<div class="user-menu">
{% if current_user %}
<span class="username">{{ current_user }}</span>
<button type="button" id="logout-btn" class="btn-logout">Logout</button>
{% endif %}
</div>
</header>
<main class="site-main">
{% block content %}{% endblock %}
</main>
<script src="/static/js/app.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+79 -103
View File
@@ -1,112 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>UPS Configuration</title>
<link rel="stylesheet" href="/static/css/style.css" />
<link rel="stylesheet" href="/static/css/config.css" />
</head>
<body>
<div class="header">
<h1>UPS Configuration</h1>
<nav>
<a href="/">← Back to Dashboard</a>
<button id="add-ups-btn" class="btn btn-primary">Add UPS</button>
</nav>
</div>
{% extends "base.html" %}
{% block title %}UPS Configuration{% endblock %}
{% block head_extra %}
<link rel="stylesheet" href="/static/css/config.css" />
{% endblock %}
{% block content %}
<div class="page-header">
<h1>UPS Configuration</h1>
<button id="add-ups-btn" class="btn btn-primary">Add UPS</button>
</div>
<div class="main-content">
<div id="ups-list" class="ups-list">
<!-- UPS configurations will be loaded here -->
<div class="main-content">
<div id="ups-list" class="ups-list"></div>
</div>
<div id="ups-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modal-title">Add UPS</h2>
<span class="close">&times;</span>
</div>
</div>
<!-- Add/Edit UPS Modal -->
<div id="ups-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modal-title">Add UPS</h2>
<span class="close">&times;</span>
<form id="ups-form">
<div class="form-group">
<label for="ups-name">Name *</label>
<input type="text" id="ups-name" name="name" required pattern="[A-Za-z0-9_-]{1,32}" maxlength="32">
<small>1-32 alphanumeric, underscore, dash</small>
</div>
<form id="ups-form">
<div class="form-group">
<label for="ups-name">Name *</label>
<input type="text" id="ups-name" name="name" required>
<small>Friendly name for the UPS</small>
</div>
<div class="form-group">
<label for="ups-host">Host/IP *</label>
<input type="text" id="ups-host" name="host" required>
<small>IP address or hostname of the apcupsd server</small>
</div>
<div class="form-group">
<label for="ups-port">Port</label>
<input type="number" id="ups-port" name="port" value="3551">
<small>apcupsd NIS port (default: 3551)</small>
</div>
<div class="form-group">
<label for="ups-interval">Polling Interval (seconds)</label>
<input type="number" id="ups-interval" name="interval_seconds" value="30" min="1">
<small>How often to poll the UPS for data</small>
</div>
<h3>Alert Thresholds</h3>
<div class="form-group">
<label for="ups-loadpct">High Load Alert (%)</label>
<input type="number" id="ups-loadpct" name="alert_loadpct_high" min="0" max="100" step="0.1">
<small>Trigger alert when load percentage reaches this value</small>
</div>
<div class="form-group">
<label for="ups-bcharge">Low Battery Alert (%)</label>
<input type="number" id="ups-bcharge" name="alert_bcharge_low" min="0" max="100" step="0.1">
<small>Trigger alert when battery charge drops to this value</small>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="ups-onbattery" name="alert_on_battery">
Alert when on battery power
</label>
</div>
<div class="form-group">
<label for="ups-runtime">Low Runtime Alert (minutes)</label>
<input type="number" id="ups-runtime" name="alert_runtime_low_minutes" min="0" step="0.1">
<small>Trigger alert when remaining runtime drops to this value</small>
</div>
<div class="form-actions">
<button type="button" id="test-connection-btn" class="btn btn-secondary">Test Connection</button>
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" id="cancel-btn" class="btn btn-secondary">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Confirmation Dialog -->
<div id="confirm-modal" class="modal">
<div class="modal-content small">
<div class="modal-header">
<h2>Confirm Action</h2>
<span class="close">&times;</span>
<div class="form-group">
<label for="ups-host">Host/IP *</label>
<input type="text" id="ups-host" name="host" required>
<small>IP or hostname of the apcupsd server</small>
</div>
<div class="form-group">
<label for="ups-port">Port</label>
<input type="number" id="ups-port" name="port" value="3551" min="1" max="65535">
</div>
<div class="form-group">
<label for="ups-interval">Polling Interval (seconds)</label>
<input type="number" id="ups-interval" name="interval_seconds" value="30" min="5" max="3600">
</div>
<h3>Alert Thresholds</h3>
<div class="form-group">
<label for="ups-loadpct">High Load Alert (%)</label>
<input type="number" id="ups-loadpct" name="alert_loadpct_high" min="0" max="100" step="0.1">
</div>
<div class="form-group">
<label for="ups-bcharge">Low Battery Alert (%)</label>
<input type="number" id="ups-bcharge" name="alert_bcharge_low" min="0" max="100" step="0.1">
</div>
<div class="form-group">
<label><input type="checkbox" id="ups-onbattery" name="alert_on_battery"> Alert when on battery power</label>
</div>
<div class="form-group">
<label for="ups-runtime">Low Runtime Alert (minutes)</label>
<input type="number" id="ups-runtime" name="alert_runtime_low_minutes" min="0" step="0.1">
</div>
<div class="form-group">
<label for="ups-itemp">High Temperature Alert (°C)</label>
<input type="number" id="ups-itemp" name="alert_itemp_high" min="0" max="120" step="0.5">
</div>
<p id="confirm-message"></p>
<div class="form-actions">
<button id="confirm-yes" class="btn btn-danger">Yes</button>
<button id="confirm-no" class="btn btn-secondary">No</button>
<button type="button" id="test-connection-btn" class="btn btn-secondary">Test Connection</button>
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" id="cancel-btn" class="btn btn-secondary">Cancel</button>
</div>
</form>
</div>
</div>
<div id="confirm-modal" class="modal">
<div class="modal-content small">
<div class="modal-header">
<h2>Confirm Action</h2>
<span class="close">&times;</span>
</div>
<p id="confirm-message"></p>
<div class="form-actions">
<button id="confirm-yes" class="btn btn-danger">Yes</button>
<button id="confirm-no" class="btn btn-secondary">No</button>
</div>
</div>
</div>
<!-- Toast notifications -->
<div id="toast-container"></div>
<script src="/static/js/config.js"></script>
</body>
</html>
<div id="toast-container"></div>
{% endblock %}
{% block scripts %}
<script src="/static/js/config.js"></script>
{% endblock %}
+51 -43
View File
@@ -1,46 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>UPS Dashboard</title>
<link rel="stylesheet" href="/static/css/style.css" />
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
<div class="header">
<h1>APC UPS Dashboard</h1>
<nav>
<a href="/config" class="config-link">⚙️ Configuration</a>
</nav>
{% extends "base.html" %}
{% block title %}Dashboard — UPS{% endblock %}
{% block head_extra %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
{% endblock %}
{% block content %}
<section class="fleet-overview" id="fleet-overview">
<div class="fleet-card">
<div class="fleet-stat">
<span class="fleet-stat-label">Total UPS</span>
<span class="fleet-stat-value" id="fleet-total">-</span>
</div>
<div class="fleet-stat"><span class="fleet-stat-label">Online</span><span class="fleet-stat-value online" id="fleet-online">-</span></div>
<div class="fleet-stat"><span class="fleet-stat-label">On Battery</span><span class="fleet-stat-value onbatt" id="fleet-onbatt">-</span></div>
<div class="fleet-stat"><span class="fleet-stat-label">Offline</span><span class="fleet-stat-value offline" id="fleet-offline">-</span></div>
<div class="fleet-stat"><span class="fleet-stat-label">Total Load (W)</span><span class="fleet-stat-value" id="fleet-watts">-</span></div>
<div class="fleet-stat"><span class="fleet-stat-label">Min Runtime</span><span class="fleet-stat-value" id="fleet-min-runtime">-</span></div>
</div>
<div id="ups-cards" data-ui='{{ ui_cfg | tojson }}'>
{% for ups in ups_list %}
<div class="card" id="card-{{ ups.name }}">
<div class="card-header">
<h2>{{ ups.name }}</h2>
<div class="ups-connection" data-field-conn>{{ ups.host }}:{{ ups.port }}</div>
<button class="add-metric-btn" data-add-metric title="Add metric tile">+</button>
<button class="reset-layout-btn" data-reset-layout title="Reset tile layout">⟳</button>
</div>
<div class="metrics">
<div class="highline">
<span class="ups-status unknown" data-field="STATUS">-</span>
<span class="badge" data-field="RUNTIME_MINUTES" data-badge="runtime">- m</span>
<span class="badge" data-field="DERIVED_WATTS" data-badge="watts">- W</span>
<span class="badge" data-field="HEADROOM_PCT" data-badge="headroom">- headroom</span>
</div>
<div class="tile-grid" data-tile-grid></div>
</div>
<div class="ups-footer" data-ups-footer></div>
</section>
<div id="ups-cards" data-ui='{{ ui_cfg | tojson }}'>
{% for ups in ups_list %}
<div class="card" id="card-{{ ups.name }}">
<div class="card-header">
<h2>
<span class="state-dot" data-state-dot></span>
{{ ups.name }}
</h2>
<div class="ups-connection" data-field-conn>{{ ups.host }}:{{ ups.port }}</div>
<button class="reset-layout-btn" data-reset-layout title="Reset tile layout">⟳</button>
</div>
{% endfor %}
{% if not ups_list %}
<div class="empty-state">
<h3>No UPS Configured</h3>
<p>Get started by <a href="/config">adding a UPS configuration</a>.</p>
<div class="metrics">
<div class="highline">
<span class="ups-status unknown" data-field="STATUS">-</span>
<span class="badge" data-field="RUNTIME_MINUTES" data-badge="runtime">- m</span>
<span class="badge" data-field="DERIVED_WATTS" data-badge="watts">- W</span>
<span class="badge" data-field="HEADROOM_PCT" data-badge="headroom">- headroom</span>
</div>
<div class="tile-grid" data-tile-grid></div>
</div>
{% endif %}
</div>
<script src="/static/js/dashboard.js?v=1"></script>
</body>
</html>
<div class="ups-footer" data-ups-footer></div>
</div>
{% endfor %}
{% if not ups_list %}
<div class="empty-state">
<h3>No UPS Configured</h3>
<p>Get started by <a href="/config">adding a UPS configuration</a>.</p>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/dashboard.js?v=2"></script>
{% endblock %}
+31
View File
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body style="font-family: -apple-system, Segoe UI, Arial, sans-serif; margin: 0; padding: 0; background: #f4f4f4;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f4f4f4; padding: 24px 0;">
<tr>
<td align="center">
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="background: #ffffff; border-radius: 6px; overflow: hidden; box-shadow: 0 2px 6px rgba(0,0,0,.08);">
<tr>
<td style="background: {{ color }}; color: #ffffff; padding: 16px 20px; font-size: 18px; font-weight: 600;">
[{{ top_severity }}] {{ ups_name }}
</td>
</tr>
<tr>
<td style="padding: 20px;">
<p style="margin: 0 0 12px; color: #333;">The following alerts were triggered:</p>
<ul style="margin: 0 0 16px; padding-left: 20px; color: #333;">
{% for a in alerts %}
<li style="margin-bottom: 6px;"><strong>[{{ a.severity }}]</strong> {{ a.message }}</li>
{% endfor %}
</ul>
<p style="margin: 0 0 8px;"><a href="{{ dashboard_url }}" style="color: #2980b9;">Open dashboard</a></p>
<p style="margin: 0; color: #888; font-size: 12px;">Sent at {{ sent_at }}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body style="font-family: -apple-system, Segoe UI, Arial, sans-serif;">
<h2>UPS Daily Summary</h2>
<p>Sent at: {{ sent_at }}</p>
<table cellpadding="6" cellspacing="0" border="1" style="border-collapse: collapse;">
<tr><th>UPS</th><th>Status</th><th>Load %</th><th>Battery %</th><th>kWh today</th><th>Alerts (24h)</th></tr>
{% for row in summary.ups_rows %}
<tr>
<td>{{ row.name }}</td>
<td>{{ row.status }}</td>
<td>{{ row.loadpct }}</td>
<td>{{ row.bcharge }}</td>
<td>{{ row.kwh }}</td>
<td>{{ row.alerts_count }}</td>
</tr>
{% endfor %}
</table>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}Events — UPS{% endblock %}
{% block content %}
<div class="page-header">
<h1>Event Log</h1>
<div class="filters">
<select id="filter-ups"><option value="">All UPS</option></select>
<select id="filter-kind"><option value="">All Events</option><option value="STATUS">STATUS</option><option value="XFER">XFER</option></select>
<button id="refresh-btn" class="btn btn-secondary">Refresh</button>
</div>
</div>
<div class="main-content">
<table class="data-table">
<thead><tr><th>Time</th><th>UPS</th><th>Type</th><th>Detail</th></tr></thead>
<tbody id="events-tbody"><tr><td colspan="4">Loading...</td></tr></tbody>
</table>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/events.js"></script>
{% endblock %}
+49
View File
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Login — UPS Dashboard</title>
<link rel="stylesheet" href="/static/css/base.css" />
<link rel="stylesheet" href="/static/css/login.css" />
</head>
<body class="auth-body">
<div class="auth-card">
<h1>UPS Dashboard</h1>
<form id="login-form">
<div class="form-field">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autocomplete="username" />
</div>
<div class="form-field">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="current-password" />
</div>
<button type="submit" class="btn-primary btn-block">Sign in</button>
<div id="login-error" class="login-error" hidden></div>
</form>
</div>
<script>
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errBox = document.getElementById('login-error');
errBox.hidden = true;
const resp = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
}),
});
if (resp.ok) {
window.location.href = '/';
} else {
const err = await resp.json().catch(() => ({}));
errBox.textContent = err.detail || 'Login failed';
errBox.hidden = false;
}
});
</script>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}Settings — UPS{% endblock %}
{% block content %}
<div class="page-header">
<h1>Settings</h1>
</div>
<div class="main-content">
<section class="settings-section">
<h2>UI / Feature Flags</h2>
<form id="ui-form">
<label><input type="checkbox" name="show_events"> Show events tile</label>
<label><input type="checkbox" name="show_energy"> Show energy</label>
<label><input type="checkbox" name="color_badges"> Colorize badges</label>
<label><input type="checkbox" name="enable_transfer_burst_alert"> Transfer-burst alerts</label>
<label><input type="checkbox" name="enable_voltage_deviation_alert"> Voltage-deviation alerts</label>
<label>Energy cost per kWh ($): <input type="number" step="0.001" min="0" name="energy_cost_per_kwh"></label>
<button type="submit" class="btn btn-primary">Save UI Settings</button>
</form>
</section>
<section class="settings-section">
<h2>SMTP / Email Alerts</h2>
<form id="smtp-form">
<label>Host <input type="text" name="host" required></label>
<label>Port <input type="number" name="port" min="1" max="65535" required></label>
<label>Username <input type="text" name="username"></label>
<label><input type="checkbox" name="use_tls"> Use STARTTLS</label>
<label><input type="checkbox" name="use_ssl"> Use SSL</label>
<label>From <input type="email" name="from_addr"></label>
<label>To (comma-separated) <input type="text" name="to_addrs"></label>
<label>Subject Prefix <input type="text" name="subject_prefix" value="[UPS]"></label>
<label>Silent hours start (0-23, optional) <input type="number" name="silent_hours_start" min="0" max="23"></label>
<label>Silent hours end (0-23, optional) <input type="number" name="silent_hours_end" min="0" max="23"></label>
<label>Daily summary hour (0-23, optional) <input type="number" name="daily_summary_hour" min="0" max="23"></label>
<p class="muted">Password is read from the <code>SMTP_PASSWORD</code> environment variable and never stored in Redis.</p>
<button type="submit" class="btn btn-primary">Save SMTP</button>
<button type="button" id="test-smtp-btn" class="btn btn-secondary">Send Test Email</button>
</form>
</section>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/settings.js"></script>
{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Setup — UPS Dashboard</title>
<link rel="stylesheet" href="/static/css/base.css" />
<link rel="stylesheet" href="/static/css/login.css" />
</head>
<body class="auth-body">
<div class="auth-card wide">
<h1>First-run Setup</h1>
<p class="muted">Create the admin account. Passwords must be at least 8 characters.</p>
<form id="setup-form">
<div class="form-field">
<label for="username">Admin username</label>
<input type="text" id="username" name="username" required minlength="3" autocomplete="username" />
</div>
<div class="form-field">
<label for="password">Password</label>
<input type="password" id="password" name="password" required minlength="8" autocomplete="new-password" />
</div>
<div class="form-field">
<label for="password2">Confirm password</label>
<input type="password" id="password2" name="password2" required minlength="8" />
</div>
<button type="submit" class="btn-primary btn-block">Create admin account</button>
<div id="setup-error" class="login-error" hidden></div>
</form>
<p class="muted" style="margin-top:16px;">After setup you'll be redirected to the dashboard. Configure UPS devices and SMTP from the nav.</p>
</div>
<script>
document.getElementById('setup-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errBox = document.getElementById('setup-error');
errBox.hidden = true;
const pw = document.getElementById('password').value;
const pw2 = document.getElementById('password2').value;
if (pw !== pw2) {
errBox.textContent = 'Passwords do not match';
errBox.hidden = false;
return;
}
const resp = await fetch('/api/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('username').value,
password: pw,
}),
});
if (resp.ok) {
window.location.href = '/';
} else {
const err = await resp.json().catch(() => ({}));
errBox.textContent = err.detail || 'Setup failed';
errBox.hidden = false;
}
});
</script>
</body>
</html>