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
61 files changed
+4410 -1306

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
.git
.github
.gitignore
.venv
venv
__pycache__
*.pyc
*.pyo
*.pyd
.pytest_cache
.coverage
htmlcov
.ruff_cache
.mypy_cache
tests/
docs/
README.md
CLAUDE.md
.env
.env.*
!.env.example
*.md
docker-compose.yml
Dockerfile
config/
+41
View File
@@ -0,0 +1,41 @@
# APC UPS Dashboard environment template
# Copy to `.env` and fill in values. Never commit real .env files.
# --- Required in production ---
# Random 32+ byte string. Generate with:
# python -c "import secrets; print(secrets.token_urlsafe(48))"
SESSION_SECRET=change-me-to-a-random-string
# Admin password hash (argon2). Generate with:
# python -c "from passlib.hash import argon2; print(argon2.hash('mysecret'))"
# If unset and no admin stored in Redis, the app shows a first-run setup page.
ADMIN_USERNAME=admin
# ADMIN_PASSWORD_HASH=
# --- Redis ---
REDIS_URL=redis://redis:6379/0
# REDIS_PASSWORD= # only used if you enable --requirepass in docker-compose
# --- SMTP (optional; configure via Settings UI for host/port/etc) ---
# SMTP password is read from env only; never stored in Redis.
# SMTP_PASSWORD=
# --- Networking / validation ---
# Set false to block all RFC1918 IPs in UPS host field (default true for homelab).
ALLOW_PRIVATE_IPS=true
# Set true when behind an HTTPS reverse proxy (enables Secure cookie + HSTS).
TRUST_PROXY=false
# --- Observability ---
LOG_LEVEL=INFO
# Session expiry (seconds). Default 14 days.
SESSION_MAX_AGE_SECONDS=1209600
# --- Locale ---
TZ=UTC
# --- Rate limiting (set false in tests) ---
RATE_LIMIT_ENABLED=true
# --- Legacy YAML migration (unused unless you bind-mount /config) ---
# UPS_CONFIG_PATH=/config/ups.yaml
+31 -5
View File
@@ -1,19 +1,45 @@
name: Build and Push Docker Image name: CI - Lint, Test, and Push Docker Image
# This workflow is manually triggered from the "Actions" tab in GitHub.
# It requires a single input: the version tag of the flood repository to use.
on: on:
push: push:
branches: [ "main" ] branches: [ "main" ]
pull_request: pull_request:
branches: [ "main" ] branches: [ "main" ]
schedule: schedule:
- cron: '0 0 3 * *' # This runs at midnight on the first day of every month - cron: '0 0 3 * *' # monthly refresh
workflow_dispatch: workflow_dispatch:
jobs: jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install coverage
- name: Ruff lint
run: ruff check .
- name: Run pytest with coverage
run: |
coverage run -m pytest tests/ -v
coverage report
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: lint-and-test
if: github.event_name != 'pull_request'
steps: steps:
- name: Set Image name - name: Set Image name
run: | run: |
+33 -8
View File
@@ -1,23 +1,48 @@
FROM python:3.12-slim # syntax=docker/dockerfile:1.6
FROM python:3.12.7-slim AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /build
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install --upgrade pip && pip wheel --wheel-dir /wheels -r requirements.txt
FROM python:3.12.7-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
REDIS_URL=redis://redis:6379/0
WORKDIR /app WORKDIR /app
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends apcupsd curl \
build-essential \ && rm -rf /var/lib/apt/lists/* \
apcupsd \ && groupadd --system --gid 10001 appuser \
&& rm -rf /var/lib/apt/lists/* && useradd --system --uid 10001 --gid 10001 --home /app --shell /usr/sbin/nologin appuser
COPY --from=builder /wheels /wheels
COPY requirements.txt ./ COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-index --find-links=/wheels -r requirements.txt \
&& rm -rf /wheels
COPY app ./app COPY app ./app
RUN chown -R appuser:appuser /app
USER appuser
EXPOSE 8000 EXPOSE 8000
ENV REDIS_URL=redis://redis:6379/0 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -fsS http://localhost:8000/healthz || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+97 -121
View File
@@ -1,148 +1,124 @@
# APC UPS Dashboard # APC UPS Dashboard
A lightweight FastAPI + Redis based dashboard for multiple APC UPS devices using the `apcaccess` CLI (apcupsd Network Information Server mode). Shows real-time metrics, lightweight charts, and maintains 7 days of historical snapshots. A production-ready FastAPI + Redis dashboard for monitoring multiple APC UPS devices via `apcupsd` NIS. Single-admin auth (argon2 + session cookies), CSRF, rate limiting, security headers, SSE live updates, 7-day history, connection-health tracking, battery degradation trends, HTML email alerts with batching and silent-hours, and Prometheus metrics.
## Features ## Features
- Multiple UPS managed dynamically (stored in Redis; add/update/delete via API/UI)
- Polling via `apcaccess` CLI against apcupsd NIS (default port 3551)
- Connection test: TCP port reachability (no protocol parsing)
- Real-time dashboard (Server Sent Events) updating key metrics
- 7-day retention of snapshots in Redis lists
- Simple Chart.js load percentage sparkline
- Docker & docker-compose deployment (image bundles apcupsd + apcaccess)
- SMTP alerting (high load, low battery %, on battery, low runtime) with cooldown
## Configuration **Monitoring**
- Multiple UPS managed dynamically via UI/REST (stored in Redis)
- Real-time dashboard with SSE, fleet-overview panel, per-UPS state indicators
- 7-day snapshot history, per-minute watts averages, daily energy + cost
- Connection-health tracker — COMMLOST detection after 3× interval, recovery INFO alerts
- Battery-health sampling (1/min) with 7-day trend + decline %
- CSV export (history/events/energy), event log, alert management + ack
Configuration is persisted in Redis (key `ups:config:json`). Use the web UI or the REST API to manage UPS entries and SMTP settings. A legacy `config/ups.yaml` (or path set via `UPS_CONFIG_PATH`) is imported once on first startup if Redis has no configuration; after migration the file is no longer written or read. **Alerts (email-only)**
- Severity taxonomy: CRITICAL (ONBATT/COMMLOST/RUNTIME_LOW/BCHARGE_LOW), WARNING (LOAD_HIGH/REPLACEBATT/SELFTEST_FAIL/TEMP_HIGH/XFER_BURST/VOLT_DEV), INFO (REACHABLE/LINE_RESTORED)
- Coalesced HTML emails (one per UPS per poll cycle), severity-colored
- 30-min per-message cooldown, silent-hours deferral (CRITICAL always delivered), retries via tenacity
- Test-email button and optional daily summary
UPS fields: **Security & ops**
- name (unique) - Single-admin auth (argon2 password, signed session cookie, double-submit CSRF)
- host (apcupsd server hostname / IP) - SSRF host validation (rejects loopback/link-local; private IPs gated by `ALLOW_PRIVATE_IPS`)
- port (default 3551) - SMTP password **only** from env — never persisted to Redis
- interval_seconds (polling interval) - Subprocess timeout on `apcaccess` (10s), rate-limiting on auth/config, security headers + CSP
- Optional alert thresholds: alert_loadpct_high, alert_bcharge_low, alert_on_battery, alert_runtime_low_minutes - Non-root container (UID 10001), pinned `python:3.12.7-slim` multi-stage build
- `/healthz`, `/readyz`, `/metrics` (Prometheus), JSON structured logs with request-ID correlation
- GitHub Actions pipeline runs ruff + pytest before building/publishing the image
SMTP fields (optional): host, port, username, password (or env `SMTP_PASSWORD`), use_tls, use_ssl, from_addr, to_addrs[], subject_prefix. ## Quick start (docker-compose)
Environment secret: set `SMTP_PASSWORD` instead of storing cleartext.
Alert suppression: identical alert per UPS suppressed for 30 minutes (cooldown).
### apcupsd Server Requirements
Your remote APC UPS hosts must be running `apcupsd` with the Network Information Server (NIS) enabled. Typical steps (on Linux):
1. Install apcupsd (example for Debian/Ubuntu):
```bash
sudo apt-get install apcupsd
```
2. Edit `/etc/apcupsd/apcupsd.conf` and confirm at least:
```
UPSTYPE usb # or 'net' / 'snmp' depending on your setup
DEVICE # usually blank for USB
NISIP 0.0.0.0 # listen on all interfaces (restrict in firewalled env)
NISPORT 3551 # must match the configured port (default 3551)
NETSERVER on # enable network server
# Optional: restrict access (recommended)
ACCESS 192.168.1.0/24 # Only allow your monitoring subnet (supported on some builds)
```
3. Restart service:
```bash
sudo systemctl restart apcupsd
```
4. Test locally:
```bash
apcaccess status
nc -vz <server-ip> 3551
```
If you receive connectivity errors in logs:
- Verify `NETSERVER on` is set.
- Check host firewall (e.g., `ufw allow 3551/tcp`).
- Confirm the port is correct and reachable from the container network.
- Test manually:
```bash
nc -vz <server-ip> 3551
apcaccess -h <server-ip>:3551 status
```
### Connection Testing Logic
Simplified: only a raw TCP connect test. If the port is reachable it's reported as success. Polling uses the `apcaccess` CLI for data collection.
## Run (docker-compose)
```bash ```bash
cp .env.example .env
# Required in production — edit .env:
# SESSION_SECRET=<run: python -c "import secrets; print(secrets.token_urlsafe(48))">
# SMTP_PASSWORD=<your SMTP password, if using alerts>
docker compose up --build docker compose up --build
``` ```
Visit http://localhost:8000
### Persistence Open http://localhost:10280 — the app redirects to `/setup` where you create the admin account on first boot. Subsequent visits require login.
Configuration and historical metrics live in Redis. The provided `docker-compose.yml` now mounts a named volume (`redis-data`) at `/data` inside the Redis container and enables Append Only File (AOF) with `--appendonly yes`. ## Environment variables
Data will persist across `docker compose down` / `up` cycles as long as you do NOT remove the volume. To explicitly remove all persisted configuration and history you must prune the volume: | Var | Required | Default | Notes |
|---|---|---|---|
| `SESSION_SECRET` | yes (prod) | ephemeral | 32+ random bytes, signs session cookies |
| `ADMIN_USERNAME` | no | `admin` | |
| `ADMIN_PASSWORD_HASH` | no | unset | Skip setup wizard by pre-seeding an argon2 hash |
| `REDIS_URL` | no | `redis://redis:6379/0` | Supports `redis://:pw@host:port/db` |
| `SMTP_PASSWORD` | no | unset | Read only from env, never stored |
| `ALLOW_PRIVATE_IPS` | no | `true` | Set false to block RFC1918 UPS hosts |
| `TRUST_PROXY` | no | `false` | Enable Secure cookies + HSTS when behind HTTPS proxy |
| `LOG_LEVEL` | no | `INFO` | DEBUG/INFO/WARNING/ERROR |
| `SESSION_MAX_AGE_SECONDS` | no | `1209600` | 14 days |
| `RATE_LIMIT_ENABLED` | no | `true` | Disable in tests |
| `TZ` | no | `UTC` | |
Generate values:
```bash
# SESSION_SECRET
python -c "import secrets; print(secrets.token_urlsafe(48))"
# ADMIN_PASSWORD_HASH (optional pre-seed)
python -c "from passlib.hash import argon2; print(argon2.hash('mysecret'))"
```
## apcupsd server requirements
Your remote APC UPS hosts must run `apcupsd` with the Network Information Server (NIS) enabled:
1. Install: `sudo apt-get install apcupsd`
2. Edit `/etc/apcupsd/apcupsd.conf`:
```
UPSTYPE usb
NISIP 0.0.0.0
NISPORT 3551
NETSERVER on
```
3. Restart: `sudo systemctl restart apcupsd`
4. Test: `apcaccess status` and `nc -vz <server-ip> 3551`
## Development
```bash ```bash
docker compose down python3.12 -m venv .venv && . .venv/bin/activate
docker volume rm apcupsd-client_redis-data # volume name may be prefixed by folder/project pip install -r requirements.txt
# Run tests (uses fakeredis)
pytest tests/
# With coverage
coverage run -m pytest tests/ && coverage report
# Lint
ruff check .
# Local dev server (needs apcaccess binary + REDIS_URL env)
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
``` ```
If you had been losing configuration previously, ensure you pulled the updated compose file and that the `redis` service includes: ## Persistence
```yaml
redis:
volumes:
- redis-data:/data
command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec"]
```
You can inspect Redis persistence files locally by running:
Config and history live in Redis via an AOF-backed `redis-data` volume. To wipe state:
```bash ```bash
docker compose exec redis ls -lh /data docker compose down && docker volume rm apcupsd-client_redis-data
``` ```
If you want to enforce periodic RDB snapshots as well, you can leave default save settings (remove the `--save ""` override). The current configuration uses AOF every second for a balance of durability and write performance. ## API (summary)
## Data Storage | Path | Auth | Notes |
Redis stores: |---|---|---|
- Latest snapshot hash: `ups:snap:<name>` | `/` (dashboard), `/config`, `/events`, `/alerts`, `/settings` | session | Jinja pages |
- History list (JSON {ts,data}): `ups:hist:<name>` | `/login`, `/setup`, `/logout` | open/session | |
- Per-minute watts averages: `ups:watts:permin:<name>` | `/healthz`, `/readyz`, `/metrics` | open | Prom metrics on `/metrics` |
- Energy (watt-seconds) daily totals: `ups:energy:<name>:YYYYMMDD` | `GET /api/stream` | session | SSE snapshots |
- Events list: `ups:event:list:<name>` | `GET /api/ups`, `/api/ups/{name}/{status\|history\|events\|energy\|health\|battery_health}` | session | |
- Recent alerts: `ups:alerts:recent:<name>` | `GET /api/ups/fleet/overview` | session | Aggregate fleet summary |
- Voltage deviation samples: `ups:volt:dev:samples:<name>` | `GET /api/ups/{name}/export?format=csv&kind=history\|events\|energy&since_days=N` | session | Streaming CSV |
| `GET /api/events`, `/api/alerts`, `/api/alerts/active` | session | |
A pruning task runs hourly removing entries older than 7 days. | `POST /api/alerts/{id}/ack` | session+CSRF | |
| `GET/POST/PUT/DELETE /api/config/{ups,smtp,ui}/...` | session (+CSRF for writes) | Rate-limited |
## Extending
- Add more charts: query `/api/ups/<name>/history`
- Add gauges: integrate a JS gauge lib in `dashboard.html`
- Alerts: create background task checking thresholds
## License ## License
MIT MIT
## Security & Hardening Notes
| Area | Current | Recommendation |
|------|---------|---------------|
| Authentication | None (open dashboard) | Add reverse proxy auth or FastAPI auth if exposed beyond LAN |
| Config Storage | Redis (JSON) | Protect Redis with auth / network policy |
| Network to apcupsd | Plain TCP | Use network segmentation / firewall; protocol has no encryption |
| Redis | No auth configured | Enable AUTH / TLS if crossing trust boundaries |
| Input Validation | Pydantic for config schema | Add stricter hostname/IP validation if multi-tenant |
| Dependency Versions | Pinned | Review periodically for CVEs |
| Logging | Polling errors logged | Avoid logging secrets; sanitize future additions |
### Additional Notes
- Legacy YAML migration (one-time) to Redis; file no longer updated afterward.
- Dynamic poller reconciles tasks on config change (no restart needed).
- Alert cooldown prevents email flood.
- AOF-based Redis persistence keeps configuration across container rebuilds.
### Suggested Future Enhancements
- Optional Basic Auth / OIDC for web UI.
- Rate limiting on config mutation endpoints.
- CSRF protection if cookies/session auth added later.
- Health endpoint (`/healthz`) returning Redis + config status.
- Structured logging (JSON) for production observability.
+275 -143
View File
@@ -1,105 +1,52 @@
"""Alert evaluation, coalescing, cooldown, silent-hours, and dispatch."""
from __future__ import annotations from __future__ import annotations
import smtplib
import ssl import hashlib
import os import logging
import time import time
from email.message import EmailMessage from dataclasses import asdict, dataclass
from typing import Dict, Any, List from datetime import datetime
from .config import load_config, SMTPConfig, UPSConfig from typing import Any
from .config import SMTPConfig, UPSConfig, load_config
from .notifications.email import EmailSendError, send_alert_email
from .storage import get_redis from .storage import get_redis
logger = logging.getLogger(__name__)
ALERT_COOLDOWN_SECONDS = 1800 # 30 minutes per distinct alert per UPS ALERT_COOLDOWN_SECONDS = 1800 # 30 minutes per distinct alert per UPS
REDIS_ALERT_KEY_PREFIX = "ups:alert:last:" 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"} STATUS_ON_BATTERY_KEYWORDS = {"ONBATT", "ON BATTERY"}
SELFTEST_FAIL_KEYWORDS = {"FAIL", "BADBATT"}
SEV_CRITICAL = "CRITICAL"
SEV_WARNING = "WARNING"
SEV_INFO = "INFO"
TEMP_HIGH_DEFAULT_C = 50.0
def evaluate_alerts(ups_cfg: UPSConfig, snapshot: Dict[str, Any]) -> List[str]: @dataclass
messages: List[str] = [] class Alert:
# Load % high severity: str
if ups_cfg.alert_loadpct_high is not None: message: str
loadpct = _to_float(snapshot.get('LOADPCT')) code: str
if loadpct is not None and loadpct >= ups_cfg.alert_loadpct_high: ups: str
messages.append( ts: int
"Load percentage high: "
f"{loadpct}% >= {ups_cfg.alert_loadpct_high}%" def hash(self) -> str:
) h = hashlib.sha1(f"{self.ups}|{self.code}|{self.message}".encode()).hexdigest()
# Battery charge low return h[:16]
if ups_cfg.alert_bcharge_low is not None:
bcharge = _to_float(snapshot.get('BCHARGE')) def as_dict(self) -> dict[str, Any]:
if bcharge is not None and bcharge <= ups_cfg.alert_bcharge_low: d = asdict(self)
messages.append( d["id"] = self.hash()
"Battery charge low: " return d
f"{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"
)
# Extended alerts based on global UI flags
cfg = load_config()
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())
onbatt_count = 0
for ev in events:
parts = ev.split('|')
if len(parts) >= 3:
try:
ts = int(parts[0])
except ValueError:
continue
if now - ts > 3600:
continue
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"
)
# Voltage deviation: track LINEV vs nominal, average deviation window
if ui.enable_voltage_deviation_alert:
linev = _extract_leading_number(str(snapshot.get('LINEV', '')))
nom = _extract_leading_number(
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}"
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)
)
if avg_dev > 8.0 and len(samples) >= 10:
messages.append(
"High average voltage deviation: "
f"{avg_dev:.1f}% over {len(samples)} samples"
)
except Exception:
pass
return messages
def _to_float(val) -> float | None: def _to_float(val) -> float | None:
@@ -111,66 +58,251 @@ def _to_float(val) -> float | None:
def _extract_leading_number(s: str) -> float | None: def _extract_leading_number(s: str) -> float | None:
try: try:
part = s.strip().split()[0] return float(s.strip().split()[0])
return float(part)
except Exception: except Exception:
return None return None
def _cooldown_key(ups_name: str, msg: str) -> str: def evaluate_alerts(ups_cfg: UPSConfig, snapshot: dict[str, Any]) -> list[Alert]:
return f"{REDIS_ALERT_KEY_PREFIX}{ups_name}:{hash(msg)}" 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))
def send_alert_email(smtp_cfg: SMTPConfig, ups_name: str, messages: List[str]): status = str(snapshot.get("STATUS", "")).upper()
password = smtp_cfg.password or os.environ.get('SMTP_PASSWORD') # ONBATT
if not smtp_cfg.to_addrs: if ups_cfg.alert_on_battery and any(k in status for k in STATUS_ON_BATTERY_KEYWORDS):
return add(SEV_CRITICAL, "ONBATT", f"UPS on battery: status={status}")
subject = f"{smtp_cfg.subject_prefix} {ups_name} alert" # Runtime low
body = "\n".join(messages) if ups_cfg.alert_runtime_low_minutes is not None:
msg = EmailMessage() runtime = _extract_leading_number(str(snapshot.get("TIMELEFT", "")))
msg['Subject'] = subject if runtime is not None and runtime <= ups_cfg.alert_runtime_low_minutes:
msg['From'] = smtp_cfg.from_addr or (smtp_cfg.username or 'ups@example') add(
msg['To'] = ", ".join(smtp_cfg.to_addrs) SEV_CRITICAL,
msg.set_content(body) "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 = _extract_leading_number(str(snapshot.get("BCHARGE", "")))
if bcharge is not None and bcharge <= ups_cfg.alert_bcharge_low:
add(
SEV_CRITICAL,
"BCHARGE_LOW",
f"Battery charge low: {bcharge}% <= {ups_cfg.alert_bcharge_low}%",
)
# 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}%",
)
# 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",
)
if smtp_cfg.use_ssl: # Extended alerts based on UI flags
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() cfg = load_config()
if not cfg.smtp: ui = cfg.ui if hasattr(cfg, "ui") else None
return if ui:
msgs = evaluate_alerts(ups_cfg, snapshot) r = get_redis()
if not msgs: if ui.enable_transfer_burst_alert:
events = r.lrange(f"ups:event:list:{name}", 0, 200)
onbatt_count = 0
for ev in events:
parts = ev.split("|")
if len(parts) >= 3:
try:
ts = int(parts[0])
except ValueError:
continue
if now - ts > 3600:
continue
if parts[1] == "STATUS" and "ONBATT" in parts[2]:
onbatt_count += 1
if onbatt_count >= 3:
add(
SEV_WARNING,
"XFER_BURST",
f"Frequent battery events: {onbatt_count} in last hour",
)
if ui.enable_voltage_deviation_alert:
linev = _extract_leading_number(str(snapshot.get("LINEV", "")))
nom = _extract_leading_number(
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:{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))
if avg_dev > 8.0 and len(samples) >= 10:
add(
SEV_WARNING,
"VOLT_DEV",
f"High voltage deviation: {avg_dev:.1f}% over {len(samples)} samples",
)
except Exception:
pass
return msgs
def _cooldown_key(alert: Alert) -> str:
return f"{REDIS_ALERT_KEY_PREFIX}{alert.ups}:{alert.hash()}"
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
def _persist_history(alerts: list[Alert]) -> None:
if not alerts:
return return
r = get_redis() r = get_redis()
to_send: List[str] = []
now = int(time.time())
for m in msgs:
key = _cooldown_key(ups_cfg.name, m)
if not r.get(key):
to_send.append(m)
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() pipe = r.pipeline()
for m in to_send: for a in alerts:
pipe.lpush(recent_key, f"{now}|{m}") pipe.lpush(
pipe.ltrim(recent_key, 0, 49) # keep last 50 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() pipe.execute()
send_alert_email(cfg.smtp, ups_cfg.name, to_send)
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 a in all_alerts:
key = _cooldown_key(a)
if not r.get(key):
fresh.append(a)
r.set(key, now, ex=ALERT_COOLDOWN_SECONDS)
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 from __future__ import annotations
import asyncio import asyncio
import shutil
import logging import logging
from typing import Dict, Any import shutil
from typing import Any
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
APCACCESS_BIN = shutil.which('apcaccess') or 'apcaccess' APCACCESS_BIN = shutil.which('apcaccess') or 'apcaccess'
APCACCESS_TIMEOUT_SECONDS = 10.0
class APCStatusError(Exception): class APCStatusError(Exception):
pass 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. """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( proc = await asyncio.create_subprocess_exec(
APCACCESS_BIN, APCACCESS_BIN,
@@ -25,14 +27,26 @@ async def fetch_status(host: str, port: int) -> Dict[str, Any]:
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=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: if proc.returncode != 0:
err_txt = stderr.decode(errors="replace").strip() err_txt = stderr.decode(errors="replace").strip()
raise APCStatusError( raise APCStatusError(
f'apcaccess exit {proc.returncode}: {err_txt}' f'apcaccess exit {proc.returncode}: {err_txt}'
) )
text = stdout.decode(errors='replace') text = stdout.decode(errors='replace')
data: Dict[str, Any] = {} data: dict[str, Any] = {}
for line in text.splitlines(): for line in text.splitlines():
if ':' not in line: if ':' not in line:
continue continue
@@ -40,7 +54,8 @@ async def fetch_status(host: str, port: int) -> Dict[str, Any]:
key = k.strip() key = k.strip()
val = v.strip() val = v.strip()
data[key] = val 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: if 'UPSNAME' not in data and 'NAME' in data:
data['UPSNAME'] = data['NAME'] data['UPSNAME'] = data['NAME']
if 'MODEL' in data: 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
+102 -30
View File
@@ -1,55 +1,116 @@
from __future__ import annotations from __future__ import annotations
from pydantic import BaseModel, Field
from typing import List, Optional
from pathlib import Path
import os
CONFIG_PATH = Path( import ipaddress
os.environ.get("UPS_CONFIG_PATH", "/config/ups.yaml") import re
) # legacy path for migration 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): class UPSConfig(BaseModel):
name: str = Field(..., description="Friendly UPS name") name: str = Field(..., description="Friendly UPS name")
host: str = Field(..., description="apcupsd NIS host/IP") host: str = Field(..., description="apcupsd NIS host/IP")
port: int = Field(3551, description="apcupsd NIS port") port: int = Field(3551, ge=1, le=65535, description="apcupsd NIS port")
interval_seconds: int = Field(30, description="Polling interval") interval_seconds: int = Field(
# Alert thresholds (any optional) 30, ge=5, le=3600, description="Polling interval"
alert_loadpct_high: Optional[float] = Field(
None, description="Trigger if LOADPCT >= value"
) )
alert_bcharge_low: Optional[float] = Field( # Alert thresholds (any optional)
None, description="Trigger if BCHARGE <= value" 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( alert_on_battery: bool = Field(
False, description="Trigger when STATUS indicates on battery" False, description="Trigger when STATUS indicates on battery"
) )
alert_runtime_low_minutes: Optional[float] = Field( alert_runtime_low_minutes: float | None = Field(
None, description="Trigger if TIMELEFT <= minutes" 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): class SMTPConfig(BaseModel):
host: str = Field(..., description="SMTP server host/IP") host: str = Field(..., description="SMTP server host/IP")
port: int = Field(..., description="SMTP port") port: int = Field(..., ge=1, le=65535, description="SMTP port")
username: Optional[str] = Field(None) username: str | None = Field(None)
password: Optional[str] = Field( # password is never persisted in Redis; read from env SMTP_PASSWORD
None, description="Plain password or set via env SMTP_PASSWORD"
)
use_tls: bool = Field(False, description="STARTTLS if true") use_tls: bool = Field(False, description="STARTTLS if true")
use_ssl: bool = Field(False, description="SSL (smtplib.SMTP_SSL) if true") use_ssl: bool = Field(False, description="SSL (smtplib.SMTP_SSL) if true")
from_addr: Optional[str] = Field(None, description="From email address") from_addr: str | None = Field(None, description="From email address")
to_addrs: List[str] = Field( to_addrs: list[str] = Field(
default_factory=list, description="Recipient list" default_factory=list, description="Recipient list"
) )
subject_prefix: str = Field("[UPS]", description="Subject prefix") 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 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): class UIConfig(BaseModel):
show_events: bool = True show_events: bool = True
show_energy: bool = False show_energy: bool = False
@@ -60,15 +121,26 @@ class AppConfig(BaseModel):
allow_resize: bool = True allow_resize: bool = True
enable_transfer_burst_alert: bool = False enable_transfer_burst_alert: bool = False
enable_voltage_deviation_alert: bool = False enable_voltage_deviation_alert: bool = False
energy_cost_per_kwh: float = 0.0
ui: UIConfig = UIConfig()
class AppConfig(BaseModel):
ups: list[UPSConfig] = Field(default_factory=list)
smtp: SMTPConfig | None = None
ui: UIConfig = Field(default_factory=UIConfig)
# Backward-compat alias: older code references AppConfig.UIConfig
UIConfig: ClassVar[type[UIConfig]] = UIConfig
_cached: AppConfig | None = None _cached: AppConfig | None = None
def load_config(path: Path = CONFIG_PATH) -> AppConfig: 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 from .config_store import load_config_redis
global _cached global _cached
if _cached: if _cached:
+39 -86
View File
@@ -1,27 +1,20 @@
from __future__ import annotations from __future__ import annotations
from typing import List, Optional, Dict, Any
import asyncio import asyncio
import logging import logging
import socket import socket
from typing import Any
try:
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, Field, ValidationError
except ImportError:
# Fallback for environments without pydantic
BaseModel = object
def Field(default=None, **kwargs): from .config import AppConfig, SMTPConfig, UIConfig, UPSConfig
return default
ValidationError = ValueError
from .config import UPSConfig, SMTPConfig, AppConfig
from .config_store import load_config_redis, save_config_redis from .config_store import load_config_redis, save_config_redis
from .settings import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Incremented every time configuration is modified so # Incremented every time configuration is modified so SSE clients can
# SSE clients can detect changes # detect changes
_config_version: int = 0 _config_version: int = 0
@@ -29,34 +22,25 @@ def get_config_version() -> int:
return _config_version return _config_version
class ConfigWriteError(Exception): # retained for API compatibility class ConfigWriteError(Exception):
"""Raised when configuration cannot be written (kept for compatibility).""" """Raised when configuration cannot be written (kept for compatibility)."""
class UPSConfigUpdate(BaseModel): class UPSConfigUpdate(BaseModel):
"""Model for updating UPS configuration""" """Model for updating UPS configuration."""
name: Optional[str] = Field(None, description="Friendly UPS name") name: str | None = None
host: Optional[str] = Field(None, description="apcupsd NIS host/IP") host: str | None = None
port: Optional[int] = Field(None, description="apcupsd NIS port") port: int | None = Field(None, ge=1, le=65535)
interval_seconds: Optional[int] = Field( interval_seconds: int | None = Field(None, ge=5, le=3600)
None, description="Polling interval" alert_loadpct_high: float | None = Field(None, ge=0, le=100)
) alert_bcharge_low: float | None = Field(None, ge=0, le=100)
alert_loadpct_high: Optional[float] = Field( alert_on_battery: bool | None = None
None, description="Trigger if LOADPCT >= value" alert_runtime_low_minutes: float | None = Field(None, ge=0)
) alert_itemp_high: float | None = Field(None, ge=0, le=120)
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"
)
class ConfigManager: class ConfigManager:
def __init__(self): def __init__(self) -> None:
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
async def load_config(self) -> AppConfig: async def load_config(self) -> AppConfig:
@@ -67,26 +51,19 @@ class ConfigManager:
async with self._lock: async with self._lock:
save_config_redis(config) save_config_redis(config)
logger.info("Configuration saved to Redis") logger.info("Configuration saved to Redis")
# Invalidate cached global config so subsequent
# load_config() calls see changes
try: try:
from . import config as config_module from . import config as config_module
config_module._cached = None config_module._cached = None
except Exception: # pragma: no cover - defensive except Exception: # pragma: no cover - defensive
logger.debug( logger.debug("Failed to invalidate config cache", exc_info=True)
"Failed to invalidate config cache", exc_info=True
)
# bump config version
global _config_version global _config_version
_config_version += 1 _config_version += 1
async def get_ups_list(self) -> List[UPSConfig]: async def get_ups_list(self) -> list[UPSConfig]:
"""Get list of all UPS configurations"""
config = await self.load_config() config = await self.load_config()
return config.ups return config.ups
async def get_ups(self, name: str) -> Optional[UPSConfig]: async def get_ups(self, name: str) -> UPSConfig | None:
"""Get UPS configuration by name"""
config = await self.load_config() config = await self.load_config()
for ups in config.ups: for ups in config.ups:
if ups.name == name: if ups.name == name:
@@ -94,110 +71,78 @@ class ConfigManager:
return None return None
async def add_ups(self, ups_config: UPSConfig) -> bool: async def add_ups(self, ups_config: UPSConfig) -> bool:
"""Add new UPS configuration"""
config = await self.load_config() 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): if any(ups.name == ups_config.name for ups in config.ups):
raise ValueError( raise ValueError(
f"UPS with name '{ups_config.name}' already exists" f"UPS with name '{ups_config.name}' already exists"
) )
config.ups.append(ups_config) config.ups.append(ups_config)
await self.save_config(config) await self.save_config(config)
# No file cache now
return True return True
async def update_ups(self, name: str, updates: UPSConfigUpdate) -> bool: async def update_ups(self, name: str, updates: UPSConfigUpdate) -> bool:
"""Update existing UPS configuration"""
config = await self.load_config() config = await self.load_config()
ups_index = None ups_index = None
for i, ups in enumerate(config.ups): for i, ups in enumerate(config.ups):
if ups.name == name: if ups.name == name:
ups_index = i ups_index = i
break break
if ups_index is None: if ups_index is None:
return False return False
# Apply updates
ups_dict = config.ups[ups_index].model_dump() ups_dict = config.ups[ups_index].model_dump()
update_dict = updates.model_dump(exclude_none=True) update_dict = updates.model_dump(exclude_none=True)
ups_dict.update(update_dict) ups_dict.update(update_dict)
# Validate updated configuration
try: try:
updated_ups = UPSConfig(**ups_dict) updated_ups = UPSConfig(**ups_dict)
except ValidationError as e: except ValidationError as e:
raise ValueError(f"Invalid configuration: {e}") raise ValueError(f"Invalid configuration: {e}")
config.ups[ups_index] = updated_ups config.ups[ups_index] = updated_ups
await self.save_config(config) await self.save_config(config)
# No file cache now
return True return True
async def delete_ups(self, name: str) -> bool: async def delete_ups(self, name: str) -> bool:
"""Delete UPS configuration"""
config = await self.load_config() config = await self.load_config()
original_count = len(config.ups) original_count = len(config.ups)
config.ups = [ups for ups in config.ups if ups.name != name] config.ups = [ups for ups in config.ups if ups.name != name]
if len(config.ups) == original_count: if len(config.ups) == original_count:
return False # UPS not found return False
await self.save_config(config) await self.save_config(config)
# No file cache now
return True return True
async def get_smtp_config(self) -> Optional[SMTPConfig]: async def get_smtp_config(self) -> SMTPConfig | None:
"""Get SMTP configuration"""
config = await self.load_config() config = await self.load_config()
return config.smtp return config.smtp
async def update_smtp_config(self, smtp_config: SMTPConfig) -> None: async def update_smtp_config(self, smtp_config: SMTPConfig) -> None:
"""Update SMTP configuration"""
config = await self.load_config() config = await self.load_config()
config.smtp = smtp_config config.smtp = smtp_config
await self.save_config(config) await self.save_config(config)
# Clear cached config async def update_ui_config(self, ui: UIConfig) -> None:
from . import config as config_module config = await self.load_config()
config_module._cached = None config.ui = ui
await self.save_config(config)
async def validate_ups_connection( async def validate_ups_connection(
self, ups_config: UPSConfig, timeout: float = 3.0 self, ups_config: UPSConfig, timeout: float = 3.0
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Port-only connectivity test (no protocol / CLI call).""" """Port-only connectivity test (no protocol / CLI call)."""
result: dict[str, Any] = {
result: Dict[str, Any] = {
"success": False, "success": False,
"message": "", "message": "",
"connectivity": {"ok": False, "error": None}, "connectivity": {"ok": False, "error": None},
"protocol": {"ok": False, "error": None}, "protocol": {"ok": False, "error": None},
"data": None, "data": None,
} }
# Raw TCP connectivity test
try: try:
# Use low-level socket to distinguish DNS/timeouts
with socket.create_connection( with socket.create_connection(
(ups_config.host, ups_config.port), timeout=timeout (ups_config.host, ups_config.port), timeout=timeout
): ):
result["connectivity"]["ok"] = True result["connectivity"]["ok"] = True
except Exception as e: # broad to surface any network issue except Exception as e:
result["connectivity"]["error"] = str(e) result["connectivity"]["error"] = str(e)
result["message"] = f"TCP connectivity failed: {e}" result["message"] = f"TCP connectivity failed: {e}"
return result return result
# For port-only test we just mirror connectivity result
if result["connectivity"]["ok"]: if result["connectivity"]["ok"]:
result["protocol"]["ok"] = True result["protocol"]["ok"] = True
result["success"] = True result["success"] = True
@@ -207,5 +152,13 @@ class ConfigManager:
return result 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() 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`` holds JSON: ``{"ups": [...], "smtp": {...}|null, "ui": {...}}``.
Key ups:config:json -> JSON object: {"ups": [...], "smtp": {...}|null} Legacy YAML import runs once if Redis is empty.
Migration:
On first load if redis key missing and legacy YAML present, import it.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Optional
from pathlib import Path
import json import json
import logging import logging
from .storage import get_redis from pathlib import Path
from .config import AppConfig, CONFIG_PATH
import yaml import yaml
from .config import CONFIG_PATH, AppConfig
from .storage import get_redis
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
REDIS_CONFIG_KEY = "ups:config:json" 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(): if not path.exists():
return None return None
try: try:
with path.open() as f: with path.open() as f:
raw = yaml.safe_load(f) or {} raw = yaml.safe_load(f) or {}
raw = _strip_smtp_password(raw)
return AppConfig(**raw) return AppConfig(**raw)
except Exception as e: # pragma: no cover except Exception as e: # pragma: no cover
logger.warning("Failed to import legacy YAML config: %s", e) logger.warning("Failed to import legacy YAML config: %s", e)
@@ -40,14 +48,13 @@ def load_config_redis() -> AppConfig:
data = json.loads(raw) data = json.loads(raw)
except json.JSONDecodeError: except json.JSONDecodeError:
data = {} data = {}
data = _strip_smtp_password(data)
return AppConfig(**data) return AppConfig(**data)
# Migration path
legacy = _load_legacy_yaml(CONFIG_PATH) legacy = _load_legacy_yaml(CONFIG_PATH)
if legacy: if legacy:
save_config_redis(legacy) save_config_redis(legacy)
logger.info("Imported legacy YAML config into Redis") logger.info("Imported legacy YAML config into Redis")
return legacy return legacy
# If nothing exists, create empty scaffold
empty = AppConfig(ups=[], smtp=None) empty = AppConfig(ups=[], smtp=None)
save_config_redis(empty) save_config_redis(empty)
return empty return empty
@@ -55,4 +62,6 @@ def load_config_redis() -> AppConfig:
def save_config_redis(cfg: AppConfig) -> None: def save_config_redis(cfg: AppConfig) -> None:
r = get_redis() 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)
+73 -458
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 from __future__ import annotations
import asyncio import asyncio
import time import logging
from fastapi import FastAPI, Request, HTTPException from contextlib import asynccontextmanager
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from slowapi import _rate_limit_exceeded_handler
import orjson from slowapi.errors import RateLimitExceeded
from .config import load_config, UPSConfig
from .storage import get_latest, get_history from .logging_config import RequestIdMiddleware, configure_logging
from .poller import poll_loop from .poller import cancel_all_tasks, poll_loop
from .config_manager import ( from .rate_limit import limiter
config_manager, from .routes import api_alerts, api_auth, api_config, api_events, api_health, api_ups, pages, sse
UPSConfigUpdate, from .security import SecurityHeadersMiddleware
ConfigWriteError, from .settings import settings
get_config_version,
logger = logging.getLogger(__name__)
_background_tasks: list[asyncio.Task] = []
@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."
) )
from .storage import get_redis task = asyncio.create_task(poll_loop())
from .apc_cli import fetch_status, APCStatusError _background_tasks.append(task)
logger.info("Poller started")
app = FastAPI(title="APC UPS Dashboard")
app.mount('/static', StaticFiles(directory='app/static'), name='static')
templates = Jinja2Templates(directory='app/templates')
@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: try:
val = float(str(raw_val).split()[0]) yield
except Exception: finally:
continue logger.info("Shutting down: cancelling background tasks")
out.append({'ts': item.get('ts'), 'value': val}) for t in _background_tasks:
return out t.cancel()
for t in _background_tasks:
@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:
try: try:
ts_s, kind, rest = item.split('|', 2) await t
parsed.append({ except (asyncio.CancelledError, Exception):
'ts': int(ts_s),
'type': kind,
'detail': rest
})
continue
except Exception:
pass pass
parsed.append({'raw': item}) await cancel_all_tasks()
return parsed logger.info("Shutdown complete")
@app.get('/api/ups/{ups_name}/energy') def create_app() -> FastAPI:
async def ups_energy(ups_name: str): application = FastAPI(title="APC UPS Dashboard", lifespan=lifespan)
"""Return today's accumulated energy (approx kWh) if available.""" application.state.limiter = limiter
r = get_redis() application.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
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}
# 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') application.mount(
async def ups_watts_per_minute(ups_name: str): "/static", StaticFiles(directory="app/static"), name="static"
"""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)
) )
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 = create_app()
@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"}
+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 from __future__ import annotations
import asyncio import asyncio
import time import json
import logging 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 .config import load_config
from .apc_cli import fetch_status, APCStatusError from .storage import get_redis, prune_old, store_snapshot
from .storage import store_snapshot, prune_old, get_redis
from .alerts import process_alerts
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_ACTIVE_TASKS: dict[str, asyncio.Task] = {} _ACTIVE_TASKS: dict[str, asyncio.Task] = {}
_RELOADER_LOCK = asyncio.Lock() _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): async def _poll_one(ups):
r = get_redis() r = get_redis()
@@ -21,7 +118,6 @@ async def _poll_one(ups):
try: try:
data = await fetch_status(ups.host, ups.port) data = await fetch_status(ups.host, ups.port)
data['UPSNAME'] = ups.name data['UPSNAME'] = ups.name
# Derived metrics
try: try:
loadpct = float(str(data.get('LOADPCT', '0')).split()[0]) loadpct = float(str(data.get('LOADPCT', '0')).split()[0])
except Exception: except Exception:
@@ -33,19 +129,15 @@ async def _poll_one(ups):
pass pass
if nompower and loadpct >= 0: if nompower and loadpct >= 0:
watts = nompower * loadpct / 100.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}" 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() timeleft_raw = str(data.get('TIMELEFT', '')).strip()
try: try:
runtime_min = float(timeleft_raw.split()[0]) runtime_min = float(timeleft_raw.split()[0])
data['RUNTIME_MINUTES'] = f"{runtime_min:.1f}" data['RUNTIME_MINUTES'] = f"{runtime_min:.1f}"
except Exception: except Exception:
pass pass
# Event detection (status changes, last transfer changes) wall_ts = int(time.time())
# Reuse redis handle r
now_ts = asyncio.get_event_loop().time()
wall_ts = int(now_ts)
status_key = f"ups:event:status:last:{ups.name}" status_key = f"ups:event:status:last:{ups.name}"
lastxfer_key = f"ups:event:lastxfer:last:{ups.name}" lastxfer_key = f"ups:event:lastxfer:last:{ups.name}"
events_list_key = f"ups:event:list:{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: if prev_status != status_now and status_now:
r.set(status_key, status_now) r.set(status_key, status_now)
r.lpush(events_list_key, f"{wall_ts}|STATUS|{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() lastxfer_now = str(data.get('LASTXFER', '')).strip()
prev_lastxfer = r.get(lastxfer_key) prev_lastxfer = r.get(lastxfer_key)
if lastxfer_now and lastxfer_now != prev_lastxfer: if lastxfer_now and lastxfer_now != prev_lastxfer:
r.set(lastxfer_key, lastxfer_now) r.set(lastxfer_key, lastxfer_now)
r.lpush(events_list_key, f"{wall_ts}|XFER|{lastxfer_now}") r.lpush(events_list_key, f"{wall_ts}|XFER|{lastxfer_now}")
# Trim events
r.ltrim(events_list_key, 0, max_events - 1) r.ltrim(events_list_key, 0, max_events - 1)
# Energy accumulation (watt-seconds)
if 'DERIVED_WATTS' in data: if 'DERIVED_WATTS' in data:
try: try:
watts = float(data['DERIVED_WATTS']) watts = float(data['DERIVED_WATTS'])
day_str = time.strftime('%Y%m%d') day_str = time.strftime('%Y%m%d')
energy_key = f"ups:energy:{ups.name}:{day_str}" energy_key = f"ups:energy:{ups.name}:{day_str}"
# increment by watts * interval_seconds (approx)
r.incrbyfloat(energy_key, watts * ups.interval_seconds) r.incrbyfloat(energy_key, watts * ups.interval_seconds)
r.expire(energy_key, 3 * 24 * 3600) r.expire(energy_key, 3 * 24 * 3600)
# Per-minute accumulation
minute = time.strftime('%Y%m%d%H%M') minute = time.strftime('%Y%m%d%H%M')
# Running sum and count in a hash
mb = r.hgetall(minute_bucket_key) mb = r.hgetall(minute_bucket_key)
if not mb or mb.get('minute') != minute: 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: try:
avg = float(mb['sum']) / max( avg = float(mb['sum']) / max(1, int(mb['count']))
1, int(mb['count']) r.lpush(series_key, f"{mb['minute']}|{avg:.2f}")
)
r.lpush(
series_key,
f"{mb['minute']}|{avg:.2f}"
)
# keep up to 24h of minutes
r.ltrim(series_key, 0, 1439) r.ltrim(series_key, 0, 1439)
except Exception: except Exception:
pass pass
r.hset( r.hset(
minute_bucket_key, minute_bucket_key,
mapping={ mapping={'minute': minute, 'sum': watts, 'count': 1},
'minute': minute,
'sum': watts,
'count': 1,
},
) )
r.expire(minute_bucket_key, 26 * 3600) r.expire(minute_bucket_key, 26 * 3600)
else: else:
@@ -121,36 +199,47 @@ async def _poll_one(ups):
except Exception: except Exception:
pass pass
await store_snapshot(ups.name, data) await store_snapshot(ups.name, data)
_record_poll_success(ups.name, data, ups.interval_seconds)
process_alerts(ups, data) process_alerts(ups, data)
except asyncio.CancelledError:
raise
except Exception as e: except Exception as e:
reason = str(e) if not isinstance(e, APCStatusError) else str(e)
if isinstance(e, APCStatusError): if isinstance(e, APCStatusError):
logger.warning("apcaccess error for %s: %s", ups.name, e) logger.warning("apcaccess error for %s: %s", ups.name, e)
else: else:
logger.warning("Polling error for %s: %s", ups.name, e) 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) await asyncio.sleep(ups.interval_seconds)
async def _reconcile_tasks(): 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: async with _RELOADER_LOCK:
cfg = load_config() cfg = load_config()
current_names = {u.name for u in cfg.ups} current = {u.name: (u.host, u.port, u.interval_seconds) for u in cfg.ups}
# cancel removed # cancel removed or changed
for name in list(_ACTIVE_TASKS.keys()): for name in list(_ACTIVE_TASKS.keys()):
if name not in current_names: if name not in current:
_ACTIVE_TASKS[name].cancel() _ACTIVE_TASKS[name].cancel()
del _ACTIVE_TASKS[name] del _ACTIVE_TASKS[name]
# add new
for ups in cfg.ups: for ups in cfg.ups:
if ups.name not in _ACTIVE_TASKS: if ups.name not in _ACTIVE_TASKS:
_ACTIVE_TASKS[ups.name] = asyncio.create_task(_poll_one(ups)) _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(): async def poll_loop():
# initial reconcile
await _reconcile_tasks() await _reconcile_tasks()
async def prune_loop(): async def prune_loop():
@@ -162,19 +251,13 @@ async def poll_loop():
await asyncio.sleep(3600) await asyncio.sleep(3600)
async def config_watch_loop(): async def config_watch_loop():
"""Periodically re-read config to capture UPS CRUD changes."""
last_fingerprint = None last_fingerprint = None
while True: while True:
try: try:
cfg = load_config() cfg = load_config()
fingerprint = tuple( fingerprint = tuple(
sorted( sorted(
( (u.name, u.host, u.port, u.interval_seconds)
u.name,
u.host,
u.port,
u.interval_seconds,
)
for u in cfg.ups for u in cfg.ups
) )
) )
@@ -188,5 +271,5 @@ async def poll_loop():
await asyncio.gather( await asyncio.gather(
prune_loop(), prune_loop(),
config_watch_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 { class ConfigManager {
constructor() { constructor() { this.confirmCallback = null; this.init(); }
this.init();
}
init() { init() { this.bindEvents(); this.loadUPSList(); }
this.bindEvents();
this.loadUPSList();
}
bindEvents() { bindEvents() {
// Add UPS button document.getElementById('add-ups-btn').addEventListener('click', () => this.showUPSModal());
document.getElementById('add-ups-btn').addEventListener('click', () => {
this.showUPSModal();
});
// Modal close buttons
document.querySelectorAll('.close').forEach(close => { document.querySelectorAll('.close').forEach(close => {
close.addEventListener('click', (e) => { close.addEventListener('click', (e) => this.closeModal(e.target.closest('.modal')));
this.closeModal(e.target.closest('.modal'));
}); });
});
// UPS form submission
document.getElementById('ups-form').addEventListener('submit', (e) => { document.getElementById('ups-form').addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault(); this.saveUPS();
this.saveUPS();
}); });
document.getElementById('test-connection-btn').addEventListener('click', () => this.testConnection());
// Test connection button
document.getElementById('test-connection-btn').addEventListener('click', () => {
this.testConnection();
});
// Cancel button
document.getElementById('cancel-btn').addEventListener('click', () => { document.getElementById('cancel-btn').addEventListener('click', () => {
this.closeModal(document.getElementById('ups-modal')); this.closeModal(document.getElementById('ups-modal'));
}); });
// Confirmation modal buttons
document.getElementById('confirm-yes').addEventListener('click', () => { document.getElementById('confirm-yes').addEventListener('click', () => {
if (this.confirmCallback) { if (this.confirmCallback) this.confirmCallback();
this.confirmCallback();
}
this.closeModal(document.getElementById('confirm-modal')); this.closeModal(document.getElementById('confirm-modal'));
}); });
document.getElementById('confirm-no').addEventListener('click', () => { document.getElementById('confirm-no').addEventListener('click', () => {
this.closeModal(document.getElementById('confirm-modal')); this.closeModal(document.getElementById('confirm-modal'));
}); });
// Click outside modal to close
window.addEventListener('click', (e) => { window.addEventListener('click', (e) => {
if (e.target.classList.contains('modal')) { if (e.target.classList.contains('modal')) this.closeModal(e.target);
this.closeModal(e.target);
}
}); });
} }
async loadUPSList() { async loadUPSList() {
try { try {
const response = await fetch('/api/config/ups'); const resp = await window.apiFetch('/api/config/ups');
const upsList = await response.json(); const list = await resp.json();
if (!resp.ok) throw new Error(list.detail || 'Failed to load');
if (!response.ok) { this.renderUPSList(list);
throw new Error(upsList.detail || 'Failed to load UPS configurations'); } catch (err) { this.showToast('Error: ' + err.message, 'error'); }
} }
this.renderUPSList(upsList); renderUPSList(list) {
} catch (error) {
this.showToast('Error loading UPS configurations: ' + error.message, 'error');
}
}
renderUPSList(upsList) {
const container = document.getElementById('ups-list'); const container = document.getElementById('ups-list');
container.textContent = '';
if (upsList.length === 0) { if (list.length === 0) {
container.innerHTML = ` const empty = el('div', { class: 'empty-state' },
<div class="empty-state"> el('h3', null, 'No UPS Configured'),
<h3>No UPS Configured</h3> el('p', null, 'Click "Add UPS" to configure your first UPS.'));
<p>Get started by clicking "Add UPS" above to configure your first UPS monitoring.</p> container.appendChild(empty);
</div>
`;
return; return;
} }
list.forEach(ups => container.appendChild(this.renderUPSItem(ups)));
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);
});
});
} }
renderUPSItem(ups) { renderUPSItem(ups) {
const alertsEnabled = [ const alerts = [];
ups.alert_loadpct_high && `Load: ${ups.alert_loadpct_high}%`, if (ups.alert_loadpct_high) alerts.push('Load: ' + ups.alert_loadpct_high + '%');
ups.alert_bcharge_low && `Battery: ${ups.alert_bcharge_low}%`, if (ups.alert_bcharge_low) alerts.push('Battery: ' + ups.alert_bcharge_low + '%');
ups.alert_on_battery && 'On Battery', if (ups.alert_on_battery) alerts.push('On Battery');
ups.alert_runtime_low_minutes && `Runtime: ${ups.alert_runtime_low_minutes}min` if (ups.alert_runtime_low_minutes) alerts.push('Runtime: ' + ups.alert_runtime_low_minutes + 'min');
].filter(Boolean); 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');
return ` const editBtn = el('button', { class: 'btn btn-primary', onclick: () => this.editUPS(ups.name) }, 'Edit');
<div class="ups-item"> const delBtn = el('button', { class: 'btn btn-danger', onclick: () => this.confirmDelete(ups.name) }, 'Delete');
<div class="ups-item-header"> const item = el('div', { class: 'ups-item' },
<div class="ups-item-title">${ups.name}</div> el('div', { class: 'ups-item-header' },
<div class="ups-item-actions"> el('div', { class: 'ups-item-title' }, ups.name),
<button class="btn btn-secondary test-btn" data-ups-name="${ups.name}">Test</button> el('div', { class: 'ups-item-actions' }, testBtn, editBtn, delBtn)),
<button class="btn btn-primary edit-btn" data-ups-name="${ups.name}">Edit</button> el('div', { class: 'ups-item-details' },
<button class="btn btn-danger delete-btn" data-ups-name="${ups.name}">Delete</button> el('div', { class: 'ups-detail' },
</div> el('div', { class: 'ups-detail-label' }, 'Host'),
</div> el('div', { class: 'ups-detail-value' }, ups.host + ':' + ups.port)),
<div class="ups-item-details"> el('div', { class: 'ups-detail' },
<div class="ups-detail"> el('div', { class: 'ups-detail-label' }, 'Polling Interval'),
<div class="ups-detail-label">Host</div> el('div', { class: 'ups-detail-value' }, ups.interval_seconds + 's')),
<div class="ups-detail-value">${ups.host}:${ups.port}</div> el('div', { class: 'ups-detail' },
</div> el('div', { class: 'ups-detail-label' }, 'Alerts'),
<div class="ups-detail"> el('div', { class: 'ups-detail-value' }, alerts.length ? alerts.join(', ') : 'None'))));
<div class="ups-detail-label">Polling Interval</div> return item;
<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>
`;
} }
showUPSModal(ups = null) { showUPSModal(ups = null) {
const modal = document.getElementById('ups-modal'); const modal = document.getElementById('ups-modal');
const title = document.getElementById('modal-title'); const title = document.getElementById('modal-title');
const form = document.getElementById('ups-form'); const form = document.getElementById('ups-form');
// Reset form
form.reset(); form.reset();
if (ups) { if (ups) {
// Edit mode
title.textContent = 'Edit UPS'; title.textContent = 'Edit UPS';
this.populateForm(ups); this.populateForm(ups);
form.dataset.mode = 'edit'; form.dataset.mode = 'edit';
form.dataset.upsName = ups.name; form.dataset.upsName = ups.name;
} else { } else {
// Add mode
title.textContent = 'Add UPS'; title.textContent = 'Add UPS';
form.dataset.mode = 'add'; form.dataset.mode = 'add';
delete form.dataset.upsName; delete form.dataset.upsName;
} }
modal.style.display = 'block'; modal.style.display = 'block';
} }
@@ -179,222 +118,107 @@ class ConfigManager {
document.getElementById('ups-interval').value = ups.interval_seconds || 30; document.getElementById('ups-interval').value = ups.interval_seconds || 30;
document.getElementById('ups-loadpct').value = ups.alert_loadpct_high || ''; document.getElementById('ups-loadpct').value = ups.alert_loadpct_high || '';
document.getElementById('ups-bcharge').value = ups.alert_bcharge_low || ''; 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 || ''; 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) { closeModal(modal) { modal.style.display = 'none'; }
modal.style.display = 'none';
}
async saveUPS() { async saveUPS() {
const form = document.getElementById('ups-form'); const form = document.getElementById('ups-form');
const formData = new FormData(form); const fd = new FormData(form);
const mode = form.dataset.mode; const mode = form.dataset.mode;
const upsName = form.dataset.upsName; const name = form.dataset.upsName;
const data = { const data = {
name: formData.get('name'), name: fd.get('name'),
host: formData.get('host'), host: fd.get('host'),
port: parseInt(formData.get('port')) || 3551, port: parseInt(fd.get('port')) || 3551,
interval_seconds: parseInt(formData.get('interval_seconds')) || 30, interval_seconds: parseInt(fd.get('interval_seconds')) || 30,
alert_on_battery: formData.has('alert_on_battery') alert_on_battery: fd.has('alert_on_battery'),
}; };
const num = (k) => { const v = fd.get(k); if (v) data[k] = parseFloat(v); };
// Add optional alert thresholds num('alert_loadpct_high'); num('alert_bcharge_low');
const loadpct = formData.get('alert_loadpct_high'); num('alert_runtime_low_minutes'); num('alert_itemp_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);
try { try {
let response; const url = mode === 'edit' ? '/api/config/ups/' + name : '/api/config/ups';
if (mode === 'edit') { const method = mode === 'edit' ? 'PUT' : 'POST';
response = await fetch(`/api/config/ups/${upsName}`, { const resp = await window.apiFetch(url, { method, body: JSON.stringify(data) });
method: 'PUT', const result = await resp.json();
headers: { 'Content-Type': 'application/json' }, if (!resp.ok) throw new Error(result.detail || 'Save failed');
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');
}
this.showToast(result.message, 'success'); this.showToast(result.message, 'success');
this.closeModal(document.getElementById('ups-modal')); this.closeModal(document.getElementById('ups-modal'));
this.loadUPSList(); this.loadUPSList();
} catch (error) { } catch (err) { this.showToast('Error: ' + err.message, 'error'); }
this.showToast('Error saving UPS: ' + error.message, 'error');
}
} }
async editUPS(upsName) { async editUPS(name) {
try { try {
const response = await fetch(`/api/config/ups/${upsName}`); const resp = await window.apiFetch('/api/config/ups/' + name);
const ups = await response.json(); const ups = await resp.json();
if (!resp.ok) throw new Error(ups.detail || 'Load failed');
if (!response.ok) {
throw new Error(ups.detail || 'Failed to load UPS configuration');
}
this.showUPSModal(ups); this.showUPSModal(ups);
} catch (error) { } catch (err) { this.showToast('Error: ' + err.message, 'error'); }
this.showToast('Error loading UPS configuration: ' + error.message, 'error');
}
} }
confirmDelete(upsName) { confirmDelete(name) {
const modal = document.getElementById('confirm-modal'); document.getElementById('confirm-message').textContent =
const message = document.getElementById('confirm-message'); 'Delete UPS "' + name + '"? This cannot be undone.';
this.confirmCallback = () => this.deleteUPS(name);
message.textContent = `Are you sure you want to delete the UPS configuration "${upsName}"? This action cannot be undone.`; document.getElementById('confirm-modal').style.display = 'block';
this.confirmCallback = () => this.deleteUPS(upsName);
modal.style.display = 'block';
} }
async deleteUPS(upsName) { async deleteUPS(name) {
try { try {
const response = await fetch(`/api/config/ups/${upsName}`, { const resp = await window.apiFetch('/api/config/ups/' + name, { method: 'DELETE' });
method: 'DELETE' const result = await resp.json();
}); if (!resp.ok) throw new Error(result.detail || 'Delete failed');
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || 'Failed to delete UPS configuration');
}
this.showToast(result.message, 'success'); this.showToast(result.message, 'success');
this.loadUPSList(); this.loadUPSList();
} catch (error) { } catch (err) { this.showToast('Error: ' + err.message, 'error'); }
this.showToast('Error deleting UPS: ' + error.message, 'error');
}
} }
async testConnection() { async testConnection() {
const form = document.getElementById('ups-form'); const form = document.getElementById('ups-form');
const formData = new FormData(form); const fd = new FormData(form);
const data = { const data = {
name: formData.get('name') || 'test', name: fd.get('name') || 'test',
host: formData.get('host'), host: fd.get('host'),
port: parseInt(formData.get('port')) || 3551, port: parseInt(fd.get('port')) || 3551,
interval_seconds: 30, interval_seconds: 30,
alert_on_battery: false alert_on_battery: false,
}; };
if (!data.host) { this.showToast('Host required', 'error'); return; }
if (!data.host) { const btn = document.getElementById('test-connection-btn');
this.showToast('Host is required for connection test', 'error'); const orig = btn.textContent;
return; btn.textContent = 'Testing...'; btn.disabled = true;
}
const testBtn = document.getElementById('test-connection-btn');
const originalText = testBtn.textContent;
testBtn.textContent = 'Testing...';
testBtn.disabled = true;
try { try {
const response = await fetch('/api/config/ups/test', { const resp = await window.apiFetch('/api/config/ups/test', {
method: 'POST', method: 'POST', body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}); });
const r = await resp.json();
const result = await response.json(); this.showToast(r.message || (r.success ? 'OK' : 'Failed'),
let msg; r.success ? 'success' : 'error');
if (result.success) { } catch (err) { this.showToast('Test failed: ' + err.message, 'error'); }
msg = 'Connection successful'; finally { btn.textContent = orig; btn.disabled = false; }
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;
}
} }
async testUPSConnection(upsName) { async testUPSConnection(name) {
const testBtn = document.querySelector(`[data-ups-name="${upsName}"].test-btn`);
const originalText = testBtn.textContent;
testBtn.textContent = 'Testing...';
testBtn.disabled = true;
try { try {
const response = await fetch(`/api/config/ups/${upsName}/test`, { const resp = await window.apiFetch('/api/config/ups/' + name + '/test', { method: 'POST' });
method: 'POST' const r = await resp.json();
}); this.showToast(r.message || (r.success ? 'OK' : 'Failed'),
r.success ? 'success' : 'error');
const result = await response.json(); } catch (err) { this.showToast('Test failed: ' + err.message, 'error'); }
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;
}
} }
showToast(message, type = 'info') { showToast(message, type = 'info') {
const container = document.getElementById('toast-container'); const container = document.getElementById('toast-container');
const toast = document.createElement('div'); const toast = el('div', { class: 'toast ' + type }, message);
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast); container.appendChild(toast);
setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 5000);
// Auto-remove after 5 seconds
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 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 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 _redis: redis.Redis | None = None
def get_redis() -> redis.Redis: def get_redis() -> redis.Redis:
global _redis global _redis
if _redis: if _redis:
return _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 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() r = get_redis()
ts = int(time.time()) ts = int(time.time())
pipe = r.pipeline() pipe = r.pipeline()
# store latest snapshot (hash)
pipe.hset(f"{SNAP_KEY_PREFIX}{ups_name}", mapping={**data, "_ts": ts}) pipe.hset(f"{SNAP_KEY_PREFIX}{ups_name}", mapping={**data, "_ts": ts})
# append to history list
hist_key = f"{HIST_KEY_PREFIX}{ups_name}" hist_key = f"{HIST_KEY_PREFIX}{ups_name}"
pipe.rpush(hist_key, json.dumps({"ts": ts, "data": data})) pipe.rpush(hist_key, json.dumps({"ts": ts, "data": data}))
pipe.ltrim(hist_key, -MAX_SAMPLES_PER_UPS, -1) pipe.ltrim(hist_key, -MAX_SAMPLES_PER_UPS, -1)
# add pruning via async task (length-based + time-based)
pipe.execute() 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() r = get_redis()
h = r.hgetall(f"{SNAP_KEY_PREFIX}{ups_name}") h = r.hgetall(f"{SNAP_KEY_PREFIX}{ups_name}")
return h or None 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() r = get_redis()
key = f"{HIST_KEY_PREFIX}{ups_name}" key = f"{HIST_KEY_PREFIX}{ups_name}"
raw = r.lrange(key, 0, -1) raw = r.lrange(key, 0, -1)
now = int(time.time()) now = int(time.time())
out: List[Dict[str, Any]] = [] out: list[dict[str, Any]] = []
for item in raw: for item in raw:
try: try:
obj = json.loads(item) obj = json.loads(item)
@@ -55,12 +66,12 @@ async def get_history(ups_name: str, since_seconds: int = RETENTION_SECONDS) ->
out.append(obj) out.append(obj)
return out return out
async def prune_old(): async def prune_old():
r = get_redis() r = get_redis()
now = int(time.time()) now = int(time.time())
cutoff = now - RETENTION_SECONDS cutoff = now - RETENTION_SECONDS
for key in r.scan_iter(f"{HIST_KEY_PREFIX}*"): for key in r.scan_iter(f"{HIST_KEY_PREFIX}*"):
# prune from left while older than cutoff
while True: while True:
item = r.lindex(key, 0) item = r.lindex(key, 0)
if not item: 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>
+20 -44
View File
@@ -1,27 +1,18 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en"> {% block title %}UPS Configuration{% endblock %}
<head> {% block head_extra %}
<meta charset="UTF-8" />
<title>UPS Configuration</title>
<link rel="stylesheet" href="/static/css/style.css" />
<link rel="stylesheet" href="/static/css/config.css" /> <link rel="stylesheet" href="/static/css/config.css" />
</head> {% endblock %}
<body> {% block content %}
<div class="header"> <div class="page-header">
<h1>UPS Configuration</h1> <h1>UPS Configuration</h1>
<nav>
<a href="/">← Back to Dashboard</a>
<button id="add-ups-btn" class="btn btn-primary">Add UPS</button> <button id="add-ups-btn" class="btn btn-primary">Add UPS</button>
</nav>
</div> </div>
<div class="main-content"> <div class="main-content">
<div id="ups-list" class="ups-list"> <div id="ups-list" class="ups-list"></div>
<!-- UPS configurations will be loaded here -->
</div>
</div> </div>
<!-- Add/Edit UPS Modal -->
<div id="ups-modal" class="modal"> <div id="ups-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -31,55 +22,42 @@
<form id="ups-form"> <form id="ups-form">
<div class="form-group"> <div class="form-group">
<label for="ups-name">Name *</label> <label for="ups-name">Name *</label>
<input type="text" id="ups-name" name="name" required> <input type="text" id="ups-name" name="name" required pattern="[A-Za-z0-9_-]{1,32}" maxlength="32">
<small>Friendly name for the UPS</small> <small>1-32 alphanumeric, underscore, dash</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="ups-host">Host/IP *</label> <label for="ups-host">Host/IP *</label>
<input type="text" id="ups-host" name="host" required> <input type="text" id="ups-host" name="host" required>
<small>IP address or hostname of the apcupsd server</small> <small>IP or hostname of the apcupsd server</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="ups-port">Port</label> <label for="ups-port">Port</label>
<input type="number" id="ups-port" name="port" value="3551"> <input type="number" id="ups-port" name="port" value="3551" min="1" max="65535">
<small>apcupsd NIS port (default: 3551)</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="ups-interval">Polling Interval (seconds)</label> <label for="ups-interval">Polling Interval (seconds)</label>
<input type="number" id="ups-interval" name="interval_seconds" value="30" min="1"> <input type="number" id="ups-interval" name="interval_seconds" value="30" min="5" max="3600">
<small>How often to poll the UPS for data</small>
</div> </div>
<h3>Alert Thresholds</h3> <h3>Alert Thresholds</h3>
<div class="form-group"> <div class="form-group">
<label for="ups-loadpct">High Load Alert (%)</label> <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"> <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>
<div class="form-group"> <div class="form-group">
<label for="ups-bcharge">Low Battery Alert (%)</label> <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"> <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>
<div class="form-group"> <div class="form-group">
<label> <label><input type="checkbox" id="ups-onbattery" name="alert_on_battery"> Alert when on battery power</label>
<input type="checkbox" id="ups-onbattery" name="alert_on_battery">
Alert when on battery power
</label>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="ups-runtime">Low Runtime Alert (minutes)</label> <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"> <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>
<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>
<div class="form-actions"> <div class="form-actions">
<button type="button" id="test-connection-btn" class="btn btn-secondary">Test Connection</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="submit" class="btn btn-primary">Save</button>
@@ -89,7 +67,6 @@
</div> </div>
</div> </div>
<!-- Confirmation Dialog -->
<div id="confirm-modal" class="modal"> <div id="confirm-modal" class="modal">
<div class="modal-content small"> <div class="modal-content small">
<div class="modal-header"> <div class="modal-header">
@@ -104,9 +81,8 @@
</div> </div>
</div> </div>
<!-- Toast notifications -->
<div id="toast-container"></div> <div id="toast-container"></div>
{% endblock %}
{% block scripts %}
<script src="/static/js/config.js"></script> <script src="/static/js/config.js"></script>
</body> {% endblock %}
</html>
+26 -18
View File
@@ -1,25 +1,32 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en"> {% block title %}Dashboard — UPS{% endblock %}
<head> {% block head_extra %}
<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> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head> {% endblock %}
<body> {% block content %}
<div class="header"> <section class="fleet-overview" id="fleet-overview">
<h1>APC UPS Dashboard</h1> <div class="fleet-card">
<nav> <div class="fleet-stat">
<a href="/config" class="config-link">⚙️ Configuration</a> <span class="fleet-stat-label">Total UPS</span>
</nav> <span class="fleet-stat-value" id="fleet-total">-</span>
</div> </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>
</section>
<div id="ups-cards" data-ui='{{ ui_cfg | tojson }}'> <div id="ups-cards" data-ui='{{ ui_cfg | tojson }}'>
{% for ups in ups_list %} {% for ups in ups_list %}
<div class="card" id="card-{{ ups.name }}"> <div class="card" id="card-{{ ups.name }}">
<div class="card-header"> <div class="card-header">
<h2>{{ ups.name }}</h2> <h2>
<span class="state-dot" data-state-dot></span>
{{ ups.name }}
</h2>
<div class="ups-connection" data-field-conn>{{ ups.host }}:{{ ups.port }}</div> <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> <button class="reset-layout-btn" data-reset-layout title="Reset tile layout">⟳</button>
</div> </div>
<div class="metrics"> <div class="metrics">
@@ -41,6 +48,7 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<script src="/static/js/dashboard.js?v=1"></script> {% endblock %}
</body> {% block scripts %}
</html> <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>
+16 -3
View File
@@ -5,18 +5,31 @@ services:
container_name: apcups-dashboard container_name: apcups-dashboard
ports: ports:
- "10280:8000" - "10280:8000"
env_file: .env
environment: environment:
- REDIS_URL=redis://redis:6379/0 - REDIS_URL=redis://redis:6379/0
depends_on: depends_on:
- redis redis:
condition: service_healthy
restart: always restart: always
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: redis container_name: redis
restart: always restart: always
# Enable persistence so UPS configuration stored in Redis survives restarts. # Uncomment to require password (must also set REDIS_URL=redis://:PASS@redis:6379/0):
# Using both RDB snapshots (default schedule) and AOF for durability. # command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec", "--requirepass", "${REDIS_PASSWORD}"]
command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec"] command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
volumes: volumes:
- redis-data:/data - redis-data:/data
volumes: volumes:
+40
View File
@@ -1,2 +1,42 @@
[project]
name = "apcupsd-client"
version = "0.2.0"
description = "FastAPI + Redis dashboard for monitoring multiple APC UPS devices via apcupsd NIS"
requires-python = ">=3.12"
readme = "README.md"
license = { text = "MIT" }
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["."] pythonpath = ["."]
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = [
"ignore::DeprecationWarning",
]
[tool.ruff]
line-length = 100
target-version = "py312"
extend-exclude = [".venv", "app/static"]
[tool.ruff.lint]
select = ["E", "F", "W", "I", "B", "UP", "SIM"]
ignore = [
"E501", # handled by formatter
"B008", # FastAPI dependency defaults pattern
"B904", # raise without from in except (noise in handlers)
"SIM105", # try/except/pass vs contextlib.suppress (not always clearer)
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["B011", "B017", "F401", "F811"]
[tool.coverage.run]
source = ["app"]
omit = ["app/static/*", "app/templates/*"]
[tool.coverage.report]
show_missing = true
skip_covered = false
fail_under = 65
+13
View File
@@ -3,7 +3,20 @@ uvicorn[standard]==0.30.6
pyyaml==6.0.2 pyyaml==6.0.2
redis==5.0.7 redis==5.0.7
pydantic==2.9.2 pydantic==2.9.2
pydantic-settings==2.5.2
jinja2==3.1.4 jinja2==3.1.4
python-multipart==0.0.9 python-multipart==0.0.9
orjson==3.10.7 orjson==3.10.7
passlib[argon2]==1.7.4
itsdangerous==2.2.0
slowapi==0.1.9
python-json-logger==2.0.7
tenacity==9.0.0
prometheus-client==0.21.0
# Dev / test dependencies (kept in main file for simple docker-based dev)
pytest==8.3.3 pytest==8.3.3
pytest-asyncio==0.24.0
httpx==0.27.2
fakeredis==2.25.1
ruff==0.6.9
View File
Whitespace-only changes.
+112
View File
@@ -0,0 +1,112 @@
"""Shared pytest fixtures: fakeredis + app overrides.
Sets env vars before app modules import settings, swaps the Redis
client in app.storage for a fakeredis instance, and mocks apcaccess.
"""
from __future__ import annotations
import asyncio
import os
import sys
from collections.abc import Iterator
from unittest.mock import patch
import pytest
# Configure env BEFORE importing any app modules
os.environ.setdefault("SESSION_SECRET", "test-session-secret-for-pytest-use-only-32bytes")
os.environ.setdefault("ADMIN_USERNAME", "admin")
os.environ.setdefault("ADMIN_PASSWORD_HASH", "")
os.environ.setdefault("RATE_LIMIT_ENABLED", "false")
os.environ.setdefault("ALLOW_PRIVATE_IPS", "true")
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/15")
os.environ.setdefault("LOG_LEVEL", "WARNING")
import fakeredis # noqa: E402
# Ensure fresh settings after env vars set
from app import settings as settings_mod # noqa: E402
settings_mod.get_settings.cache_clear()
settings_mod.settings = settings_mod.get_settings()
from app import storage as storage_mod # noqa: E402
@pytest.fixture
def fake_redis():
"""Provide a clean fakeredis client installed into storage module."""
client = fakeredis.FakeRedis(decode_responses=True)
storage_mod._redis = client
yield client
client.flushall()
storage_mod._redis = None
@pytest.fixture
def clean_config_cache():
"""Reset module-level config cache to force reload from (fake) Redis."""
from app import config as config_mod
config_mod._cached = None
yield
config_mod._cached = None
@pytest.fixture
def app_client(fake_redis, clean_config_cache):
"""FastAPI TestClient with fakeredis wired in."""
from fastapi.testclient import TestClient
from app.main import create_app
app = create_app()
with TestClient(app) as client:
yield client
@pytest.fixture
def authed_client(app_client, fake_redis):
"""Test client with admin set up and logged in."""
from app.auth import store_admin
store_admin("admin", "testpassword123")
r = app_client.post(
"/api/login",
json={"username": "admin", "password": "testpassword123"},
)
assert r.status_code == 200
csrf = r.json()["csrf_token"]
app_client.headers.update({"X-CSRF-Token": csrf})
return app_client
@pytest.fixture
def mock_apcaccess():
"""Patch apc_cli.fetch_status to return a canned snapshot."""
from app import apc_cli
async def _fake_fetch(host, port):
return {
"STATUS": "ONLINE",
"LOADPCT": "25.0",
"NOMPOWER": "900",
"BCHARGE": "100.0",
"TIMELEFT": "45.0 Minutes",
"LINEV": "120.0",
"NOMINV": "120",
"ITEMP": "30.5",
"BATTV": "13.5",
"NOMBATTV": "12.0",
"SELFTEST": "NO",
"REPLACEBATT": "NO",
"LASTXFER": "No transfers since turnon",
}
with patch.object(apc_cli, "fetch_status", _fake_fetch):
yield _fake_fetch
def pytest_collection_modifyitems(config, items): # noqa: ARG001
"""Mark all async tests as asyncio automatically."""
for item in items:
if asyncio.iscoroutinefunction(item.function):
item.add_marker(pytest.mark.asyncio)
+150
View File
@@ -0,0 +1,150 @@
"""Alert rules, cooldown, silent hours, deferred drain."""
from __future__ import annotations
from datetime import datetime
from unittest.mock import patch
import pytest
from app.alerts import (
SEV_CRITICAL,
SEV_INFO,
SEV_WARNING,
Alert,
_defer_for_silent_hours,
_in_silent_hours,
drain_deferred,
emit_info,
evaluate_alerts,
process_alerts,
)
from app.config import SMTPConfig, UPSConfig
def _ups(**overrides):
base = dict(
name="u1", host="192.168.1.10", port=3551, interval_seconds=30,
alert_on_battery=True,
alert_runtime_low_minutes=10.0,
alert_bcharge_low=20.0,
alert_loadpct_high=80.0,
alert_itemp_high=45.0,
)
base.update(overrides)
return UPSConfig(**base)
def _snap(**overrides):
base = dict(
STATUS="ONLINE", LOADPCT="25.0", BCHARGE="100.0",
TIMELEFT="60.0 Minutes", ITEMP="30.0",
SELFTEST="NO", REPLACEBATT="NO",
)
base.update(overrides)
return base
def test_no_alerts_normal_state(fake_redis, clean_config_cache):
alerts = evaluate_alerts(_ups(), _snap())
assert alerts == []
def test_onbatt_critical(fake_redis, clean_config_cache):
alerts = evaluate_alerts(_ups(), _snap(STATUS="ONBATT"))
assert any(a.code == "ONBATT" and a.severity == SEV_CRITICAL for a in alerts)
def test_runtime_low_critical(fake_redis, clean_config_cache):
alerts = evaluate_alerts(_ups(), _snap(TIMELEFT="5.0 Minutes"))
assert any(a.code == "RUNTIME_LOW" and a.severity == SEV_CRITICAL for a in alerts)
def test_bcharge_low_critical(fake_redis, clean_config_cache):
alerts = evaluate_alerts(_ups(), _snap(BCHARGE="15.0"))
assert any(a.code == "BCHARGE_LOW" and a.severity == SEV_CRITICAL for a in alerts)
def test_load_high_warning(fake_redis, clean_config_cache):
alerts = evaluate_alerts(_ups(), _snap(LOADPCT="90.0"))
assert any(a.code == "LOAD_HIGH" and a.severity == SEV_WARNING for a in alerts)
def test_replacebatt_warning(fake_redis, clean_config_cache):
alerts = evaluate_alerts(_ups(), _snap(REPLACEBATT="YES"))
assert any(a.code == "REPLACEBATT" and a.severity == SEV_WARNING for a in alerts)
def test_selftest_fail_warning(fake_redis, clean_config_cache):
alerts = evaluate_alerts(_ups(), _snap(SELFTEST="FAIL"))
assert any(a.code == "SELFTEST_FAIL" and a.severity == SEV_WARNING for a in alerts)
def test_temp_high_warning(fake_redis, clean_config_cache):
alerts = evaluate_alerts(_ups(), _snap(ITEMP="55.0"))
assert any(a.code == "TEMP_HIGH" and a.severity == SEV_WARNING for a in alerts)
def test_multiple_alerts_coalesced(fake_redis, clean_config_cache):
alerts = evaluate_alerts(
_ups(),
_snap(STATUS="ONBATT", BCHARGE="10.0", LOADPCT="95.0"),
)
codes = {a.code for a in alerts}
assert "ONBATT" in codes
assert "BCHARGE_LOW" in codes
assert "LOAD_HIGH" in codes
def test_alert_hash_stable():
a1 = Alert(SEV_CRITICAL, "msg", "CODE", "u1", 100)
a2 = Alert(SEV_CRITICAL, "msg", "CODE", "u1", 999)
assert a1.hash() == a2.hash() # hash doesn't depend on ts
def test_silent_hours_same_day():
smtp = SMTPConfig(host="x", port=25, silent_hours_start=22, silent_hours_end=23)
now = datetime(2024, 1, 1, 22, 30)
assert _in_silent_hours(smtp, now) is True
now2 = datetime(2024, 1, 1, 23, 30)
assert _in_silent_hours(smtp, now2) is False
def test_silent_hours_wrap_midnight():
smtp = SMTPConfig(host="x", port=25, silent_hours_start=22, silent_hours_end=7)
assert _in_silent_hours(smtp, datetime(2024, 1, 1, 23, 0)) is True
assert _in_silent_hours(smtp, datetime(2024, 1, 1, 3, 0)) is True
assert _in_silent_hours(smtp, datetime(2024, 1, 1, 10, 0)) is False
def test_silent_hours_unset():
smtp = SMTPConfig(host="x", port=25)
assert _in_silent_hours(smtp) is False
def test_defer_and_drain(fake_redis):
a = Alert(SEV_WARNING, "msg1", "CODE1", "u1", 100)
b = Alert(SEV_WARNING, "msg2", "CODE2", "u1", 101)
_defer_for_silent_hours([a, b])
drained = drain_deferred("u1")
assert len(drained) == 2
codes = {x.code for x in drained}
assert codes == {"CODE1", "CODE2"}
# Second drain is empty
assert drain_deferred("u1") == []
def test_cooldown_suppresses_second_call(fake_redis, clean_config_cache):
# Avoid real SMTP: no smtp in config
with patch("app.alerts.dispatch_alerts"):
fresh = process_alerts(_ups(), _snap(STATUS="ONBATT"))
assert any(a.code == "ONBATT" for a in fresh)
# Immediate second call: cooled down
fresh2 = process_alerts(_ups(), _snap(STATUS="ONBATT"))
assert all(a.code != "ONBATT" for a in fresh2)
def test_emit_info_writes_history(fake_redis, clean_config_cache):
with patch("app.alerts.dispatch_alerts"):
emit_info("u1", "REACHABLE", "u1 came back online")
entries = fake_redis.lrange("ups:alerts:history:all", 0, -1)
assert any("REACHABLE" in e for e in entries)
+252
View File
@@ -0,0 +1,252 @@
"""Integration tests for UPS/alerts/events API routes."""
from __future__ import annotations
import json
import time
def _add_ups(client, name="u1", host="192.168.1.10"):
return client.post(
"/api/config/ups",
json={"name": name, "host": host, "port": 3551, "interval_seconds": 30},
)
def test_ups_list_empty(authed_client):
r = authed_client.get("/api/ups")
assert r.status_code == 200
assert r.json() == []
def test_ups_list_after_add(authed_client):
_add_ups(authed_client)
r = authed_client.get("/api/ups")
assert r.status_code == 200
body = r.json()
assert len(body) == 1
assert body[0]["name"] == "u1"
def test_fleet_overview_empty(authed_client):
r = authed_client.get("/api/ups/fleet/overview")
assert r.status_code == 200
body = r.json()
assert body["total"] == 0
assert body["counts"]["online"] == 0
def test_fleet_overview_with_data(authed_client, fake_redis):
_add_ups(authed_client, name="u1")
_add_ups(authed_client, name="u2", host="192.168.1.20")
fake_redis.hset("ups:snap:u1", mapping={"STATUS": "ONLINE", "DERIVED_WATTS": "200", "TIMELEFT": "40"})
fake_redis.hset("ups:snap:u2", mapping={"STATUS": "ONBATT", "DERIVED_WATTS": "150", "TIMELEFT": "10"})
r = authed_client.get("/api/ups/fleet/overview")
body = r.json()
assert body["total"] == 2
assert body["counts"]["online"] == 1
assert body["counts"]["on_battery"] == 1
assert body["total_watts"] == 350.0
assert body["min_timeleft_minutes"] == 10.0
def test_fleet_overview_detects_offline(authed_client, fake_redis):
_add_ups(authed_client, name="u1")
fake_redis.set("ups:health:offline:u1", "1")
fake_redis.hset("ups:snap:u1", mapping={"STATUS": "ONLINE"})
r = authed_client.get("/api/ups/fleet/overview")
body = r.json()
assert body["counts"]["offline"] == 1
def test_ups_status(authed_client, fake_redis):
_add_ups(authed_client)
fake_redis.hset("ups:snap:u1", mapping={"STATUS": "ONLINE", "_ts": "100"})
r = authed_client.get("/api/ups/u1")
assert r.status_code == 200
body = r.json()
assert body["STATUS"] == "ONLINE"
def test_ups_history(authed_client, fake_redis):
_add_ups(authed_client)
now = int(time.time())
fake_redis.rpush(
"ups:hist:u1",
json.dumps({"ts": now, "data": {"STATUS": "ONLINE", "LOADPCT": "50"}}),
)
r = authed_client.get("/api/ups/u1/history")
body = r.json()
assert len(body) == 1
assert body[0]["data"]["STATUS"] == "ONLINE"
def test_ups_metric(authed_client, fake_redis):
_add_ups(authed_client)
now = int(time.time())
for v in (10, 20, 30):
fake_redis.rpush(
"ups:hist:u1",
json.dumps({"ts": now, "data": {"LOADPCT": str(v)}}),
)
r = authed_client.get("/api/ups/u1/metric/LOADPCT")
body = r.json()
assert len(body) == 3
assert body[0]["value"] == 10.0
def test_ups_events(authed_client, fake_redis):
_add_ups(authed_client)
fake_redis.lpush("ups:event:list:u1", f"{int(time.time())}|STATUS|ONLINE")
r = authed_client.get("/api/ups/u1/events")
body = r.json()
assert len(body) == 1
assert body[0]["type"] == "STATUS"
def test_ups_energy_empty(authed_client):
_add_ups(authed_client)
r = authed_client.get("/api/ups/u1/energy")
assert r.status_code == 200
body = r.json()
assert body["kwh_today"] is None
def test_ups_energy_with_data(authed_client, fake_redis):
_add_ups(authed_client)
day_str = time.strftime("%Y%m%d")
fake_redis.set(f"ups:energy:u1:{day_str}", "3600000") # 1 kWh
r = authed_client.get("/api/ups/u1/energy")
body = r.json()
assert body["kwh_today"] == 1.0
def test_ups_health(authed_client, fake_redis):
_add_ups(authed_client)
fake_redis.set("ups:health:last_ok:u1", "12345")
r = authed_client.get("/api/ups/u1/health")
body = r.json()
assert body["online"] is True
assert body["last_ok_ts"] == 12345
def test_ups_health_offline(authed_client, fake_redis):
_add_ups(authed_client)
fake_redis.set("ups:health:offline:u1", "1")
fake_redis.set("ups:health:fail_count:u1", "3")
r = authed_client.get("/api/ups/u1/health")
body = r.json()
assert body["online"] is False
assert body["fail_count"] == 3
def test_battery_health_empty(authed_client):
_add_ups(authed_client)
r = authed_client.get("/api/ups/u1/battery_health")
assert r.status_code == 200
def test_events_list_empty(authed_client):
r = authed_client.get("/api/events")
assert r.status_code == 200
assert r.json() == []
def test_events_list_all(authed_client, fake_redis):
_add_ups(authed_client, name="u1")
_add_ups(authed_client, name="u2", host="192.168.1.20")
now = int(time.time())
fake_redis.lpush("ups:event:list:u1", f"{now}|STATUS|ONLINE")
fake_redis.lpush("ups:event:list:u2", f"{now - 10}|XFER|Low voltage")
r = authed_client.get("/api/events")
body = r.json()
assert len(body) == 2
# Sorted by ts desc
assert body[0]["ts"] >= body[1]["ts"]
def test_events_filter_by_ups(authed_client, fake_redis):
_add_ups(authed_client, name="u1")
_add_ups(authed_client, name="u2", host="192.168.1.20")
now = int(time.time())
fake_redis.lpush("ups:event:list:u1", f"{now}|STATUS|ONLINE")
fake_redis.lpush("ups:event:list:u2", f"{now}|STATUS|ONBATT")
r = authed_client.get("/api/events?ups=u1")
body = r.json()
assert all(e["ups"] == "u1" for e in body)
def test_events_filter_by_kind(authed_client, fake_redis):
_add_ups(authed_client)
now = int(time.time())
fake_redis.lpush("ups:event:list:u1", f"{now}|STATUS|ONLINE")
fake_redis.lpush("ups:event:list:u1", f"{now}|XFER|Low voltage")
r = authed_client.get("/api/events?kind=XFER")
body = r.json()
assert all(e["type"] == "XFER" for e in body)
def test_alerts_list_empty(authed_client):
r = authed_client.get("/api/alerts")
assert r.status_code == 200
assert r.json() == []
def test_alerts_list_with_data(authed_client, fake_redis):
now = int(time.time())
fake_redis.lpush(
"ups:alerts:history:all",
f"{now}|CRITICAL|u1|ONBATT|On battery power|abc123",
)
r = authed_client.get("/api/alerts")
body = r.json()
assert len(body) == 1
assert body[0]["severity"] == "CRITICAL"
assert body[0]["ups"] == "u1"
assert body[0]["acked_ts"] is None
def test_alerts_filter_by_severity(authed_client, fake_redis):
now = int(time.time())
fake_redis.lpush("ups:alerts:history:all", f"{now}|CRITICAL|u1|C|x|a")
fake_redis.lpush("ups:alerts:history:all", f"{now}|WARNING|u1|W|y|b")
r = authed_client.get("/api/alerts?severity=warning")
body = r.json()
assert all(a["severity"] == "WARNING" for a in body)
def test_alerts_active_filters_acked(authed_client, fake_redis):
now = int(time.time())
fake_redis.lpush(
"ups:alerts:history:all",
f"{now}|CRITICAL|u1|A|msg|alert1",
)
fake_redis.lpush(
"ups:alerts:history:all",
f"{now}|CRITICAL|u1|B|msg2|alert2",
)
fake_redis.set("ups:alerts:ack:alert1", str(now))
r = authed_client.get("/api/alerts/active")
body = r.json()
assert all(a["id"] != "alert1" for a in body)
def test_alerts_ack(authed_client, fake_redis):
r = authed_client.post("/api/alerts/myid/ack")
assert r.status_code == 200
assert fake_redis.get("ups:alerts:ack:myid") is not None
def test_healthz_no_auth_required(app_client):
r = app_client.get("/healthz")
assert r.status_code == 200
def test_readyz_reports_redis_status(app_client, fake_redis):
r = app_client.get("/readyz")
assert r.status_code in (200, 503)
def test_metrics_endpoint(app_client, fake_redis):
r = app_client.get("/metrics")
assert r.status_code == 200
assert "ups_" in r.text or "# HELP" in r.text
+148
View File
@@ -0,0 +1,148 @@
"""Auth: hash/verify, session token, login/logout, CSRF, setup."""
from __future__ import annotations
import pytest
from app.auth import (
create_session_token,
get_stored_admin,
hash_password,
is_admin_configured,
store_admin,
verify_password,
verify_session_token,
)
def test_hash_and_verify_password(fake_redis):
h = hash_password("supersecret")
assert h.startswith("$argon2")
assert verify_password("supersecret", h) is True
assert verify_password("wrong", h) is False
def test_verify_password_bad_hash_returns_false(fake_redis):
assert verify_password("anything", "not-a-valid-hash") is False
def test_session_token_roundtrip(fake_redis):
tok = create_session_token("admin")
assert verify_session_token(tok) == "admin"
def test_session_token_rejects_tampered(fake_redis):
tok = create_session_token("admin")
bad = tok[:-4] + "abcd"
assert verify_session_token(bad) is None
def test_store_and_get_admin(fake_redis):
store_admin("alice", "pw-strong-1")
user, h = get_stored_admin()
assert user == "alice"
assert h and h.startswith("$argon2")
assert is_admin_configured() is True
def test_login_rejects_before_setup(app_client, fake_redis):
r = app_client.post(
"/api/login",
json={"username": "admin", "password": "test"},
)
assert r.status_code == 409
def test_login_happy_path(app_client, fake_redis):
store_admin("admin", "testpassword123")
r = app_client.post(
"/api/login",
json={"username": "admin", "password": "testpassword123"},
)
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert "csrf_token" in body
# Cookies set
assert "ups_session" in r.cookies
assert "csrf_token" in r.cookies
def test_login_bad_password(app_client, fake_redis):
store_admin("admin", "testpassword123")
r = app_client.post(
"/api/login",
json={"username": "admin", "password": "wrong"},
)
assert r.status_code == 401
def test_login_bad_username(app_client, fake_redis):
store_admin("admin", "testpassword123")
r = app_client.post(
"/api/login",
json={"username": "notadmin", "password": "testpassword123"},
)
assert r.status_code == 401
def test_logout_clears_cookies(authed_client):
r = authed_client.post("/api/logout")
assert r.status_code == 200
def test_setup_creates_admin(app_client, fake_redis):
r = app_client.post(
"/api/setup",
json={"username": "newadmin", "password": "setup-pw-1234"},
)
assert r.status_code == 200
assert is_admin_configured() is True
def test_setup_rejects_when_configured(app_client, fake_redis):
store_admin("admin", "already-set-password")
r = app_client.post(
"/api/setup",
json={"username": "newadmin", "password": "setup-pw-1234"},
)
assert r.status_code == 409
def test_setup_rejects_short_password(app_client, fake_redis):
r = app_client.post(
"/api/setup",
json={"username": "admin", "password": "short"},
)
assert r.status_code == 422 # pydantic validation
def test_setup_rejects_bad_username(app_client, fake_redis):
r = app_client.post(
"/api/setup",
json={"username": "bad user!", "password": "good-password-1"},
)
assert r.status_code == 400
def test_protected_endpoint_requires_auth(app_client):
r = app_client.get("/api/config/ups")
assert r.status_code == 401
def test_csrf_required_for_mutations(app_client, fake_redis):
store_admin("admin", "testpassword123")
r = app_client.post(
"/api/login",
json={"username": "admin", "password": "testpassword123"},
)
assert r.status_code == 200
# DELETE without CSRF header
r2 = app_client.delete("/api/config/ups/nonexistent")
assert r2.status_code == 403
def test_mutations_work_with_csrf(authed_client):
# Authed client already has CSRF header set in fixture
r = authed_client.delete("/api/config/ups/nonexistent")
# Expected 404 because UPS doesn't exist (NOT 401/403)
assert r.status_code == 404
+135
View File
@@ -0,0 +1,135 @@
"""Config CRUD API: auth, CSRF, create/list/update/delete."""
from __future__ import annotations
def test_unauthed_cannot_list_ups(app_client):
r = app_client.get("/api/config/ups")
assert r.status_code == 401
def test_unauthed_cannot_add_ups(app_client):
r = app_client.post(
"/api/config/ups",
json={"name": "u1", "host": "192.168.1.10"},
)
assert r.status_code == 401
def test_add_and_list_ups(authed_client):
r = authed_client.post(
"/api/config/ups",
json={"name": "u1", "host": "192.168.1.10", "port": 3551, "interval_seconds": 30},
)
assert r.status_code == 200
r2 = authed_client.get("/api/config/ups")
assert r2.status_code == 200
body = r2.json()
assert len(body) == 1
assert body[0]["name"] == "u1"
assert body[0]["host"] == "192.168.1.10"
def test_add_duplicate_rejects(authed_client):
authed_client.post(
"/api/config/ups",
json={"name": "u1", "host": "192.168.1.10"},
)
r = authed_client.post(
"/api/config/ups",
json={"name": "u1", "host": "192.168.1.11"},
)
assert r.status_code == 400
def test_add_ssrf_host_rejected(authed_client):
r = authed_client.post(
"/api/config/ups",
json={"name": "bad", "host": "127.0.0.1"},
)
assert r.status_code == 422
def test_update_ups(authed_client):
authed_client.post(
"/api/config/ups",
json={"name": "u1", "host": "192.168.1.10"},
)
r = authed_client.put(
"/api/config/ups/u1",
json={"host": "192.168.2.20", "interval_seconds": 60},
)
assert r.status_code == 200
r2 = authed_client.get("/api/config/ups/u1")
body = r2.json()
assert body["host"] == "192.168.2.20"
assert body["interval_seconds"] == 60
def test_update_nonexistent(authed_client):
r = authed_client.put(
"/api/config/ups/missing",
json={"host": "192.168.2.20"},
)
assert r.status_code == 404
def test_delete_ups(authed_client):
authed_client.post(
"/api/config/ups",
json={"name": "u1", "host": "192.168.1.10"},
)
r = authed_client.delete("/api/config/ups/u1")
assert r.status_code == 200
r2 = authed_client.get("/api/config/ups")
assert r2.json() == []
def test_delete_nonexistent(authed_client):
r = authed_client.delete("/api/config/ups/missing")
assert r.status_code == 404
def test_get_smtp_none(authed_client):
r = authed_client.get("/api/config/smtp")
assert r.status_code == 200
assert r.json() is None
def test_smtp_password_always_redacted(authed_client, monkeypatch):
from app import settings as settings_mod
monkeypatch.setattr(settings_mod.settings, "smtp_password", "supersecret")
r = authed_client.put(
"/api/config/smtp",
json={
"host": "smtp.example.com",
"port": 587,
"username": "user@example.com",
"use_tls": True,
"from_addr": "alerts@example.com",
"to_addrs": ["ops@example.com"],
},
)
assert r.status_code == 200
r2 = authed_client.get("/api/config/smtp")
body = r2.json()
# Never raw password
assert body.get("password") in ("***", None)
assert body["host"] == "smtp.example.com"
def test_ui_config_default(authed_client):
r = authed_client.get("/api/config/ui")
assert r.status_code == 200
body = r.json()
assert "show_events" in body
def test_ui_config_update(authed_client):
r = authed_client.put(
"/api/config/ui",
json={"show_energy": True, "energy_cost_per_kwh": 0.15},
)
assert r.status_code == 200
body = r.json()
assert body["ui"]["show_energy"] is True
assert body["ui"]["energy_cost_per_kwh"] == 0.15
+74
View File
@@ -0,0 +1,74 @@
"""CSV exports shape and content."""
from __future__ import annotations
import json
import time
import pytest
from app.exports import export_energy_csv, export_events_csv, export_history_csv
async def _collect(gen):
return [chunk async for chunk in gen]
async def test_history_export_header_only_when_empty(fake_redis):
rows = await _collect(export_history_csv("u1", since_days=1))
assert len(rows) == 1
assert rows[0].startswith("ts,status,loadpct,bcharge,timeleft,linev,derived_watts")
async def test_history_export_with_data(fake_redis):
now = int(time.time())
entry = {"ts": now, "data": {
"STATUS": "ONLINE", "LOADPCT": "25.0",
"BCHARGE": "100", "TIMELEFT": "45",
"LINEV": "120", "DERIVED_WATTS": "225",
}}
fake_redis.rpush("ups:hist:u1", json.dumps(entry))
rows = await _collect(export_history_csv("u1", since_days=1))
assert len(rows) == 2 # header + 1
data_row = rows[1]
assert "ONLINE" in data_row
assert "225" in data_row
async def test_events_export_header_only_empty(fake_redis):
rows = await _collect(export_events_csv("u1", since_days=1))
assert len(rows) == 1
assert rows[0].startswith("ts,type,detail")
async def test_events_export_with_data(fake_redis):
now = int(time.time())
fake_redis.rpush("ups:event:list:u1", f"{now}|STATUS|ONBATT")
fake_redis.rpush("ups:event:list:u1", f"{now}|XFER|Low voltage")
rows = await _collect(export_events_csv("u1", since_days=1))
assert len(rows) == 3
body = "\n".join(rows)
assert "STATUS" in body
assert "ONBATT" in body
assert "XFER" in body
async def test_events_export_applies_cutoff(fake_redis):
# Event older than 1 day should be filtered out
old_ts = int(time.time()) - (2 * 24 * 3600)
fake_redis.rpush("ups:event:list:u1", f"{old_ts}|STATUS|OLD")
rows = await _collect(export_events_csv("u1", since_days=1))
assert len(rows) == 1 # header only
async def test_energy_export_header(fake_redis):
rows = await _collect(export_energy_csv("u1", since_days=3))
assert rows[0].startswith("date,kwh")
async def test_energy_export_with_data(fake_redis):
day_str = time.strftime("%Y%m%d")
fake_redis.set(f"ups:energy:u1:{day_str}", "3600000") # 3,600,000 Ws = 1 kWh
rows = await _collect(export_energy_csv("u1", since_days=1))
# header + today's data
assert len(rows) >= 2
assert "1.0000" in "\n".join(rows)
+107
View File
@@ -0,0 +1,107 @@
"""Connection health transitions + battery history append."""
from __future__ import annotations
import json
import time
from app.poller import (
HEALTH_OFFLINE_MIN_SECONDS,
_get_offline_threshold,
_record_poll_failure,
_record_poll_success,
)
def test_offline_threshold_floor():
assert _get_offline_threshold(30) == HEALTH_OFFLINE_MIN_SECONDS
assert _get_offline_threshold(60) == 180
def test_offline_threshold_scales_with_interval():
assert _get_offline_threshold(120) == 360
def test_poll_success_sets_health_key(fake_redis):
snap = {"BCHARGE": "100", "TIMELEFT": "45 Minutes", "BATTV": "13.5", "NOMBATTV": "12"}
_record_poll_success("u1", snap, 30)
assert fake_redis.get("ups:health:last_ok:u1") is not None
assert fake_redis.get("ups:health:fail_count:u1") is None
def test_poll_success_clears_fail_count(fake_redis):
fake_redis.set("ups:health:fail_count:u1", "5")
_record_poll_success("u1", {}, 30)
assert fake_redis.get("ups:health:fail_count:u1") is None
def test_poll_success_recovery_emits_info(fake_redis, clean_config_cache):
from unittest.mock import patch
fake_redis.set("ups:health:offline:u1", "1")
with patch("app.alerts.dispatch_alerts"):
_record_poll_success("u1", {}, 30)
# Offline flag cleared
assert fake_redis.get("ups:health:offline:u1") is None
# History has recovery entry
entries = fake_redis.lrange("ups:alerts:history:all", 0, -1)
assert any("REACHABLE" in e for e in entries)
def test_battery_history_appended(fake_redis):
snap = {"BCHARGE": "100", "TIMELEFT": "45 Minutes", "BATTV": "13.5", "NOMBATTV": "12"}
_record_poll_success("u1", snap, 30)
entries = fake_redis.lrange("ups:battery:history:u1", 0, -1)
assert len(entries) == 1
parsed = json.loads(entries[0])
assert parsed["bcharge"] == 100.0
assert parsed["battv"] == 13.5
def test_battery_history_throttled_at_60s(fake_redis):
snap = {"BCHARGE": "100", "TIMELEFT": "45", "BATTV": "13", "NOMBATTV": "12"}
_record_poll_success("u1", snap, 30)
# Immediately call again: no new sample (60s gate)
_record_poll_success("u1", snap, 30)
entries = fake_redis.lrange("ups:battery:history:u1", 0, -1)
assert len(entries) == 1
def test_poll_failure_increments_count(fake_redis):
_record_poll_failure("u1", 30, "timeout")
assert fake_redis.get("ups:health:fail_count:u1") == "1"
_record_poll_failure("u1", 30, "timeout")
assert fake_redis.get("ups:health:fail_count:u1") == "2"
def test_poll_failure_emits_offline_after_threshold(fake_redis, clean_config_cache):
from unittest.mock import patch
# Simulate an old last_ok so we cross the threshold
fake_redis.set("ups:health:last_ok:u1", str(int(time.time()) - 1000))
with patch("app.alerts.dispatch_alerts"):
_record_poll_failure("u1", 30, "connection refused")
# Offline flag now set
assert fake_redis.get("ups:health:offline:u1") == "1"
# Alert recorded in global history
entries = fake_redis.lrange("ups:alerts:history:all", 0, -1)
assert any("COMMLOST" in e for e in entries)
def test_poll_failure_idempotent_while_offline(fake_redis, clean_config_cache):
from unittest.mock import patch
fake_redis.set("ups:health:last_ok:u1", str(int(time.time()) - 1000))
with patch("app.alerts.dispatch_alerts"):
_record_poll_failure("u1", 30, "x")
_record_poll_failure("u1", 30, "x")
# Only one COMMLOST alert
entries = fake_redis.lrange("ups:alerts:history:all", 0, -1)
assert sum("COMMLOST" in e for e in entries) == 1
def test_poll_failure_below_threshold_no_alert(fake_redis, clean_config_cache):
from unittest.mock import patch
# Recent last_ok -> under threshold
fake_redis.set("ups:health:last_ok:u1", str(int(time.time())))
with patch("app.alerts.dispatch_alerts"):
_record_poll_failure("u1", 30, "transient")
assert fake_redis.get("ups:health:offline:u1") is None
entries = fake_redis.lrange("ups:alerts:history:all", 0, -1)
assert not any("COMMLOST" in e for e in entries)
+113
View File
@@ -0,0 +1,113 @@
"""SSRF / config validators + SMTP password redaction."""
from __future__ import annotations
import pytest
from app.config import SMTPConfig, UPSConfig
from app.config_manager import smtp_redacted_dict
from app.settings import settings
def test_valid_ups_config():
u = UPSConfig(name="rack1", host="192.168.1.10", port=3551, interval_seconds=30)
assert u.host == "192.168.1.10"
assert u.interval_seconds == 30
def test_reject_loopback_literal():
with pytest.raises(Exception, match="loopback"):
UPSConfig(name="bad", host="localhost")
def test_reject_loopback_ip():
with pytest.raises(Exception, match="not a valid polling target"):
UPSConfig(name="bad", host="127.0.0.1")
def test_reject_ipv6_loopback():
with pytest.raises(Exception, match="not a valid polling target"):
UPSConfig(name="bad", host="::1")
def test_reject_link_local():
with pytest.raises(Exception, match="not a valid polling target"):
UPSConfig(name="bad", host="169.254.1.1")
def test_reject_private_ip_when_disabled(monkeypatch):
monkeypatch.setattr(settings, "allow_private_ips", False)
with pytest.raises(Exception, match="private IP"):
UPSConfig(name="bad", host="10.0.0.5")
def test_accept_private_ip_when_enabled(monkeypatch):
monkeypatch.setattr(settings, "allow_private_ips", True)
u = UPSConfig(name="good", host="10.0.0.5")
assert u.host == "10.0.0.5"
def test_reject_empty_name():
with pytest.raises(Exception):
UPSConfig(name="", host="192.168.1.10")
def test_reject_name_too_long():
with pytest.raises(Exception):
UPSConfig(name="a" * 33, host="192.168.1.10")
def test_reject_bad_name_chars():
with pytest.raises(Exception, match="alphanumeric"):
UPSConfig(name="bad name!", host="192.168.1.10")
def test_accept_valid_name_chars():
u = UPSConfig(name="ups_rack-01", host="192.168.1.10")
assert u.name == "ups_rack-01"
def test_reject_invalid_port():
with pytest.raises(Exception):
UPSConfig(name="x", host="192.168.1.10", port=0)
with pytest.raises(Exception):
UPSConfig(name="x", host="192.168.1.10", port=70000)
def test_reject_invalid_interval():
with pytest.raises(Exception):
UPSConfig(name="x", host="192.168.1.10", interval_seconds=1)
with pytest.raises(Exception):
UPSConfig(name="x", host="192.168.1.10", interval_seconds=99999)
def test_accept_valid_hostname():
u = UPSConfig(name="dns", host="ups.lan.example.com")
assert u.host == "ups.lan.example.com"
def test_reject_bad_hostname():
with pytest.raises(Exception):
UPSConfig(name="x", host="not valid host")
def test_smtp_model_has_no_password_field():
s = SMTPConfig(host="smtp.example.com", port=587)
assert not hasattr(s, "password")
def test_smtp_redacted_none():
assert smtp_redacted_dict(None) is None
def test_smtp_redacted_env_set(monkeypatch):
monkeypatch.setattr(settings, "smtp_password", "secret")
s = SMTPConfig(host="smtp.example.com", port=587)
d = smtp_redacted_dict(s)
assert d["password"] == "***"
def test_smtp_redacted_env_unset(monkeypatch):
monkeypatch.setattr(settings, "smtp_password", None)
s = SMTPConfig(host="smtp.example.com", port=587)
d = smtp_redacted_dict(s)
assert d["password"] is None