Adding the new rewrite
This commit is contained in:
1 parent
4aca9f08d1
commit
9b48b01d1d
61 files changed
+4518
-1414
No files matched your search
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -1,37 +1,63 @@
|
|||||||
name: Build and Push Docker Image
|
name: CI - Lint, Test, and Push Docker Image
|
||||||
# This workflow is manually triggered from the "Actions" tab in GitHub.
|
|
||||||
# It requires a single input: the version tag of the flood repository to use.
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "main" ]
|
branches: [ "main" ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main" ]
|
branches: [ "main" ]
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 0 3 * *' # This runs at midnight on the first day of every month
|
- cron: '0 0 3 * *' # monthly refresh
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
lint-and-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install coverage
|
||||||
|
|
||||||
|
- name: Ruff lint
|
||||||
|
run: ruff check .
|
||||||
|
|
||||||
|
- name: Run pytest with coverage
|
||||||
|
run: |
|
||||||
|
coverage run -m pytest tests/ -v
|
||||||
|
coverage report
|
||||||
|
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint-and-test
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
steps:
|
steps:
|
||||||
- name: Set Image name
|
- name: Set Image name
|
||||||
run: |
|
run: |
|
||||||
echo "IMAGE_NAME=k2patel/apcupsd-client:latest" >> $GITHUB_ENV
|
echo "IMAGE_NAME=k2patel/apcupsd-client:latest" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
- name: Build the Docker image
|
- name: Build the Docker image
|
||||||
id: build
|
id: build
|
||||||
run: |
|
run: |
|
||||||
docker build . -t ${{ env.IMAGE_NAME }}
|
docker build . -t ${{ env.IMAGE_NAME }}
|
||||||
|
|
||||||
- name: Push the Docker image
|
- name: Push the Docker image
|
||||||
run: docker push ${{ env.IMAGE_NAME }}
|
run: docker push ${{ env.IMAGE_NAME }}
|
||||||
+33
-8
@@ -1,23 +1,48 @@
|
|||||||
FROM python:3.12-slim
|
# syntax=docker/dockerfile:1.6
|
||||||
|
FROM python:3.12.7-slim AS builder
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --upgrade pip && pip wheel --wheel-dir /wheels -r requirements.txt
|
||||||
|
|
||||||
|
|
||||||
|
FROM python:3.12.7-slim AS runtime
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends apcupsd curl \
|
||||||
build-essential \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
apcupsd \
|
&& groupadd --system --gid 10001 appuser \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& useradd --system --uid 10001 --gid 10001 --home /app --shell /usr/sbin/nologin appuser
|
||||||
|
|
||||||
|
COPY --from=builder /wheels /wheels
|
||||||
COPY requirements.txt ./
|
COPY requirements.txt ./
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-index --find-links=/wheels -r requirements.txt \
|
||||||
|
&& rm -rf /wheels
|
||||||
|
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
|
|
||||||
|
RUN chown -R appuser:appuser /app
|
||||||
|
USER appuser
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
ENV REDIS_URL=redis://redis:6379/0
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD curl -fsS http://localhost:8000/healthz || exit 1
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -1,148 +1,124 @@
|
|||||||
# APC UPS Dashboard
|
# APC UPS Dashboard
|
||||||
|
|
||||||
A lightweight FastAPI + Redis based dashboard for multiple APC UPS devices using the `apcaccess` CLI (apcupsd Network Information Server mode). Shows real-time metrics, lightweight charts, and maintains 7 days of historical snapshots.
|
A production-ready FastAPI + Redis dashboard for monitoring multiple APC UPS devices via `apcupsd` NIS. Single-admin auth (argon2 + session cookies), CSRF, rate limiting, security headers, SSE live updates, 7-day history, connection-health tracking, battery degradation trends, HTML email alerts with batching and silent-hours, and Prometheus metrics.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- Multiple UPS managed dynamically (stored in Redis; add/update/delete via API/UI)
|
|
||||||
- Polling via `apcaccess` CLI against apcupsd NIS (default port 3551)
|
|
||||||
- Connection test: TCP port reachability (no protocol parsing)
|
|
||||||
- Real-time dashboard (Server Sent Events) updating key metrics
|
|
||||||
- 7-day retention of snapshots in Redis lists
|
|
||||||
- Simple Chart.js load percentage sparkline
|
|
||||||
- Docker & docker-compose deployment (image bundles apcupsd + apcaccess)
|
|
||||||
- SMTP alerting (high load, low battery %, on battery, low runtime) with cooldown
|
|
||||||
|
|
||||||
## Configuration
|
**Monitoring**
|
||||||
|
- Multiple UPS managed dynamically via UI/REST (stored in Redis)
|
||||||
|
- Real-time dashboard with SSE, fleet-overview panel, per-UPS state indicators
|
||||||
|
- 7-day snapshot history, per-minute watts averages, daily energy + cost
|
||||||
|
- Connection-health tracker — COMMLOST detection after 3× interval, recovery INFO alerts
|
||||||
|
- Battery-health sampling (1/min) with 7-day trend + decline %
|
||||||
|
- CSV export (history/events/energy), event log, alert management + ack
|
||||||
|
|
||||||
Configuration is persisted in Redis (key `ups:config:json`). Use the web UI or the REST API to manage UPS entries and SMTP settings. A legacy `config/ups.yaml` (or path set via `UPS_CONFIG_PATH`) is imported once on first startup if Redis has no configuration; after migration the file is no longer written or read.
|
**Alerts (email-only)**
|
||||||
|
- Severity taxonomy: CRITICAL (ONBATT/COMMLOST/RUNTIME_LOW/BCHARGE_LOW), WARNING (LOAD_HIGH/REPLACEBATT/SELFTEST_FAIL/TEMP_HIGH/XFER_BURST/VOLT_DEV), INFO (REACHABLE/LINE_RESTORED)
|
||||||
|
- Coalesced HTML emails (one per UPS per poll cycle), severity-colored
|
||||||
|
- 30-min per-message cooldown, silent-hours deferral (CRITICAL always delivered), retries via tenacity
|
||||||
|
- Test-email button and optional daily summary
|
||||||
|
|
||||||
UPS fields:
|
**Security & ops**
|
||||||
- name (unique)
|
- Single-admin auth (argon2 password, signed session cookie, double-submit CSRF)
|
||||||
- host (apcupsd server hostname / IP)
|
- SSRF host validation (rejects loopback/link-local; private IPs gated by `ALLOW_PRIVATE_IPS`)
|
||||||
- port (default 3551)
|
- SMTP password **only** from env — never persisted to Redis
|
||||||
- interval_seconds (polling interval)
|
- Subprocess timeout on `apcaccess` (10s), rate-limiting on auth/config, security headers + CSP
|
||||||
- Optional alert thresholds: alert_loadpct_high, alert_bcharge_low, alert_on_battery, alert_runtime_low_minutes
|
- Non-root container (UID 10001), pinned `python:3.12.7-slim` multi-stage build
|
||||||
|
- `/healthz`, `/readyz`, `/metrics` (Prometheus), JSON structured logs with request-ID correlation
|
||||||
|
- GitHub Actions pipeline runs ruff + pytest before building/publishing the image
|
||||||
|
|
||||||
SMTP fields (optional): host, port, username, password (or env `SMTP_PASSWORD`), use_tls, use_ssl, from_addr, to_addrs[], subject_prefix.
|
## Quick start (docker-compose)
|
||||||
|
|
||||||
Environment secret: set `SMTP_PASSWORD` instead of storing cleartext.
|
|
||||||
|
|
||||||
Alert suppression: identical alert per UPS suppressed for 30 minutes (cooldown).
|
|
||||||
|
|
||||||
### apcupsd Server Requirements
|
|
||||||
Your remote APC UPS hosts must be running `apcupsd` with the Network Information Server (NIS) enabled. Typical steps (on Linux):
|
|
||||||
|
|
||||||
1. Install apcupsd (example for Debian/Ubuntu):
|
|
||||||
```bash
|
|
||||||
sudo apt-get install apcupsd
|
|
||||||
```
|
|
||||||
2. Edit `/etc/apcupsd/apcupsd.conf` and confirm at least:
|
|
||||||
```
|
|
||||||
UPSTYPE usb # or 'net' / 'snmp' depending on your setup
|
|
||||||
DEVICE # usually blank for USB
|
|
||||||
NISIP 0.0.0.0 # listen on all interfaces (restrict in firewalled env)
|
|
||||||
NISPORT 3551 # must match the configured port (default 3551)
|
|
||||||
NETSERVER on # enable network server
|
|
||||||
# Optional: restrict access (recommended)
|
|
||||||
ACCESS 192.168.1.0/24 # Only allow your monitoring subnet (supported on some builds)
|
|
||||||
```
|
|
||||||
3. Restart service:
|
|
||||||
```bash
|
|
||||||
sudo systemctl restart apcupsd
|
|
||||||
```
|
|
||||||
4. Test locally:
|
|
||||||
```bash
|
|
||||||
apcaccess status
|
|
||||||
nc -vz <server-ip> 3551
|
|
||||||
```
|
|
||||||
|
|
||||||
If you receive connectivity errors in logs:
|
|
||||||
- Verify `NETSERVER on` is set.
|
|
||||||
- Check host firewall (e.g., `ufw allow 3551/tcp`).
|
|
||||||
- Confirm the port is correct and reachable from the container network.
|
|
||||||
- Test manually:
|
|
||||||
```bash
|
|
||||||
nc -vz <server-ip> 3551
|
|
||||||
apcaccess -h <server-ip>:3551 status
|
|
||||||
```
|
|
||||||
|
|
||||||
### Connection Testing Logic
|
|
||||||
Simplified: only a raw TCP connect test. If the port is reachable it's reported as success. Polling uses the `apcaccess` CLI for data collection.
|
|
||||||
|
|
||||||
## Run (docker-compose)
|
|
||||||
```bash
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Required in production — edit .env:
|
||||||
|
# SESSION_SECRET=<run: python -c "import secrets; print(secrets.token_urlsafe(48))">
|
||||||
|
# SMTP_PASSWORD=<your SMTP password, if using alerts>
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
Visit http://localhost:8000
|
|
||||||
|
|
||||||
### Persistence
|
Open http://localhost:10280 — the app redirects to `/setup` where you create the admin account on first boot. Subsequent visits require login.
|
||||||
|
|
||||||
Configuration and historical metrics live in Redis. The provided `docker-compose.yml` now mounts a named volume (`redis-data`) at `/data` inside the Redis container and enables Append Only File (AOF) with `--appendonly yes`.
|
## Environment variables
|
||||||
|
|
||||||
Data will persist across `docker compose down` / `up` cycles as long as you do NOT remove the volume. To explicitly remove all persisted configuration and history you must prune the volume:
|
| Var | Required | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `SESSION_SECRET` | yes (prod) | ephemeral | 32+ random bytes, signs session cookies |
|
||||||
|
| `ADMIN_USERNAME` | no | `admin` | |
|
||||||
|
| `ADMIN_PASSWORD_HASH` | no | unset | Skip setup wizard by pre-seeding an argon2 hash |
|
||||||
|
| `REDIS_URL` | no | `redis://redis:6379/0` | Supports `redis://:pw@host:port/db` |
|
||||||
|
| `SMTP_PASSWORD` | no | unset | Read only from env, never stored |
|
||||||
|
| `ALLOW_PRIVATE_IPS` | no | `true` | Set false to block RFC1918 UPS hosts |
|
||||||
|
| `TRUST_PROXY` | no | `false` | Enable Secure cookies + HSTS when behind HTTPS proxy |
|
||||||
|
| `LOG_LEVEL` | no | `INFO` | DEBUG/INFO/WARNING/ERROR |
|
||||||
|
| `SESSION_MAX_AGE_SECONDS` | no | `1209600` | 14 days |
|
||||||
|
| `RATE_LIMIT_ENABLED` | no | `true` | Disable in tests |
|
||||||
|
| `TZ` | no | `UTC` | |
|
||||||
|
|
||||||
|
Generate values:
|
||||||
|
```bash
|
||||||
|
# SESSION_SECRET
|
||||||
|
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||||
|
# ADMIN_PASSWORD_HASH (optional pre-seed)
|
||||||
|
python -c "from passlib.hash import argon2; print(argon2.hash('mysecret'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
## apcupsd server requirements
|
||||||
|
|
||||||
|
Your remote APC UPS hosts must run `apcupsd` with the Network Information Server (NIS) enabled:
|
||||||
|
|
||||||
|
1. Install: `sudo apt-get install apcupsd`
|
||||||
|
2. Edit `/etc/apcupsd/apcupsd.conf`:
|
||||||
|
```
|
||||||
|
UPSTYPE usb
|
||||||
|
NISIP 0.0.0.0
|
||||||
|
NISPORT 3551
|
||||||
|
NETSERVER on
|
||||||
|
```
|
||||||
|
3. Restart: `sudo systemctl restart apcupsd`
|
||||||
|
4. Test: `apcaccess status` and `nc -vz <server-ip> 3551`
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down
|
python3.12 -m venv .venv && . .venv/bin/activate
|
||||||
docker volume rm apcupsd-client_redis-data # volume name may be prefixed by folder/project
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Run tests (uses fakeredis)
|
||||||
|
pytest tests/
|
||||||
|
|
||||||
|
# With coverage
|
||||||
|
coverage run -m pytest tests/ && coverage report
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
ruff check .
|
||||||
|
|
||||||
|
# Local dev server (needs apcaccess binary + REDIS_URL env)
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
```
|
```
|
||||||
|
|
||||||
If you had been losing configuration previously, ensure you pulled the updated compose file and that the `redis` service includes:
|
## Persistence
|
||||||
|
|
||||||
```yaml
|
|
||||||
redis:
|
|
||||||
volumes:
|
|
||||||
- redis-data:/data
|
|
||||||
command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec"]
|
|
||||||
```
|
|
||||||
|
|
||||||
You can inspect Redis persistence files locally by running:
|
|
||||||
|
|
||||||
|
Config and history live in Redis via an AOF-backed `redis-data` volume. To wipe state:
|
||||||
```bash
|
```bash
|
||||||
docker compose exec redis ls -lh /data
|
docker compose down && docker volume rm apcupsd-client_redis-data
|
||||||
```
|
```
|
||||||
|
|
||||||
If you want to enforce periodic RDB snapshots as well, you can leave default save settings (remove the `--save ""` override). The current configuration uses AOF every second for a balance of durability and write performance.
|
## API (summary)
|
||||||
|
|
||||||
## Data Storage
|
| Path | Auth | Notes |
|
||||||
Redis stores:
|
|---|---|---|
|
||||||
- Latest snapshot hash: `ups:snap:<name>`
|
| `/` (dashboard), `/config`, `/events`, `/alerts`, `/settings` | session | Jinja pages |
|
||||||
- History list (JSON {ts,data}): `ups:hist:<name>`
|
| `/login`, `/setup`, `/logout` | open/session | |
|
||||||
- Per-minute watts averages: `ups:watts:permin:<name>`
|
| `/healthz`, `/readyz`, `/metrics` | open | Prom metrics on `/metrics` |
|
||||||
- Energy (watt-seconds) daily totals: `ups:energy:<name>:YYYYMMDD`
|
| `GET /api/stream` | session | SSE snapshots |
|
||||||
- Events list: `ups:event:list:<name>`
|
| `GET /api/ups`, `/api/ups/{name}/{status\|history\|events\|energy\|health\|battery_health}` | session | |
|
||||||
- Recent alerts: `ups:alerts:recent:<name>`
|
| `GET /api/ups/fleet/overview` | session | Aggregate fleet summary |
|
||||||
- Voltage deviation samples: `ups:volt:dev:samples:<name>`
|
| `GET /api/ups/{name}/export?format=csv&kind=history\|events\|energy&since_days=N` | session | Streaming CSV |
|
||||||
|
| `GET /api/events`, `/api/alerts`, `/api/alerts/active` | session | |
|
||||||
A pruning task runs hourly removing entries older than 7 days.
|
| `POST /api/alerts/{id}/ack` | session+CSRF | |
|
||||||
|
| `GET/POST/PUT/DELETE /api/config/{ups,smtp,ui}/...` | session (+CSRF for writes) | Rate-limited |
|
||||||
## Extending
|
|
||||||
- Add more charts: query `/api/ups/<name>/history`
|
|
||||||
- Add gauges: integrate a JS gauge lib in `dashboard.html`
|
|
||||||
- Alerts: create background task checking thresholds
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
||||||
## Security & Hardening Notes
|
|
||||||
| Area | Current | Recommendation |
|
|
||||||
|------|---------|---------------|
|
|
||||||
| Authentication | None (open dashboard) | Add reverse proxy auth or FastAPI auth if exposed beyond LAN |
|
|
||||||
| Config Storage | Redis (JSON) | Protect Redis with auth / network policy |
|
|
||||||
| Network to apcupsd | Plain TCP | Use network segmentation / firewall; protocol has no encryption |
|
|
||||||
| Redis | No auth configured | Enable AUTH / TLS if crossing trust boundaries |
|
|
||||||
| Input Validation | Pydantic for config schema | Add stricter hostname/IP validation if multi-tenant |
|
|
||||||
| Dependency Versions | Pinned | Review periodically for CVEs |
|
|
||||||
| Logging | Polling errors logged | Avoid logging secrets; sanitize future additions |
|
|
||||||
|
|
||||||
### Additional Notes
|
|
||||||
- Legacy YAML migration (one-time) to Redis; file no longer updated afterward.
|
|
||||||
- Dynamic poller reconciles tasks on config change (no restart needed).
|
|
||||||
- Alert cooldown prevents email flood.
|
|
||||||
- AOF-based Redis persistence keeps configuration across container rebuilds.
|
|
||||||
|
|
||||||
### Suggested Future Enhancements
|
|
||||||
- Optional Basic Auth / OIDC for web UI.
|
|
||||||
- Rate limiting on config mutation endpoints.
|
|
||||||
- CSRF protection if cookies/session auth added later.
|
|
||||||
- Health endpoint (`/healthz`) returning Redis + config status.
|
|
||||||
- Structured logging (JSON) for production observability.
|
|
||||||
+274
-142
@@ -1,105 +1,52 @@
|
|||||||
|
"""Alert evaluation, coalescing, cooldown, silent-hours, and dispatch."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import smtplib
|
|
||||||
import ssl
|
import hashlib
|
||||||
import os
|
import logging
|
||||||
import time
|
import time
|
||||||
from email.message import EmailMessage
|
from dataclasses import asdict, dataclass
|
||||||
from typing import Dict, Any, List
|
from datetime import datetime
|
||||||
from .config import load_config, SMTPConfig, UPSConfig
|
from typing import Any
|
||||||
|
|
||||||
|
from .config import SMTPConfig, UPSConfig, load_config
|
||||||
|
from .notifications.email import EmailSendError, send_alert_email
|
||||||
from .storage import get_redis
|
from .storage import get_redis
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
ALERT_COOLDOWN_SECONDS = 1800 # 30 minutes per distinct alert per UPS
|
ALERT_COOLDOWN_SECONDS = 1800 # 30 minutes per distinct alert per UPS
|
||||||
REDIS_ALERT_KEY_PREFIX = "ups:alert:last:"
|
REDIS_ALERT_KEY_PREFIX = "ups:alert:last:"
|
||||||
|
REDIS_PENDING_KEY_PREFIX = "ups:alerts:pending:" # list of deferred messages
|
||||||
|
REDIS_HISTORY_KEY_PREFIX = "ups:alerts:history:" # global history
|
||||||
|
ALERT_HISTORY_MAX = 500
|
||||||
|
ALERT_HISTORY_TTL_SECONDS = 30 * 24 * 3600
|
||||||
|
|
||||||
STATUS_ON_BATTERY_KEYWORDS = {"ONBATT", "ON BATTERY"}
|
STATUS_ON_BATTERY_KEYWORDS = {"ONBATT", "ON BATTERY"}
|
||||||
|
SELFTEST_FAIL_KEYWORDS = {"FAIL", "BADBATT"}
|
||||||
|
|
||||||
|
SEV_CRITICAL = "CRITICAL"
|
||||||
|
SEV_WARNING = "WARNING"
|
||||||
|
SEV_INFO = "INFO"
|
||||||
|
|
||||||
|
TEMP_HIGH_DEFAULT_C = 50.0
|
||||||
|
|
||||||
|
|
||||||
def evaluate_alerts(ups_cfg: UPSConfig, snapshot: Dict[str, Any]) -> List[str]:
|
@dataclass
|
||||||
messages: List[str] = []
|
class Alert:
|
||||||
# Load % high
|
severity: str
|
||||||
if ups_cfg.alert_loadpct_high is not None:
|
message: str
|
||||||
loadpct = _to_float(snapshot.get('LOADPCT'))
|
code: str
|
||||||
if loadpct is not None and loadpct >= ups_cfg.alert_loadpct_high:
|
ups: str
|
||||||
messages.append(
|
ts: int
|
||||||
"Load percentage high: "
|
|
||||||
f"{loadpct}% >= {ups_cfg.alert_loadpct_high}%"
|
def hash(self) -> str:
|
||||||
)
|
h = hashlib.sha1(f"{self.ups}|{self.code}|{self.message}".encode()).hexdigest()
|
||||||
# Battery charge low
|
return h[:16]
|
||||||
if ups_cfg.alert_bcharge_low is not None:
|
|
||||||
bcharge = _to_float(snapshot.get('BCHARGE'))
|
def as_dict(self) -> dict[str, Any]:
|
||||||
if bcharge is not None and bcharge <= ups_cfg.alert_bcharge_low:
|
d = asdict(self)
|
||||||
messages.append(
|
d["id"] = self.hash()
|
||||||
"Battery charge low: "
|
return d
|
||||||
f"{bcharge}% <= {ups_cfg.alert_bcharge_low}%"
|
|
||||||
)
|
|
||||||
# On battery status
|
|
||||||
if ups_cfg.alert_on_battery:
|
|
||||||
status = str(snapshot.get('STATUS', '')).upper()
|
|
||||||
if any(k in status for k in STATUS_ON_BATTERY_KEYWORDS):
|
|
||||||
messages.append(f"UPS on battery: status={status}")
|
|
||||||
# Runtime low
|
|
||||||
if ups_cfg.alert_runtime_low_minutes is not None:
|
|
||||||
timeleft = snapshot.get('TIMELEFT')
|
|
||||||
# TIMELEFT often like '15.0 Minutes' -> attempt parse
|
|
||||||
runtime = _extract_leading_number(str(timeleft))
|
|
||||||
if (
|
|
||||||
runtime is not None
|
|
||||||
and runtime <= ups_cfg.alert_runtime_low_minutes
|
|
||||||
):
|
|
||||||
messages.append(
|
|
||||||
"Runtime low: "
|
|
||||||
f"{runtime}m <= {ups_cfg.alert_runtime_low_minutes}m"
|
|
||||||
)
|
|
||||||
# Extended alerts based on global UI flags
|
|
||||||
cfg = load_config()
|
|
||||||
ui = cfg.ui if hasattr(cfg, 'ui') else None
|
|
||||||
if ui:
|
|
||||||
r = get_redis()
|
|
||||||
# Transfer burst: count status ONBATT events in last hour
|
|
||||||
if ui.enable_transfer_burst_alert:
|
|
||||||
# We rely on event list already capturing STATUS transitions
|
|
||||||
events = r.lrange(f"ups:event:list:{ups_cfg.name}", 0, 200)
|
|
||||||
now = int(time.time())
|
|
||||||
onbatt_count = 0
|
|
||||||
for ev in events:
|
|
||||||
parts = ev.split('|')
|
|
||||||
if len(parts) >= 3:
|
|
||||||
try:
|
|
||||||
ts = int(parts[0])
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if now - ts > 3600:
|
|
||||||
continue
|
|
||||||
if parts[1] == 'STATUS' and 'ONBATT' in parts[2]:
|
|
||||||
onbatt_count += 1
|
|
||||||
if onbatt_count >= 3: # threshold heuristically chosen
|
|
||||||
messages.append(
|
|
||||||
f"Frequent battery events: {onbatt_count} in last hour"
|
|
||||||
)
|
|
||||||
# Voltage deviation: track LINEV vs nominal, average deviation window
|
|
||||||
if ui.enable_voltage_deviation_alert:
|
|
||||||
linev = _extract_leading_number(str(snapshot.get('LINEV', '')))
|
|
||||||
nom = _extract_leading_number(
|
|
||||||
str(snapshot.get('NOMINV', snapshot.get('NOMINPUT', '')))
|
|
||||||
)
|
|
||||||
if linev and nom:
|
|
||||||
dev_pct = abs(linev - nom) / nom * 100.0
|
|
||||||
dev_key = f"ups:volt:dev:samples:{ups_cfg.name}"
|
|
||||||
r.lpush(dev_key, f"{dev_pct:.2f}")
|
|
||||||
r.ltrim(dev_key, 0, 49)
|
|
||||||
samples = r.lrange(dev_key, 0, -1)
|
|
||||||
try:
|
|
||||||
avg_dev = sum(float(s) for s in samples) / max(
|
|
||||||
1, len(samples)
|
|
||||||
)
|
|
||||||
if avg_dev > 8.0 and len(samples) >= 10:
|
|
||||||
messages.append(
|
|
||||||
"High average voltage deviation: "
|
|
||||||
f"{avg_dev:.1f}% over {len(samples)} samples"
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return messages
|
|
||||||
|
|
||||||
|
|
||||||
def _to_float(val) -> float | None:
|
def _to_float(val) -> float | None:
|
||||||
@@ -111,66 +58,251 @@ def _to_float(val) -> float | None:
|
|||||||
|
|
||||||
def _extract_leading_number(s: str) -> float | None:
|
def _extract_leading_number(s: str) -> float | None:
|
||||||
try:
|
try:
|
||||||
part = s.strip().split()[0]
|
return float(s.strip().split()[0])
|
||||||
return float(part)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _cooldown_key(ups_name: str, msg: str) -> str:
|
def evaluate_alerts(ups_cfg: UPSConfig, snapshot: dict[str, Any]) -> list[Alert]:
|
||||||
return f"{REDIS_ALERT_KEY_PREFIX}{ups_name}:{hash(msg)}"
|
msgs: list[Alert] = []
|
||||||
|
now = int(time.time())
|
||||||
|
name = ups_cfg.name
|
||||||
|
|
||||||
|
def add(sev: str, code: str, message: str) -> None:
|
||||||
|
msgs.append(Alert(sev, message, code, name, now))
|
||||||
|
|
||||||
def send_alert_email(smtp_cfg: SMTPConfig, ups_name: str, messages: List[str]):
|
status = str(snapshot.get("STATUS", "")).upper()
|
||||||
password = smtp_cfg.password or os.environ.get('SMTP_PASSWORD')
|
# ONBATT
|
||||||
if not smtp_cfg.to_addrs:
|
if ups_cfg.alert_on_battery and any(k in status for k in STATUS_ON_BATTERY_KEYWORDS):
|
||||||
return
|
add(SEV_CRITICAL, "ONBATT", f"UPS on battery: status={status}")
|
||||||
subject = f"{smtp_cfg.subject_prefix} {ups_name} alert"
|
# Runtime low
|
||||||
body = "\n".join(messages)
|
if ups_cfg.alert_runtime_low_minutes is not None:
|
||||||
msg = EmailMessage()
|
runtime = _extract_leading_number(str(snapshot.get("TIMELEFT", "")))
|
||||||
msg['Subject'] = subject
|
if runtime is not None and runtime <= ups_cfg.alert_runtime_low_minutes:
|
||||||
msg['From'] = smtp_cfg.from_addr or (smtp_cfg.username or 'ups@example')
|
add(
|
||||||
msg['To'] = ", ".join(smtp_cfg.to_addrs)
|
SEV_CRITICAL,
|
||||||
msg.set_content(body)
|
"RUNTIME_LOW",
|
||||||
|
f"Runtime low: {runtime}m <= {ups_cfg.alert_runtime_low_minutes}m",
|
||||||
|
)
|
||||||
|
# Battery charge low
|
||||||
|
if ups_cfg.alert_bcharge_low is not None:
|
||||||
|
bcharge = _extract_leading_number(str(snapshot.get("BCHARGE", "")))
|
||||||
|
if bcharge is not None and bcharge <= ups_cfg.alert_bcharge_low:
|
||||||
|
add(
|
||||||
|
SEV_CRITICAL,
|
||||||
|
"BCHARGE_LOW",
|
||||||
|
f"Battery charge low: {bcharge}% <= {ups_cfg.alert_bcharge_low}%",
|
||||||
|
)
|
||||||
|
# Load high
|
||||||
|
if ups_cfg.alert_loadpct_high is not None:
|
||||||
|
loadpct = _extract_leading_number(str(snapshot.get("LOADPCT", "")))
|
||||||
|
if loadpct is not None and loadpct >= ups_cfg.alert_loadpct_high:
|
||||||
|
add(
|
||||||
|
SEV_WARNING,
|
||||||
|
"LOAD_HIGH",
|
||||||
|
f"Load high: {loadpct}% >= {ups_cfg.alert_loadpct_high}%",
|
||||||
|
)
|
||||||
|
# REPLACEBATT flag
|
||||||
|
replacebatt = str(snapshot.get("REPLACEBATT", "")).upper()
|
||||||
|
if replacebatt and replacebatt not in {"NO", "", "0"}:
|
||||||
|
add(SEV_WARNING, "REPLACEBATT", "UPS reports battery needs replacement")
|
||||||
|
# SELFTEST fail
|
||||||
|
selftest = str(snapshot.get("SELFTEST", "")).upper()
|
||||||
|
if any(k in selftest for k in SELFTEST_FAIL_KEYWORDS):
|
||||||
|
add(SEV_WARNING, "SELFTEST_FAIL", f"Self-test failure: {selftest}")
|
||||||
|
# Temperature high
|
||||||
|
threshold = ups_cfg.alert_itemp_high or TEMP_HIGH_DEFAULT_C
|
||||||
|
itemp = _extract_leading_number(str(snapshot.get("ITEMP", "")))
|
||||||
|
if itemp is not None and itemp >= threshold:
|
||||||
|
add(
|
||||||
|
SEV_WARNING,
|
||||||
|
"TEMP_HIGH",
|
||||||
|
f"Internal temperature high: {itemp}C >= {threshold}C",
|
||||||
|
)
|
||||||
|
|
||||||
if smtp_cfg.use_ssl:
|
# Extended alerts based on UI flags
|
||||||
context = ssl.create_default_context()
|
|
||||||
with smtplib.SMTP_SSL(
|
|
||||||
smtp_cfg.host, smtp_cfg.port, context=context, timeout=30
|
|
||||||
) as server:
|
|
||||||
if smtp_cfg.username and password:
|
|
||||||
server.login(smtp_cfg.username, password)
|
|
||||||
server.send_message(msg)
|
|
||||||
else:
|
|
||||||
with smtplib.SMTP(smtp_cfg.host, smtp_cfg.port, timeout=30) as server:
|
|
||||||
if smtp_cfg.use_tls:
|
|
||||||
server.starttls(context=ssl.create_default_context())
|
|
||||||
if smtp_cfg.username and password:
|
|
||||||
server.login(smtp_cfg.username, password)
|
|
||||||
server.send_message(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def process_alerts(ups_cfg: UPSConfig, snapshot: Dict[str, Any]):
|
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
if not cfg.smtp:
|
ui = cfg.ui if hasattr(cfg, "ui") else None
|
||||||
return
|
if ui:
|
||||||
msgs = evaluate_alerts(ups_cfg, snapshot)
|
r = get_redis()
|
||||||
if not msgs:
|
if ui.enable_transfer_burst_alert:
|
||||||
|
events = r.lrange(f"ups:event:list:{name}", 0, 200)
|
||||||
|
onbatt_count = 0
|
||||||
|
for ev in events:
|
||||||
|
parts = ev.split("|")
|
||||||
|
if len(parts) >= 3:
|
||||||
|
try:
|
||||||
|
ts = int(parts[0])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if now - ts > 3600:
|
||||||
|
continue
|
||||||
|
if parts[1] == "STATUS" and "ONBATT" in parts[2]:
|
||||||
|
onbatt_count += 1
|
||||||
|
if onbatt_count >= 3:
|
||||||
|
add(
|
||||||
|
SEV_WARNING,
|
||||||
|
"XFER_BURST",
|
||||||
|
f"Frequent battery events: {onbatt_count} in last hour",
|
||||||
|
)
|
||||||
|
if ui.enable_voltage_deviation_alert:
|
||||||
|
linev = _extract_leading_number(str(snapshot.get("LINEV", "")))
|
||||||
|
nom = _extract_leading_number(
|
||||||
|
str(snapshot.get("NOMINV", snapshot.get("NOMINPUT", "")))
|
||||||
|
)
|
||||||
|
if linev and nom:
|
||||||
|
dev_pct = abs(linev - nom) / nom * 100.0
|
||||||
|
dev_key = f"ups:volt:dev:samples:{name}"
|
||||||
|
r.lpush(dev_key, f"{dev_pct:.2f}")
|
||||||
|
r.ltrim(dev_key, 0, 49)
|
||||||
|
samples = r.lrange(dev_key, 0, -1)
|
||||||
|
try:
|
||||||
|
avg_dev = sum(float(s) for s in samples) / max(1, len(samples))
|
||||||
|
if avg_dev > 8.0 and len(samples) >= 10:
|
||||||
|
add(
|
||||||
|
SEV_WARNING,
|
||||||
|
"VOLT_DEV",
|
||||||
|
f"High voltage deviation: {avg_dev:.1f}% over {len(samples)} samples",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return msgs
|
||||||
|
|
||||||
|
|
||||||
|
def _cooldown_key(alert: Alert) -> str:
|
||||||
|
return f"{REDIS_ALERT_KEY_PREFIX}{alert.ups}:{alert.hash()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _in_silent_hours(smtp_cfg: SMTPConfig, now: datetime | None = None) -> bool:
|
||||||
|
if smtp_cfg.silent_hours_start is None or smtp_cfg.silent_hours_end is None:
|
||||||
|
return False
|
||||||
|
if smtp_cfg.silent_hours_start == smtp_cfg.silent_hours_end:
|
||||||
|
return False
|
||||||
|
if now is None:
|
||||||
|
now = datetime.now()
|
||||||
|
h = now.hour
|
||||||
|
s = smtp_cfg.silent_hours_start
|
||||||
|
e = smtp_cfg.silent_hours_end
|
||||||
|
if s < e:
|
||||||
|
return s <= h < e
|
||||||
|
# Wraps past midnight
|
||||||
|
return h >= s or h < e
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_history(alerts: list[Alert]) -> None:
|
||||||
|
if not alerts:
|
||||||
return
|
return
|
||||||
r = get_redis()
|
r = get_redis()
|
||||||
to_send: List[str] = []
|
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())
|
now = int(time.time())
|
||||||
for m in msgs:
|
for a in all_alerts:
|
||||||
key = _cooldown_key(ups_cfg.name, m)
|
key = _cooldown_key(a)
|
||||||
if not r.get(key):
|
if not r.get(key):
|
||||||
to_send.append(m)
|
fresh.append(a)
|
||||||
r.set(key, now, ex=ALERT_COOLDOWN_SECONDS)
|
r.set(key, now, ex=ALERT_COOLDOWN_SECONDS)
|
||||||
if to_send:
|
if not fresh:
|
||||||
# Store recent alerts with timestamp for health reporting
|
return []
|
||||||
recent_key = f"ups:alerts:recent:{ups_cfg.name}"
|
_persist_history(fresh)
|
||||||
pipe = r.pipeline()
|
if not cfg.smtp:
|
||||||
for m in to_send:
|
return fresh
|
||||||
pipe.lpush(recent_key, f"{now}|{m}")
|
# Silent hours filter: keep CRITICAL, defer others
|
||||||
pipe.ltrim(recent_key, 0, 49) # keep last 50
|
if _in_silent_hours(cfg.smtp):
|
||||||
pipe.execute()
|
critical = [a for a in fresh if a.severity == SEV_CRITICAL]
|
||||||
send_alert_email(cfg.smtp, ups_cfg.name, to_send)
|
deferred = [a for a in fresh if a.severity != SEV_CRITICAL]
|
||||||
|
if deferred:
|
||||||
|
_defer_for_silent_hours(deferred)
|
||||||
|
if critical:
|
||||||
|
dispatch_alerts(cfg.smtp, ups_cfg.name, critical)
|
||||||
|
else:
|
||||||
|
# Also flush any deferred alerts for this UPS
|
||||||
|
deferred = drain_deferred(ups_cfg.name)
|
||||||
|
dispatch_alerts(cfg.smtp, ups_cfg.name, fresh + deferred)
|
||||||
|
return fresh
|
||||||
|
|
||||||
|
|
||||||
|
def emit_info(ups_name: str, code: str, message: str) -> None:
|
||||||
|
"""Record and dispatch a single INFO alert (e.g. recovery events)."""
|
||||||
|
alert = Alert(SEV_INFO, message, code, ups_name, int(time.time()))
|
||||||
|
r = get_redis()
|
||||||
|
if r.get(_cooldown_key(alert)):
|
||||||
|
return
|
||||||
|
r.set(_cooldown_key(alert), int(time.time()), ex=ALERT_COOLDOWN_SECONDS)
|
||||||
|
_persist_history([alert])
|
||||||
|
cfg = load_config()
|
||||||
|
if cfg.smtp:
|
||||||
|
if _in_silent_hours(cfg.smtp):
|
||||||
|
_defer_for_silent_hours([alert])
|
||||||
|
else:
|
||||||
|
dispatch_alerts(cfg.smtp, ups_name, [alert])
|
||||||
+22
-7
@@ -1,22 +1,24 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import shutil
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any
|
import shutil
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
APCACCESS_BIN = shutil.which('apcaccess') or 'apcaccess'
|
APCACCESS_BIN = shutil.which('apcaccess') or 'apcaccess'
|
||||||
|
APCACCESS_TIMEOUT_SECONDS = 10.0
|
||||||
|
|
||||||
|
|
||||||
class APCStatusError(Exception):
|
class APCStatusError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def fetch_status(host: str, port: int) -> Dict[str, Any]:
|
async def fetch_status(host: str, port: int) -> dict[str, Any]:
|
||||||
"""Invoke apcaccess CLI and parse key:value lines into a dict.
|
"""Invoke apcaccess CLI and parse key:value lines into a dict.
|
||||||
|
|
||||||
Raises APCStatusError if binary fails or returns no data.
|
Raises APCStatusError if binary fails, times out, or returns no data.
|
||||||
"""
|
"""
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
APCACCESS_BIN,
|
APCACCESS_BIN,
|
||||||
@@ -25,14 +27,26 @@ async def fetch_status(host: str, port: int) -> Dict[str, Any]:
|
|||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
stdout, stderr = await proc.communicate()
|
try:
|
||||||
|
stdout, stderr = await asyncio.wait_for(
|
||||||
|
proc.communicate(), timeout=APCACCESS_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
await proc.wait()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
raise APCStatusError(
|
||||||
|
f"apcaccess timed out after {APCACCESS_TIMEOUT_SECONDS}s for {host}:{port}"
|
||||||
|
)
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
err_txt = stderr.decode(errors="replace").strip()
|
err_txt = stderr.decode(errors="replace").strip()
|
||||||
raise APCStatusError(
|
raise APCStatusError(
|
||||||
f'apcaccess exit {proc.returncode}: {err_txt}'
|
f'apcaccess exit {proc.returncode}: {err_txt}'
|
||||||
)
|
)
|
||||||
text = stdout.decode(errors='replace')
|
text = stdout.decode(errors='replace')
|
||||||
data: Dict[str, Any] = {}
|
data: dict[str, Any] = {}
|
||||||
for line in text.splitlines():
|
for line in text.splitlines():
|
||||||
if ':' not in line:
|
if ':' not in line:
|
||||||
continue
|
continue
|
||||||
@@ -40,7 +54,8 @@ async def fetch_status(host: str, port: int) -> Dict[str, Any]:
|
|||||||
key = k.strip()
|
key = k.strip()
|
||||||
val = v.strip()
|
val = v.strip()
|
||||||
data[key] = val
|
data[key] = val
|
||||||
# Normalize expected fields/aliases
|
if not data:
|
||||||
|
raise APCStatusError("apcaccess returned no data")
|
||||||
if 'UPSNAME' not in data and 'NAME' in data:
|
if 'UPSNAME' not in data and 'NAME' in data:
|
||||||
data['UPSNAME'] = data['NAME']
|
data['UPSNAME'] = data['NAME']
|
||||||
if 'MODEL' in data:
|
if 'MODEL' in data:
|
||||||
|
|||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
"""Authentication: argon2 password hashing + signed session cookies.
|
||||||
|
|
||||||
|
Single-admin design for homelab use. Credentials set via env vars:
|
||||||
|
ADMIN_USERNAME, ADMIN_PASSWORD_HASH, SESSION_SECRET
|
||||||
|
|
||||||
|
If ADMIN_PASSWORD_HASH is unset, the first-run setup page generates one
|
||||||
|
and stores it in Redis under the key ``ups:admin:hash``. The stored hash
|
||||||
|
takes precedence over env vars so the operator can change their password
|
||||||
|
via the UI.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Request, Response
|
||||||
|
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||||
|
from passlib.hash import argon2
|
||||||
|
|
||||||
|
from .settings import settings
|
||||||
|
from .storage import get_redis
|
||||||
|
|
||||||
|
SESSION_COOKIE = "ups_session"
|
||||||
|
CSRF_COOKIE = "csrf_token"
|
||||||
|
CSRF_HEADER = "X-CSRF-Token"
|
||||||
|
ADMIN_HASH_KEY = "ups:admin:hash"
|
||||||
|
ADMIN_USER_KEY = "ups:admin:username"
|
||||||
|
|
||||||
|
|
||||||
|
def _serializer() -> URLSafeTimedSerializer:
|
||||||
|
secret = settings.session_secret
|
||||||
|
if not secret:
|
||||||
|
# Best effort ephemeral secret so we don't crash at import time in dev
|
||||||
|
secret = "dev-insecure-change-me-please"
|
||||||
|
return URLSafeTimedSerializer(secret, salt="ups-session")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Produce an argon2 hash of the given password."""
|
||||||
|
return argon2.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, hashed: str) -> bool:
|
||||||
|
try:
|
||||||
|
return argon2.verify(password, hashed)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_stored_admin() -> tuple[str | None, str | None]:
|
||||||
|
"""Return (username, password_hash) from Redis or settings.
|
||||||
|
|
||||||
|
Redis-stored credentials take precedence when present.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
r = get_redis()
|
||||||
|
redis_hash = r.get(ADMIN_HASH_KEY)
|
||||||
|
redis_user = r.get(ADMIN_USER_KEY)
|
||||||
|
except Exception:
|
||||||
|
redis_hash = None
|
||||||
|
redis_user = None
|
||||||
|
username = redis_user or settings.admin_username
|
||||||
|
pw_hash = redis_hash or settings.admin_password_hash
|
||||||
|
return username, pw_hash
|
||||||
|
|
||||||
|
|
||||||
|
def store_admin(username: str, password: str) -> None:
|
||||||
|
"""Persist admin credentials to Redis."""
|
||||||
|
r = get_redis()
|
||||||
|
r.set(ADMIN_USER_KEY, username)
|
||||||
|
r.set(ADMIN_HASH_KEY, hash_password(password))
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin_configured() -> bool:
|
||||||
|
_, pw_hash = get_stored_admin()
|
||||||
|
return bool(pw_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def create_session_token(username: str) -> str:
|
||||||
|
return _serializer().dumps({"u": username})
|
||||||
|
|
||||||
|
|
||||||
|
def verify_session_token(token: str) -> str | None:
|
||||||
|
try:
|
||||||
|
data = _serializer().loads(
|
||||||
|
token, max_age=settings.session_max_age_seconds
|
||||||
|
)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data.get("u")
|
||||||
|
except (BadSignature, SignatureExpired):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def make_csrf_token() -> str:
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def set_auth_cookies(response: Response, username: str) -> str:
|
||||||
|
"""Set session + CSRF cookies on the response. Returns CSRF token."""
|
||||||
|
session_token = create_session_token(username)
|
||||||
|
csrf_token = make_csrf_token()
|
||||||
|
secure = settings.trust_proxy
|
||||||
|
response.set_cookie(
|
||||||
|
SESSION_COOKIE,
|
||||||
|
session_token,
|
||||||
|
max_age=settings.session_max_age_seconds,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
secure=secure,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
response.set_cookie(
|
||||||
|
CSRF_COOKIE,
|
||||||
|
csrf_token,
|
||||||
|
max_age=settings.session_max_age_seconds,
|
||||||
|
httponly=False, # JS needs to read this
|
||||||
|
samesite="lax",
|
||||||
|
secure=secure,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
return csrf_token
|
||||||
|
|
||||||
|
|
||||||
|
def clear_auth_cookies(response: Response) -> None:
|
||||||
|
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||||
|
response.delete_cookie(CSRF_COOKIE, path="/")
|
||||||
|
|
||||||
|
|
||||||
|
def current_user(request: Request) -> str | None:
|
||||||
|
"""Return current session username or None."""
|
||||||
|
token = request.cookies.get(SESSION_COOKIE)
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
return verify_session_token(token)
|
||||||
|
|
||||||
|
|
||||||
|
def require_session(request: Request) -> str:
|
||||||
|
"""FastAPI dependency - 401 if not authenticated."""
|
||||||
|
user = current_user(request)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_csrf(request: Request) -> None:
|
||||||
|
"""Enforce double-submit CSRF token on mutating requests."""
|
||||||
|
if request.method in ("GET", "HEAD", "OPTIONS"):
|
||||||
|
return
|
||||||
|
cookie_token = request.cookies.get(CSRF_COOKIE)
|
||||||
|
header_token = request.headers.get(CSRF_HEADER)
|
||||||
|
if not cookie_token or not header_token or cookie_token != header_token:
|
||||||
|
raise HTTPException(status_code=403, detail="CSRF token invalid or missing")
|
||||||
|
|
||||||
|
|
||||||
|
def require_session_and_csrf(
|
||||||
|
request: Request, user: str = Depends(require_session)
|
||||||
|
) -> str:
|
||||||
|
require_csrf(request)
|
||||||
|
return user
|
||||||
+111
-39
@@ -1,74 +1,146 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from typing import List, Optional
|
|
||||||
from pathlib import Path
|
|
||||||
import os
|
|
||||||
|
|
||||||
CONFIG_PATH = Path(
|
import ipaddress
|
||||||
os.environ.get("UPS_CONFIG_PATH", "/config/ups.yaml")
|
import re
|
||||||
) # legacy path for migration
|
from pathlib import Path
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
from .settings import settings
|
||||||
|
|
||||||
|
CONFIG_PATH = Path(settings.ups_config_path) # legacy path for migration
|
||||||
|
|
||||||
|
_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,32}$")
|
||||||
|
_HOSTNAME_RE = re.compile(
|
||||||
|
r"^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
_BLOCKED_LITERALS = {"localhost", "localhost.localdomain"}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_host_string(host: str) -> str:
|
||||||
|
"""Validate a host field. Rejects loopback/link-local always; rejects
|
||||||
|
private ranges when ALLOW_PRIVATE_IPS is false.
|
||||||
|
"""
|
||||||
|
host = host.strip()
|
||||||
|
if not host:
|
||||||
|
raise ValueError("host must not be empty")
|
||||||
|
if host.lower() in _BLOCKED_LITERALS:
|
||||||
|
raise ValueError(f"host '{host}' is not permitted (loopback alias)")
|
||||||
|
# Try as IP first
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(host)
|
||||||
|
if ip.is_loopback or ip.is_link_local or ip.is_unspecified or ip.is_multicast:
|
||||||
|
raise ValueError(f"host IP {host} is not a valid polling target")
|
||||||
|
if ip.is_private and not settings.allow_private_ips:
|
||||||
|
raise ValueError(
|
||||||
|
f"private IP {host} not allowed (set ALLOW_PRIVATE_IPS=true)"
|
||||||
|
)
|
||||||
|
return host
|
||||||
|
except ValueError as ip_err:
|
||||||
|
# Fall through to hostname validation only when the string wasn't a
|
||||||
|
# parseable IP address
|
||||||
|
if "does not appear to be an IPv4 or IPv6 address" not in str(ip_err):
|
||||||
|
raise
|
||||||
|
# Validate as hostname
|
||||||
|
if not _HOSTNAME_RE.match(host):
|
||||||
|
raise ValueError(f"host '{host}' is not a valid hostname or IP")
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
class UPSConfig(BaseModel):
|
class UPSConfig(BaseModel):
|
||||||
name: str = Field(..., description="Friendly UPS name")
|
name: str = Field(..., description="Friendly UPS name")
|
||||||
host: str = Field(..., description="apcupsd NIS host/IP")
|
host: str = Field(..., description="apcupsd NIS host/IP")
|
||||||
port: int = Field(3551, description="apcupsd NIS port")
|
port: int = Field(3551, ge=1, le=65535, description="apcupsd NIS port")
|
||||||
interval_seconds: int = Field(30, description="Polling interval")
|
interval_seconds: int = Field(
|
||||||
# Alert thresholds (any optional)
|
30, ge=5, le=3600, description="Polling interval"
|
||||||
alert_loadpct_high: Optional[float] = Field(
|
|
||||||
None, description="Trigger if LOADPCT >= value"
|
|
||||||
)
|
)
|
||||||
alert_bcharge_low: Optional[float] = Field(
|
# Alert thresholds (any optional)
|
||||||
None, description="Trigger if BCHARGE <= value"
|
alert_loadpct_high: float | None = Field(
|
||||||
|
None, ge=0, le=100, description="Trigger if LOADPCT >= value"
|
||||||
|
)
|
||||||
|
alert_bcharge_low: float | None = Field(
|
||||||
|
None, ge=0, le=100, description="Trigger if BCHARGE <= value"
|
||||||
)
|
)
|
||||||
alert_on_battery: bool = Field(
|
alert_on_battery: bool = Field(
|
||||||
False, description="Trigger when STATUS indicates on battery"
|
False, description="Trigger when STATUS indicates on battery"
|
||||||
)
|
)
|
||||||
alert_runtime_low_minutes: Optional[float] = Field(
|
alert_runtime_low_minutes: float | None = Field(
|
||||||
None, description="Trigger if TIMELEFT <= minutes"
|
None, ge=0, description="Trigger if TIMELEFT <= minutes"
|
||||||
)
|
)
|
||||||
|
alert_itemp_high: float | None = Field(
|
||||||
|
None, ge=0, le=120, description="Trigger if internal temp >= C"
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("name")
|
||||||
|
@classmethod
|
||||||
|
def _validate_name(cls, v: str) -> str:
|
||||||
|
if not _NAME_RE.match(v):
|
||||||
|
raise ValueError(
|
||||||
|
"name must be 1-32 chars, alphanumeric / underscore / dash only"
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("host")
|
||||||
|
@classmethod
|
||||||
|
def _validate_host(cls, v: str) -> str:
|
||||||
|
return _validate_host_string(v)
|
||||||
|
|
||||||
|
|
||||||
class SMTPConfig(BaseModel):
|
class SMTPConfig(BaseModel):
|
||||||
host: str = Field(..., description="SMTP server host/IP")
|
host: str = Field(..., description="SMTP server host/IP")
|
||||||
port: int = Field(..., description="SMTP port")
|
port: int = Field(..., ge=1, le=65535, description="SMTP port")
|
||||||
username: Optional[str] = Field(None)
|
username: str | None = Field(None)
|
||||||
password: Optional[str] = Field(
|
# password is never persisted in Redis; read from env SMTP_PASSWORD
|
||||||
None, description="Plain password or set via env SMTP_PASSWORD"
|
|
||||||
)
|
|
||||||
use_tls: bool = Field(False, description="STARTTLS if true")
|
use_tls: bool = Field(False, description="STARTTLS if true")
|
||||||
use_ssl: bool = Field(False, description="SSL (smtplib.SMTP_SSL) if true")
|
use_ssl: bool = Field(False, description="SSL (smtplib.SMTP_SSL) if true")
|
||||||
from_addr: Optional[str] = Field(None, description="From email address")
|
from_addr: str | None = Field(None, description="From email address")
|
||||||
to_addrs: List[str] = Field(
|
to_addrs: list[str] = Field(
|
||||||
default_factory=list, description="Recipient list"
|
default_factory=list, description="Recipient list"
|
||||||
)
|
)
|
||||||
subject_prefix: str = Field("[UPS]", description="Subject prefix")
|
subject_prefix: str = Field("[UPS]", description="Subject prefix")
|
||||||
|
silent_hours_start: int | None = Field(
|
||||||
|
None, ge=0, le=23, description="Silent window start hour (local time)"
|
||||||
|
)
|
||||||
|
silent_hours_end: int | None = Field(
|
||||||
|
None, ge=0, le=23, description="Silent window end hour (local time)"
|
||||||
|
)
|
||||||
|
daily_summary_hour: int | None = Field(
|
||||||
|
None, ge=0, le=23, description="Hour to send daily summary"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class 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):
|
class AppConfig(BaseModel):
|
||||||
ups: List[UPSConfig]
|
ups: list[UPSConfig] = Field(default_factory=list)
|
||||||
smtp: Optional[SMTPConfig] = None
|
smtp: SMTPConfig | None = None
|
||||||
# UI feature flags (optional; default values used if missing)
|
ui: UIConfig = Field(default_factory=UIConfig)
|
||||||
# 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
|
|
||||||
|
|
||||||
ui: UIConfig = UIConfig()
|
# Backward-compat alias: older code references AppConfig.UIConfig
|
||||||
|
UIConfig: ClassVar[type[UIConfig]] = UIConfig
|
||||||
|
|
||||||
|
|
||||||
_cached: AppConfig | None = None
|
_cached: AppConfig | None = None
|
||||||
|
|
||||||
|
|
||||||
def load_config(path: Path = CONFIG_PATH) -> AppConfig:
|
def load_config(path: Path = CONFIG_PATH) -> AppConfig:
|
||||||
# Use redis store (lazy import to avoid circular)
|
"""Load config from Redis, cached in-process.
|
||||||
|
|
||||||
|
Invalidate via ``config_module._cached = None`` after any write.
|
||||||
|
"""
|
||||||
from .config_store import load_config_redis
|
from .config_store import load_config_redis
|
||||||
global _cached
|
global _cached
|
||||||
if _cached:
|
if _cached:
|
||||||
|
|||||||
+40
-87
@@ -1,27 +1,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import List, Optional, Dict, Any
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
try:
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
from pydantic import BaseModel, Field, ValidationError
|
|
||||||
except ImportError:
|
|
||||||
# Fallback for environments without pydantic
|
|
||||||
BaseModel = object
|
|
||||||
|
|
||||||
def Field(default=None, **kwargs):
|
from .config import AppConfig, SMTPConfig, UIConfig, UPSConfig
|
||||||
return default
|
|
||||||
|
|
||||||
ValidationError = ValueError
|
|
||||||
|
|
||||||
from .config import UPSConfig, SMTPConfig, AppConfig
|
|
||||||
from .config_store import load_config_redis, save_config_redis
|
from .config_store import load_config_redis, save_config_redis
|
||||||
|
from .settings import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Incremented every time configuration is modified so
|
# Incremented every time configuration is modified so SSE clients can
|
||||||
# SSE clients can detect changes
|
# detect changes
|
||||||
_config_version: int = 0
|
_config_version: int = 0
|
||||||
|
|
||||||
|
|
||||||
@@ -29,34 +22,25 @@ def get_config_version() -> int:
|
|||||||
return _config_version
|
return _config_version
|
||||||
|
|
||||||
|
|
||||||
class ConfigWriteError(Exception): # retained for API compatibility
|
class ConfigWriteError(Exception):
|
||||||
"""Raised when configuration cannot be written (kept for compatibility)."""
|
"""Raised when configuration cannot be written (kept for compatibility)."""
|
||||||
|
|
||||||
|
|
||||||
class UPSConfigUpdate(BaseModel):
|
class UPSConfigUpdate(BaseModel):
|
||||||
"""Model for updating UPS configuration"""
|
"""Model for updating UPS configuration."""
|
||||||
name: Optional[str] = Field(None, description="Friendly UPS name")
|
name: str | None = None
|
||||||
host: Optional[str] = Field(None, description="apcupsd NIS host/IP")
|
host: str | None = None
|
||||||
port: Optional[int] = Field(None, description="apcupsd NIS port")
|
port: int | None = Field(None, ge=1, le=65535)
|
||||||
interval_seconds: Optional[int] = Field(
|
interval_seconds: int | None = Field(None, ge=5, le=3600)
|
||||||
None, description="Polling interval"
|
alert_loadpct_high: float | None = Field(None, ge=0, le=100)
|
||||||
)
|
alert_bcharge_low: float | None = Field(None, ge=0, le=100)
|
||||||
alert_loadpct_high: Optional[float] = Field(
|
alert_on_battery: bool | None = None
|
||||||
None, description="Trigger if LOADPCT >= value"
|
alert_runtime_low_minutes: float | None = Field(None, ge=0)
|
||||||
)
|
alert_itemp_high: float | None = Field(None, ge=0, le=120)
|
||||||
alert_bcharge_low: Optional[float] = Field(
|
|
||||||
None, description="Trigger if BCHARGE <= value"
|
|
||||||
)
|
|
||||||
alert_on_battery: Optional[bool] = Field(
|
|
||||||
None, description="Trigger when STATUS indicates on battery"
|
|
||||||
)
|
|
||||||
alert_runtime_low_minutes: Optional[float] = Field(
|
|
||||||
None, description="Trigger if TIMELEFT <= minutes"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigManager:
|
class ConfigManager:
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
async def load_config(self) -> AppConfig:
|
async def load_config(self) -> AppConfig:
|
||||||
@@ -67,26 +51,19 @@ class ConfigManager:
|
|||||||
async with self._lock:
|
async with self._lock:
|
||||||
save_config_redis(config)
|
save_config_redis(config)
|
||||||
logger.info("Configuration saved to Redis")
|
logger.info("Configuration saved to Redis")
|
||||||
# Invalidate cached global config so subsequent
|
|
||||||
# load_config() calls see changes
|
|
||||||
try:
|
try:
|
||||||
from . import config as config_module
|
from . import config as config_module
|
||||||
config_module._cached = None
|
config_module._cached = None
|
||||||
except Exception: # pragma: no cover - defensive
|
except Exception: # pragma: no cover - defensive
|
||||||
logger.debug(
|
logger.debug("Failed to invalidate config cache", exc_info=True)
|
||||||
"Failed to invalidate config cache", exc_info=True
|
|
||||||
)
|
|
||||||
# bump config version
|
|
||||||
global _config_version
|
global _config_version
|
||||||
_config_version += 1
|
_config_version += 1
|
||||||
|
|
||||||
async def get_ups_list(self) -> List[UPSConfig]:
|
async def get_ups_list(self) -> list[UPSConfig]:
|
||||||
"""Get list of all UPS configurations"""
|
|
||||||
config = await self.load_config()
|
config = await self.load_config()
|
||||||
return config.ups
|
return config.ups
|
||||||
|
|
||||||
async def get_ups(self, name: str) -> Optional[UPSConfig]:
|
async def get_ups(self, name: str) -> UPSConfig | None:
|
||||||
"""Get UPS configuration by name"""
|
|
||||||
config = await self.load_config()
|
config = await self.load_config()
|
||||||
for ups in config.ups:
|
for ups in config.ups:
|
||||||
if ups.name == name:
|
if ups.name == name:
|
||||||
@@ -94,110 +71,78 @@ class ConfigManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def add_ups(self, ups_config: UPSConfig) -> bool:
|
async def add_ups(self, ups_config: UPSConfig) -> bool:
|
||||||
"""Add new UPS configuration"""
|
|
||||||
config = await self.load_config()
|
config = await self.load_config()
|
||||||
|
|
||||||
# Check if UPS with same name already exists
|
|
||||||
if any(ups.name == ups_config.name for ups in config.ups):
|
if any(ups.name == ups_config.name for ups in config.ups):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"UPS with name '{ups_config.name}' already exists"
|
f"UPS with name '{ups_config.name}' already exists"
|
||||||
)
|
)
|
||||||
|
|
||||||
config.ups.append(ups_config)
|
config.ups.append(ups_config)
|
||||||
await self.save_config(config)
|
await self.save_config(config)
|
||||||
|
|
||||||
# No file cache now
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def update_ups(self, name: str, updates: UPSConfigUpdate) -> bool:
|
async def update_ups(self, name: str, updates: UPSConfigUpdate) -> bool:
|
||||||
"""Update existing UPS configuration"""
|
|
||||||
config = await self.load_config()
|
config = await self.load_config()
|
||||||
|
|
||||||
ups_index = None
|
ups_index = None
|
||||||
for i, ups in enumerate(config.ups):
|
for i, ups in enumerate(config.ups):
|
||||||
if ups.name == name:
|
if ups.name == name:
|
||||||
ups_index = i
|
ups_index = i
|
||||||
break
|
break
|
||||||
|
|
||||||
if ups_index is None:
|
if ups_index is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Apply updates
|
|
||||||
ups_dict = config.ups[ups_index].model_dump()
|
ups_dict = config.ups[ups_index].model_dump()
|
||||||
update_dict = updates.model_dump(exclude_none=True)
|
update_dict = updates.model_dump(exclude_none=True)
|
||||||
ups_dict.update(update_dict)
|
ups_dict.update(update_dict)
|
||||||
|
|
||||||
# Validate updated configuration
|
|
||||||
try:
|
try:
|
||||||
updated_ups = UPSConfig(**ups_dict)
|
updated_ups = UPSConfig(**ups_dict)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
raise ValueError(f"Invalid configuration: {e}")
|
raise ValueError(f"Invalid configuration: {e}")
|
||||||
|
|
||||||
config.ups[ups_index] = updated_ups
|
config.ups[ups_index] = updated_ups
|
||||||
await self.save_config(config)
|
await self.save_config(config)
|
||||||
|
|
||||||
# No file cache now
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def delete_ups(self, name: str) -> bool:
|
async def delete_ups(self, name: str) -> bool:
|
||||||
"""Delete UPS configuration"""
|
|
||||||
config = await self.load_config()
|
config = await self.load_config()
|
||||||
|
|
||||||
original_count = len(config.ups)
|
original_count = len(config.ups)
|
||||||
config.ups = [ups for ups in config.ups if ups.name != name]
|
config.ups = [ups for ups in config.ups if ups.name != name]
|
||||||
|
|
||||||
if len(config.ups) == original_count:
|
if len(config.ups) == original_count:
|
||||||
return False # UPS not found
|
return False
|
||||||
|
|
||||||
await self.save_config(config)
|
await self.save_config(config)
|
||||||
|
|
||||||
# No file cache now
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def get_smtp_config(self) -> Optional[SMTPConfig]:
|
async def get_smtp_config(self) -> SMTPConfig | None:
|
||||||
"""Get SMTP configuration"""
|
|
||||||
config = await self.load_config()
|
config = await self.load_config()
|
||||||
return config.smtp
|
return config.smtp
|
||||||
|
|
||||||
async def update_smtp_config(self, smtp_config: SMTPConfig) -> None:
|
async def update_smtp_config(self, smtp_config: SMTPConfig) -> None:
|
||||||
"""Update SMTP configuration"""
|
|
||||||
config = await self.load_config()
|
config = await self.load_config()
|
||||||
config.smtp = smtp_config
|
config.smtp = smtp_config
|
||||||
await self.save_config(config)
|
await self.save_config(config)
|
||||||
|
|
||||||
# Clear cached config
|
async def update_ui_config(self, ui: UIConfig) -> None:
|
||||||
from . import config as config_module
|
config = await self.load_config()
|
||||||
config_module._cached = None
|
config.ui = ui
|
||||||
|
await self.save_config(config)
|
||||||
|
|
||||||
async def validate_ups_connection(
|
async def validate_ups_connection(
|
||||||
self, ups_config: UPSConfig, timeout: float = 3.0
|
self, ups_config: UPSConfig, timeout: float = 3.0
|
||||||
) -> Dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Port-only connectivity test (no protocol / CLI call)."""
|
"""Port-only connectivity test (no protocol / CLI call)."""
|
||||||
|
result: dict[str, Any] = {
|
||||||
result: Dict[str, Any] = {
|
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "",
|
"message": "",
|
||||||
"connectivity": {"ok": False, "error": None},
|
"connectivity": {"ok": False, "error": None},
|
||||||
"protocol": {"ok": False, "error": None},
|
"protocol": {"ok": False, "error": None},
|
||||||
"data": None,
|
"data": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Raw TCP connectivity test
|
|
||||||
try:
|
try:
|
||||||
# Use low-level socket to distinguish DNS/timeouts
|
|
||||||
with socket.create_connection(
|
with socket.create_connection(
|
||||||
(ups_config.host, ups_config.port), timeout=timeout
|
(ups_config.host, ups_config.port), timeout=timeout
|
||||||
):
|
):
|
||||||
result["connectivity"]["ok"] = True
|
result["connectivity"]["ok"] = True
|
||||||
except Exception as e: # broad to surface any network issue
|
except Exception as e:
|
||||||
result["connectivity"]["error"] = str(e)
|
result["connectivity"]["error"] = str(e)
|
||||||
result["message"] = f"TCP connectivity failed: {e}"
|
result["message"] = f"TCP connectivity failed: {e}"
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# For port-only test we just mirror connectivity result
|
|
||||||
if result["connectivity"]["ok"]:
|
if result["connectivity"]["ok"]:
|
||||||
result["protocol"]["ok"] = True
|
result["protocol"]["ok"] = True
|
||||||
result["success"] = True
|
result["success"] = True
|
||||||
@@ -207,5 +152,13 @@ class ConfigManager:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
def smtp_redacted_dict(smtp: SMTPConfig | None) -> dict[str, Any] | None:
|
||||||
|
"""Return SMTP config dict with password redacted from env."""
|
||||||
|
if smtp is None:
|
||||||
|
return None
|
||||||
|
d = smtp.model_dump()
|
||||||
|
d["password"] = "***" if settings.smtp_password else None
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
config_manager = ConfigManager()
|
config_manager = ConfigManager()
|
||||||
+23
-14
@@ -1,31 +1,39 @@
|
|||||||
"""Redis-backed configuration storage replacing YAML file.
|
"""Redis-backed configuration storage.
|
||||||
|
|
||||||
Schema:
|
Key ``ups:config:json`` holds JSON: ``{"ups": [...], "smtp": {...}|null, "ui": {...}}``.
|
||||||
Key ups:config:json -> JSON object: {"ups": [...], "smtp": {...}|null}
|
Legacy YAML import runs once if Redis is empty.
|
||||||
|
|
||||||
Migration:
|
|
||||||
On first load if redis key missing and legacy YAML present, import it.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import Optional
|
|
||||||
from pathlib import Path
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from .storage import get_redis
|
from pathlib import Path
|
||||||
from .config import AppConfig, CONFIG_PATH
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
from .config import CONFIG_PATH, AppConfig
|
||||||
|
from .storage import get_redis
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
REDIS_CONFIG_KEY = "ups:config:json"
|
REDIS_CONFIG_KEY = "ups:config:json"
|
||||||
|
|
||||||
|
|
||||||
def _load_legacy_yaml(path: Path) -> Optional[AppConfig]:
|
def _strip_smtp_password(data: dict) -> dict:
|
||||||
|
"""Strip persisted SMTP passwords (migration from older schema)."""
|
||||||
|
smtp = data.get("smtp")
|
||||||
|
if isinstance(smtp, dict) and "password" in smtp:
|
||||||
|
smtp.pop("password", None)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _load_legacy_yaml(path: Path) -> AppConfig | None:
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
with path.open() as f:
|
with path.open() as f:
|
||||||
raw = yaml.safe_load(f) or {}
|
raw = yaml.safe_load(f) or {}
|
||||||
|
raw = _strip_smtp_password(raw)
|
||||||
return AppConfig(**raw)
|
return AppConfig(**raw)
|
||||||
except Exception as e: # pragma: no cover
|
except Exception as e: # pragma: no cover
|
||||||
logger.warning("Failed to import legacy YAML config: %s", e)
|
logger.warning("Failed to import legacy YAML config: %s", e)
|
||||||
@@ -40,14 +48,13 @@ def load_config_redis() -> AppConfig:
|
|||||||
data = json.loads(raw)
|
data = json.loads(raw)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
data = {}
|
data = {}
|
||||||
|
data = _strip_smtp_password(data)
|
||||||
return AppConfig(**data)
|
return AppConfig(**data)
|
||||||
# Migration path
|
|
||||||
legacy = _load_legacy_yaml(CONFIG_PATH)
|
legacy = _load_legacy_yaml(CONFIG_PATH)
|
||||||
if legacy:
|
if legacy:
|
||||||
save_config_redis(legacy)
|
save_config_redis(legacy)
|
||||||
logger.info("Imported legacy YAML config into Redis")
|
logger.info("Imported legacy YAML config into Redis")
|
||||||
return legacy
|
return legacy
|
||||||
# If nothing exists, create empty scaffold
|
|
||||||
empty = AppConfig(ups=[], smtp=None)
|
empty = AppConfig(ups=[], smtp=None)
|
||||||
save_config_redis(empty)
|
save_config_redis(empty)
|
||||||
return empty
|
return empty
|
||||||
@@ -55,4 +62,6 @@ def load_config_redis() -> AppConfig:
|
|||||||
|
|
||||||
def save_config_redis(cfg: AppConfig) -> None:
|
def save_config_redis(cfg: AppConfig) -> None:
|
||||||
r = get_redis()
|
r = get_redis()
|
||||||
r.set(REDIS_CONFIG_KEY, json.dumps(cfg.model_dump(exclude_none=True)))
|
data = cfg.model_dump(exclude_none=True)
|
||||||
|
data = _strip_smtp_password(data)
|
||||||
|
r.set(REDIS_CONFIG_KEY, json.dumps(data))
|
||||||
@@ -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
|
||||||
@@ -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})
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Structured JSON logging with request-ID correlation."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextvars
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from pythonjsonlogger import jsonlogger
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
|
from .settings import settings
|
||||||
|
|
||||||
|
_request_id_var: contextvars.ContextVar[str] = contextvars.ContextVar(
|
||||||
|
"request_id", default="-"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_id() -> str:
|
||||||
|
return _request_id_var.get()
|
||||||
|
|
||||||
|
|
||||||
|
class RequestIdFilter(logging.Filter):
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
record.request_id = _request_id_var.get()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging() -> None:
|
||||||
|
"""Configure root logger to emit JSON with request_id."""
|
||||||
|
root = logging.getLogger()
|
||||||
|
# Remove any pre-existing handlers (uvicorn installs its own)
|
||||||
|
for h in list(root.handlers):
|
||||||
|
root.removeHandler(h)
|
||||||
|
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
formatter = jsonlogger.JsonFormatter(
|
||||||
|
fmt="%(asctime)s %(levelname)s %(name)s %(request_id)s %(message)s",
|
||||||
|
rename_fields={"asctime": "ts", "levelname": "level", "name": "logger"},
|
||||||
|
)
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
handler.addFilter(RequestIdFilter())
|
||||||
|
root.addHandler(handler)
|
||||||
|
root.setLevel(getattr(logging, settings.log_level.upper(), logging.INFO))
|
||||||
|
|
||||||
|
# Calm down noisy loggers
|
||||||
|
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
|
class RequestIdMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request, call_next):
|
||||||
|
rid = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
|
||||||
|
token = _request_id_var.set(rid)
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Request-ID"] = rid
|
||||||
|
return response
|
||||||
|
finally:
|
||||||
|
_request_id_var.reset(token)
|
||||||
+72
-457
@@ -1,472 +1,87 @@
|
|||||||
|
"""FastAPI application entry point.
|
||||||
|
|
||||||
|
Assembles middleware, lifespan (poller start/stop), auth, routes.
|
||||||
|
All request handlers live in app/routes/*.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import logging
|
||||||
from fastapi import FastAPI, Request, HTTPException
|
from contextlib import asynccontextmanager
|
||||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from slowapi import _rate_limit_exceeded_handler
|
||||||
import orjson
|
from slowapi.errors import RateLimitExceeded
|
||||||
from .config import load_config, UPSConfig
|
|
||||||
from .storage import get_latest, get_history
|
|
||||||
from .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
|
|
||||||
|
|
||||||
app = FastAPI(title="APC UPS Dashboard")
|
from .logging_config import RequestIdMiddleware, configure_logging
|
||||||
app.mount('/static', StaticFiles(directory='app/static'), name='static')
|
from .poller import cancel_all_tasks, poll_loop
|
||||||
templates = Jinja2Templates(directory='app/templates')
|
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")
|
@asynccontextmanager
|
||||||
async def startup():
|
async def lifespan(app: FastAPI):
|
||||||
asyncio.create_task(poll_loop())
|
configure_logging()
|
||||||
|
if not settings.session_secret:
|
||||||
|
logger.warning(
|
||||||
@app.get('/', response_class=HTMLResponse)
|
"SESSION_SECRET not set; using insecure ephemeral secret. "
|
||||||
async def dashboard(request: Request):
|
"Set SESSION_SECRET in your environment for production."
|
||||||
cfg = load_config()
|
)
|
||||||
return templates.TemplateResponse(
|
task = asyncio.create_task(poll_loop())
|
||||||
'dashboard.html', {
|
_background_tasks.append(task)
|
||||||
"request": request,
|
logger.info("Poller started")
|
||||||
"ups_list": cfg.ups,
|
try:
|
||||||
"ui_cfg": cfg.ui.model_dump(),
|
yield
|
||||||
}
|
finally:
|
||||||
)
|
logger.info("Shutting down: cancelling background tasks")
|
||||||
|
for t in _background_tasks:
|
||||||
|
t.cancel()
|
||||||
@app.get('/config', response_class=HTMLResponse)
|
for t in _background_tasks:
|
||||||
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:
|
|
||||||
try:
|
try:
|
||||||
ts_s, kind, rest = item.split('|', 2)
|
await t
|
||||||
parsed.append({
|
except (asyncio.CancelledError, Exception):
|
||||||
'ts': int(ts_s),
|
|
||||||
'type': kind,
|
|
||||||
'detail': rest
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
except Exception:
|
|
||||||
pass
|
pass
|
||||||
parsed.append({'raw': item})
|
await cancel_all_tasks()
|
||||||
return parsed
|
logger.info("Shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
@app.get('/api/ups/{ups_name}/energy')
|
def create_app() -> FastAPI:
|
||||||
async def ups_energy(ups_name: str):
|
application = FastAPI(title="APC UPS Dashboard", lifespan=lifespan)
|
||||||
"""Return today's accumulated energy (approx kWh) if available."""
|
application.state.limiter = limiter
|
||||||
r = get_redis()
|
application.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
day_str = time.strftime('%Y%m%d') # type: ignore
|
|
||||||
key = f"ups:energy:{ups_name}:{day_str}"
|
|
||||||
watt_seconds = r.get(key)
|
|
||||||
if watt_seconds:
|
|
||||||
try:
|
|
||||||
ws = float(watt_seconds)
|
|
||||||
kwh = ws / 3600.0 / 1000.0
|
|
||||||
return {'kwh_today': round(kwh, 4)}
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return {'kwh_today': None}
|
|
||||||
|
|
||||||
|
# Middleware (outermost first in registration; Starlette wraps inside-out)
|
||||||
|
application.add_middleware(SecurityHeadersMiddleware)
|
||||||
|
application.add_middleware(RequestIdMiddleware)
|
||||||
|
|
||||||
@app.get('/api/ups/{ups_name}/watts_per_minute')
|
application.mount(
|
||||||
async def ups_watts_per_minute(ups_name: str):
|
"/static", StaticFiles(directory="app/static"), name="static"
|
||||||
"""Return recent per-minute average watts for a UPS (last 24h)."""
|
|
||||||
r = get_redis()
|
|
||||||
key = f"ups:watts:permin:{ups_name}"
|
|
||||||
raw = r.lrange(key, 0, 1440)
|
|
||||||
out = []
|
|
||||||
for item in raw:
|
|
||||||
if '|' in item:
|
|
||||||
minute, avg = item.split('|', 1)
|
|
||||||
try:
|
|
||||||
out.append({'minute': minute, 'avg_watts': float(avg)})
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
# list stored newest-first; reverse to chronological
|
|
||||||
out.reverse()
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
@app.get('/api/ups/{ups_name}/health')
|
|
||||||
async def ups_health(ups_name: str):
|
|
||||||
"""Return aggregated health indicators.
|
|
||||||
|
|
||||||
Includes recent alerts, voltage deviation stats, and ONBATT event count.
|
|
||||||
"""
|
|
||||||
r = get_redis()
|
|
||||||
# Recent alerts
|
|
||||||
alerts_key = f"ups:alerts:recent:{ups_name}"
|
|
||||||
alert_raw = r.lrange(alerts_key, 0, 19)
|
|
||||||
alerts = []
|
|
||||||
for a in alert_raw:
|
|
||||||
if '|' in a:
|
|
||||||
ts_s, msg = a.split('|', 1)
|
|
||||||
try:
|
|
||||||
alerts.append({'ts': int(ts_s), 'msg': msg})
|
|
||||||
except ValueError:
|
|
||||||
alerts.append({'raw': a})
|
|
||||||
else:
|
|
||||||
alerts.append({'raw': a})
|
|
||||||
# Voltage deviation samples
|
|
||||||
dev_key = f"ups:volt:dev:samples:{ups_name}"
|
|
||||||
dev_samples = r.lrange(dev_key, 0, 49)
|
|
||||||
dev_vals = []
|
|
||||||
for d in dev_samples:
|
|
||||||
try:
|
|
||||||
dev_vals.append(float(d))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
dev_avg = sum(devVals := dev_vals) / len(devVals) if dev_vals else None
|
|
||||||
dev_max = max(dev_vals) if dev_vals else None
|
|
||||||
# Transfer burst count (recent hour ONBATT events)
|
|
||||||
events_key = f"ups:event:list:{ups_name}"
|
|
||||||
now = int(time.time())
|
|
||||||
events = r.lrange(events_key, 0, 200)
|
|
||||||
onbatt_hour = 0
|
|
||||||
for ev in events:
|
|
||||||
parts = ev.split('|')
|
|
||||||
if len(parts) >= 3:
|
|
||||||
try:
|
|
||||||
ts_e = int(parts[0])
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if now - ts_e > 3600:
|
|
||||||
continue
|
|
||||||
if parts[1] == 'STATUS' and 'ONBATT' in parts[2]:
|
|
||||||
onbatt_hour += 1
|
|
||||||
return {
|
|
||||||
'alerts': alerts,
|
|
||||||
'voltage_deviation': {
|
|
||||||
'avg_pct': round(dev_avg, 2) if dev_avg is not None else None,
|
|
||||||
'max_pct': round(dev_max, 2) if dev_max is not None else None,
|
|
||||||
'samples': len(dev_vals)
|
|
||||||
},
|
|
||||||
'onbatt_last_hour': onbatt_hour
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get('/api/ups/{ups_name}/debug')
|
|
||||||
async def ups_debug(ups_name: str):
|
|
||||||
"""Return current apcaccess CLI status for a UPS."""
|
|
||||||
cfg = load_config()
|
|
||||||
target = next((u for u in cfg.ups if u.name == ups_name), None)
|
|
||||||
if not target:
|
|
||||||
raise HTTPException(status_code=404, detail='UPS not found')
|
|
||||||
try:
|
|
||||||
data = await fetch_status(target.host, target.port)
|
|
||||||
return data
|
|
||||||
except APCStatusError as e:
|
|
||||||
raise HTTPException(status_code=502, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@app.get('/api/stream')
|
|
||||||
async def stream():
|
|
||||||
# simple Server Sent Events stream of snapshots (polling redis every 5s)
|
|
||||||
async def event_gen():
|
|
||||||
while True:
|
|
||||||
cfg = load_config()
|
|
||||||
payload = {"snapshots": {}, "cfgVersion": get_config_version()}
|
|
||||||
for u in cfg.ups:
|
|
||||||
snap = await get_latest(u.name)
|
|
||||||
if snap:
|
|
||||||
payload["snapshots"][u.name] = snap
|
|
||||||
# include simple config metadata to help client reconcile
|
|
||||||
payload["upsMeta"] = [
|
|
||||||
{"name": u.name, "host": u.host, "port": u.port}
|
|
||||||
for u in cfg.ups
|
|
||||||
]
|
|
||||||
# Backward compatibility: also flatten UPS snapshots at top level
|
|
||||||
for name, snap in payload["snapshots"].items():
|
|
||||||
payload.setdefault(name, snap)
|
|
||||||
yield f"data: {orjson.dumps(payload).decode()}\n\n"
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
return StreamingResponse(event_gen(), media_type='text/event-stream')
|
|
||||||
|
|
||||||
|
|
||||||
# Configuration management endpoints
|
|
||||||
@app.get('/api/config/ups')
|
|
||||||
async def get_ups_configs():
|
|
||||||
"""Get all UPS configurations"""
|
|
||||||
try:
|
|
||||||
ups_list = await config_manager.get_ups_list()
|
|
||||||
return [ups.model_dump() for ups in ups_list]
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@app.get('/api/config/ups/{ups_name}')
|
|
||||||
async def get_ups_config(ups_name: str):
|
|
||||||
"""Get specific UPS configuration"""
|
|
||||||
try:
|
|
||||||
ups = await config_manager.get_ups(ups_name)
|
|
||||||
if not ups:
|
|
||||||
raise HTTPException(status_code=404, detail="UPS not found")
|
|
||||||
return ups.model_dump()
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@app.post('/api/config/ups')
|
|
||||||
async def add_ups_config(ups_config: UPSConfig):
|
|
||||||
"""Add new UPS configuration"""
|
|
||||||
try:
|
|
||||||
await config_manager.add_ups(ups_config)
|
|
||||||
return {"message": "UPS configuration added successfully"}
|
|
||||||
except ConfigWriteError as e:
|
|
||||||
raise HTTPException(status_code=507, detail=str(e))
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@app.put('/api/config/ups/{ups_name}')
|
|
||||||
async def update_ups_config(ups_name: str, updates: UPSConfigUpdate):
|
|
||||||
"""Update UPS configuration"""
|
|
||||||
try:
|
|
||||||
success = await config_manager.update_ups(ups_name, updates)
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(status_code=404, detail="UPS not found")
|
|
||||||
return {"message": "UPS configuration updated successfully"}
|
|
||||||
except ConfigWriteError as e:
|
|
||||||
raise HTTPException(status_code=507, detail=str(e))
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@app.delete('/api/config/ups/{ups_name}')
|
|
||||||
async def delete_ups_config(ups_name: str):
|
|
||||||
"""Delete UPS configuration"""
|
|
||||||
try:
|
|
||||||
success = await config_manager.delete_ups(ups_name)
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(status_code=404, detail="UPS not found")
|
|
||||||
return {"message": "UPS configuration deleted successfully"}
|
|
||||||
except ConfigWriteError as e:
|
|
||||||
raise HTTPException(status_code=507, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@app.post('/api/config/ups/{ups_name}/test')
|
|
||||||
async def test_ups_connection(ups_name: str):
|
|
||||||
"""Test UPS connection"""
|
|
||||||
try:
|
|
||||||
ups = await config_manager.get_ups(ups_name)
|
|
||||||
if not ups:
|
|
||||||
raise HTTPException(status_code=404, detail="UPS not found")
|
|
||||||
|
|
||||||
result = await config_manager.validate_ups_connection(ups)
|
|
||||||
return result
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@app.post('/api/config/ups/test')
|
|
||||||
async def test_new_ups_connection(ups_config: UPSConfig):
|
|
||||||
"""Test new UPS configuration connection"""
|
|
||||||
try:
|
|
||||||
result = await config_manager.validate_ups_connection(ups_config)
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@app.get('/api/config/smtp')
|
|
||||||
async def get_smtp_config():
|
|
||||||
"""Get SMTP configuration"""
|
|
||||||
try:
|
|
||||||
smtp = await config_manager.get_smtp_config()
|
|
||||||
return smtp.model_dump() if smtp else None
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@app.get('/api/config/ui')
|
|
||||||
async def get_ui_config():
|
|
||||||
cfg = load_config()
|
|
||||||
return cfg.ui.model_dump()
|
|
||||||
|
|
||||||
|
|
||||||
@app.put('/api/config/ui')
|
|
||||||
async def update_ui_config(payload: dict):
|
|
||||||
# Simple partial update of UI flags
|
|
||||||
from .config import AppConfig
|
|
||||||
cfg = load_config()
|
|
||||||
ui_dict = cfg.ui.model_dump()
|
|
||||||
allowed = set(ui_dict.keys())
|
|
||||||
for k, v in payload.items():
|
|
||||||
if k in allowed and isinstance(v, bool):
|
|
||||||
ui_dict[k] = v
|
|
||||||
# Reconstruct full config and save via manager
|
|
||||||
new_cfg = AppConfig(
|
|
||||||
ups=cfg.ups, smtp=cfg.smtp, ui=cfg.ui.__class__(**ui_dict)
|
|
||||||
)
|
)
|
||||||
await config_manager.save_config(new_cfg)
|
|
||||||
return {"message": "UI config updated", "ui": ui_dict}
|
# Routers
|
||||||
|
application.include_router(pages.router)
|
||||||
|
application.include_router(api_auth.router)
|
||||||
|
application.include_router(api_health.router)
|
||||||
|
application.include_router(api_config.router)
|
||||||
|
application.include_router(api_ups.router)
|
||||||
|
application.include_router(api_alerts.router)
|
||||||
|
application.include_router(api_events.router)
|
||||||
|
application.include_router(sse.router)
|
||||||
|
|
||||||
|
@application.exception_handler(Exception)
|
||||||
|
async def unhandled_exc(request: Request, exc: Exception): # noqa: ARG001
|
||||||
|
logger.exception("Unhandled error", extra={"path": request.url.path})
|
||||||
|
return JSONResponse({"detail": "Internal server error"}, status_code=500)
|
||||||
|
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
# --- UI Tile Layout Persistence ---
|
app = create_app()
|
||||||
@app.get('/api/ups/{ups_name}/ui_tiles')
|
|
||||||
async def get_ups_ui_tiles(ups_name: str):
|
|
||||||
"""Return persisted UI tile settings for a UPS.
|
|
||||||
|
|
||||||
Includes: types, order, hidden, custom. Falls back to empty defaults if
|
|
||||||
not stored yet.
|
|
||||||
"""
|
|
||||||
r = get_redis()
|
|
||||||
key = f"ups:ui:tiles:{ups_name}"
|
|
||||||
raw = r.get(key)
|
|
||||||
if not raw:
|
|
||||||
return {
|
|
||||||
"types": {},
|
|
||||||
"order": [],
|
|
||||||
"hidden": [],
|
|
||||||
"custom": [],
|
|
||||||
"positions": {}
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
data = orjson.loads(raw)
|
|
||||||
# Basic shape validation
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise ValueError
|
|
||||||
return {
|
|
||||||
"types": data.get("types", {}),
|
|
||||||
"order": data.get("order", []),
|
|
||||||
"hidden": data.get("hidden", []),
|
|
||||||
"custom": data.get("custom", []),
|
|
||||||
"positions": data.get("positions", {}),
|
|
||||||
}
|
|
||||||
except Exception:
|
|
||||||
return {
|
|
||||||
"types": {},
|
|
||||||
"order": [],
|
|
||||||
"hidden": [],
|
|
||||||
"custom": [],
|
|
||||||
"positions": {}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post('/api/ups/{ups_name}/ui_tiles')
|
|
||||||
async def save_ups_ui_tiles(ups_name: str, payload: dict):
|
|
||||||
"""Persist UI tile settings for a UPS.
|
|
||||||
|
|
||||||
Expects JSON: { types: {...}, order: [...], hidden: [...], custom: [...] }
|
|
||||||
"""
|
|
||||||
# Light validation / sanitization
|
|
||||||
types = (payload.get('types')
|
|
||||||
if isinstance(payload.get('types'), dict) else {})
|
|
||||||
order = (payload.get('order')
|
|
||||||
if isinstance(payload.get('order'), list) else [])
|
|
||||||
hidden = (payload.get('hidden')
|
|
||||||
if isinstance(payload.get('hidden'), list) else [])
|
|
||||||
custom = (payload.get('custom')
|
|
||||||
if isinstance(payload.get('custom'), list) else [])
|
|
||||||
positions = (payload.get('positions')
|
|
||||||
if isinstance(payload.get('positions'), dict) else {})
|
|
||||||
# Ensure custom entries minimally formed
|
|
||||||
norm_custom = []
|
|
||||||
for c in custom or []:
|
|
||||||
if not isinstance(c, dict):
|
|
||||||
continue
|
|
||||||
metric = c.get('metric')
|
|
||||||
chart = c.get('chart')
|
|
||||||
cid = c.get('id') or ''
|
|
||||||
source = c.get('source', 'live')
|
|
||||||
if not metric or not chart:
|
|
||||||
continue
|
|
||||||
norm_custom.append({
|
|
||||||
'id': cid,
|
|
||||||
'metric': metric,
|
|
||||||
'chart': chart,
|
|
||||||
'source': source
|
|
||||||
})
|
|
||||||
doc = {
|
|
||||||
'types': types,
|
|
||||||
'order': order,
|
|
||||||
'hidden': hidden,
|
|
||||||
'custom': norm_custom,
|
|
||||||
'positions': positions,
|
|
||||||
'saved_ts': int(time.time())
|
|
||||||
}
|
|
||||||
r = get_redis()
|
|
||||||
key = f"ups:ui:tiles:{ups_name}"
|
|
||||||
r.set(key, orjson.dumps(doc))
|
|
||||||
return {"message": "saved", "count_custom": len(norm_custom)}
|
|
||||||
|
|
||||||
|
|
||||||
@app.delete('/api/ups/{ups_name}/ui_tiles')
|
|
||||||
async def clear_ups_ui_tiles(ups_name: str):
|
|
||||||
"""Delete stored UI tile layout/settings for a UPS (full reset)."""
|
|
||||||
r = get_redis()
|
|
||||||
key = f"ups:ui:tiles:{ups_name}"
|
|
||||||
r.delete(key)
|
|
||||||
return {"message": "cleared"}
|
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Notification channels."""
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""SMTP email sending with retry, batching, and HTML templates."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
from datetime import datetime
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
from tenacity import (
|
||||||
|
RetryError,
|
||||||
|
retry,
|
||||||
|
retry_if_exception_type,
|
||||||
|
stop_after_attempt,
|
||||||
|
wait_exponential,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..config import SMTPConfig
|
||||||
|
from ..settings import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_TEMPLATE_DIR = Path(__file__).parent.parent / "templates" / "emails"
|
||||||
|
_env = Environment(
|
||||||
|
loader=FileSystemLoader(str(_TEMPLATE_DIR)),
|
||||||
|
autoescape=select_autoescape(["html", "xml"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
SEVERITY_COLORS = {
|
||||||
|
"CRITICAL": "#c0392b",
|
||||||
|
"WARNING": "#e67e22",
|
||||||
|
"INFO": "#2980b9",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSendError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_password(smtp_cfg: SMTPConfig) -> str | None:
|
||||||
|
# Password is never stored in Redis; always from env var
|
||||||
|
return settings.smtp_password
|
||||||
|
|
||||||
|
|
||||||
|
@retry(
|
||||||
|
reraise=True,
|
||||||
|
stop=stop_after_attempt(3),
|
||||||
|
wait=wait_exponential(multiplier=1, min=1, max=10),
|
||||||
|
retry=retry_if_exception_type((smtplib.SMTPException, OSError)),
|
||||||
|
)
|
||||||
|
def _send_raw(smtp_cfg: SMTPConfig, msg: EmailMessage) -> None:
|
||||||
|
password = _resolve_password(smtp_cfg)
|
||||||
|
if smtp_cfg.use_ssl:
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
with smtplib.SMTP_SSL(
|
||||||
|
smtp_cfg.host, smtp_cfg.port, context=context, timeout=30
|
||||||
|
) as server:
|
||||||
|
if smtp_cfg.username and password:
|
||||||
|
server.login(smtp_cfg.username, password)
|
||||||
|
server.send_message(msg)
|
||||||
|
else:
|
||||||
|
with smtplib.SMTP(smtp_cfg.host, smtp_cfg.port, timeout=30) as server:
|
||||||
|
if smtp_cfg.use_tls:
|
||||||
|
server.starttls(context=ssl.create_default_context())
|
||||||
|
if smtp_cfg.username and password:
|
||||||
|
server.login(smtp_cfg.username, password)
|
||||||
|
server.send_message(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def send_alert_email(
|
||||||
|
smtp_cfg: SMTPConfig,
|
||||||
|
ups_name: str,
|
||||||
|
alerts: list[dict[str, Any]],
|
||||||
|
dashboard_url: str = "http://localhost:8000/",
|
||||||
|
) -> None:
|
||||||
|
"""Send a single HTML email coalescing multiple alerts for a UPS."""
|
||||||
|
if not smtp_cfg.to_addrs or not alerts:
|
||||||
|
return
|
||||||
|
top_severity = _pick_top_severity(alerts)
|
||||||
|
subject = f"{smtp_cfg.subject_prefix} [{top_severity}] {ups_name}"
|
||||||
|
template = _env.get_template("alert.html")
|
||||||
|
html = template.render(
|
||||||
|
ups_name=ups_name,
|
||||||
|
alerts=alerts,
|
||||||
|
top_severity=top_severity,
|
||||||
|
color=SEVERITY_COLORS.get(top_severity, "#555"),
|
||||||
|
dashboard_url=dashboard_url,
|
||||||
|
sent_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
)
|
||||||
|
text_body = "\n".join(
|
||||||
|
f"[{a.get('severity', 'INFO')}] {a.get('message', '')}" for a in alerts
|
||||||
|
)
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = smtp_cfg.from_addr or (smtp_cfg.username or "ups@example.local")
|
||||||
|
msg["To"] = ", ".join(smtp_cfg.to_addrs)
|
||||||
|
msg.set_content(text_body)
|
||||||
|
msg.add_alternative(html, subtype="html")
|
||||||
|
try:
|
||||||
|
_send_raw(smtp_cfg, msg)
|
||||||
|
except RetryError as e:
|
||||||
|
raise EmailSendError(f"SMTP send failed after retries: {e}") from e
|
||||||
|
except Exception as e: # noqa: BLE001 - surface as domain error
|
||||||
|
raise EmailSendError(f"SMTP send failed: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def send_test_email(smtp_cfg: SMTPConfig) -> None:
|
||||||
|
if not smtp_cfg.to_addrs:
|
||||||
|
raise EmailSendError("No recipients configured")
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["Subject"] = f"{smtp_cfg.subject_prefix} Test email"
|
||||||
|
msg["From"] = smtp_cfg.from_addr or (smtp_cfg.username or "ups@example.local")
|
||||||
|
msg["To"] = ", ".join(smtp_cfg.to_addrs)
|
||||||
|
msg.set_content(
|
||||||
|
"This is a test message from your APC UPS Dashboard.\n"
|
||||||
|
f"Sent at {datetime.now().isoformat()}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_send_raw(smtp_cfg, msg)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
raise EmailSendError(str(e)) from e
|
||||||
|
|
||||||
|
|
||||||
|
def send_daily_summary(
|
||||||
|
smtp_cfg: SMTPConfig,
|
||||||
|
summary: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
if not smtp_cfg.to_addrs:
|
||||||
|
return
|
||||||
|
template = _env.get_template("summary.html")
|
||||||
|
html = template.render(summary=summary, sent_at=datetime.now().isoformat())
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["Subject"] = f"{smtp_cfg.subject_prefix} Daily summary"
|
||||||
|
msg["From"] = smtp_cfg.from_addr or (smtp_cfg.username or "ups@example.local")
|
||||||
|
msg["To"] = ", ".join(smtp_cfg.to_addrs)
|
||||||
|
msg.set_content(str(summary))
|
||||||
|
msg.add_alternative(html, subtype="html")
|
||||||
|
try:
|
||||||
|
_send_raw(smtp_cfg, msg)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("Daily summary send failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_top_severity(alerts: list[dict[str, Any]]) -> str:
|
||||||
|
order = {"CRITICAL": 3, "WARNING": 2, "INFO": 1}
|
||||||
|
best = "INFO"
|
||||||
|
best_rank = 0
|
||||||
|
for a in alerts:
|
||||||
|
sev = a.get("severity", "INFO")
|
||||||
|
rank = order.get(sev, 0)
|
||||||
|
if rank > best_rank:
|
||||||
|
best = sev
|
||||||
|
best_rank = rank
|
||||||
|
return best
|
||||||
+136
-53
@@ -1,17 +1,114 @@
|
|||||||
|
"""Per-UPS async polling, event/energy/battery-history tracking.
|
||||||
|
|
||||||
|
Connection health:
|
||||||
|
ups:health:last_ok:<name> - unix ts of last successful poll
|
||||||
|
ups:health:fail_count:<name> - consecutive failures
|
||||||
|
ups:health:offline:<name> - "1" if we've emitted a COMMLOST alert
|
||||||
|
|
||||||
|
Battery history:
|
||||||
|
ups:battery:history:<name> - JSON {ts,bcharge,timeleft,battv,nombattv}
|
||||||
|
capped at 10080 entries (~7d @ 1/min)
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from .alerts import Alert, dispatch_alerts, emit_info, process_alerts
|
||||||
|
from .apc_cli import APCStatusError, fetch_status
|
||||||
from .config import load_config
|
from .config import load_config
|
||||||
from .apc_cli import fetch_status, APCStatusError
|
from .storage import get_redis, prune_old, store_snapshot
|
||||||
from .storage import store_snapshot, prune_old, get_redis
|
|
||||||
from .alerts import process_alerts
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_ACTIVE_TASKS: dict[str, asyncio.Task] = {}
|
_ACTIVE_TASKS: dict[str, asyncio.Task] = {}
|
||||||
_RELOADER_LOCK = asyncio.Lock()
|
_RELOADER_LOCK = asyncio.Lock()
|
||||||
|
|
||||||
|
BATTERY_HISTORY_MAX = 10080 # 7 days @ 1/minute
|
||||||
|
HEALTH_OFFLINE_MIN_SECONDS = 180
|
||||||
|
|
||||||
|
|
||||||
|
def _get_offline_threshold(interval_seconds: int) -> int:
|
||||||
|
return max(HEALTH_OFFLINE_MIN_SECONDS, 3 * interval_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_poll_success(name: str, snapshot: dict, interval_seconds: int) -> None:
|
||||||
|
r = get_redis()
|
||||||
|
now = int(time.time())
|
||||||
|
r.set(f"ups:health:last_ok:{name}", now)
|
||||||
|
r.delete(f"ups:health:fail_count:{name}")
|
||||||
|
was_offline = r.get(f"ups:health:offline:{name}")
|
||||||
|
if was_offline:
|
||||||
|
r.delete(f"ups:health:offline:{name}")
|
||||||
|
emit_info(name, "REACHABLE", f"UPS {name} reachable again")
|
||||||
|
# Battery history append (sample every ~60s to keep list bounded)
|
||||||
|
last_sample_key = f"ups:battery:history:last_ts:{name}"
|
||||||
|
last_ts_raw = r.get(last_sample_key)
|
||||||
|
try:
|
||||||
|
last_ts = int(last_ts_raw) if last_ts_raw else 0
|
||||||
|
except ValueError:
|
||||||
|
last_ts = 0
|
||||||
|
if now - last_ts >= 60:
|
||||||
|
try:
|
||||||
|
sample = {
|
||||||
|
"ts": now,
|
||||||
|
"bcharge": _f(snapshot.get("BCHARGE")),
|
||||||
|
"timeleft": _f(snapshot.get("TIMELEFT")),
|
||||||
|
"battv": _f(snapshot.get("BATTV")),
|
||||||
|
"nombattv": _f(snapshot.get("NOMBATTV")),
|
||||||
|
}
|
||||||
|
r.lpush(f"ups:battery:history:{name}", json.dumps(sample))
|
||||||
|
r.ltrim(f"ups:battery:history:{name}", 0, BATTERY_HISTORY_MAX - 1)
|
||||||
|
r.set(last_sample_key, now)
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _record_poll_failure(name: str, interval_seconds: int, reason: str) -> None:
|
||||||
|
r = get_redis()
|
||||||
|
now = int(time.time())
|
||||||
|
r.incr(f"ups:health:fail_count:{name}")
|
||||||
|
last_ok = r.get(f"ups:health:last_ok:{name}")
|
||||||
|
try:
|
||||||
|
last_ok_ts = int(last_ok) if last_ok else 0
|
||||||
|
except ValueError:
|
||||||
|
last_ok_ts = 0
|
||||||
|
threshold = _get_offline_threshold(interval_seconds)
|
||||||
|
# If we never succeeded, base threshold on process start (use last_ok=0 => big delta)
|
||||||
|
if now - last_ok_ts > threshold:
|
||||||
|
offline_flag = r.get(f"ups:health:offline:{name}")
|
||||||
|
if not offline_flag:
|
||||||
|
r.set(f"ups:health:offline:{name}", "1")
|
||||||
|
alert = Alert(
|
||||||
|
"CRITICAL",
|
||||||
|
f"UPS {name} unreachable ({reason})",
|
||||||
|
"COMMLOST",
|
||||||
|
name,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
cfg = load_config()
|
||||||
|
r.lpush(
|
||||||
|
"ups:alerts:history:all",
|
||||||
|
f"{alert.ts}|CRITICAL|{alert.ups}|COMMLOST|{alert.message}|{alert.hash()}",
|
||||||
|
)
|
||||||
|
r.ltrim("ups:alerts:history:all", 0, 499)
|
||||||
|
r.lpush(
|
||||||
|
f"ups:alerts:recent:{name}",
|
||||||
|
f"{alert.ts}|CRITICAL|{alert.message}",
|
||||||
|
)
|
||||||
|
r.ltrim(f"ups:alerts:recent:{name}", 0, 49)
|
||||||
|
if cfg.smtp:
|
||||||
|
dispatch_alerts(cfg.smtp, name, [alert])
|
||||||
|
|
||||||
|
|
||||||
|
def _f(v):
|
||||||
|
try:
|
||||||
|
return float(str(v).split()[0]) if v is not None else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _poll_one(ups):
|
async def _poll_one(ups):
|
||||||
r = get_redis()
|
r = get_redis()
|
||||||
@@ -21,7 +118,6 @@ async def _poll_one(ups):
|
|||||||
try:
|
try:
|
||||||
data = await fetch_status(ups.host, ups.port)
|
data = await fetch_status(ups.host, ups.port)
|
||||||
data['UPSNAME'] = ups.name
|
data['UPSNAME'] = ups.name
|
||||||
# Derived metrics
|
|
||||||
try:
|
try:
|
||||||
loadpct = float(str(data.get('LOADPCT', '0')).split()[0])
|
loadpct = float(str(data.get('LOADPCT', '0')).split()[0])
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -33,19 +129,15 @@ async def _poll_one(ups):
|
|||||||
pass
|
pass
|
||||||
if nompower and loadpct >= 0:
|
if nompower and loadpct >= 0:
|
||||||
watts = nompower * loadpct / 100.0
|
watts = nompower * loadpct / 100.0
|
||||||
data['DERIVED_WATTS'] = f"{watts:.0f}" # integer string
|
data['DERIVED_WATTS'] = f"{watts:.0f}"
|
||||||
data['HEADROOM_PCT'] = f"{max(0.0, 100.0 - loadpct):.0f}"
|
data['HEADROOM_PCT'] = f"{max(0.0, 100.0 - loadpct):.0f}"
|
||||||
# Runtime minutes (normalize TIMELEFT like '15.0 Minutes')
|
|
||||||
timeleft_raw = str(data.get('TIMELEFT', '')).strip()
|
timeleft_raw = str(data.get('TIMELEFT', '')).strip()
|
||||||
try:
|
try:
|
||||||
runtime_min = float(timeleft_raw.split()[0])
|
runtime_min = float(timeleft_raw.split()[0])
|
||||||
data['RUNTIME_MINUTES'] = f"{runtime_min:.1f}"
|
data['RUNTIME_MINUTES'] = f"{runtime_min:.1f}"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Event detection (status changes, last transfer changes)
|
wall_ts = int(time.time())
|
||||||
# Reuse redis handle r
|
|
||||||
now_ts = asyncio.get_event_loop().time()
|
|
||||||
wall_ts = int(now_ts)
|
|
||||||
status_key = f"ups:event:status:last:{ups.name}"
|
status_key = f"ups:event:status:last:{ups.name}"
|
||||||
lastxfer_key = f"ups:event:lastxfer:last:{ups.name}"
|
lastxfer_key = f"ups:event:lastxfer:last:{ups.name}"
|
||||||
events_list_key = f"ups:event:list:{ups.name}"
|
events_list_key = f"ups:event:list:{ups.name}"
|
||||||
@@ -55,53 +147,39 @@ async def _poll_one(ups):
|
|||||||
if prev_status != status_now and status_now:
|
if prev_status != status_now and status_now:
|
||||||
r.set(status_key, status_now)
|
r.set(status_key, status_now)
|
||||||
r.lpush(events_list_key, f"{wall_ts}|STATUS|{status_now}")
|
r.lpush(events_list_key, f"{wall_ts}|STATUS|{status_now}")
|
||||||
|
# Transition from ONBATT -> ONLINE: info alert
|
||||||
|
if prev_status and "ONBATT" in str(prev_status) and "ONLINE" in status_now:
|
||||||
|
emit_info(
|
||||||
|
ups.name,
|
||||||
|
"LINE_RESTORED",
|
||||||
|
f"UPS {ups.name} returned to line power",
|
||||||
|
)
|
||||||
lastxfer_now = str(data.get('LASTXFER', '')).strip()
|
lastxfer_now = str(data.get('LASTXFER', '')).strip()
|
||||||
prev_lastxfer = r.get(lastxfer_key)
|
prev_lastxfer = r.get(lastxfer_key)
|
||||||
if lastxfer_now and lastxfer_now != prev_lastxfer:
|
if lastxfer_now and lastxfer_now != prev_lastxfer:
|
||||||
r.set(lastxfer_key, lastxfer_now)
|
r.set(lastxfer_key, lastxfer_now)
|
||||||
r.lpush(events_list_key, f"{wall_ts}|XFER|{lastxfer_now}")
|
r.lpush(events_list_key, f"{wall_ts}|XFER|{lastxfer_now}")
|
||||||
# Trim events
|
|
||||||
r.ltrim(events_list_key, 0, max_events - 1)
|
r.ltrim(events_list_key, 0, max_events - 1)
|
||||||
# Energy accumulation (watt-seconds)
|
|
||||||
if 'DERIVED_WATTS' in data:
|
if 'DERIVED_WATTS' in data:
|
||||||
try:
|
try:
|
||||||
watts = float(data['DERIVED_WATTS'])
|
watts = float(data['DERIVED_WATTS'])
|
||||||
day_str = time.strftime('%Y%m%d')
|
day_str = time.strftime('%Y%m%d')
|
||||||
energy_key = f"ups:energy:{ups.name}:{day_str}"
|
energy_key = f"ups:energy:{ups.name}:{day_str}"
|
||||||
# increment by watts * interval_seconds (approx)
|
|
||||||
r.incrbyfloat(energy_key, watts * ups.interval_seconds)
|
r.incrbyfloat(energy_key, watts * ups.interval_seconds)
|
||||||
r.expire(energy_key, 3 * 24 * 3600)
|
r.expire(energy_key, 3 * 24 * 3600)
|
||||||
# Per-minute accumulation
|
|
||||||
minute = time.strftime('%Y%m%d%H%M')
|
minute = time.strftime('%Y%m%d%H%M')
|
||||||
# Running sum and count in a hash
|
|
||||||
mb = r.hgetall(minute_bucket_key)
|
mb = r.hgetall(minute_bucket_key)
|
||||||
if not mb or mb.get('minute') != minute:
|
if not mb or mb.get('minute') != minute:
|
||||||
# finalize previous bucket
|
if mb and 'sum' in mb and 'count' in mb and 'minute' in mb:
|
||||||
if (
|
|
||||||
mb
|
|
||||||
and 'sum' in mb
|
|
||||||
and 'count' in mb
|
|
||||||
and 'minute' in mb
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
avg = float(mb['sum']) / max(
|
avg = float(mb['sum']) / max(1, int(mb['count']))
|
||||||
1, int(mb['count'])
|
r.lpush(series_key, f"{mb['minute']}|{avg:.2f}")
|
||||||
)
|
|
||||||
r.lpush(
|
|
||||||
series_key,
|
|
||||||
f"{mb['minute']}|{avg:.2f}"
|
|
||||||
)
|
|
||||||
# keep up to 24h of minutes
|
|
||||||
r.ltrim(series_key, 0, 1439)
|
r.ltrim(series_key, 0, 1439)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
r.hset(
|
r.hset(
|
||||||
minute_bucket_key,
|
minute_bucket_key,
|
||||||
mapping={
|
mapping={'minute': minute, 'sum': watts, 'count': 1},
|
||||||
'minute': minute,
|
|
||||||
'sum': watts,
|
|
||||||
'count': 1,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
r.expire(minute_bucket_key, 26 * 3600)
|
r.expire(minute_bucket_key, 26 * 3600)
|
||||||
else:
|
else:
|
||||||
@@ -121,36 +199,47 @@ async def _poll_one(ups):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
await store_snapshot(ups.name, data)
|
await store_snapshot(ups.name, data)
|
||||||
|
_record_poll_success(ups.name, data, ups.interval_seconds)
|
||||||
process_alerts(ups, data)
|
process_alerts(ups, data)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
reason = str(e) if not isinstance(e, APCStatusError) else str(e)
|
||||||
if isinstance(e, APCStatusError):
|
if isinstance(e, APCStatusError):
|
||||||
logger.warning("apcaccess error for %s: %s", ups.name, e)
|
logger.warning("apcaccess error for %s: %s", ups.name, e)
|
||||||
else:
|
else:
|
||||||
logger.warning("Polling error for %s: %s", ups.name, e)
|
logger.warning("Polling error for %s: %s", ups.name, e)
|
||||||
|
_record_poll_failure(ups.name, ups.interval_seconds, reason)
|
||||||
await asyncio.sleep(ups.interval_seconds)
|
await asyncio.sleep(ups.interval_seconds)
|
||||||
|
|
||||||
|
|
||||||
async def _reconcile_tasks():
|
async def _reconcile_tasks():
|
||||||
"""Ensure a polling task exists per configured UPS and remove stale ones.
|
|
||||||
|
|
||||||
Creates tasks for new UPS entries and cancels tasks whose UPS were removed.
|
|
||||||
"""
|
|
||||||
async with _RELOADER_LOCK:
|
async with _RELOADER_LOCK:
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
current_names = {u.name for u in cfg.ups}
|
current = {u.name: (u.host, u.port, u.interval_seconds) for u in cfg.ups}
|
||||||
# cancel removed
|
# cancel removed or changed
|
||||||
for name in list(_ACTIVE_TASKS.keys()):
|
for name in list(_ACTIVE_TASKS.keys()):
|
||||||
if name not in current_names:
|
if name not in current:
|
||||||
_ACTIVE_TASKS[name].cancel()
|
_ACTIVE_TASKS[name].cancel()
|
||||||
del _ACTIVE_TASKS[name]
|
del _ACTIVE_TASKS[name]
|
||||||
# add new
|
|
||||||
for ups in cfg.ups:
|
for ups in cfg.ups:
|
||||||
if ups.name not in _ACTIVE_TASKS:
|
if ups.name not in _ACTIVE_TASKS:
|
||||||
_ACTIVE_TASKS[ups.name] = asyncio.create_task(_poll_one(ups))
|
_ACTIVE_TASKS[ups.name] = asyncio.create_task(_poll_one(ups))
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_all_tasks() -> None:
|
||||||
|
"""Cancel all running poller tasks (used on shutdown)."""
|
||||||
|
for t in _ACTIVE_TASKS.values():
|
||||||
|
t.cancel()
|
||||||
|
for _name, t in list(_ACTIVE_TASKS.items()):
|
||||||
|
try:
|
||||||
|
await t
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
_ACTIVE_TASKS.clear()
|
||||||
|
|
||||||
|
|
||||||
async def poll_loop():
|
async def poll_loop():
|
||||||
# initial reconcile
|
|
||||||
await _reconcile_tasks()
|
await _reconcile_tasks()
|
||||||
|
|
||||||
async def prune_loop():
|
async def prune_loop():
|
||||||
@@ -162,19 +251,13 @@ async def poll_loop():
|
|||||||
await asyncio.sleep(3600)
|
await asyncio.sleep(3600)
|
||||||
|
|
||||||
async def config_watch_loop():
|
async def config_watch_loop():
|
||||||
"""Periodically re-read config to capture UPS CRUD changes."""
|
|
||||||
last_fingerprint = None
|
last_fingerprint = None
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
fingerprint = tuple(
|
fingerprint = tuple(
|
||||||
sorted(
|
sorted(
|
||||||
(
|
(u.name, u.host, u.port, u.interval_seconds)
|
||||||
u.name,
|
|
||||||
u.host,
|
|
||||||
u.port,
|
|
||||||
u.interval_seconds,
|
|
||||||
)
|
|
||||||
for u in cfg.ups
|
for u in cfg.ups
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -188,5 +271,5 @@ async def poll_loop():
|
|||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
prune_loop(),
|
prune_loop(),
|
||||||
config_watch_loop(),
|
config_watch_loop(),
|
||||||
*(_ACTIVE_TASKS.values()),
|
return_exceptions=True,
|
||||||
)
|
)
|
||||||
@@ -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=[],
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Route modules split by concern."""
|
||||||
@@ -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"}
|
||||||
@@ -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}
|
||||||
@@ -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()}
|
||||||
@@ -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]
|
||||||
@@ -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)
|
||||||
@@ -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"}
|
||||||
@@ -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})
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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; }
|
||||||
@@ -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; }
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Shared client-side helpers: CSRF, logout, fetch wrapper
|
||||||
|
(function () {
|
||||||
|
function getCsrfToken() {
|
||||||
|
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
if (meta && meta.content) return meta.content;
|
||||||
|
const m = document.cookie.match(/(?:^|;)\s*csrf_token=([^;]+)/);
|
||||||
|
return m ? decodeURIComponent(m[1]) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.apiFetch = async function (url, opts = {}) {
|
||||||
|
const method = (opts.method || 'GET').toUpperCase();
|
||||||
|
const headers = Object.assign({ Accept: 'application/json' }, opts.headers || {});
|
||||||
|
if (method !== 'GET' && method !== 'HEAD') {
|
||||||
|
headers['X-CSRF-Token'] = getCsrfToken();
|
||||||
|
if (opts.body && !(opts.body instanceof FormData) && !headers['Content-Type']) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const resp = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts, { headers }));
|
||||||
|
if (resp.status === 401) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
throw new Error('unauthorized');
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', async (e) => {
|
||||||
|
if (e.target && e.target.id === 'logout-btn') {
|
||||||
|
await window.apiFetch('/api/logout', { method: 'POST' });
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
+121
-297
@@ -1,174 +1,113 @@
|
|||||||
// Configuration management JavaScript
|
// UPS Configuration page (uses apiFetch for CSRF)
|
||||||
|
|
||||||
|
function el(tag, attrs, ...children) {
|
||||||
|
const e = document.createElement(tag);
|
||||||
|
if (attrs) Object.entries(attrs).forEach(([k, v]) => {
|
||||||
|
if (k === 'class') e.className = v;
|
||||||
|
else if (k === 'dataset') Object.assign(e.dataset, v);
|
||||||
|
else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2), v);
|
||||||
|
else if (v !== null && v !== undefined) e.setAttribute(k, v);
|
||||||
|
});
|
||||||
|
children.forEach(c => {
|
||||||
|
if (c == null) return;
|
||||||
|
e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
||||||
|
});
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
class ConfigManager {
|
class ConfigManager {
|
||||||
constructor() {
|
constructor() { this.confirmCallback = null; this.init(); }
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
init() { this.bindEvents(); this.loadUPSList(); }
|
||||||
this.bindEvents();
|
|
||||||
this.loadUPSList();
|
|
||||||
}
|
|
||||||
|
|
||||||
bindEvents() {
|
bindEvents() {
|
||||||
// Add UPS button
|
document.getElementById('add-ups-btn').addEventListener('click', () => this.showUPSModal());
|
||||||
document.getElementById('add-ups-btn').addEventListener('click', () => {
|
|
||||||
this.showUPSModal();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Modal close buttons
|
|
||||||
document.querySelectorAll('.close').forEach(close => {
|
document.querySelectorAll('.close').forEach(close => {
|
||||||
close.addEventListener('click', (e) => {
|
close.addEventListener('click', (e) => this.closeModal(e.target.closest('.modal')));
|
||||||
this.closeModal(e.target.closest('.modal'));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// UPS form submission
|
|
||||||
document.getElementById('ups-form').addEventListener('submit', (e) => {
|
document.getElementById('ups-form').addEventListener('submit', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault(); this.saveUPS();
|
||||||
this.saveUPS();
|
|
||||||
});
|
});
|
||||||
|
document.getElementById('test-connection-btn').addEventListener('click', () => this.testConnection());
|
||||||
// Test connection button
|
|
||||||
document.getElementById('test-connection-btn').addEventListener('click', () => {
|
|
||||||
this.testConnection();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cancel button
|
|
||||||
document.getElementById('cancel-btn').addEventListener('click', () => {
|
document.getElementById('cancel-btn').addEventListener('click', () => {
|
||||||
this.closeModal(document.getElementById('ups-modal'));
|
this.closeModal(document.getElementById('ups-modal'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Confirmation modal buttons
|
|
||||||
document.getElementById('confirm-yes').addEventListener('click', () => {
|
document.getElementById('confirm-yes').addEventListener('click', () => {
|
||||||
if (this.confirmCallback) {
|
if (this.confirmCallback) this.confirmCallback();
|
||||||
this.confirmCallback();
|
|
||||||
}
|
|
||||||
this.closeModal(document.getElementById('confirm-modal'));
|
this.closeModal(document.getElementById('confirm-modal'));
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('confirm-no').addEventListener('click', () => {
|
document.getElementById('confirm-no').addEventListener('click', () => {
|
||||||
this.closeModal(document.getElementById('confirm-modal'));
|
this.closeModal(document.getElementById('confirm-modal'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click outside modal to close
|
|
||||||
window.addEventListener('click', (e) => {
|
window.addEventListener('click', (e) => {
|
||||||
if (e.target.classList.contains('modal')) {
|
if (e.target.classList.contains('modal')) this.closeModal(e.target);
|
||||||
this.closeModal(e.target);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadUPSList() {
|
async loadUPSList() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/config/ups');
|
const resp = await window.apiFetch('/api/config/ups');
|
||||||
const upsList = await response.json();
|
const list = await resp.json();
|
||||||
|
if (!resp.ok) throw new Error(list.detail || 'Failed to load');
|
||||||
if (!response.ok) {
|
this.renderUPSList(list);
|
||||||
throw new Error(upsList.detail || 'Failed to load UPS configurations');
|
} catch (err) { this.showToast('Error: ' + err.message, 'error'); }
|
||||||
}
|
|
||||||
|
|
||||||
this.renderUPSList(upsList);
|
|
||||||
} catch (error) {
|
|
||||||
this.showToast('Error loading UPS configurations: ' + error.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderUPSList(upsList) {
|
renderUPSList(list) {
|
||||||
const container = document.getElementById('ups-list');
|
const container = document.getElementById('ups-list');
|
||||||
|
container.textContent = '';
|
||||||
if (upsList.length === 0) {
|
if (list.length === 0) {
|
||||||
container.innerHTML = `
|
const empty = el('div', { class: 'empty-state' },
|
||||||
<div class="empty-state">
|
el('h3', null, 'No UPS Configured'),
|
||||||
<h3>No UPS Configured</h3>
|
el('p', null, 'Click "Add UPS" to configure your first UPS.'));
|
||||||
<p>Get started by clicking "Add UPS" above to configure your first UPS monitoring.</p>
|
container.appendChild(empty);
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
list.forEach(ups => container.appendChild(this.renderUPSItem(ups)));
|
||||||
container.innerHTML = upsList.map(ups => this.renderUPSItem(ups)).join('');
|
|
||||||
|
|
||||||
// Bind action buttons
|
|
||||||
container.querySelectorAll('.edit-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', (e) => {
|
|
||||||
const upsName = e.target.dataset.upsName;
|
|
||||||
this.editUPS(upsName);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
container.querySelectorAll('.delete-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', (e) => {
|
|
||||||
const upsName = e.target.dataset.upsName;
|
|
||||||
this.confirmDelete(upsName);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
container.querySelectorAll('.test-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', (e) => {
|
|
||||||
const upsName = e.target.dataset.upsName;
|
|
||||||
this.testUPSConnection(upsName);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderUPSItem(ups) {
|
renderUPSItem(ups) {
|
||||||
const alertsEnabled = [
|
const alerts = [];
|
||||||
ups.alert_loadpct_high && `Load: ${ups.alert_loadpct_high}%`,
|
if (ups.alert_loadpct_high) alerts.push('Load: ' + ups.alert_loadpct_high + '%');
|
||||||
ups.alert_bcharge_low && `Battery: ${ups.alert_bcharge_low}%`,
|
if (ups.alert_bcharge_low) alerts.push('Battery: ' + ups.alert_bcharge_low + '%');
|
||||||
ups.alert_on_battery && 'On Battery',
|
if (ups.alert_on_battery) alerts.push('On Battery');
|
||||||
ups.alert_runtime_low_minutes && `Runtime: ${ups.alert_runtime_low_minutes}min`
|
if (ups.alert_runtime_low_minutes) alerts.push('Runtime: ' + ups.alert_runtime_low_minutes + 'min');
|
||||||
].filter(Boolean);
|
if (ups.alert_itemp_high) alerts.push('Temp: ' + ups.alert_itemp_high + '°C');
|
||||||
|
const testBtn = el('button', { class: 'btn btn-secondary', onclick: () => this.testUPSConnection(ups.name) }, 'Test');
|
||||||
return `
|
const editBtn = el('button', { class: 'btn btn-primary', onclick: () => this.editUPS(ups.name) }, 'Edit');
|
||||||
<div class="ups-item">
|
const delBtn = el('button', { class: 'btn btn-danger', onclick: () => this.confirmDelete(ups.name) }, 'Delete');
|
||||||
<div class="ups-item-header">
|
const item = el('div', { class: 'ups-item' },
|
||||||
<div class="ups-item-title">${ups.name}</div>
|
el('div', { class: 'ups-item-header' },
|
||||||
<div class="ups-item-actions">
|
el('div', { class: 'ups-item-title' }, ups.name),
|
||||||
<button class="btn btn-secondary test-btn" data-ups-name="${ups.name}">Test</button>
|
el('div', { class: 'ups-item-actions' }, testBtn, editBtn, delBtn)),
|
||||||
<button class="btn btn-primary edit-btn" data-ups-name="${ups.name}">Edit</button>
|
el('div', { class: 'ups-item-details' },
|
||||||
<button class="btn btn-danger delete-btn" data-ups-name="${ups.name}">Delete</button>
|
el('div', { class: 'ups-detail' },
|
||||||
</div>
|
el('div', { class: 'ups-detail-label' }, 'Host'),
|
||||||
</div>
|
el('div', { class: 'ups-detail-value' }, ups.host + ':' + ups.port)),
|
||||||
<div class="ups-item-details">
|
el('div', { class: 'ups-detail' },
|
||||||
<div class="ups-detail">
|
el('div', { class: 'ups-detail-label' }, 'Polling Interval'),
|
||||||
<div class="ups-detail-label">Host</div>
|
el('div', { class: 'ups-detail-value' }, ups.interval_seconds + 's')),
|
||||||
<div class="ups-detail-value">${ups.host}:${ups.port}</div>
|
el('div', { class: 'ups-detail' },
|
||||||
</div>
|
el('div', { class: 'ups-detail-label' }, 'Alerts'),
|
||||||
<div class="ups-detail">
|
el('div', { class: 'ups-detail-value' }, alerts.length ? alerts.join(', ') : 'None'))));
|
||||||
<div class="ups-detail-label">Polling Interval</div>
|
return item;
|
||||||
<div class="ups-detail-value">${ups.interval_seconds}s</div>
|
|
||||||
</div>
|
|
||||||
<div class="ups-detail">
|
|
||||||
<div class="ups-detail-label">Alerts</div>
|
|
||||||
<div class="ups-detail-value">${alertsEnabled.length > 0 ? alertsEnabled.join(', ') : 'None'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showUPSModal(ups = null) {
|
showUPSModal(ups = null) {
|
||||||
const modal = document.getElementById('ups-modal');
|
const modal = document.getElementById('ups-modal');
|
||||||
const title = document.getElementById('modal-title');
|
const title = document.getElementById('modal-title');
|
||||||
const form = document.getElementById('ups-form');
|
const form = document.getElementById('ups-form');
|
||||||
|
|
||||||
// Reset form
|
|
||||||
form.reset();
|
form.reset();
|
||||||
|
|
||||||
if (ups) {
|
if (ups) {
|
||||||
// Edit mode
|
|
||||||
title.textContent = 'Edit UPS';
|
title.textContent = 'Edit UPS';
|
||||||
this.populateForm(ups);
|
this.populateForm(ups);
|
||||||
form.dataset.mode = 'edit';
|
form.dataset.mode = 'edit';
|
||||||
form.dataset.upsName = ups.name;
|
form.dataset.upsName = ups.name;
|
||||||
} else {
|
} else {
|
||||||
// Add mode
|
|
||||||
title.textContent = 'Add UPS';
|
title.textContent = 'Add UPS';
|
||||||
form.dataset.mode = 'add';
|
form.dataset.mode = 'add';
|
||||||
delete form.dataset.upsName;
|
delete form.dataset.upsName;
|
||||||
}
|
}
|
||||||
|
|
||||||
modal.style.display = 'block';
|
modal.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,222 +118,107 @@ class ConfigManager {
|
|||||||
document.getElementById('ups-interval').value = ups.interval_seconds || 30;
|
document.getElementById('ups-interval').value = ups.interval_seconds || 30;
|
||||||
document.getElementById('ups-loadpct').value = ups.alert_loadpct_high || '';
|
document.getElementById('ups-loadpct').value = ups.alert_loadpct_high || '';
|
||||||
document.getElementById('ups-bcharge').value = ups.alert_bcharge_low || '';
|
document.getElementById('ups-bcharge').value = ups.alert_bcharge_low || '';
|
||||||
document.getElementById('ups-onbattery').checked = ups.alert_on_battery || false;
|
document.getElementById('ups-onbattery').checked = !!ups.alert_on_battery;
|
||||||
document.getElementById('ups-runtime').value = ups.alert_runtime_low_minutes || '';
|
document.getElementById('ups-runtime').value = ups.alert_runtime_low_minutes || '';
|
||||||
|
const itemp = document.getElementById('ups-itemp');
|
||||||
|
if (itemp) itemp.value = ups.alert_itemp_high || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
closeModal(modal) {
|
closeModal(modal) { modal.style.display = 'none'; }
|
||||||
modal.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveUPS() {
|
async saveUPS() {
|
||||||
const form = document.getElementById('ups-form');
|
const form = document.getElementById('ups-form');
|
||||||
const formData = new FormData(form);
|
const fd = new FormData(form);
|
||||||
const mode = form.dataset.mode;
|
const mode = form.dataset.mode;
|
||||||
const upsName = form.dataset.upsName;
|
const name = form.dataset.upsName;
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
name: formData.get('name'),
|
name: fd.get('name'),
|
||||||
host: formData.get('host'),
|
host: fd.get('host'),
|
||||||
port: parseInt(formData.get('port')) || 3551,
|
port: parseInt(fd.get('port')) || 3551,
|
||||||
interval_seconds: parseInt(formData.get('interval_seconds')) || 30,
|
interval_seconds: parseInt(fd.get('interval_seconds')) || 30,
|
||||||
alert_on_battery: formData.has('alert_on_battery')
|
alert_on_battery: fd.has('alert_on_battery'),
|
||||||
};
|
};
|
||||||
|
const num = (k) => { const v = fd.get(k); if (v) data[k] = parseFloat(v); };
|
||||||
// Add optional alert thresholds
|
num('alert_loadpct_high'); num('alert_bcharge_low');
|
||||||
const loadpct = formData.get('alert_loadpct_high');
|
num('alert_runtime_low_minutes'); num('alert_itemp_high');
|
||||||
if (loadpct) data.alert_loadpct_high = parseFloat(loadpct);
|
|
||||||
|
|
||||||
const bcharge = formData.get('alert_bcharge_low');
|
|
||||||
if (bcharge) data.alert_bcharge_low = parseFloat(bcharge);
|
|
||||||
|
|
||||||
const runtime = formData.get('alert_runtime_low_minutes');
|
|
||||||
if (runtime) data.alert_runtime_low_minutes = parseFloat(runtime);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let response;
|
const url = mode === 'edit' ? '/api/config/ups/' + name : '/api/config/ups';
|
||||||
if (mode === 'edit') {
|
const method = mode === 'edit' ? 'PUT' : 'POST';
|
||||||
response = await fetch(`/api/config/ups/${upsName}`, {
|
const resp = await window.apiFetch(url, { method, body: JSON.stringify(data) });
|
||||||
method: 'PUT',
|
const result = await resp.json();
|
||||||
headers: { 'Content-Type': 'application/json' },
|
if (!resp.ok) throw new Error(result.detail || 'Save failed');
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
response = await fetch('/api/config/ups', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(result.detail || 'Failed to save UPS configuration');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.showToast(result.message, 'success');
|
this.showToast(result.message, 'success');
|
||||||
this.closeModal(document.getElementById('ups-modal'));
|
this.closeModal(document.getElementById('ups-modal'));
|
||||||
this.loadUPSList();
|
this.loadUPSList();
|
||||||
} catch (error) {
|
} catch (err) { this.showToast('Error: ' + err.message, 'error'); }
|
||||||
this.showToast('Error saving UPS: ' + error.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async editUPS(upsName) {
|
async editUPS(name) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/config/ups/${upsName}`);
|
const resp = await window.apiFetch('/api/config/ups/' + name);
|
||||||
const ups = await response.json();
|
const ups = await resp.json();
|
||||||
|
if (!resp.ok) throw new Error(ups.detail || 'Load failed');
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(ups.detail || 'Failed to load UPS configuration');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.showUPSModal(ups);
|
this.showUPSModal(ups);
|
||||||
} catch (error) {
|
} catch (err) { this.showToast('Error: ' + err.message, 'error'); }
|
||||||
this.showToast('Error loading UPS configuration: ' + error.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmDelete(upsName) {
|
confirmDelete(name) {
|
||||||
const modal = document.getElementById('confirm-modal');
|
document.getElementById('confirm-message').textContent =
|
||||||
const message = document.getElementById('confirm-message');
|
'Delete UPS "' + name + '"? This cannot be undone.';
|
||||||
|
this.confirmCallback = () => this.deleteUPS(name);
|
||||||
message.textContent = `Are you sure you want to delete the UPS configuration "${upsName}"? This action cannot be undone.`;
|
document.getElementById('confirm-modal').style.display = 'block';
|
||||||
|
|
||||||
this.confirmCallback = () => this.deleteUPS(upsName);
|
|
||||||
modal.style.display = 'block';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteUPS(upsName) {
|
async deleteUPS(name) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/config/ups/${upsName}`, {
|
const resp = await window.apiFetch('/api/config/ups/' + name, { method: 'DELETE' });
|
||||||
method: 'DELETE'
|
const result = await resp.json();
|
||||||
});
|
if (!resp.ok) throw new Error(result.detail || 'Delete failed');
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(result.detail || 'Failed to delete UPS configuration');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.showToast(result.message, 'success');
|
this.showToast(result.message, 'success');
|
||||||
this.loadUPSList();
|
this.loadUPSList();
|
||||||
} catch (error) {
|
} catch (err) { this.showToast('Error: ' + err.message, 'error'); }
|
||||||
this.showToast('Error deleting UPS: ' + error.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async testConnection() {
|
async testConnection() {
|
||||||
const form = document.getElementById('ups-form');
|
const form = document.getElementById('ups-form');
|
||||||
const formData = new FormData(form);
|
const fd = new FormData(form);
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
name: formData.get('name') || 'test',
|
name: fd.get('name') || 'test',
|
||||||
host: formData.get('host'),
|
host: fd.get('host'),
|
||||||
port: parseInt(formData.get('port')) || 3551,
|
port: parseInt(fd.get('port')) || 3551,
|
||||||
interval_seconds: 30,
|
interval_seconds: 30,
|
||||||
alert_on_battery: false
|
alert_on_battery: false,
|
||||||
};
|
};
|
||||||
|
if (!data.host) { this.showToast('Host required', 'error'); return; }
|
||||||
if (!data.host) {
|
const btn = document.getElementById('test-connection-btn');
|
||||||
this.showToast('Host is required for connection test', 'error');
|
const orig = btn.textContent;
|
||||||
return;
|
btn.textContent = 'Testing...'; btn.disabled = true;
|
||||||
}
|
|
||||||
|
|
||||||
const testBtn = document.getElementById('test-connection-btn');
|
|
||||||
const originalText = testBtn.textContent;
|
|
||||||
testBtn.textContent = 'Testing...';
|
|
||||||
testBtn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/config/ups/test', {
|
const resp = await window.apiFetch('/api/config/ups/test', {
|
||||||
method: 'POST',
|
method: 'POST', body: JSON.stringify(data),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
});
|
||||||
|
const r = await resp.json();
|
||||||
const result = await response.json();
|
this.showToast(r.message || (r.success ? 'OK' : 'Failed'),
|
||||||
let msg;
|
r.success ? 'success' : 'error');
|
||||||
if (result.success) {
|
} catch (err) { this.showToast('Test failed: ' + err.message, 'error'); }
|
||||||
msg = 'Connection successful';
|
finally { btn.textContent = orig; btn.disabled = false; }
|
||||||
if (result.data && result.data.STATUS) {
|
|
||||||
msg += ` (STATUS=${result.data.STATUS})`;
|
|
||||||
}
|
|
||||||
this.showToast(msg, 'success');
|
|
||||||
} else {
|
|
||||||
if (result.connectivity && !result.connectivity.ok) {
|
|
||||||
msg = 'TCP connectivity failed: ' + (result.connectivity.error || 'unknown error');
|
|
||||||
} else if (result.protocol && !result.protocol.ok) {
|
|
||||||
msg = 'Protocol error after TCP success: ' + (result.protocol.error || 'unknown error');
|
|
||||||
} else {
|
|
||||||
msg = result.message || 'Connection failed';
|
|
||||||
}
|
|
||||||
this.showToast(msg, 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.showToast('Connection test failed: ' + error.message, 'error');
|
|
||||||
} finally {
|
|
||||||
testBtn.textContent = originalText;
|
|
||||||
testBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async testUPSConnection(upsName) {
|
async testUPSConnection(name) {
|
||||||
const testBtn = document.querySelector(`[data-ups-name="${upsName}"].test-btn`);
|
|
||||||
const originalText = testBtn.textContent;
|
|
||||||
testBtn.textContent = 'Testing...';
|
|
||||||
testBtn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/config/ups/${upsName}/test`, {
|
const resp = await window.apiFetch('/api/config/ups/' + name + '/test', { method: 'POST' });
|
||||||
method: 'POST'
|
const r = await resp.json();
|
||||||
});
|
this.showToast(r.message || (r.success ? 'OK' : 'Failed'),
|
||||||
|
r.success ? 'success' : 'error');
|
||||||
const result = await response.json();
|
} catch (err) { this.showToast('Test failed: ' + err.message, 'error'); }
|
||||||
let msg;
|
|
||||||
if (result.success) {
|
|
||||||
msg = `Connection to ${upsName} successful`;
|
|
||||||
if (result.data && result.data.STATUS) {
|
|
||||||
msg += ` (STATUS=${result.data.STATUS})`;
|
|
||||||
}
|
|
||||||
this.showToast(msg, 'success');
|
|
||||||
} else {
|
|
||||||
if (result.connectivity && !result.connectivity.ok) {
|
|
||||||
msg = `TCP connectivity failed: ${result.connectivity.error}`;
|
|
||||||
} else if (result.protocol && !result.protocol.ok) {
|
|
||||||
msg = `Protocol error: ${result.protocol.error}`;
|
|
||||||
} else {
|
|
||||||
msg = result.message || 'Connection failed';
|
|
||||||
}
|
|
||||||
this.showToast(`Connection to ${upsName} failed: ${msg}`, 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.showToast(`Connection test failed: ${error.message}`, 'error');
|
|
||||||
} finally {
|
|
||||||
testBtn.textContent = originalText;
|
|
||||||
testBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast(message, type = 'info') {
|
showToast(message, type = 'info') {
|
||||||
const container = document.getElementById('toast-container');
|
const container = document.getElementById('toast-container');
|
||||||
const toast = document.createElement('div');
|
const toast = el('div', { class: 'toast ' + type }, message);
|
||||||
toast.className = `toast ${type}`;
|
|
||||||
toast.textContent = message;
|
|
||||||
|
|
||||||
container.appendChild(toast);
|
container.appendChild(toast);
|
||||||
|
setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 5000);
|
||||||
// Auto-remove after 5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
if (toast.parentNode) {
|
|
||||||
toast.parentNode.removeChild(toast);
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize when DOM is loaded
|
document.addEventListener('DOMContentLoaded', () => { new ConfigManager(); });
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
new ConfigManager();
|
|
||||||
});
|
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Settings page
|
||||||
|
(async function () {
|
||||||
|
async function loadUi() {
|
||||||
|
const r = await window.apiFetch('/api/config/ui');
|
||||||
|
if (!r.ok) return;
|
||||||
|
const ui = await r.json();
|
||||||
|
const form = document.getElementById('ui-form');
|
||||||
|
Object.entries(ui).forEach(([k, v]) => {
|
||||||
|
const el = form.elements[k];
|
||||||
|
if (!el) return;
|
||||||
|
if (el.type === 'checkbox') el.checked = !!v;
|
||||||
|
else el.value = v;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSmtp() {
|
||||||
|
const r = await window.apiFetch('/api/config/smtp');
|
||||||
|
if (!r.ok) return;
|
||||||
|
const s = await r.json();
|
||||||
|
if (!s) return;
|
||||||
|
const form = document.getElementById('smtp-form');
|
||||||
|
Object.entries(s).forEach(([k, v]) => {
|
||||||
|
const el = form.elements[k];
|
||||||
|
if (!el) return;
|
||||||
|
if (k === 'to_addrs' && Array.isArray(v)) el.value = v.join(', ');
|
||||||
|
else if (el.type === 'checkbox') el.checked = !!v;
|
||||||
|
else if (v !== null && v !== undefined) el.value = v;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('ui-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = e.target;
|
||||||
|
const payload = {
|
||||||
|
show_events: f.show_events.checked,
|
||||||
|
show_energy: f.show_energy.checked,
|
||||||
|
color_badges: f.color_badges.checked,
|
||||||
|
enable_transfer_burst_alert: f.enable_transfer_burst_alert.checked,
|
||||||
|
enable_voltage_deviation_alert: f.enable_voltage_deviation_alert.checked,
|
||||||
|
energy_cost_per_kwh: parseFloat(f.energy_cost_per_kwh.value) || 0,
|
||||||
|
};
|
||||||
|
const r = await window.apiFetch('/api/config/ui', {
|
||||||
|
method: 'PUT', body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
alert(r.ok ? 'UI settings saved' : 'Save failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('smtp-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = e.target;
|
||||||
|
const to = f.to_addrs.value.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
const payload = {
|
||||||
|
host: f.host.value,
|
||||||
|
port: parseInt(f.port.value, 10),
|
||||||
|
username: f.username.value || null,
|
||||||
|
use_tls: f.use_tls.checked,
|
||||||
|
use_ssl: f.use_ssl.checked,
|
||||||
|
from_addr: f.from_addr.value || null,
|
||||||
|
to_addrs: to,
|
||||||
|
subject_prefix: f.subject_prefix.value || '[UPS]',
|
||||||
|
silent_hours_start: f.silent_hours_start.value ? parseInt(f.silent_hours_start.value, 10) : null,
|
||||||
|
silent_hours_end: f.silent_hours_end.value ? parseInt(f.silent_hours_end.value, 10) : null,
|
||||||
|
daily_summary_hour: f.daily_summary_hour.value ? parseInt(f.daily_summary_hour.value, 10) : null,
|
||||||
|
};
|
||||||
|
const r = await window.apiFetch('/api/config/smtp', {
|
||||||
|
method: 'PUT', body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
alert(r.ok ? 'SMTP saved' : 'Save failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('test-smtp-btn').addEventListener('click', async () => {
|
||||||
|
const r = await window.apiFetch('/api/config/smtp/test', { method: 'POST' });
|
||||||
|
const body = await r.json().catch(() => ({}));
|
||||||
|
alert(r.ok ? 'Test email sent' : 'Failed: ' + (body.detail || 'unknown'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadUi();
|
||||||
|
await loadSmtp();
|
||||||
|
})();
|
||||||
+30
-19
@@ -1,51 +1,62 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import asyncio
|
|
||||||
import time
|
|
||||||
from typing import Dict, Any, List
|
|
||||||
import redis
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
REDIS_URL = os.environ.get("REDIS_URL", "redis://redis:6379/0")
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import redis
|
||||||
|
|
||||||
|
from .settings import settings
|
||||||
|
|
||||||
RETENTION_SECONDS = 7 * 24 * 3600
|
RETENTION_SECONDS = 7 * 24 * 3600
|
||||||
MAX_SAMPLES_PER_UPS = 7 * 24 * 60 * 2 # assume worst-case 30s interval -> ~20160 entries
|
MAX_SAMPLES_PER_UPS = 7 * 24 * 60 * 2 # worst-case ~30s interval
|
||||||
|
|
||||||
_redis: redis.Redis | None = None
|
_redis: redis.Redis | None = None
|
||||||
|
|
||||||
|
|
||||||
def get_redis() -> redis.Redis:
|
def get_redis() -> redis.Redis:
|
||||||
global _redis
|
global _redis
|
||||||
if _redis:
|
if _redis:
|
||||||
return _redis
|
return _redis
|
||||||
_redis = redis.Redis.from_url(REDIS_URL, decode_responses=True)
|
_redis = redis.Redis.from_url(settings.redis_url, decode_responses=True)
|
||||||
return _redis
|
return _redis
|
||||||
|
|
||||||
SNAP_KEY_PREFIX = "ups:snap:" # latest hash per ups
|
|
||||||
HIST_KEY_PREFIX = "ups:hist:" # time-series list per ups (append JSON)
|
|
||||||
|
|
||||||
async def store_snapshot(ups_name: str, data: Dict[str, Any]):
|
def reset_redis_client() -> None:
|
||||||
|
"""Test hook - clears cached Redis connection."""
|
||||||
|
global _redis
|
||||||
|
_redis = None
|
||||||
|
|
||||||
|
|
||||||
|
SNAP_KEY_PREFIX = "ups:snap:"
|
||||||
|
HIST_KEY_PREFIX = "ups:hist:"
|
||||||
|
|
||||||
|
|
||||||
|
async def store_snapshot(ups_name: str, data: dict[str, Any]):
|
||||||
r = get_redis()
|
r = get_redis()
|
||||||
ts = int(time.time())
|
ts = int(time.time())
|
||||||
pipe = r.pipeline()
|
pipe = r.pipeline()
|
||||||
# store latest snapshot (hash)
|
|
||||||
pipe.hset(f"{SNAP_KEY_PREFIX}{ups_name}", mapping={**data, "_ts": ts})
|
pipe.hset(f"{SNAP_KEY_PREFIX}{ups_name}", mapping={**data, "_ts": ts})
|
||||||
# append to history list
|
|
||||||
hist_key = f"{HIST_KEY_PREFIX}{ups_name}"
|
hist_key = f"{HIST_KEY_PREFIX}{ups_name}"
|
||||||
pipe.rpush(hist_key, json.dumps({"ts": ts, "data": data}))
|
pipe.rpush(hist_key, json.dumps({"ts": ts, "data": data}))
|
||||||
pipe.ltrim(hist_key, -MAX_SAMPLES_PER_UPS, -1)
|
pipe.ltrim(hist_key, -MAX_SAMPLES_PER_UPS, -1)
|
||||||
# add pruning via async task (length-based + time-based)
|
|
||||||
pipe.execute()
|
pipe.execute()
|
||||||
|
|
||||||
async def get_latest(ups_name: str) -> Dict[str, Any] | None:
|
|
||||||
|
async def get_latest(ups_name: str) -> dict[str, Any] | None:
|
||||||
r = get_redis()
|
r = get_redis()
|
||||||
h = r.hgetall(f"{SNAP_KEY_PREFIX}{ups_name}")
|
h = r.hgetall(f"{SNAP_KEY_PREFIX}{ups_name}")
|
||||||
return h or None
|
return h or None
|
||||||
|
|
||||||
async def get_history(ups_name: str, since_seconds: int = RETENTION_SECONDS) -> List[Dict[str, Any]]:
|
|
||||||
|
async def get_history(
|
||||||
|
ups_name: str, since_seconds: int = RETENTION_SECONDS
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
r = get_redis()
|
r = get_redis()
|
||||||
key = f"{HIST_KEY_PREFIX}{ups_name}"
|
key = f"{HIST_KEY_PREFIX}{ups_name}"
|
||||||
raw = r.lrange(key, 0, -1)
|
raw = r.lrange(key, 0, -1)
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
out: List[Dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
for item in raw:
|
for item in raw:
|
||||||
try:
|
try:
|
||||||
obj = json.loads(item)
|
obj = json.loads(item)
|
||||||
@@ -55,12 +66,12 @@ async def get_history(ups_name: str, since_seconds: int = RETENTION_SECONDS) ->
|
|||||||
out.append(obj)
|
out.append(obj)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def prune_old():
|
async def prune_old():
|
||||||
r = get_redis()
|
r = get_redis()
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
cutoff = now - RETENTION_SECONDS
|
cutoff = now - RETENTION_SECONDS
|
||||||
for key in r.scan_iter(f"{HIST_KEY_PREFIX}*"):
|
for key in r.scan_iter(f"{HIST_KEY_PREFIX}*"):
|
||||||
# prune from left while older than cutoff
|
|
||||||
while True:
|
while True:
|
||||||
item = r.lindex(key, 0)
|
item = r.lindex(key, 0)
|
||||||
if not item:
|
if not item:
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Alerts — UPS{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Alerts</h1>
|
||||||
|
<div class="filters">
|
||||||
|
<select id="filter-severity"><option value="">All Severities</option><option value="CRITICAL">CRITICAL</option><option value="WARNING">WARNING</option><option value="INFO">INFO</option></select>
|
||||||
|
<select id="filter-ups"><option value="">All UPS</option></select>
|
||||||
|
<select id="filter-view"><option value="active">Active</option><option value="history">History (30d)</option></select>
|
||||||
|
<button id="refresh-btn" class="btn btn-secondary">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="main-content">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>Time</th><th>Severity</th><th>UPS</th><th>Code</th><th>Message</th><th>Acked</th><th></th></tr></thead>
|
||||||
|
<tbody id="alerts-tbody"><tr><td colspan="7">Loading...</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="/static/js/alerts.js"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{% block title %}UPS Dashboard{% endblock %}</title>
|
||||||
|
<meta name="csrf-token" content="{{ request.cookies.get('csrf_token', '') }}" />
|
||||||
|
<link rel="stylesheet" href="/static/css/base.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css" />
|
||||||
|
{% block head_extra %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="brand"><a href="/">⚡ APC UPS Dashboard</a></div>
|
||||||
|
<nav class="main-nav">
|
||||||
|
<a href="/" class="{% if active_nav == 'dashboard' %}active{% endif %}">Dashboard</a>
|
||||||
|
<a href="/events" class="{% if active_nav == 'events' %}active{% endif %}">Events</a>
|
||||||
|
<a href="/alerts" class="{% if active_nav == 'alerts' %}active{% endif %}">Alerts</a>
|
||||||
|
<a href="/config" class="{% if active_nav == 'config' %}active{% endif %}">UPS Config</a>
|
||||||
|
<a href="/settings" class="{% if active_nav == 'settings' %}active{% endif %}">Settings</a>
|
||||||
|
</nav>
|
||||||
|
<div class="user-menu">
|
||||||
|
{% if current_user %}
|
||||||
|
<span class="username">{{ current_user }}</span>
|
||||||
|
<button type="button" id="logout-btn" class="btn-logout">Logout</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="site-main">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+79
-103
@@ -1,112 +1,88 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="en">
|
{% block title %}UPS Configuration{% endblock %}
|
||||||
<head>
|
{% block head_extra %}
|
||||||
<meta charset="UTF-8" />
|
<link rel="stylesheet" href="/static/css/config.css" />
|
||||||
<title>UPS Configuration</title>
|
{% endblock %}
|
||||||
<link rel="stylesheet" href="/static/css/style.css" />
|
{% block content %}
|
||||||
<link rel="stylesheet" href="/static/css/config.css" />
|
<div class="page-header">
|
||||||
</head>
|
<h1>UPS Configuration</h1>
|
||||||
<body>
|
<button id="add-ups-btn" class="btn btn-primary">Add UPS</button>
|
||||||
<div class="header">
|
</div>
|
||||||
<h1>UPS Configuration</h1>
|
|
||||||
<nav>
|
|
||||||
<a href="/">← Back to Dashboard</a>
|
|
||||||
<button id="add-ups-btn" class="btn btn-primary">Add UPS</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
<div id="ups-list" class="ups-list">
|
<div id="ups-list" class="ups-list"></div>
|
||||||
<!-- UPS configurations will be loaded here -->
|
</div>
|
||||||
|
|
||||||
|
<div id="ups-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="modal-title">Add UPS</h2>
|
||||||
|
<span class="close">×</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<form id="ups-form">
|
||||||
|
<div class="form-group">
|
||||||
<!-- Add/Edit UPS Modal -->
|
<label for="ups-name">Name *</label>
|
||||||
<div id="ups-modal" class="modal">
|
<input type="text" id="ups-name" name="name" required pattern="[A-Za-z0-9_-]{1,32}" maxlength="32">
|
||||||
<div class="modal-content">
|
<small>1-32 alphanumeric, underscore, dash</small>
|
||||||
<div class="modal-header">
|
|
||||||
<h2 id="modal-title">Add UPS</h2>
|
|
||||||
<span class="close">×</span>
|
|
||||||
</div>
|
</div>
|
||||||
<form id="ups-form">
|
<div class="form-group">
|
||||||
<div class="form-group">
|
<label for="ups-host">Host/IP *</label>
|
||||||
<label for="ups-name">Name *</label>
|
<input type="text" id="ups-host" name="host" required>
|
||||||
<input type="text" id="ups-name" name="name" required>
|
<small>IP or hostname of the apcupsd server</small>
|
||||||
<small>Friendly name for the UPS</small>
|
</div>
|
||||||
</div>
|
<div class="form-group">
|
||||||
|
<label for="ups-port">Port</label>
|
||||||
<div class="form-group">
|
<input type="number" id="ups-port" name="port" value="3551" min="1" max="65535">
|
||||||
<label for="ups-host">Host/IP *</label>
|
</div>
|
||||||
<input type="text" id="ups-host" name="host" required>
|
<div class="form-group">
|
||||||
<small>IP address or hostname of the apcupsd server</small>
|
<label for="ups-interval">Polling Interval (seconds)</label>
|
||||||
</div>
|
<input type="number" id="ups-interval" name="interval_seconds" value="30" min="5" max="3600">
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<h3>Alert Thresholds</h3>
|
||||||
<label for="ups-port">Port</label>
|
<div class="form-group">
|
||||||
<input type="number" id="ups-port" name="port" value="3551">
|
<label for="ups-loadpct">High Load Alert (%)</label>
|
||||||
<small>apcupsd NIS port (default: 3551)</small>
|
<input type="number" id="ups-loadpct" name="alert_loadpct_high" min="0" max="100" step="0.1">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
<div class="form-group">
|
<label for="ups-bcharge">Low Battery Alert (%)</label>
|
||||||
<label for="ups-interval">Polling Interval (seconds)</label>
|
<input type="number" id="ups-bcharge" name="alert_bcharge_low" min="0" max="100" step="0.1">
|
||||||
<input type="number" id="ups-interval" name="interval_seconds" value="30" min="1">
|
</div>
|
||||||
<small>How often to poll the UPS for data</small>
|
<div class="form-group">
|
||||||
</div>
|
<label><input type="checkbox" id="ups-onbattery" name="alert_on_battery"> Alert when on battery power</label>
|
||||||
|
</div>
|
||||||
<h3>Alert Thresholds</h3>
|
<div class="form-group">
|
||||||
|
<label for="ups-runtime">Low Runtime Alert (minutes)</label>
|
||||||
<div class="form-group">
|
<input type="number" id="ups-runtime" name="alert_runtime_low_minutes" min="0" step="0.1">
|
||||||
<label for="ups-loadpct">High Load Alert (%)</label>
|
</div>
|
||||||
<input type="number" id="ups-loadpct" name="alert_loadpct_high" min="0" max="100" step="0.1">
|
<div class="form-group">
|
||||||
<small>Trigger alert when load percentage reaches this value</small>
|
<label for="ups-itemp">High Temperature Alert (°C)</label>
|
||||||
</div>
|
<input type="number" id="ups-itemp" name="alert_itemp_high" min="0" max="120" step="0.5">
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ups-bcharge">Low Battery Alert (%)</label>
|
|
||||||
<input type="number" id="ups-bcharge" name="alert_bcharge_low" min="0" max="100" step="0.1">
|
|
||||||
<small>Trigger alert when battery charge drops to this value</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" id="ups-onbattery" name="alert_on_battery">
|
|
||||||
Alert when on battery power
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ups-runtime">Low Runtime Alert (minutes)</label>
|
|
||||||
<input type="number" id="ups-runtime" name="alert_runtime_low_minutes" min="0" step="0.1">
|
|
||||||
<small>Trigger alert when remaining runtime drops to this value</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="button" id="test-connection-btn" class="btn btn-secondary">Test Connection</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Save</button>
|
|
||||||
<button type="button" id="cancel-btn" class="btn btn-secondary">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Confirmation Dialog -->
|
|
||||||
<div id="confirm-modal" class="modal">
|
|
||||||
<div class="modal-content small">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2>Confirm Action</h2>
|
|
||||||
<span class="close">×</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="confirm-message"></p>
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button id="confirm-yes" class="btn btn-danger">Yes</button>
|
<button type="button" id="test-connection-btn" class="btn btn-secondary">Test Connection</button>
|
||||||
<button id="confirm-no" class="btn btn-secondary">No</button>
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<button type="button" id="cancel-btn" class="btn btn-secondary">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="confirm-modal" class="modal">
|
||||||
|
<div class="modal-content small">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Confirm Action</h2>
|
||||||
|
<span class="close">×</span>
|
||||||
|
</div>
|
||||||
|
<p id="confirm-message"></p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button id="confirm-yes" class="btn btn-danger">Yes</button>
|
||||||
|
<button id="confirm-no" class="btn btn-secondary">No</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Toast notifications -->
|
<div id="toast-container"></div>
|
||||||
<div id="toast-container"></div>
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
<script src="/static/js/config.js"></script>
|
<script src="/static/js/config.js"></script>
|
||||||
</body>
|
{% endblock %}
|
||||||
</html>
|
|
||||||
@@ -1,46 +1,54 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="en">
|
{% block title %}Dashboard — UPS{% endblock %}
|
||||||
<head>
|
{% block head_extra %}
|
||||||
<meta charset="UTF-8" />
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<title>UPS Dashboard</title>
|
{% endblock %}
|
||||||
<link rel="stylesheet" href="/static/css/style.css" />
|
{% block content %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<section class="fleet-overview" id="fleet-overview">
|
||||||
</head>
|
<div class="fleet-card">
|
||||||
<body>
|
<div class="fleet-stat">
|
||||||
<div class="header">
|
<span class="fleet-stat-label">Total UPS</span>
|
||||||
<h1>APC UPS Dashboard</h1>
|
<span class="fleet-stat-value" id="fleet-total">-</span>
|
||||||
<nav>
|
</div>
|
||||||
<a href="/config" class="config-link">⚙️ Configuration</a>
|
<div class="fleet-stat"><span class="fleet-stat-label">Online</span><span class="fleet-stat-value online" id="fleet-online">-</span></div>
|
||||||
</nav>
|
<div class="fleet-stat"><span class="fleet-stat-label">On Battery</span><span class="fleet-stat-value onbatt" id="fleet-onbatt">-</span></div>
|
||||||
|
<div class="fleet-stat"><span class="fleet-stat-label">Offline</span><span class="fleet-stat-value offline" id="fleet-offline">-</span></div>
|
||||||
|
<div class="fleet-stat"><span class="fleet-stat-label">Total Load (W)</span><span class="fleet-stat-value" id="fleet-watts">-</span></div>
|
||||||
|
<div class="fleet-stat"><span class="fleet-stat-label">Min Runtime</span><span class="fleet-stat-value" id="fleet-min-runtime">-</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="ups-cards" data-ui='{{ ui_cfg | tojson }}'>
|
</section>
|
||||||
{% for ups in ups_list %}
|
|
||||||
<div class="card" id="card-{{ ups.name }}">
|
<div id="ups-cards" data-ui='{{ ui_cfg | tojson }}'>
|
||||||
<div class="card-header">
|
{% for ups in ups_list %}
|
||||||
<h2>{{ ups.name }}</h2>
|
<div class="card" id="card-{{ ups.name }}">
|
||||||
<div class="ups-connection" data-field-conn>{{ ups.host }}:{{ ups.port }}</div>
|
<div class="card-header">
|
||||||
<button class="add-metric-btn" data-add-metric title="Add metric tile">+</button>
|
<h2>
|
||||||
<button class="reset-layout-btn" data-reset-layout title="Reset tile layout">⟳</button>
|
<span class="state-dot" data-state-dot></span>
|
||||||
</div>
|
{{ ups.name }}
|
||||||
<div class="metrics">
|
</h2>
|
||||||
<div class="highline">
|
<div class="ups-connection" data-field-conn>{{ ups.host }}:{{ ups.port }}</div>
|
||||||
<span class="ups-status unknown" data-field="STATUS">-</span>
|
<button class="reset-layout-btn" data-reset-layout title="Reset tile layout">⟳</button>
|
||||||
<span class="badge" data-field="RUNTIME_MINUTES" data-badge="runtime">- m</span>
|
|
||||||
<span class="badge" data-field="DERIVED_WATTS" data-badge="watts">- W</span>
|
|
||||||
<span class="badge" data-field="HEADROOM_PCT" data-badge="headroom">- headroom</span>
|
|
||||||
</div>
|
|
||||||
<div class="tile-grid" data-tile-grid></div>
|
|
||||||
</div>
|
|
||||||
<div class="ups-footer" data-ups-footer></div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
<div class="metrics">
|
||||||
{% if not ups_list %}
|
<div class="highline">
|
||||||
<div class="empty-state">
|
<span class="ups-status unknown" data-field="STATUS">-</span>
|
||||||
<h3>No UPS Configured</h3>
|
<span class="badge" data-field="RUNTIME_MINUTES" data-badge="runtime">- m</span>
|
||||||
<p>Get started by <a href="/config">adding a UPS configuration</a>.</p>
|
<span class="badge" data-field="DERIVED_WATTS" data-badge="watts">- W</span>
|
||||||
|
<span class="badge" data-field="HEADROOM_PCT" data-badge="headroom">- headroom</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile-grid" data-tile-grid></div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
<div class="ups-footer" data-ups-footer></div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/js/dashboard.js?v=1"></script>
|
{% endfor %}
|
||||||
</body>
|
{% if not ups_list %}
|
||||||
</html>
|
<div class="empty-state">
|
||||||
|
<h3>No UPS Configured</h3>
|
||||||
|
<p>Get started by <a href="/config">adding a UPS configuration</a>.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="/static/js/dashboard.js?v=2"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="UTF-8"></head>
|
||||||
|
<body style="font-family: -apple-system, Segoe UI, Arial, sans-serif; margin: 0; padding: 0; background: #f4f4f4;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f4f4f4; padding: 24px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="background: #ffffff; border-radius: 6px; overflow: hidden; box-shadow: 0 2px 6px rgba(0,0,0,.08);">
|
||||||
|
<tr>
|
||||||
|
<td style="background: {{ color }}; color: #ffffff; padding: 16px 20px; font-size: 18px; font-weight: 600;">
|
||||||
|
[{{ top_severity }}] {{ ups_name }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 20px;">
|
||||||
|
<p style="margin: 0 0 12px; color: #333;">The following alerts were triggered:</p>
|
||||||
|
<ul style="margin: 0 0 16px; padding-left: 20px; color: #333;">
|
||||||
|
{% for a in alerts %}
|
||||||
|
<li style="margin-bottom: 6px;"><strong>[{{ a.severity }}]</strong> {{ a.message }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<p style="margin: 0 0 8px;"><a href="{{ dashboard_url }}" style="color: #2980b9;">Open dashboard</a></p>
|
||||||
|
<p style="margin: 0; color: #888; font-size: 12px;">Sent at {{ sent_at }}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="UTF-8"></head>
|
||||||
|
<body style="font-family: -apple-system, Segoe UI, Arial, sans-serif;">
|
||||||
|
<h2>UPS Daily Summary</h2>
|
||||||
|
<p>Sent at: {{ sent_at }}</p>
|
||||||
|
<table cellpadding="6" cellspacing="0" border="1" style="border-collapse: collapse;">
|
||||||
|
<tr><th>UPS</th><th>Status</th><th>Load %</th><th>Battery %</th><th>kWh today</th><th>Alerts (24h)</th></tr>
|
||||||
|
{% for row in summary.ups_rows %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.name }}</td>
|
||||||
|
<td>{{ row.status }}</td>
|
||||||
|
<td>{{ row.loadpct }}</td>
|
||||||
|
<td>{{ row.bcharge }}</td>
|
||||||
|
<td>{{ row.kwh }}</td>
|
||||||
|
<td>{{ row.alerts_count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Events — UPS{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Event Log</h1>
|
||||||
|
<div class="filters">
|
||||||
|
<select id="filter-ups"><option value="">All UPS</option></select>
|
||||||
|
<select id="filter-kind"><option value="">All Events</option><option value="STATUS">STATUS</option><option value="XFER">XFER</option></select>
|
||||||
|
<button id="refresh-btn" class="btn btn-secondary">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="main-content">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>Time</th><th>UPS</th><th>Type</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody id="events-tbody"><tr><td colspan="4">Loading...</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="/static/js/events.js"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Login — UPS Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/base.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/login.css" />
|
||||||
|
</head>
|
||||||
|
<body class="auth-body">
|
||||||
|
<div class="auth-card">
|
||||||
|
<h1>UPS Dashboard</h1>
|
||||||
|
<form id="login-form">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" required autocomplete="username" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required autocomplete="current-password" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary btn-block">Sign in</button>
|
||||||
|
<div id="login-error" class="login-error" hidden></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const errBox = document.getElementById('login-error');
|
||||||
|
errBox.hidden = true;
|
||||||
|
const resp = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: document.getElementById('username').value,
|
||||||
|
password: document.getElementById('password').value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
window.location.href = '/';
|
||||||
|
} else {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
errBox.textContent = err.detail || 'Login failed';
|
||||||
|
errBox.hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Settings — UPS{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Settings</h1>
|
||||||
|
</div>
|
||||||
|
<div class="main-content">
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2>UI / Feature Flags</h2>
|
||||||
|
<form id="ui-form">
|
||||||
|
<label><input type="checkbox" name="show_events"> Show events tile</label>
|
||||||
|
<label><input type="checkbox" name="show_energy"> Show energy</label>
|
||||||
|
<label><input type="checkbox" name="color_badges"> Colorize badges</label>
|
||||||
|
<label><input type="checkbox" name="enable_transfer_burst_alert"> Transfer-burst alerts</label>
|
||||||
|
<label><input type="checkbox" name="enable_voltage_deviation_alert"> Voltage-deviation alerts</label>
|
||||||
|
<label>Energy cost per kWh ($): <input type="number" step="0.001" min="0" name="energy_cost_per_kwh"></label>
|
||||||
|
<button type="submit" class="btn btn-primary">Save UI Settings</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2>SMTP / Email Alerts</h2>
|
||||||
|
<form id="smtp-form">
|
||||||
|
<label>Host <input type="text" name="host" required></label>
|
||||||
|
<label>Port <input type="number" name="port" min="1" max="65535" required></label>
|
||||||
|
<label>Username <input type="text" name="username"></label>
|
||||||
|
<label><input type="checkbox" name="use_tls"> Use STARTTLS</label>
|
||||||
|
<label><input type="checkbox" name="use_ssl"> Use SSL</label>
|
||||||
|
<label>From <input type="email" name="from_addr"></label>
|
||||||
|
<label>To (comma-separated) <input type="text" name="to_addrs"></label>
|
||||||
|
<label>Subject Prefix <input type="text" name="subject_prefix" value="[UPS]"></label>
|
||||||
|
<label>Silent hours start (0-23, optional) <input type="number" name="silent_hours_start" min="0" max="23"></label>
|
||||||
|
<label>Silent hours end (0-23, optional) <input type="number" name="silent_hours_end" min="0" max="23"></label>
|
||||||
|
<label>Daily summary hour (0-23, optional) <input type="number" name="daily_summary_hour" min="0" max="23"></label>
|
||||||
|
<p class="muted">Password is read from the <code>SMTP_PASSWORD</code> environment variable and never stored in Redis.</p>
|
||||||
|
<button type="submit" class="btn btn-primary">Save SMTP</button>
|
||||||
|
<button type="button" id="test-smtp-btn" class="btn btn-secondary">Send Test Email</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="/static/js/settings.js"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Setup — UPS Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/base.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/login.css" />
|
||||||
|
</head>
|
||||||
|
<body class="auth-body">
|
||||||
|
<div class="auth-card wide">
|
||||||
|
<h1>First-run Setup</h1>
|
||||||
|
<p class="muted">Create the admin account. Passwords must be at least 8 characters.</p>
|
||||||
|
<form id="setup-form">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="username">Admin username</label>
|
||||||
|
<input type="text" id="username" name="username" required minlength="3" autocomplete="username" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required minlength="8" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="password2">Confirm password</label>
|
||||||
|
<input type="password" id="password2" name="password2" required minlength="8" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary btn-block">Create admin account</button>
|
||||||
|
<div id="setup-error" class="login-error" hidden></div>
|
||||||
|
</form>
|
||||||
|
<p class="muted" style="margin-top:16px;">After setup you'll be redirected to the dashboard. Configure UPS devices and SMTP from the nav.</p>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('setup-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const errBox = document.getElementById('setup-error');
|
||||||
|
errBox.hidden = true;
|
||||||
|
const pw = document.getElementById('password').value;
|
||||||
|
const pw2 = document.getElementById('password2').value;
|
||||||
|
if (pw !== pw2) {
|
||||||
|
errBox.textContent = 'Passwords do not match';
|
||||||
|
errBox.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resp = await fetch('/api/setup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: document.getElementById('username').value,
|
||||||
|
password: pw,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
window.location.href = '/';
|
||||||
|
} else {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
errBox.textContent = err.detail || 'Setup failed';
|
||||||
|
errBox.hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+16
-3
@@ -5,18 +5,31 @@ services:
|
|||||||
container_name: apcups-dashboard
|
container_name: apcups-dashboard
|
||||||
ports:
|
ports:
|
||||||
- "10280:8000"
|
- "10280:8000"
|
||||||
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
restart: always
|
restart: always
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://localhost:8000/healthz"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: redis
|
container_name: redis
|
||||||
restart: always
|
restart: always
|
||||||
# Enable persistence so UPS configuration stored in Redis survives restarts.
|
# Uncomment to require password (must also set REDIS_URL=redis://:PASS@redis:6379/0):
|
||||||
# Using both RDB snapshots (default schedule) and AOF for durability.
|
# command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec", "--requirepass", "${REDIS_PASSWORD}"]
|
||||||
command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec"]
|
command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,2 +1,42 @@
|
|||||||
|
[project]
|
||||||
|
name = "apcupsd-client"
|
||||||
|
version = "0.2.0"
|
||||||
|
description = "FastAPI + Redis dashboard for monitoring multiple APC UPS devices via apcupsd NIS"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
readme = "README.md"
|
||||||
|
license = { text = "MIT" }
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
pythonpath = ["."]
|
pythonpath = ["."]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
|
filterwarnings = [
|
||||||
|
"ignore::DeprecationWarning",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
|
extend-exclude = [".venv", "app/static"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "W", "I", "B", "UP", "SIM"]
|
||||||
|
ignore = [
|
||||||
|
"E501", # handled by formatter
|
||||||
|
"B008", # FastAPI dependency defaults pattern
|
||||||
|
"B904", # raise without from in except (noise in handlers)
|
||||||
|
"SIM105", # try/except/pass vs contextlib.suppress (not always clearer)
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/*" = ["B011", "B017", "F401", "F811"]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["app"]
|
||||||
|
omit = ["app/static/*", "app/templates/*"]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
show_missing = true
|
||||||
|
skip_covered = false
|
||||||
|
fail_under = 65
|
||||||
@@ -3,7 +3,20 @@ uvicorn[standard]==0.30.6
|
|||||||
pyyaml==6.0.2
|
pyyaml==6.0.2
|
||||||
redis==5.0.7
|
redis==5.0.7
|
||||||
pydantic==2.9.2
|
pydantic==2.9.2
|
||||||
|
pydantic-settings==2.5.2
|
||||||
jinja2==3.1.4
|
jinja2==3.1.4
|
||||||
python-multipart==0.0.9
|
python-multipart==0.0.9
|
||||||
orjson==3.10.7
|
orjson==3.10.7
|
||||||
|
passlib[argon2]==1.7.4
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
slowapi==0.1.9
|
||||||
|
python-json-logger==2.0.7
|
||||||
|
tenacity==9.0.0
|
||||||
|
prometheus-client==0.21.0
|
||||||
|
|
||||||
|
# Dev / test dependencies (kept in main file for simple docker-based dev)
|
||||||
pytest==8.3.3
|
pytest==8.3.3
|
||||||
|
pytest-asyncio==0.24.0
|
||||||
|
httpx==0.27.2
|
||||||
|
fakeredis==2.25.1
|
||||||
|
ruff==0.6.9
|
||||||
Whitespace-only changes.
@@ -0,0 +1,112 @@
|
|||||||
|
"""Shared pytest fixtures: fakeredis + app overrides.
|
||||||
|
|
||||||
|
Sets env vars before app modules import settings, swaps the Redis
|
||||||
|
client in app.storage for a fakeredis instance, and mocks apcaccess.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Configure env BEFORE importing any app modules
|
||||||
|
os.environ.setdefault("SESSION_SECRET", "test-session-secret-for-pytest-use-only-32bytes")
|
||||||
|
os.environ.setdefault("ADMIN_USERNAME", "admin")
|
||||||
|
os.environ.setdefault("ADMIN_PASSWORD_HASH", "")
|
||||||
|
os.environ.setdefault("RATE_LIMIT_ENABLED", "false")
|
||||||
|
os.environ.setdefault("ALLOW_PRIVATE_IPS", "true")
|
||||||
|
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/15")
|
||||||
|
os.environ.setdefault("LOG_LEVEL", "WARNING")
|
||||||
|
|
||||||
|
import fakeredis # noqa: E402
|
||||||
|
|
||||||
|
# Ensure fresh settings after env vars set
|
||||||
|
from app import settings as settings_mod # noqa: E402
|
||||||
|
|
||||||
|
settings_mod.get_settings.cache_clear()
|
||||||
|
settings_mod.settings = settings_mod.get_settings()
|
||||||
|
|
||||||
|
from app import storage as storage_mod # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_redis():
|
||||||
|
"""Provide a clean fakeredis client installed into storage module."""
|
||||||
|
client = fakeredis.FakeRedis(decode_responses=True)
|
||||||
|
storage_mod._redis = client
|
||||||
|
yield client
|
||||||
|
client.flushall()
|
||||||
|
storage_mod._redis = None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def clean_config_cache():
|
||||||
|
"""Reset module-level config cache to force reload from (fake) Redis."""
|
||||||
|
from app import config as config_mod
|
||||||
|
config_mod._cached = None
|
||||||
|
yield
|
||||||
|
config_mod._cached = None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app_client(fake_redis, clean_config_cache):
|
||||||
|
"""FastAPI TestClient with fakeredis wired in."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
with TestClient(app) as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def authed_client(app_client, fake_redis):
|
||||||
|
"""Test client with admin set up and logged in."""
|
||||||
|
from app.auth import store_admin
|
||||||
|
store_admin("admin", "testpassword123")
|
||||||
|
r = app_client.post(
|
||||||
|
"/api/login",
|
||||||
|
json={"username": "admin", "password": "testpassword123"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
csrf = r.json()["csrf_token"]
|
||||||
|
app_client.headers.update({"X-CSRF-Token": csrf})
|
||||||
|
return app_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_apcaccess():
|
||||||
|
"""Patch apc_cli.fetch_status to return a canned snapshot."""
|
||||||
|
from app import apc_cli
|
||||||
|
|
||||||
|
async def _fake_fetch(host, port):
|
||||||
|
return {
|
||||||
|
"STATUS": "ONLINE",
|
||||||
|
"LOADPCT": "25.0",
|
||||||
|
"NOMPOWER": "900",
|
||||||
|
"BCHARGE": "100.0",
|
||||||
|
"TIMELEFT": "45.0 Minutes",
|
||||||
|
"LINEV": "120.0",
|
||||||
|
"NOMINV": "120",
|
||||||
|
"ITEMP": "30.5",
|
||||||
|
"BATTV": "13.5",
|
||||||
|
"NOMBATTV": "12.0",
|
||||||
|
"SELFTEST": "NO",
|
||||||
|
"REPLACEBATT": "NO",
|
||||||
|
"LASTXFER": "No transfers since turnon",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(apc_cli, "fetch_status", _fake_fetch):
|
||||||
|
yield _fake_fetch
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_collection_modifyitems(config, items): # noqa: ARG001
|
||||||
|
"""Mark all async tests as asyncio automatically."""
|
||||||
|
for item in items:
|
||||||
|
if asyncio.iscoroutinefunction(item.function):
|
||||||
|
item.add_marker(pytest.mark.asyncio)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Alert rules, cooldown, silent hours, deferred drain."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.alerts import (
|
||||||
|
SEV_CRITICAL,
|
||||||
|
SEV_INFO,
|
||||||
|
SEV_WARNING,
|
||||||
|
Alert,
|
||||||
|
_defer_for_silent_hours,
|
||||||
|
_in_silent_hours,
|
||||||
|
drain_deferred,
|
||||||
|
emit_info,
|
||||||
|
evaluate_alerts,
|
||||||
|
process_alerts,
|
||||||
|
)
|
||||||
|
from app.config import SMTPConfig, UPSConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _ups(**overrides):
|
||||||
|
base = dict(
|
||||||
|
name="u1", host="192.168.1.10", port=3551, interval_seconds=30,
|
||||||
|
alert_on_battery=True,
|
||||||
|
alert_runtime_low_minutes=10.0,
|
||||||
|
alert_bcharge_low=20.0,
|
||||||
|
alert_loadpct_high=80.0,
|
||||||
|
alert_itemp_high=45.0,
|
||||||
|
)
|
||||||
|
base.update(overrides)
|
||||||
|
return UPSConfig(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def _snap(**overrides):
|
||||||
|
base = dict(
|
||||||
|
STATUS="ONLINE", LOADPCT="25.0", BCHARGE="100.0",
|
||||||
|
TIMELEFT="60.0 Minutes", ITEMP="30.0",
|
||||||
|
SELFTEST="NO", REPLACEBATT="NO",
|
||||||
|
)
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_alerts_normal_state(fake_redis, clean_config_cache):
|
||||||
|
alerts = evaluate_alerts(_ups(), _snap())
|
||||||
|
assert alerts == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_onbatt_critical(fake_redis, clean_config_cache):
|
||||||
|
alerts = evaluate_alerts(_ups(), _snap(STATUS="ONBATT"))
|
||||||
|
assert any(a.code == "ONBATT" and a.severity == SEV_CRITICAL for a in alerts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_low_critical(fake_redis, clean_config_cache):
|
||||||
|
alerts = evaluate_alerts(_ups(), _snap(TIMELEFT="5.0 Minutes"))
|
||||||
|
assert any(a.code == "RUNTIME_LOW" and a.severity == SEV_CRITICAL for a in alerts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bcharge_low_critical(fake_redis, clean_config_cache):
|
||||||
|
alerts = evaluate_alerts(_ups(), _snap(BCHARGE="15.0"))
|
||||||
|
assert any(a.code == "BCHARGE_LOW" and a.severity == SEV_CRITICAL for a in alerts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_high_warning(fake_redis, clean_config_cache):
|
||||||
|
alerts = evaluate_alerts(_ups(), _snap(LOADPCT="90.0"))
|
||||||
|
assert any(a.code == "LOAD_HIGH" and a.severity == SEV_WARNING for a in alerts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_replacebatt_warning(fake_redis, clean_config_cache):
|
||||||
|
alerts = evaluate_alerts(_ups(), _snap(REPLACEBATT="YES"))
|
||||||
|
assert any(a.code == "REPLACEBATT" and a.severity == SEV_WARNING for a in alerts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_selftest_fail_warning(fake_redis, clean_config_cache):
|
||||||
|
alerts = evaluate_alerts(_ups(), _snap(SELFTEST="FAIL"))
|
||||||
|
assert any(a.code == "SELFTEST_FAIL" and a.severity == SEV_WARNING for a in alerts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_temp_high_warning(fake_redis, clean_config_cache):
|
||||||
|
alerts = evaluate_alerts(_ups(), _snap(ITEMP="55.0"))
|
||||||
|
assert any(a.code == "TEMP_HIGH" and a.severity == SEV_WARNING for a in alerts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_alerts_coalesced(fake_redis, clean_config_cache):
|
||||||
|
alerts = evaluate_alerts(
|
||||||
|
_ups(),
|
||||||
|
_snap(STATUS="ONBATT", BCHARGE="10.0", LOADPCT="95.0"),
|
||||||
|
)
|
||||||
|
codes = {a.code for a in alerts}
|
||||||
|
assert "ONBATT" in codes
|
||||||
|
assert "BCHARGE_LOW" in codes
|
||||||
|
assert "LOAD_HIGH" in codes
|
||||||
|
|
||||||
|
|
||||||
|
def test_alert_hash_stable():
|
||||||
|
a1 = Alert(SEV_CRITICAL, "msg", "CODE", "u1", 100)
|
||||||
|
a2 = Alert(SEV_CRITICAL, "msg", "CODE", "u1", 999)
|
||||||
|
assert a1.hash() == a2.hash() # hash doesn't depend on ts
|
||||||
|
|
||||||
|
|
||||||
|
def test_silent_hours_same_day():
|
||||||
|
smtp = SMTPConfig(host="x", port=25, silent_hours_start=22, silent_hours_end=23)
|
||||||
|
now = datetime(2024, 1, 1, 22, 30)
|
||||||
|
assert _in_silent_hours(smtp, now) is True
|
||||||
|
now2 = datetime(2024, 1, 1, 23, 30)
|
||||||
|
assert _in_silent_hours(smtp, now2) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_silent_hours_wrap_midnight():
|
||||||
|
smtp = SMTPConfig(host="x", port=25, silent_hours_start=22, silent_hours_end=7)
|
||||||
|
assert _in_silent_hours(smtp, datetime(2024, 1, 1, 23, 0)) is True
|
||||||
|
assert _in_silent_hours(smtp, datetime(2024, 1, 1, 3, 0)) is True
|
||||||
|
assert _in_silent_hours(smtp, datetime(2024, 1, 1, 10, 0)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_silent_hours_unset():
|
||||||
|
smtp = SMTPConfig(host="x", port=25)
|
||||||
|
assert _in_silent_hours(smtp) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_defer_and_drain(fake_redis):
|
||||||
|
a = Alert(SEV_WARNING, "msg1", "CODE1", "u1", 100)
|
||||||
|
b = Alert(SEV_WARNING, "msg2", "CODE2", "u1", 101)
|
||||||
|
_defer_for_silent_hours([a, b])
|
||||||
|
drained = drain_deferred("u1")
|
||||||
|
assert len(drained) == 2
|
||||||
|
codes = {x.code for x in drained}
|
||||||
|
assert codes == {"CODE1", "CODE2"}
|
||||||
|
# Second drain is empty
|
||||||
|
assert drain_deferred("u1") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_cooldown_suppresses_second_call(fake_redis, clean_config_cache):
|
||||||
|
# Avoid real SMTP: no smtp in config
|
||||||
|
with patch("app.alerts.dispatch_alerts"):
|
||||||
|
fresh = process_alerts(_ups(), _snap(STATUS="ONBATT"))
|
||||||
|
assert any(a.code == "ONBATT" for a in fresh)
|
||||||
|
# Immediate second call: cooled down
|
||||||
|
fresh2 = process_alerts(_ups(), _snap(STATUS="ONBATT"))
|
||||||
|
assert all(a.code != "ONBATT" for a in fresh2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_info_writes_history(fake_redis, clean_config_cache):
|
||||||
|
with patch("app.alerts.dispatch_alerts"):
|
||||||
|
emit_info("u1", "REACHABLE", "u1 came back online")
|
||||||
|
entries = fake_redis.lrange("ups:alerts:history:all", 0, -1)
|
||||||
|
assert any("REACHABLE" in e for e in entries)
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""Integration tests for UPS/alerts/events API routes."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def _add_ups(client, name="u1", host="192.168.1.10"):
|
||||||
|
return client.post(
|
||||||
|
"/api/config/ups",
|
||||||
|
json={"name": name, "host": host, "port": 3551, "interval_seconds": 30},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ups_list_empty(authed_client):
|
||||||
|
r = authed_client.get("/api/ups")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ups_list_after_add(authed_client):
|
||||||
|
_add_ups(authed_client)
|
||||||
|
r = authed_client.get("/api/ups")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert len(body) == 1
|
||||||
|
assert body[0]["name"] == "u1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fleet_overview_empty(authed_client):
|
||||||
|
r = authed_client.get("/api/ups/fleet/overview")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["total"] == 0
|
||||||
|
assert body["counts"]["online"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fleet_overview_with_data(authed_client, fake_redis):
|
||||||
|
_add_ups(authed_client, name="u1")
|
||||||
|
_add_ups(authed_client, name="u2", host="192.168.1.20")
|
||||||
|
fake_redis.hset("ups:snap:u1", mapping={"STATUS": "ONLINE", "DERIVED_WATTS": "200", "TIMELEFT": "40"})
|
||||||
|
fake_redis.hset("ups:snap:u2", mapping={"STATUS": "ONBATT", "DERIVED_WATTS": "150", "TIMELEFT": "10"})
|
||||||
|
r = authed_client.get("/api/ups/fleet/overview")
|
||||||
|
body = r.json()
|
||||||
|
assert body["total"] == 2
|
||||||
|
assert body["counts"]["online"] == 1
|
||||||
|
assert body["counts"]["on_battery"] == 1
|
||||||
|
assert body["total_watts"] == 350.0
|
||||||
|
assert body["min_timeleft_minutes"] == 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fleet_overview_detects_offline(authed_client, fake_redis):
|
||||||
|
_add_ups(authed_client, name="u1")
|
||||||
|
fake_redis.set("ups:health:offline:u1", "1")
|
||||||
|
fake_redis.hset("ups:snap:u1", mapping={"STATUS": "ONLINE"})
|
||||||
|
r = authed_client.get("/api/ups/fleet/overview")
|
||||||
|
body = r.json()
|
||||||
|
assert body["counts"]["offline"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_ups_status(authed_client, fake_redis):
|
||||||
|
_add_ups(authed_client)
|
||||||
|
fake_redis.hset("ups:snap:u1", mapping={"STATUS": "ONLINE", "_ts": "100"})
|
||||||
|
r = authed_client.get("/api/ups/u1")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["STATUS"] == "ONLINE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ups_history(authed_client, fake_redis):
|
||||||
|
_add_ups(authed_client)
|
||||||
|
now = int(time.time())
|
||||||
|
fake_redis.rpush(
|
||||||
|
"ups:hist:u1",
|
||||||
|
json.dumps({"ts": now, "data": {"STATUS": "ONLINE", "LOADPCT": "50"}}),
|
||||||
|
)
|
||||||
|
r = authed_client.get("/api/ups/u1/history")
|
||||||
|
body = r.json()
|
||||||
|
assert len(body) == 1
|
||||||
|
assert body[0]["data"]["STATUS"] == "ONLINE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ups_metric(authed_client, fake_redis):
|
||||||
|
_add_ups(authed_client)
|
||||||
|
now = int(time.time())
|
||||||
|
for v in (10, 20, 30):
|
||||||
|
fake_redis.rpush(
|
||||||
|
"ups:hist:u1",
|
||||||
|
json.dumps({"ts": now, "data": {"LOADPCT": str(v)}}),
|
||||||
|
)
|
||||||
|
r = authed_client.get("/api/ups/u1/metric/LOADPCT")
|
||||||
|
body = r.json()
|
||||||
|
assert len(body) == 3
|
||||||
|
assert body[0]["value"] == 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_ups_events(authed_client, fake_redis):
|
||||||
|
_add_ups(authed_client)
|
||||||
|
fake_redis.lpush("ups:event:list:u1", f"{int(time.time())}|STATUS|ONLINE")
|
||||||
|
r = authed_client.get("/api/ups/u1/events")
|
||||||
|
body = r.json()
|
||||||
|
assert len(body) == 1
|
||||||
|
assert body[0]["type"] == "STATUS"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ups_energy_empty(authed_client):
|
||||||
|
_add_ups(authed_client)
|
||||||
|
r = authed_client.get("/api/ups/u1/energy")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["kwh_today"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ups_energy_with_data(authed_client, fake_redis):
|
||||||
|
_add_ups(authed_client)
|
||||||
|
day_str = time.strftime("%Y%m%d")
|
||||||
|
fake_redis.set(f"ups:energy:u1:{day_str}", "3600000") # 1 kWh
|
||||||
|
r = authed_client.get("/api/ups/u1/energy")
|
||||||
|
body = r.json()
|
||||||
|
assert body["kwh_today"] == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_ups_health(authed_client, fake_redis):
|
||||||
|
_add_ups(authed_client)
|
||||||
|
fake_redis.set("ups:health:last_ok:u1", "12345")
|
||||||
|
r = authed_client.get("/api/ups/u1/health")
|
||||||
|
body = r.json()
|
||||||
|
assert body["online"] is True
|
||||||
|
assert body["last_ok_ts"] == 12345
|
||||||
|
|
||||||
|
|
||||||
|
def test_ups_health_offline(authed_client, fake_redis):
|
||||||
|
_add_ups(authed_client)
|
||||||
|
fake_redis.set("ups:health:offline:u1", "1")
|
||||||
|
fake_redis.set("ups:health:fail_count:u1", "3")
|
||||||
|
r = authed_client.get("/api/ups/u1/health")
|
||||||
|
body = r.json()
|
||||||
|
assert body["online"] is False
|
||||||
|
assert body["fail_count"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_battery_health_empty(authed_client):
|
||||||
|
_add_ups(authed_client)
|
||||||
|
r = authed_client.get("/api/ups/u1/battery_health")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_events_list_empty(authed_client):
|
||||||
|
r = authed_client.get("/api/events")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_events_list_all(authed_client, fake_redis):
|
||||||
|
_add_ups(authed_client, name="u1")
|
||||||
|
_add_ups(authed_client, name="u2", host="192.168.1.20")
|
||||||
|
now = int(time.time())
|
||||||
|
fake_redis.lpush("ups:event:list:u1", f"{now}|STATUS|ONLINE")
|
||||||
|
fake_redis.lpush("ups:event:list:u2", f"{now - 10}|XFER|Low voltage")
|
||||||
|
r = authed_client.get("/api/events")
|
||||||
|
body = r.json()
|
||||||
|
assert len(body) == 2
|
||||||
|
# Sorted by ts desc
|
||||||
|
assert body[0]["ts"] >= body[1]["ts"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_events_filter_by_ups(authed_client, fake_redis):
|
||||||
|
_add_ups(authed_client, name="u1")
|
||||||
|
_add_ups(authed_client, name="u2", host="192.168.1.20")
|
||||||
|
now = int(time.time())
|
||||||
|
fake_redis.lpush("ups:event:list:u1", f"{now}|STATUS|ONLINE")
|
||||||
|
fake_redis.lpush("ups:event:list:u2", f"{now}|STATUS|ONBATT")
|
||||||
|
r = authed_client.get("/api/events?ups=u1")
|
||||||
|
body = r.json()
|
||||||
|
assert all(e["ups"] == "u1" for e in body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_events_filter_by_kind(authed_client, fake_redis):
|
||||||
|
_add_ups(authed_client)
|
||||||
|
now = int(time.time())
|
||||||
|
fake_redis.lpush("ups:event:list:u1", f"{now}|STATUS|ONLINE")
|
||||||
|
fake_redis.lpush("ups:event:list:u1", f"{now}|XFER|Low voltage")
|
||||||
|
r = authed_client.get("/api/events?kind=XFER")
|
||||||
|
body = r.json()
|
||||||
|
assert all(e["type"] == "XFER" for e in body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_alerts_list_empty(authed_client):
|
||||||
|
r = authed_client.get("/api/alerts")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_alerts_list_with_data(authed_client, fake_redis):
|
||||||
|
now = int(time.time())
|
||||||
|
fake_redis.lpush(
|
||||||
|
"ups:alerts:history:all",
|
||||||
|
f"{now}|CRITICAL|u1|ONBATT|On battery power|abc123",
|
||||||
|
)
|
||||||
|
r = authed_client.get("/api/alerts")
|
||||||
|
body = r.json()
|
||||||
|
assert len(body) == 1
|
||||||
|
assert body[0]["severity"] == "CRITICAL"
|
||||||
|
assert body[0]["ups"] == "u1"
|
||||||
|
assert body[0]["acked_ts"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_alerts_filter_by_severity(authed_client, fake_redis):
|
||||||
|
now = int(time.time())
|
||||||
|
fake_redis.lpush("ups:alerts:history:all", f"{now}|CRITICAL|u1|C|x|a")
|
||||||
|
fake_redis.lpush("ups:alerts:history:all", f"{now}|WARNING|u1|W|y|b")
|
||||||
|
r = authed_client.get("/api/alerts?severity=warning")
|
||||||
|
body = r.json()
|
||||||
|
assert all(a["severity"] == "WARNING" for a in body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_alerts_active_filters_acked(authed_client, fake_redis):
|
||||||
|
now = int(time.time())
|
||||||
|
fake_redis.lpush(
|
||||||
|
"ups:alerts:history:all",
|
||||||
|
f"{now}|CRITICAL|u1|A|msg|alert1",
|
||||||
|
)
|
||||||
|
fake_redis.lpush(
|
||||||
|
"ups:alerts:history:all",
|
||||||
|
f"{now}|CRITICAL|u1|B|msg2|alert2",
|
||||||
|
)
|
||||||
|
fake_redis.set("ups:alerts:ack:alert1", str(now))
|
||||||
|
r = authed_client.get("/api/alerts/active")
|
||||||
|
body = r.json()
|
||||||
|
assert all(a["id"] != "alert1" for a in body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_alerts_ack(authed_client, fake_redis):
|
||||||
|
r = authed_client.post("/api/alerts/myid/ack")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert fake_redis.get("ups:alerts:ack:myid") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_healthz_no_auth_required(app_client):
|
||||||
|
r = app_client.get("/healthz")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_readyz_reports_redis_status(app_client, fake_redis):
|
||||||
|
r = app_client.get("/readyz")
|
||||||
|
assert r.status_code in (200, 503)
|
||||||
|
|
||||||
|
|
||||||
|
def test_metrics_endpoint(app_client, fake_redis):
|
||||||
|
r = app_client.get("/metrics")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "ups_" in r.text or "# HELP" in r.text
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Auth: hash/verify, session token, login/logout, CSRF, setup."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.auth import (
|
||||||
|
create_session_token,
|
||||||
|
get_stored_admin,
|
||||||
|
hash_password,
|
||||||
|
is_admin_configured,
|
||||||
|
store_admin,
|
||||||
|
verify_password,
|
||||||
|
verify_session_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_and_verify_password(fake_redis):
|
||||||
|
h = hash_password("supersecret")
|
||||||
|
assert h.startswith("$argon2")
|
||||||
|
assert verify_password("supersecret", h) is True
|
||||||
|
assert verify_password("wrong", h) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_password_bad_hash_returns_false(fake_redis):
|
||||||
|
assert verify_password("anything", "not-a-valid-hash") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_token_roundtrip(fake_redis):
|
||||||
|
tok = create_session_token("admin")
|
||||||
|
assert verify_session_token(tok) == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_token_rejects_tampered(fake_redis):
|
||||||
|
tok = create_session_token("admin")
|
||||||
|
bad = tok[:-4] + "abcd"
|
||||||
|
assert verify_session_token(bad) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_and_get_admin(fake_redis):
|
||||||
|
store_admin("alice", "pw-strong-1")
|
||||||
|
user, h = get_stored_admin()
|
||||||
|
assert user == "alice"
|
||||||
|
assert h and h.startswith("$argon2")
|
||||||
|
assert is_admin_configured() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_rejects_before_setup(app_client, fake_redis):
|
||||||
|
r = app_client.post(
|
||||||
|
"/api/login",
|
||||||
|
json={"username": "admin", "password": "test"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_happy_path(app_client, fake_redis):
|
||||||
|
store_admin("admin", "testpassword123")
|
||||||
|
r = app_client.post(
|
||||||
|
"/api/login",
|
||||||
|
json={"username": "admin", "password": "testpassword123"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert "csrf_token" in body
|
||||||
|
# Cookies set
|
||||||
|
assert "ups_session" in r.cookies
|
||||||
|
assert "csrf_token" in r.cookies
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_bad_password(app_client, fake_redis):
|
||||||
|
store_admin("admin", "testpassword123")
|
||||||
|
r = app_client.post(
|
||||||
|
"/api/login",
|
||||||
|
json={"username": "admin", "password": "wrong"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_bad_username(app_client, fake_redis):
|
||||||
|
store_admin("admin", "testpassword123")
|
||||||
|
r = app_client.post(
|
||||||
|
"/api/login",
|
||||||
|
json={"username": "notadmin", "password": "testpassword123"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_clears_cookies(authed_client):
|
||||||
|
r = authed_client.post("/api/logout")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_creates_admin(app_client, fake_redis):
|
||||||
|
r = app_client.post(
|
||||||
|
"/api/setup",
|
||||||
|
json={"username": "newadmin", "password": "setup-pw-1234"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert is_admin_configured() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_rejects_when_configured(app_client, fake_redis):
|
||||||
|
store_admin("admin", "already-set-password")
|
||||||
|
r = app_client.post(
|
||||||
|
"/api/setup",
|
||||||
|
json={"username": "newadmin", "password": "setup-pw-1234"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_rejects_short_password(app_client, fake_redis):
|
||||||
|
r = app_client.post(
|
||||||
|
"/api/setup",
|
||||||
|
json={"username": "admin", "password": "short"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422 # pydantic validation
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_rejects_bad_username(app_client, fake_redis):
|
||||||
|
r = app_client.post(
|
||||||
|
"/api/setup",
|
||||||
|
json={"username": "bad user!", "password": "good-password-1"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_protected_endpoint_requires_auth(app_client):
|
||||||
|
r = app_client.get("/api/config/ups")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_csrf_required_for_mutations(app_client, fake_redis):
|
||||||
|
store_admin("admin", "testpassword123")
|
||||||
|
r = app_client.post(
|
||||||
|
"/api/login",
|
||||||
|
json={"username": "admin", "password": "testpassword123"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
# DELETE without CSRF header
|
||||||
|
r2 = app_client.delete("/api/config/ups/nonexistent")
|
||||||
|
assert r2.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_mutations_work_with_csrf(authed_client):
|
||||||
|
# Authed client already has CSRF header set in fixture
|
||||||
|
r = authed_client.delete("/api/config/ups/nonexistent")
|
||||||
|
# Expected 404 because UPS doesn't exist (NOT 401/403)
|
||||||
|
assert r.status_code == 404
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Config CRUD API: auth, CSRF, create/list/update/delete."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def test_unauthed_cannot_list_ups(app_client):
|
||||||
|
r = app_client.get("/api/config/ups")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_unauthed_cannot_add_ups(app_client):
|
||||||
|
r = app_client.post(
|
||||||
|
"/api/config/ups",
|
||||||
|
json={"name": "u1", "host": "192.168.1.10"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_and_list_ups(authed_client):
|
||||||
|
r = authed_client.post(
|
||||||
|
"/api/config/ups",
|
||||||
|
json={"name": "u1", "host": "192.168.1.10", "port": 3551, "interval_seconds": 30},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
r2 = authed_client.get("/api/config/ups")
|
||||||
|
assert r2.status_code == 200
|
||||||
|
body = r2.json()
|
||||||
|
assert len(body) == 1
|
||||||
|
assert body[0]["name"] == "u1"
|
||||||
|
assert body[0]["host"] == "192.168.1.10"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_duplicate_rejects(authed_client):
|
||||||
|
authed_client.post(
|
||||||
|
"/api/config/ups",
|
||||||
|
json={"name": "u1", "host": "192.168.1.10"},
|
||||||
|
)
|
||||||
|
r = authed_client.post(
|
||||||
|
"/api/config/ups",
|
||||||
|
json={"name": "u1", "host": "192.168.1.11"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_ssrf_host_rejected(authed_client):
|
||||||
|
r = authed_client.post(
|
||||||
|
"/api/config/ups",
|
||||||
|
json={"name": "bad", "host": "127.0.0.1"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_ups(authed_client):
|
||||||
|
authed_client.post(
|
||||||
|
"/api/config/ups",
|
||||||
|
json={"name": "u1", "host": "192.168.1.10"},
|
||||||
|
)
|
||||||
|
r = authed_client.put(
|
||||||
|
"/api/config/ups/u1",
|
||||||
|
json={"host": "192.168.2.20", "interval_seconds": 60},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
r2 = authed_client.get("/api/config/ups/u1")
|
||||||
|
body = r2.json()
|
||||||
|
assert body["host"] == "192.168.2.20"
|
||||||
|
assert body["interval_seconds"] == 60
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_nonexistent(authed_client):
|
||||||
|
r = authed_client.put(
|
||||||
|
"/api/config/ups/missing",
|
||||||
|
json={"host": "192.168.2.20"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_ups(authed_client):
|
||||||
|
authed_client.post(
|
||||||
|
"/api/config/ups",
|
||||||
|
json={"name": "u1", "host": "192.168.1.10"},
|
||||||
|
)
|
||||||
|
r = authed_client.delete("/api/config/ups/u1")
|
||||||
|
assert r.status_code == 200
|
||||||
|
r2 = authed_client.get("/api/config/ups")
|
||||||
|
assert r2.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_nonexistent(authed_client):
|
||||||
|
r = authed_client.delete("/api/config/ups/missing")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_smtp_none(authed_client):
|
||||||
|
r = authed_client.get("/api/config/smtp")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_smtp_password_always_redacted(authed_client, monkeypatch):
|
||||||
|
from app import settings as settings_mod
|
||||||
|
monkeypatch.setattr(settings_mod.settings, "smtp_password", "supersecret")
|
||||||
|
r = authed_client.put(
|
||||||
|
"/api/config/smtp",
|
||||||
|
json={
|
||||||
|
"host": "smtp.example.com",
|
||||||
|
"port": 587,
|
||||||
|
"username": "user@example.com",
|
||||||
|
"use_tls": True,
|
||||||
|
"from_addr": "alerts@example.com",
|
||||||
|
"to_addrs": ["ops@example.com"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
r2 = authed_client.get("/api/config/smtp")
|
||||||
|
body = r2.json()
|
||||||
|
# Never raw password
|
||||||
|
assert body.get("password") in ("***", None)
|
||||||
|
assert body["host"] == "smtp.example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ui_config_default(authed_client):
|
||||||
|
r = authed_client.get("/api/config/ui")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert "show_events" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_ui_config_update(authed_client):
|
||||||
|
r = authed_client.put(
|
||||||
|
"/api/config/ui",
|
||||||
|
json={"show_energy": True, "energy_cost_per_kwh": 0.15},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["ui"]["show_energy"] is True
|
||||||
|
assert body["ui"]["energy_cost_per_kwh"] == 0.15
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""CSV exports shape and content."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.exports import export_energy_csv, export_events_csv, export_history_csv
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect(gen):
|
||||||
|
return [chunk async for chunk in gen]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_history_export_header_only_when_empty(fake_redis):
|
||||||
|
rows = await _collect(export_history_csv("u1", since_days=1))
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].startswith("ts,status,loadpct,bcharge,timeleft,linev,derived_watts")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_history_export_with_data(fake_redis):
|
||||||
|
now = int(time.time())
|
||||||
|
entry = {"ts": now, "data": {
|
||||||
|
"STATUS": "ONLINE", "LOADPCT": "25.0",
|
||||||
|
"BCHARGE": "100", "TIMELEFT": "45",
|
||||||
|
"LINEV": "120", "DERIVED_WATTS": "225",
|
||||||
|
}}
|
||||||
|
fake_redis.rpush("ups:hist:u1", json.dumps(entry))
|
||||||
|
rows = await _collect(export_history_csv("u1", since_days=1))
|
||||||
|
assert len(rows) == 2 # header + 1
|
||||||
|
data_row = rows[1]
|
||||||
|
assert "ONLINE" in data_row
|
||||||
|
assert "225" in data_row
|
||||||
|
|
||||||
|
|
||||||
|
async def test_events_export_header_only_empty(fake_redis):
|
||||||
|
rows = await _collect(export_events_csv("u1", since_days=1))
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].startswith("ts,type,detail")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_events_export_with_data(fake_redis):
|
||||||
|
now = int(time.time())
|
||||||
|
fake_redis.rpush("ups:event:list:u1", f"{now}|STATUS|ONBATT")
|
||||||
|
fake_redis.rpush("ups:event:list:u1", f"{now}|XFER|Low voltage")
|
||||||
|
rows = await _collect(export_events_csv("u1", since_days=1))
|
||||||
|
assert len(rows) == 3
|
||||||
|
body = "\n".join(rows)
|
||||||
|
assert "STATUS" in body
|
||||||
|
assert "ONBATT" in body
|
||||||
|
assert "XFER" in body
|
||||||
|
|
||||||
|
|
||||||
|
async def test_events_export_applies_cutoff(fake_redis):
|
||||||
|
# Event older than 1 day should be filtered out
|
||||||
|
old_ts = int(time.time()) - (2 * 24 * 3600)
|
||||||
|
fake_redis.rpush("ups:event:list:u1", f"{old_ts}|STATUS|OLD")
|
||||||
|
rows = await _collect(export_events_csv("u1", since_days=1))
|
||||||
|
assert len(rows) == 1 # header only
|
||||||
|
|
||||||
|
|
||||||
|
async def test_energy_export_header(fake_redis):
|
||||||
|
rows = await _collect(export_energy_csv("u1", since_days=3))
|
||||||
|
assert rows[0].startswith("date,kwh")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_energy_export_with_data(fake_redis):
|
||||||
|
day_str = time.strftime("%Y%m%d")
|
||||||
|
fake_redis.set(f"ups:energy:u1:{day_str}", "3600000") # 3,600,000 Ws = 1 kWh
|
||||||
|
rows = await _collect(export_energy_csv("u1", since_days=1))
|
||||||
|
# header + today's data
|
||||||
|
assert len(rows) >= 2
|
||||||
|
assert "1.0000" in "\n".join(rows)
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Connection health transitions + battery history append."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from app.poller import (
|
||||||
|
HEALTH_OFFLINE_MIN_SECONDS,
|
||||||
|
_get_offline_threshold,
|
||||||
|
_record_poll_failure,
|
||||||
|
_record_poll_success,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_threshold_floor():
|
||||||
|
assert _get_offline_threshold(30) == HEALTH_OFFLINE_MIN_SECONDS
|
||||||
|
assert _get_offline_threshold(60) == 180
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_threshold_scales_with_interval():
|
||||||
|
assert _get_offline_threshold(120) == 360
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_success_sets_health_key(fake_redis):
|
||||||
|
snap = {"BCHARGE": "100", "TIMELEFT": "45 Minutes", "BATTV": "13.5", "NOMBATTV": "12"}
|
||||||
|
_record_poll_success("u1", snap, 30)
|
||||||
|
assert fake_redis.get("ups:health:last_ok:u1") is not None
|
||||||
|
assert fake_redis.get("ups:health:fail_count:u1") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_success_clears_fail_count(fake_redis):
|
||||||
|
fake_redis.set("ups:health:fail_count:u1", "5")
|
||||||
|
_record_poll_success("u1", {}, 30)
|
||||||
|
assert fake_redis.get("ups:health:fail_count:u1") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_success_recovery_emits_info(fake_redis, clean_config_cache):
|
||||||
|
from unittest.mock import patch
|
||||||
|
fake_redis.set("ups:health:offline:u1", "1")
|
||||||
|
with patch("app.alerts.dispatch_alerts"):
|
||||||
|
_record_poll_success("u1", {}, 30)
|
||||||
|
# Offline flag cleared
|
||||||
|
assert fake_redis.get("ups:health:offline:u1") is None
|
||||||
|
# History has recovery entry
|
||||||
|
entries = fake_redis.lrange("ups:alerts:history:all", 0, -1)
|
||||||
|
assert any("REACHABLE" in e for e in entries)
|
||||||
|
|
||||||
|
|
||||||
|
def test_battery_history_appended(fake_redis):
|
||||||
|
snap = {"BCHARGE": "100", "TIMELEFT": "45 Minutes", "BATTV": "13.5", "NOMBATTV": "12"}
|
||||||
|
_record_poll_success("u1", snap, 30)
|
||||||
|
entries = fake_redis.lrange("ups:battery:history:u1", 0, -1)
|
||||||
|
assert len(entries) == 1
|
||||||
|
parsed = json.loads(entries[0])
|
||||||
|
assert parsed["bcharge"] == 100.0
|
||||||
|
assert parsed["battv"] == 13.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_battery_history_throttled_at_60s(fake_redis):
|
||||||
|
snap = {"BCHARGE": "100", "TIMELEFT": "45", "BATTV": "13", "NOMBATTV": "12"}
|
||||||
|
_record_poll_success("u1", snap, 30)
|
||||||
|
# Immediately call again: no new sample (60s gate)
|
||||||
|
_record_poll_success("u1", snap, 30)
|
||||||
|
entries = fake_redis.lrange("ups:battery:history:u1", 0, -1)
|
||||||
|
assert len(entries) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_failure_increments_count(fake_redis):
|
||||||
|
_record_poll_failure("u1", 30, "timeout")
|
||||||
|
assert fake_redis.get("ups:health:fail_count:u1") == "1"
|
||||||
|
_record_poll_failure("u1", 30, "timeout")
|
||||||
|
assert fake_redis.get("ups:health:fail_count:u1") == "2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_failure_emits_offline_after_threshold(fake_redis, clean_config_cache):
|
||||||
|
from unittest.mock import patch
|
||||||
|
# Simulate an old last_ok so we cross the threshold
|
||||||
|
fake_redis.set("ups:health:last_ok:u1", str(int(time.time()) - 1000))
|
||||||
|
with patch("app.alerts.dispatch_alerts"):
|
||||||
|
_record_poll_failure("u1", 30, "connection refused")
|
||||||
|
# Offline flag now set
|
||||||
|
assert fake_redis.get("ups:health:offline:u1") == "1"
|
||||||
|
# Alert recorded in global history
|
||||||
|
entries = fake_redis.lrange("ups:alerts:history:all", 0, -1)
|
||||||
|
assert any("COMMLOST" in e for e in entries)
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_failure_idempotent_while_offline(fake_redis, clean_config_cache):
|
||||||
|
from unittest.mock import patch
|
||||||
|
fake_redis.set("ups:health:last_ok:u1", str(int(time.time()) - 1000))
|
||||||
|
with patch("app.alerts.dispatch_alerts"):
|
||||||
|
_record_poll_failure("u1", 30, "x")
|
||||||
|
_record_poll_failure("u1", 30, "x")
|
||||||
|
# Only one COMMLOST alert
|
||||||
|
entries = fake_redis.lrange("ups:alerts:history:all", 0, -1)
|
||||||
|
assert sum("COMMLOST" in e for e in entries) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_failure_below_threshold_no_alert(fake_redis, clean_config_cache):
|
||||||
|
from unittest.mock import patch
|
||||||
|
# Recent last_ok -> under threshold
|
||||||
|
fake_redis.set("ups:health:last_ok:u1", str(int(time.time())))
|
||||||
|
with patch("app.alerts.dispatch_alerts"):
|
||||||
|
_record_poll_failure("u1", 30, "transient")
|
||||||
|
assert fake_redis.get("ups:health:offline:u1") is None
|
||||||
|
entries = fake_redis.lrange("ups:alerts:history:all", 0, -1)
|
||||||
|
assert not any("COMMLOST" in e for e in entries)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""SSRF / config validators + SMTP password redaction."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.config import SMTPConfig, UPSConfig
|
||||||
|
from app.config_manager import smtp_redacted_dict
|
||||||
|
from app.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_ups_config():
|
||||||
|
u = UPSConfig(name="rack1", host="192.168.1.10", port=3551, interval_seconds=30)
|
||||||
|
assert u.host == "192.168.1.10"
|
||||||
|
assert u.interval_seconds == 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_loopback_literal():
|
||||||
|
with pytest.raises(Exception, match="loopback"):
|
||||||
|
UPSConfig(name="bad", host="localhost")
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_loopback_ip():
|
||||||
|
with pytest.raises(Exception, match="not a valid polling target"):
|
||||||
|
UPSConfig(name="bad", host="127.0.0.1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_ipv6_loopback():
|
||||||
|
with pytest.raises(Exception, match="not a valid polling target"):
|
||||||
|
UPSConfig(name="bad", host="::1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_link_local():
|
||||||
|
with pytest.raises(Exception, match="not a valid polling target"):
|
||||||
|
UPSConfig(name="bad", host="169.254.1.1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_private_ip_when_disabled(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "allow_private_ips", False)
|
||||||
|
with pytest.raises(Exception, match="private IP"):
|
||||||
|
UPSConfig(name="bad", host="10.0.0.5")
|
||||||
|
|
||||||
|
|
||||||
|
def test_accept_private_ip_when_enabled(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "allow_private_ips", True)
|
||||||
|
u = UPSConfig(name="good", host="10.0.0.5")
|
||||||
|
assert u.host == "10.0.0.5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_empty_name():
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
UPSConfig(name="", host="192.168.1.10")
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_name_too_long():
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
UPSConfig(name="a" * 33, host="192.168.1.10")
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_bad_name_chars():
|
||||||
|
with pytest.raises(Exception, match="alphanumeric"):
|
||||||
|
UPSConfig(name="bad name!", host="192.168.1.10")
|
||||||
|
|
||||||
|
|
||||||
|
def test_accept_valid_name_chars():
|
||||||
|
u = UPSConfig(name="ups_rack-01", host="192.168.1.10")
|
||||||
|
assert u.name == "ups_rack-01"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_invalid_port():
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
UPSConfig(name="x", host="192.168.1.10", port=0)
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
UPSConfig(name="x", host="192.168.1.10", port=70000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_invalid_interval():
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
UPSConfig(name="x", host="192.168.1.10", interval_seconds=1)
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
UPSConfig(name="x", host="192.168.1.10", interval_seconds=99999)
|
||||||
|
|
||||||
|
|
||||||
|
def test_accept_valid_hostname():
|
||||||
|
u = UPSConfig(name="dns", host="ups.lan.example.com")
|
||||||
|
assert u.host == "ups.lan.example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_bad_hostname():
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
UPSConfig(name="x", host="not valid host")
|
||||||
|
|
||||||
|
|
||||||
|
def test_smtp_model_has_no_password_field():
|
||||||
|
s = SMTPConfig(host="smtp.example.com", port=587)
|
||||||
|
assert not hasattr(s, "password")
|
||||||
|
|
||||||
|
|
||||||
|
def test_smtp_redacted_none():
|
||||||
|
assert smtp_redacted_dict(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_smtp_redacted_env_set(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "smtp_password", "secret")
|
||||||
|
s = SMTPConfig(host="smtp.example.com", port=587)
|
||||||
|
d = smtp_redacted_dict(s)
|
||||||
|
assert d["password"] == "***"
|
||||||
|
|
||||||
|
|
||||||
|
def test_smtp_redacted_env_unset(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "smtp_password", None)
|
||||||
|
s = SMTPConfig(host="smtp.example.com", port=587)
|
||||||
|
d = smtp_redacted_dict(s)
|
||||||
|
assert d["password"] is None
|
||||||
Reference in new issue
Block a user