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

+1
View File
@@ -0,0 +1 @@
"""Route modules split by concern."""
+62
View File
@@ -0,0 +1,62 @@
"""Alert history and acknowledgement endpoints."""
import time
from fastapi import APIRouter, Depends, Query
from ..auth import require_session, require_session_and_csrf
from ..storage import get_redis
router = APIRouter(prefix="/api/alerts")
@router.get("")
async def list_alerts(
ups: str | None = Query(None),
severity: str | None = Query(None),
days: int = Query(30, ge=1, le=90),
user=Depends(require_session),
):
r = get_redis()
raw = r.lrange("ups:alerts:history:all", 0, -1)
cutoff = int(time.time()) - days * 86400
out = []
for item in raw:
parts = item.split("|", 5)
if len(parts) < 5:
continue
try:
ts = int(parts[0])
except ValueError:
continue
if ts < cutoff:
continue
row = {
"ts": ts,
"severity": parts[1],
"ups": parts[2],
"code": parts[3],
"message": parts[4],
"id": parts[5] if len(parts) > 5 else "",
}
if ups and row["ups"] != ups:
continue
if severity and row["severity"] != severity.upper():
continue
ack = r.get(f"ups:alerts:ack:{row['id']}")
row["acked_ts"] = int(ack) if ack else None
out.append(row)
return out
@router.get("/active")
async def list_active(user=Depends(require_session)):
"""Active alerts = history entries not yet acknowledged, most recent first."""
all_alerts = await list_alerts(days=7, user=user)
return [a for a in all_alerts if not a.get("acked_ts")]
@router.post("/{alert_id}/ack")
async def ack_alert(alert_id: str, user=Depends(require_session_and_csrf)):
r = get_redis()
r.set(f"ups:alerts:ack:{alert_id}", int(time.time()), ex=30 * 86400)
return {"message": "acknowledged"}
+62
View File
@@ -0,0 +1,62 @@
"""Authentication endpoints: login, logout, first-run setup."""
import re
from fastapi import APIRouter, HTTPException, Request, Response
from pydantic import BaseModel, Field
from ..auth import (
clear_auth_cookies,
get_stored_admin,
is_admin_configured,
set_auth_cookies,
store_admin,
verify_password,
)
from ..rate_limit import limiter
router = APIRouter()
class LoginRequest(BaseModel):
username: str = Field(..., min_length=1, max_length=64)
password: str = Field(..., min_length=1, max_length=256)
class SetupRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=64)
password: str = Field(..., min_length=8, max_length=256)
_PW_POLICY_MSG = "Password must be at least 8 characters"
@router.post("/api/login")
@limiter.limit("5/minute")
async def api_login(request: Request, response: Response, payload: LoginRequest):
username, pw_hash = get_stored_admin()
if not pw_hash:
raise HTTPException(status_code=409, detail="Admin not configured; run setup")
if payload.username != username or not verify_password(payload.password, pw_hash):
raise HTTPException(status_code=401, detail="Invalid credentials")
csrf = set_auth_cookies(response, username)
return {"ok": True, "csrf_token": csrf}
@router.post("/api/logout")
async def api_logout(response: Response):
clear_auth_cookies(response)
return {"ok": True}
@router.post("/api/setup")
@limiter.limit("3/minute")
async def api_setup(request: Request, response: Response, payload: SetupRequest):
if is_admin_configured():
raise HTTPException(status_code=409, detail="Admin already configured")
if len(payload.password) < 8:
raise HTTPException(status_code=400, detail=_PW_POLICY_MSG)
if not re.match(r"^[a-zA-Z0-9_.@-]+$", payload.username):
raise HTTPException(status_code=400, detail="Invalid username characters")
store_admin(payload.username, payload.password)
csrf = set_auth_cookies(response, payload.username)
return {"ok": True, "csrf_token": csrf}
+147
View File
@@ -0,0 +1,147 @@
"""Configuration CRUD endpoints (UPS, SMTP, UI). All require session+CSRF for writes."""
from fastapi import APIRouter, Depends, HTTPException, Request
from ..auth import require_session, require_session_and_csrf
from ..config import SMTPConfig, UIConfig, UPSConfig
from ..config_manager import (
ConfigWriteError,
UPSConfigUpdate,
config_manager,
smtp_redacted_dict,
)
from ..notifications.email import EmailSendError, send_test_email
from ..rate_limit import limiter
router = APIRouter(prefix="/api/config")
@router.get("/ups")
async def get_ups_configs(user=Depends(require_session)):
try:
ups_list = await config_manager.get_ups_list()
return [ups.model_dump() for ups in ups_list]
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=500, detail=str(e))
@router.get("/ups/{ups_name}")
async def get_ups_config(ups_name: str, user=Depends(require_session)):
ups = await config_manager.get_ups(ups_name)
if not ups:
raise HTTPException(status_code=404, detail="UPS not found")
return ups.model_dump()
@router.post("/ups")
@limiter.limit("30/minute")
async def add_ups_config(
request: Request, ups_config: UPSConfig, user=Depends(require_session_and_csrf)
):
try:
await config_manager.add_ups(ups_config)
return {"message": "UPS configuration added successfully"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except ConfigWriteError as e:
raise HTTPException(status_code=507, detail=str(e))
@router.put("/ups/{ups_name}")
@limiter.limit("30/minute")
async def update_ups_config(
request: Request,
ups_name: str,
updates: UPSConfigUpdate,
user=Depends(require_session_and_csrf),
):
try:
success = await config_manager.update_ups(ups_name, updates)
if not success:
raise HTTPException(status_code=404, detail="UPS not found")
return {"message": "UPS configuration updated successfully"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except ConfigWriteError as e:
raise HTTPException(status_code=507, detail=str(e))
@router.delete("/ups/{ups_name}")
@limiter.limit("30/minute")
async def delete_ups_config(
request: Request, ups_name: str, user=Depends(require_session_and_csrf)
):
success = await config_manager.delete_ups(ups_name)
if not success:
raise HTTPException(status_code=404, detail="UPS not found")
return {"message": "UPS configuration deleted successfully"}
@router.post("/ups/{ups_name}/test")
@limiter.limit("5/minute")
async def test_ups_connection(
request: Request, ups_name: str, user=Depends(require_session_and_csrf)
):
ups = await config_manager.get_ups(ups_name)
if not ups:
raise HTTPException(status_code=404, detail="UPS not found")
return await config_manager.validate_ups_connection(ups)
@router.post("/ups/test")
@limiter.limit("5/minute")
async def test_new_ups_connection(
request: Request, ups_config: UPSConfig, user=Depends(require_session_and_csrf)
):
return await config_manager.validate_ups_connection(ups_config)
@router.get("/smtp")
async def get_smtp_config(user=Depends(require_session)):
smtp = await config_manager.get_smtp_config()
return smtp_redacted_dict(smtp)
@router.put("/smtp")
@limiter.limit("30/minute")
async def update_smtp_config(
request: Request, smtp_config: SMTPConfig, user=Depends(require_session_and_csrf)
):
await config_manager.update_smtp_config(smtp_config)
return {"message": "SMTP configuration updated"}
@router.post("/smtp/test")
@limiter.limit("5/minute")
async def test_smtp(request: Request, user=Depends(require_session_and_csrf)):
smtp = await config_manager.get_smtp_config()
if not smtp:
raise HTTPException(status_code=400, detail="SMTP not configured")
try:
send_test_email(smtp)
except EmailSendError as e:
raise HTTPException(status_code=502, detail=str(e))
return {"message": "Test email sent"}
@router.get("/ui")
async def get_ui_config(user=Depends(require_session)):
from ..config import load_config
cfg = load_config()
return cfg.ui.model_dump()
@router.put("/ui")
@limiter.limit("30/minute")
async def update_ui_config(
request: Request, payload: dict, user=Depends(require_session_and_csrf)
):
from ..config import load_config
cfg = load_config()
ui_dict = cfg.ui.model_dump()
for k, v in payload.items():
if k in ui_dict:
ui_dict[k] = v
new_ui = UIConfig(**ui_dict)
await config_manager.update_ui_config(new_ui)
return {"message": "UI config updated", "ui": new_ui.model_dump()}
+37
View File
@@ -0,0 +1,37 @@
"""Consolidated events log across all UPS."""
from fastapi import APIRouter, Depends, Query
from ..auth import require_session
from ..config import load_config
from ..storage import get_redis
router = APIRouter(prefix="/api/events")
@router.get("")
async def list_events(
ups: str | None = Query(None),
kind: str | None = Query(None, description="STATUS|XFER"),
limit: int = Query(200, ge=1, le=1000),
user=Depends(require_session),
):
r = get_redis()
cfg = load_config()
target_upses = [ups] if ups else [u.name for u in cfg.ups]
out = []
for name in target_upses:
raw = r.lrange(f"ups:event:list:{name}", 0, limit)
for item in raw:
parts = item.split("|", 2)
if len(parts) != 3:
continue
try:
ts = int(parts[0])
except ValueError:
continue
if kind and parts[1] != kind.upper():
continue
out.append({"ts": ts, "type": parts[1], "detail": parts[2], "ups": name})
out.sort(key=lambda x: x["ts"], reverse=True)
return out[:limit]
+25
View File
@@ -0,0 +1,25 @@
"""Liveness, readiness, and Prometheus metrics endpoints (unauthenticated)."""
from fastapi import APIRouter
from fastapi.responses import JSONResponse, Response
from ..health import liveness, readiness
from ..metrics import render_metrics
router = APIRouter()
@router.get("/healthz")
async def healthz():
return liveness()
@router.get("/readyz")
async def readyz():
status, payload = readiness()
return JSONResponse(payload, status_code=status)
@router.get("/metrics")
async def metrics():
body, content_type = render_metrics()
return Response(content=body, media_type=content_type)
+356
View File
@@ -0,0 +1,356 @@
"""Per-UPS data APIs: status, history, metric, events, energy, health, battery health, export, debug, tiles."""
import json
import time
import orjson
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from ..apc_cli import APCStatusError, fetch_status
from ..auth import require_session, require_session_and_csrf
from ..config import load_config
from ..exports import export_energy_csv, export_events_csv, export_history_csv
from ..storage import get_history, get_latest, get_redis
router = APIRouter(prefix="/api/ups")
@router.get("")
async def list_ups(user=Depends(require_session)):
cfg = load_config()
return [{"name": u.name, "host": u.host, "port": u.port} for u in cfg.ups]
@router.get("/fleet/overview")
async def fleet_overview(user=Depends(require_session)):
"""Aggregate summary across all UPS."""
cfg = load_config()
r = get_redis()
total = len(cfg.ups)
counts = {"online": 0, "on_battery": 0, "warning": 0, "offline": 0, "unknown": 0}
total_watts = 0.0
min_timeleft = None
rows = []
for ups in cfg.ups:
snap = r.hgetall(f"ups:snap:{ups.name}") or {}
is_offline = bool(r.get(f"ups:health:offline:{ups.name}"))
status = str(snap.get("STATUS", "")).upper()
state = "unknown"
if is_offline:
state = "offline"
elif "ONBATT" in status:
state = "on_battery"
elif "ONLINE" in status:
state = "online"
counts[state] = counts.get(state, 0) + 1
try:
w = float(snap.get("DERIVED_WATTS", 0) or 0)
total_watts += w
except ValueError:
pass
try:
tl = float(str(snap.get("TIMELEFT", "")).split()[0])
if min_timeleft is None or tl < min_timeleft:
min_timeleft = tl
except Exception:
pass
rows.append({"name": ups.name, "state": state, "status": status})
return {
"total": total,
"counts": counts,
"total_watts": round(total_watts, 1),
"min_timeleft_minutes": min_timeleft,
"rows": rows,
}
@router.get("/{ups_name}")
async def ups_status(ups_name: str, user=Depends(require_session)):
snap = await get_latest(ups_name)
return snap or {"error": "not found"}
@router.get("/{ups_name}/history")
async def ups_history(ups_name: str, user=Depends(require_session)):
return await get_history(ups_name)
@router.get("/{ups_name}/metric/{metric}")
async def metric_history(
ups_name: str, metric: str, limit: int = 120, user=Depends(require_session)
):
limit = max(1, min(limit, 500))
all_hist = await get_history(ups_name)
recent = all_hist[-limit:]
out = []
for item in recent:
data = item.get("data", {})
raw_val = data.get(metric)
if raw_val is None:
continue
try:
val = float(str(raw_val).split()[0])
except Exception:
continue
out.append({"ts": item.get("ts"), "value": val})
return out
@router.get("/{ups_name}/events")
async def ups_events(ups_name: str, user=Depends(require_session)):
r = get_redis()
raw = r.lrange(f"ups:event:list:{ups_name}", 0, 99)
parsed = []
for item in raw:
if "|" in item:
try:
ts_s, kind, rest = item.split("|", 2)
parsed.append({"ts": int(ts_s), "type": kind, "detail": rest})
continue
except Exception:
pass
parsed.append({"raw": item})
return parsed
@router.get("/{ups_name}/energy")
async def ups_energy(ups_name: str, user=Depends(require_session)):
r = get_redis()
day_str = time.strftime("%Y%m%d")
key = f"ups:energy:{ups_name}:{day_str}"
watt_seconds = r.get(key)
cfg = load_config()
rate = cfg.ui.energy_cost_per_kwh if hasattr(cfg.ui, "energy_cost_per_kwh") else 0.0
if watt_seconds:
try:
ws = float(watt_seconds)
kwh = ws / 3600.0 / 1000.0
return {"kwh_today": round(kwh, 4), "cost_today": round(kwh * rate, 4)}
except ValueError:
pass
return {"kwh_today": None, "cost_today": None}
@router.get("/{ups_name}/watts_per_minute")
async def ups_watts_per_minute(ups_name: str, user=Depends(require_session)):
r = get_redis()
raw = r.lrange(f"ups:watts:permin:{ups_name}", 0, 1440)
out = []
for item in raw:
if "|" in item:
minute, avg = item.split("|", 1)
try:
out.append({"minute": minute, "avg_watts": float(avg)})
except ValueError:
continue
out.reverse()
return out
@router.get("/{ups_name}/health")
async def ups_health(ups_name: str, user=Depends(require_session)):
r = get_redis()
alert_raw = r.lrange(f"ups:alerts:recent:{ups_name}", 0, 19)
alerts = []
for a in alert_raw:
parts = a.split("|", 2)
if len(parts) == 3:
try:
alerts.append({"ts": int(parts[0]), "severity": parts[1], "msg": parts[2]})
continue
except ValueError:
pass
if "|" in a:
ts_s, msg = a.split("|", 1)
try:
alerts.append({"ts": int(ts_s), "msg": msg})
continue
except ValueError:
pass
alerts.append({"raw": a})
dev_samples = r.lrange(f"ups:volt:dev:samples:{ups_name}", 0, 49)
dev_vals = []
for d in dev_samples:
try:
dev_vals.append(float(d))
except ValueError:
continue
dev_avg = sum(dev_vals) / len(dev_vals) if dev_vals else None
dev_max = max(dev_vals) if dev_vals else None
last_ok = r.get(f"ups:health:last_ok:{ups_name}")
is_offline = bool(r.get(f"ups:health:offline:{ups_name}"))
fail_count = r.get(f"ups:health:fail_count:{ups_name}")
events = r.lrange(f"ups:event:list:{ups_name}", 0, 200)
now = int(time.time())
onbatt_hour = 0
for ev in events:
parts = ev.split("|")
if len(parts) >= 3:
try:
ts_e = int(parts[0])
except ValueError:
continue
if now - ts_e > 3600:
continue
if parts[1] == "STATUS" and "ONBATT" in parts[2]:
onbatt_hour += 1
return {
"online": not is_offline,
"last_ok_ts": int(last_ok) if last_ok else None,
"fail_count": int(fail_count) if fail_count else 0,
"alerts": alerts,
"voltage_deviation": {
"avg_pct": round(dev_avg, 2) if dev_avg is not None else None,
"max_pct": round(dev_max, 2) if dev_max is not None else None,
"samples": len(dev_vals),
},
"onbatt_last_hour": onbatt_hour,
}
@router.get("/{ups_name}/battery_health")
async def battery_health(ups_name: str, user=Depends(require_session)):
"""Return battery-health trend from sampled history."""
r = get_redis()
raw = r.lrange(f"ups:battery:history:{ups_name}", 0, -1)
samples = []
for item in raw:
try:
samples.append(json.loads(item))
except Exception:
continue
samples.sort(key=lambda s: s.get("ts", 0))
# Estimate runtime-at-full-charge: TIMELEFT * (100 / BCHARGE) when BCHARGE>0
normalized = []
for s in samples:
bc = s.get("bcharge")
tl = s.get("timeleft")
if bc and tl and bc > 10:
normalized.append({"ts": s["ts"], "est_full_runtime_min": tl * 100.0 / bc})
slope_per_day = None
decline_pct_14d = None
if len(normalized) >= 10:
first = normalized[0]
last = normalized[-1]
span_days = max(1e-6, (last["ts"] - first["ts"]) / 86400.0)
slope_per_day = (last["est_full_runtime_min"] - first["est_full_runtime_min"]) / span_days
# Compute 14d decline as % of initial runtime
cutoff_14d = last["ts"] - 14 * 86400
older = [n for n in normalized if n["ts"] <= cutoff_14d]
if older:
baseline = older[0]["est_full_runtime_min"]
if baseline > 0:
decline_pct_14d = (
(baseline - last["est_full_runtime_min"]) / baseline * 100.0
)
return {
"samples": len(samples),
"estimated_full_runtime_samples": normalized[-200:],
"slope_per_day": round(slope_per_day, 3) if slope_per_day is not None else None,
"decline_pct_14d": round(decline_pct_14d, 2) if decline_pct_14d is not None else None,
}
@router.get("/{ups_name}/export")
async def export_ups(
ups_name: str,
format: str = Query("csv"),
since_days: int = Query(7, ge=1, le=30),
kind: str = Query("history"),
user=Depends(require_session),
):
if format != "csv":
raise HTTPException(status_code=400, detail="Only csv format is supported")
if kind == "history":
gen = export_history_csv(ups_name, since_days)
filename = f"{ups_name}_history_{since_days}d.csv"
elif kind == "events":
gen = export_events_csv(ups_name, since_days)
filename = f"{ups_name}_events_{since_days}d.csv"
elif kind == "energy":
gen = export_energy_csv(ups_name, since_days)
filename = f"{ups_name}_energy_{since_days}d.csv"
else:
raise HTTPException(status_code=400, detail="kind must be history|events|energy")
return StreamingResponse(
gen,
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/{ups_name}/debug")
async def ups_debug(ups_name: str, user=Depends(require_session)):
cfg = load_config()
target = next((u for u in cfg.ups if u.name == ups_name), None)
if not target:
raise HTTPException(status_code=404, detail="UPS not found")
try:
return await fetch_status(target.host, target.port)
except APCStatusError as e:
raise HTTPException(status_code=502, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{ups_name}/ui_tiles")
async def get_ups_ui_tiles(ups_name: str, user=Depends(require_session)):
r = get_redis()
raw = r.get(f"ups:ui:tiles:{ups_name}")
default = {"types": {}, "order": [], "hidden": [], "custom": [], "positions": {}}
if not raw:
return default
try:
data = orjson.loads(raw)
return {
"types": data.get("types", {}),
"order": data.get("order", []),
"hidden": data.get("hidden", []),
"custom": data.get("custom", []),
"positions": data.get("positions", {}),
}
except Exception:
return default
@router.post("/{ups_name}/ui_tiles")
async def save_ups_ui_tiles(
ups_name: str, payload: dict, user=Depends(require_session_and_csrf)
):
types = payload.get("types") if isinstance(payload.get("types"), dict) else {}
order = payload.get("order") if isinstance(payload.get("order"), list) else []
hidden = payload.get("hidden") if isinstance(payload.get("hidden"), list) else []
custom = payload.get("custom") if isinstance(payload.get("custom"), list) else []
positions = payload.get("positions") if isinstance(payload.get("positions"), dict) else {}
norm_custom = []
for c in custom or []:
if not isinstance(c, dict):
continue
metric = c.get("metric")
chart = c.get("chart")
if not metric or not chart:
continue
norm_custom.append({
"id": c.get("id") or "",
"metric": metric,
"chart": chart,
"source": c.get("source", "live"),
})
doc = {
"types": types,
"order": order,
"hidden": hidden,
"custom": norm_custom,
"positions": positions,
"saved_ts": int(time.time()),
}
r = get_redis()
r.set(f"ups:ui:tiles:{ups_name}", orjson.dumps(doc))
return {"message": "saved", "count_custom": len(norm_custom)}
@router.delete("/{ups_name}/ui_tiles")
async def clear_ups_ui_tiles(ups_name: str, user=Depends(require_session_and_csrf)):
r = get_redis()
r.delete(f"ups:ui:tiles:{ups_name}")
return {"message": "cleared"}
+118
View File
@@ -0,0 +1,118 @@
"""HTML page routes (dashboard, config, login, setup, events, alerts, settings)."""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from ..auth import CSRF_COOKIE, current_user, is_admin_configured, make_csrf_token
from ..config import load_config
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
def _ensure_csrf(request: Request, response):
token = request.cookies.get(CSRF_COOKIE)
if not token:
token = make_csrf_token()
response.set_cookie(
CSRF_COOKIE, token, httponly=False, samesite="lax", path="/"
)
return token
def _session_or_setup_redirect(request: Request):
if not is_admin_configured():
return RedirectResponse("/setup", status_code=302)
user = current_user(request)
if not user:
return RedirectResponse("/login", status_code=302)
return None
@router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
redirect = _session_or_setup_redirect(request)
if redirect:
return redirect
cfg = load_config()
user = current_user(request)
response = templates.TemplateResponse(
"dashboard.html",
{
"request": request,
"ups_list": cfg.ups,
"ui_cfg": cfg.ui.model_dump(),
"current_user": user,
"active_nav": "dashboard",
},
)
_ensure_csrf(request, response)
return response
@router.get("/config", response_class=HTMLResponse)
async def config_page(request: Request):
redirect = _session_or_setup_redirect(request)
if redirect:
return redirect
response = templates.TemplateResponse(
"config.html",
{"request": request, "current_user": current_user(request), "active_nav": "config"},
)
_ensure_csrf(request, response)
return response
@router.get("/events", response_class=HTMLResponse)
async def events_page(request: Request):
redirect = _session_or_setup_redirect(request)
if redirect:
return redirect
response = templates.TemplateResponse(
"events.html",
{"request": request, "current_user": current_user(request), "active_nav": "events"},
)
_ensure_csrf(request, response)
return response
@router.get("/alerts", response_class=HTMLResponse)
async def alerts_page(request: Request):
redirect = _session_or_setup_redirect(request)
if redirect:
return redirect
response = templates.TemplateResponse(
"alerts.html",
{"request": request, "current_user": current_user(request), "active_nav": "alerts"},
)
_ensure_csrf(request, response)
return response
@router.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
redirect = _session_or_setup_redirect(request)
if redirect:
return redirect
response = templates.TemplateResponse(
"settings.html",
{"request": request, "current_user": current_user(request), "active_nav": "settings"},
)
_ensure_csrf(request, response)
return response
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
if not is_admin_configured():
return RedirectResponse("/setup", status_code=302)
if current_user(request):
return RedirectResponse("/", status_code=302)
return templates.TemplateResponse("login.html", {"request": request})
@router.get("/setup", response_class=HTMLResponse)
async def setup_page(request: Request):
if is_admin_configured():
return RedirectResponse("/login", status_code=302)
return templates.TemplateResponse("setup.html", {"request": request})
+41
View File
@@ -0,0 +1,41 @@
"""Server-Sent Events stream with snapshots + fleet overview."""
import asyncio
import orjson
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from ..auth import require_session
from ..config import load_config
from ..config_manager import get_config_version
from ..storage import get_latest, get_redis
router = APIRouter()
@router.get("/api/stream")
async def stream(user=Depends(require_session)):
async def event_gen():
while True:
cfg = load_config()
r = get_redis()
payload = {"snapshots": {}, "cfgVersion": get_config_version()}
for u in cfg.ups:
snap = await get_latest(u.name)
if snap:
payload["snapshots"][u.name] = snap
payload["upsMeta"] = [
{
"name": u.name,
"host": u.host,
"port": u.port,
"offline": bool(r.get(f"ups:health:offline:{u.name}")),
}
for u in cfg.ups
]
for name, snap in payload["snapshots"].items():
payload.setdefault(name, snap)
yield f"data: {orjson.dumps(payload).decode()}\n\n"
await asyncio.sleep(5)
return StreamingResponse(event_gen(), media_type="text/event-stream")