Adding the Initial WIP
This commit is contained in:
commit
326479c338
25 files changed
+6629
No files matched your search
+53
@@ -0,0 +1,53 @@
|
|||||||
|
# Python
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
apcupsd \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
ENV REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
SMTP fields (optional): host, port, username, password (or env `SMTP_PASSWORD`), use_tls, use_ssl, from_addr, to_addrs[], subject_prefix.
|
||||||
|
|
||||||
|
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
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
Visit http://localhost:8000
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
docker volume rm apcupsd-client_redis-data # volume name may be prefixed by folder/project
|
||||||
|
```
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec redis ls -lh /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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Package init
|
||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
from .config import load_config, SMTPConfig, UPSConfig
|
||||||
|
from .storage import get_redis
|
||||||
|
|
||||||
|
ALERT_COOLDOWN_SECONDS = 1800 # 30 minutes per distinct alert per UPS
|
||||||
|
REDIS_ALERT_KEY_PREFIX = "ups:alert:last:"
|
||||||
|
|
||||||
|
STATUS_ON_BATTERY_KEYWORDS = {"ONBATT", "ON BATTERY"}
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float(val) -> float | None:
|
||||||
|
try:
|
||||||
|
return float(val)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_leading_number(s: str) -> float | None:
|
||||||
|
try:
|
||||||
|
part = s.strip().split()[0]
|
||||||
|
return float(part)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cooldown_key(ups_name: str, msg: str) -> str:
|
||||||
|
return f"{REDIS_ALERT_KEY_PREFIX}{ups_name}:{hash(msg)}"
|
||||||
|
|
||||||
|
|
||||||
|
def send_alert_email(smtp_cfg: SMTPConfig, ups_name: str, messages: List[str]):
|
||||||
|
password = smtp_cfg.password or os.environ.get('SMTP_PASSWORD')
|
||||||
|
if not smtp_cfg.to_addrs:
|
||||||
|
return
|
||||||
|
subject = f"{smtp_cfg.subject_prefix} {ups_name} alert"
|
||||||
|
body = "\n".join(messages)
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg['Subject'] = subject
|
||||||
|
msg['From'] = smtp_cfg.from_addr or (smtp_cfg.username or 'ups@example')
|
||||||
|
msg['To'] = ", ".join(smtp_cfg.to_addrs)
|
||||||
|
msg.set_content(body)
|
||||||
|
|
||||||
|
if smtp_cfg.use_ssl:
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
with smtplib.SMTP_SSL(
|
||||||
|
smtp_cfg.host, smtp_cfg.port, context=context, timeout=30
|
||||||
|
) as server:
|
||||||
|
if smtp_cfg.username and password:
|
||||||
|
server.login(smtp_cfg.username, password)
|
||||||
|
server.send_message(msg)
|
||||||
|
else:
|
||||||
|
with smtplib.SMTP(smtp_cfg.host, smtp_cfg.port, timeout=30) as server:
|
||||||
|
if smtp_cfg.use_tls:
|
||||||
|
server.starttls(context=ssl.create_default_context())
|
||||||
|
if smtp_cfg.username and password:
|
||||||
|
server.login(smtp_cfg.username, password)
|
||||||
|
server.send_message(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def process_alerts(ups_cfg: UPSConfig, snapshot: Dict[str, Any]):
|
||||||
|
cfg = load_config()
|
||||||
|
if not cfg.smtp:
|
||||||
|
return
|
||||||
|
msgs = evaluate_alerts(ups_cfg, snapshot)
|
||||||
|
if not msgs:
|
||||||
|
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
|
||||||
|
pipe.execute()
|
||||||
|
send_alert_email(cfg.smtp, ups_cfg.name, to_send)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
APCACCESS_BIN = shutil.which('apcaccess') or 'apcaccess'
|
||||||
|
|
||||||
|
|
||||||
|
class APCStatusError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
APCACCESS_BIN,
|
||||||
|
'-h', f'{host}:{port}',
|
||||||
|
'status',
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
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] = {}
|
||||||
|
for line in text.splitlines():
|
||||||
|
if ':' not in line:
|
||||||
|
continue
|
||||||
|
k, v = line.split(':', 1)
|
||||||
|
key = k.strip()
|
||||||
|
val = v.strip()
|
||||||
|
data[key] = val
|
||||||
|
# Normalize expected fields/aliases
|
||||||
|
if 'UPSNAME' not in data and 'NAME' in data:
|
||||||
|
data['UPSNAME'] = data['NAME']
|
||||||
|
if 'MODEL' in data:
|
||||||
|
data['MODEL_NAME'] = data['MODEL']
|
||||||
|
return data
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
alert_bcharge_low: Optional[float] = Field(
|
||||||
|
None, 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
default_factory=list, description="Recipient list"
|
||||||
|
)
|
||||||
|
subject_prefix: str = Field("[UPS]", description="Subject prefix")
|
||||||
|
|
||||||
|
|
||||||
|
class AppConfig(BaseModel):
|
||||||
|
ups: List[UPSConfig]
|
||||||
|
smtp: Optional[SMTPConfig] = None
|
||||||
|
# UI feature flags (optional; default values used if missing)
|
||||||
|
# Added for dashboard toggleable features
|
||||||
|
class UIConfig(BaseModel):
|
||||||
|
show_events: bool = True
|
||||||
|
show_energy: bool = False
|
||||||
|
color_badges: bool = True
|
||||||
|
show_headroom: bool = True
|
||||||
|
show_watts: bool = True
|
||||||
|
show_runtime: bool = True
|
||||||
|
allow_resize: bool = True
|
||||||
|
enable_transfer_burst_alert: bool = False
|
||||||
|
enable_voltage_deviation_alert: bool = False
|
||||||
|
|
||||||
|
ui: UIConfig = UIConfig()
|
||||||
|
|
||||||
|
|
||||||
|
_cached: AppConfig | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path = CONFIG_PATH) -> AppConfig:
|
||||||
|
# Use redis store (lazy import to avoid circular)
|
||||||
|
from .config_store import load_config_redis
|
||||||
|
global _cached
|
||||||
|
if _cached:
|
||||||
|
return _cached
|
||||||
|
cfg = load_config_redis()
|
||||||
|
_cached = cfg
|
||||||
|
return cfg
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
|
||||||
|
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_store import load_config_redis, save_config_redis
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Incremented every time configuration is modified so
|
||||||
|
# SSE clients can detect changes
|
||||||
|
_config_version: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_config_version() -> int:
|
||||||
|
return _config_version
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigWriteError(Exception): # retained for API compatibility
|
||||||
|
"""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"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigManager:
|
||||||
|
def __init__(self):
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def load_config(self) -> AppConfig:
|
||||||
|
async with self._lock:
|
||||||
|
return load_config_redis()
|
||||||
|
|
||||||
|
async def save_config(self, config: AppConfig) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
save_config_redis(config)
|
||||||
|
logger.info("Configuration saved to Redis")
|
||||||
|
# Invalidate cached global config so subsequent
|
||||||
|
# load_config() calls see changes
|
||||||
|
try:
|
||||||
|
from . import config as config_module
|
||||||
|
config_module._cached = None
|
||||||
|
except Exception: # pragma: no cover - defensive
|
||||||
|
logger.debug(
|
||||||
|
"Failed to invalidate config cache", exc_info=True
|
||||||
|
)
|
||||||
|
# bump config version
|
||||||
|
global _config_version
|
||||||
|
_config_version += 1
|
||||||
|
|
||||||
|
async def get_ups_list(self) -> List[UPSConfig]:
|
||||||
|
"""Get list of all UPS configurations"""
|
||||||
|
config = await self.load_config()
|
||||||
|
return config.ups
|
||||||
|
|
||||||
|
async def get_ups(self, name: str) -> Optional[UPSConfig]:
|
||||||
|
"""Get UPS configuration by name"""
|
||||||
|
config = await self.load_config()
|
||||||
|
for ups in config.ups:
|
||||||
|
if ups.name == name:
|
||||||
|
return ups
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def add_ups(self, ups_config: UPSConfig) -> bool:
|
||||||
|
"""Add new UPS configuration"""
|
||||||
|
config = await self.load_config()
|
||||||
|
|
||||||
|
# Check if UPS with same name already exists
|
||||||
|
if any(ups.name == ups_config.name for ups in config.ups):
|
||||||
|
raise ValueError(
|
||||||
|
f"UPS with name '{ups_config.name}' already exists"
|
||||||
|
)
|
||||||
|
|
||||||
|
config.ups.append(ups_config)
|
||||||
|
await self.save_config(config)
|
||||||
|
|
||||||
|
# No file cache now
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def update_ups(self, name: str, updates: UPSConfigUpdate) -> bool:
|
||||||
|
"""Update existing UPS configuration"""
|
||||||
|
config = await self.load_config()
|
||||||
|
|
||||||
|
ups_index = None
|
||||||
|
for i, ups in enumerate(config.ups):
|
||||||
|
if ups.name == name:
|
||||||
|
ups_index = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if ups_index is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Apply updates
|
||||||
|
ups_dict = config.ups[ups_index].model_dump()
|
||||||
|
update_dict = updates.model_dump(exclude_none=True)
|
||||||
|
ups_dict.update(update_dict)
|
||||||
|
|
||||||
|
# Validate updated configuration
|
||||||
|
try:
|
||||||
|
updated_ups = UPSConfig(**ups_dict)
|
||||||
|
except ValidationError as e:
|
||||||
|
raise ValueError(f"Invalid configuration: {e}")
|
||||||
|
|
||||||
|
config.ups[ups_index] = updated_ups
|
||||||
|
await self.save_config(config)
|
||||||
|
|
||||||
|
# No file cache now
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def delete_ups(self, name: str) -> bool:
|
||||||
|
"""Delete UPS configuration"""
|
||||||
|
config = await self.load_config()
|
||||||
|
|
||||||
|
original_count = len(config.ups)
|
||||||
|
config.ups = [ups for ups in config.ups if ups.name != name]
|
||||||
|
|
||||||
|
if len(config.ups) == original_count:
|
||||||
|
return False # UPS not found
|
||||||
|
|
||||||
|
await self.save_config(config)
|
||||||
|
|
||||||
|
# No file cache now
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_smtp_config(self) -> Optional[SMTPConfig]:
|
||||||
|
"""Get SMTP configuration"""
|
||||||
|
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 validate_ups_connection(
|
||||||
|
self, ups_config: UPSConfig, timeout: float = 3.0
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Port-only connectivity test (no protocol / CLI call)."""
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
result["message"] = "TCP port reachable"
|
||||||
|
else:
|
||||||
|
result["message"] = result["message"] or "TCP port unreachable"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
config_manager = ConfigManager()
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Redis-backed configuration storage replacing YAML file.
|
||||||
|
|
||||||
|
Schema:
|
||||||
|
Key ups:config:json -> JSON object: {"ups": [...], "smtp": {...}|null}
|
||||||
|
|
||||||
|
Migration:
|
||||||
|
On first load if redis key missing and legacy YAML present, import it.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
REDIS_CONFIG_KEY = "ups:config:json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_legacy_yaml(path: Path) -> Optional[AppConfig]:
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with path.open() as f:
|
||||||
|
raw = yaml.safe_load(f) or {}
|
||||||
|
return AppConfig(**raw)
|
||||||
|
except Exception as e: # pragma: no cover
|
||||||
|
logger.warning("Failed to import legacy YAML config: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_config_redis() -> AppConfig:
|
||||||
|
r = get_redis()
|
||||||
|
raw = r.get(REDIS_CONFIG_KEY)
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def save_config_redis(cfg: AppConfig) -> None:
|
||||||
|
r = get_redis()
|
||||||
|
r.set(REDIS_CONFIG_KEY, json.dumps(cfg.model_dump(exclude_none=True)))
|
||||||
+472
@@ -0,0 +1,472 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from fastapi import FastAPI, Request, HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
import orjson
|
||||||
|
from .config import load_config, UPSConfig
|
||||||
|
from .storage import get_latest, get_history
|
||||||
|
from .poller import poll_loop
|
||||||
|
from .config_manager import (
|
||||||
|
config_manager,
|
||||||
|
UPSConfigUpdate,
|
||||||
|
ConfigWriteError,
|
||||||
|
get_config_version,
|
||||||
|
)
|
||||||
|
from .storage import get_redis
|
||||||
|
from .apc_cli import fetch_status, APCStatusError
|
||||||
|
|
||||||
|
app = FastAPI(title="APC UPS Dashboard")
|
||||||
|
app.mount('/static', StaticFiles(directory='app/static'), name='static')
|
||||||
|
templates = Jinja2Templates(directory='app/templates')
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup():
|
||||||
|
asyncio.create_task(poll_loop())
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/', response_class=HTMLResponse)
|
||||||
|
async def dashboard(request: Request):
|
||||||
|
cfg = load_config()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
'dashboard.html', {
|
||||||
|
"request": request,
|
||||||
|
"ups_list": cfg.ups,
|
||||||
|
"ui_cfg": cfg.ui.model_dump(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/config', response_class=HTMLResponse)
|
||||||
|
async def config_page(request: Request):
|
||||||
|
return templates.TemplateResponse('config.html', {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/api/ups')
|
||||||
|
async def list_ups():
|
||||||
|
cfg = load_config()
|
||||||
|
return [{"name": u.name, "host": u.host, "port": u.port} for u in cfg.ups]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/api/ups/{ups_name}')
|
||||||
|
async def ups_status(ups_name: str):
|
||||||
|
snap = await get_latest(ups_name)
|
||||||
|
return snap or {"error": "not found"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/api/ups/{ups_name}/history')
|
||||||
|
async def ups_history(ups_name: str):
|
||||||
|
hist = await get_history(ups_name)
|
||||||
|
return hist
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/api/ups/{ups_name}/metric/{metric}')
|
||||||
|
async def metric_history(ups_name: str, metric: str, limit: int = 120):
|
||||||
|
"""Return recent numeric history samples for a single metric.
|
||||||
|
|
||||||
|
Limit capped at 500 to avoid large payloads.
|
||||||
|
"""
|
||||||
|
limit = max(1, min(limit, 500))
|
||||||
|
all_hist = await get_history(ups_name)
|
||||||
|
# Use only most recent entries
|
||||||
|
recent = all_hist[-limit:]
|
||||||
|
out = []
|
||||||
|
for item in recent:
|
||||||
|
data = item.get('data', {})
|
||||||
|
raw_val = data.get(metric)
|
||||||
|
if raw_val is None:
|
||||||
|
continue
|
||||||
|
# Extract leading number
|
||||||
|
try:
|
||||||
|
val = float(str(raw_val).split()[0])
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
out.append({'ts': item.get('ts'), 'value': val})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/api/ups/{ups_name}/events')
|
||||||
|
async def ups_events(ups_name: str):
|
||||||
|
"""Return recent status/transfer events for a UPS."""
|
||||||
|
r = get_redis()
|
||||||
|
key = f"ups:event:list:{ups_name}"
|
||||||
|
raw = r.lrange(key, 0, 99)
|
||||||
|
parsed = []
|
||||||
|
for item in raw:
|
||||||
|
if '|' in item:
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@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}
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
)
|
||||||
|
await config_manager.save_config(new_cfg)
|
||||||
|
return {"message": "UI config updated", "ui": ui_dict}
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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"}
|
||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
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
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_ACTIVE_TASKS: dict[str, asyncio.Task] = {}
|
||||||
|
_RELOADER_LOCK = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
async def _poll_one(ups):
|
||||||
|
r = get_redis()
|
||||||
|
minute_bucket_key = f"ups:watts:minute:last:{ups.name}"
|
||||||
|
series_key = f"ups:watts:permin:{ups.name}"
|
||||||
|
while True:
|
||||||
|
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:
|
||||||
|
loadpct = 0.0
|
||||||
|
nompower = None
|
||||||
|
try:
|
||||||
|
nompower = float(str(data.get('NOMPOWER', '')).split()[0])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if nompower and loadpct >= 0:
|
||||||
|
watts = nompower * loadpct / 100.0
|
||||||
|
data['DERIVED_WATTS'] = f"{watts:.0f}" # integer string
|
||||||
|
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)
|
||||||
|
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}"
|
||||||
|
max_events = 100
|
||||||
|
status_now = str(data.get('STATUS', '')).upper()
|
||||||
|
prev_status = r.get(status_key)
|
||||||
|
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}")
|
||||||
|
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
|
||||||
|
):
|
||||||
|
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
|
||||||
|
r.ltrim(series_key, 0, 1439)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
r.hset(
|
||||||
|
minute_bucket_key,
|
||||||
|
mapping={
|
||||||
|
'minute': minute,
|
||||||
|
'sum': watts,
|
||||||
|
'count': 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
r.expire(minute_bucket_key, 26 * 3600)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
new_sum = float(mb.get('sum', '0')) + watts
|
||||||
|
new_count = int(mb.get('count', '0')) + 1
|
||||||
|
r.hset(
|
||||||
|
minute_bucket_key,
|
||||||
|
mapping={
|
||||||
|
'minute': minute,
|
||||||
|
'sum': new_sum,
|
||||||
|
'count': new_count,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await store_snapshot(ups.name, data)
|
||||||
|
process_alerts(ups, data)
|
||||||
|
except Exception as 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)
|
||||||
|
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
|
||||||
|
for name in list(_ACTIVE_TASKS.keys()):
|
||||||
|
if name not in current_names:
|
||||||
|
_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 poll_loop():
|
||||||
|
# initial reconcile
|
||||||
|
await _reconcile_tasks()
|
||||||
|
|
||||||
|
async def prune_loop():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await prune_old()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Prune error: %s", e)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
for u in cfg.ups
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if fingerprint != last_fingerprint:
|
||||||
|
await _reconcile_tasks()
|
||||||
|
last_fingerprint = fingerprint
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Config watch error: %s", e)
|
||||||
|
await asyncio.sleep(15)
|
||||||
|
|
||||||
|
await asyncio.gather(
|
||||||
|
prune_loop(),
|
||||||
|
config_watch_loop(),
|
||||||
|
*(_ACTIVE_TASKS.values()),
|
||||||
|
)
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
/* Configuration page styles */
|
||||||
|
body {
|
||||||
|
background: #10151b;
|
||||||
|
color: #eef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-item {
|
||||||
|
background: #1d2530;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ups-item-details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ups-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ups-detail-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ups-detail-value {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #eef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: #1d2530;
|
||||||
|
margin: 5% auto;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #999;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
color: #eef;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form styles */
|
||||||
|
form {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #eef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="text"],
|
||||||
|
.form-group input[type="number"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: #2d3748;
|
||||||
|
color: #eef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="text"]:focus,
|
||||||
|
.form-group input[type="number"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4a9eff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive design */
|
||||||
|
@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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #10151b;
|
||||||
|
color: #eef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
background: #1d2530;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin: 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 260px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||||
|
position: relative;
|
||||||
|
resize: both; /* fallback native resize for browsers that support */
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highline {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ups-status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ups-status.online {
|
||||||
|
background-color: #155724;
|
||||||
|
color: #d4edda;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ups-status.onbattery {
|
||||||
|
background-color: #856404;
|
||||||
|
color: #fff3cd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ups-status.unknown {
|
||||||
|
background-color: #383d41;
|
||||||
|
color: #d6d8db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ups-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.chart {
|
||||||
|
width: 300px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 8px;
|
||||||
|
color: #9fb1c3;
|
||||||
|
user-select: none;
|
||||||
|
opacity: 0.75;
|
||||||
|
transition: opacity .15s;
|
||||||
|
}
|
||||||
|
.resize-handle:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.card.resizing { outline: 1px dashed #4a9eff; }
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #999;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
.mode-toggle:hover { background:#364250; }
|
||||||
|
|
||||||
|
/* 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; }
|
||||||
|
|
||||||
|
/* 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 */
|
||||||
|
.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;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(29, 37, 48, 0.95);
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
z-index: 10;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.ups-footer .event-marquee { display: inline-block; animation: marquee 18s linear infinite; }
|
||||||
|
|
||||||
|
/* Value tile display */
|
||||||
|
.big-value-container {
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.big-value-container .big-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4a9eff;
|
||||||
|
}
|
||||||
|
.big-value-container .value-unit {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #9fb1c3;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Voltage color coding based on US electrical specs */
|
||||||
|
.voltage-good {
|
||||||
|
color: #22c55e !important; /* Green for voltage in acceptable range (114V-126V) */
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
// Configuration management JavaScript
|
||||||
|
|
||||||
|
class ConfigManager {
|
||||||
|
constructor() {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.bindEvents();
|
||||||
|
this.loadUPSList();
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
// Add UPS button
|
||||||
|
document.getElementById('add-ups-btn').addEventListener('click', () => {
|
||||||
|
this.showUPSModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal close buttons
|
||||||
|
document.querySelectorAll('.close').forEach(close => {
|
||||||
|
close.addEventListener('click', (e) => {
|
||||||
|
this.closeModal(e.target.closest('.modal'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// UPS form submission
|
||||||
|
document.getElementById('ups-form').addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.saveUPS();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test connection button
|
||||||
|
document.getElementById('test-connection-btn').addEventListener('click', () => {
|
||||||
|
this.testConnection();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel button
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadUPSList() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/config/ups');
|
||||||
|
const upsList = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(upsList.detail || 'Failed to load UPS configurations');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderUPSList(upsList);
|
||||||
|
} catch (error) {
|
||||||
|
this.showToast('Error loading UPS configurations: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderUPSList(upsList) {
|
||||||
|
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>
|
||||||
|
`;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
populateForm(ups) {
|
||||||
|
document.getElementById('ups-name').value = ups.name || '';
|
||||||
|
document.getElementById('ups-host').value = ups.host || '';
|
||||||
|
document.getElementById('ups-port').value = ups.port || 3551;
|
||||||
|
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-runtime').value = ups.alert_runtime_low_minutes || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal(modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveUPS() {
|
||||||
|
const form = document.getElementById('ups-form');
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const mode = form.dataset.mode;
|
||||||
|
const upsName = 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')
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showToast(result.message, 'success');
|
||||||
|
this.closeModal(document.getElementById('ups-modal'));
|
||||||
|
this.loadUPSList();
|
||||||
|
} catch (error) {
|
||||||
|
this.showToast('Error saving UPS: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async editUPS(upsName) {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showUPSModal(ups);
|
||||||
|
} catch (error) {
|
||||||
|
this.showToast('Error loading UPS configuration: ' + error.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';
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUPS(upsName) {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showToast(result.message, 'success');
|
||||||
|
this.loadUPSList();
|
||||||
|
} catch (error) {
|
||||||
|
this.showToast('Error deleting UPS: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async testConnection() {
|
||||||
|
const form = document.getElementById('ups-form');
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
name: formData.get('name') || 'test',
|
||||||
|
host: formData.get('host'),
|
||||||
|
port: parseInt(formData.get('port')) || 3551,
|
||||||
|
interval_seconds: 30,
|
||||||
|
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;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/config/ups/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async testUPSConnection(upsName) {
|
||||||
|
const testBtn = document.querySelector(`[data-ups-name="${upsName}"].test-btn`);
|
||||||
|
const originalText = testBtn.textContent;
|
||||||
|
testBtn.textContent = 'Testing...';
|
||||||
|
testBtn.disabled = true;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(message, type = 'info') {
|
||||||
|
const container = document.getElementById('toast-container');
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast ${type}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
|
||||||
|
container.appendChild(toast);
|
||||||
|
|
||||||
|
// Auto-remove after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (toast.parentNode) {
|
||||||
|
toast.parentNode.removeChild(toast);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize when DOM is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
new ConfigManager();
|
||||||
|
});
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
// Clean UPS Dashboard - Simplified but Functional
|
||||||
|
const DEBUG_ENABLED = localStorage.getItem('ups_debug') === 'true' ||
|
||||||
|
new URLSearchParams(window.location.search).get('debug') === 'true';
|
||||||
|
|
||||||
|
function debugLog(...args) {
|
||||||
|
if (DEBUG_ENABLED) console.log('[UPS Debug]', ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugError(...args) {
|
||||||
|
console.error('[UPS Error]', ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug control functions
|
||||||
|
window.upsDebug = {
|
||||||
|
enable: () => {
|
||||||
|
localStorage.setItem('ups_debug', 'true');
|
||||||
|
location.reload();
|
||||||
|
},
|
||||||
|
disable: () => {
|
||||||
|
localStorage.removeItem('ups_debug');
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const evtSource = new EventSource('/api/stream');
|
||||||
|
const charts = {};
|
||||||
|
|
||||||
|
// Core tile definitions
|
||||||
|
const TILE_REGISTRY = [
|
||||||
|
{ id: 'load_pct', metric: 'LOADPCT', label: 'Load %', type: 'gauge' },
|
||||||
|
{ id: 'battery_pct', metric: 'BCHARGE', label: 'Battery %', type: 'gauge' },
|
||||||
|
{ id: 'watts_usage', metric: 'DERIVED_WATTS', label: 'Watts', type: 'line' },
|
||||||
|
{ id: 'volt_line', metric: 'LINEV', label: 'Line V', type: 'value' },
|
||||||
|
{ id: 'volt_output', metric: 'OUTPUTV', label: 'Output V', type: 'value', fallback: 'LINEV' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Storage for tile positions and card sizes
|
||||||
|
const TILE_POS_KEY = 'upsTilePos.v1';
|
||||||
|
const CARD_SIZE_KEY = 'upsCardSizes.v1';
|
||||||
|
let savedTilePos = {};
|
||||||
|
let savedCardSizes = {};
|
||||||
|
|
||||||
|
function loadFromStorage(key, defaultValue = {}) {
|
||||||
|
try { return JSON.parse(localStorage.getItem(key) || '{}'); } catch(_) { return defaultValue; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveToStorage(key, data) {
|
||||||
|
try { localStorage.setItem(key, JSON.stringify(data)); } catch(_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize storage
|
||||||
|
savedTilePos = loadFromStorage(TILE_POS_KEY);
|
||||||
|
savedCardSizes = loadFromStorage(CARD_SIZE_KEY);
|
||||||
|
|
||||||
|
// Initialize tiles for a UPS
|
||||||
|
function initTilesFor(name, grid) {
|
||||||
|
if (!grid) return;
|
||||||
|
debugLog('Creating tiles for UPS:', name);
|
||||||
|
|
||||||
|
TILE_REGISTRY.forEach((tileDef, idx) => {
|
||||||
|
const tile = document.createElement('div');
|
||||||
|
tile.className = 'tile';
|
||||||
|
tile.dataset.tile = tileDef.id;
|
||||||
|
|
||||||
|
// Default positioning
|
||||||
|
tile.style.left = (10 + (idx % 3) * 220) + 'px';
|
||||||
|
tile.style.top = (10 + Math.floor(idx / 3) * 180) + 'px';
|
||||||
|
tile.style.width = '200px';
|
||||||
|
tile.style.height = '160px';
|
||||||
|
|
||||||
|
tile.innerHTML = `
|
||||||
|
<h4>${tileDef.label}</h4>
|
||||||
|
<div class="tile-body">
|
||||||
|
${createTileContent(name, tileDef)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
grid.appendChild(tile);
|
||||||
|
attachTileBehavior(name, tile, tileDef);
|
||||||
|
});
|
||||||
|
|
||||||
|
applyStoredPositions(name, grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTileContent(name, tileDef) {
|
||||||
|
if (tileDef.type === 'gauge') {
|
||||||
|
return `<div class="gauge" data-gauge="${tileDef.metric}">
|
||||||
|
<svg viewBox="0 0 120 70">
|
||||||
|
<path d="M10 60 A50 50 0 0 1 110 60" class="gauge-bg" />
|
||||||
|
<path d="M10 60 A50 50 0 0 1 110 60" class="gauge-fill" stroke-dasharray="0 157" />
|
||||||
|
<text x="60" y="45" class="gauge-text" text-anchor="middle">--%</text>
|
||||||
|
</svg>
|
||||||
|
</div>`;
|
||||||
|
} else if (tileDef.type === 'value') {
|
||||||
|
const unit = tileDef.metric.includes('V') ? 'V' : 'W';
|
||||||
|
return `<div class="big-value-container">
|
||||||
|
<span class="big-value" data-value="${tileDef.metric}">--</span>
|
||||||
|
<span class="value-unit">${unit}</span>
|
||||||
|
</div>`;
|
||||||
|
} else if (tileDef.type === 'line') {
|
||||||
|
const canvasId = `chart-${name}-${tileDef.id}`;
|
||||||
|
return `<canvas id="${canvasId}" height="90"></canvas>`;
|
||||||
|
}
|
||||||
|
return '<div>--</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachTileBehavior(name, tile, tileDef) {
|
||||||
|
// Make tiles draggable
|
||||||
|
let isDragging = false;
|
||||||
|
let startX, startY, initialLeft, initialTop;
|
||||||
|
|
||||||
|
tile.addEventListener('mousedown', (e) => {
|
||||||
|
if (e.target.tagName === 'CANVAS') return; // Don't drag on charts
|
||||||
|
isDragging = true;
|
||||||
|
startX = e.clientX;
|
||||||
|
startY = e.clientY;
|
||||||
|
initialLeft = parseInt(tile.style.left) || 0;
|
||||||
|
initialTop = parseInt(tile.style.top) || 0;
|
||||||
|
tile.style.zIndex = 1000;
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const deltaX = e.clientX - startX;
|
||||||
|
const deltaY = e.clientY - startY;
|
||||||
|
tile.style.left = (initialLeft + deltaX) + 'px';
|
||||||
|
tile.style.top = (initialTop + deltaY) + 'px';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', () => {
|
||||||
|
if (isDragging) {
|
||||||
|
isDragging = false;
|
||||||
|
tile.style.zIndex = '';
|
||||||
|
savePositions(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize chart if needed
|
||||||
|
if (tileDef.type === 'line') {
|
||||||
|
setTimeout(() => initChart(name, tileDef), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initChart(name, tileDef) {
|
||||||
|
const canvasId = `chart-${name}-${tileDef.id}`;
|
||||||
|
const canvas = document.getElementById(canvasId);
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
debugLog('Creating chart:', canvasId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
charts[canvasId] = new Chart(canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{
|
||||||
|
label: tileDef.label,
|
||||||
|
data: [],
|
||||||
|
borderColor: '#ff6b35',
|
||||||
|
backgroundColor: 'rgba(255,107,53,0.15)',
|
||||||
|
tension: 0.3,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 0
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: false },
|
||||||
|
x: { display: false }
|
||||||
|
},
|
||||||
|
plugins: { legend: { display: false } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
debugError('Chart creation failed:', canvasId, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePositions(name) {
|
||||||
|
const grid = document.querySelector(`#card-${name} [data-tile-grid]`);
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
savedTilePos[name] = {};
|
||||||
|
grid.querySelectorAll('.tile').forEach(tile => {
|
||||||
|
const id = tile.dataset.tile;
|
||||||
|
savedTilePos[name][id] = {
|
||||||
|
left: parseInt(tile.style.left) || 0,
|
||||||
|
top: parseInt(tile.style.top) || 0,
|
||||||
|
width: parseInt(tile.style.width) || 200,
|
||||||
|
height: parseInt(tile.style.height) || 160
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
saveToStorage(TILE_POS_KEY, savedTilePos);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStoredPositions(name, grid) {
|
||||||
|
const positions = savedTilePos[name];
|
||||||
|
if (!positions) return;
|
||||||
|
|
||||||
|
grid.querySelectorAll('.tile').forEach(tile => {
|
||||||
|
const id = tile.dataset.tile;
|
||||||
|
const pos = positions[id];
|
||||||
|
if (pos) {
|
||||||
|
tile.style.left = pos.left + 'px';
|
||||||
|
tile.style.top = pos.top + 'px';
|
||||||
|
tile.style.width = pos.width + 'px';
|
||||||
|
tile.style.height = pos.height + 'px';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset layout function
|
||||||
|
function resetLayout(name) {
|
||||||
|
debugLog('Resetting layout for:', name);
|
||||||
|
const grid = document.querySelector(`#card-${name} [data-tile-grid]`);
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
// Clear existing tiles and charts
|
||||||
|
Object.keys(charts).forEach(chartId => {
|
||||||
|
if (chartId.includes(name)) {
|
||||||
|
try { charts[chartId].destroy(); } catch(_) {}
|
||||||
|
delete charts[chartId];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
grid.innerHTML = '';
|
||||||
|
|
||||||
|
// Clear saved positions
|
||||||
|
delete savedTilePos[name];
|
||||||
|
saveToStorage(TILE_POS_KEY, savedTilePos);
|
||||||
|
|
||||||
|
// Recreate tiles
|
||||||
|
initTilesFor(name, grid);
|
||||||
|
|
||||||
|
// Auto-arrange with proper spacing
|
||||||
|
autoArrangeTiles(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoArrangeTiles(name) {
|
||||||
|
const grid = document.querySelector(`#card-${name} [data-tile-grid]`);
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
const tiles = Array.from(grid.querySelectorAll('.tile'));
|
||||||
|
const cols = 3;
|
||||||
|
const tileWidth = 200;
|
||||||
|
const tileHeight = 160;
|
||||||
|
const gap = 20;
|
||||||
|
|
||||||
|
tiles.forEach((tile, idx) => {
|
||||||
|
const col = idx % cols;
|
||||||
|
const row = Math.floor(idx / cols);
|
||||||
|
tile.style.left = (10 + col * (tileWidth + gap)) + 'px';
|
||||||
|
tile.style.top = (10 + row * (tileHeight + gap)) + 'px';
|
||||||
|
tile.style.width = tileWidth + 'px';
|
||||||
|
tile.style.height = tileHeight + 'px';
|
||||||
|
});
|
||||||
|
|
||||||
|
savePositions(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update functions
|
||||||
|
function updateUPSData(name, data) {
|
||||||
|
const card = document.getElementById(`card-${name}`);
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
// Ensure tiles exist
|
||||||
|
const grid = card.querySelector('[data-tile-grid]');
|
||||||
|
if (grid && grid.children.length === 0) {
|
||||||
|
initTilesFor(name, grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status and badges
|
||||||
|
updateBadges(card, data);
|
||||||
|
|
||||||
|
// Update tiles
|
||||||
|
TILE_REGISTRY.forEach(tileDef => {
|
||||||
|
updateTile(name, tileDef, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBadges(card, data) {
|
||||||
|
// Update status
|
||||||
|
const status = card.querySelector('[data-field="STATUS"]');
|
||||||
|
if (status) {
|
||||||
|
status.textContent = data.STATUS || 'UNKNOWN';
|
||||||
|
status.className = 'ups-status ' + (data.STATUS === 'ONLINE' ? 'online' : 'unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update model/serial
|
||||||
|
const modelSerial = card.querySelector('[data-model-serial]');
|
||||||
|
if (modelSerial && (data.MODEL || data.SERIALNO)) {
|
||||||
|
const parts = [];
|
||||||
|
if (data.MODEL) parts.push(data.MODEL);
|
||||||
|
if (data.SERIALNO) parts.push(`S/N: ${data.SERIALNO}`);
|
||||||
|
modelSerial.textContent = parts.join(' • ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update other badges
|
||||||
|
['RUNTIME_MINUTES', 'DERIVED_WATTS', 'HEADROOM_PCT'].forEach(field => {
|
||||||
|
const badge = card.querySelector(`[data-field="${field}"]`);
|
||||||
|
if (badge && data[field]) {
|
||||||
|
const value = parseFloat(data[field]) || 0;
|
||||||
|
const unit = field === 'RUNTIME_MINUTES' ? ' m' :
|
||||||
|
field === 'DERIVED_WATTS' ? ' W' : ' headroom';
|
||||||
|
badge.textContent = value.toFixed(0) + unit;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTile(name, tileDef, data) {
|
||||||
|
const rawValue = data[tileDef.metric] || (tileDef.fallback ? data[tileDef.fallback] : null);
|
||||||
|
if (!rawValue) return;
|
||||||
|
|
||||||
|
const value = parseFloat(rawValue) || 0;
|
||||||
|
|
||||||
|
if (tileDef.type === 'gauge') {
|
||||||
|
updateGauge(name, tileDef.metric, value);
|
||||||
|
} else if (tileDef.type === 'value') {
|
||||||
|
updateValueDisplay(name, tileDef.metric, value);
|
||||||
|
} else if (tileDef.type === 'line') {
|
||||||
|
updateChart(name, tileDef, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGauge(name, metric, value) {
|
||||||
|
const gauge = document.querySelector(`#card-${name} [data-gauge="${metric}"]`);
|
||||||
|
if (!gauge) return;
|
||||||
|
|
||||||
|
const fill = gauge.querySelector('.gauge-fill');
|
||||||
|
const text = gauge.querySelector('.gauge-text');
|
||||||
|
|
||||||
|
if (fill && text) {
|
||||||
|
const total = 157;
|
||||||
|
const filled = (value / 100) * total;
|
||||||
|
fill.setAttribute('stroke-dasharray', `${filled} ${total - filled}`);
|
||||||
|
text.textContent = value.toFixed(0) + '%';
|
||||||
|
|
||||||
|
gauge.className = 'gauge ' + (value < 15 ? 'critical' : value < 35 ? 'low' : 'good');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateValueDisplay(name, metric, value) {
|
||||||
|
const element = document.querySelector(`#card-${name} [data-value="${metric}"]`);
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
element.textContent = value.toFixed(1);
|
||||||
|
|
||||||
|
if (metric.includes('V')) {
|
||||||
|
element.className = 'big-value ' + (value >= 114 && value <= 126 ? 'voltage-good' : 'voltage-bad');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChart(name, tileDef, value) {
|
||||||
|
const chartId = `chart-${name}-${tileDef.id}`;
|
||||||
|
const chart = charts[chartId];
|
||||||
|
if (!chart) return;
|
||||||
|
|
||||||
|
const now = new Date().toLocaleTimeString();
|
||||||
|
chart.data.labels.push(now);
|
||||||
|
chart.data.datasets[0].data.push(value);
|
||||||
|
|
||||||
|
// Keep last 60 points
|
||||||
|
if (chart.data.labels.length > 60) {
|
||||||
|
chart.data.labels.shift();
|
||||||
|
chart.data.datasets[0].data.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smart scaling for watts
|
||||||
|
if (tileDef.id === 'watts_usage') {
|
||||||
|
const values = chart.data.datasets[0].data;
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const range = max - min;
|
||||||
|
|
||||||
|
if (range <= 50) {
|
||||||
|
chart.options.scales.y.min = Math.max(0, min - 10);
|
||||||
|
chart.options.scales.y.max = max + 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chart.update('none');
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE event handling
|
||||||
|
evtSource.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(e.data);
|
||||||
|
const snapshots = payload.snapshots || {};
|
||||||
|
|
||||||
|
Object.entries(snapshots).forEach(([name, snap]) => {
|
||||||
|
updateUPSData(name, snap);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
debugError('SSE Error:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Card resize functionality
|
||||||
|
function persistCardSize(name, width, height) {
|
||||||
|
savedCardSizes[name] = { width, height };
|
||||||
|
saveToStorage(CARD_SIZE_KEY, savedCardSizes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreCardSize(name) {
|
||||||
|
const card = document.getElementById(`card-${name}`);
|
||||||
|
const saved = savedCardSizes[name];
|
||||||
|
if (card && saved) {
|
||||||
|
card.style.width = saved.width + 'px';
|
||||||
|
card.style.height = saved.height + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on DOM ready
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
debugLog('Dashboard initializing...');
|
||||||
|
|
||||||
|
// Initialize existing cards
|
||||||
|
document.querySelectorAll('.card').forEach(card => {
|
||||||
|
const name = card.id.replace('card-', '');
|
||||||
|
const grid = card.querySelector('[data-tile-grid]');
|
||||||
|
if (grid) {
|
||||||
|
initTilesFor(name, grid);
|
||||||
|
restoreCardSize(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach reset button listeners
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.matches('[data-reset-layout]')) {
|
||||||
|
const card = e.target.closest('.card');
|
||||||
|
const name = card.id.replace('card-', '');
|
||||||
|
resetLayout(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch for new cards added dynamically
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
mutation.addedNodes.forEach((node) => {
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE && node.classList?.contains('card')) {
|
||||||
|
const name = node.id.replace('card-', '');
|
||||||
|
const grid = node.querySelector('[data-tile-grid]');
|
||||||
|
if (grid) {
|
||||||
|
debugLog('New UPS card detected:', name);
|
||||||
|
initTilesFor(name, grid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const upsCards = document.getElementById('ups-cards');
|
||||||
|
if (upsCards) {
|
||||||
|
observer.observe(upsCards, { childList: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,336 @@
|
|||||||
|
// Simple UPS Dashboard
|
||||||
|
const DEBUG = localStorage.getItem('debug') === 'true' || true; // Temporarily enable debug
|
||||||
|
const log = DEBUG ? console.log.bind(console, '[UPS]') : () => {};
|
||||||
|
|
||||||
|
// Global state
|
||||||
|
const charts = {};
|
||||||
|
const evtSource = new EventSource('/api/stream');
|
||||||
|
let lastUpdateTs = {};
|
||||||
|
|
||||||
|
// Core tile types
|
||||||
|
const TILES = {
|
||||||
|
load_pct: { metric: 'LOADPCT', label: 'Load %', type: 'gauge' },
|
||||||
|
battery_pct: { metric: 'BCHARGE', label: 'Battery %', type: 'gauge' },
|
||||||
|
watts_usage: { metric: 'DERIVED_WATTS', label: 'Watts', type: 'line' },
|
||||||
|
volt_line: { metric: 'LINEV', label: 'Line V', type: 'value' },
|
||||||
|
volt_output: { metric: 'OUTPUTV', label: 'Output V', type: 'value' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
log('DOM loaded, Chart.js available:', typeof Chart !== 'undefined');
|
||||||
|
initializeDashboard();
|
||||||
|
setupEventListeners();
|
||||||
|
setupDynamicCardObserver();
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupDynamicCardObserver() {
|
||||||
|
// Watch for new UPS cards being added to the DOM
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
mutation.addedNodes.forEach((node) => {
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE && node.classList?.contains('card')) {
|
||||||
|
const name = node.id.replace('card-', '');
|
||||||
|
log('New UPS card detected:', name);
|
||||||
|
createTiles(name);
|
||||||
|
initChartsForUPS(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const upsCards = document.getElementById('ups-cards');
|
||||||
|
if (upsCards) {
|
||||||
|
observer.observe(upsCards, { childList: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeDashboard() {
|
||||||
|
document.querySelectorAll('.card').forEach(card => {
|
||||||
|
const name = card.id.replace('card-', '');
|
||||||
|
createTiles(name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupEventListeners() {
|
||||||
|
// Reset button handlers
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.matches('[data-reset-layout]')) {
|
||||||
|
const card = e.target.closest('.card');
|
||||||
|
const name = card.id.replace('card-', '');
|
||||||
|
resetLayout(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTiles(upsName) {
|
||||||
|
log('Creating tiles for UPS:', upsName);
|
||||||
|
const grid = document.querySelector(`#card-${upsName} [data-tile-grid]`);
|
||||||
|
if (!grid) {
|
||||||
|
console.warn('Grid not found for UPS:', upsName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.innerHTML = '';
|
||||||
|
log('Creating', Object.keys(TILES).length, 'tiles');
|
||||||
|
|
||||||
|
Object.entries(TILES).forEach(([id, config], index) => {
|
||||||
|
log('Creating tile:', id, config);
|
||||||
|
const tile = document.createElement('div');
|
||||||
|
tile.className = 'tile';
|
||||||
|
tile.dataset.tile = id;
|
||||||
|
tile.innerHTML = `
|
||||||
|
<h4>${config.label}</h4>
|
||||||
|
<div class="tile-body">
|
||||||
|
${createTileContent(upsName, id, config)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Simple positioning
|
||||||
|
const x = (index % 3) * 220 + 10;
|
||||||
|
const y = Math.floor(index / 3) * 180 + 10;
|
||||||
|
tile.style.left = x + 'px';
|
||||||
|
tile.style.top = y + 'px';
|
||||||
|
tile.style.width = '200px';
|
||||||
|
tile.style.height = '160px';
|
||||||
|
|
||||||
|
grid.appendChild(tile);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTileContent(upsName, tileId, config) {
|
||||||
|
if (config.type === 'gauge') {
|
||||||
|
return `<div class="gauge" data-gauge="${config.metric}">
|
||||||
|
<svg viewBox="0 0 120 70">
|
||||||
|
<path d="M10 60 A50 50 0 0 1 110 60" class="gauge-bg" />
|
||||||
|
<path d="M10 60 A50 50 0 0 1 110 60" class="gauge-fill" stroke-dasharray="0 157" />
|
||||||
|
<text x="60" y="45" class="gauge-text" text-anchor="middle">--%</text>
|
||||||
|
</svg>
|
||||||
|
</div>`;
|
||||||
|
} else if (config.type === 'value') {
|
||||||
|
const unit = config.metric.includes('V') ? 'V' : 'W';
|
||||||
|
return `<div class="big-value-container">
|
||||||
|
<span class="big-value" data-value="${config.metric}">--</span>
|
||||||
|
<span class="value-unit">${unit}</span>
|
||||||
|
</div>`;
|
||||||
|
} else if (config.type === 'line') {
|
||||||
|
const canvasId = `chart-${upsName}-${tileId}`;
|
||||||
|
return `<canvas id="${canvasId}" height="90"></canvas>`;
|
||||||
|
}
|
||||||
|
return '<div>--</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLayout(upsName) {
|
||||||
|
log('Resetting layout for', upsName);
|
||||||
|
createTiles(upsName);
|
||||||
|
|
||||||
|
// Clear any existing charts
|
||||||
|
Object.keys(charts).forEach(key => {
|
||||||
|
if (key.includes(upsName)) {
|
||||||
|
charts[key].destroy();
|
||||||
|
delete charts[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recreate charts
|
||||||
|
initChartsForUPS(upsName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initChartsForUPS(upsName) {
|
||||||
|
log('Initializing charts for UPS:', upsName);
|
||||||
|
Object.entries(TILES).forEach(([id, config]) => {
|
||||||
|
if (config.type === 'line') {
|
||||||
|
const canvasId = `chart-${upsName}-${id}`;
|
||||||
|
const canvas = document.getElementById(canvasId);
|
||||||
|
log('Looking for canvas:', canvasId, 'found:', !!canvas);
|
||||||
|
if (canvas) {
|
||||||
|
try {
|
||||||
|
charts[canvasId] = new Chart(canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{
|
||||||
|
label: config.label,
|
||||||
|
data: [],
|
||||||
|
borderColor: '#ff6b35',
|
||||||
|
backgroundColor: 'rgba(255,107,53,0.15)',
|
||||||
|
tension: 0.3,
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: false },
|
||||||
|
x: { display: false }
|
||||||
|
},
|
||||||
|
plugins: { legend: { display: false } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
log('Chart created successfully for:', canvasId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating chart:', canvasId, error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('Canvas not found for chart:', canvasId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE Event handling
|
||||||
|
evtSource.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(e.data);
|
||||||
|
const snapshots = payload.snapshots || {};
|
||||||
|
|
||||||
|
Object.entries(snapshots).forEach(([name, snap]) => {
|
||||||
|
updateUPSData(name, snap);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('SSE Error:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateUPSData(name, data) {
|
||||||
|
const card = document.getElementById(`card-${name}`);
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
// Ensure tiles exist for this UPS (in case card was added dynamically)
|
||||||
|
const grid = card.querySelector('[data-tile-grid]');
|
||||||
|
if (grid && grid.children.length === 0) {
|
||||||
|
log('Creating tiles for new UPS:', name);
|
||||||
|
createTiles(name);
|
||||||
|
initChartsForUPS(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUpdateTs[name] = Date.now();
|
||||||
|
|
||||||
|
// Update status badges
|
||||||
|
updateBadges(card, data);
|
||||||
|
|
||||||
|
// Update tiles
|
||||||
|
Object.entries(TILES).forEach(([id, config]) => {
|
||||||
|
updateTile(name, id, config, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBadges(card, data) {
|
||||||
|
// Update status
|
||||||
|
const status = card.querySelector('[data-field="STATUS"]');
|
||||||
|
if (status) {
|
||||||
|
status.textContent = data.STATUS || 'UNKNOWN';
|
||||||
|
status.className = 'ups-status ' + (data.STATUS === 'ONLINE' ? 'online' : 'unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update model/serial display
|
||||||
|
const modelSerial = card.querySelector('[data-model-serial]');
|
||||||
|
if (modelSerial && (data.MODEL || data.SERIALNO)) {
|
||||||
|
const parts = [];
|
||||||
|
if (data.MODEL) parts.push(data.MODEL);
|
||||||
|
if (data.SERIALNO) parts.push(`S/N: ${data.SERIALNO}`);
|
||||||
|
modelSerial.textContent = parts.join(' • ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update other badges
|
||||||
|
['RUNTIME_MINUTES', 'DERIVED_WATTS', 'HEADROOM_PCT'].forEach(field => {
|
||||||
|
const badge = card.querySelector(`[data-field="${field}"]`);
|
||||||
|
if (badge && data[field]) {
|
||||||
|
const value = parseFloat(data[field]) || 0;
|
||||||
|
const unit = field === 'RUNTIME_MINUTES' ? ' m' :
|
||||||
|
field === 'DERIVED_WATTS' ? ' W' : ' headroom';
|
||||||
|
badge.textContent = value.toFixed(0) + unit;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTile(upsName, tileId, config, data) {
|
||||||
|
const rawValue = data[config.metric] || (config.metric === 'OUTPUTV' ? data['LINEV'] : null);
|
||||||
|
if (!rawValue) return;
|
||||||
|
|
||||||
|
const value = parseFloat(rawValue) || 0;
|
||||||
|
|
||||||
|
if (config.type === 'gauge') {
|
||||||
|
updateGauge(upsName, config.metric, value);
|
||||||
|
} else if (config.type === 'value') {
|
||||||
|
updateValueDisplay(upsName, config.metric, value);
|
||||||
|
} else if (config.type === 'line') {
|
||||||
|
updateChart(upsName, tileId, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGauge(upsName, metric, value) {
|
||||||
|
const gauge = document.querySelector(`#card-${upsName} [data-gauge="${metric}"]`);
|
||||||
|
if (!gauge) return;
|
||||||
|
|
||||||
|
const fill = gauge.querySelector('.gauge-fill');
|
||||||
|
const text = gauge.querySelector('.gauge-text');
|
||||||
|
|
||||||
|
if (fill && text) {
|
||||||
|
const total = 157;
|
||||||
|
const filled = (value / 100) * total;
|
||||||
|
fill.setAttribute('stroke-dasharray', `${filled} ${total - filled}`);
|
||||||
|
text.textContent = value.toFixed(0) + '%';
|
||||||
|
|
||||||
|
// Color coding
|
||||||
|
gauge.className = 'gauge ' + (value < 15 ? 'critical' : value < 35 ? 'low' : 'good');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateValueDisplay(upsName, metric, value) {
|
||||||
|
const element = document.querySelector(`#card-${upsName} [data-value="${metric}"]`);
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
element.textContent = value.toFixed(1);
|
||||||
|
|
||||||
|
// Voltage color coding
|
||||||
|
if (metric.includes('V')) {
|
||||||
|
element.className = 'big-value ' + (value >= 114 && value <= 126 ? 'voltage-good' : 'voltage-bad');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChart(upsName, tileId, value) {
|
||||||
|
const chartId = `chart-${upsName}-${tileId}`;
|
||||||
|
const chart = charts[chartId];
|
||||||
|
if (!chart) return;
|
||||||
|
|
||||||
|
const now = new Date().toLocaleTimeString();
|
||||||
|
chart.data.labels.push(now);
|
||||||
|
chart.data.datasets[0].data.push(value);
|
||||||
|
|
||||||
|
// Keep last 60 points
|
||||||
|
if (chart.data.labels.length > 60) {
|
||||||
|
chart.data.labels.shift();
|
||||||
|
chart.data.datasets[0].data.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-scale for watts
|
||||||
|
if (tileId === 'watts_usage') {
|
||||||
|
const values = chart.data.datasets[0].data;
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const range = max - min;
|
||||||
|
|
||||||
|
if (range <= 50) {
|
||||||
|
chart.options.scales.y.min = Math.max(0, min - 10);
|
||||||
|
chart.options.scales.y.max = max + 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chart.update('none');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize charts after DOM is ready
|
||||||
|
setTimeout(() => {
|
||||||
|
document.querySelectorAll('.card').forEach(card => {
|
||||||
|
const name = card.id.replace('card-', '');
|
||||||
|
initChartsForUPS(name);
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Debug controls
|
||||||
|
window.upsDebug = {
|
||||||
|
enable: () => { localStorage.setItem('debug', 'true'); location.reload(); },
|
||||||
|
disable: () => { localStorage.removeItem('debug'); location.reload(); }
|
||||||
|
};
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,76 @@
|
|||||||
|
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")
|
||||||
|
RETENTION_SECONDS = 7 * 24 * 3600
|
||||||
|
MAX_SAMPLES_PER_UPS = 7 * 24 * 60 * 2 # assume worst-case 30s interval -> ~20160 entries
|
||||||
|
|
||||||
|
_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)
|
||||||
|
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]):
|
||||||
|
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:
|
||||||
|
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]]:
|
||||||
|
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]] = []
|
||||||
|
for item in raw:
|
||||||
|
try:
|
||||||
|
obj = json.loads(item)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if now - obj.get("ts", 0) <= since_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:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
obj = json.loads(item)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
r.lpop(key)
|
||||||
|
continue
|
||||||
|
if obj.get("ts", 0) < cutoff:
|
||||||
|
r.lpop(key)
|
||||||
|
else:
|
||||||
|
break
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>UPS Configuration</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/config.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>UPS Configuration</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="/">← Back to Dashboard</a>
|
||||||
|
<button id="add-ups-btn" class="btn btn-primary">Add UPS</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main-content">
|
||||||
|
<div id="ups-list" class="ups-list">
|
||||||
|
<!-- UPS configurations will be loaded here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add/Edit UPS Modal -->
|
||||||
|
<div id="ups-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="modal-title">Add UPS</h2>
|
||||||
|
<span class="close">×</span>
|
||||||
|
</div>
|
||||||
|
<form id="ups-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ups-name">Name *</label>
|
||||||
|
<input type="text" id="ups-name" name="name" required>
|
||||||
|
<small>Friendly name for the UPS</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ups-host">Host/IP *</label>
|
||||||
|
<input type="text" id="ups-host" name="host" required>
|
||||||
|
<small>IP address or hostname of the apcupsd server</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ups-port">Port</label>
|
||||||
|
<input type="number" id="ups-port" name="port" value="3551">
|
||||||
|
<small>apcupsd NIS port (default: 3551)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ups-interval">Polling Interval (seconds)</label>
|
||||||
|
<input type="number" id="ups-interval" name="interval_seconds" value="30" min="1">
|
||||||
|
<small>How often to poll the UPS for data</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Alert Thresholds</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ups-loadpct">High Load Alert (%)</label>
|
||||||
|
<input type="number" id="ups-loadpct" name="alert_loadpct_high" min="0" max="100" step="0.1">
|
||||||
|
<small>Trigger alert when load percentage reaches this value</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ups-bcharge">Low Battery Alert (%)</label>
|
||||||
|
<input type="number" id="ups-bcharge" name="alert_bcharge_low" min="0" max="100" step="0.1">
|
||||||
|
<small>Trigger alert when battery charge drops to this value</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="ups-onbattery" name="alert_on_battery">
|
||||||
|
Alert when on battery power
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ups-runtime">Low Runtime Alert (minutes)</label>
|
||||||
|
<input type="number" id="ups-runtime" name="alert_runtime_low_minutes" min="0" step="0.1">
|
||||||
|
<small>Trigger alert when remaining runtime drops to this value</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" id="test-connection-btn" class="btn btn-secondary">Test Connection</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<button type="button" id="cancel-btn" class="btn btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirmation Dialog -->
|
||||||
|
<div id="confirm-modal" class="modal">
|
||||||
|
<div class="modal-content small">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Confirm Action</h2>
|
||||||
|
<span class="close">×</span>
|
||||||
|
</div>
|
||||||
|
<p id="confirm-message"></p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button id="confirm-yes" class="btn btn-danger">Yes</button>
|
||||||
|
<button id="confirm-no" class="btn btn-secondary">No</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast notifications -->
|
||||||
|
<div id="toast-container"></div>
|
||||||
|
|
||||||
|
<script src="/static/js/config.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>UPS Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css" />
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>APC UPS Dashboard</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="/config" class="config-link">⚙️ Configuration</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div id="ups-cards" data-ui='{{ ui_cfg | tojson }}'>
|
||||||
|
{% for ups in ups_list %}
|
||||||
|
<div class="card" id="card-{{ ups.name }}">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>{{ ups.name }}</h2>
|
||||||
|
<div class="ups-connection" data-field-conn>{{ ups.host }}:{{ ups.port }}</div>
|
||||||
|
<button class="add-metric-btn" data-add-metric title="Add metric tile">+</button>
|
||||||
|
<button class="reset-layout-btn" data-reset-layout title="Reset tile layout">⟳</button>
|
||||||
|
</div>
|
||||||
|
<div class="metrics">
|
||||||
|
<div class="highline">
|
||||||
|
<span class="ups-status unknown" data-field="STATUS">-</span>
|
||||||
|
<span class="badge" data-field="RUNTIME_MINUTES" data-badge="runtime">- m</span>
|
||||||
|
<span class="badge" data-field="DERIVED_WATTS" data-badge="watts">- W</span>
|
||||||
|
<span class="badge" data-field="HEADROOM_PCT" data-badge="headroom">- headroom</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile-grid" data-tile-grid></div>
|
||||||
|
</div>
|
||||||
|
<div class="ups-footer" data-ups-footer></div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not ups_list %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>No UPS Configured</h3>
|
||||||
|
<p>Get started by <a href="/config">adding a UPS configuration</a>.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<script src="/static/js/dashboard.js?v=1"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
ups:
|
||||||
|
- name: ups1
|
||||||
|
host: 192.168.1.10
|
||||||
|
port: 3551
|
||||||
|
interval_seconds: 30
|
||||||
|
alert_loadpct_high: 80
|
||||||
|
alert_bcharge_low: 40
|
||||||
|
alert_on_battery: true
|
||||||
|
alert_runtime_low_minutes: 10
|
||||||
|
|
||||||
|
smtp:
|
||||||
|
host: 192.168.1.50
|
||||||
|
port: 25
|
||||||
|
username: ""
|
||||||
|
password: "" # or set env SMTP_PASSWORD
|
||||||
|
use_tls: false
|
||||||
|
use_ssl: false
|
||||||
|
from_addr: ups-monitor@example.local
|
||||||
|
to_addrs:
|
||||||
|
- admin@example.local
|
||||||
|
subject_prefix: "[UPS]"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
version: '3.9'
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
container_name: apcups-dashboard
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: redis
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
# Enable persistence so UPS configuration stored in Redis survives restarts.
|
||||||
|
# Using both RDB snapshots (default schedule) and AOF for durability.
|
||||||
|
command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec"]
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
volumes:
|
||||||
|
redis-data:
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["."]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
pyyaml==6.0.2
|
||||||
|
redis==5.0.7
|
||||||
|
pydantic==2.9.2
|
||||||
|
jinja2==3.1.4
|
||||||
|
python-multipart==0.0.9
|
||||||
|
orjson==3.10.7
|
||||||
|
pytest==8.3.3
|
||||||
Reference in new issue
Block a user