Adding the Initial WIP

This commit is contained in:
Ketan Patel committed 2025-10-14 20:09:28 -04:00
commit 326479c338
25 files changed
+6629

No files matched your search

+1
View File
@@ -0,0 +1 @@
# Package init
+176
View File
@@ -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)
+48
View File
@@ -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
+78
View File
@@ -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
+211
View File
@@ -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()
+58
View File
@@ -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
View File
@@ -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
View File
@@ -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()),
)
+355
View File
@@ -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;
}
+328
View File
@@ -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; }
+400
View File
@@ -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();
});
+460
View File
@@ -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
+336
View File
@@ -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
+76
View File
@@ -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
+112
View File
@@ -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">&times;</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">&times;</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>
+46
View File
@@ -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>