Adding the new rewrite
This commit is contained in:
1 parent
4aca9f08d1
commit
9b48b01d1d
62 files changed
+4510
-1406
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Route modules split by concern."""
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Alert history and acknowledgement endpoints."""
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from ..auth import require_session, require_session_and_csrf
|
||||
from ..storage import get_redis
|
||||
|
||||
router = APIRouter(prefix="/api/alerts")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_alerts(
|
||||
ups: str | None = Query(None),
|
||||
severity: str | None = Query(None),
|
||||
days: int = Query(30, ge=1, le=90),
|
||||
user=Depends(require_session),
|
||||
):
|
||||
r = get_redis()
|
||||
raw = r.lrange("ups:alerts:history:all", 0, -1)
|
||||
cutoff = int(time.time()) - days * 86400
|
||||
out = []
|
||||
for item in raw:
|
||||
parts = item.split("|", 5)
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
try:
|
||||
ts = int(parts[0])
|
||||
except ValueError:
|
||||
continue
|
||||
if ts < cutoff:
|
||||
continue
|
||||
row = {
|
||||
"ts": ts,
|
||||
"severity": parts[1],
|
||||
"ups": parts[2],
|
||||
"code": parts[3],
|
||||
"message": parts[4],
|
||||
"id": parts[5] if len(parts) > 5 else "",
|
||||
}
|
||||
if ups and row["ups"] != ups:
|
||||
continue
|
||||
if severity and row["severity"] != severity.upper():
|
||||
continue
|
||||
ack = r.get(f"ups:alerts:ack:{row['id']}")
|
||||
row["acked_ts"] = int(ack) if ack else None
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/active")
|
||||
async def list_active(user=Depends(require_session)):
|
||||
"""Active alerts = history entries not yet acknowledged, most recent first."""
|
||||
all_alerts = await list_alerts(days=7, user=user)
|
||||
return [a for a in all_alerts if not a.get("acked_ts")]
|
||||
|
||||
|
||||
@router.post("/{alert_id}/ack")
|
||||
async def ack_alert(alert_id: str, user=Depends(require_session_and_csrf)):
|
||||
r = get_redis()
|
||||
r.set(f"ups:alerts:ack:{alert_id}", int(time.time()), ex=30 * 86400)
|
||||
return {"message": "acknowledged"}
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Authentication endpoints: login, logout, first-run setup."""
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..auth import (
|
||||
clear_auth_cookies,
|
||||
get_stored_admin,
|
||||
is_admin_configured,
|
||||
set_auth_cookies,
|
||||
store_admin,
|
||||
verify_password,
|
||||
)
|
||||
from ..rate_limit import limiter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(..., min_length=1, max_length=64)
|
||||
password: str = Field(..., min_length=1, max_length=256)
|
||||
|
||||
|
||||
class SetupRequest(BaseModel):
|
||||
username: str = Field(..., min_length=3, max_length=64)
|
||||
password: str = Field(..., min_length=8, max_length=256)
|
||||
|
||||
|
||||
_PW_POLICY_MSG = "Password must be at least 8 characters"
|
||||
|
||||
|
||||
@router.post("/api/login")
|
||||
@limiter.limit("5/minute")
|
||||
async def api_login(request: Request, response: Response, payload: LoginRequest):
|
||||
username, pw_hash = get_stored_admin()
|
||||
if not pw_hash:
|
||||
raise HTTPException(status_code=409, detail="Admin not configured; run setup")
|
||||
if payload.username != username or not verify_password(payload.password, pw_hash):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
csrf = set_auth_cookies(response, username)
|
||||
return {"ok": True, "csrf_token": csrf}
|
||||
|
||||
|
||||
@router.post("/api/logout")
|
||||
async def api_logout(response: Response):
|
||||
clear_auth_cookies(response)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/api/setup")
|
||||
@limiter.limit("3/minute")
|
||||
async def api_setup(request: Request, response: Response, payload: SetupRequest):
|
||||
if is_admin_configured():
|
||||
raise HTTPException(status_code=409, detail="Admin already configured")
|
||||
if len(payload.password) < 8:
|
||||
raise HTTPException(status_code=400, detail=_PW_POLICY_MSG)
|
||||
if not re.match(r"^[a-zA-Z0-9_.@-]+$", payload.username):
|
||||
raise HTTPException(status_code=400, detail="Invalid username characters")
|
||||
store_admin(payload.username, payload.password)
|
||||
csrf = set_auth_cookies(response, payload.username)
|
||||
return {"ok": True, "csrf_token": csrf}
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Configuration CRUD endpoints (UPS, SMTP, UI). All require session+CSRF for writes."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from ..auth import require_session, require_session_and_csrf
|
||||
from ..config import SMTPConfig, UIConfig, UPSConfig
|
||||
from ..config_manager import (
|
||||
ConfigWriteError,
|
||||
UPSConfigUpdate,
|
||||
config_manager,
|
||||
smtp_redacted_dict,
|
||||
)
|
||||
from ..notifications.email import EmailSendError, send_test_email
|
||||
from ..rate_limit import limiter
|
||||
|
||||
router = APIRouter(prefix="/api/config")
|
||||
|
||||
|
||||
@router.get("/ups")
|
||||
async def get_ups_configs(user=Depends(require_session)):
|
||||
try:
|
||||
ups_list = await config_manager.get_ups_list()
|
||||
return [ups.model_dump() for ups in ups_list]
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/ups/{ups_name}")
|
||||
async def get_ups_config(ups_name: str, user=Depends(require_session)):
|
||||
ups = await config_manager.get_ups(ups_name)
|
||||
if not ups:
|
||||
raise HTTPException(status_code=404, detail="UPS not found")
|
||||
return ups.model_dump()
|
||||
|
||||
|
||||
@router.post("/ups")
|
||||
@limiter.limit("30/minute")
|
||||
async def add_ups_config(
|
||||
request: Request, ups_config: UPSConfig, user=Depends(require_session_and_csrf)
|
||||
):
|
||||
try:
|
||||
await config_manager.add_ups(ups_config)
|
||||
return {"message": "UPS configuration added successfully"}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except ConfigWriteError as e:
|
||||
raise HTTPException(status_code=507, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/ups/{ups_name}")
|
||||
@limiter.limit("30/minute")
|
||||
async def update_ups_config(
|
||||
request: Request,
|
||||
ups_name: str,
|
||||
updates: UPSConfigUpdate,
|
||||
user=Depends(require_session_and_csrf),
|
||||
):
|
||||
try:
|
||||
success = await config_manager.update_ups(ups_name, updates)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="UPS not found")
|
||||
return {"message": "UPS configuration updated successfully"}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except ConfigWriteError as e:
|
||||
raise HTTPException(status_code=507, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/ups/{ups_name}")
|
||||
@limiter.limit("30/minute")
|
||||
async def delete_ups_config(
|
||||
request: Request, ups_name: str, user=Depends(require_session_and_csrf)
|
||||
):
|
||||
success = await config_manager.delete_ups(ups_name)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="UPS not found")
|
||||
return {"message": "UPS configuration deleted successfully"}
|
||||
|
||||
|
||||
@router.post("/ups/{ups_name}/test")
|
||||
@limiter.limit("5/minute")
|
||||
async def test_ups_connection(
|
||||
request: Request, ups_name: str, user=Depends(require_session_and_csrf)
|
||||
):
|
||||
ups = await config_manager.get_ups(ups_name)
|
||||
if not ups:
|
||||
raise HTTPException(status_code=404, detail="UPS not found")
|
||||
return await config_manager.validate_ups_connection(ups)
|
||||
|
||||
|
||||
@router.post("/ups/test")
|
||||
@limiter.limit("5/minute")
|
||||
async def test_new_ups_connection(
|
||||
request: Request, ups_config: UPSConfig, user=Depends(require_session_and_csrf)
|
||||
):
|
||||
return await config_manager.validate_ups_connection(ups_config)
|
||||
|
||||
|
||||
@router.get("/smtp")
|
||||
async def get_smtp_config(user=Depends(require_session)):
|
||||
smtp = await config_manager.get_smtp_config()
|
||||
return smtp_redacted_dict(smtp)
|
||||
|
||||
|
||||
@router.put("/smtp")
|
||||
@limiter.limit("30/minute")
|
||||
async def update_smtp_config(
|
||||
request: Request, smtp_config: SMTPConfig, user=Depends(require_session_and_csrf)
|
||||
):
|
||||
await config_manager.update_smtp_config(smtp_config)
|
||||
return {"message": "SMTP configuration updated"}
|
||||
|
||||
|
||||
@router.post("/smtp/test")
|
||||
@limiter.limit("5/minute")
|
||||
async def test_smtp(request: Request, user=Depends(require_session_and_csrf)):
|
||||
smtp = await config_manager.get_smtp_config()
|
||||
if not smtp:
|
||||
raise HTTPException(status_code=400, detail="SMTP not configured")
|
||||
try:
|
||||
send_test_email(smtp)
|
||||
except EmailSendError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
return {"message": "Test email sent"}
|
||||
|
||||
|
||||
@router.get("/ui")
|
||||
async def get_ui_config(user=Depends(require_session)):
|
||||
from ..config import load_config
|
||||
cfg = load_config()
|
||||
return cfg.ui.model_dump()
|
||||
|
||||
|
||||
@router.put("/ui")
|
||||
@limiter.limit("30/minute")
|
||||
async def update_ui_config(
|
||||
request: Request, payload: dict, user=Depends(require_session_and_csrf)
|
||||
):
|
||||
from ..config import load_config
|
||||
cfg = load_config()
|
||||
ui_dict = cfg.ui.model_dump()
|
||||
for k, v in payload.items():
|
||||
if k in ui_dict:
|
||||
ui_dict[k] = v
|
||||
new_ui = UIConfig(**ui_dict)
|
||||
await config_manager.update_ui_config(new_ui)
|
||||
return {"message": "UI config updated", "ui": new_ui.model_dump()}
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Consolidated events log across all UPS."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from ..auth import require_session
|
||||
from ..config import load_config
|
||||
from ..storage import get_redis
|
||||
|
||||
router = APIRouter(prefix="/api/events")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_events(
|
||||
ups: str | None = Query(None),
|
||||
kind: str | None = Query(None, description="STATUS|XFER"),
|
||||
limit: int = Query(200, ge=1, le=1000),
|
||||
user=Depends(require_session),
|
||||
):
|
||||
r = get_redis()
|
||||
cfg = load_config()
|
||||
target_upses = [ups] if ups else [u.name for u in cfg.ups]
|
||||
out = []
|
||||
for name in target_upses:
|
||||
raw = r.lrange(f"ups:event:list:{name}", 0, limit)
|
||||
for item in raw:
|
||||
parts = item.split("|", 2)
|
||||
if len(parts) != 3:
|
||||
continue
|
||||
try:
|
||||
ts = int(parts[0])
|
||||
except ValueError:
|
||||
continue
|
||||
if kind and parts[1] != kind.upper():
|
||||
continue
|
||||
out.append({"ts": ts, "type": parts[1], "detail": parts[2], "ups": name})
|
||||
out.sort(key=lambda x: x["ts"], reverse=True)
|
||||
return out[:limit]
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Liveness, readiness, and Prometheus metrics endpoints (unauthenticated)."""
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from ..health import liveness, readiness
|
||||
from ..metrics import render_metrics
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/healthz")
|
||||
async def healthz():
|
||||
return liveness()
|
||||
|
||||
|
||||
@router.get("/readyz")
|
||||
async def readyz():
|
||||
status, payload = readiness()
|
||||
return JSONResponse(payload, status_code=status)
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
async def metrics():
|
||||
body, content_type = render_metrics()
|
||||
return Response(content=body, media_type=content_type)
|
||||
@@ -0,0 +1,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"}
|
||||
@@ -0,0 +1,118 @@
|
||||
"""HTML page routes (dashboard, config, login, setup, events, alerts, settings)."""
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from ..auth import CSRF_COOKIE, current_user, is_admin_configured, make_csrf_token
|
||||
from ..config import load_config
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
def _ensure_csrf(request: Request, response):
|
||||
token = request.cookies.get(CSRF_COOKIE)
|
||||
if not token:
|
||||
token = make_csrf_token()
|
||||
response.set_cookie(
|
||||
CSRF_COOKIE, token, httponly=False, samesite="lax", path="/"
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def _session_or_setup_redirect(request: Request):
|
||||
if not is_admin_configured():
|
||||
return RedirectResponse("/setup", status_code=302)
|
||||
user = current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request):
|
||||
redirect = _session_or_setup_redirect(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
cfg = load_config()
|
||||
user = current_user(request)
|
||||
response = templates.TemplateResponse(
|
||||
"dashboard.html",
|
||||
{
|
||||
"request": request,
|
||||
"ups_list": cfg.ups,
|
||||
"ui_cfg": cfg.ui.model_dump(),
|
||||
"current_user": user,
|
||||
"active_nav": "dashboard",
|
||||
},
|
||||
)
|
||||
_ensure_csrf(request, response)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/config", response_class=HTMLResponse)
|
||||
async def config_page(request: Request):
|
||||
redirect = _session_or_setup_redirect(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
response = templates.TemplateResponse(
|
||||
"config.html",
|
||||
{"request": request, "current_user": current_user(request), "active_nav": "config"},
|
||||
)
|
||||
_ensure_csrf(request, response)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/events", response_class=HTMLResponse)
|
||||
async def events_page(request: Request):
|
||||
redirect = _session_or_setup_redirect(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
response = templates.TemplateResponse(
|
||||
"events.html",
|
||||
{"request": request, "current_user": current_user(request), "active_nav": "events"},
|
||||
)
|
||||
_ensure_csrf(request, response)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/alerts", response_class=HTMLResponse)
|
||||
async def alerts_page(request: Request):
|
||||
redirect = _session_or_setup_redirect(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
response = templates.TemplateResponse(
|
||||
"alerts.html",
|
||||
{"request": request, "current_user": current_user(request), "active_nav": "alerts"},
|
||||
)
|
||||
_ensure_csrf(request, response)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request):
|
||||
redirect = _session_or_setup_redirect(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
response = templates.TemplateResponse(
|
||||
"settings.html",
|
||||
{"request": request, "current_user": current_user(request), "active_nav": "settings"},
|
||||
)
|
||||
_ensure_csrf(request, response)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request):
|
||||
if not is_admin_configured():
|
||||
return RedirectResponse("/setup", status_code=302)
|
||||
if current_user(request):
|
||||
return RedirectResponse("/", status_code=302)
|
||||
return templates.TemplateResponse("login.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/setup", response_class=HTMLResponse)
|
||||
async def setup_page(request: Request):
|
||||
if is_admin_configured():
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
return templates.TemplateResponse("setup.html", {"request": request})
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Server-Sent Events stream with snapshots + fleet overview."""
|
||||
import asyncio
|
||||
|
||||
import orjson
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from ..auth import require_session
|
||||
from ..config import load_config
|
||||
from ..config_manager import get_config_version
|
||||
from ..storage import get_latest, get_redis
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/stream")
|
||||
async def stream(user=Depends(require_session)):
|
||||
async def event_gen():
|
||||
while True:
|
||||
cfg = load_config()
|
||||
r = get_redis()
|
||||
payload = {"snapshots": {}, "cfgVersion": get_config_version()}
|
||||
for u in cfg.ups:
|
||||
snap = await get_latest(u.name)
|
||||
if snap:
|
||||
payload["snapshots"][u.name] = snap
|
||||
payload["upsMeta"] = [
|
||||
{
|
||||
"name": u.name,
|
||||
"host": u.host,
|
||||
"port": u.port,
|
||||
"offline": bool(r.get(f"ups:health:offline:{u.name}")),
|
||||
}
|
||||
for u in cfg.ups
|
||||
]
|
||||
for name, snap in payload["snapshots"].items():
|
||||
payload.setdefault(name, snap)
|
||||
yield f"data: {orjson.dumps(payload).decode()}\n\n"
|
||||
await asyncio.sleep(5)
|
||||
|
||||
return StreamingResponse(event_gen(), media_type="text/event-stream")
|
||||
Reference in new issue
Block a user