Adding the new rewrite

This commit is contained in:
Ketan Patel committed 2026-04-04 13:53:57 -04:00
1 parent 4aca9f08d1
commit 9b48b01d1d
62 files changed
+4510 -1406

No files matched your search

+72 -457
View File
@@ -1,472 +1,87 @@
"""FastAPI application entry point.
Assembles middleware, lifespan (poller start/stop), auth, routes.
All request handlers live in app/routes/*.
"""
from __future__ import annotations
import asyncio
import time
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
import orjson
from .config import load_config, UPSConfig
from .storage import get_latest, get_history
from .poller import poll_loop
from .config_manager import (
config_manager,
UPSConfigUpdate,
ConfigWriteError,
get_config_version,
)
from .storage import get_redis
from .apc_cli import fetch_status, APCStatusError
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
app = FastAPI(title="APC UPS Dashboard")
app.mount('/static', StaticFiles(directory='app/static'), name='static')
templates = Jinja2Templates(directory='app/templates')
from .logging_config import RequestIdMiddleware, configure_logging
from .poller import cancel_all_tasks, poll_loop
from .rate_limit import limiter
from .routes import api_alerts, api_auth, api_config, api_events, api_health, api_ups, pages, sse
from .security import SecurityHeadersMiddleware
from .settings import settings
logger = logging.getLogger(__name__)
_background_tasks: list[asyncio.Task] = []
@app.on_event("startup")
async def startup():
asyncio.create_task(poll_loop())
@app.get('/', response_class=HTMLResponse)
async def dashboard(request: Request):
cfg = load_config()
return templates.TemplateResponse(
'dashboard.html', {
"request": request,
"ups_list": cfg.ups,
"ui_cfg": cfg.ui.model_dump(),
}
)
@app.get('/config', response_class=HTMLResponse)
async def config_page(request: Request):
return templates.TemplateResponse('config.html', {"request": request})
@app.get('/api/ups')
async def list_ups():
cfg = load_config()
return [{"name": u.name, "host": u.host, "port": u.port} for u in cfg.ups]
@app.get('/api/ups/{ups_name}')
async def ups_status(ups_name: str):
snap = await get_latest(ups_name)
return snap or {"error": "not found"}
@app.get('/api/ups/{ups_name}/history')
async def ups_history(ups_name: str):
hist = await get_history(ups_name)
return hist
@app.get('/api/ups/{ups_name}/metric/{metric}')
async def metric_history(ups_name: str, metric: str, limit: int = 120):
"""Return recent numeric history samples for a single metric.
Limit capped at 500 to avoid large payloads.
"""
limit = max(1, min(limit, 500))
all_hist = await get_history(ups_name)
# Use only most recent entries
recent = all_hist[-limit:]
out = []
for item in recent:
data = item.get('data', {})
raw_val = data.get(metric)
if raw_val is None:
continue
# Extract leading number
try:
val = float(str(raw_val).split()[0])
except Exception:
continue
out.append({'ts': item.get('ts'), 'value': val})
return out
@app.get('/api/ups/{ups_name}/events')
async def ups_events(ups_name: str):
"""Return recent status/transfer events for a UPS."""
r = get_redis()
key = f"ups:event:list:{ups_name}"
raw = r.lrange(key, 0, 99)
parsed = []
for item in raw:
if '|' in item:
@asynccontextmanager
async def lifespan(app: FastAPI):
configure_logging()
if not settings.session_secret:
logger.warning(
"SESSION_SECRET not set; using insecure ephemeral secret. "
"Set SESSION_SECRET in your environment for production."
)
task = asyncio.create_task(poll_loop())
_background_tasks.append(task)
logger.info("Poller started")
try:
yield
finally:
logger.info("Shutting down: cancelling background tasks")
for t in _background_tasks:
t.cancel()
for t in _background_tasks:
try:
ts_s, kind, rest = item.split('|', 2)
parsed.append({
'ts': int(ts_s),
'type': kind,
'detail': rest
})
continue
except Exception:
await t
except (asyncio.CancelledError, Exception):
pass
parsed.append({'raw': item})
return parsed
await cancel_all_tasks()
logger.info("Shutdown complete")
@app.get('/api/ups/{ups_name}/energy')
async def ups_energy(ups_name: str):
"""Return today's accumulated energy (approx kWh) if available."""
r = get_redis()
day_str = time.strftime('%Y%m%d') # type: ignore
key = f"ups:energy:{ups_name}:{day_str}"
watt_seconds = r.get(key)
if watt_seconds:
try:
ws = float(watt_seconds)
kwh = ws / 3600.0 / 1000.0
return {'kwh_today': round(kwh, 4)}
except ValueError:
pass
return {'kwh_today': None}
def create_app() -> FastAPI:
application = FastAPI(title="APC UPS Dashboard", lifespan=lifespan)
application.state.limiter = limiter
application.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Middleware (outermost first in registration; Starlette wraps inside-out)
application.add_middleware(SecurityHeadersMiddleware)
application.add_middleware(RequestIdMiddleware)
@app.get('/api/ups/{ups_name}/watts_per_minute')
async def ups_watts_per_minute(ups_name: str):
"""Return recent per-minute average watts for a UPS (last 24h)."""
r = get_redis()
key = f"ups:watts:permin:{ups_name}"
raw = r.lrange(key, 0, 1440)
out = []
for item in raw:
if '|' in item:
minute, avg = item.split('|', 1)
try:
out.append({'minute': minute, 'avg_watts': float(avg)})
except ValueError:
continue
# list stored newest-first; reverse to chronological
out.reverse()
return out
@app.get('/api/ups/{ups_name}/health')
async def ups_health(ups_name: str):
"""Return aggregated health indicators.
Includes recent alerts, voltage deviation stats, and ONBATT event count.
"""
r = get_redis()
# Recent alerts
alerts_key = f"ups:alerts:recent:{ups_name}"
alert_raw = r.lrange(alerts_key, 0, 19)
alerts = []
for a in alert_raw:
if '|' in a:
ts_s, msg = a.split('|', 1)
try:
alerts.append({'ts': int(ts_s), 'msg': msg})
except ValueError:
alerts.append({'raw': a})
else:
alerts.append({'raw': a})
# Voltage deviation samples
dev_key = f"ups:volt:dev:samples:{ups_name}"
dev_samples = r.lrange(dev_key, 0, 49)
dev_vals = []
for d in dev_samples:
try:
dev_vals.append(float(d))
except ValueError:
continue
dev_avg = sum(devVals := dev_vals) / len(devVals) if dev_vals else None
dev_max = max(dev_vals) if dev_vals else None
# Transfer burst count (recent hour ONBATT events)
events_key = f"ups:event:list:{ups_name}"
now = int(time.time())
events = r.lrange(events_key, 0, 200)
onbatt_hour = 0
for ev in events:
parts = ev.split('|')
if len(parts) >= 3:
try:
ts_e = int(parts[0])
except ValueError:
continue
if now - ts_e > 3600:
continue
if parts[1] == 'STATUS' and 'ONBATT' in parts[2]:
onbatt_hour += 1
return {
'alerts': alerts,
'voltage_deviation': {
'avg_pct': round(dev_avg, 2) if dev_avg is not None else None,
'max_pct': round(dev_max, 2) if dev_max is not None else None,
'samples': len(dev_vals)
},
'onbatt_last_hour': onbatt_hour
}
@app.get('/api/ups/{ups_name}/debug')
async def ups_debug(ups_name: str):
"""Return current apcaccess CLI status for a UPS."""
cfg = load_config()
target = next((u for u in cfg.ups if u.name == ups_name), None)
if not target:
raise HTTPException(status_code=404, detail='UPS not found')
try:
data = await fetch_status(target.host, target.port)
return data
except APCStatusError as e:
raise HTTPException(status_code=502, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get('/api/stream')
async def stream():
# simple Server Sent Events stream of snapshots (polling redis every 5s)
async def event_gen():
while True:
cfg = load_config()
payload = {"snapshots": {}, "cfgVersion": get_config_version()}
for u in cfg.ups:
snap = await get_latest(u.name)
if snap:
payload["snapshots"][u.name] = snap
# include simple config metadata to help client reconcile
payload["upsMeta"] = [
{"name": u.name, "host": u.host, "port": u.port}
for u in cfg.ups
]
# Backward compatibility: also flatten UPS snapshots at top level
for name, snap in payload["snapshots"].items():
payload.setdefault(name, snap)
yield f"data: {orjson.dumps(payload).decode()}\n\n"
await asyncio.sleep(5)
return StreamingResponse(event_gen(), media_type='text/event-stream')
# Configuration management endpoints
@app.get('/api/config/ups')
async def get_ups_configs():
"""Get all UPS configurations"""
try:
ups_list = await config_manager.get_ups_list()
return [ups.model_dump() for ups in ups_list]
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get('/api/config/ups/{ups_name}')
async def get_ups_config(ups_name: str):
"""Get specific UPS configuration"""
try:
ups = await config_manager.get_ups(ups_name)
if not ups:
raise HTTPException(status_code=404, detail="UPS not found")
return ups.model_dump()
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post('/api/config/ups')
async def add_ups_config(ups_config: UPSConfig):
"""Add new UPS configuration"""
try:
await config_manager.add_ups(ups_config)
return {"message": "UPS configuration added successfully"}
except ConfigWriteError as e:
raise HTTPException(status_code=507, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.put('/api/config/ups/{ups_name}')
async def update_ups_config(ups_name: str, updates: UPSConfigUpdate):
"""Update UPS configuration"""
try:
success = await config_manager.update_ups(ups_name, updates)
if not success:
raise HTTPException(status_code=404, detail="UPS not found")
return {"message": "UPS configuration updated successfully"}
except ConfigWriteError as e:
raise HTTPException(status_code=507, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.delete('/api/config/ups/{ups_name}')
async def delete_ups_config(ups_name: str):
"""Delete UPS configuration"""
try:
success = await config_manager.delete_ups(ups_name)
if not success:
raise HTTPException(status_code=404, detail="UPS not found")
return {"message": "UPS configuration deleted successfully"}
except ConfigWriteError as e:
raise HTTPException(status_code=507, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post('/api/config/ups/{ups_name}/test')
async def test_ups_connection(ups_name: str):
"""Test UPS connection"""
try:
ups = await config_manager.get_ups(ups_name)
if not ups:
raise HTTPException(status_code=404, detail="UPS not found")
result = await config_manager.validate_ups_connection(ups)
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post('/api/config/ups/test')
async def test_new_ups_connection(ups_config: UPSConfig):
"""Test new UPS configuration connection"""
try:
result = await config_manager.validate_ups_connection(ups_config)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get('/api/config/smtp')
async def get_smtp_config():
"""Get SMTP configuration"""
try:
smtp = await config_manager.get_smtp_config()
return smtp.model_dump() if smtp else None
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get('/api/config/ui')
async def get_ui_config():
cfg = load_config()
return cfg.ui.model_dump()
@app.put('/api/config/ui')
async def update_ui_config(payload: dict):
# Simple partial update of UI flags
from .config import AppConfig
cfg = load_config()
ui_dict = cfg.ui.model_dump()
allowed = set(ui_dict.keys())
for k, v in payload.items():
if k in allowed and isinstance(v, bool):
ui_dict[k] = v
# Reconstruct full config and save via manager
new_cfg = AppConfig(
ups=cfg.ups, smtp=cfg.smtp, ui=cfg.ui.__class__(**ui_dict)
application.mount(
"/static", StaticFiles(directory="app/static"), name="static"
)
await config_manager.save_config(new_cfg)
return {"message": "UI config updated", "ui": ui_dict}
# Routers
application.include_router(pages.router)
application.include_router(api_auth.router)
application.include_router(api_health.router)
application.include_router(api_config.router)
application.include_router(api_ups.router)
application.include_router(api_alerts.router)
application.include_router(api_events.router)
application.include_router(sse.router)
@application.exception_handler(Exception)
async def unhandled_exc(request: Request, exc: Exception): # noqa: ARG001
logger.exception("Unhandled error", extra={"path": request.url.path})
return JSONResponse({"detail": "Internal server error"}, status_code=500)
return application
# --- UI Tile Layout Persistence ---
@app.get('/api/ups/{ups_name}/ui_tiles')
async def get_ups_ui_tiles(ups_name: str):
"""Return persisted UI tile settings for a UPS.
Includes: types, order, hidden, custom. Falls back to empty defaults if
not stored yet.
"""
r = get_redis()
key = f"ups:ui:tiles:{ups_name}"
raw = r.get(key)
if not raw:
return {
"types": {},
"order": [],
"hidden": [],
"custom": [],
"positions": {}
}
try:
data = orjson.loads(raw)
# Basic shape validation
if not isinstance(data, dict):
raise ValueError
return {
"types": data.get("types", {}),
"order": data.get("order", []),
"hidden": data.get("hidden", []),
"custom": data.get("custom", []),
"positions": data.get("positions", {}),
}
except Exception:
return {
"types": {},
"order": [],
"hidden": [],
"custom": [],
"positions": {}
}
@app.post('/api/ups/{ups_name}/ui_tiles')
async def save_ups_ui_tiles(ups_name: str, payload: dict):
"""Persist UI tile settings for a UPS.
Expects JSON: { types: {...}, order: [...], hidden: [...], custom: [...] }
"""
# Light validation / sanitization
types = (payload.get('types')
if isinstance(payload.get('types'), dict) else {})
order = (payload.get('order')
if isinstance(payload.get('order'), list) else [])
hidden = (payload.get('hidden')
if isinstance(payload.get('hidden'), list) else [])
custom = (payload.get('custom')
if isinstance(payload.get('custom'), list) else [])
positions = (payload.get('positions')
if isinstance(payload.get('positions'), dict) else {})
# Ensure custom entries minimally formed
norm_custom = []
for c in custom or []:
if not isinstance(c, dict):
continue
metric = c.get('metric')
chart = c.get('chart')
cid = c.get('id') or ''
source = c.get('source', 'live')
if not metric or not chart:
continue
norm_custom.append({
'id': cid,
'metric': metric,
'chart': chart,
'source': source
})
doc = {
'types': types,
'order': order,
'hidden': hidden,
'custom': norm_custom,
'positions': positions,
'saved_ts': int(time.time())
}
r = get_redis()
key = f"ups:ui:tiles:{ups_name}"
r.set(key, orjson.dumps(doc))
return {"message": "saved", "count_custom": len(norm_custom)}
@app.delete('/api/ups/{ups_name}/ui_tiles')
async def clear_ups_ui_tiles(ups_name: str):
"""Delete stored UI tile layout/settings for a UPS (full reset)."""
r = get_redis()
key = f"ups:ui:tiles:{ups_name}"
r.delete(key)
return {"message": "cleared"}
app = create_app()