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"}