From 9b48b01d1d8276843b33641a71c0631f33a10b19 Mon Sep 17 00:00:00 2001 From: Ketan Patel Date: Sat, 4 Apr 2026 13:53:57 -0400 Subject: [PATCH] Adding the new rewrite --- .dockerignore | 25 ++ .env.example | 41 +++ .github/workflows/docker-image.yml | 68 ++-- Dockerfile | 41 ++- README.md | 218 ++++++------ app/__init__.py | 2 +- app/alerts.py | 376 +++++++++++++------- app/apc_cli.py | 29 +- app/auth.py | 159 +++++++++ app/config.py | 150 +++++--- app/config_manager.py | 149 +++----- app/config_store.py | 37 +- app/exports.py | 78 +++++ app/health.py | 29 ++ app/logging_config.py | 59 ++++ app/main.py | 529 ++++------------------------- app/metrics.py | 74 ++++ app/notifications/__init__.py | 1 + app/notifications/email.py | 157 +++++++++ app/poller.py | 189 ++++++++--- app/rate_limit.py | 27 ++ app/routes/__init__.py | 1 + app/routes/api_alerts.py | 62 ++++ app/routes/api_auth.py | 62 ++++ app/routes/api_config.py | 147 ++++++++ app/routes/api_events.py | 37 ++ app/routes/api_health.py | 25 ++ app/routes/api_ups.py | 356 +++++++++++++++++++ app/routes/pages.py | 118 +++++++ app/routes/sse.py | 41 +++ app/security.py | 41 +++ app/settings.py | 81 +++++ app/static/css/base.css | 57 ++++ app/static/css/login.css | 11 + app/static/js/alerts.js | 80 +++++ app/static/js/app.js | 33 ++ app/static/js/config.js | 418 +++++++---------------- app/static/js/events.js | 56 +++ app/static/js/settings.js | 79 +++++ app/storage.py | 49 +-- app/templates/alerts.html | 22 ++ app/templates/base.html | 35 ++ app/templates/config.html | 182 +++++----- app/templates/dashboard.html | 94 ++--- app/templates/emails/alert.html | 31 ++ app/templates/emails/summary.html | 21 ++ app/templates/events.html | 21 ++ app/templates/login.html | 49 +++ app/templates/settings.html | 44 +++ app/templates/setup.html | 62 ++++ docker-compose.yml | 19 +- pyproject.toml | 40 +++ requirements.txt | 13 + tests/__init__.py | 0 tests/conftest.py | 112 ++++++ tests/test_alerts.py | 150 ++++++++ tests/test_api_routes.py | 252 ++++++++++++++ tests/test_auth.py | 148 ++++++++ tests/test_config_api.py | 135 ++++++++ tests/test_exports.py | 74 ++++ tests/test_poller.py | 107 ++++++ tests/test_validators.py | 113 ++++++ 62 files changed, 4510 insertions(+), 1406 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 app/auth.py create mode 100644 app/exports.py create mode 100644 app/health.py create mode 100644 app/logging_config.py create mode 100644 app/metrics.py create mode 100644 app/notifications/__init__.py create mode 100644 app/notifications/email.py create mode 100644 app/rate_limit.py create mode 100644 app/routes/__init__.py create mode 100644 app/routes/api_alerts.py create mode 100644 app/routes/api_auth.py create mode 100644 app/routes/api_config.py create mode 100644 app/routes/api_events.py create mode 100644 app/routes/api_health.py create mode 100644 app/routes/api_ups.py create mode 100644 app/routes/pages.py create mode 100644 app/routes/sse.py create mode 100644 app/security.py create mode 100644 app/settings.py create mode 100644 app/static/css/base.css create mode 100644 app/static/css/login.css create mode 100644 app/static/js/alerts.js create mode 100644 app/static/js/app.js create mode 100644 app/static/js/events.js create mode 100644 app/static/js/settings.js create mode 100644 app/templates/alerts.html create mode 100644 app/templates/base.html create mode 100644 app/templates/emails/alert.html create mode 100644 app/templates/emails/summary.html create mode 100644 app/templates/events.html create mode 100644 app/templates/login.html create mode 100644 app/templates/settings.html create mode 100644 app/templates/setup.html create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_alerts.py create mode 100644 tests/test_api_routes.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_config_api.py create mode 100644 tests/test_exports.py create mode 100644 tests/test_poller.py create mode 100644 tests/test_validators.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..02d6205 --- /dev/null +++ b/.dockerignore @@ -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/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5d1dc6a --- /dev/null +++ b/.env.example @@ -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 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index ece3f0e..4d7ee5f 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -1,37 +1,63 @@ -name: Build 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. +name: CI - Lint, Test, and Push Docker Image + on: push: branches: [ "main" ] pull_request: branches: [ "main" ] schedule: - - cron: '0 0 3 * *' # This runs at midnight on the first day of every month + - cron: '0 0 3 * *' # monthly refresh workflow_dispatch: 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: runs-on: ubuntu-latest - + needs: lint-and-test + if: github.event_name != 'pull_request' steps: - - name: Set Image name - run: | - echo "IMAGE_NAME=k2patel/apcupsd-client:latest" >> $GITHUB_ENV + - name: Set Image name + run: | + echo "IMAGE_NAME=k2patel/apcupsd-client:latest" >> $GITHUB_ENV - - name: Checkout code - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@v4 - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} - - name: Build the Docker image - id: build - run: | - docker build . -t ${{ env.IMAGE_NAME }} + - name: Build the Docker image + id: build + run: | + docker build . -t ${{ env.IMAGE_NAME }} - - name: Push the Docker image - run: docker push ${{ env.IMAGE_NAME }} \ No newline at end of file + - name: Push the Docker image + run: docker push ${{ env.IMAGE_NAME }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 7577deb..c831866 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ - 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 RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - build-essential \ - apcupsd \ - && rm -rf /var/lib/apt/lists/* + && apt-get install -y --no-install-recommends apcupsd curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 10001 appuser \ + && useradd --system --uid 10001 --gid 10001 --home /app --shell /usr/sbin/nologin appuser +COPY --from=builder /wheels /wheels 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 +RUN chown -R appuser:appuser /app +USER appuser + 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"] diff --git a/README.md b/README.md index 13190b7..d1cb31e 100644 --- a/README.md +++ b/README.md @@ -1,148 +1,124 @@ # 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 -- 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: -- name (unique) -- host (apcupsd server hostname / IP) -- port (default 3551) -- interval_seconds (polling interval) -- Optional alert thresholds: alert_loadpct_high, alert_bcharge_low, alert_on_battery, alert_runtime_low_minutes +**Security & ops** +- Single-admin auth (argon2 password, signed session cookie, double-submit CSRF) +- SSRF host validation (rejects loopback/link-local; private IPs gated by `ALLOW_PRIVATE_IPS`) +- SMTP password **only** from env — never persisted to Redis +- Subprocess timeout on `apcaccess` (10s), rate-limiting on auth/config, security headers + CSP +- 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 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 3551 - apcaccess -h :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 +cp .env.example .env +# Required in production — edit .env: +# SESSION_SECRET= +# SMTP_PASSWORD= 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 3551` + +## Development ```bash -docker compose down -docker volume rm apcupsd-client_redis-data # volume name may be prefixed by folder/project +python3.12 -m venv .venv && . .venv/bin/activate +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: - -```yaml - redis: - volumes: - - redis-data:/data - command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec"] -``` - -You can inspect Redis persistence files locally by running: +## Persistence +Config and history live in Redis via an AOF-backed `redis-data` volume. To wipe state: ```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 -Redis stores: -- Latest snapshot hash: `ups:snap:` -- History list (JSON {ts,data}): `ups:hist:` -- Per-minute watts averages: `ups:watts:permin:` -- Energy (watt-seconds) daily totals: `ups:energy::YYYYMMDD` -- Events list: `ups:event:list:` -- Recent alerts: `ups:alerts:recent:` -- Voltage deviation samples: `ups:volt:dev:samples:` - -A pruning task runs hourly removing entries older than 7 days. - -## Extending -- Add more charts: query `/api/ups//history` -- Add gauges: integrate a JS gauge lib in `dashboard.html` -- Alerts: create background task checking thresholds +| Path | Auth | Notes | +|---|---|---| +| `/` (dashboard), `/config`, `/events`, `/alerts`, `/settings` | session | Jinja pages | +| `/login`, `/setup`, `/logout` | open/session | | +| `/healthz`, `/readyz`, `/metrics` | open | Prom metrics on `/metrics` | +| `GET /api/stream` | session | SSE snapshots | +| `GET /api/ups`, `/api/ups/{name}/{status\|history\|events\|energy\|health\|battery_health}` | session | | +| `GET /api/ups/fleet/overview` | session | Aggregate fleet summary | +| `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 | | +| `POST /api/alerts/{id}/ack` | session+CSRF | | +| `GET/POST/PUT/DELETE /api/config/{ups,smtp,ui}/...` | session (+CSRF for writes) | Rate-limited | ## License + 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. diff --git a/app/__init__.py b/app/__init__.py index ac50932..03d47fc 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1 +1 @@ -# Package init \ No newline at end of file +# Package init diff --git a/app/alerts.py b/app/alerts.py index 0e4c098..55fecc9 100644 --- a/app/alerts.py +++ b/app/alerts.py @@ -1,68 +1,135 @@ +"""Alert evaluation, coalescing, cooldown, silent-hours, and dispatch.""" from __future__ import annotations -import smtplib -import ssl -import os + +import hashlib +import logging import time -from email.message import EmailMessage -from typing import Dict, Any, List -from .config import load_config, SMTPConfig, UPSConfig +from dataclasses import asdict, dataclass +from datetime import datetime +from typing import Any + +from .config import SMTPConfig, UPSConfig, load_config +from .notifications.email import EmailSendError, send_alert_email from .storage import get_redis +logger = logging.getLogger(__name__) + ALERT_COOLDOWN_SECONDS = 1800 # 30 minutes per distinct alert per UPS REDIS_ALERT_KEY_PREFIX = "ups:alert:last:" +REDIS_PENDING_KEY_PREFIX = "ups:alerts:pending:" # list of deferred messages +REDIS_HISTORY_KEY_PREFIX = "ups:alerts:history:" # global history +ALERT_HISTORY_MAX = 500 +ALERT_HISTORY_TTL_SECONDS = 30 * 24 * 3600 STATUS_ON_BATTERY_KEYWORDS = {"ONBATT", "ON BATTERY"} +SELFTEST_FAIL_KEYWORDS = {"FAIL", "BADBATT"} - -def evaluate_alerts(ups_cfg: UPSConfig, snapshot: Dict[str, Any]) -> List[str]: - messages: List[str] = [] - # Load % high - if ups_cfg.alert_loadpct_high is not None: - loadpct = _to_float(snapshot.get('LOADPCT')) - if loadpct is not None and loadpct >= ups_cfg.alert_loadpct_high: - messages.append( - "Load percentage high: " - f"{loadpct}% >= {ups_cfg.alert_loadpct_high}%" +SEV_CRITICAL = "CRITICAL" +SEV_WARNING = "WARNING" +SEV_INFO = "INFO" + +TEMP_HIGH_DEFAULT_C = 50.0 + + +@dataclass +class Alert: + severity: str + message: str + code: str + ups: str + ts: int + + def hash(self) -> str: + h = hashlib.sha1(f"{self.ups}|{self.code}|{self.message}".encode()).hexdigest() + return h[:16] + + def as_dict(self) -> dict[str, Any]: + d = asdict(self) + d["id"] = self.hash() + return d + + +def _to_float(val) -> float | None: + try: + return float(val) + except (TypeError, ValueError): + return None + + +def _extract_leading_number(s: str) -> float | None: + try: + return float(s.strip().split()[0]) + except Exception: + return None + + +def evaluate_alerts(ups_cfg: UPSConfig, snapshot: dict[str, Any]) -> list[Alert]: + msgs: list[Alert] = [] + now = int(time.time()) + name = ups_cfg.name + + def add(sev: str, code: str, message: str) -> None: + msgs.append(Alert(sev, message, code, name, now)) + + status = str(snapshot.get("STATUS", "")).upper() + # ONBATT + if ups_cfg.alert_on_battery and any(k in status for k in STATUS_ON_BATTERY_KEYWORDS): + add(SEV_CRITICAL, "ONBATT", f"UPS on battery: status={status}") + # Runtime low + if ups_cfg.alert_runtime_low_minutes is not None: + runtime = _extract_leading_number(str(snapshot.get("TIMELEFT", ""))) + if runtime is not None and runtime <= ups_cfg.alert_runtime_low_minutes: + add( + SEV_CRITICAL, + "RUNTIME_LOW", + f"Runtime low: {runtime}m <= {ups_cfg.alert_runtime_low_minutes}m", ) # Battery charge low if ups_cfg.alert_bcharge_low is not None: - bcharge = _to_float(snapshot.get('BCHARGE')) + bcharge = _extract_leading_number(str(snapshot.get("BCHARGE", ""))) if bcharge is not None and bcharge <= ups_cfg.alert_bcharge_low: - messages.append( - "Battery charge low: " - f"{bcharge}% <= {ups_cfg.alert_bcharge_low}%" + add( + SEV_CRITICAL, + "BCHARGE_LOW", + f"Battery charge low: {bcharge}% <= {ups_cfg.alert_bcharge_low}%", ) - # On battery status - if ups_cfg.alert_on_battery: - status = str(snapshot.get('STATUS', '')).upper() - if any(k in status for k in STATUS_ON_BATTERY_KEYWORDS): - messages.append(f"UPS on battery: status={status}") - # Runtime low - if ups_cfg.alert_runtime_low_minutes is not None: - timeleft = snapshot.get('TIMELEFT') - # TIMELEFT often like '15.0 Minutes' -> attempt parse - runtime = _extract_leading_number(str(timeleft)) - if ( - runtime is not None - and runtime <= ups_cfg.alert_runtime_low_minutes - ): - messages.append( - "Runtime low: " - f"{runtime}m <= {ups_cfg.alert_runtime_low_minutes}m" + # Load high + if ups_cfg.alert_loadpct_high is not None: + loadpct = _extract_leading_number(str(snapshot.get("LOADPCT", ""))) + if loadpct is not None and loadpct >= ups_cfg.alert_loadpct_high: + add( + SEV_WARNING, + "LOAD_HIGH", + f"Load high: {loadpct}% >= {ups_cfg.alert_loadpct_high}%", ) - # Extended alerts based on global UI flags + # REPLACEBATT flag + replacebatt = str(snapshot.get("REPLACEBATT", "")).upper() + if replacebatt and replacebatt not in {"NO", "", "0"}: + add(SEV_WARNING, "REPLACEBATT", "UPS reports battery needs replacement") + # SELFTEST fail + selftest = str(snapshot.get("SELFTEST", "")).upper() + if any(k in selftest for k in SELFTEST_FAIL_KEYWORDS): + add(SEV_WARNING, "SELFTEST_FAIL", f"Self-test failure: {selftest}") + # Temperature high + threshold = ups_cfg.alert_itemp_high or TEMP_HIGH_DEFAULT_C + itemp = _extract_leading_number(str(snapshot.get("ITEMP", ""))) + if itemp is not None and itemp >= threshold: + add( + SEV_WARNING, + "TEMP_HIGH", + f"Internal temperature high: {itemp}C >= {threshold}C", + ) + + # Extended alerts based on UI flags cfg = load_config() - ui = cfg.ui if hasattr(cfg, 'ui') else None + ui = cfg.ui if hasattr(cfg, "ui") else None if ui: r = get_redis() - # Transfer burst: count status ONBATT events in last hour if ui.enable_transfer_burst_alert: - # We rely on event list already capturing STATUS transitions - events = r.lrange(f"ups:event:list:{ups_cfg.name}", 0, 200) - now = int(time.time()) + events = r.lrange(f"ups:event:list:{name}", 0, 200) onbatt_count = 0 for ev in events: - parts = ev.split('|') + parts = ev.split("|") if len(parts) >= 3: try: ts = int(parts[0]) @@ -70,107 +137,172 @@ def evaluate_alerts(ups_cfg: UPSConfig, snapshot: Dict[str, Any]) -> List[str]: continue if now - ts > 3600: continue - if parts[1] == 'STATUS' and 'ONBATT' in parts[2]: + if parts[1] == "STATUS" and "ONBATT" in parts[2]: onbatt_count += 1 - if onbatt_count >= 3: # threshold heuristically chosen - messages.append( - f"Frequent battery events: {onbatt_count} in last hour" + if onbatt_count >= 3: + add( + SEV_WARNING, + "XFER_BURST", + f"Frequent battery events: {onbatt_count} in last hour", ) - # Voltage deviation: track LINEV vs nominal, average deviation window if ui.enable_voltage_deviation_alert: - linev = _extract_leading_number(str(snapshot.get('LINEV', ''))) + linev = _extract_leading_number(str(snapshot.get("LINEV", ""))) nom = _extract_leading_number( - str(snapshot.get('NOMINV', snapshot.get('NOMINPUT', ''))) + str(snapshot.get("NOMINV", snapshot.get("NOMINPUT", ""))) ) if linev and nom: dev_pct = abs(linev - nom) / nom * 100.0 - dev_key = f"ups:volt:dev:samples:{ups_cfg.name}" + dev_key = f"ups:volt:dev:samples:{name}" r.lpush(dev_key, f"{dev_pct:.2f}") r.ltrim(dev_key, 0, 49) samples = r.lrange(dev_key, 0, -1) try: - avg_dev = sum(float(s) for s in samples) / max( - 1, len(samples) - ) + avg_dev = sum(float(s) for s in samples) / max(1, len(samples)) if avg_dev > 8.0 and len(samples) >= 10: - messages.append( - "High average voltage deviation: " - f"{avg_dev:.1f}% over {len(samples)} samples" + add( + SEV_WARNING, + "VOLT_DEV", + f"High voltage deviation: {avg_dev:.1f}% over {len(samples)} samples", ) except Exception: pass - return messages + return msgs - -def _to_float(val) -> float | None: - try: - return float(val) - except (TypeError, ValueError): - return None - -def _extract_leading_number(s: str) -> float | None: - try: - part = s.strip().split()[0] - return float(part) - except Exception: - return None +def _cooldown_key(alert: Alert) -> str: + return f"{REDIS_ALERT_KEY_PREFIX}{alert.ups}:{alert.hash()}" - -def _cooldown_key(ups_name: str, msg: str) -> str: - return f"{REDIS_ALERT_KEY_PREFIX}{ups_name}:{hash(msg)}" - -def send_alert_email(smtp_cfg: SMTPConfig, ups_name: str, messages: List[str]): - password = smtp_cfg.password or os.environ.get('SMTP_PASSWORD') - if not smtp_cfg.to_addrs: - return - subject = f"{smtp_cfg.subject_prefix} {ups_name} alert" - body = "\n".join(messages) - msg = EmailMessage() - msg['Subject'] = subject - msg['From'] = smtp_cfg.from_addr or (smtp_cfg.username or 'ups@example') - msg['To'] = ", ".join(smtp_cfg.to_addrs) - msg.set_content(body) +def _in_silent_hours(smtp_cfg: SMTPConfig, now: datetime | None = None) -> bool: + if smtp_cfg.silent_hours_start is None or smtp_cfg.silent_hours_end is None: + return False + if smtp_cfg.silent_hours_start == smtp_cfg.silent_hours_end: + return False + if now is None: + now = datetime.now() + h = now.hour + s = smtp_cfg.silent_hours_start + e = smtp_cfg.silent_hours_end + if s < e: + return s <= h < e + # Wraps past midnight + return h >= s or h < e - if smtp_cfg.use_ssl: - context = ssl.create_default_context() - with smtplib.SMTP_SSL( - smtp_cfg.host, smtp_cfg.port, context=context, timeout=30 - ) as server: - if smtp_cfg.username and password: - server.login(smtp_cfg.username, password) - server.send_message(msg) - else: - with smtplib.SMTP(smtp_cfg.host, smtp_cfg.port, timeout=30) as server: - if smtp_cfg.use_tls: - server.starttls(context=ssl.create_default_context()) - if smtp_cfg.username and password: - server.login(smtp_cfg.username, password) - server.send_message(msg) - -def process_alerts(ups_cfg: UPSConfig, snapshot: Dict[str, Any]): - cfg = load_config() - if not cfg.smtp: - return - msgs = evaluate_alerts(ups_cfg, snapshot) - if not msgs: +def _persist_history(alerts: list[Alert]) -> None: + if not alerts: return r = get_redis() - to_send: List[str] = [] + pipe = r.pipeline() + for a in alerts: + pipe.lpush( + REDIS_HISTORY_KEY_PREFIX + "all", + f"{a.ts}|{a.severity}|{a.ups}|{a.code}|{a.message}|{a.hash()}", + ) + pipe.lpush( + f"ups:alerts:recent:{a.ups}", f"{a.ts}|{a.severity}|{a.message}" + ) + pipe.ltrim(REDIS_HISTORY_KEY_PREFIX + "all", 0, ALERT_HISTORY_MAX - 1) + pipe.expire(REDIS_HISTORY_KEY_PREFIX + "all", ALERT_HISTORY_TTL_SECONDS) + for a in alerts: + pipe.ltrim(f"ups:alerts:recent:{a.ups}", 0, 49) + pipe.execute() + + +def _defer_for_silent_hours(alerts: list[Alert]) -> None: + r = get_redis() + for a in alerts: + key = REDIS_PENDING_KEY_PREFIX + a.ups + r.lpush(key, f"{a.ts}|{a.severity}|{a.code}|{a.message}") + # Keep at most 200 deferred + for ups in {a.ups for a in alerts}: + r.ltrim(REDIS_PENDING_KEY_PREFIX + ups, 0, 199) + + +def drain_deferred(ups_name: str) -> list[Alert]: + r = get_redis() + key = REDIS_PENDING_KEY_PREFIX + ups_name + raw = r.lrange(key, 0, -1) + r.delete(key) + out: list[Alert] = [] + for item in raw: + parts = item.split("|", 3) + if len(parts) == 4: + try: + ts = int(parts[0]) + except ValueError: + continue + out.append(Alert(parts[1], parts[3], parts[2], ups_name, ts)) + return out + + +def dispatch_alerts( + smtp_cfg: SMTPConfig | None, + ups_name: str, + alerts: list[Alert], + dashboard_url: str = "http://localhost:8000/", +) -> None: + """Send a coalesced email containing all alerts for one UPS.""" + if not smtp_cfg or not alerts: + return + payload = [ + {"severity": a.severity, "message": a.message, "code": a.code} + for a in alerts + ] + try: + send_alert_email(smtp_cfg, ups_name, payload, dashboard_url=dashboard_url) + except EmailSendError as e: + logger.warning("Alert email failed for %s: %s", ups_name, e) + + +def process_alerts(ups_cfg: UPSConfig, snapshot: dict[str, Any]) -> list[Alert]: + """Evaluate alerts, apply cooldown + silent hours, dispatch email. + + Returns the list of alerts that were considered fresh (not cooled down). + """ + cfg = load_config() + all_alerts = evaluate_alerts(ups_cfg, snapshot) + if not all_alerts: + return [] + r = get_redis() + fresh: list[Alert] = [] now = int(time.time()) - for m in msgs: - key = _cooldown_key(ups_cfg.name, m) + for a in all_alerts: + key = _cooldown_key(a) if not r.get(key): - to_send.append(m) + fresh.append(a) r.set(key, now, ex=ALERT_COOLDOWN_SECONDS) - if to_send: - # Store recent alerts with timestamp for health reporting - recent_key = f"ups:alerts:recent:{ups_cfg.name}" - pipe = r.pipeline() - for m in to_send: - pipe.lpush(recent_key, f"{now}|{m}") - pipe.ltrim(recent_key, 0, 49) # keep last 50 - pipe.execute() - send_alert_email(cfg.smtp, ups_cfg.name, to_send) + if not fresh: + return [] + _persist_history(fresh) + if not cfg.smtp: + return fresh + # Silent hours filter: keep CRITICAL, defer others + if _in_silent_hours(cfg.smtp): + critical = [a for a in fresh if a.severity == SEV_CRITICAL] + deferred = [a for a in fresh if a.severity != SEV_CRITICAL] + if deferred: + _defer_for_silent_hours(deferred) + if critical: + dispatch_alerts(cfg.smtp, ups_cfg.name, critical) + else: + # Also flush any deferred alerts for this UPS + deferred = drain_deferred(ups_cfg.name) + dispatch_alerts(cfg.smtp, ups_cfg.name, fresh + deferred) + return fresh + + +def emit_info(ups_name: str, code: str, message: str) -> None: + """Record and dispatch a single INFO alert (e.g. recovery events).""" + alert = Alert(SEV_INFO, message, code, ups_name, int(time.time())) + r = get_redis() + if r.get(_cooldown_key(alert)): + return + r.set(_cooldown_key(alert), int(time.time()), ex=ALERT_COOLDOWN_SECONDS) + _persist_history([alert]) + cfg = load_config() + if cfg.smtp: + if _in_silent_hours(cfg.smtp): + _defer_for_silent_hours([alert]) + else: + dispatch_alerts(cfg.smtp, ups_name, [alert]) diff --git a/app/apc_cli.py b/app/apc_cli.py index d7ab0fb..88b764c 100644 --- a/app/apc_cli.py +++ b/app/apc_cli.py @@ -1,22 +1,24 @@ from __future__ import annotations + import asyncio -import shutil import logging -from typing import Dict, Any +import shutil +from typing import Any logger = logging.getLogger(__name__) APCACCESS_BIN = shutil.which('apcaccess') or 'apcaccess' +APCACCESS_TIMEOUT_SECONDS = 10.0 class APCStatusError(Exception): pass -async def fetch_status(host: str, port: int) -> Dict[str, Any]: +async def fetch_status(host: str, port: int) -> dict[str, Any]: """Invoke apcaccess CLI and parse key:value lines into a dict. - Raises APCStatusError if binary fails or returns no data. + Raises APCStatusError if binary fails, times out, or returns no data. """ proc = await asyncio.create_subprocess_exec( APCACCESS_BIN, @@ -25,14 +27,26 @@ async def fetch_status(host: str, port: int) -> Dict[str, Any]: stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) - stdout, stderr = await proc.communicate() + try: + stdout, stderr = await asyncio.wait_for( + proc.communicate(), timeout=APCACCESS_TIMEOUT_SECONDS + ) + except TimeoutError: + try: + proc.kill() + await proc.wait() + except ProcessLookupError: + pass + raise APCStatusError( + f"apcaccess timed out after {APCACCESS_TIMEOUT_SECONDS}s for {host}:{port}" + ) if proc.returncode != 0: err_txt = stderr.decode(errors="replace").strip() raise APCStatusError( f'apcaccess exit {proc.returncode}: {err_txt}' ) text = stdout.decode(errors='replace') - data: Dict[str, Any] = {} + data: dict[str, Any] = {} for line in text.splitlines(): if ':' not in line: continue @@ -40,7 +54,8 @@ async def fetch_status(host: str, port: int) -> Dict[str, Any]: key = k.strip() val = v.strip() data[key] = val - # Normalize expected fields/aliases + if not data: + raise APCStatusError("apcaccess returned no data") if 'UPSNAME' not in data and 'NAME' in data: data['UPSNAME'] = data['NAME'] if 'MODEL' in data: diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..c5796fc --- /dev/null +++ b/app/auth.py @@ -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 diff --git a/app/config.py b/app/config.py index ba5f23e..61b5da2 100644 --- a/app/config.py +++ b/app/config.py @@ -1,74 +1,146 @@ from __future__ import annotations -from pydantic import BaseModel, Field -from typing import List, Optional -from pathlib import Path -import os -CONFIG_PATH = Path( - os.environ.get("UPS_CONFIG_PATH", "/config/ups.yaml") -) # legacy path for migration +import ipaddress +import re +from pathlib import Path +from typing import ClassVar + +from pydantic import BaseModel, Field, field_validator + +from .settings import settings + +CONFIG_PATH = Path(settings.ups_config_path) # legacy path for migration + +_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,32}$") +_HOSTNAME_RE = re.compile( + r"^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" +) + +_BLOCKED_LITERALS = {"localhost", "localhost.localdomain"} + + +def _validate_host_string(host: str) -> str: + """Validate a host field. Rejects loopback/link-local always; rejects + private ranges when ALLOW_PRIVATE_IPS is false. + """ + host = host.strip() + if not host: + raise ValueError("host must not be empty") + if host.lower() in _BLOCKED_LITERALS: + raise ValueError(f"host '{host}' is not permitted (loopback alias)") + # Try as IP first + try: + ip = ipaddress.ip_address(host) + if ip.is_loopback or ip.is_link_local or ip.is_unspecified or ip.is_multicast: + raise ValueError(f"host IP {host} is not a valid polling target") + if ip.is_private and not settings.allow_private_ips: + raise ValueError( + f"private IP {host} not allowed (set ALLOW_PRIVATE_IPS=true)" + ) + return host + except ValueError as ip_err: + # Fall through to hostname validation only when the string wasn't a + # parseable IP address + if "does not appear to be an IPv4 or IPv6 address" not in str(ip_err): + raise + # Validate as hostname + if not _HOSTNAME_RE.match(host): + raise ValueError(f"host '{host}' is not a valid hostname or IP") + return host class UPSConfig(BaseModel): name: str = Field(..., description="Friendly UPS name") host: str = Field(..., description="apcupsd NIS host/IP") - port: int = Field(3551, description="apcupsd NIS port") - interval_seconds: int = Field(30, description="Polling interval") - # Alert thresholds (any optional) - alert_loadpct_high: Optional[float] = Field( - None, description="Trigger if LOADPCT >= value" + port: int = Field(3551, ge=1, le=65535, description="apcupsd NIS port") + interval_seconds: int = Field( + 30, ge=5, le=3600, description="Polling interval" ) - alert_bcharge_low: Optional[float] = Field( - None, description="Trigger if BCHARGE <= value" + # Alert thresholds (any optional) + alert_loadpct_high: float | None = Field( + None, ge=0, le=100, description="Trigger if LOADPCT >= value" + ) + alert_bcharge_low: float | None = Field( + None, ge=0, le=100, description="Trigger if BCHARGE <= value" ) alert_on_battery: bool = Field( False, description="Trigger when STATUS indicates on battery" ) - alert_runtime_low_minutes: Optional[float] = Field( - None, description="Trigger if TIMELEFT <= minutes" + alert_runtime_low_minutes: float | None = Field( + None, ge=0, description="Trigger if TIMELEFT <= minutes" ) + alert_itemp_high: float | None = Field( + None, ge=0, le=120, description="Trigger if internal temp >= C" + ) + + @field_validator("name") + @classmethod + def _validate_name(cls, v: str) -> str: + if not _NAME_RE.match(v): + raise ValueError( + "name must be 1-32 chars, alphanumeric / underscore / dash only" + ) + return v + + @field_validator("host") + @classmethod + def _validate_host(cls, v: str) -> str: + return _validate_host_string(v) class SMTPConfig(BaseModel): host: str = Field(..., description="SMTP server host/IP") - port: int = Field(..., description="SMTP port") - username: Optional[str] = Field(None) - password: Optional[str] = Field( - None, description="Plain password or set via env SMTP_PASSWORD" - ) + port: int = Field(..., ge=1, le=65535, description="SMTP port") + username: str | None = Field(None) + # password is never persisted in Redis; read from env SMTP_PASSWORD use_tls: bool = Field(False, description="STARTTLS if true") use_ssl: bool = Field(False, description="SSL (smtplib.SMTP_SSL) if true") - from_addr: Optional[str] = Field(None, description="From email address") - to_addrs: List[str] = Field( + from_addr: str | None = Field(None, description="From email address") + to_addrs: list[str] = Field( default_factory=list, description="Recipient list" ) subject_prefix: str = Field("[UPS]", description="Subject prefix") + silent_hours_start: int | None = Field( + None, ge=0, le=23, description="Silent window start hour (local time)" + ) + silent_hours_end: int | None = Field( + None, ge=0, le=23, description="Silent window end hour (local time)" + ) + daily_summary_hour: int | None = Field( + None, ge=0, le=23, description="Hour to send daily summary" + ) + + +class UIConfig(BaseModel): + show_events: bool = True + show_energy: bool = False + color_badges: bool = True + show_headroom: bool = True + show_watts: bool = True + show_runtime: bool = True + allow_resize: bool = True + enable_transfer_burst_alert: bool = False + enable_voltage_deviation_alert: bool = False + energy_cost_per_kwh: float = 0.0 class AppConfig(BaseModel): - ups: List[UPSConfig] - smtp: Optional[SMTPConfig] = None - # UI feature flags (optional; default values used if missing) - # Added for dashboard toggleable features - class UIConfig(BaseModel): - show_events: bool = True - show_energy: bool = False - color_badges: bool = True - show_headroom: bool = True - show_watts: bool = True - show_runtime: bool = True - allow_resize: bool = True - enable_transfer_burst_alert: bool = False - enable_voltage_deviation_alert: bool = False + ups: list[UPSConfig] = Field(default_factory=list) + smtp: SMTPConfig | None = None + ui: UIConfig = Field(default_factory=UIConfig) - ui: UIConfig = UIConfig() + # Backward-compat alias: older code references AppConfig.UIConfig + UIConfig: ClassVar[type[UIConfig]] = UIConfig _cached: AppConfig | None = None def load_config(path: Path = CONFIG_PATH) -> AppConfig: - # Use redis store (lazy import to avoid circular) + """Load config from Redis, cached in-process. + + Invalidate via ``config_module._cached = None`` after any write. + """ from .config_store import load_config_redis global _cached if _cached: diff --git a/app/config_manager.py b/app/config_manager.py index bf74bb3..34960dd 100644 --- a/app/config_manager.py +++ b/app/config_manager.py @@ -1,27 +1,20 @@ from __future__ import annotations -from typing import List, Optional, Dict, Any + import asyncio import logging import socket +from typing import Any -try: - from pydantic import BaseModel, Field, ValidationError -except ImportError: - # Fallback for environments without pydantic - BaseModel = object - - def Field(default=None, **kwargs): - return default - - ValidationError = ValueError +from pydantic import BaseModel, Field, ValidationError -from .config import UPSConfig, SMTPConfig, AppConfig +from .config import AppConfig, SMTPConfig, UIConfig, UPSConfig from .config_store import load_config_redis, save_config_redis +from .settings import settings logger = logging.getLogger(__name__) -# Incremented every time configuration is modified so -# SSE clients can detect changes +# Incremented every time configuration is modified so SSE clients can +# detect changes _config_version: int = 0 @@ -29,175 +22,127 @@ def get_config_version() -> int: return _config_version -class ConfigWriteError(Exception): # retained for API compatibility +class ConfigWriteError(Exception): """Raised when configuration cannot be written (kept for compatibility).""" class UPSConfigUpdate(BaseModel): - """Model for updating UPS configuration""" - name: Optional[str] = Field(None, description="Friendly UPS name") - host: Optional[str] = Field(None, description="apcupsd NIS host/IP") - port: Optional[int] = Field(None, description="apcupsd NIS port") - interval_seconds: Optional[int] = Field( - None, description="Polling interval" - ) - alert_loadpct_high: Optional[float] = Field( - None, description="Trigger if LOADPCT >= value" - ) - alert_bcharge_low: Optional[float] = Field( - None, description="Trigger if BCHARGE <= value" - ) - alert_on_battery: Optional[bool] = Field( - None, description="Trigger when STATUS indicates on battery" - ) - alert_runtime_low_minutes: Optional[float] = Field( - None, description="Trigger if TIMELEFT <= minutes" - ) + """Model for updating UPS configuration.""" + name: str | None = None + host: str | None = None + port: int | None = Field(None, ge=1, le=65535) + interval_seconds: int | None = Field(None, ge=5, le=3600) + alert_loadpct_high: float | None = Field(None, ge=0, le=100) + alert_bcharge_low: float | None = Field(None, ge=0, le=100) + alert_on_battery: bool | None = None + alert_runtime_low_minutes: float | None = Field(None, ge=0) + alert_itemp_high: float | None = Field(None, ge=0, le=120) class ConfigManager: - def __init__(self): + def __init__(self) -> None: self._lock = asyncio.Lock() - + async def load_config(self) -> AppConfig: async with self._lock: return load_config_redis() - + async def save_config(self, config: AppConfig) -> None: async with self._lock: save_config_redis(config) logger.info("Configuration saved to Redis") - # Invalidate cached global config so subsequent - # load_config() calls see changes try: from . import config as config_module config_module._cached = None except Exception: # pragma: no cover - defensive - logger.debug( - "Failed to invalidate config cache", exc_info=True - ) - # bump config version + logger.debug("Failed to invalidate config cache", exc_info=True) global _config_version _config_version += 1 - - async def get_ups_list(self) -> List[UPSConfig]: - """Get list of all UPS configurations""" + + async def get_ups_list(self) -> list[UPSConfig]: config = await self.load_config() return config.ups - - async def get_ups(self, name: str) -> Optional[UPSConfig]: - """Get UPS configuration by name""" + + async def get_ups(self, name: str) -> UPSConfig | None: config = await self.load_config() for ups in config.ups: if ups.name == name: return ups return None - + async def add_ups(self, ups_config: UPSConfig) -> bool: - """Add new UPS configuration""" config = await self.load_config() - - # Check if UPS with same name already exists if any(ups.name == ups_config.name for ups in config.ups): raise ValueError( f"UPS with name '{ups_config.name}' already exists" ) - config.ups.append(ups_config) await self.save_config(config) - - # No file cache now - return True - + async def update_ups(self, name: str, updates: UPSConfigUpdate) -> bool: - """Update existing UPS configuration""" config = await self.load_config() - ups_index = None for i, ups in enumerate(config.ups): if ups.name == name: ups_index = i break - if ups_index is None: return False - - # Apply updates ups_dict = config.ups[ups_index].model_dump() update_dict = updates.model_dump(exclude_none=True) ups_dict.update(update_dict) - - # Validate updated configuration try: updated_ups = UPSConfig(**ups_dict) except ValidationError as e: raise ValueError(f"Invalid configuration: {e}") - config.ups[ups_index] = updated_ups await self.save_config(config) - - # No file cache now - return True - + async def delete_ups(self, name: str) -> bool: - """Delete UPS configuration""" config = await self.load_config() - original_count = len(config.ups) config.ups = [ups for ups in config.ups if ups.name != name] - if len(config.ups) == original_count: - return False # UPS not found - + return False await self.save_config(config) - - # No file cache now - return True - - async def get_smtp_config(self) -> Optional[SMTPConfig]: - """Get SMTP configuration""" + + async def get_smtp_config(self) -> SMTPConfig | None: config = await self.load_config() return config.smtp - + async def update_smtp_config(self, smtp_config: SMTPConfig) -> None: - """Update SMTP configuration""" config = await self.load_config() config.smtp = smtp_config await self.save_config(config) - - # Clear cached config - from . import config as config_module - config_module._cached = None - + + async def update_ui_config(self, ui: UIConfig) -> None: + config = await self.load_config() + config.ui = ui + await self.save_config(config) + async def validate_ups_connection( self, ups_config: UPSConfig, timeout: float = 3.0 - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Port-only connectivity test (no protocol / CLI call).""" - - result: Dict[str, Any] = { + result: dict[str, Any] = { "success": False, "message": "", "connectivity": {"ok": False, "error": None}, "protocol": {"ok": False, "error": None}, "data": None, } - - # Raw TCP connectivity test try: - # Use low-level socket to distinguish DNS/timeouts with socket.create_connection( (ups_config.host, ups_config.port), timeout=timeout ): result["connectivity"]["ok"] = True - except Exception as e: # broad to surface any network issue + except Exception as e: result["connectivity"]["error"] = str(e) result["message"] = f"TCP connectivity failed: {e}" return result - - # For port-only test we just mirror connectivity result if result["connectivity"]["ok"]: result["protocol"]["ok"] = True result["success"] = True @@ -207,5 +152,13 @@ class ConfigManager: return result -# Global instance +def smtp_redacted_dict(smtp: SMTPConfig | None) -> dict[str, Any] | None: + """Return SMTP config dict with password redacted from env.""" + if smtp is None: + return None + d = smtp.model_dump() + d["password"] = "***" if settings.smtp_password else None + return d + + config_manager = ConfigManager() diff --git a/app/config_store.py b/app/config_store.py index 62784eb..98b105a 100644 --- a/app/config_store.py +++ b/app/config_store.py @@ -1,31 +1,39 @@ -"""Redis-backed configuration storage replacing YAML file. +"""Redis-backed configuration storage. -Schema: - Key ups:config:json -> JSON object: {"ups": [...], "smtp": {...}|null} - -Migration: - On first load if redis key missing and legacy YAML present, import it. +Key ``ups:config:json`` holds JSON: ``{"ups": [...], "smtp": {...}|null, "ui": {...}}``. +Legacy YAML import runs once if Redis is empty. """ from __future__ import annotations -from typing import Optional -from pathlib import Path + import json import logging -from .storage import get_redis -from .config import AppConfig, CONFIG_PATH +from pathlib import Path + import yaml +from .config import CONFIG_PATH, AppConfig +from .storage import get_redis + logger = logging.getLogger(__name__) REDIS_CONFIG_KEY = "ups:config:json" -def _load_legacy_yaml(path: Path) -> Optional[AppConfig]: +def _strip_smtp_password(data: dict) -> dict: + """Strip persisted SMTP passwords (migration from older schema).""" + smtp = data.get("smtp") + if isinstance(smtp, dict) and "password" in smtp: + smtp.pop("password", None) + return data + + +def _load_legacy_yaml(path: Path) -> AppConfig | None: if not path.exists(): return None try: with path.open() as f: raw = yaml.safe_load(f) or {} + raw = _strip_smtp_password(raw) return AppConfig(**raw) except Exception as e: # pragma: no cover logger.warning("Failed to import legacy YAML config: %s", e) @@ -40,14 +48,13 @@ def load_config_redis() -> AppConfig: data = json.loads(raw) except json.JSONDecodeError: data = {} + data = _strip_smtp_password(data) return AppConfig(**data) - # Migration path legacy = _load_legacy_yaml(CONFIG_PATH) if legacy: save_config_redis(legacy) logger.info("Imported legacy YAML config into Redis") return legacy - # If nothing exists, create empty scaffold empty = AppConfig(ups=[], smtp=None) save_config_redis(empty) return empty @@ -55,4 +62,6 @@ def load_config_redis() -> AppConfig: def save_config_redis(cfg: AppConfig) -> None: r = get_redis() - r.set(REDIS_CONFIG_KEY, json.dumps(cfg.model_dump(exclude_none=True))) + data = cfg.model_dump(exclude_none=True) + data = _strip_smtp_password(data) + r.set(REDIS_CONFIG_KEY, json.dumps(data)) diff --git a/app/exports.py b/app/exports.py new file mode 100644 index 0000000..9cf0e86 --- /dev/null +++ b/app/exports.py @@ -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 diff --git a/app/health.py b/app/health.py new file mode 100644 index 0000000..84cd365 --- /dev/null +++ b/app/health.py @@ -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}) diff --git a/app/logging_config.py b/app/logging_config.py new file mode 100644 index 0000000..dcb62f1 --- /dev/null +++ b/app/logging_config.py @@ -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) diff --git a/app/main.py b/app/main.py index d580eb6..6804756 100644 --- a/app/main.py +++ b/app/main.py @@ -1,472 +1,87 @@ +"""FastAPI application entry point. + +Assembles middleware, lifespan (poller start/stop), auth, routes. +All request handlers live in app/routes/*. +""" from __future__ import annotations + import asyncio -import time -from fastapi import FastAPI, Request, HTTPException -from fastapi.responses import HTMLResponse, StreamingResponse +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates -import orjson -from .config import load_config, UPSConfig -from .storage import get_latest, get_history -from .poller import poll_loop -from .config_manager import ( - config_manager, - UPSConfigUpdate, - ConfigWriteError, - get_config_version, -) -from .storage import get_redis -from .apc_cli import fetch_status, APCStatusError +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded -app = FastAPI(title="APC UPS Dashboard") -app.mount('/static', StaticFiles(directory='app/static'), name='static') -templates = Jinja2Templates(directory='app/templates') +from .logging_config import RequestIdMiddleware, configure_logging +from .poller import cancel_all_tasks, poll_loop +from .rate_limit import limiter +from .routes import api_alerts, api_auth, api_config, api_events, api_health, api_ups, pages, sse +from .security import SecurityHeadersMiddleware +from .settings import settings + +logger = logging.getLogger(__name__) + +_background_tasks: list[asyncio.Task] = [] -@app.on_event("startup") -async def startup(): - asyncio.create_task(poll_loop()) - - -@app.get('/', response_class=HTMLResponse) -async def dashboard(request: Request): - cfg = load_config() - return templates.TemplateResponse( - 'dashboard.html', { - "request": request, - "ups_list": cfg.ups, - "ui_cfg": cfg.ui.model_dump(), - } - ) - - -@app.get('/config', response_class=HTMLResponse) -async def config_page(request: Request): - return templates.TemplateResponse('config.html', {"request": request}) - - -@app.get('/api/ups') -async def list_ups(): - cfg = load_config() - return [{"name": u.name, "host": u.host, "port": u.port} for u in cfg.ups] - - -@app.get('/api/ups/{ups_name}') -async def ups_status(ups_name: str): - snap = await get_latest(ups_name) - return snap or {"error": "not found"} - - -@app.get('/api/ups/{ups_name}/history') -async def ups_history(ups_name: str): - hist = await get_history(ups_name) - return hist - - -@app.get('/api/ups/{ups_name}/metric/{metric}') -async def metric_history(ups_name: str, metric: str, limit: int = 120): - """Return recent numeric history samples for a single metric. - - Limit capped at 500 to avoid large payloads. - """ - limit = max(1, min(limit, 500)) - all_hist = await get_history(ups_name) - # Use only most recent entries - recent = all_hist[-limit:] - out = [] - for item in recent: - data = item.get('data', {}) - raw_val = data.get(metric) - if raw_val is None: - continue - # Extract leading number - try: - val = float(str(raw_val).split()[0]) - except Exception: - continue - out.append({'ts': item.get('ts'), 'value': val}) - return out - - -@app.get('/api/ups/{ups_name}/events') -async def ups_events(ups_name: str): - """Return recent status/transfer events for a UPS.""" - r = get_redis() - key = f"ups:event:list:{ups_name}" - raw = r.lrange(key, 0, 99) - parsed = [] - for item in raw: - if '|' in item: +@asynccontextmanager +async def lifespan(app: FastAPI): + configure_logging() + if not settings.session_secret: + logger.warning( + "SESSION_SECRET not set; using insecure ephemeral secret. " + "Set SESSION_SECRET in your environment for production." + ) + task = asyncio.create_task(poll_loop()) + _background_tasks.append(task) + logger.info("Poller started") + try: + yield + finally: + logger.info("Shutting down: cancelling background tasks") + for t in _background_tasks: + t.cancel() + for t in _background_tasks: try: - ts_s, kind, rest = item.split('|', 2) - parsed.append({ - 'ts': int(ts_s), - 'type': kind, - 'detail': rest - }) - continue - except Exception: + await t + except (asyncio.CancelledError, Exception): pass - parsed.append({'raw': item}) - return parsed + await cancel_all_tasks() + logger.info("Shutdown complete") -@app.get('/api/ups/{ups_name}/energy') -async def ups_energy(ups_name: str): - """Return today's accumulated energy (approx kWh) if available.""" - r = get_redis() - day_str = time.strftime('%Y%m%d') # type: ignore - key = f"ups:energy:{ups_name}:{day_str}" - watt_seconds = r.get(key) - if watt_seconds: - try: - ws = float(watt_seconds) - kwh = ws / 3600.0 / 1000.0 - return {'kwh_today': round(kwh, 4)} - except ValueError: - pass - return {'kwh_today': None} +def create_app() -> FastAPI: + application = FastAPI(title="APC UPS Dashboard", lifespan=lifespan) + application.state.limiter = limiter + application.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + # Middleware (outermost first in registration; Starlette wraps inside-out) + application.add_middleware(SecurityHeadersMiddleware) + application.add_middleware(RequestIdMiddleware) -@app.get('/api/ups/{ups_name}/watts_per_minute') -async def ups_watts_per_minute(ups_name: str): - """Return recent per-minute average watts for a UPS (last 24h).""" - r = get_redis() - key = f"ups:watts:permin:{ups_name}" - raw = r.lrange(key, 0, 1440) - out = [] - for item in raw: - if '|' in item: - minute, avg = item.split('|', 1) - try: - out.append({'minute': minute, 'avg_watts': float(avg)}) - except ValueError: - continue - # list stored newest-first; reverse to chronological - out.reverse() - return out - - -@app.get('/api/ups/{ups_name}/health') -async def ups_health(ups_name: str): - """Return aggregated health indicators. - - Includes recent alerts, voltage deviation stats, and ONBATT event count. - """ - r = get_redis() - # Recent alerts - alerts_key = f"ups:alerts:recent:{ups_name}" - alert_raw = r.lrange(alerts_key, 0, 19) - alerts = [] - for a in alert_raw: - if '|' in a: - ts_s, msg = a.split('|', 1) - try: - alerts.append({'ts': int(ts_s), 'msg': msg}) - except ValueError: - alerts.append({'raw': a}) - else: - alerts.append({'raw': a}) - # Voltage deviation samples - dev_key = f"ups:volt:dev:samples:{ups_name}" - dev_samples = r.lrange(dev_key, 0, 49) - dev_vals = [] - for d in dev_samples: - try: - dev_vals.append(float(d)) - except ValueError: - continue - dev_avg = sum(devVals := dev_vals) / len(devVals) if dev_vals else None - dev_max = max(dev_vals) if dev_vals else None - # Transfer burst count (recent hour ONBATT events) - events_key = f"ups:event:list:{ups_name}" - now = int(time.time()) - events = r.lrange(events_key, 0, 200) - onbatt_hour = 0 - for ev in events: - parts = ev.split('|') - if len(parts) >= 3: - try: - ts_e = int(parts[0]) - except ValueError: - continue - if now - ts_e > 3600: - continue - if parts[1] == 'STATUS' and 'ONBATT' in parts[2]: - onbatt_hour += 1 - return { - 'alerts': alerts, - 'voltage_deviation': { - 'avg_pct': round(dev_avg, 2) if dev_avg is not None else None, - 'max_pct': round(dev_max, 2) if dev_max is not None else None, - 'samples': len(dev_vals) - }, - 'onbatt_last_hour': onbatt_hour - } - - -@app.get('/api/ups/{ups_name}/debug') -async def ups_debug(ups_name: str): - """Return current apcaccess CLI status for a UPS.""" - cfg = load_config() - target = next((u for u in cfg.ups if u.name == ups_name), None) - if not target: - raise HTTPException(status_code=404, detail='UPS not found') - try: - data = await fetch_status(target.host, target.port) - return data - except APCStatusError as e: - raise HTTPException(status_code=502, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.get('/api/stream') -async def stream(): - # simple Server Sent Events stream of snapshots (polling redis every 5s) - async def event_gen(): - while True: - cfg = load_config() - payload = {"snapshots": {}, "cfgVersion": get_config_version()} - for u in cfg.ups: - snap = await get_latest(u.name) - if snap: - payload["snapshots"][u.name] = snap - # include simple config metadata to help client reconcile - payload["upsMeta"] = [ - {"name": u.name, "host": u.host, "port": u.port} - for u in cfg.ups - ] - # Backward compatibility: also flatten UPS snapshots at top level - for name, snap in payload["snapshots"].items(): - payload.setdefault(name, snap) - yield f"data: {orjson.dumps(payload).decode()}\n\n" - await asyncio.sleep(5) - return StreamingResponse(event_gen(), media_type='text/event-stream') - - -# Configuration management endpoints -@app.get('/api/config/ups') -async def get_ups_configs(): - """Get all UPS configurations""" - try: - ups_list = await config_manager.get_ups_list() - return [ups.model_dump() for ups in ups_list] - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.get('/api/config/ups/{ups_name}') -async def get_ups_config(ups_name: str): - """Get specific UPS configuration""" - try: - ups = await config_manager.get_ups(ups_name) - if not ups: - raise HTTPException(status_code=404, detail="UPS not found") - return ups.model_dump() - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.post('/api/config/ups') -async def add_ups_config(ups_config: UPSConfig): - """Add new UPS configuration""" - try: - await config_manager.add_ups(ups_config) - return {"message": "UPS configuration added successfully"} - except ConfigWriteError as e: - raise HTTPException(status_code=507, detail=str(e)) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.put('/api/config/ups/{ups_name}') -async def update_ups_config(ups_name: str, updates: UPSConfigUpdate): - """Update UPS configuration""" - try: - success = await config_manager.update_ups(ups_name, updates) - if not success: - raise HTTPException(status_code=404, detail="UPS not found") - return {"message": "UPS configuration updated successfully"} - except ConfigWriteError as e: - raise HTTPException(status_code=507, detail=str(e)) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.delete('/api/config/ups/{ups_name}') -async def delete_ups_config(ups_name: str): - """Delete UPS configuration""" - try: - success = await config_manager.delete_ups(ups_name) - if not success: - raise HTTPException(status_code=404, detail="UPS not found") - return {"message": "UPS configuration deleted successfully"} - except ConfigWriteError as e: - raise HTTPException(status_code=507, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.post('/api/config/ups/{ups_name}/test') -async def test_ups_connection(ups_name: str): - """Test UPS connection""" - try: - ups = await config_manager.get_ups(ups_name) - if not ups: - raise HTTPException(status_code=404, detail="UPS not found") - - result = await config_manager.validate_ups_connection(ups) - return result - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.post('/api/config/ups/test') -async def test_new_ups_connection(ups_config: UPSConfig): - """Test new UPS configuration connection""" - try: - result = await config_manager.validate_ups_connection(ups_config) - return result - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.get('/api/config/smtp') -async def get_smtp_config(): - """Get SMTP configuration""" - try: - smtp = await config_manager.get_smtp_config() - return smtp.model_dump() if smtp else None - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.get('/api/config/ui') -async def get_ui_config(): - cfg = load_config() - return cfg.ui.model_dump() - - -@app.put('/api/config/ui') -async def update_ui_config(payload: dict): - # Simple partial update of UI flags - from .config import AppConfig - cfg = load_config() - ui_dict = cfg.ui.model_dump() - allowed = set(ui_dict.keys()) - for k, v in payload.items(): - if k in allowed and isinstance(v, bool): - ui_dict[k] = v - # Reconstruct full config and save via manager - new_cfg = AppConfig( - ups=cfg.ups, smtp=cfg.smtp, ui=cfg.ui.__class__(**ui_dict) + application.mount( + "/static", StaticFiles(directory="app/static"), name="static" ) - await config_manager.save_config(new_cfg) - return {"message": "UI config updated", "ui": ui_dict} + + # Routers + application.include_router(pages.router) + application.include_router(api_auth.router) + application.include_router(api_health.router) + application.include_router(api_config.router) + application.include_router(api_ups.router) + application.include_router(api_alerts.router) + application.include_router(api_events.router) + application.include_router(sse.router) + + @application.exception_handler(Exception) + async def unhandled_exc(request: Request, exc: Exception): # noqa: ARG001 + logger.exception("Unhandled error", extra={"path": request.url.path}) + return JSONResponse({"detail": "Internal server error"}, status_code=500) + + return application -# --- UI Tile Layout Persistence --- -@app.get('/api/ups/{ups_name}/ui_tiles') -async def get_ups_ui_tiles(ups_name: str): - """Return persisted UI tile settings for a UPS. - - Includes: types, order, hidden, custom. Falls back to empty defaults if - not stored yet. - """ - r = get_redis() - key = f"ups:ui:tiles:{ups_name}" - raw = r.get(key) - if not raw: - return { - "types": {}, - "order": [], - "hidden": [], - "custom": [], - "positions": {} - } - try: - data = orjson.loads(raw) - # Basic shape validation - if not isinstance(data, dict): - raise ValueError - return { - "types": data.get("types", {}), - "order": data.get("order", []), - "hidden": data.get("hidden", []), - "custom": data.get("custom", []), - "positions": data.get("positions", {}), - } - except Exception: - return { - "types": {}, - "order": [], - "hidden": [], - "custom": [], - "positions": {} - } - - -@app.post('/api/ups/{ups_name}/ui_tiles') -async def save_ups_ui_tiles(ups_name: str, payload: dict): - """Persist UI tile settings for a UPS. - - Expects JSON: { types: {...}, order: [...], hidden: [...], custom: [...] } - """ - # Light validation / sanitization - types = (payload.get('types') - if isinstance(payload.get('types'), dict) else {}) - order = (payload.get('order') - if isinstance(payload.get('order'), list) else []) - hidden = (payload.get('hidden') - if isinstance(payload.get('hidden'), list) else []) - custom = (payload.get('custom') - if isinstance(payload.get('custom'), list) else []) - positions = (payload.get('positions') - if isinstance(payload.get('positions'), dict) else {}) - # Ensure custom entries minimally formed - norm_custom = [] - for c in custom or []: - if not isinstance(c, dict): - continue - metric = c.get('metric') - chart = c.get('chart') - cid = c.get('id') or '' - source = c.get('source', 'live') - if not metric or not chart: - continue - norm_custom.append({ - 'id': cid, - 'metric': metric, - 'chart': chart, - 'source': source - }) - doc = { - 'types': types, - 'order': order, - 'hidden': hidden, - 'custom': norm_custom, - 'positions': positions, - 'saved_ts': int(time.time()) - } - r = get_redis() - key = f"ups:ui:tiles:{ups_name}" - r.set(key, orjson.dumps(doc)) - return {"message": "saved", "count_custom": len(norm_custom)} - - -@app.delete('/api/ups/{ups_name}/ui_tiles') -async def clear_ups_ui_tiles(ups_name: str): - """Delete stored UI tile layout/settings for a UPS (full reset).""" - r = get_redis() - key = f"ups:ui:tiles:{ups_name}" - r.delete(key) - return {"message": "cleared"} +app = create_app() diff --git a/app/metrics.py b/app/metrics.py new file mode 100644 index 0000000..18f6aea --- /dev/null +++ b/app/metrics.py @@ -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() diff --git a/app/notifications/__init__.py b/app/notifications/__init__.py new file mode 100644 index 0000000..3ac81e7 --- /dev/null +++ b/app/notifications/__init__.py @@ -0,0 +1 @@ +"""Notification channels.""" diff --git a/app/notifications/email.py b/app/notifications/email.py new file mode 100644 index 0000000..7da21f9 --- /dev/null +++ b/app/notifications/email.py @@ -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 diff --git a/app/poller.py b/app/poller.py index caef5ca..1580950 100644 --- a/app/poller.py +++ b/app/poller.py @@ -1,17 +1,114 @@ +"""Per-UPS async polling, event/energy/battery-history tracking. + +Connection health: + ups:health:last_ok: - unix ts of last successful poll + ups:health:fail_count: - consecutive failures + ups:health:offline: - "1" if we've emitted a COMMLOST alert + +Battery history: + ups:battery:history: - JSON {ts,bcharge,timeleft,battv,nombattv} + capped at 10080 entries (~7d @ 1/min) +""" from __future__ import annotations + import asyncio -import time +import json import logging +import time + +from .alerts import Alert, dispatch_alerts, emit_info, process_alerts +from .apc_cli import APCStatusError, fetch_status from .config import load_config -from .apc_cli import fetch_status, APCStatusError -from .storage import store_snapshot, prune_old, get_redis -from .alerts import process_alerts +from .storage import get_redis, prune_old, store_snapshot logger = logging.getLogger(__name__) _ACTIVE_TASKS: dict[str, asyncio.Task] = {} _RELOADER_LOCK = asyncio.Lock() +BATTERY_HISTORY_MAX = 10080 # 7 days @ 1/minute +HEALTH_OFFLINE_MIN_SECONDS = 180 + + +def _get_offline_threshold(interval_seconds: int) -> int: + return max(HEALTH_OFFLINE_MIN_SECONDS, 3 * interval_seconds) + + +def _record_poll_success(name: str, snapshot: dict, interval_seconds: int) -> None: + r = get_redis() + now = int(time.time()) + r.set(f"ups:health:last_ok:{name}", now) + r.delete(f"ups:health:fail_count:{name}") + was_offline = r.get(f"ups:health:offline:{name}") + if was_offline: + r.delete(f"ups:health:offline:{name}") + emit_info(name, "REACHABLE", f"UPS {name} reachable again") + # Battery history append (sample every ~60s to keep list bounded) + last_sample_key = f"ups:battery:history:last_ts:{name}" + last_ts_raw = r.get(last_sample_key) + try: + last_ts = int(last_ts_raw) if last_ts_raw else 0 + except ValueError: + last_ts = 0 + if now - last_ts >= 60: + try: + sample = { + "ts": now, + "bcharge": _f(snapshot.get("BCHARGE")), + "timeleft": _f(snapshot.get("TIMELEFT")), + "battv": _f(snapshot.get("BATTV")), + "nombattv": _f(snapshot.get("NOMBATTV")), + } + r.lpush(f"ups:battery:history:{name}", json.dumps(sample)) + r.ltrim(f"ups:battery:history:{name}", 0, BATTERY_HISTORY_MAX - 1) + r.set(last_sample_key, now) + except Exception: # pragma: no cover + pass + + +def _record_poll_failure(name: str, interval_seconds: int, reason: str) -> None: + r = get_redis() + now = int(time.time()) + r.incr(f"ups:health:fail_count:{name}") + last_ok = r.get(f"ups:health:last_ok:{name}") + try: + last_ok_ts = int(last_ok) if last_ok else 0 + except ValueError: + last_ok_ts = 0 + threshold = _get_offline_threshold(interval_seconds) + # If we never succeeded, base threshold on process start (use last_ok=0 => big delta) + if now - last_ok_ts > threshold: + offline_flag = r.get(f"ups:health:offline:{name}") + if not offline_flag: + r.set(f"ups:health:offline:{name}", "1") + alert = Alert( + "CRITICAL", + f"UPS {name} unreachable ({reason})", + "COMMLOST", + name, + now, + ) + cfg = load_config() + r.lpush( + "ups:alerts:history:all", + f"{alert.ts}|CRITICAL|{alert.ups}|COMMLOST|{alert.message}|{alert.hash()}", + ) + r.ltrim("ups:alerts:history:all", 0, 499) + r.lpush( + f"ups:alerts:recent:{name}", + f"{alert.ts}|CRITICAL|{alert.message}", + ) + r.ltrim(f"ups:alerts:recent:{name}", 0, 49) + if cfg.smtp: + dispatch_alerts(cfg.smtp, name, [alert]) + + +def _f(v): + try: + return float(str(v).split()[0]) if v is not None else None + except Exception: + return None + async def _poll_one(ups): r = get_redis() @@ -21,7 +118,6 @@ async def _poll_one(ups): try: data = await fetch_status(ups.host, ups.port) data['UPSNAME'] = ups.name - # Derived metrics try: loadpct = float(str(data.get('LOADPCT', '0')).split()[0]) except Exception: @@ -33,19 +129,15 @@ async def _poll_one(ups): pass if nompower and loadpct >= 0: watts = nompower * loadpct / 100.0 - data['DERIVED_WATTS'] = f"{watts:.0f}" # integer string + data['DERIVED_WATTS'] = f"{watts:.0f}" data['HEADROOM_PCT'] = f"{max(0.0, 100.0 - loadpct):.0f}" - # Runtime minutes (normalize TIMELEFT like '15.0 Minutes') timeleft_raw = str(data.get('TIMELEFT', '')).strip() try: runtime_min = float(timeleft_raw.split()[0]) data['RUNTIME_MINUTES'] = f"{runtime_min:.1f}" except Exception: pass - # Event detection (status changes, last transfer changes) - # Reuse redis handle r - now_ts = asyncio.get_event_loop().time() - wall_ts = int(now_ts) + wall_ts = int(time.time()) status_key = f"ups:event:status:last:{ups.name}" lastxfer_key = f"ups:event:lastxfer:last:{ups.name}" events_list_key = f"ups:event:list:{ups.name}" @@ -55,53 +147,39 @@ async def _poll_one(ups): if prev_status != status_now and status_now: r.set(status_key, status_now) r.lpush(events_list_key, f"{wall_ts}|STATUS|{status_now}") + # Transition from ONBATT -> ONLINE: info alert + if prev_status and "ONBATT" in str(prev_status) and "ONLINE" in status_now: + emit_info( + ups.name, + "LINE_RESTORED", + f"UPS {ups.name} returned to line power", + ) lastxfer_now = str(data.get('LASTXFER', '')).strip() prev_lastxfer = r.get(lastxfer_key) if lastxfer_now and lastxfer_now != prev_lastxfer: r.set(lastxfer_key, lastxfer_now) r.lpush(events_list_key, f"{wall_ts}|XFER|{lastxfer_now}") - # Trim events r.ltrim(events_list_key, 0, max_events - 1) - # Energy accumulation (watt-seconds) if 'DERIVED_WATTS' in data: try: watts = float(data['DERIVED_WATTS']) day_str = time.strftime('%Y%m%d') energy_key = f"ups:energy:{ups.name}:{day_str}" - # increment by watts * interval_seconds (approx) r.incrbyfloat(energy_key, watts * ups.interval_seconds) r.expire(energy_key, 3 * 24 * 3600) - # Per-minute accumulation minute = time.strftime('%Y%m%d%H%M') - # Running sum and count in a hash mb = r.hgetall(minute_bucket_key) if not mb or mb.get('minute') != minute: - # finalize previous bucket - if ( - mb - and 'sum' in mb - and 'count' in mb - and 'minute' in mb - ): + if mb and 'sum' in mb and 'count' in mb and 'minute' in mb: try: - avg = float(mb['sum']) / max( - 1, int(mb['count']) - ) - r.lpush( - series_key, - f"{mb['minute']}|{avg:.2f}" - ) - # keep up to 24h of minutes + avg = float(mb['sum']) / max(1, int(mb['count'])) + r.lpush(series_key, f"{mb['minute']}|{avg:.2f}") r.ltrim(series_key, 0, 1439) except Exception: pass r.hset( minute_bucket_key, - mapping={ - 'minute': minute, - 'sum': watts, - 'count': 1, - }, + mapping={'minute': minute, 'sum': watts, 'count': 1}, ) r.expire(minute_bucket_key, 26 * 3600) else: @@ -121,36 +199,47 @@ async def _poll_one(ups): except Exception: pass await store_snapshot(ups.name, data) + _record_poll_success(ups.name, data, ups.interval_seconds) process_alerts(ups, data) + except asyncio.CancelledError: + raise except Exception as e: + reason = str(e) if not isinstance(e, APCStatusError) else str(e) if isinstance(e, APCStatusError): logger.warning("apcaccess error for %s: %s", ups.name, e) else: logger.warning("Polling error for %s: %s", ups.name, e) + _record_poll_failure(ups.name, ups.interval_seconds, reason) await asyncio.sleep(ups.interval_seconds) async def _reconcile_tasks(): - """Ensure a polling task exists per configured UPS and remove stale ones. - - Creates tasks for new UPS entries and cancels tasks whose UPS were removed. - """ async with _RELOADER_LOCK: cfg = load_config() - current_names = {u.name for u in cfg.ups} - # cancel removed + current = {u.name: (u.host, u.port, u.interval_seconds) for u in cfg.ups} + # cancel removed or changed for name in list(_ACTIVE_TASKS.keys()): - if name not in current_names: + if name not in current: _ACTIVE_TASKS[name].cancel() del _ACTIVE_TASKS[name] - # add new for ups in cfg.ups: if ups.name not in _ACTIVE_TASKS: _ACTIVE_TASKS[ups.name] = asyncio.create_task(_poll_one(ups)) +async def cancel_all_tasks() -> None: + """Cancel all running poller tasks (used on shutdown).""" + for t in _ACTIVE_TASKS.values(): + t.cancel() + for _name, t in list(_ACTIVE_TASKS.items()): + try: + await t + except (asyncio.CancelledError, Exception): + pass + _ACTIVE_TASKS.clear() + + async def poll_loop(): - # initial reconcile await _reconcile_tasks() async def prune_loop(): @@ -162,19 +251,13 @@ async def poll_loop(): await asyncio.sleep(3600) async def config_watch_loop(): - """Periodically re-read config to capture UPS CRUD changes.""" last_fingerprint = None while True: try: cfg = load_config() fingerprint = tuple( sorted( - ( - u.name, - u.host, - u.port, - u.interval_seconds, - ) + (u.name, u.host, u.port, u.interval_seconds) for u in cfg.ups ) ) @@ -188,5 +271,5 @@ async def poll_loop(): await asyncio.gather( prune_loop(), config_watch_loop(), - *(_ACTIVE_TASKS.values()), + return_exceptions=True, ) diff --git a/app/rate_limit.py b/app/rate_limit.py new file mode 100644 index 0000000..b38b6ca --- /dev/null +++ b/app/rate_limit.py @@ -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=[], +) diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..e786aef --- /dev/null +++ b/app/routes/__init__.py @@ -0,0 +1 @@ +"""Route modules split by concern.""" diff --git a/app/routes/api_alerts.py b/app/routes/api_alerts.py new file mode 100644 index 0000000..6c16291 --- /dev/null +++ b/app/routes/api_alerts.py @@ -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"} diff --git a/app/routes/api_auth.py b/app/routes/api_auth.py new file mode 100644 index 0000000..e077b91 --- /dev/null +++ b/app/routes/api_auth.py @@ -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} diff --git a/app/routes/api_config.py b/app/routes/api_config.py new file mode 100644 index 0000000..d7a066a --- /dev/null +++ b/app/routes/api_config.py @@ -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()} diff --git a/app/routes/api_events.py b/app/routes/api_events.py new file mode 100644 index 0000000..5ed96e8 --- /dev/null +++ b/app/routes/api_events.py @@ -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] diff --git a/app/routes/api_health.py b/app/routes/api_health.py new file mode 100644 index 0000000..0b55e4a --- /dev/null +++ b/app/routes/api_health.py @@ -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) diff --git a/app/routes/api_ups.py b/app/routes/api_ups.py new file mode 100644 index 0000000..b7719a7 --- /dev/null +++ b/app/routes/api_ups.py @@ -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"} diff --git a/app/routes/pages.py b/app/routes/pages.py new file mode 100644 index 0000000..0affd70 --- /dev/null +++ b/app/routes/pages.py @@ -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}) diff --git a/app/routes/sse.py b/app/routes/sse.py new file mode 100644 index 0000000..e81a1e7 --- /dev/null +++ b/app/routes/sse.py @@ -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") diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..e8825c0 --- /dev/null +++ b/app/security.py @@ -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 diff --git a/app/settings.py b/app/settings.py new file mode 100644 index 0000000..4edafa9 --- /dev/null +++ b/app/settings.py @@ -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() diff --git a/app/static/css/base.css b/app/static/css/base.css new file mode 100644 index 0000000..fd33143 --- /dev/null +++ b/app/static/css/base.css @@ -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; } diff --git a/app/static/css/login.css b/app/static/css/login.css new file mode 100644 index 0000000..3f69d39 --- /dev/null +++ b/app/static/css/login.css @@ -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; } diff --git a/app/static/js/alerts.js b/app/static/js/alerts.js new file mode 100644 index 0000000..3ba44d3 --- /dev/null +++ b/app/static/js/alerts.js @@ -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(); +})(); diff --git a/app/static/js/app.js b/app/static/js/app.js new file mode 100644 index 0000000..b10b732 --- /dev/null +++ b/app/static/js/app.js @@ -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'; + } + }); +})(); diff --git a/app/static/js/config.js b/app/static/js/config.js index 1c7c053..74976df 100644 --- a/app/static/js/config.js +++ b/app/static/js/config.js @@ -1,174 +1,113 @@ -// Configuration management JavaScript +// UPS Configuration page (uses apiFetch for CSRF) + +function el(tag, attrs, ...children) { + const e = document.createElement(tag); + if (attrs) Object.entries(attrs).forEach(([k, v]) => { + if (k === 'class') e.className = v; + else if (k === 'dataset') Object.assign(e.dataset, v); + else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2), v); + else if (v !== null && v !== undefined) e.setAttribute(k, v); + }); + children.forEach(c => { + if (c == null) return; + e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); + }); + return e; +} class ConfigManager { - constructor() { - this.init(); - } + constructor() { this.confirmCallback = null; this.init(); } - init() { - this.bindEvents(); - this.loadUPSList(); - } + init() { this.bindEvents(); this.loadUPSList(); } bindEvents() { - // Add UPS button - document.getElementById('add-ups-btn').addEventListener('click', () => { - this.showUPSModal(); - }); - - // Modal close buttons + document.getElementById('add-ups-btn').addEventListener('click', () => this.showUPSModal()); document.querySelectorAll('.close').forEach(close => { - close.addEventListener('click', (e) => { - this.closeModal(e.target.closest('.modal')); - }); + close.addEventListener('click', (e) => this.closeModal(e.target.closest('.modal'))); }); - - // UPS form submission document.getElementById('ups-form').addEventListener('submit', (e) => { - e.preventDefault(); - this.saveUPS(); + e.preventDefault(); this.saveUPS(); }); - - // Test connection button - document.getElementById('test-connection-btn').addEventListener('click', () => { - this.testConnection(); - }); - - // Cancel button + document.getElementById('test-connection-btn').addEventListener('click', () => this.testConnection()); document.getElementById('cancel-btn').addEventListener('click', () => { this.closeModal(document.getElementById('ups-modal')); }); - - // Confirmation modal buttons document.getElementById('confirm-yes').addEventListener('click', () => { - if (this.confirmCallback) { - this.confirmCallback(); - } + if (this.confirmCallback) this.confirmCallback(); this.closeModal(document.getElementById('confirm-modal')); }); - document.getElementById('confirm-no').addEventListener('click', () => { this.closeModal(document.getElementById('confirm-modal')); }); - - // Click outside modal to close window.addEventListener('click', (e) => { - if (e.target.classList.contains('modal')) { - this.closeModal(e.target); - } + if (e.target.classList.contains('modal')) this.closeModal(e.target); }); } async loadUPSList() { try { - const response = await fetch('/api/config/ups'); - const upsList = await response.json(); - - if (!response.ok) { - throw new Error(upsList.detail || 'Failed to load UPS configurations'); - } - - this.renderUPSList(upsList); - } catch (error) { - this.showToast('Error loading UPS configurations: ' + error.message, 'error'); - } + const resp = await window.apiFetch('/api/config/ups'); + const list = await resp.json(); + if (!resp.ok) throw new Error(list.detail || 'Failed to load'); + this.renderUPSList(list); + } catch (err) { this.showToast('Error: ' + err.message, 'error'); } } - renderUPSList(upsList) { + renderUPSList(list) { const container = document.getElementById('ups-list'); - - if (upsList.length === 0) { - container.innerHTML = ` -
-

No UPS Configured

-

Get started by clicking "Add UPS" above to configure your first UPS monitoring.

-
- `; + container.textContent = ''; + if (list.length === 0) { + const empty = el('div', { class: 'empty-state' }, + el('h3', null, 'No UPS Configured'), + el('p', null, 'Click "Add UPS" to configure your first UPS.')); + container.appendChild(empty); return; } - - container.innerHTML = upsList.map(ups => this.renderUPSItem(ups)).join(''); - - // Bind action buttons - container.querySelectorAll('.edit-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - const upsName = e.target.dataset.upsName; - this.editUPS(upsName); - }); - }); - - container.querySelectorAll('.delete-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - const upsName = e.target.dataset.upsName; - this.confirmDelete(upsName); - }); - }); - - container.querySelectorAll('.test-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - const upsName = e.target.dataset.upsName; - this.testUPSConnection(upsName); - }); - }); + list.forEach(ups => container.appendChild(this.renderUPSItem(ups))); } renderUPSItem(ups) { - const alertsEnabled = [ - ups.alert_loadpct_high && `Load: ${ups.alert_loadpct_high}%`, - ups.alert_bcharge_low && `Battery: ${ups.alert_bcharge_low}%`, - ups.alert_on_battery && 'On Battery', - ups.alert_runtime_low_minutes && `Runtime: ${ups.alert_runtime_low_minutes}min` - ].filter(Boolean); - - return ` -
-
-
${ups.name}
-
- - - -
-
-
-
-
Host
-
${ups.host}:${ups.port}
-
-
-
Polling Interval
-
${ups.interval_seconds}s
-
-
-
Alerts
-
${alertsEnabled.length > 0 ? alertsEnabled.join(', ') : 'None'}
-
-
-
- `; + const alerts = []; + if (ups.alert_loadpct_high) alerts.push('Load: ' + ups.alert_loadpct_high + '%'); + if (ups.alert_bcharge_low) alerts.push('Battery: ' + ups.alert_bcharge_low + '%'); + if (ups.alert_on_battery) alerts.push('On Battery'); + if (ups.alert_runtime_low_minutes) alerts.push('Runtime: ' + ups.alert_runtime_low_minutes + 'min'); + if (ups.alert_itemp_high) alerts.push('Temp: ' + ups.alert_itemp_high + '°C'); + const testBtn = el('button', { class: 'btn btn-secondary', onclick: () => this.testUPSConnection(ups.name) }, 'Test'); + const editBtn = el('button', { class: 'btn btn-primary', onclick: () => this.editUPS(ups.name) }, 'Edit'); + const delBtn = el('button', { class: 'btn btn-danger', onclick: () => this.confirmDelete(ups.name) }, 'Delete'); + const item = el('div', { class: 'ups-item' }, + el('div', { class: 'ups-item-header' }, + el('div', { class: 'ups-item-title' }, ups.name), + el('div', { class: 'ups-item-actions' }, testBtn, editBtn, delBtn)), + el('div', { class: 'ups-item-details' }, + el('div', { class: 'ups-detail' }, + el('div', { class: 'ups-detail-label' }, 'Host'), + el('div', { class: 'ups-detail-value' }, ups.host + ':' + ups.port)), + el('div', { class: 'ups-detail' }, + el('div', { class: 'ups-detail-label' }, 'Polling Interval'), + el('div', { class: 'ups-detail-value' }, ups.interval_seconds + 's')), + el('div', { class: 'ups-detail' }, + el('div', { class: 'ups-detail-label' }, 'Alerts'), + el('div', { class: 'ups-detail-value' }, alerts.length ? alerts.join(', ') : 'None')))); + return item; } showUPSModal(ups = null) { const modal = document.getElementById('ups-modal'); const title = document.getElementById('modal-title'); const form = document.getElementById('ups-form'); - - // Reset form form.reset(); - if (ups) { - // Edit mode title.textContent = 'Edit UPS'; this.populateForm(ups); form.dataset.mode = 'edit'; form.dataset.upsName = ups.name; } else { - // Add mode title.textContent = 'Add UPS'; form.dataset.mode = 'add'; delete form.dataset.upsName; } - modal.style.display = 'block'; } @@ -179,222 +118,107 @@ class ConfigManager { document.getElementById('ups-interval').value = ups.interval_seconds || 30; document.getElementById('ups-loadpct').value = ups.alert_loadpct_high || ''; document.getElementById('ups-bcharge').value = ups.alert_bcharge_low || ''; - document.getElementById('ups-onbattery').checked = ups.alert_on_battery || false; + document.getElementById('ups-onbattery').checked = !!ups.alert_on_battery; document.getElementById('ups-runtime').value = ups.alert_runtime_low_minutes || ''; + const itemp = document.getElementById('ups-itemp'); + if (itemp) itemp.value = ups.alert_itemp_high || ''; } - closeModal(modal) { - modal.style.display = 'none'; - } + closeModal(modal) { modal.style.display = 'none'; } async saveUPS() { const form = document.getElementById('ups-form'); - const formData = new FormData(form); + const fd = new FormData(form); const mode = form.dataset.mode; - const upsName = form.dataset.upsName; - + const name = form.dataset.upsName; const data = { - name: formData.get('name'), - host: formData.get('host'), - port: parseInt(formData.get('port')) || 3551, - interval_seconds: parseInt(formData.get('interval_seconds')) || 30, - alert_on_battery: formData.has('alert_on_battery') + name: fd.get('name'), + host: fd.get('host'), + port: parseInt(fd.get('port')) || 3551, + interval_seconds: parseInt(fd.get('interval_seconds')) || 30, + alert_on_battery: fd.has('alert_on_battery'), }; - - // Add optional alert thresholds - const loadpct = formData.get('alert_loadpct_high'); - if (loadpct) data.alert_loadpct_high = parseFloat(loadpct); - - const bcharge = formData.get('alert_bcharge_low'); - if (bcharge) data.alert_bcharge_low = parseFloat(bcharge); - - const runtime = formData.get('alert_runtime_low_minutes'); - if (runtime) data.alert_runtime_low_minutes = parseFloat(runtime); - + const num = (k) => { const v = fd.get(k); if (v) data[k] = parseFloat(v); }; + num('alert_loadpct_high'); num('alert_bcharge_low'); + num('alert_runtime_low_minutes'); num('alert_itemp_high'); try { - let response; - if (mode === 'edit') { - response = await fetch(`/api/config/ups/${upsName}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }); - } else { - response = await fetch('/api/config/ups', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }); - } - - const result = await response.json(); - - if (!response.ok) { - throw new Error(result.detail || 'Failed to save UPS configuration'); - } - + const url = mode === 'edit' ? '/api/config/ups/' + name : '/api/config/ups'; + const method = mode === 'edit' ? 'PUT' : 'POST'; + const resp = await window.apiFetch(url, { method, body: JSON.stringify(data) }); + const result = await resp.json(); + if (!resp.ok) throw new Error(result.detail || 'Save failed'); this.showToast(result.message, 'success'); this.closeModal(document.getElementById('ups-modal')); this.loadUPSList(); - } catch (error) { - this.showToast('Error saving UPS: ' + error.message, 'error'); - } + } catch (err) { this.showToast('Error: ' + err.message, 'error'); } } - async editUPS(upsName) { + async editUPS(name) { try { - const response = await fetch(`/api/config/ups/${upsName}`); - const ups = await response.json(); - - if (!response.ok) { - throw new Error(ups.detail || 'Failed to load UPS configuration'); - } - + const resp = await window.apiFetch('/api/config/ups/' + name); + const ups = await resp.json(); + if (!resp.ok) throw new Error(ups.detail || 'Load failed'); this.showUPSModal(ups); - } catch (error) { - this.showToast('Error loading UPS configuration: ' + error.message, 'error'); - } + } catch (err) { this.showToast('Error: ' + err.message, 'error'); } } - confirmDelete(upsName) { - const modal = document.getElementById('confirm-modal'); - const message = document.getElementById('confirm-message'); - - message.textContent = `Are you sure you want to delete the UPS configuration "${upsName}"? This action cannot be undone.`; - - this.confirmCallback = () => this.deleteUPS(upsName); - modal.style.display = 'block'; + confirmDelete(name) { + document.getElementById('confirm-message').textContent = + 'Delete UPS "' + name + '"? This cannot be undone.'; + this.confirmCallback = () => this.deleteUPS(name); + document.getElementById('confirm-modal').style.display = 'block'; } - async deleteUPS(upsName) { + async deleteUPS(name) { try { - const response = await fetch(`/api/config/ups/${upsName}`, { - method: 'DELETE' - }); - - const result = await response.json(); - - if (!response.ok) { - throw new Error(result.detail || 'Failed to delete UPS configuration'); - } - + const resp = await window.apiFetch('/api/config/ups/' + name, { method: 'DELETE' }); + const result = await resp.json(); + if (!resp.ok) throw new Error(result.detail || 'Delete failed'); this.showToast(result.message, 'success'); this.loadUPSList(); - } catch (error) { - this.showToast('Error deleting UPS: ' + error.message, 'error'); - } + } catch (err) { this.showToast('Error: ' + err.message, 'error'); } } async testConnection() { const form = document.getElementById('ups-form'); - const formData = new FormData(form); - + const fd = new FormData(form); const data = { - name: formData.get('name') || 'test', - host: formData.get('host'), - port: parseInt(formData.get('port')) || 3551, + name: fd.get('name') || 'test', + host: fd.get('host'), + port: parseInt(fd.get('port')) || 3551, interval_seconds: 30, - alert_on_battery: false + alert_on_battery: false, }; - - if (!data.host) { - this.showToast('Host is required for connection test', 'error'); - return; - } - - const testBtn = document.getElementById('test-connection-btn'); - const originalText = testBtn.textContent; - testBtn.textContent = 'Testing...'; - testBtn.disabled = true; - + if (!data.host) { this.showToast('Host required', 'error'); return; } + const btn = document.getElementById('test-connection-btn'); + const orig = btn.textContent; + btn.textContent = 'Testing...'; btn.disabled = true; try { - const response = await fetch('/api/config/ups/test', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) + const resp = await window.apiFetch('/api/config/ups/test', { + method: 'POST', body: JSON.stringify(data), }); - - const result = await response.json(); - let msg; - if (result.success) { - msg = 'Connection successful'; - if (result.data && result.data.STATUS) { - msg += ` (STATUS=${result.data.STATUS})`; - } - this.showToast(msg, 'success'); - } else { - if (result.connectivity && !result.connectivity.ok) { - msg = 'TCP connectivity failed: ' + (result.connectivity.error || 'unknown error'); - } else if (result.protocol && !result.protocol.ok) { - msg = 'Protocol error after TCP success: ' + (result.protocol.error || 'unknown error'); - } else { - msg = result.message || 'Connection failed'; - } - this.showToast(msg, 'error'); - } - } catch (error) { - this.showToast('Connection test failed: ' + error.message, 'error'); - } finally { - testBtn.textContent = originalText; - testBtn.disabled = false; - } + const r = await resp.json(); + this.showToast(r.message || (r.success ? 'OK' : 'Failed'), + r.success ? 'success' : 'error'); + } catch (err) { this.showToast('Test failed: ' + err.message, 'error'); } + finally { btn.textContent = orig; btn.disabled = false; } } - async testUPSConnection(upsName) { - const testBtn = document.querySelector(`[data-ups-name="${upsName}"].test-btn`); - const originalText = testBtn.textContent; - testBtn.textContent = 'Testing...'; - testBtn.disabled = true; - + async testUPSConnection(name) { try { - const response = await fetch(`/api/config/ups/${upsName}/test`, { - method: 'POST' - }); - - const result = await response.json(); - let msg; - if (result.success) { - msg = `Connection to ${upsName} successful`; - if (result.data && result.data.STATUS) { - msg += ` (STATUS=${result.data.STATUS})`; - } - this.showToast(msg, 'success'); - } else { - if (result.connectivity && !result.connectivity.ok) { - msg = `TCP connectivity failed: ${result.connectivity.error}`; - } else if (result.protocol && !result.protocol.ok) { - msg = `Protocol error: ${result.protocol.error}`; - } else { - msg = result.message || 'Connection failed'; - } - this.showToast(`Connection to ${upsName} failed: ${msg}`, 'error'); - } - } catch (error) { - this.showToast(`Connection test failed: ${error.message}`, 'error'); - } finally { - testBtn.textContent = originalText; - testBtn.disabled = false; - } + const resp = await window.apiFetch('/api/config/ups/' + name + '/test', { method: 'POST' }); + const r = await resp.json(); + this.showToast(r.message || (r.success ? 'OK' : 'Failed'), + r.success ? 'success' : 'error'); + } catch (err) { this.showToast('Test failed: ' + err.message, 'error'); } } showToast(message, type = 'info') { const container = document.getElementById('toast-container'); - const toast = document.createElement('div'); - toast.className = `toast ${type}`; - toast.textContent = message; - + const toast = el('div', { class: 'toast ' + type }, message); container.appendChild(toast); - - // Auto-remove after 5 seconds - setTimeout(() => { - if (toast.parentNode) { - toast.parentNode.removeChild(toast); - } - }, 5000); + setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 5000); } } -// Initialize when DOM is loaded -document.addEventListener('DOMContentLoaded', () => { - new ConfigManager(); -}); \ No newline at end of file +document.addEventListener('DOMContentLoaded', () => { new ConfigManager(); }); diff --git a/app/static/js/events.js b/app/static/js/events.js new file mode 100644 index 0000000..e264dac --- /dev/null +++ b/app/static/js/events.js @@ -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(); +})(); diff --git a/app/static/js/settings.js b/app/static/js/settings.js new file mode 100644 index 0000000..d794915 --- /dev/null +++ b/app/static/js/settings.js @@ -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(); +})(); diff --git a/app/storage.py b/app/storage.py index 2a657af..7c25861 100644 --- a/app/storage.py +++ b/app/storage.py @@ -1,51 +1,62 @@ from __future__ import annotations -import asyncio -import time -from typing import Dict, Any, List -import redis -import json -import os -REDIS_URL = os.environ.get("REDIS_URL", "redis://redis:6379/0") +import json +import time +from typing import Any + +import redis + +from .settings import settings + RETENTION_SECONDS = 7 * 24 * 3600 -MAX_SAMPLES_PER_UPS = 7 * 24 * 60 * 2 # assume worst-case 30s interval -> ~20160 entries +MAX_SAMPLES_PER_UPS = 7 * 24 * 60 * 2 # worst-case ~30s interval _redis: redis.Redis | None = None + def get_redis() -> redis.Redis: global _redis if _redis: return _redis - _redis = redis.Redis.from_url(REDIS_URL, decode_responses=True) + _redis = redis.Redis.from_url(settings.redis_url, decode_responses=True) return _redis -SNAP_KEY_PREFIX = "ups:snap:" # latest hash per ups -HIST_KEY_PREFIX = "ups:hist:" # time-series list per ups (append JSON) -async def store_snapshot(ups_name: str, data: Dict[str, Any]): +def reset_redis_client() -> None: + """Test hook - clears cached Redis connection.""" + global _redis + _redis = None + + +SNAP_KEY_PREFIX = "ups:snap:" +HIST_KEY_PREFIX = "ups:hist:" + + +async def store_snapshot(ups_name: str, data: dict[str, Any]): r = get_redis() ts = int(time.time()) pipe = r.pipeline() - # store latest snapshot (hash) pipe.hset(f"{SNAP_KEY_PREFIX}{ups_name}", mapping={**data, "_ts": ts}) - # append to history list hist_key = f"{HIST_KEY_PREFIX}{ups_name}" pipe.rpush(hist_key, json.dumps({"ts": ts, "data": data})) pipe.ltrim(hist_key, -MAX_SAMPLES_PER_UPS, -1) - # add pruning via async task (length-based + time-based) pipe.execute() -async def get_latest(ups_name: str) -> Dict[str, Any] | None: + +async def get_latest(ups_name: str) -> dict[str, Any] | None: r = get_redis() h = r.hgetall(f"{SNAP_KEY_PREFIX}{ups_name}") return h or None -async def get_history(ups_name: str, since_seconds: int = RETENTION_SECONDS) -> List[Dict[str, Any]]: + +async def get_history( + ups_name: str, since_seconds: int = RETENTION_SECONDS +) -> list[dict[str, Any]]: r = get_redis() key = f"{HIST_KEY_PREFIX}{ups_name}" raw = r.lrange(key, 0, -1) now = int(time.time()) - out: List[Dict[str, Any]] = [] + out: list[dict[str, Any]] = [] for item in raw: try: obj = json.loads(item) @@ -55,12 +66,12 @@ async def get_history(ups_name: str, since_seconds: int = RETENTION_SECONDS) -> out.append(obj) return out + async def prune_old(): r = get_redis() now = int(time.time()) cutoff = now - RETENTION_SECONDS for key in r.scan_iter(f"{HIST_KEY_PREFIX}*"): - # prune from left while older than cutoff while True: item = r.lindex(key, 0) if not item: diff --git a/app/templates/alerts.html b/app/templates/alerts.html new file mode 100644 index 0000000..6f8a7ab --- /dev/null +++ b/app/templates/alerts.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Alerts — UPS{% endblock %} +{% block content %} + +
+ + + +
TimeSeverityUPSCodeMessageAcked
Loading...
+
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..a7f7206 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,35 @@ + + + + + + {% block title %}UPS Dashboard{% endblock %} + + + + {% block head_extra %}{% endblock %} + + + +
+ {% block content %}{% endblock %} +
+ + {% block scripts %}{% endblock %} + + diff --git a/app/templates/config.html b/app/templates/config.html index 5d44e85..b169d40 100644 --- a/app/templates/config.html +++ b/app/templates/config.html @@ -1,112 +1,88 @@ - - - - - UPS Configuration - - - - -
-

UPS Configuration

- -
+{% extends "base.html" %} +{% block title %}UPS Configuration{% endblock %} +{% block head_extra %} + +{% endblock %} +{% block content %} + -
-
- +
+
+
+ +