64 files changed
+5404
-1883
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,51 @@
|
||||
# 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
|
||||
# (recommended — the wizard stores the hash in Redis and avoids the $-escaping trap below).
|
||||
ADMIN_USERNAME=admin
|
||||
# ADMIN_PASSWORD_HASH=
|
||||
#
|
||||
# IMPORTANT: argon2 hashes start with `$argon2id$v=19$m=...` and contain many `$`
|
||||
# characters. Docker Compose interprets `$word` as variable substitution, so pasting
|
||||
# a raw hash here will produce warnings like
|
||||
# WARN The "argon2id" variable is not set. Defaulting to a blank string.
|
||||
# and the container will receive a mangled hash (and you will not be able to log in).
|
||||
# Quoting (single or double) does NOT escape `$` in .env — you must double every `$`:
|
||||
# ADMIN_PASSWORD_HASH=$$argon2id$$v=19$$m=65536,t=3,p=4$$<salt>$$<hash>
|
||||
# Or just leave this unset and use the /setup wizard on first boot.
|
||||
|
||||
# --- 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,19 +1,45 @@
|
||||
name: Build and Push Docker Image
|
||||
# This workflow is manually triggered from the "Actions" tab in GitHub.
|
||||
# It requires a single input: the version tag of the flood repository to use.
|
||||
name: CI - Lint, Test, and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
schedule:
|
||||
- cron: '0 0 3 * *' # This runs at midnight on the first day of every month
|
||||
- cron: '0 0 3 * *' # monthly refresh
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
lint-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: pip
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install coverage
|
||||
|
||||
- name: Ruff lint
|
||||
run: ruff check .
|
||||
|
||||
- name: Run pytest with coverage
|
||||
run: |
|
||||
coverage run -m pytest tests/ -v
|
||||
coverage report
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
needs: lint-and-test
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
- name: Set Image name
|
||||
run: |
|
||||
|
||||
+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 \
|
||||
PYTHONUNBUFFERED=1
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --upgrade pip && pip wheel --wheel-dir /wheels -r requirements.txt
|
||||
|
||||
|
||||
FROM python:3.12.7-slim AS runtime
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
apcupsd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
&& apt-get install -y --no-install-recommends apcupsd curl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& groupadd --system --gid 10001 appuser \
|
||||
&& useradd --system --uid 10001 --gid 10001 --home /app --shell /usr/sbin/nologin appuser
|
||||
|
||||
COPY --from=builder /wheels /wheels
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip install --no-index --find-links=/wheels -r requirements.txt \
|
||||
&& rm -rf /wheels
|
||||
|
||||
COPY app ./app
|
||||
|
||||
RUN chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENV REDIS_URL=redis://redis:6379/0
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD curl -fsS http://localhost:8000/healthz || exit 1
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -1,148 +1,126 @@
|
||||
# APC UPS Dashboard
|
||||
|
||||
A lightweight FastAPI + Redis based dashboard for multiple APC UPS devices using the `apcaccess` CLI (apcupsd Network Information Server mode). Shows real-time metrics, lightweight charts, and maintains 7 days of historical snapshots.
|
||||
A production-ready FastAPI + Redis dashboard for monitoring multiple APC UPS devices via `apcupsd` NIS. Single-admin auth (argon2 + session cookies), CSRF, rate limiting, security headers, SSE live updates, 7-day history, connection-health tracking, battery degradation trends, HTML email alerts with batching and silent-hours, and Prometheus metrics.
|
||||
|
||||
## Features
|
||||
- Multiple UPS managed dynamically (stored in Redis; add/update/delete via API/UI)
|
||||
- Polling via `apcaccess` CLI against apcupsd NIS (default port 3551)
|
||||
- Connection test: TCP port reachability (no protocol parsing)
|
||||
- Real-time dashboard (Server Sent Events) updating key metrics
|
||||
- 7-day retention of snapshots in Redis lists
|
||||
- Simple Chart.js load percentage sparkline
|
||||
- Docker & docker-compose deployment (image bundles apcupsd + apcaccess)
|
||||
- SMTP alerting (high load, low battery %, on battery, low runtime) with cooldown
|
||||
|
||||
## Configuration
|
||||
**Monitoring**
|
||||
- Multiple UPS managed dynamically via UI/REST (stored in Redis)
|
||||
- Real-time dashboard with SSE, fleet-overview panel, per-UPS state indicators
|
||||
- 7-day snapshot history, per-minute watts averages, daily energy + cost
|
||||
- Connection-health tracker — COMMLOST detection after 3× interval, recovery INFO alerts
|
||||
- Battery-health sampling (1/min) with 7-day trend + decline %
|
||||
- CSV export (history/events/energy), event log, alert management + ack
|
||||
|
||||
Configuration is persisted in Redis (key `ups:config:json`). Use the web UI or the REST API to manage UPS entries and SMTP settings. A legacy `config/ups.yaml` (or path set via `UPS_CONFIG_PATH`) is imported once on first startup if Redis has no configuration; after migration the file is no longer written or read.
|
||||
**Alerts (email-only)**
|
||||
- Severity taxonomy: CRITICAL (ONBATT/COMMLOST/RUNTIME_LOW/BCHARGE_LOW), WARNING (LOAD_HIGH/REPLACEBATT/SELFTEST_FAIL/TEMP_HIGH/XFER_BURST/VOLT_DEV), INFO (REACHABLE/LINE_RESTORED)
|
||||
- Coalesced HTML emails (one per UPS per poll cycle), severity-colored
|
||||
- 30-min per-message cooldown, silent-hours deferral (CRITICAL always delivered), retries via tenacity
|
||||
- Test-email button and optional daily summary
|
||||
|
||||
UPS fields:
|
||||
- name (unique)
|
||||
- host (apcupsd server hostname / IP)
|
||||
- port (default 3551)
|
||||
- interval_seconds (polling interval)
|
||||
- Optional alert thresholds: alert_loadpct_high, alert_bcharge_low, alert_on_battery, alert_runtime_low_minutes
|
||||
**Security & ops**
|
||||
- Single-admin auth (argon2 password, signed session cookie, double-submit CSRF)
|
||||
- SSRF host validation (rejects loopback/link-local; private IPs gated by `ALLOW_PRIVATE_IPS`)
|
||||
- SMTP password **only** from env — never persisted to Redis
|
||||
- Subprocess timeout on `apcaccess` (10s), rate-limiting on auth/config, security headers + CSP
|
||||
- Non-root container (UID 10001), pinned `python:3.12.7-slim` multi-stage build
|
||||
- `/healthz`, `/readyz`, `/metrics` (Prometheus), JSON structured logs with request-ID correlation
|
||||
- GitHub Actions pipeline runs ruff + pytest before building/publishing the image
|
||||
|
||||
SMTP fields (optional): host, port, username, password (or env `SMTP_PASSWORD`), use_tls, use_ssl, from_addr, to_addrs[], subject_prefix.
|
||||
## Quick start (docker-compose)
|
||||
|
||||
Environment secret: set `SMTP_PASSWORD` instead of storing cleartext.
|
||||
|
||||
Alert suppression: identical alert per UPS suppressed for 30 minutes (cooldown).
|
||||
|
||||
### apcupsd Server Requirements
|
||||
Your remote APC UPS hosts must be running `apcupsd` with the Network Information Server (NIS) enabled. Typical steps (on Linux):
|
||||
|
||||
1. Install apcupsd (example for Debian/Ubuntu):
|
||||
```bash
|
||||
sudo apt-get install apcupsd
|
||||
```
|
||||
2. Edit `/etc/apcupsd/apcupsd.conf` and confirm at least:
|
||||
```
|
||||
UPSTYPE usb # or 'net' / 'snmp' depending on your setup
|
||||
DEVICE # usually blank for USB
|
||||
NISIP 0.0.0.0 # listen on all interfaces (restrict in firewalled env)
|
||||
NISPORT 3551 # must match the configured port (default 3551)
|
||||
NETSERVER on # enable network server
|
||||
# Optional: restrict access (recommended)
|
||||
ACCESS 192.168.1.0/24 # Only allow your monitoring subnet (supported on some builds)
|
||||
```
|
||||
3. Restart service:
|
||||
```bash
|
||||
sudo systemctl restart apcupsd
|
||||
```
|
||||
4. Test locally:
|
||||
```bash
|
||||
apcaccess status
|
||||
nc -vz <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
|
||||
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
|
||||
```
|
||||
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'))"
|
||||
```
|
||||
|
||||
> **Heads-up — `$` escaping in `.env`:** argon2 hashes look like `$argon2id$v=19$m=...$<salt>$<hash>`. Docker Compose treats `$word` as variable substitution in `.env`, so pasting a raw hash produces warnings (`The "argon2id" variable is not set…`) and the container receives a mangled, unverifiable hash — you won't be able to log in. Quoting does **not** help. Either double every `$` (`$$argon2id$$v=19$$m=...`), or skip this variable entirely and use the `/setup` wizard (recommended).
|
||||
|
||||
## 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
|
||||
docker compose down
|
||||
docker volume rm apcupsd-client_redis-data # volume name may be prefixed by folder/project
|
||||
python3.12 -m venv .venv && . .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run tests (uses fakeredis)
|
||||
pytest tests/
|
||||
|
||||
# With coverage
|
||||
coverage run -m pytest tests/ && coverage report
|
||||
|
||||
# Lint
|
||||
ruff check .
|
||||
|
||||
# Local dev server (needs apcaccess binary + REDIS_URL env)
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
If you had been losing configuration previously, ensure you pulled the updated compose file and that the `redis` service includes:
|
||||
|
||||
```yaml
|
||||
redis:
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec"]
|
||||
```
|
||||
|
||||
You can inspect Redis persistence files locally by running:
|
||||
## Persistence
|
||||
|
||||
Config and history live in Redis via an AOF-backed `redis-data` volume. To wipe state:
|
||||
```bash
|
||||
docker compose exec redis ls -lh /data
|
||||
docker compose down && docker volume rm apcupsd-client_redis-data
|
||||
```
|
||||
|
||||
If you want to enforce periodic RDB snapshots as well, you can leave default save settings (remove the `--save ""` override). The current configuration uses AOF every second for a balance of durability and write performance.
|
||||
## API (summary)
|
||||
|
||||
## Data Storage
|
||||
Redis stores:
|
||||
- Latest snapshot hash: `ups:snap:<name>`
|
||||
- History list (JSON {ts,data}): `ups:hist:<name>`
|
||||
- Per-minute watts averages: `ups:watts:permin:<name>`
|
||||
- Energy (watt-seconds) daily totals: `ups:energy:<name>:YYYYMMDD`
|
||||
- Events list: `ups:event:list:<name>`
|
||||
- Recent alerts: `ups:alerts:recent:<name>`
|
||||
- Voltage deviation samples: `ups:volt:dev:samples:<name>`
|
||||
|
||||
A pruning task runs hourly removing entries older than 7 days.
|
||||
|
||||
## 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
|
||||
| Path | Auth | Notes |
|
||||
|---|---|---|
|
||||
| `/` (dashboard), `/config`, `/events`, `/alerts`, `/settings` | session | Jinja pages |
|
||||
| `/login`, `/setup`, `/logout` | open/session | |
|
||||
| `/healthz`, `/readyz`, `/metrics` | open | Prom metrics on `/metrics` |
|
||||
| `GET /api/stream` | session | SSE snapshots |
|
||||
| `GET /api/ups`, `/api/ups/{name}/{status\|history\|events\|energy\|health\|battery_health}` | session | |
|
||||
| `GET /api/ups/fleet/overview` | session | Aggregate fleet summary |
|
||||
| `GET /api/ups/{name}/export?format=csv&kind=history\|events\|energy&since_days=N` | session | Streaming CSV |
|
||||
| `GET /api/events`, `/api/alerts`, `/api/alerts/active` | session | |
|
||||
| `POST /api/alerts/{id}/ack` | session+CSRF | |
|
||||
| `GET/POST/PUT/DELETE /api/config/{ups,smtp,ui}/...` | session (+CSRF for writes) | Rate-limited |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Security & Hardening Notes
|
||||
| Area | Current | Recommendation |
|
||||
|------|---------|---------------|
|
||||
| Authentication | None (open dashboard) | Add reverse proxy auth or FastAPI auth if exposed beyond LAN |
|
||||
| Config Storage | Redis (JSON) | Protect Redis with auth / network policy |
|
||||
| Network to apcupsd | Plain TCP | Use network segmentation / firewall; protocol has no encryption |
|
||||
| Redis | No auth configured | Enable AUTH / TLS if crossing trust boundaries |
|
||||
| Input Validation | Pydantic for config schema | Add stricter hostname/IP validation if multi-tenant |
|
||||
| Dependency Versions | Pinned | Review periodically for CVEs |
|
||||
| Logging | Polling errors logged | Avoid logging secrets; sanitize future additions |
|
||||
|
||||
### Additional Notes
|
||||
- Legacy YAML migration (one-time) to Redis; file no longer updated afterward.
|
||||
- Dynamic poller reconciles tasks on config change (no restart needed).
|
||||
- Alert cooldown prevents email flood.
|
||||
- AOF-based Redis persistence keeps configuration across container rebuilds.
|
||||
|
||||
### Suggested Future Enhancements
|
||||
- Optional Basic Auth / OIDC for web UI.
|
||||
- Rate limiting on config mutation endpoints.
|
||||
- CSRF protection if cookies/session auth added later.
|
||||
- Health endpoint (`/healthz`) returning Redis + config status.
|
||||
- Structured logging (JSON) for production observability.
|
||||
+275
-143
@@ -1,105 +1,52 @@
|
||||
"""Alert evaluation, coalescing, cooldown, silent-hours, and dispatch."""
|
||||
from __future__ import annotations
|
||||
import smtplib
|
||||
import ssl
|
||||
import os
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from email.message import EmailMessage
|
||||
from typing import Dict, Any, List
|
||||
from .config import load_config, SMTPConfig, UPSConfig
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from .config import SMTPConfig, UPSConfig, load_config
|
||||
from .notifications.email import EmailSendError, send_alert_email
|
||||
from .storage import get_redis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALERT_COOLDOWN_SECONDS = 1800 # 30 minutes per distinct alert per UPS
|
||||
REDIS_ALERT_KEY_PREFIX = "ups:alert:last:"
|
||||
REDIS_PENDING_KEY_PREFIX = "ups:alerts:pending:" # list of deferred messages
|
||||
REDIS_HISTORY_KEY_PREFIX = "ups:alerts:history:" # global history
|
||||
ALERT_HISTORY_MAX = 500
|
||||
ALERT_HISTORY_TTL_SECONDS = 30 * 24 * 3600
|
||||
|
||||
STATUS_ON_BATTERY_KEYWORDS = {"ONBATT", "ON BATTERY"}
|
||||
SELFTEST_FAIL_KEYWORDS = {"FAIL", "BADBATT"}
|
||||
|
||||
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]:
|
||||
messages: List[str] = []
|
||||
# Load % high
|
||||
if ups_cfg.alert_loadpct_high is not None:
|
||||
loadpct = _to_float(snapshot.get('LOADPCT'))
|
||||
if loadpct is not None and loadpct >= ups_cfg.alert_loadpct_high:
|
||||
messages.append(
|
||||
"Load percentage high: "
|
||||
f"{loadpct}% >= {ups_cfg.alert_loadpct_high}%"
|
||||
)
|
||||
# Battery charge low
|
||||
if ups_cfg.alert_bcharge_low is not None:
|
||||
bcharge = _to_float(snapshot.get('BCHARGE'))
|
||||
if bcharge is not None and bcharge <= ups_cfg.alert_bcharge_low:
|
||||
messages.append(
|
||||
"Battery charge low: "
|
||||
f"{bcharge}% <= {ups_cfg.alert_bcharge_low}%"
|
||||
)
|
||||
# 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
|
||||
@dataclass
|
||||
class Alert:
|
||||
severity: str
|
||||
message: str
|
||||
code: str
|
||||
ups: str
|
||||
ts: int
|
||||
|
||||
def hash(self) -> str:
|
||||
h = hashlib.sha1(f"{self.ups}|{self.code}|{self.message}".encode()).hexdigest()
|
||||
return h[:16]
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
d = asdict(self)
|
||||
d["id"] = self.hash()
|
||||
return d
|
||||
|
||||
|
||||
def _to_float(val) -> float | None:
|
||||
@@ -111,66 +58,251 @@ def _to_float(val) -> float | None:
|
||||
|
||||
def _extract_leading_number(s: str) -> float | None:
|
||||
try:
|
||||
part = s.strip().split()[0]
|
||||
return float(part)
|
||||
return float(s.strip().split()[0])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _cooldown_key(ups_name: str, msg: str) -> str:
|
||||
return f"{REDIS_ALERT_KEY_PREFIX}{ups_name}:{hash(msg)}"
|
||||
def evaluate_alerts(ups_cfg: UPSConfig, snapshot: dict[str, Any]) -> list[Alert]:
|
||||
msgs: list[Alert] = []
|
||||
now = int(time.time())
|
||||
name = ups_cfg.name
|
||||
|
||||
def add(sev: str, code: str, message: str) -> None:
|
||||
msgs.append(Alert(sev, message, code, name, now))
|
||||
|
||||
def send_alert_email(smtp_cfg: SMTPConfig, ups_name: str, messages: List[str]):
|
||||
password = smtp_cfg.password or os.environ.get('SMTP_PASSWORD')
|
||||
if not smtp_cfg.to_addrs:
|
||||
return
|
||||
subject = f"{smtp_cfg.subject_prefix} {ups_name} alert"
|
||||
body = "\n".join(messages)
|
||||
msg = EmailMessage()
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = smtp_cfg.from_addr or (smtp_cfg.username or 'ups@example')
|
||||
msg['To'] = ", ".join(smtp_cfg.to_addrs)
|
||||
msg.set_content(body)
|
||||
status = str(snapshot.get("STATUS", "")).upper()
|
||||
# ONBATT
|
||||
if ups_cfg.alert_on_battery and any(k in status for k in STATUS_ON_BATTERY_KEYWORDS):
|
||||
add(SEV_CRITICAL, "ONBATT", f"UPS on battery: status={status}")
|
||||
# Runtime low
|
||||
if ups_cfg.alert_runtime_low_minutes is not None:
|
||||
runtime = _extract_leading_number(str(snapshot.get("TIMELEFT", "")))
|
||||
if runtime is not None and runtime <= ups_cfg.alert_runtime_low_minutes:
|
||||
add(
|
||||
SEV_CRITICAL,
|
||||
"RUNTIME_LOW",
|
||||
f"Runtime low: {runtime}m <= {ups_cfg.alert_runtime_low_minutes}m",
|
||||
)
|
||||
# Battery charge low
|
||||
if ups_cfg.alert_bcharge_low is not None:
|
||||
bcharge = _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:
|
||||
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]):
|
||||
# Extended alerts based on UI flags
|
||||
cfg = load_config()
|
||||
if not cfg.smtp:
|
||||
return
|
||||
msgs = evaluate_alerts(ups_cfg, snapshot)
|
||||
if not msgs:
|
||||
ui = cfg.ui if hasattr(cfg, "ui") else None
|
||||
if ui:
|
||||
r = get_redis()
|
||||
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
|
||||
r = get_redis()
|
||||
to_send: List[str] = []
|
||||
now = int(time.time())
|
||||
for m in msgs:
|
||||
key = _cooldown_key(ups_cfg.name, m)
|
||||
if not r.get(key):
|
||||
to_send.append(m)
|
||||
r.set(key, now, ex=ALERT_COOLDOWN_SECONDS)
|
||||
if to_send:
|
||||
# Store recent alerts with timestamp for health reporting
|
||||
recent_key = f"ups:alerts:recent:{ups_cfg.name}"
|
||||
pipe = r.pipeline()
|
||||
for m in to_send:
|
||||
pipe.lpush(recent_key, f"{now}|{m}")
|
||||
pipe.ltrim(recent_key, 0, 49) # keep last 50
|
||||
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()
|
||||
send_alert_email(cfg.smtp, ups_cfg.name, to_send)
|
||||
|
||||
|
||||
def _defer_for_silent_hours(alerts: list[Alert]) -> None:
|
||||
r = get_redis()
|
||||
for a in alerts:
|
||||
key = REDIS_PENDING_KEY_PREFIX + a.ups
|
||||
r.lpush(key, f"{a.ts}|{a.severity}|{a.code}|{a.message}")
|
||||
# Keep at most 200 deferred
|
||||
for ups in {a.ups for a in alerts}:
|
||||
r.ltrim(REDIS_PENDING_KEY_PREFIX + ups, 0, 199)
|
||||
|
||||
|
||||
def drain_deferred(ups_name: str) -> list[Alert]:
|
||||
r = get_redis()
|
||||
key = REDIS_PENDING_KEY_PREFIX + ups_name
|
||||
raw = r.lrange(key, 0, -1)
|
||||
r.delete(key)
|
||||
out: list[Alert] = []
|
||||
for item in raw:
|
||||
parts = item.split("|", 3)
|
||||
if len(parts) == 4:
|
||||
try:
|
||||
ts = int(parts[0])
|
||||
except ValueError:
|
||||
continue
|
||||
out.append(Alert(parts[1], parts[3], parts[2], ups_name, ts))
|
||||
return out
|
||||
|
||||
|
||||
def dispatch_alerts(
|
||||
smtp_cfg: SMTPConfig | None,
|
||||
ups_name: str,
|
||||
alerts: list[Alert],
|
||||
dashboard_url: str = "http://localhost:8000/",
|
||||
) -> None:
|
||||
"""Send a coalesced email containing all alerts for one UPS."""
|
||||
if not smtp_cfg or not alerts:
|
||||
return
|
||||
payload = [
|
||||
{"severity": a.severity, "message": a.message, "code": a.code}
|
||||
for a in alerts
|
||||
]
|
||||
try:
|
||||
send_alert_email(smtp_cfg, ups_name, payload, dashboard_url=dashboard_url)
|
||||
except EmailSendError as e:
|
||||
logger.warning("Alert email failed for %s: %s", ups_name, e)
|
||||
|
||||
|
||||
def process_alerts(ups_cfg: UPSConfig, snapshot: dict[str, Any]) -> list[Alert]:
|
||||
"""Evaluate alerts, apply cooldown + silent hours, dispatch email.
|
||||
|
||||
Returns the list of alerts that were considered fresh (not cooled down).
|
||||
"""
|
||||
cfg = load_config()
|
||||
all_alerts = evaluate_alerts(ups_cfg, snapshot)
|
||||
if not all_alerts:
|
||||
return []
|
||||
r = get_redis()
|
||||
fresh: list[Alert] = []
|
||||
now = int(time.time())
|
||||
for a in all_alerts:
|
||||
key = _cooldown_key(a)
|
||||
if not r.get(key):
|
||||
fresh.append(a)
|
||||
r.set(key, now, ex=ALERT_COOLDOWN_SECONDS)
|
||||
if not fresh:
|
||||
return []
|
||||
_persist_history(fresh)
|
||||
if not cfg.smtp:
|
||||
return fresh
|
||||
# Silent hours filter: keep CRITICAL, defer others
|
||||
if _in_silent_hours(cfg.smtp):
|
||||
critical = [a for a in fresh if a.severity == SEV_CRITICAL]
|
||||
deferred = [a for a in fresh if a.severity != SEV_CRITICAL]
|
||||
if deferred:
|
||||
_defer_for_silent_hours(deferred)
|
||||
if critical:
|
||||
dispatch_alerts(cfg.smtp, ups_cfg.name, critical)
|
||||
else:
|
||||
# Also flush any deferred alerts for this UPS
|
||||
deferred = drain_deferred(ups_cfg.name)
|
||||
dispatch_alerts(cfg.smtp, ups_cfg.name, fresh + deferred)
|
||||
return fresh
|
||||
|
||||
|
||||
def emit_info(ups_name: str, code: str, message: str) -> None:
|
||||
"""Record and dispatch a single INFO alert (e.g. recovery events)."""
|
||||
alert = Alert(SEV_INFO, message, code, ups_name, int(time.time()))
|
||||
r = get_redis()
|
||||
if r.get(_cooldown_key(alert)):
|
||||
return
|
||||
r.set(_cooldown_key(alert), int(time.time()), ex=ALERT_COOLDOWN_SECONDS)
|
||||
_persist_history([alert])
|
||||
cfg = load_config()
|
||||
if cfg.smtp:
|
||||
if _in_silent_hours(cfg.smtp):
|
||||
_defer_for_silent_hours([alert])
|
||||
else:
|
||||
dispatch_alerts(cfg.smtp, ups_name, [alert])
|
||||
+22
-7
@@ -1,22 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APCACCESS_BIN = shutil.which('apcaccess') or 'apcaccess'
|
||||
APCACCESS_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
|
||||
class APCStatusError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def fetch_status(host: str, port: int) -> Dict[str, Any]:
|
||||
async def fetch_status(host: str, port: int) -> dict[str, Any]:
|
||||
"""Invoke apcaccess CLI and parse key:value lines into a dict.
|
||||
|
||||
Raises APCStatusError if binary fails or returns no data.
|
||||
Raises APCStatusError if binary fails, times out, or returns no data.
|
||||
"""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
APCACCESS_BIN,
|
||||
@@ -25,14 +27,26 @@ async def fetch_status(host: str, port: int) -> Dict[str, Any]:
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=APCACCESS_TIMEOUT_SECONDS
|
||||
)
|
||||
except TimeoutError:
|
||||
try:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
raise APCStatusError(
|
||||
f"apcaccess timed out after {APCACCESS_TIMEOUT_SECONDS}s for {host}:{port}"
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
err_txt = stderr.decode(errors="replace").strip()
|
||||
raise APCStatusError(
|
||||
f'apcaccess exit {proc.returncode}: {err_txt}'
|
||||
)
|
||||
text = stdout.decode(errors='replace')
|
||||
data: Dict[str, Any] = {}
|
||||
data: dict[str, Any] = {}
|
||||
for line in text.splitlines():
|
||||
if ':' not in line:
|
||||
continue
|
||||
@@ -40,7 +54,8 @@ async def fetch_status(host: str, port: int) -> Dict[str, Any]:
|
||||
key = k.strip()
|
||||
val = v.strip()
|
||||
data[key] = val
|
||||
# Normalize expected fields/aliases
|
||||
if not data:
|
||||
raise APCStatusError("apcaccess returned no data")
|
||||
if 'UPSNAME' not in data and 'NAME' in data:
|
||||
data['UPSNAME'] = data['NAME']
|
||||
if 'MODEL' in data:
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
"""Authentication: argon2 password hashing + signed session cookies.
|
||||
|
||||
Single-admin design for homelab use. Credentials set via env vars:
|
||||
ADMIN_USERNAME, ADMIN_PASSWORD_HASH, SESSION_SECRET
|
||||
|
||||
If ADMIN_PASSWORD_HASH is unset, the first-run setup page generates one
|
||||
and stores it in Redis under the key ``ups:admin:hash``. The stored hash
|
||||
takes precedence over env vars so the operator can change their password
|
||||
via the UI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, Response
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
from passlib.hash import argon2
|
||||
|
||||
from .settings import settings
|
||||
from .storage import get_redis
|
||||
|
||||
SESSION_COOKIE = "ups_session"
|
||||
CSRF_COOKIE = "csrf_token"
|
||||
CSRF_HEADER = "X-CSRF-Token"
|
||||
ADMIN_HASH_KEY = "ups:admin:hash"
|
||||
ADMIN_USER_KEY = "ups:admin:username"
|
||||
|
||||
|
||||
def _serializer() -> URLSafeTimedSerializer:
|
||||
secret = settings.session_secret
|
||||
if not secret:
|
||||
# Best effort ephemeral secret so we don't crash at import time in dev
|
||||
secret = "dev-insecure-change-me-please"
|
||||
return URLSafeTimedSerializer(secret, salt="ups-session")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Produce an argon2 hash of the given password."""
|
||||
return argon2.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, hashed: str) -> bool:
|
||||
try:
|
||||
return argon2.verify(password, hashed)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_stored_admin() -> tuple[str | None, str | None]:
|
||||
"""Return (username, password_hash) from Redis or settings.
|
||||
|
||||
Redis-stored credentials take precedence when present.
|
||||
"""
|
||||
try:
|
||||
r = get_redis()
|
||||
redis_hash = r.get(ADMIN_HASH_KEY)
|
||||
redis_user = r.get(ADMIN_USER_KEY)
|
||||
except Exception:
|
||||
redis_hash = None
|
||||
redis_user = None
|
||||
username = redis_user or settings.admin_username
|
||||
pw_hash = redis_hash or settings.admin_password_hash
|
||||
return username, pw_hash
|
||||
|
||||
|
||||
def store_admin(username: str, password: str) -> None:
|
||||
"""Persist admin credentials to Redis."""
|
||||
r = get_redis()
|
||||
r.set(ADMIN_USER_KEY, username)
|
||||
r.set(ADMIN_HASH_KEY, hash_password(password))
|
||||
|
||||
|
||||
def is_admin_configured() -> bool:
|
||||
_, pw_hash = get_stored_admin()
|
||||
return bool(pw_hash)
|
||||
|
||||
|
||||
def create_session_token(username: str) -> str:
|
||||
return _serializer().dumps({"u": username})
|
||||
|
||||
|
||||
def verify_session_token(token: str) -> str | None:
|
||||
try:
|
||||
data = _serializer().loads(
|
||||
token, max_age=settings.session_max_age_seconds
|
||||
)
|
||||
if isinstance(data, dict):
|
||||
return data.get("u")
|
||||
except (BadSignature, SignatureExpired):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def make_csrf_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def set_auth_cookies(response: Response, username: str) -> str:
|
||||
"""Set session + CSRF cookies on the response. Returns CSRF token."""
|
||||
session_token = create_session_token(username)
|
||||
csrf_token = make_csrf_token()
|
||||
secure = settings.trust_proxy
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
session_token,
|
||||
max_age=settings.session_max_age_seconds,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=secure,
|
||||
path="/",
|
||||
)
|
||||
response.set_cookie(
|
||||
CSRF_COOKIE,
|
||||
csrf_token,
|
||||
max_age=settings.session_max_age_seconds,
|
||||
httponly=False, # JS needs to read this
|
||||
samesite="lax",
|
||||
secure=secure,
|
||||
path="/",
|
||||
)
|
||||
return csrf_token
|
||||
|
||||
|
||||
def clear_auth_cookies(response: Response) -> None:
|
||||
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||
response.delete_cookie(CSRF_COOKIE, path="/")
|
||||
|
||||
|
||||
def current_user(request: Request) -> str | None:
|
||||
"""Return current session username or None."""
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
if not token:
|
||||
return None
|
||||
return verify_session_token(token)
|
||||
|
||||
|
||||
def require_session(request: Request) -> str:
|
||||
"""FastAPI dependency - 401 if not authenticated."""
|
||||
user = current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
def require_csrf(request: Request) -> None:
|
||||
"""Enforce double-submit CSRF token on mutating requests."""
|
||||
if request.method in ("GET", "HEAD", "OPTIONS"):
|
||||
return
|
||||
cookie_token = request.cookies.get(CSRF_COOKIE)
|
||||
header_token = request.headers.get(CSRF_HEADER)
|
||||
if not cookie_token or not header_token or cookie_token != header_token:
|
||||
raise HTTPException(status_code=403, detail="CSRF token invalid or missing")
|
||||
|
||||
|
||||
def require_session_and_csrf(
|
||||
request: Request, user: str = Depends(require_session)
|
||||
) -> str:
|
||||
require_csrf(request)
|
||||
return user
|
||||
+102
-30
@@ -1,55 +1,116 @@
|
||||
from __future__ import annotations
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
CONFIG_PATH = Path(
|
||||
os.environ.get("UPS_CONFIG_PATH", "/config/ups.yaml")
|
||||
) # legacy path for migration
|
||||
import ipaddress
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from .settings import settings
|
||||
|
||||
CONFIG_PATH = Path(settings.ups_config_path) # legacy path for migration
|
||||
|
||||
_NAME_RE = re.compile(r"^[a-zA-Z0-9_.\- ]{1,32}$")
|
||||
_HOSTNAME_RE = re.compile(
|
||||
r"^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
|
||||
)
|
||||
|
||||
_BLOCKED_LITERALS = {"localhost", "localhost.localdomain"}
|
||||
|
||||
|
||||
def _validate_host_string(host: str) -> str:
|
||||
"""Validate a host field. Rejects loopback/link-local always; rejects
|
||||
private ranges when ALLOW_PRIVATE_IPS is false.
|
||||
"""
|
||||
host = host.strip()
|
||||
if not host:
|
||||
raise ValueError("host must not be empty")
|
||||
if host.lower() in _BLOCKED_LITERALS:
|
||||
raise ValueError(f"host '{host}' is not permitted (loopback alias)")
|
||||
# Try as IP first
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
if ip.is_loopback or ip.is_link_local or ip.is_unspecified or ip.is_multicast:
|
||||
raise ValueError(f"host IP {host} is not a valid polling target")
|
||||
if ip.is_private and not settings.allow_private_ips:
|
||||
raise ValueError(
|
||||
f"private IP {host} not allowed (set ALLOW_PRIVATE_IPS=true)"
|
||||
)
|
||||
return host
|
||||
except ValueError as ip_err:
|
||||
# Fall through to hostname validation only when the string wasn't a
|
||||
# parseable IP address
|
||||
if "does not appear to be an IPv4 or IPv6 address" not in str(ip_err):
|
||||
raise
|
||||
# Validate as hostname
|
||||
if not _HOSTNAME_RE.match(host):
|
||||
raise ValueError(f"host '{host}' is not a valid hostname or IP")
|
||||
return host
|
||||
|
||||
|
||||
class UPSConfig(BaseModel):
|
||||
name: str = Field(..., description="Friendly UPS name")
|
||||
host: str = Field(..., description="apcupsd NIS host/IP")
|
||||
port: int = Field(3551, description="apcupsd NIS port")
|
||||
interval_seconds: int = Field(30, description="Polling interval")
|
||||
# Alert thresholds (any optional)
|
||||
alert_loadpct_high: Optional[float] = Field(
|
||||
None, description="Trigger if LOADPCT >= value"
|
||||
port: int = Field(3551, ge=1, le=65535, description="apcupsd NIS port")
|
||||
interval_seconds: int = Field(
|
||||
30, ge=5, le=3600, description="Polling interval"
|
||||
)
|
||||
alert_bcharge_low: Optional[float] = Field(
|
||||
None, description="Trigger if BCHARGE <= value"
|
||||
# Alert thresholds (any optional)
|
||||
alert_loadpct_high: float | None = Field(
|
||||
None, ge=0, le=100, description="Trigger if LOADPCT >= value"
|
||||
)
|
||||
alert_bcharge_low: float | None = Field(
|
||||
None, ge=0, le=100, description="Trigger if BCHARGE <= value"
|
||||
)
|
||||
alert_on_battery: bool = Field(
|
||||
False, description="Trigger when STATUS indicates on battery"
|
||||
)
|
||||
alert_runtime_low_minutes: Optional[float] = Field(
|
||||
None, description="Trigger if TIMELEFT <= minutes"
|
||||
alert_runtime_low_minutes: float | None = Field(
|
||||
None, ge=0, description="Trigger if TIMELEFT <= minutes"
|
||||
)
|
||||
alert_itemp_high: float | None = Field(
|
||||
None, ge=0, le=120, description="Trigger if internal temp >= C"
|
||||
)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _validate_name(cls, v: str) -> str:
|
||||
if not _NAME_RE.match(v):
|
||||
raise ValueError(
|
||||
"name must be 1-32 chars (letters, digits, space, dot, underscore, dash)"
|
||||
)
|
||||
return v.strip()
|
||||
|
||||
@field_validator("host")
|
||||
@classmethod
|
||||
def _validate_host(cls, v: str) -> str:
|
||||
return _validate_host_string(v)
|
||||
|
||||
|
||||
class SMTPConfig(BaseModel):
|
||||
host: str = Field(..., description="SMTP server host/IP")
|
||||
port: int = Field(..., description="SMTP port")
|
||||
username: Optional[str] = Field(None)
|
||||
password: Optional[str] = Field(
|
||||
None, description="Plain password or set via env SMTP_PASSWORD"
|
||||
)
|
||||
port: int = Field(..., ge=1, le=65535, description="SMTP port")
|
||||
username: str | None = Field(None)
|
||||
# password is never persisted in Redis; read from env SMTP_PASSWORD
|
||||
use_tls: bool = Field(False, description="STARTTLS if true")
|
||||
use_ssl: bool = Field(False, description="SSL (smtplib.SMTP_SSL) if true")
|
||||
from_addr: Optional[str] = Field(None, description="From email address")
|
||||
to_addrs: List[str] = Field(
|
||||
from_addr: str | None = Field(None, description="From email address")
|
||||
to_addrs: list[str] = Field(
|
||||
default_factory=list, description="Recipient list"
|
||||
)
|
||||
subject_prefix: str = Field("[UPS]", description="Subject prefix")
|
||||
silent_hours_start: int | None = Field(
|
||||
None, ge=0, le=23, description="Silent window start hour (local time)"
|
||||
)
|
||||
silent_hours_end: int | None = Field(
|
||||
None, ge=0, le=23, description="Silent window end hour (local time)"
|
||||
)
|
||||
daily_summary_hour: int | None = Field(
|
||||
None, ge=0, le=23, description="Hour to send daily summary"
|
||||
)
|
||||
|
||||
|
||||
class AppConfig(BaseModel):
|
||||
ups: List[UPSConfig]
|
||||
smtp: Optional[SMTPConfig] = None
|
||||
# UI feature flags (optional; default values used if missing)
|
||||
# Added for dashboard toggleable features
|
||||
class UIConfig(BaseModel):
|
||||
show_events: bool = True
|
||||
show_energy: bool = False
|
||||
@@ -60,15 +121,26 @@ class AppConfig(BaseModel):
|
||||
allow_resize: bool = True
|
||||
enable_transfer_burst_alert: bool = False
|
||||
enable_voltage_deviation_alert: bool = False
|
||||
energy_cost_per_kwh: float = 0.0
|
||||
|
||||
ui: UIConfig = UIConfig()
|
||||
|
||||
class AppConfig(BaseModel):
|
||||
ups: list[UPSConfig] = Field(default_factory=list)
|
||||
smtp: SMTPConfig | None = None
|
||||
ui: UIConfig = Field(default_factory=UIConfig)
|
||||
|
||||
# Backward-compat alias: older code references AppConfig.UIConfig
|
||||
UIConfig: ClassVar[type[UIConfig]] = UIConfig
|
||||
|
||||
|
||||
_cached: AppConfig | None = None
|
||||
|
||||
|
||||
def load_config(path: Path = CONFIG_PATH) -> AppConfig:
|
||||
# Use redis store (lazy import to avoid circular)
|
||||
"""Load config from Redis, cached in-process.
|
||||
|
||||
Invalidate via ``config_module._cached = None`` after any write.
|
||||
"""
|
||||
from .config_store import load_config_redis
|
||||
global _cached
|
||||
if _cached:
|
||||
|
||||
+39
-86
@@ -1,27 +1,20 @@
|
||||
from __future__ import annotations
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import socket
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
except ImportError:
|
||||
# Fallback for environments without pydantic
|
||||
BaseModel = object
|
||||
|
||||
def Field(default=None, **kwargs):
|
||||
return default
|
||||
|
||||
ValidationError = ValueError
|
||||
|
||||
from .config import UPSConfig, SMTPConfig, AppConfig
|
||||
from .config import AppConfig, SMTPConfig, UIConfig, UPSConfig
|
||||
from .config_store import load_config_redis, save_config_redis
|
||||
from .settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Incremented every time configuration is modified so
|
||||
# SSE clients can detect changes
|
||||
# Incremented every time configuration is modified so SSE clients can
|
||||
# detect changes
|
||||
_config_version: int = 0
|
||||
|
||||
|
||||
@@ -29,34 +22,25 @@ def get_config_version() -> int:
|
||||
return _config_version
|
||||
|
||||
|
||||
class ConfigWriteError(Exception): # retained for API compatibility
|
||||
class ConfigWriteError(Exception):
|
||||
"""Raised when configuration cannot be written (kept for compatibility)."""
|
||||
|
||||
|
||||
class UPSConfigUpdate(BaseModel):
|
||||
"""Model for updating UPS configuration"""
|
||||
name: Optional[str] = Field(None, description="Friendly UPS name")
|
||||
host: Optional[str] = Field(None, description="apcupsd NIS host/IP")
|
||||
port: Optional[int] = Field(None, description="apcupsd NIS port")
|
||||
interval_seconds: Optional[int] = Field(
|
||||
None, description="Polling interval"
|
||||
)
|
||||
alert_loadpct_high: Optional[float] = Field(
|
||||
None, description="Trigger if LOADPCT >= value"
|
||||
)
|
||||
alert_bcharge_low: Optional[float] = Field(
|
||||
None, description="Trigger if BCHARGE <= value"
|
||||
)
|
||||
alert_on_battery: Optional[bool] = Field(
|
||||
None, description="Trigger when STATUS indicates on battery"
|
||||
)
|
||||
alert_runtime_low_minutes: Optional[float] = Field(
|
||||
None, description="Trigger if TIMELEFT <= minutes"
|
||||
)
|
||||
"""Model for updating UPS configuration."""
|
||||
name: str | None = None
|
||||
host: str | None = None
|
||||
port: int | None = Field(None, ge=1, le=65535)
|
||||
interval_seconds: int | None = Field(None, ge=5, le=3600)
|
||||
alert_loadpct_high: float | None = Field(None, ge=0, le=100)
|
||||
alert_bcharge_low: float | None = Field(None, ge=0, le=100)
|
||||
alert_on_battery: bool | None = None
|
||||
alert_runtime_low_minutes: float | None = Field(None, ge=0)
|
||||
alert_itemp_high: float | None = Field(None, ge=0, le=120)
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def load_config(self) -> AppConfig:
|
||||
@@ -67,26 +51,19 @@ class ConfigManager:
|
||||
async with self._lock:
|
||||
save_config_redis(config)
|
||||
logger.info("Configuration saved to Redis")
|
||||
# Invalidate cached global config so subsequent
|
||||
# load_config() calls see changes
|
||||
try:
|
||||
from . import config as config_module
|
||||
config_module._cached = None
|
||||
except Exception: # pragma: no cover - defensive
|
||||
logger.debug(
|
||||
"Failed to invalidate config cache", exc_info=True
|
||||
)
|
||||
# bump config version
|
||||
logger.debug("Failed to invalidate config cache", exc_info=True)
|
||||
global _config_version
|
||||
_config_version += 1
|
||||
|
||||
async def get_ups_list(self) -> List[UPSConfig]:
|
||||
"""Get list of all UPS configurations"""
|
||||
async def get_ups_list(self) -> list[UPSConfig]:
|
||||
config = await self.load_config()
|
||||
return config.ups
|
||||
|
||||
async def get_ups(self, name: str) -> Optional[UPSConfig]:
|
||||
"""Get UPS configuration by name"""
|
||||
async def get_ups(self, name: str) -> UPSConfig | None:
|
||||
config = await self.load_config()
|
||||
for ups in config.ups:
|
||||
if ups.name == name:
|
||||
@@ -94,110 +71,78 @@ class ConfigManager:
|
||||
return None
|
||||
|
||||
async def add_ups(self, ups_config: UPSConfig) -> bool:
|
||||
"""Add new UPS configuration"""
|
||||
config = await self.load_config()
|
||||
|
||||
# Check if UPS with same name already exists
|
||||
if any(ups.name == ups_config.name for ups in config.ups):
|
||||
raise ValueError(
|
||||
f"UPS with name '{ups_config.name}' already exists"
|
||||
)
|
||||
|
||||
config.ups.append(ups_config)
|
||||
await self.save_config(config)
|
||||
|
||||
# No file cache now
|
||||
|
||||
return True
|
||||
|
||||
async def update_ups(self, name: str, updates: UPSConfigUpdate) -> bool:
|
||||
"""Update existing UPS configuration"""
|
||||
config = await self.load_config()
|
||||
|
||||
ups_index = None
|
||||
for i, ups in enumerate(config.ups):
|
||||
if ups.name == name:
|
||||
ups_index = i
|
||||
break
|
||||
|
||||
if ups_index is None:
|
||||
return False
|
||||
|
||||
# Apply updates
|
||||
ups_dict = config.ups[ups_index].model_dump()
|
||||
update_dict = updates.model_dump(exclude_none=True)
|
||||
ups_dict.update(update_dict)
|
||||
|
||||
# Validate updated configuration
|
||||
try:
|
||||
updated_ups = UPSConfig(**ups_dict)
|
||||
except ValidationError as e:
|
||||
raise ValueError(f"Invalid configuration: {e}")
|
||||
|
||||
config.ups[ups_index] = updated_ups
|
||||
await self.save_config(config)
|
||||
|
||||
# No file cache now
|
||||
|
||||
return True
|
||||
|
||||
async def delete_ups(self, name: str) -> bool:
|
||||
"""Delete UPS configuration"""
|
||||
config = await self.load_config()
|
||||
|
||||
original_count = len(config.ups)
|
||||
config.ups = [ups for ups in config.ups if ups.name != name]
|
||||
|
||||
if len(config.ups) == original_count:
|
||||
return False # UPS not found
|
||||
|
||||
return False
|
||||
await self.save_config(config)
|
||||
|
||||
# No file cache now
|
||||
|
||||
return True
|
||||
|
||||
async def get_smtp_config(self) -> Optional[SMTPConfig]:
|
||||
"""Get SMTP configuration"""
|
||||
async def get_smtp_config(self) -> SMTPConfig | None:
|
||||
config = await self.load_config()
|
||||
return config.smtp
|
||||
|
||||
async def update_smtp_config(self, smtp_config: SMTPConfig) -> None:
|
||||
"""Update SMTP configuration"""
|
||||
config = await self.load_config()
|
||||
config.smtp = smtp_config
|
||||
await self.save_config(config)
|
||||
|
||||
# Clear cached config
|
||||
from . import config as config_module
|
||||
config_module._cached = None
|
||||
async def update_ui_config(self, ui: UIConfig) -> None:
|
||||
config = await self.load_config()
|
||||
config.ui = ui
|
||||
await self.save_config(config)
|
||||
|
||||
async def validate_ups_connection(
|
||||
self, ups_config: UPSConfig, timeout: float = 3.0
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
"""Port-only connectivity test (no protocol / CLI call)."""
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
result: dict[str, Any] = {
|
||||
"success": False,
|
||||
"message": "",
|
||||
"connectivity": {"ok": False, "error": None},
|
||||
"protocol": {"ok": False, "error": None},
|
||||
"data": None,
|
||||
}
|
||||
|
||||
# Raw TCP connectivity test
|
||||
try:
|
||||
# Use low-level socket to distinguish DNS/timeouts
|
||||
with socket.create_connection(
|
||||
(ups_config.host, ups_config.port), timeout=timeout
|
||||
):
|
||||
result["connectivity"]["ok"] = True
|
||||
except Exception as e: # broad to surface any network issue
|
||||
except Exception as e:
|
||||
result["connectivity"]["error"] = str(e)
|
||||
result["message"] = f"TCP connectivity failed: {e}"
|
||||
return result
|
||||
|
||||
# For port-only test we just mirror connectivity result
|
||||
if result["connectivity"]["ok"]:
|
||||
result["protocol"]["ok"] = True
|
||||
result["success"] = True
|
||||
@@ -207,5 +152,13 @@ class ConfigManager:
|
||||
return result
|
||||
|
||||
|
||||
# Global instance
|
||||
def smtp_redacted_dict(smtp: SMTPConfig | None) -> dict[str, Any] | None:
|
||||
"""Return SMTP config dict with password redacted from env."""
|
||||
if smtp is None:
|
||||
return None
|
||||
d = smtp.model_dump()
|
||||
d["password"] = "***" if settings.smtp_password else None
|
||||
return d
|
||||
|
||||
|
||||
config_manager = ConfigManager()
|
||||
+23
-14
@@ -1,31 +1,39 @@
|
||||
"""Redis-backed configuration storage replacing YAML file.
|
||||
"""Redis-backed configuration storage.
|
||||
|
||||
Schema:
|
||||
Key ups:config:json -> JSON object: {"ups": [...], "smtp": {...}|null}
|
||||
|
||||
Migration:
|
||||
On first load if redis key missing and legacy YAML present, import it.
|
||||
Key ``ups:config:json`` holds JSON: ``{"ups": [...], "smtp": {...}|null, "ui": {...}}``.
|
||||
Legacy YAML import runs once if Redis is empty.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
|
||||
import json
|
||||
import logging
|
||||
from .storage import get_redis
|
||||
from .config import AppConfig, CONFIG_PATH
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from .config import CONFIG_PATH, AppConfig
|
||||
from .storage import get_redis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REDIS_CONFIG_KEY = "ups:config:json"
|
||||
|
||||
|
||||
def _load_legacy_yaml(path: Path) -> Optional[AppConfig]:
|
||||
def _strip_smtp_password(data: dict) -> dict:
|
||||
"""Strip persisted SMTP passwords (migration from older schema)."""
|
||||
smtp = data.get("smtp")
|
||||
if isinstance(smtp, dict) and "password" in smtp:
|
||||
smtp.pop("password", None)
|
||||
return data
|
||||
|
||||
|
||||
def _load_legacy_yaml(path: Path) -> AppConfig | None:
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
with path.open() as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
raw = _strip_smtp_password(raw)
|
||||
return AppConfig(**raw)
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.warning("Failed to import legacy YAML config: %s", e)
|
||||
@@ -40,14 +48,13 @@ def load_config_redis() -> AppConfig:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
data = {}
|
||||
data = _strip_smtp_password(data)
|
||||
return AppConfig(**data)
|
||||
# Migration path
|
||||
legacy = _load_legacy_yaml(CONFIG_PATH)
|
||||
if legacy:
|
||||
save_config_redis(legacy)
|
||||
logger.info("Imported legacy YAML config into Redis")
|
||||
return legacy
|
||||
# If nothing exists, create empty scaffold
|
||||
empty = AppConfig(ups=[], smtp=None)
|
||||
save_config_redis(empty)
|
||||
return empty
|
||||
@@ -55,4 +62,6 @@ def load_config_redis() -> AppConfig:
|
||||
|
||||
def save_config_redis(cfg: AppConfig) -> None:
|
||||
r = get_redis()
|
||||
r.set(REDIS_CONFIG_KEY, json.dumps(cfg.model_dump(exclude_none=True)))
|
||||
data = cfg.model_dump(exclude_none=True)
|
||||
data = _strip_smtp_password(data)
|
||||
r.set(REDIS_CONFIG_KEY, json.dumps(data))
|
||||
@@ -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,67 @@
|
||||
"""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)
|
||||
|
||||
# Filter out "Invalid HTTP request received" noise from port scanners,
|
||||
# TLS probes, and healthchecks hitting the HTTP socket.
|
||||
class _InvalidHttpFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
msg = record.getMessage()
|
||||
return "Invalid HTTP request received" not in msg
|
||||
logging.getLogger("uvicorn.error").addFilter(_InvalidHttpFilter())
|
||||
|
||||
|
||||
class RequestIdMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request, call_next):
|
||||
rid = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
|
||||
token = _request_id_var.set(rid)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-ID"] = rid
|
||||
return response
|
||||
finally:
|
||||
_request_id_var.reset(token)
|
||||
+73
-458
@@ -1,472 +1,87 @@
|
||||
"""FastAPI application entry point.
|
||||
|
||||
Assembles middleware, lifespan (poller start/stop), auth, routes.
|
||||
All request handlers live in app/routes/*.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
import orjson
|
||||
from .config import load_config, UPSConfig
|
||||
from .storage import get_latest, get_history
|
||||
from .poller import poll_loop
|
||||
from .config_manager import (
|
||||
config_manager,
|
||||
UPSConfigUpdate,
|
||||
ConfigWriteError,
|
||||
get_config_version,
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
|
||||
from .logging_config import RequestIdMiddleware, configure_logging
|
||||
from .poller import cancel_all_tasks, poll_loop
|
||||
from .rate_limit import limiter
|
||||
from .routes import api_alerts, api_auth, api_config, api_events, api_health, api_ups, pages, sse
|
||||
from .security import SecurityHeadersMiddleware
|
||||
from .settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_background_tasks: list[asyncio.Task] = []
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
configure_logging()
|
||||
if not settings.session_secret:
|
||||
logger.warning(
|
||||
"SESSION_SECRET not set; using insecure ephemeral secret. "
|
||||
"Set SESSION_SECRET in your environment for production."
|
||||
)
|
||||
from .storage import get_redis
|
||||
from .apc_cli import fetch_status, APCStatusError
|
||||
|
||||
app = FastAPI(title="APC UPS Dashboard")
|
||||
app.mount('/static', StaticFiles(directory='app/static'), name='static')
|
||||
templates = Jinja2Templates(directory='app/templates')
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
asyncio.create_task(poll_loop())
|
||||
|
||||
|
||||
@app.get('/', response_class=HTMLResponse)
|
||||
async def dashboard(request: Request):
|
||||
cfg = load_config()
|
||||
return templates.TemplateResponse(
|
||||
'dashboard.html', {
|
||||
"request": request,
|
||||
"ups_list": cfg.ups,
|
||||
"ui_cfg": cfg.ui.model_dump(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get('/config', response_class=HTMLResponse)
|
||||
async def config_page(request: Request):
|
||||
return templates.TemplateResponse('config.html', {"request": request})
|
||||
|
||||
|
||||
@app.get('/api/ups')
|
||||
async def list_ups():
|
||||
cfg = load_config()
|
||||
return [{"name": u.name, "host": u.host, "port": u.port} for u in cfg.ups]
|
||||
|
||||
|
||||
@app.get('/api/ups/{ups_name}')
|
||||
async def ups_status(ups_name: str):
|
||||
snap = await get_latest(ups_name)
|
||||
return snap or {"error": "not found"}
|
||||
|
||||
|
||||
@app.get('/api/ups/{ups_name}/history')
|
||||
async def ups_history(ups_name: str):
|
||||
hist = await get_history(ups_name)
|
||||
return hist
|
||||
|
||||
|
||||
@app.get('/api/ups/{ups_name}/metric/{metric}')
|
||||
async def metric_history(ups_name: str, metric: str, limit: int = 120):
|
||||
"""Return recent numeric history samples for a single metric.
|
||||
|
||||
Limit capped at 500 to avoid large payloads.
|
||||
"""
|
||||
limit = max(1, min(limit, 500))
|
||||
all_hist = await get_history(ups_name)
|
||||
# Use only most recent entries
|
||||
recent = all_hist[-limit:]
|
||||
out = []
|
||||
for item in recent:
|
||||
data = item.get('data', {})
|
||||
raw_val = data.get(metric)
|
||||
if raw_val is None:
|
||||
continue
|
||||
# Extract leading number
|
||||
task = asyncio.create_task(poll_loop())
|
||||
_background_tasks.append(task)
|
||||
logger.info("Poller started")
|
||||
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:
|
||||
yield
|
||||
finally:
|
||||
logger.info("Shutting down: cancelling background tasks")
|
||||
for t in _background_tasks:
|
||||
t.cancel()
|
||||
for t in _background_tasks:
|
||||
try:
|
||||
ts_s, kind, rest = item.split('|', 2)
|
||||
parsed.append({
|
||||
'ts': int(ts_s),
|
||||
'type': kind,
|
||||
'detail': rest
|
||||
})
|
||||
continue
|
||||
except Exception:
|
||||
await t
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
parsed.append({'raw': item})
|
||||
return parsed
|
||||
await cancel_all_tasks()
|
||||
logger.info("Shutdown complete")
|
||||
|
||||
|
||||
@app.get('/api/ups/{ups_name}/energy')
|
||||
async def ups_energy(ups_name: str):
|
||||
"""Return today's accumulated energy (approx kWh) if available."""
|
||||
r = get_redis()
|
||||
day_str = time.strftime('%Y%m%d') # type: ignore
|
||||
key = f"ups:energy:{ups_name}:{day_str}"
|
||||
watt_seconds = r.get(key)
|
||||
if watt_seconds:
|
||||
try:
|
||||
ws = float(watt_seconds)
|
||||
kwh = ws / 3600.0 / 1000.0
|
||||
return {'kwh_today': round(kwh, 4)}
|
||||
except ValueError:
|
||||
pass
|
||||
return {'kwh_today': None}
|
||||
def create_app() -> FastAPI:
|
||||
application = FastAPI(title="APC UPS Dashboard", lifespan=lifespan)
|
||||
application.state.limiter = limiter
|
||||
application.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
# Middleware (outermost first in registration; Starlette wraps inside-out)
|
||||
application.add_middleware(SecurityHeadersMiddleware)
|
||||
application.add_middleware(RequestIdMiddleware)
|
||||
|
||||
@app.get('/api/ups/{ups_name}/watts_per_minute')
|
||||
async def ups_watts_per_minute(ups_name: str):
|
||||
"""Return recent per-minute average watts for a UPS (last 24h)."""
|
||||
r = get_redis()
|
||||
key = f"ups:watts:permin:{ups_name}"
|
||||
raw = r.lrange(key, 0, 1440)
|
||||
out = []
|
||||
for item in raw:
|
||||
if '|' in item:
|
||||
minute, avg = item.split('|', 1)
|
||||
try:
|
||||
out.append({'minute': minute, 'avg_watts': float(avg)})
|
||||
except ValueError:
|
||||
continue
|
||||
# list stored newest-first; reverse to chronological
|
||||
out.reverse()
|
||||
return out
|
||||
|
||||
|
||||
@app.get('/api/ups/{ups_name}/health')
|
||||
async def ups_health(ups_name: str):
|
||||
"""Return aggregated health indicators.
|
||||
|
||||
Includes recent alerts, voltage deviation stats, and ONBATT event count.
|
||||
"""
|
||||
r = get_redis()
|
||||
# Recent alerts
|
||||
alerts_key = f"ups:alerts:recent:{ups_name}"
|
||||
alert_raw = r.lrange(alerts_key, 0, 19)
|
||||
alerts = []
|
||||
for a in alert_raw:
|
||||
if '|' in a:
|
||||
ts_s, msg = a.split('|', 1)
|
||||
try:
|
||||
alerts.append({'ts': int(ts_s), 'msg': msg})
|
||||
except ValueError:
|
||||
alerts.append({'raw': a})
|
||||
else:
|
||||
alerts.append({'raw': a})
|
||||
# Voltage deviation samples
|
||||
dev_key = f"ups:volt:dev:samples:{ups_name}"
|
||||
dev_samples = r.lrange(dev_key, 0, 49)
|
||||
dev_vals = []
|
||||
for d in dev_samples:
|
||||
try:
|
||||
dev_vals.append(float(d))
|
||||
except ValueError:
|
||||
continue
|
||||
dev_avg = sum(devVals := dev_vals) / len(devVals) if dev_vals else None
|
||||
dev_max = max(dev_vals) if dev_vals else None
|
||||
# Transfer burst count (recent hour ONBATT events)
|
||||
events_key = f"ups:event:list:{ups_name}"
|
||||
now = int(time.time())
|
||||
events = r.lrange(events_key, 0, 200)
|
||||
onbatt_hour = 0
|
||||
for ev in events:
|
||||
parts = ev.split('|')
|
||||
if len(parts) >= 3:
|
||||
try:
|
||||
ts_e = int(parts[0])
|
||||
except ValueError:
|
||||
continue
|
||||
if now - ts_e > 3600:
|
||||
continue
|
||||
if parts[1] == 'STATUS' and 'ONBATT' in parts[2]:
|
||||
onbatt_hour += 1
|
||||
return {
|
||||
'alerts': alerts,
|
||||
'voltage_deviation': {
|
||||
'avg_pct': round(dev_avg, 2) if dev_avg is not None else None,
|
||||
'max_pct': round(dev_max, 2) if dev_max is not None else None,
|
||||
'samples': len(dev_vals)
|
||||
},
|
||||
'onbatt_last_hour': onbatt_hour
|
||||
}
|
||||
|
||||
|
||||
@app.get('/api/ups/{ups_name}/debug')
|
||||
async def ups_debug(ups_name: str):
|
||||
"""Return current apcaccess CLI status for a UPS."""
|
||||
cfg = load_config()
|
||||
target = next((u for u in cfg.ups if u.name == ups_name), None)
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail='UPS not found')
|
||||
try:
|
||||
data = await fetch_status(target.host, target.port)
|
||||
return data
|
||||
except APCStatusError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get('/api/stream')
|
||||
async def stream():
|
||||
# simple Server Sent Events stream of snapshots (polling redis every 5s)
|
||||
async def event_gen():
|
||||
while True:
|
||||
cfg = load_config()
|
||||
payload = {"snapshots": {}, "cfgVersion": get_config_version()}
|
||||
for u in cfg.ups:
|
||||
snap = await get_latest(u.name)
|
||||
if snap:
|
||||
payload["snapshots"][u.name] = snap
|
||||
# include simple config metadata to help client reconcile
|
||||
payload["upsMeta"] = [
|
||||
{"name": u.name, "host": u.host, "port": u.port}
|
||||
for u in cfg.ups
|
||||
]
|
||||
# Backward compatibility: also flatten UPS snapshots at top level
|
||||
for name, snap in payload["snapshots"].items():
|
||||
payload.setdefault(name, snap)
|
||||
yield f"data: {orjson.dumps(payload).decode()}\n\n"
|
||||
await asyncio.sleep(5)
|
||||
return StreamingResponse(event_gen(), media_type='text/event-stream')
|
||||
|
||||
|
||||
# Configuration management endpoints
|
||||
@app.get('/api/config/ups')
|
||||
async def get_ups_configs():
|
||||
"""Get all UPS configurations"""
|
||||
try:
|
||||
ups_list = await config_manager.get_ups_list()
|
||||
return [ups.model_dump() for ups in ups_list]
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get('/api/config/ups/{ups_name}')
|
||||
async def get_ups_config(ups_name: str):
|
||||
"""Get specific UPS configuration"""
|
||||
try:
|
||||
ups = await config_manager.get_ups(ups_name)
|
||||
if not ups:
|
||||
raise HTTPException(status_code=404, detail="UPS not found")
|
||||
return ups.model_dump()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post('/api/config/ups')
|
||||
async def add_ups_config(ups_config: UPSConfig):
|
||||
"""Add new UPS configuration"""
|
||||
try:
|
||||
await config_manager.add_ups(ups_config)
|
||||
return {"message": "UPS configuration added successfully"}
|
||||
except ConfigWriteError as e:
|
||||
raise HTTPException(status_code=507, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.put('/api/config/ups/{ups_name}')
|
||||
async def update_ups_config(ups_name: str, updates: UPSConfigUpdate):
|
||||
"""Update UPS configuration"""
|
||||
try:
|
||||
success = await config_manager.update_ups(ups_name, updates)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="UPS not found")
|
||||
return {"message": "UPS configuration updated successfully"}
|
||||
except ConfigWriteError as e:
|
||||
raise HTTPException(status_code=507, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.delete('/api/config/ups/{ups_name}')
|
||||
async def delete_ups_config(ups_name: str):
|
||||
"""Delete UPS configuration"""
|
||||
try:
|
||||
success = await config_manager.delete_ups(ups_name)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="UPS not found")
|
||||
return {"message": "UPS configuration deleted successfully"}
|
||||
except ConfigWriteError as e:
|
||||
raise HTTPException(status_code=507, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post('/api/config/ups/{ups_name}/test')
|
||||
async def test_ups_connection(ups_name: str):
|
||||
"""Test UPS connection"""
|
||||
try:
|
||||
ups = await config_manager.get_ups(ups_name)
|
||||
if not ups:
|
||||
raise HTTPException(status_code=404, detail="UPS not found")
|
||||
|
||||
result = await config_manager.validate_ups_connection(ups)
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post('/api/config/ups/test')
|
||||
async def test_new_ups_connection(ups_config: UPSConfig):
|
||||
"""Test new UPS configuration connection"""
|
||||
try:
|
||||
result = await config_manager.validate_ups_connection(ups_config)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get('/api/config/smtp')
|
||||
async def get_smtp_config():
|
||||
"""Get SMTP configuration"""
|
||||
try:
|
||||
smtp = await config_manager.get_smtp_config()
|
||||
return smtp.model_dump() if smtp else None
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get('/api/config/ui')
|
||||
async def get_ui_config():
|
||||
cfg = load_config()
|
||||
return cfg.ui.model_dump()
|
||||
|
||||
|
||||
@app.put('/api/config/ui')
|
||||
async def update_ui_config(payload: dict):
|
||||
# Simple partial update of UI flags
|
||||
from .config import AppConfig
|
||||
cfg = load_config()
|
||||
ui_dict = cfg.ui.model_dump()
|
||||
allowed = set(ui_dict.keys())
|
||||
for k, v in payload.items():
|
||||
if k in allowed and isinstance(v, bool):
|
||||
ui_dict[k] = v
|
||||
# Reconstruct full config and save via manager
|
||||
new_cfg = AppConfig(
|
||||
ups=cfg.ups, smtp=cfg.smtp, ui=cfg.ui.__class__(**ui_dict)
|
||||
application.mount(
|
||||
"/static", StaticFiles(directory="app/static"), name="static"
|
||||
)
|
||||
await config_manager.save_config(new_cfg)
|
||||
return {"message": "UI config updated", "ui": ui_dict}
|
||||
|
||||
# Routers
|
||||
application.include_router(pages.router)
|
||||
application.include_router(api_auth.router)
|
||||
application.include_router(api_health.router)
|
||||
application.include_router(api_config.router)
|
||||
application.include_router(api_ups.router)
|
||||
application.include_router(api_alerts.router)
|
||||
application.include_router(api_events.router)
|
||||
application.include_router(sse.router)
|
||||
|
||||
@application.exception_handler(Exception)
|
||||
async def unhandled_exc(request: Request, exc: Exception): # noqa: ARG001
|
||||
logger.exception("Unhandled error", extra={"path": request.url.path})
|
||||
return JSONResponse({"detail": "Internal server error"}, status_code=500)
|
||||
|
||||
return application
|
||||
|
||||
|
||||
# --- UI Tile Layout Persistence ---
|
||||
@app.get('/api/ups/{ups_name}/ui_tiles')
|
||||
async def get_ups_ui_tiles(ups_name: str):
|
||||
"""Return persisted UI tile settings for a UPS.
|
||||
|
||||
Includes: types, order, hidden, custom. Falls back to empty defaults if
|
||||
not stored yet.
|
||||
"""
|
||||
r = get_redis()
|
||||
key = f"ups:ui:tiles:{ups_name}"
|
||||
raw = r.get(key)
|
||||
if not raw:
|
||||
return {
|
||||
"types": {},
|
||||
"order": [],
|
||||
"hidden": [],
|
||||
"custom": [],
|
||||
"positions": {}
|
||||
}
|
||||
try:
|
||||
data = orjson.loads(raw)
|
||||
# Basic shape validation
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError
|
||||
return {
|
||||
"types": data.get("types", {}),
|
||||
"order": data.get("order", []),
|
||||
"hidden": data.get("hidden", []),
|
||||
"custom": data.get("custom", []),
|
||||
"positions": data.get("positions", {}),
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
"types": {},
|
||||
"order": [],
|
||||
"hidden": [],
|
||||
"custom": [],
|
||||
"positions": {}
|
||||
}
|
||||
|
||||
|
||||
@app.post('/api/ups/{ups_name}/ui_tiles')
|
||||
async def save_ups_ui_tiles(ups_name: str, payload: dict):
|
||||
"""Persist UI tile settings for a UPS.
|
||||
|
||||
Expects JSON: { types: {...}, order: [...], hidden: [...], custom: [...] }
|
||||
"""
|
||||
# Light validation / sanitization
|
||||
types = (payload.get('types')
|
||||
if isinstance(payload.get('types'), dict) else {})
|
||||
order = (payload.get('order')
|
||||
if isinstance(payload.get('order'), list) else [])
|
||||
hidden = (payload.get('hidden')
|
||||
if isinstance(payload.get('hidden'), list) else [])
|
||||
custom = (payload.get('custom')
|
||||
if isinstance(payload.get('custom'), list) else [])
|
||||
positions = (payload.get('positions')
|
||||
if isinstance(payload.get('positions'), dict) else {})
|
||||
# Ensure custom entries minimally formed
|
||||
norm_custom = []
|
||||
for c in custom or []:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
metric = c.get('metric')
|
||||
chart = c.get('chart')
|
||||
cid = c.get('id') or ''
|
||||
source = c.get('source', 'live')
|
||||
if not metric or not chart:
|
||||
continue
|
||||
norm_custom.append({
|
||||
'id': cid,
|
||||
'metric': metric,
|
||||
'chart': chart,
|
||||
'source': source
|
||||
})
|
||||
doc = {
|
||||
'types': types,
|
||||
'order': order,
|
||||
'hidden': hidden,
|
||||
'custom': norm_custom,
|
||||
'positions': positions,
|
||||
'saved_ts': int(time.time())
|
||||
}
|
||||
r = get_redis()
|
||||
key = f"ups:ui:tiles:{ups_name}"
|
||||
r.set(key, orjson.dumps(doc))
|
||||
return {"message": "saved", "count_custom": len(norm_custom)}
|
||||
|
||||
|
||||
@app.delete('/api/ups/{ups_name}/ui_tiles')
|
||||
async def clear_ups_ui_tiles(ups_name: str):
|
||||
"""Delete stored UI tile layout/settings for a UPS (full reset)."""
|
||||
r = get_redis()
|
||||
key = f"ups:ui:tiles:{ups_name}"
|
||||
r.delete(key)
|
||||
return {"message": "cleared"}
|
||||
app = create_app()
|
||||
@@ -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
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from .alerts import Alert, dispatch_alerts, emit_info, process_alerts
|
||||
from .apc_cli import APCStatusError, fetch_status
|
||||
from .config import load_config
|
||||
from .apc_cli import fetch_status, APCStatusError
|
||||
from .storage import store_snapshot, prune_old, get_redis
|
||||
from .alerts import process_alerts
|
||||
from .storage import get_redis, prune_old, store_snapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ACTIVE_TASKS: dict[str, asyncio.Task] = {}
|
||||
_RELOADER_LOCK = asyncio.Lock()
|
||||
|
||||
BATTERY_HISTORY_MAX = 10080 # 7 days @ 1/minute
|
||||
HEALTH_OFFLINE_MIN_SECONDS = 180
|
||||
|
||||
|
||||
def _get_offline_threshold(interval_seconds: int) -> int:
|
||||
return max(HEALTH_OFFLINE_MIN_SECONDS, 3 * interval_seconds)
|
||||
|
||||
|
||||
def _record_poll_success(name: str, snapshot: dict, interval_seconds: int) -> None:
|
||||
r = get_redis()
|
||||
now = int(time.time())
|
||||
r.set(f"ups:health:last_ok:{name}", now)
|
||||
r.delete(f"ups:health:fail_count:{name}")
|
||||
was_offline = r.get(f"ups:health:offline:{name}")
|
||||
if was_offline:
|
||||
r.delete(f"ups:health:offline:{name}")
|
||||
emit_info(name, "REACHABLE", f"UPS {name} reachable again")
|
||||
# Battery history append (sample every ~60s to keep list bounded)
|
||||
last_sample_key = f"ups:battery:history:last_ts:{name}"
|
||||
last_ts_raw = r.get(last_sample_key)
|
||||
try:
|
||||
last_ts = int(last_ts_raw) if last_ts_raw else 0
|
||||
except ValueError:
|
||||
last_ts = 0
|
||||
if now - last_ts >= 60:
|
||||
try:
|
||||
sample = {
|
||||
"ts": now,
|
||||
"bcharge": _f(snapshot.get("BCHARGE")),
|
||||
"timeleft": _f(snapshot.get("TIMELEFT")),
|
||||
"battv": _f(snapshot.get("BATTV")),
|
||||
"nombattv": _f(snapshot.get("NOMBATTV")),
|
||||
}
|
||||
r.lpush(f"ups:battery:history:{name}", json.dumps(sample))
|
||||
r.ltrim(f"ups:battery:history:{name}", 0, BATTERY_HISTORY_MAX - 1)
|
||||
r.set(last_sample_key, now)
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
|
||||
def _record_poll_failure(name: str, interval_seconds: int, reason: str) -> None:
|
||||
r = get_redis()
|
||||
now = int(time.time())
|
||||
r.incr(f"ups:health:fail_count:{name}")
|
||||
last_ok = r.get(f"ups:health:last_ok:{name}")
|
||||
try:
|
||||
last_ok_ts = int(last_ok) if last_ok else 0
|
||||
except ValueError:
|
||||
last_ok_ts = 0
|
||||
threshold = _get_offline_threshold(interval_seconds)
|
||||
# If we never succeeded, base threshold on process start (use last_ok=0 => big delta)
|
||||
if now - last_ok_ts > threshold:
|
||||
offline_flag = r.get(f"ups:health:offline:{name}")
|
||||
if not offline_flag:
|
||||
r.set(f"ups:health:offline:{name}", "1")
|
||||
alert = Alert(
|
||||
"CRITICAL",
|
||||
f"UPS {name} unreachable ({reason})",
|
||||
"COMMLOST",
|
||||
name,
|
||||
now,
|
||||
)
|
||||
cfg = load_config()
|
||||
r.lpush(
|
||||
"ups:alerts:history:all",
|
||||
f"{alert.ts}|CRITICAL|{alert.ups}|COMMLOST|{alert.message}|{alert.hash()}",
|
||||
)
|
||||
r.ltrim("ups:alerts:history:all", 0, 499)
|
||||
r.lpush(
|
||||
f"ups:alerts:recent:{name}",
|
||||
f"{alert.ts}|CRITICAL|{alert.message}",
|
||||
)
|
||||
r.ltrim(f"ups:alerts:recent:{name}", 0, 49)
|
||||
if cfg.smtp:
|
||||
dispatch_alerts(cfg.smtp, name, [alert])
|
||||
|
||||
|
||||
def _f(v):
|
||||
try:
|
||||
return float(str(v).split()[0]) if v is not None else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _poll_one(ups):
|
||||
r = get_redis()
|
||||
@@ -21,7 +118,6 @@ async def _poll_one(ups):
|
||||
try:
|
||||
data = await fetch_status(ups.host, ups.port)
|
||||
data['UPSNAME'] = ups.name
|
||||
# Derived metrics
|
||||
try:
|
||||
loadpct = float(str(data.get('LOADPCT', '0')).split()[0])
|
||||
except Exception:
|
||||
@@ -33,19 +129,15 @@ async def _poll_one(ups):
|
||||
pass
|
||||
if nompower and loadpct >= 0:
|
||||
watts = nompower * loadpct / 100.0
|
||||
data['DERIVED_WATTS'] = f"{watts:.0f}" # integer string
|
||||
data['DERIVED_WATTS'] = f"{watts:.0f}"
|
||||
data['HEADROOM_PCT'] = f"{max(0.0, 100.0 - loadpct):.0f}"
|
||||
# Runtime minutes (normalize TIMELEFT like '15.0 Minutes')
|
||||
timeleft_raw = str(data.get('TIMELEFT', '')).strip()
|
||||
try:
|
||||
runtime_min = float(timeleft_raw.split()[0])
|
||||
data['RUNTIME_MINUTES'] = f"{runtime_min:.1f}"
|
||||
except Exception:
|
||||
pass
|
||||
# Event detection (status changes, last transfer changes)
|
||||
# Reuse redis handle r
|
||||
now_ts = asyncio.get_event_loop().time()
|
||||
wall_ts = int(now_ts)
|
||||
wall_ts = int(time.time())
|
||||
status_key = f"ups:event:status:last:{ups.name}"
|
||||
lastxfer_key = f"ups:event:lastxfer:last:{ups.name}"
|
||||
events_list_key = f"ups:event:list:{ups.name}"
|
||||
@@ -55,53 +147,39 @@ async def _poll_one(ups):
|
||||
if prev_status != status_now and status_now:
|
||||
r.set(status_key, status_now)
|
||||
r.lpush(events_list_key, f"{wall_ts}|STATUS|{status_now}")
|
||||
# Transition from ONBATT -> ONLINE: info alert
|
||||
if prev_status and "ONBATT" in str(prev_status) and "ONLINE" in status_now:
|
||||
emit_info(
|
||||
ups.name,
|
||||
"LINE_RESTORED",
|
||||
f"UPS {ups.name} returned to line power",
|
||||
)
|
||||
lastxfer_now = str(data.get('LASTXFER', '')).strip()
|
||||
prev_lastxfer = r.get(lastxfer_key)
|
||||
if lastxfer_now and lastxfer_now != prev_lastxfer:
|
||||
r.set(lastxfer_key, lastxfer_now)
|
||||
r.lpush(events_list_key, f"{wall_ts}|XFER|{lastxfer_now}")
|
||||
# Trim events
|
||||
r.ltrim(events_list_key, 0, max_events - 1)
|
||||
# Energy accumulation (watt-seconds)
|
||||
if 'DERIVED_WATTS' in data:
|
||||
try:
|
||||
watts = float(data['DERIVED_WATTS'])
|
||||
day_str = time.strftime('%Y%m%d')
|
||||
energy_key = f"ups:energy:{ups.name}:{day_str}"
|
||||
# increment by watts * interval_seconds (approx)
|
||||
r.incrbyfloat(energy_key, watts * ups.interval_seconds)
|
||||
r.expire(energy_key, 3 * 24 * 3600)
|
||||
# Per-minute accumulation
|
||||
minute = time.strftime('%Y%m%d%H%M')
|
||||
# Running sum and count in a hash
|
||||
mb = r.hgetall(minute_bucket_key)
|
||||
if not mb or mb.get('minute') != minute:
|
||||
# finalize previous bucket
|
||||
if (
|
||||
mb
|
||||
and 'sum' in mb
|
||||
and 'count' in mb
|
||||
and 'minute' in mb
|
||||
):
|
||||
if mb and 'sum' in mb and 'count' in mb and 'minute' in mb:
|
||||
try:
|
||||
avg = float(mb['sum']) / max(
|
||||
1, int(mb['count'])
|
||||
)
|
||||
r.lpush(
|
||||
series_key,
|
||||
f"{mb['minute']}|{avg:.2f}"
|
||||
)
|
||||
# keep up to 24h of minutes
|
||||
avg = float(mb['sum']) / max(1, int(mb['count']))
|
||||
r.lpush(series_key, f"{mb['minute']}|{avg:.2f}")
|
||||
r.ltrim(series_key, 0, 1439)
|
||||
except Exception:
|
||||
pass
|
||||
r.hset(
|
||||
minute_bucket_key,
|
||||
mapping={
|
||||
'minute': minute,
|
||||
'sum': watts,
|
||||
'count': 1,
|
||||
},
|
||||
mapping={'minute': minute, 'sum': watts, 'count': 1},
|
||||
)
|
||||
r.expire(minute_bucket_key, 26 * 3600)
|
||||
else:
|
||||
@@ -121,36 +199,47 @@ async def _poll_one(ups):
|
||||
except Exception:
|
||||
pass
|
||||
await store_snapshot(ups.name, data)
|
||||
_record_poll_success(ups.name, data, ups.interval_seconds)
|
||||
process_alerts(ups, data)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
reason = str(e) if not isinstance(e, APCStatusError) else str(e)
|
||||
if isinstance(e, APCStatusError):
|
||||
logger.warning("apcaccess error for %s: %s", ups.name, e)
|
||||
else:
|
||||
logger.warning("Polling error for %s: %s", ups.name, e)
|
||||
_record_poll_failure(ups.name, ups.interval_seconds, reason)
|
||||
await asyncio.sleep(ups.interval_seconds)
|
||||
|
||||
|
||||
async def _reconcile_tasks():
|
||||
"""Ensure a polling task exists per configured UPS and remove stale ones.
|
||||
|
||||
Creates tasks for new UPS entries and cancels tasks whose UPS were removed.
|
||||
"""
|
||||
async with _RELOADER_LOCK:
|
||||
cfg = load_config()
|
||||
current_names = {u.name for u in cfg.ups}
|
||||
# cancel removed
|
||||
current = {u.name: (u.host, u.port, u.interval_seconds) for u in cfg.ups}
|
||||
# cancel removed or changed
|
||||
for name in list(_ACTIVE_TASKS.keys()):
|
||||
if name not in current_names:
|
||||
if name not in current:
|
||||
_ACTIVE_TASKS[name].cancel()
|
||||
del _ACTIVE_TASKS[name]
|
||||
# add new
|
||||
for ups in cfg.ups:
|
||||
if ups.name not in _ACTIVE_TASKS:
|
||||
_ACTIVE_TASKS[ups.name] = asyncio.create_task(_poll_one(ups))
|
||||
|
||||
|
||||
async def cancel_all_tasks() -> None:
|
||||
"""Cancel all running poller tasks (used on shutdown)."""
|
||||
for t in _ACTIVE_TASKS.values():
|
||||
t.cancel()
|
||||
for _name, t in list(_ACTIVE_TASKS.items()):
|
||||
try:
|
||||
await t
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
_ACTIVE_TASKS.clear()
|
||||
|
||||
|
||||
async def poll_loop():
|
||||
# initial reconcile
|
||||
await _reconcile_tasks()
|
||||
|
||||
async def prune_loop():
|
||||
@@ -162,19 +251,13 @@ async def poll_loop():
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
async def config_watch_loop():
|
||||
"""Periodically re-read config to capture UPS CRUD changes."""
|
||||
last_fingerprint = None
|
||||
while True:
|
||||
try:
|
||||
cfg = load_config()
|
||||
fingerprint = tuple(
|
||||
sorted(
|
||||
(
|
||||
u.name,
|
||||
u.host,
|
||||
u.port,
|
||||
u.interval_seconds,
|
||||
)
|
||||
(u.name, u.host, u.port, u.interval_seconds)
|
||||
for u in cfg.ups
|
||||
)
|
||||
)
|
||||
@@ -188,5 +271,5 @@ async def poll_loop():
|
||||
await asyncio.gather(
|
||||
prune_loop(),
|
||||
config_watch_loop(),
|
||||
*(_ACTIVE_TASKS.values()),
|
||||
return_exceptions=True,
|
||||
)
|
||||
@@ -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,372 @@
|
||||
"""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": {}, "card_size": None,
|
||||
}
|
||||
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", {}),
|
||||
"card_size": data.get("card_size"),
|
||||
}
|
||||
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 {}
|
||||
raw_card_size = payload.get("card_size")
|
||||
card_size = None
|
||||
if isinstance(raw_card_size, dict):
|
||||
try:
|
||||
w = int(raw_card_size.get("width") or 0)
|
||||
h = int(raw_card_size.get("height") or 0)
|
||||
# Clamp to sane bounds matching the frontend resize limits
|
||||
if 200 <= w <= 2000 and 200 <= h <= 2000:
|
||||
card_size = {"width": w, "height": h}
|
||||
except (TypeError, ValueError):
|
||||
card_size = None
|
||||
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,
|
||||
"card_size": card_size,
|
||||
"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,456 @@
|
||||
/* ========================================================================
|
||||
Design Tokens — dark theme (slate/indigo)
|
||||
======================================================================== */
|
||||
:root {
|
||||
/* Surfaces */
|
||||
--bg: #0b1220;
|
||||
--surface: #131c2e;
|
||||
--surface-2: #1a2540;
|
||||
--surface-3: #223052;
|
||||
--border: #263654;
|
||||
--border-hi: #36507d;
|
||||
|
||||
/* Text */
|
||||
--text: #e6edf7;
|
||||
--text-muted: #9fb0cb;
|
||||
--text-dim: #6c7fa2;
|
||||
|
||||
/* Brand / accent */
|
||||
--accent: #60a5fa;
|
||||
--accent-hover: #3b82f6;
|
||||
--accent-dim: #1e3a8a;
|
||||
|
||||
/* Semantic */
|
||||
--success: #10b981;
|
||||
--success-bg: #053b2d;
|
||||
--warning: #f59e0b;
|
||||
--warning-bg: #3a2608;
|
||||
--danger: #ef4444;
|
||||
--danger-bg: #3b0f0f;
|
||||
--info: #38bdf8;
|
||||
--info-bg: #052b3a;
|
||||
|
||||
/* Elevation */
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,.3);
|
||||
--shadow-md: 0 2px 8px rgba(0,0,0,.35);
|
||||
--shadow-lg: 0 8px 24px rgba(0,0,0,.45);
|
||||
|
||||
/* Radii & spacing */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 10px;
|
||||
|
||||
/* Typography */
|
||||
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
Reset & base
|
||||
======================================================================== */
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { color: var(--accent-hover); text-decoration: underline; }
|
||||
|
||||
h1, h2, h3, h4 { margin: 0 0 0.5rem 0; font-weight: 600; color: var(--text); }
|
||||
h1 { font-size: 1.4rem; }
|
||||
h2 { font-size: 1.15rem; }
|
||||
h3 { font-size: 1rem; }
|
||||
|
||||
hr { border: 0; border-top: 1px solid var(--border); margin: 1rem 0; }
|
||||
|
||||
code, pre { font-family: var(--font-mono); }
|
||||
|
||||
/* ========================================================================
|
||||
Header & nav
|
||||
======================================================================== */
|
||||
.site-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 10px 24px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.site-header .brand a {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
.site-header .brand a:hover { text-decoration: none; color: var(--accent); }
|
||||
|
||||
.main-nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.main-nav a {
|
||||
color: var(--text-muted);
|
||||
padding: 7px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.main-nav a:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.main-nav a.active {
|
||||
background: var(--accent-dim);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.user-menu { display: flex; align-items: center; gap: 10px; }
|
||||
.user-menu .username {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 8px;
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.btn-logout {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: background .15s, color .15s, border-color .15s;
|
||||
}
|
||||
.btn-logout:hover {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.site-main { padding: 20px 24px; max-width: 1600px; margin: 0 auto; }
|
||||
|
||||
/* ========================================================================
|
||||
Page scaffolding
|
||||
======================================================================== */
|
||||
.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: 7px 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
}
|
||||
.filters select:focus, .filters input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.15);
|
||||
}
|
||||
|
||||
.muted { color: var(--text-dim); font-size: 13px; }
|
||||
|
||||
/* ========================================================================
|
||||
Tables
|
||||
======================================================================== */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.data-table th, .data-table td {
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
.data-table th {
|
||||
background: var(--surface-2);
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
.data-table tr:last-child td { border-bottom: none; }
|
||||
.data-table tr:hover td { background: var(--surface-2); }
|
||||
|
||||
/* Severity pills */
|
||||
.sev {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
.sev.CRITICAL { background: var(--danger-bg); color: var(--danger); border: 1px solid var(--danger); }
|
||||
.sev.WARNING { background: var(--warning-bg); color: var(--warning); border: 1px solid var(--warning); }
|
||||
.sev.INFO { background: var(--info-bg); color: var(--info); border: 1px solid var(--info); }
|
||||
|
||||
/* ========================================================================
|
||||
Settings
|
||||
======================================================================== */
|
||||
.settings-section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
max-width: 860px;
|
||||
}
|
||||
.settings-section h2 {
|
||||
margin-top: 0;
|
||||
font-size: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.settings-form { display: flex; flex-direction: column; gap: 18px; }
|
||||
|
||||
/* ---- Toggle grid (tabular on/off switches) ---- */
|
||||
.toggle-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.toggle-grid.compact .toggle-row { padding: 10px 14px; }
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background .15s;
|
||||
}
|
||||
.toggle-row:last-child { border-bottom: none; }
|
||||
.toggle-row:hover { background: var(--surface-3); }
|
||||
.toggle-label { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.toggle-title { font-size: 14px; color: var(--text); font-weight: 500; }
|
||||
.toggle-help { font-size: 12px; color: var(--text-dim); }
|
||||
|
||||
/* iOS-style switch */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.switch .slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--border-hi);
|
||||
border-radius: 22px;
|
||||
transition: background .2s, border-color .2s;
|
||||
}
|
||||
.switch .slider::before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
background: var(--text-muted);
|
||||
border-radius: 50%;
|
||||
transition: transform .2s, background .2s;
|
||||
}
|
||||
.switch input:checked + .slider {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
.switch input:checked + .slider::before {
|
||||
transform: translateX(18px);
|
||||
background: #fff;
|
||||
}
|
||||
.switch input:focus-visible + .slider {
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.25);
|
||||
}
|
||||
|
||||
/* ---- Labeled input rows ---- */
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px 20px;
|
||||
}
|
||||
.field-row { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.field-row.wide { grid-column: 1 / -1; }
|
||||
.field-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.field-input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.field-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.15);
|
||||
}
|
||||
.field-input::placeholder { color: var(--text-dim); }
|
||||
|
||||
/* Inline (non-grid) single field row */
|
||||
.settings-form > .field-row { max-width: 280px; }
|
||||
|
||||
.form-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.field-grid { grid-template-columns: 1fr; }
|
||||
.toggle-row { padding: 12px 14px; }
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
Buttons (global)
|
||||
======================================================================== */
|
||||
.btn-primary {
|
||||
background: var(--accent-hover);
|
||||
color: #fff;
|
||||
border: 0;
|
||||
padding: 9px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent); }
|
||||
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn-block { width: 100%; }
|
||||
|
||||
/* ========================================================================
|
||||
Fleet overview
|
||||
======================================================================== */
|
||||
.fleet-overview { margin-bottom: 20px; }
|
||||
.fleet-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 18px 22px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.fleet-stat { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.fleet-stat-label {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.fleet-stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.fleet-stat-value.online { color: var(--success); }
|
||||
.fleet-stat-value.onbatt { color: var(--warning); }
|
||||
.fleet-stat-value.offline { color: var(--danger); }
|
||||
|
||||
/* ========================================================================
|
||||
State indicators (shared)
|
||||
======================================================================== */
|
||||
.state-dot {
|
||||
display: inline-block;
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
box-shadow: 0 0 0 2px rgba(255,255,255,.05);
|
||||
}
|
||||
.state-dot.online { background: var(--success); box-shadow: 0 0 6px rgba(16,185,129,.5); }
|
||||
.state-dot.onbatt { background: var(--warning); box-shadow: 0 0 6px rgba(245,158,11,.5); }
|
||||
.state-dot.offline { background: var(--danger); box-shadow: 0 0 6px rgba(239,68,68,.5); }
|
||||
|
||||
/* ========================================================================
|
||||
Empty state
|
||||
======================================================================== */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1.5rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
width: 100%;
|
||||
}
|
||||
.empty-state h3 { color: var(--text); margin-bottom: 0.5rem; }
|
||||
.empty-state a { color: var(--accent); }
|
||||
|
||||
/* ========================================================================
|
||||
Scrollbars
|
||||
======================================================================== */
|
||||
* { scrollbar-width: thin; scrollbar-color: var(--border-hi) var(--bg); }
|
||||
*::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
*::-webkit-scrollbar-track { background: var(--bg); }
|
||||
*::-webkit-scrollbar-thumb { background: var(--border-hi); border-radius: 5px; border: 2px solid var(--bg); }
|
||||
*::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
|
||||
|
||||
/* ========================================================================
|
||||
Responsive
|
||||
======================================================================== */
|
||||
@media (max-width: 768px) {
|
||||
.site-header { flex-direction: column; align-items: flex-start; gap: 12px; padding: 12px 16px; }
|
||||
.main-nav { width: 100%; }
|
||||
.site-main { padding: 16px; }
|
||||
.fleet-card { grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 14px; padding: 14px 16px; }
|
||||
.fleet-stat-value { font-size: 20px; }
|
||||
}
|
||||
+116
-289
@@ -1,355 +1,182 @@
|
||||
/* Configuration page styles */
|
||||
body {
|
||||
background: #10151b;
|
||||
color: #eef;
|
||||
}
|
||||
/* ============================================================
|
||||
Config page — UPS list, modals, toasts.
|
||||
Inherits design tokens from base.css.
|
||||
============================================================ */
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #eef;
|
||||
}
|
||||
|
||||
.header nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header nav a {
|
||||
text-decoration: none;
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header nav a:hover {
|
||||
color: #eef;
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #545b62;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: #1e7e34;
|
||||
}
|
||||
|
||||
/* UPS list styles */
|
||||
.ups-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
/* UPS list */
|
||||
.ups-list { display: grid; gap: 12px; }
|
||||
|
||||
.ups-item {
|
||||
background: #1d2530;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 18px 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.ups-item:hover { border-color: var(--border-hi); }
|
||||
|
||||
.ups-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.ups-item-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #eef;
|
||||
}
|
||||
|
||||
.ups-item-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.ups-item-title { font-size: 16px; font-weight: 600; color: var(--text); }
|
||||
.ups-item-actions { display: flex; gap: 8px; }
|
||||
|
||||
.ups-item-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ups-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ups-detail { display: flex; flex-direction: column; }
|
||||
.ups-detail-label {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 2px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ups-detail-value { font-weight: 500; color: var(--text); font-size: 13px; }
|
||||
|
||||
.ups-detail-value {
|
||||
/* Generic buttons */
|
||||
.btn {
|
||||
padding: 7px 14px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #eef;
|
||||
transition: background .15s, border-color .15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-primary { background: var(--accent-hover); color: #fff; border-color: var(--accent-hover); }
|
||||
.btn-primary:hover { background: var(--accent); }
|
||||
.btn-secondary { background: var(--surface-2); color: var(--text); border-color: var(--border); }
|
||||
.btn-secondary:hover { background: var(--surface-3); border-color: var(--border-hi); }
|
||||
.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||
.btn-danger:hover { background: #dc2626; }
|
||||
.btn-success { background: var(--success); color: #fff; border-color: var(--success); }
|
||||
.btn-success:hover { background: #059669; }
|
||||
|
||||
.ups-status {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ups-status.online {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.ups-status.offline {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.ups-status.testing {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
/* Modals */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #1d2530;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
margin: 5% auto;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.modal-content.small {
|
||||
max-width: 400px;
|
||||
}
|
||||
.modal-content.small { max-width: 400px; }
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
color: #eef;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-header h2 { margin: 0; font-size: 16px; }
|
||||
|
||||
.close {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: #eef;
|
||||
}
|
||||
|
||||
/* Form styles */
|
||||
form {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-dim);
|
||||
padding: 0 8px;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
.close:hover { color: var(--text); }
|
||||
|
||||
/* Forms */
|
||||
form { padding: 18px 20px; }
|
||||
.form-group { margin-bottom: 18px; }
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
color: #eef;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"] {
|
||||
.form-group input[type="number"],
|
||||
.form-group input[type="password"],
|
||||
.form-group input[type="email"],
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
background: #2d3748;
|
||||
color: #eef;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input[type="text"]:focus,
|
||||
.form-group input[type="number"]:focus {
|
||||
.form-group input:focus, .form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #4a9eff;
|
||||
box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.25);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.15);
|
||||
}
|
||||
|
||||
.form-group input[type="checkbox"] {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
color: #999;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.form-group input[type="checkbox"] { margin-right: 8px; vertical-align: middle; }
|
||||
.form-group small { display: block; margin-top: 4px; color: var(--text-dim); font-size: 11px; }
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
/* Toast notifications */
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 1100;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Toasts */
|
||||
#toast-container { position: fixed; top: 80px; right: 20px; z-index: 1100; }
|
||||
.toast {
|
||||
background: #1d2530;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
max-width: 300px;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
color: #eef;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
border-left: 4px solid #28a745;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-left: 4px solid #dc3545;
|
||||
}
|
||||
|
||||
.toast.warning {
|
||||
border-left: 4px solid #ffc107;
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
border-left: 4px solid #17a2b8;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left-width: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
box-shadow: var(--shadow-md);
|
||||
max-width: 320px;
|
||||
animation: slideIn .25s ease-out;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
.toast.success { border-left-color: var(--success); }
|
||||
.toast.error { border-left-color: var(--danger); }
|
||||
.toast.warning { border-left-color: var(--warning); }
|
||||
.toast.info { border-left-color: var(--info); }
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
from { transform: translateX(110%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.ups-item-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ups-item-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
margin: 2% auto;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
color: #eef;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
.ups-item-details { grid-template-columns: 1fr; }
|
||||
.ups-item-header { flex-direction: column; align-items: flex-start; gap: 10px; }
|
||||
.modal-content { width: 95%; margin: 2% auto; }
|
||||
.form-actions { flex-direction: column; }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/* ============================================================
|
||||
Login / Setup page. Inherits tokens from base.css.
|
||||
============================================================ */
|
||||
|
||||
.auth-body {
|
||||
background:
|
||||
radial-gradient(ellipse 800px 600px at 50% -200px, rgba(96,165,250,.08), transparent),
|
||||
var(--bg);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 36px 32px;
|
||||
width: 380px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.auth-card.wide { width: 460px; }
|
||||
|
||||
.auth-card h1 {
|
||||
margin: 0 0 6px;
|
||||
text-align: center;
|
||||
font-size: 22px;
|
||||
color: var(--text);
|
||||
}
|
||||
.auth-card .muted { text-align: center; margin-bottom: 20px; }
|
||||
|
||||
.form-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
|
||||
.form-field label { font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px; font-weight: 600; }
|
||||
.form-field input {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.form-field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.15);
|
||||
}
|
||||
|
||||
.login-error {
|
||||
margin-top: 12px;
|
||||
padding: 10px 14px;
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
}
|
||||
+262
-271
@@ -1,328 +1,319 @@
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
background: #10151b;
|
||||
color: #eef;
|
||||
}
|
||||
/* ============================================================
|
||||
Dashboard — UPS cards, tiles, badges, charts.
|
||||
Inherits design tokens from base.css. No body/header redefinition.
|
||||
============================================================ */
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
color: #eef;
|
||||
}
|
||||
|
||||
.header nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.config-link {
|
||||
color: #6c757d;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.config-link:hover {
|
||||
background-color: #2d3748;
|
||||
color: #eef;
|
||||
/* Card grid */
|
||||
#ups-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #1d2530;
|
||||
padding: 1.5rem;
|
||||
margin: 0.5rem;
|
||||
border-radius: 8px;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
min-width: 320px;
|
||||
min-height: 260px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 16px 18px 28px;
|
||||
border-radius: var(--radius-lg);
|
||||
position: relative;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
position: relative;
|
||||
resize: both; /* fallback native resize for browsers that support */
|
||||
box-shadow: var(--shadow-sm);
|
||||
min-height: 280px;
|
||||
resize: both;
|
||||
overflow: hidden;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.card:hover { border-color: var(--border-hi); box-shadow: var(--shadow-md); }
|
||||
.card.resizing { outline: 2px dashed var(--accent); }
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
.ups-connection {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
padding: 2px 6px;
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ------- Highline (status + key badges) ------- */
|
||||
.highline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: #243140;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.5px;
|
||||
color: #cde;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge[data-badge="runtime"] { background:#2c3e50; }
|
||||
.badge[data-badge="watts"] { background:#1f4f61; }
|
||||
.badge[data-badge="headroom"] { background:#3b3f2a; }
|
||||
.badge[data-badge="energy"] { background:#28324a; }
|
||||
|
||||
/* Battery Gauge */
|
||||
.gauge { width: 120px; height: 70px; position: relative; }
|
||||
.gauge svg { width: 100%; height: 100%; }
|
||||
.gauge-bg { fill: none; stroke: #243140; stroke-width: 10; stroke-linecap: round; }
|
||||
.gauge-fill { fill: none; stroke: #4a9eff; stroke-width: 10; stroke-linecap: round; transition: stroke-dasharray .4s ease, stroke .4s; }
|
||||
.gauge-text { fill: #cde; font-size: 16px; font-weight: 600; dominant-baseline: middle; }
|
||||
.gauge.low .gauge-fill { stroke: #d97745; }
|
||||
.gauge.critical .gauge-fill { stroke: #d9534f; }
|
||||
.gauge.good .gauge-fill { stroke: #3bb273; }
|
||||
|
||||
.events { margin-top: 0.75rem; }
|
||||
.events-header { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 1px; opacity: 0.6; margin-bottom: 0.25rem; }
|
||||
.events-list { list-style: none; padding: 0; margin: 0; max-height: 90px; overflow-y: auto; font-size: 0.65rem; line-height: 1.2; }
|
||||
.events-list li { padding: 2px 0; border-bottom: 1px solid #233; }
|
||||
.events-list li:last-child { border-bottom: none; }
|
||||
|
||||
.card-header {
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: #eef;
|
||||
}
|
||||
|
||||
.ups-connection {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 32px; /* Reserve space for footer */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.metric-row > div {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.val {
|
||||
font-weight: bold;
|
||||
color: #4a9eff;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ups-status {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.ups-status.online { background: var(--success-bg); color: var(--success); border-color: var(--success); }
|
||||
.ups-status.onbattery { background: var(--warning-bg); color: var(--warning); border-color: var(--warning); }
|
||||
.ups-status.unknown { background: var(--surface-2); color: var(--text-dim); }
|
||||
.ups-status.offline { background: var(--danger-bg); color: var(--danger); border-color: var(--danger); }
|
||||
|
||||
.badge {
|
||||
background: var(--surface-2);
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
letter-spacing: .5px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.badge[data-badge="runtime"] { background: var(--surface-2); color: var(--accent); }
|
||||
.badge[data-badge="watts"] { background: var(--surface-2); color: var(--info); }
|
||||
.badge[data-badge="headroom"] { background: var(--surface-2); color: var(--warning); }
|
||||
.badge[data-badge="energy"] { background: var(--surface-2); color: var(--success); }
|
||||
|
||||
.ups-status.online {
|
||||
background-color: #155724;
|
||||
color: #d4edda;
|
||||
}
|
||||
/* ------- Battery gauge ------- */
|
||||
.gauge { width: 120px; height: 70px; position: relative; }
|
||||
.gauge svg { width: 100%; height: 100%; }
|
||||
.gauge-bg { fill: none; stroke: var(--surface-3); stroke-width: 10; stroke-linecap: round; }
|
||||
.gauge-fill { fill: none; stroke: var(--accent); stroke-width: 10; stroke-linecap: round; transition: stroke-dasharray .4s ease, stroke .4s; }
|
||||
.gauge-text { fill: var(--text); font-size: 16px; font-weight: 600; dominant-baseline: middle; }
|
||||
.gauge.low .gauge-fill { stroke: var(--warning); }
|
||||
.gauge.critical .gauge-fill { stroke: var(--danger); }
|
||||
.gauge.good .gauge-fill { stroke: var(--success); }
|
||||
|
||||
.ups-status.onbattery {
|
||||
background-color: #856404;
|
||||
color: #fff3cd;
|
||||
}
|
||||
/* ------- Metrics layout ------- */
|
||||
.metrics { font-size: 13px; padding-bottom: 28px; position: relative; }
|
||||
.metric-row { display: flex; justify-content: space-between; margin-bottom: 0.5rem; }
|
||||
.metric-row > div { flex: 1; }
|
||||
.val { font-weight: 600; color: var(--accent); }
|
||||
|
||||
.ups-status.unknown {
|
||||
background-color: #383d41;
|
||||
color: #d6d8db;
|
||||
}
|
||||
|
||||
.charts {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
#ups-cards {
|
||||
/* ------- Tile system ------- */
|
||||
.tile-grid { position: relative; margin-top: 8px; min-height: 140px; }
|
||||
.tile {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 10px;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
min-height: 110px;
|
||||
min-width: 140px;
|
||||
box-sizing: border-box;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
|
||||
canvas.chart {
|
||||
width: 300px;
|
||||
margin-bottom: 0.5rem;
|
||||
.tile:hover { border-color: var(--border-hi); }
|
||||
.tile h4 {
|
||||
margin: 0 0 6px 0;
|
||||
font-size: 10px;
|
||||
letter-spacing: .5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tile .tile-body { flex: 1; display: flex; align-items: center; justify-content: center; }
|
||||
.tile .big-value { font-size: 22px; font-weight: 700; color: var(--accent); }
|
||||
.tile canvas { width: 100% !important; height: 90px !important; }
|
||||
.tile .gauge { margin: auto; }
|
||||
.tile.small { min-height: 80px; }
|
||||
.tile.hidden { display: none; }
|
||||
.tile.dragging { opacity: 0.5; border: 1px dashed var(--accent); }
|
||||
.tile.selected { outline: 2px solid var(--accent); }
|
||||
.tile-controls { position: absolute; top: 4px; right: 4px; display: flex; gap: 4px; }
|
||||
.tile-controls select {
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.tile-resize {
|
||||
position: absolute; right: 4px; bottom: 4px; width: 12px; height: 12px;
|
||||
cursor: nwse-resize; background: rgba(255,255,255,0.06);
|
||||
border: 1px solid var(--border); border-radius: 2px;
|
||||
}
|
||||
.tile-resize:after {
|
||||
content: ''; position: absolute; right: 2px; bottom: 2px;
|
||||
width: 6px; height: 6px;
|
||||
border-right: 2px solid var(--accent); border-bottom: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
/* ------- Card resize handle ------- */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
background: linear-gradient(135deg, #2f3b48 0%, #3e4c5c 60%, #4a5b6e 100%);
|
||||
border: 1px solid #556;
|
||||
width: 16px; height: 16px;
|
||||
right: 4px; bottom: 4px;
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--border-hi);
|
||||
border-radius: 3px;
|
||||
cursor: nwse-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 8px;
|
||||
color: #9fb1c3;
|
||||
color: var(--accent);
|
||||
user-select: none;
|
||||
opacity: 0.75;
|
||||
transition: opacity .15s;
|
||||
transition: opacity .15s, background .15s;
|
||||
z-index: 20;
|
||||
}
|
||||
.resize-handle:hover { opacity: 1; }
|
||||
|
||||
.card.resizing { outline: 1px dashed #4a9eff; }
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #999;
|
||||
width: 100%;
|
||||
.resize-handle::before {
|
||||
content: '';
|
||||
width: 8px; height: 8px;
|
||||
border-right: 2px solid currentColor;
|
||||
border-bottom: 2px solid currentColor;
|
||||
}
|
||||
.resize-handle:hover { opacity: 1; background: var(--accent-dim); }
|
||||
|
||||
.empty-state h3 {
|
||||
color: #eef;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state a {
|
||||
color: #4a9eff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.empty-state a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Mode toggle */
|
||||
.mode-toggle {
|
||||
margin-left: auto;
|
||||
background: #2b3542;
|
||||
color: #cdd6e2;
|
||||
border: 1px solid #3a4756;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
/* ------- Card action buttons ------- */
|
||||
.mode-toggle, .reset-layout-btn, .add-metric-btn {
|
||||
background: var(--surface-2);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
transition: background .15s, color .15s, border-color .15s;
|
||||
}
|
||||
.mode-toggle:hover { background:#364250; }
|
||||
.mode-toggle:hover, .reset-layout-btn:hover, .add-metric-btn:hover {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
border-color: var(--border-hi);
|
||||
}
|
||||
.add-metric-btn { width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; }
|
||||
.reset-layout-btn { width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; }
|
||||
|
||||
/* Add Metric Button */
|
||||
.add-metric-btn { background:#2b3542; color:#9fb1c3; border:1px solid #3a4756; border-radius:4px; cursor:pointer; width:26px; height:26px; font-size:16px; line-height:1; display:flex; align-items:center; justify-content:center; }
|
||||
.add-metric-btn:hover { background:#364250; }
|
||||
/* ------- Events (legacy, used by some views) ------- */
|
||||
.events { margin-top: 12px; }
|
||||
.events-header {
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: 1px;
|
||||
color: var(--text-dim); margin-bottom: 4px; font-weight: 600;
|
||||
}
|
||||
.events-list {
|
||||
list-style: none; padding: 0; margin: 0;
|
||||
max-height: 90px; overflow-y: auto;
|
||||
font-size: 11px; line-height: 1.3;
|
||||
}
|
||||
.events-list li { padding: 3px 0; border-bottom: 1px solid var(--border); color: var(--text-muted); }
|
||||
.events-list li:last-child { border-bottom: none; }
|
||||
|
||||
/* Reset layout button */
|
||||
.reset-layout-btn { background:#2b3542; color:#9fb1c3; border:1px solid #3a4756; border-radius:4px; cursor:pointer; width:28px; height:26px; font-size:14px; display:flex; align-items:center; justify-content:center; }
|
||||
.reset-layout-btn:hover { background:#364250; }
|
||||
|
||||
/* Metric Modal */
|
||||
.metric-modal-backdrop { position:fixed; inset:0; background:rgba(0,0,0,0.55); display:flex; align-items:center; justify-content:center; z-index:1000; }
|
||||
.metric-modal { background:#1d2530; border:1px solid #2c3846; padding:1rem 1.25rem; width:340px; border-radius:8px; box-shadow:0 4px 16px rgba(0,0,0,0.4); }
|
||||
.metric-modal h3 { margin-top:0; margin-bottom:0.75rem; font-size:1rem; color:#eef; }
|
||||
.metric-modal label { font-size:0.7rem; text-transform:uppercase; letter-spacing:1px; opacity:0.75; display:block; margin-top:0.75rem; margin-bottom:0.25rem; }
|
||||
.metric-modal select, .metric-modal input { width:100%; background:#283240; color:#cdd6e2; border:1px solid #364250; border-radius:4px; padding:4px 6px; font-size:0.85rem; }
|
||||
.metric-modal-actions { display:flex; justify-content:flex-end; gap:0.5rem; margin-top:1rem; }
|
||||
.metric-modal button { background:#2b3542; border:1px solid #3a4756; color:#cdd6e2; padding:6px 10px; border-radius:4px; cursor:pointer; font-size:0.75rem; }
|
||||
.metric-modal button.primary { background:#1f5d85; border-color:#2675aa; }
|
||||
.metric-modal button.primary:hover { background:#2675aa; }
|
||||
.metric-modal button:hover { background:#364250; }
|
||||
|
||||
/* Compact mode */
|
||||
.card.compact .charts { display: none; }
|
||||
.card.compact .events { display: none; }
|
||||
.card.compact { min-height: 140px; }
|
||||
.card.compact .metric-row { margin-bottom: 0.25rem; }
|
||||
.card.compact .highline { margin-bottom: 0.4rem; }
|
||||
|
||||
/* Tile system */
|
||||
.tile-grid { position: relative; margin-top: 0.6rem; min-height: 140px; }
|
||||
.tile { background:#212b36; border:1px solid #2c3a48; border-radius:6px; padding:0.5rem 0.6rem; position:absolute; display:flex; flex-direction:column; min-height:110px; min-width:140px; box-sizing:border-box; }
|
||||
.tile h4 { margin:0 0 0.4rem 0; font-size:0.7rem; letter-spacing:1px; text-transform:uppercase; opacity:0.7; font-weight:600; }
|
||||
.tile .tile-body { flex:1; display:flex; align-items:center; justify-content:center; }
|
||||
.tile .big-value { font-size:1.4rem; font-weight:600; color:#4a9eff; }
|
||||
.tile canvas { width:100%!important; height:90px!important; }
|
||||
.tile .gauge { margin:auto; }
|
||||
.tile-controls { position:absolute; top:4px; right:4px; display:flex; gap:4px; }
|
||||
.tile-controls select { background:#2b3542; border:1px solid #364250; color:#cdd6e2; font-size:0.6rem; padding:2px 4px; border-radius:4px; }
|
||||
.tile.small { min-height:80px; }
|
||||
.tile.hidden { display:none; }
|
||||
.tile.dragging { opacity:0.5; border:1px dashed #4a9eff; }
|
||||
.tile.selected { outline:2px solid #4a9eff; }
|
||||
.tile-resize { position:absolute; right:4px; bottom:4px; width:12px; height:12px; cursor:nwse-resize; background:rgba(255,255,255,0.08); border:1px solid #3a4756; border-radius:2px; }
|
||||
.tile-resize:after { content:''; position:absolute; right:2px; bottom:2px; width:6px; height:6px; border-right:2px solid #4a9eff; border-bottom:2px solid #4a9eff; }
|
||||
|
||||
/* Event footer */
|
||||
/* ------- Footer marquee ------- */
|
||||
.ups-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
font-size: 0.65rem;
|
||||
color: #9fb1c3;
|
||||
border-top: 1px solid #2c3a48;
|
||||
padding: 4px 8px;
|
||||
min-height: 20px;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 5px 10px;
|
||||
min-height: 22px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
background: rgba(29, 37, 48, 0.95);
|
||||
border-radius: 0 0 8px 8px;
|
||||
background: var(--surface);
|
||||
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||||
z-index: 10;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ups-footer .event-marquee { display: inline-block; animation: marquee 18s linear infinite; }
|
||||
@keyframes marquee { 0% { transform: translateX(0); } 100% { transform: translateX(-100%); } }
|
||||
|
||||
/* Value tile display */
|
||||
/* ------- Big-value tile ------- */
|
||||
.big-value-container {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
display: flex; align-items: baseline; justify-content: center;
|
||||
gap: 4px; height: 100%; padding: 10px;
|
||||
}
|
||||
.big-value-container .big-value {
|
||||
font-size: 2rem;
|
||||
.big-value-container .big-value { font-size: 2rem; font-weight: 700; color: var(--accent); }
|
||||
.big-value-container .value-unit { font-size: 0.9rem; color: var(--text-dim); font-weight: normal; }
|
||||
|
||||
/* Voltage status (within spec/out of spec) */
|
||||
.voltage-good { color: var(--success) !important; }
|
||||
.voltage-bad { color: var(--danger) !important; }
|
||||
|
||||
/* Legend */
|
||||
.legend-badge {
|
||||
background: var(--surface-2);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
margin: 2px;
|
||||
display: inline-block;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ------- Compact mode ------- */
|
||||
.card.compact .charts, .card.compact .events, .card.compact .tile-grid { display: none; }
|
||||
.card.compact { min-height: 140px; }
|
||||
.card.compact .metric-row { margin-bottom: 0.25rem; }
|
||||
.card.compact .highline { margin-bottom: 0.4rem; }
|
||||
|
||||
/* ------- Metric modal ------- */
|
||||
.metric-modal-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.metric-modal {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 18px 20px;
|
||||
width: 360px;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.metric-modal h3 { margin-top: 0; margin-bottom: 12px; font-size: 15px; }
|
||||
.metric-modal label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
color: var(--text-dim);
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
color: #4a9eff;
|
||||
}
|
||||
.big-value-container .value-unit {
|
||||
font-size: 0.9rem;
|
||||
color: #9fb1c3;
|
||||
font-weight: normal;
|
||||
.metric-modal select, .metric-modal input {
|
||||
width: 100%;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Voltage color coding based on US electrical specs */
|
||||
.voltage-good {
|
||||
color: #22c55e !important; /* Green for voltage in acceptable range (114V-126V) */
|
||||
.metric-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
|
||||
.metric-modal button {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 7px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: background .15s;
|
||||
}
|
||||
.voltage-bad {
|
||||
color: #ef4444 !important; /* Red for voltage out of acceptable range */
|
||||
}
|
||||
|
||||
@keyframes marquee { 0% { transform:translateX(0); } 100% { transform:translateX(-100%); } }
|
||||
.legend-badge { background:#243140; padding:2px 6px; border-radius:10px; font-size:0.6rem; margin:2px; display:inline-block; }
|
||||
|
||||
.card.compact .tile-grid { display:none; }
|
||||
.metric-modal button:hover { background: var(--surface-3); }
|
||||
.metric-modal button.primary { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; }
|
||||
.metric-modal button.primary:hover { background: var(--accent); }
|
||||
@@ -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 {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
constructor() { this.confirmCallback = null; this.init(); }
|
||||
|
||||
init() {
|
||||
this.bindEvents();
|
||||
this.loadUPSList();
|
||||
}
|
||||
init() { this.bindEvents(); this.loadUPSList(); }
|
||||
|
||||
bindEvents() {
|
||||
// Add UPS button
|
||||
document.getElementById('add-ups-btn').addEventListener('click', () => {
|
||||
this.showUPSModal();
|
||||
});
|
||||
|
||||
// Modal close buttons
|
||||
document.getElementById('add-ups-btn').addEventListener('click', () => this.showUPSModal());
|
||||
document.querySelectorAll('.close').forEach(close => {
|
||||
close.addEventListener('click', (e) => {
|
||||
this.closeModal(e.target.closest('.modal'));
|
||||
close.addEventListener('click', (e) => this.closeModal(e.target.closest('.modal')));
|
||||
});
|
||||
});
|
||||
|
||||
// UPS form submission
|
||||
document.getElementById('ups-form').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
this.saveUPS();
|
||||
e.preventDefault(); this.saveUPS();
|
||||
});
|
||||
|
||||
// Test connection button
|
||||
document.getElementById('test-connection-btn').addEventListener('click', () => {
|
||||
this.testConnection();
|
||||
});
|
||||
|
||||
// Cancel button
|
||||
document.getElementById('test-connection-btn').addEventListener('click', () => this.testConnection());
|
||||
document.getElementById('cancel-btn').addEventListener('click', () => {
|
||||
this.closeModal(document.getElementById('ups-modal'));
|
||||
});
|
||||
|
||||
// Confirmation modal buttons
|
||||
document.getElementById('confirm-yes').addEventListener('click', () => {
|
||||
if (this.confirmCallback) {
|
||||
this.confirmCallback();
|
||||
}
|
||||
if (this.confirmCallback) this.confirmCallback();
|
||||
this.closeModal(document.getElementById('confirm-modal'));
|
||||
});
|
||||
|
||||
document.getElementById('confirm-no').addEventListener('click', () => {
|
||||
this.closeModal(document.getElementById('confirm-modal'));
|
||||
});
|
||||
|
||||
// Click outside modal to close
|
||||
window.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('modal')) {
|
||||
this.closeModal(e.target);
|
||||
}
|
||||
if (e.target.classList.contains('modal')) this.closeModal(e.target);
|
||||
});
|
||||
}
|
||||
|
||||
async loadUPSList() {
|
||||
try {
|
||||
const response = await fetch('/api/config/ups');
|
||||
const upsList = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(upsList.detail || 'Failed to load UPS configurations');
|
||||
const resp = await window.apiFetch('/api/config/ups');
|
||||
const list = await resp.json();
|
||||
if (!resp.ok) throw new Error(list.detail || 'Failed to load');
|
||||
this.renderUPSList(list);
|
||||
} catch (err) { this.showToast('Error: ' + err.message, 'error'); }
|
||||
}
|
||||
|
||||
this.renderUPSList(upsList);
|
||||
} catch (error) {
|
||||
this.showToast('Error loading UPS configurations: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
renderUPSList(upsList) {
|
||||
renderUPSList(list) {
|
||||
const container = document.getElementById('ups-list');
|
||||
|
||||
if (upsList.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h3>No UPS Configured</h3>
|
||||
<p>Get started by clicking "Add UPS" above to configure your first UPS monitoring.</p>
|
||||
</div>
|
||||
`;
|
||||
container.textContent = '';
|
||||
if (list.length === 0) {
|
||||
const empty = el('div', { class: 'empty-state' },
|
||||
el('h3', null, 'No UPS Configured'),
|
||||
el('p', null, 'Click "Add UPS" to configure your first UPS.'));
|
||||
container.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = upsList.map(ups => this.renderUPSItem(ups)).join('');
|
||||
|
||||
// Bind action buttons
|
||||
container.querySelectorAll('.edit-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const upsName = e.target.dataset.upsName;
|
||||
this.editUPS(upsName);
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.delete-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const upsName = e.target.dataset.upsName;
|
||||
this.confirmDelete(upsName);
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.test-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const upsName = e.target.dataset.upsName;
|
||||
this.testUPSConnection(upsName);
|
||||
});
|
||||
});
|
||||
list.forEach(ups => container.appendChild(this.renderUPSItem(ups)));
|
||||
}
|
||||
|
||||
renderUPSItem(ups) {
|
||||
const alertsEnabled = [
|
||||
ups.alert_loadpct_high && `Load: ${ups.alert_loadpct_high}%`,
|
||||
ups.alert_bcharge_low && `Battery: ${ups.alert_bcharge_low}%`,
|
||||
ups.alert_on_battery && 'On Battery',
|
||||
ups.alert_runtime_low_minutes && `Runtime: ${ups.alert_runtime_low_minutes}min`
|
||||
].filter(Boolean);
|
||||
|
||||
return `
|
||||
<div class="ups-item">
|
||||
<div class="ups-item-header">
|
||||
<div class="ups-item-title">${ups.name}</div>
|
||||
<div class="ups-item-actions">
|
||||
<button class="btn btn-secondary test-btn" data-ups-name="${ups.name}">Test</button>
|
||||
<button class="btn btn-primary edit-btn" data-ups-name="${ups.name}">Edit</button>
|
||||
<button class="btn btn-danger delete-btn" data-ups-name="${ups.name}">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ups-item-details">
|
||||
<div class="ups-detail">
|
||||
<div class="ups-detail-label">Host</div>
|
||||
<div class="ups-detail-value">${ups.host}:${ups.port}</div>
|
||||
</div>
|
||||
<div class="ups-detail">
|
||||
<div class="ups-detail-label">Polling Interval</div>
|
||||
<div class="ups-detail-value">${ups.interval_seconds}s</div>
|
||||
</div>
|
||||
<div class="ups-detail">
|
||||
<div class="ups-detail-label">Alerts</div>
|
||||
<div class="ups-detail-value">${alertsEnabled.length > 0 ? alertsEnabled.join(', ') : 'None'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const alerts = [];
|
||||
if (ups.alert_loadpct_high) alerts.push('Load: ' + ups.alert_loadpct_high + '%');
|
||||
if (ups.alert_bcharge_low) alerts.push('Battery: ' + ups.alert_bcharge_low + '%');
|
||||
if (ups.alert_on_battery) alerts.push('On Battery');
|
||||
if (ups.alert_runtime_low_minutes) alerts.push('Runtime: ' + ups.alert_runtime_low_minutes + 'min');
|
||||
if (ups.alert_itemp_high) alerts.push('Temp: ' + ups.alert_itemp_high + '°C');
|
||||
const testBtn = el('button', { class: 'btn btn-secondary', onclick: () => this.testUPSConnection(ups.name) }, 'Test');
|
||||
const editBtn = el('button', { class: 'btn btn-primary', onclick: () => this.editUPS(ups.name) }, 'Edit');
|
||||
const delBtn = el('button', { class: 'btn btn-danger', onclick: () => this.confirmDelete(ups.name) }, 'Delete');
|
||||
const item = el('div', { class: 'ups-item' },
|
||||
el('div', { class: 'ups-item-header' },
|
||||
el('div', { class: 'ups-item-title' }, ups.name),
|
||||
el('div', { class: 'ups-item-actions' }, testBtn, editBtn, delBtn)),
|
||||
el('div', { class: 'ups-item-details' },
|
||||
el('div', { class: 'ups-detail' },
|
||||
el('div', { class: 'ups-detail-label' }, 'Host'),
|
||||
el('div', { class: 'ups-detail-value' }, ups.host + ':' + ups.port)),
|
||||
el('div', { class: 'ups-detail' },
|
||||
el('div', { class: 'ups-detail-label' }, 'Polling Interval'),
|
||||
el('div', { class: 'ups-detail-value' }, ups.interval_seconds + 's')),
|
||||
el('div', { class: 'ups-detail' },
|
||||
el('div', { class: 'ups-detail-label' }, 'Alerts'),
|
||||
el('div', { class: 'ups-detail-value' }, alerts.length ? alerts.join(', ') : 'None'))));
|
||||
return item;
|
||||
}
|
||||
|
||||
showUPSModal(ups = null) {
|
||||
const modal = document.getElementById('ups-modal');
|
||||
const title = document.getElementById('modal-title');
|
||||
const form = document.getElementById('ups-form');
|
||||
|
||||
// Reset form
|
||||
form.reset();
|
||||
|
||||
if (ups) {
|
||||
// Edit mode
|
||||
title.textContent = 'Edit UPS';
|
||||
this.populateForm(ups);
|
||||
form.dataset.mode = 'edit';
|
||||
form.dataset.upsName = ups.name;
|
||||
} else {
|
||||
// Add mode
|
||||
title.textContent = 'Add UPS';
|
||||
form.dataset.mode = 'add';
|
||||
delete form.dataset.upsName;
|
||||
}
|
||||
|
||||
modal.style.display = 'block';
|
||||
}
|
||||
|
||||
@@ -179,222 +118,107 @@ class ConfigManager {
|
||||
document.getElementById('ups-interval').value = ups.interval_seconds || 30;
|
||||
document.getElementById('ups-loadpct').value = ups.alert_loadpct_high || '';
|
||||
document.getElementById('ups-bcharge').value = ups.alert_bcharge_low || '';
|
||||
document.getElementById('ups-onbattery').checked = ups.alert_on_battery || false;
|
||||
document.getElementById('ups-onbattery').checked = !!ups.alert_on_battery;
|
||||
document.getElementById('ups-runtime').value = ups.alert_runtime_low_minutes || '';
|
||||
const itemp = document.getElementById('ups-itemp');
|
||||
if (itemp) itemp.value = ups.alert_itemp_high || '';
|
||||
}
|
||||
|
||||
closeModal(modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
closeModal(modal) { modal.style.display = 'none'; }
|
||||
|
||||
async saveUPS() {
|
||||
const form = document.getElementById('ups-form');
|
||||
const formData = new FormData(form);
|
||||
const fd = new FormData(form);
|
||||
const mode = form.dataset.mode;
|
||||
const upsName = form.dataset.upsName;
|
||||
|
||||
const name = form.dataset.upsName;
|
||||
const data = {
|
||||
name: formData.get('name'),
|
||||
host: formData.get('host'),
|
||||
port: parseInt(formData.get('port')) || 3551,
|
||||
interval_seconds: parseInt(formData.get('interval_seconds')) || 30,
|
||||
alert_on_battery: formData.has('alert_on_battery')
|
||||
name: fd.get('name'),
|
||||
host: fd.get('host'),
|
||||
port: parseInt(fd.get('port')) || 3551,
|
||||
interval_seconds: parseInt(fd.get('interval_seconds')) || 30,
|
||||
alert_on_battery: fd.has('alert_on_battery'),
|
||||
};
|
||||
|
||||
// Add optional alert thresholds
|
||||
const loadpct = formData.get('alert_loadpct_high');
|
||||
if (loadpct) data.alert_loadpct_high = parseFloat(loadpct);
|
||||
|
||||
const bcharge = formData.get('alert_bcharge_low');
|
||||
if (bcharge) data.alert_bcharge_low = parseFloat(bcharge);
|
||||
|
||||
const runtime = formData.get('alert_runtime_low_minutes');
|
||||
if (runtime) data.alert_runtime_low_minutes = parseFloat(runtime);
|
||||
|
||||
const num = (k) => { const v = fd.get(k); if (v) data[k] = parseFloat(v); };
|
||||
num('alert_loadpct_high'); num('alert_bcharge_low');
|
||||
num('alert_runtime_low_minutes'); num('alert_itemp_high');
|
||||
try {
|
||||
let response;
|
||||
if (mode === 'edit') {
|
||||
response = await fetch(`/api/config/ups/${upsName}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
} else {
|
||||
response = await fetch('/api/config/ups', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.detail || 'Failed to save UPS configuration');
|
||||
}
|
||||
|
||||
const url = mode === 'edit' ? '/api/config/ups/' + name : '/api/config/ups';
|
||||
const method = mode === 'edit' ? 'PUT' : 'POST';
|
||||
const resp = await window.apiFetch(url, { method, body: JSON.stringify(data) });
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) throw new Error(result.detail || 'Save failed');
|
||||
this.showToast(result.message, 'success');
|
||||
this.closeModal(document.getElementById('ups-modal'));
|
||||
this.loadUPSList();
|
||||
} catch (error) {
|
||||
this.showToast('Error saving UPS: ' + error.message, 'error');
|
||||
}
|
||||
} catch (err) { this.showToast('Error: ' + err.message, 'error'); }
|
||||
}
|
||||
|
||||
async editUPS(upsName) {
|
||||
async editUPS(name) {
|
||||
try {
|
||||
const response = await fetch(`/api/config/ups/${upsName}`);
|
||||
const ups = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(ups.detail || 'Failed to load UPS configuration');
|
||||
}
|
||||
|
||||
const resp = await window.apiFetch('/api/config/ups/' + name);
|
||||
const ups = await resp.json();
|
||||
if (!resp.ok) throw new Error(ups.detail || 'Load failed');
|
||||
this.showUPSModal(ups);
|
||||
} catch (error) {
|
||||
this.showToast('Error loading UPS configuration: ' + error.message, 'error');
|
||||
}
|
||||
} catch (err) { this.showToast('Error: ' + err.message, 'error'); }
|
||||
}
|
||||
|
||||
confirmDelete(upsName) {
|
||||
const modal = document.getElementById('confirm-modal');
|
||||
const message = document.getElementById('confirm-message');
|
||||
|
||||
message.textContent = `Are you sure you want to delete the UPS configuration "${upsName}"? This action cannot be undone.`;
|
||||
|
||||
this.confirmCallback = () => this.deleteUPS(upsName);
|
||||
modal.style.display = 'block';
|
||||
confirmDelete(name) {
|
||||
document.getElementById('confirm-message').textContent =
|
||||
'Delete UPS "' + name + '"? This cannot be undone.';
|
||||
this.confirmCallback = () => this.deleteUPS(name);
|
||||
document.getElementById('confirm-modal').style.display = 'block';
|
||||
}
|
||||
|
||||
async deleteUPS(upsName) {
|
||||
async deleteUPS(name) {
|
||||
try {
|
||||
const response = await fetch(`/api/config/ups/${upsName}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.detail || 'Failed to delete UPS configuration');
|
||||
}
|
||||
|
||||
const resp = await window.apiFetch('/api/config/ups/' + name, { method: 'DELETE' });
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) throw new Error(result.detail || 'Delete failed');
|
||||
this.showToast(result.message, 'success');
|
||||
this.loadUPSList();
|
||||
} catch (error) {
|
||||
this.showToast('Error deleting UPS: ' + error.message, 'error');
|
||||
}
|
||||
} catch (err) { this.showToast('Error: ' + err.message, 'error'); }
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
const form = document.getElementById('ups-form');
|
||||
const formData = new FormData(form);
|
||||
|
||||
const fd = new FormData(form);
|
||||
const data = {
|
||||
name: formData.get('name') || 'test',
|
||||
host: formData.get('host'),
|
||||
port: parseInt(formData.get('port')) || 3551,
|
||||
name: fd.get('name') || 'test',
|
||||
host: fd.get('host'),
|
||||
port: parseInt(fd.get('port')) || 3551,
|
||||
interval_seconds: 30,
|
||||
alert_on_battery: false
|
||||
alert_on_battery: false,
|
||||
};
|
||||
|
||||
if (!data.host) {
|
||||
this.showToast('Host is required for connection test', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const testBtn = document.getElementById('test-connection-btn');
|
||||
const originalText = testBtn.textContent;
|
||||
testBtn.textContent = 'Testing...';
|
||||
testBtn.disabled = true;
|
||||
|
||||
if (!data.host) { this.showToast('Host required', 'error'); return; }
|
||||
const btn = document.getElementById('test-connection-btn');
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = 'Testing...'; btn.disabled = true;
|
||||
try {
|
||||
const response = await fetch('/api/config/ups/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
const resp = await window.apiFetch('/api/config/ups/test', {
|
||||
method: 'POST', body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
let msg;
|
||||
if (result.success) {
|
||||
msg = 'Connection successful';
|
||||
if (result.data && result.data.STATUS) {
|
||||
msg += ` (STATUS=${result.data.STATUS})`;
|
||||
}
|
||||
this.showToast(msg, 'success');
|
||||
} else {
|
||||
if (result.connectivity && !result.connectivity.ok) {
|
||||
msg = 'TCP connectivity failed: ' + (result.connectivity.error || 'unknown error');
|
||||
} else if (result.protocol && !result.protocol.ok) {
|
||||
msg = 'Protocol error after TCP success: ' + (result.protocol.error || 'unknown error');
|
||||
} else {
|
||||
msg = result.message || 'Connection failed';
|
||||
}
|
||||
this.showToast(msg, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showToast('Connection test failed: ' + error.message, 'error');
|
||||
} finally {
|
||||
testBtn.textContent = originalText;
|
||||
testBtn.disabled = false;
|
||||
}
|
||||
const r = await resp.json();
|
||||
this.showToast(r.message || (r.success ? 'OK' : 'Failed'),
|
||||
r.success ? 'success' : 'error');
|
||||
} catch (err) { this.showToast('Test failed: ' + err.message, 'error'); }
|
||||
finally { btn.textContent = orig; btn.disabled = false; }
|
||||
}
|
||||
|
||||
async testUPSConnection(upsName) {
|
||||
const testBtn = document.querySelector(`[data-ups-name="${upsName}"].test-btn`);
|
||||
const originalText = testBtn.textContent;
|
||||
testBtn.textContent = 'Testing...';
|
||||
testBtn.disabled = true;
|
||||
|
||||
async testUPSConnection(name) {
|
||||
try {
|
||||
const response = await fetch(`/api/config/ups/${upsName}/test`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
let msg;
|
||||
if (result.success) {
|
||||
msg = `Connection to ${upsName} successful`;
|
||||
if (result.data && result.data.STATUS) {
|
||||
msg += ` (STATUS=${result.data.STATUS})`;
|
||||
}
|
||||
this.showToast(msg, 'success');
|
||||
} else {
|
||||
if (result.connectivity && !result.connectivity.ok) {
|
||||
msg = `TCP connectivity failed: ${result.connectivity.error}`;
|
||||
} else if (result.protocol && !result.protocol.ok) {
|
||||
msg = `Protocol error: ${result.protocol.error}`;
|
||||
} else {
|
||||
msg = result.message || 'Connection failed';
|
||||
}
|
||||
this.showToast(`Connection to ${upsName} failed: ${msg}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showToast(`Connection test failed: ${error.message}`, 'error');
|
||||
} finally {
|
||||
testBtn.textContent = originalText;
|
||||
testBtn.disabled = false;
|
||||
}
|
||||
const resp = await window.apiFetch('/api/config/ups/' + name + '/test', { method: 'POST' });
|
||||
const r = await resp.json();
|
||||
this.showToast(r.message || (r.success ? 'OK' : 'Failed'),
|
||||
r.success ? 'success' : 'error');
|
||||
} catch (err) { this.showToast('Test failed: ' + err.message, 'error'); }
|
||||
}
|
||||
|
||||
showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toast-container');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
|
||||
const toast = el('div', { class: 'toast ' + type }, message);
|
||||
container.appendChild(toast);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 5000);
|
||||
setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new ConfigManager();
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', () => { new ConfigManager(); });
|
||||
+48
-15
@@ -38,6 +38,25 @@ window.upsDebug = {
|
||||
|
||||
const evtSource = new EventSource('/api/stream');
|
||||
const charts = {};
|
||||
|
||||
// -------- Fleet overview poller --------
|
||||
async function refreshFleetOverview() {
|
||||
try {
|
||||
const r = await fetch('/api/ups/fleet/overview', { credentials: 'same-origin' });
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
const setText = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
|
||||
setText('fleet-total', d.total ?? 0);
|
||||
setText('fleet-online', d.counts?.online ?? 0);
|
||||
setText('fleet-onbatt', d.counts?.on_battery ?? 0);
|
||||
setText('fleet-offline', d.counts?.offline ?? 0);
|
||||
setText('fleet-watts', d.total_watts != null ? Math.round(d.total_watts) : '—');
|
||||
const minRT = d.min_timeleft_minutes;
|
||||
setText('fleet-min-runtime', minRT != null ? `${Math.round(minRT)} m` : '—');
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
refreshFleetOverview();
|
||||
setInterval(refreshFleetOverview, 10000);
|
||||
// Tile registry and state
|
||||
const TILE_REGISTRY = [
|
||||
{ id: 'load_pct', metric: 'LOADPCT', label: 'UPS Load %', short: 'Load', types: ['gauge','line','bar'], defaultType: 'gauge' },
|
||||
@@ -145,7 +164,7 @@ async function loadServerTileConfig(name) {
|
||||
saveToStorage(CARD_SIZE_KEY, savedCardSizes);
|
||||
// Clear server layout
|
||||
try {
|
||||
await fetch(`/api/ups/${name}/ui_tiles`, { method:'DELETE' });
|
||||
await window.apiFetch(`/api/ups/${name}/ui_tiles`, { method:'DELETE' });
|
||||
debugLog(`Cleared server layout for ${name}`);
|
||||
} catch(err) {
|
||||
debugWarn(`Failed to clear server layout for ${name}:`, err);
|
||||
@@ -305,10 +324,11 @@ async function saveServerTileConfig(name) {
|
||||
id: c.id, metric: c.metric, chart: c.chart, source: c.source || 'live'
|
||||
}));
|
||||
const positions = savedTilePos[name] || {};
|
||||
const card_size = savedCardSizes[name] || null;
|
||||
try {
|
||||
await fetch(`/api/ups/${name}/ui_tiles`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ types, order, hidden: hiddenArr, custom, positions })
|
||||
await window.apiFetch(`/api/ups/${name}/ui_tiles`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ types, order, hidden: hiddenArr, custom, positions, card_size })
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
@@ -350,6 +370,10 @@ function ensureUpsCard(name) {
|
||||
card.appendChild(handle);
|
||||
attachResizeBehavior(card, handle);
|
||||
}
|
||||
// Restore saved card size if not already applied inline
|
||||
if (!card.style.width && savedCardSizes[name]) {
|
||||
restoreCardSize(name);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
const container = document.getElementById('ups-cards');
|
||||
@@ -1253,7 +1277,7 @@ function persistTilePositions(name, grid) {
|
||||
|
||||
positions[id] = { left, top, width, height };
|
||||
|
||||
// Calculate required card size
|
||||
// Track required card size (to grow card if tiles overflow)
|
||||
maxX = Math.max(maxX, left + width);
|
||||
maxY = Math.max(maxY, top + height);
|
||||
}
|
||||
@@ -1262,19 +1286,24 @@ function persistTilePositions(name, grid) {
|
||||
savedTilePos[name] = positions;
|
||||
try { localStorage.setItem(TILE_POS_KEY, JSON.stringify(savedTilePos)); } catch(_) {}
|
||||
|
||||
// Auto-adjust card size based on tile positions
|
||||
// Only grow the card if tiles overflow its current bounds. Never shrink
|
||||
// the user's manually-sized card.
|
||||
if (maxX > 0 && maxY > 0) {
|
||||
const cardWidth = Math.max(maxX + 20, 400); // 20px padding
|
||||
const cardHeight = Math.max(maxY + 70, 300); // 70px for header + padding
|
||||
persistCardSize(name, cardWidth, cardHeight);
|
||||
|
||||
// Apply the size immediately
|
||||
const card = document.getElementById(`card-${name}`);
|
||||
if (card) {
|
||||
card.style.width = `${cardWidth}px`;
|
||||
card.style.height = `${cardHeight}px`;
|
||||
grid.style.width = `${cardWidth - 20}px`;
|
||||
grid.style.height = `${cardHeight - 70}px`;
|
||||
const neededW = maxX + 20; // 20px right padding
|
||||
const neededH = maxY + 70; // 70px for header + bottom padding
|
||||
const currentW = card.offsetWidth;
|
||||
const currentH = card.offsetHeight;
|
||||
const newW = Math.max(currentW, neededW);
|
||||
const newH = Math.max(currentH, neededH);
|
||||
if (newW > currentW || newH > currentH) {
|
||||
card.style.width = `${newW}px`;
|
||||
card.style.height = `${newH}px`;
|
||||
grid.style.width = `${newW - 20}px`;
|
||||
grid.style.height = `${newH - 70}px`;
|
||||
persistCardSize(name, newW, newH);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1457,6 +1486,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
savedTileHidden[name] = hiddenSet;
|
||||
savedCustomTiles[name] = cfg.custom || [];
|
||||
savedTilePos[name] = cfg.positions || savedTilePos[name] || {};
|
||||
if (cfg.card_size && cfg.card_size.width && cfg.card_size.height) {
|
||||
savedCardSizes[name] = { width: cfg.card_size.width, height: cfg.card_size.height };
|
||||
saveToStorage(CARD_SIZE_KEY, savedCardSizes);
|
||||
}
|
||||
try { localStorage.setItem(TILE_TYPE_KEY, JSON.stringify(savedTileTypes)); } catch{}
|
||||
try { localStorage.setItem(TILE_ORDER_KEY, JSON.stringify(savedTileOrder)); } catch{}
|
||||
try { localStorage.setItem(TILE_HIDDEN_KEY, JSON.stringify(savedTileHidden)); } catch{}
|
||||
|
||||
@@ -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
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Dict, Any, List
|
||||
import redis
|
||||
import json
|
||||
import os
|
||||
|
||||
REDIS_URL = os.environ.get("REDIS_URL", "redis://redis:6379/0")
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import redis
|
||||
|
||||
from .settings import settings
|
||||
|
||||
RETENTION_SECONDS = 7 * 24 * 3600
|
||||
MAX_SAMPLES_PER_UPS = 7 * 24 * 60 * 2 # assume worst-case 30s interval -> ~20160 entries
|
||||
MAX_SAMPLES_PER_UPS = 7 * 24 * 60 * 2 # worst-case ~30s interval
|
||||
|
||||
_redis: redis.Redis | None = None
|
||||
|
||||
|
||||
def get_redis() -> redis.Redis:
|
||||
global _redis
|
||||
if _redis:
|
||||
return _redis
|
||||
_redis = redis.Redis.from_url(REDIS_URL, decode_responses=True)
|
||||
_redis = redis.Redis.from_url(settings.redis_url, decode_responses=True)
|
||||
return _redis
|
||||
|
||||
SNAP_KEY_PREFIX = "ups:snap:" # latest hash per ups
|
||||
HIST_KEY_PREFIX = "ups:hist:" # time-series list per ups (append JSON)
|
||||
|
||||
async def store_snapshot(ups_name: str, data: Dict[str, Any]):
|
||||
def reset_redis_client() -> None:
|
||||
"""Test hook - clears cached Redis connection."""
|
||||
global _redis
|
||||
_redis = None
|
||||
|
||||
|
||||
SNAP_KEY_PREFIX = "ups:snap:"
|
||||
HIST_KEY_PREFIX = "ups:hist:"
|
||||
|
||||
|
||||
async def store_snapshot(ups_name: str, data: dict[str, Any]):
|
||||
r = get_redis()
|
||||
ts = int(time.time())
|
||||
pipe = r.pipeline()
|
||||
# store latest snapshot (hash)
|
||||
pipe.hset(f"{SNAP_KEY_PREFIX}{ups_name}", mapping={**data, "_ts": ts})
|
||||
# append to history list
|
||||
hist_key = f"{HIST_KEY_PREFIX}{ups_name}"
|
||||
pipe.rpush(hist_key, json.dumps({"ts": ts, "data": data}))
|
||||
pipe.ltrim(hist_key, -MAX_SAMPLES_PER_UPS, -1)
|
||||
# add pruning via async task (length-based + time-based)
|
||||
pipe.execute()
|
||||
|
||||
async def get_latest(ups_name: str) -> Dict[str, Any] | None:
|
||||
|
||||
async def get_latest(ups_name: str) -> dict[str, Any] | None:
|
||||
r = get_redis()
|
||||
h = r.hgetall(f"{SNAP_KEY_PREFIX}{ups_name}")
|
||||
return h or None
|
||||
|
||||
async def get_history(ups_name: str, since_seconds: int = RETENTION_SECONDS) -> List[Dict[str, Any]]:
|
||||
|
||||
async def get_history(
|
||||
ups_name: str, since_seconds: int = RETENTION_SECONDS
|
||||
) -> list[dict[str, Any]]:
|
||||
r = get_redis()
|
||||
key = f"{HIST_KEY_PREFIX}{ups_name}"
|
||||
raw = r.lrange(key, 0, -1)
|
||||
now = int(time.time())
|
||||
out: List[Dict[str, Any]] = []
|
||||
out: list[dict[str, Any]] = []
|
||||
for item in raw:
|
||||
try:
|
||||
obj = json.loads(item)
|
||||
@@ -55,12 +66,12 @@ async def get_history(ups_name: str, since_seconds: int = RETENTION_SECONDS) ->
|
||||
out.append(obj)
|
||||
return out
|
||||
|
||||
|
||||
async def prune_old():
|
||||
r = get_redis()
|
||||
now = int(time.time())
|
||||
cutoff = now - RETENTION_SECONDS
|
||||
for key in r.scan_iter(f"{HIST_KEY_PREFIX}*"):
|
||||
# prune from left while older than cutoff
|
||||
while True:
|
||||
item = r.lindex(key, 0)
|
||||
if not item:
|
||||
|
||||
@@ -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?v=3"></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?v=4" />
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=4" />
|
||||
{% 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?v=4"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
+22
-46
@@ -1,27 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>UPS Configuration</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css" />
|
||||
<link rel="stylesheet" href="/static/css/config.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
{% extends "base.html" %}
|
||||
{% block title %}UPS Configuration{% endblock %}
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/css/config.css?v=3" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>UPS Configuration</h1>
|
||||
<nav>
|
||||
<a href="/">← Back to Dashboard</a>
|
||||
<button id="add-ups-btn" class="btn btn-primary">Add UPS</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<div id="ups-list" class="ups-list">
|
||||
<!-- UPS configurations will be loaded here -->
|
||||
</div>
|
||||
<div id="ups-list" class="ups-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit UPS Modal -->
|
||||
<div id="ups-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
@@ -31,55 +22,42 @@
|
||||
<form id="ups-form">
|
||||
<div class="form-group">
|
||||
<label for="ups-name">Name *</label>
|
||||
<input type="text" id="ups-name" name="name" required>
|
||||
<small>Friendly name for the UPS</small>
|
||||
<input type="text" id="ups-name" name="name" required pattern="[A-Za-z0-9_.\- ]{1,32}" maxlength="32">
|
||||
<small>1-32 chars (letters, digits, space, dot, underscore, dash)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ups-host">Host/IP *</label>
|
||||
<input type="text" id="ups-host" name="host" required>
|
||||
<small>IP address or hostname of the apcupsd server</small>
|
||||
<small>IP or hostname of the apcupsd server</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ups-port">Port</label>
|
||||
<input type="number" id="ups-port" name="port" value="3551">
|
||||
<small>apcupsd NIS port (default: 3551)</small>
|
||||
<input type="number" id="ups-port" name="port" value="3551" min="1" max="65535">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ups-interval">Polling Interval (seconds)</label>
|
||||
<input type="number" id="ups-interval" name="interval_seconds" value="30" min="1">
|
||||
<small>How often to poll the UPS for data</small>
|
||||
<input type="number" id="ups-interval" name="interval_seconds" value="30" min="5" max="3600">
|
||||
</div>
|
||||
|
||||
<h3>Alert Thresholds</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ups-loadpct">High Load Alert (%)</label>
|
||||
<input type="number" id="ups-loadpct" name="alert_loadpct_high" min="0" max="100" step="0.1">
|
||||
<small>Trigger alert when load percentage reaches this value</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ups-bcharge">Low Battery Alert (%)</label>
|
||||
<input type="number" id="ups-bcharge" name="alert_bcharge_low" min="0" max="100" step="0.1">
|
||||
<small>Trigger alert when battery charge drops to this value</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="ups-onbattery" name="alert_on_battery">
|
||||
Alert when on battery power
|
||||
</label>
|
||||
<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-group">
|
||||
<label for="ups-itemp">High Temperature Alert (°C)</label>
|
||||
<input type="number" id="ups-itemp" name="alert_itemp_high" min="0" max="120" step="0.5">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" id="test-connection-btn" class="btn btn-secondary">Test Connection</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
@@ -89,7 +67,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<div id="confirm-modal" class="modal">
|
||||
<div class="modal-content small">
|
||||
<div class="modal-header">
|
||||
@@ -104,9 +81,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast notifications -->
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script src="/static/js/config.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/config.js?v=3"></script>
|
||||
{% endblock %}
|
||||
@@ -1,25 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>UPS Dashboard</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css" />
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard — UPS{% endblock %}
|
||||
{% block head_extra %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>APC UPS Dashboard</h1>
|
||||
<nav>
|
||||
<a href="/config" class="config-link">⚙️ Configuration</a>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<section class="fleet-overview" id="fleet-overview">
|
||||
<div class="fleet-card">
|
||||
<div class="fleet-stat">
|
||||
<span class="fleet-stat-label">Total UPS</span>
|
||||
<span class="fleet-stat-value" id="fleet-total">-</span>
|
||||
</div>
|
||||
<div class="fleet-stat"><span class="fleet-stat-label">Online</span><span class="fleet-stat-value online" id="fleet-online">-</span></div>
|
||||
<div class="fleet-stat"><span class="fleet-stat-label">On Battery</span><span class="fleet-stat-value onbatt" id="fleet-onbatt">-</span></div>
|
||||
<div class="fleet-stat"><span class="fleet-stat-label">Offline</span><span class="fleet-stat-value offline" id="fleet-offline">-</span></div>
|
||||
<div class="fleet-stat"><span class="fleet-stat-label">Total Load (W)</span><span class="fleet-stat-value" id="fleet-watts">-</span></div>
|
||||
<div class="fleet-stat"><span class="fleet-stat-label">Min Runtime</span><span class="fleet-stat-value" id="fleet-min-runtime">-</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="ups-cards" data-ui='{{ ui_cfg | tojson }}'>
|
||||
{% for ups in ups_list %}
|
||||
<div class="card" id="card-{{ ups.name }}">
|
||||
<div class="card-header">
|
||||
<h2>{{ ups.name }}</h2>
|
||||
<h2>
|
||||
<span class="state-dot" data-state-dot></span>
|
||||
{{ ups.name }}
|
||||
</h2>
|
||||
<div class="ups-connection" data-field-conn>{{ ups.host }}:{{ ups.port }}</div>
|
||||
<button class="add-metric-btn" data-add-metric title="Add metric tile">+</button>
|
||||
<button class="reset-layout-btn" data-reset-layout title="Reset tile layout">⟳</button>
|
||||
</div>
|
||||
<div class="metrics">
|
||||
@@ -41,6 +48,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<script src="/static/js/dashboard.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/dashboard.js?v=5"></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?v=3"></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,120 @@
|
||||
{% 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" class="settings-form">
|
||||
<div class="toggle-grid">
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-label">
|
||||
<span class="toggle-title">Show events tile</span>
|
||||
<span class="toggle-help">Display the recent-events marquee on each UPS card</span>
|
||||
</div>
|
||||
<label class="switch"><input type="checkbox" name="show_events"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-label">
|
||||
<span class="toggle-title">Show energy</span>
|
||||
<span class="toggle-help">Show today's kWh and estimated cost on cards</span>
|
||||
</div>
|
||||
<label class="switch"><input type="checkbox" name="show_energy"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-label">
|
||||
<span class="toggle-title">Colorize badges</span>
|
||||
<span class="toggle-help">Color-code runtime/watts/headroom pills</span>
|
||||
</div>
|
||||
<label class="switch"><input type="checkbox" name="color_badges"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-label">
|
||||
<span class="toggle-title">Transfer-burst alerts</span>
|
||||
<span class="toggle-help">Alert when ≥3 ONBATT events occur within an hour</span>
|
||||
</div>
|
||||
<label class="switch"><input type="checkbox" name="enable_transfer_burst_alert"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-label">
|
||||
<span class="toggle-title">Voltage-deviation alerts</span>
|
||||
<span class="toggle-help">Alert when line voltage deviates >8% from nominal</span>
|
||||
</div>
|
||||
<label class="switch"><input type="checkbox" name="enable_voltage_deviation_alert"><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label class="field-label" for="energy-cost">Energy cost per kWh ($)</label>
|
||||
<input class="field-input" id="energy-cost" type="number" step="0.001" min="0" name="energy_cost_per_kwh">
|
||||
</div>
|
||||
<div class="form-bar">
|
||||
<button type="submit" class="btn btn-primary">Save UI Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>SMTP / Email Alerts</h2>
|
||||
<form id="smtp-form" class="settings-form">
|
||||
<div class="field-grid">
|
||||
<div class="field-row">
|
||||
<label class="field-label" for="smtp-host">Host *</label>
|
||||
<input class="field-input" id="smtp-host" type="text" name="host" required>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label class="field-label" for="smtp-port">Port *</label>
|
||||
<input class="field-input" id="smtp-port" type="number" name="port" min="1" max="65535" required>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label class="field-label" for="smtp-user">Username</label>
|
||||
<input class="field-input" id="smtp-user" type="text" name="username">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label class="field-label" for="smtp-from">From address</label>
|
||||
<input class="field-input" id="smtp-from" type="email" name="from_addr">
|
||||
</div>
|
||||
<div class="field-row wide">
|
||||
<label class="field-label" for="smtp-to">To (comma-separated)</label>
|
||||
<input class="field-input" id="smtp-to" type="text" name="to_addrs">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label class="field-label" for="smtp-subj">Subject prefix</label>
|
||||
<input class="field-input" id="smtp-subj" type="text" name="subject_prefix" value="[UPS]">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label class="field-label" for="smtp-sh-start">Silent hours start</label>
|
||||
<input class="field-input" id="smtp-sh-start" type="number" name="silent_hours_start" min="0" max="23" placeholder="0-23">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label class="field-label" for="smtp-sh-end">Silent hours end</label>
|
||||
<input class="field-input" id="smtp-sh-end" type="number" name="silent_hours_end" min="0" max="23" placeholder="0-23">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label class="field-label" for="smtp-daily">Daily summary hour</label>
|
||||
<input class="field-input" id="smtp-daily" type="number" name="daily_summary_hour" min="0" max="23" placeholder="0-23">
|
||||
</div>
|
||||
</div>
|
||||
<div class="toggle-grid compact">
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-label"><span class="toggle-title">Use STARTTLS</span></div>
|
||||
<label class="switch"><input type="checkbox" name="use_tls"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-label"><span class="toggle-title">Use SSL</span></div>
|
||||
<label class="switch"><input type="checkbox" name="use_ssl"><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted">Password is read from the <code>SMTP_PASSWORD</code> environment variable and never stored in Redis.</p>
|
||||
<div class="form-bar">
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/settings.js?v=4"></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
|
||||
ports:
|
||||
- "10280:8000"
|
||||
env_file: .env
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
depends_on:
|
||||
- redis
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8000/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: redis
|
||||
restart: always
|
||||
# Enable persistence so UPS configuration stored in Redis survives restarts.
|
||||
# Using both RDB snapshots (default schedule) and AOF for durability.
|
||||
# Uncomment to require password (must also set REDIS_URL=redis://:PASS@redis:6379/0):
|
||||
# command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec", "--requirepass", "${REDIS_PASSWORD}"]
|
||||
command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec"]
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
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]
|
||||
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
|
||||
redis==5.0.7
|
||||
pydantic==2.9.2
|
||||
pydantic-settings==2.5.2
|
||||
jinja2==3.1.4
|
||||
python-multipart==0.0.9
|
||||
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-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,120 @@
|
||||
"""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="letters, digits"):
|
||||
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_accept_name_with_space_and_dot():
|
||||
u = UPSConfig(name="APC UPS", host="192.168.1.10")
|
||||
assert u.name == "APC UPS"
|
||||
u2 = UPSConfig(name="Server.Room.UPS", host="192.168.1.10")
|
||||
assert u2.name == "Server.Room.UPS"
|
||||
|
||||
|
||||
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