""" SLM Dashboard Router Provides API endpoints for the Sound Level Meters dashboard page. """ from fastapi import APIRouter, Request, Depends, Query from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session from sqlalchemy import func from datetime import datetime, timedelta import asyncio import httpx import logging import os from backend.database import get_db from backend.models import RosterUnit from backend.routers.roster_edit import sync_slm_to_slmm_cache from backend.templates_config import templates logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"]) # SLMM backend URL - configurable via environment variable SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") @router.get("/stats", response_class=HTMLResponse) async def get_slm_stats(request: Request, db: Session = Depends(get_db)): """ Get summary statistics for SLM dashboard. Returns HTML partial with stat cards. """ # Query all SLMs all_slms = db.query(RosterUnit).filter_by(device_type="slm").all() # Count deployed vs benched deployed_count = sum(1 for slm in all_slms if slm.deployed and not slm.retired) benched_count = sum(1 for slm in all_slms if not slm.deployed and not slm.retired) retired_count = sum(1 for slm in all_slms if slm.retired) # Count recently active (checked in last hour) one_hour_ago = datetime.utcnow() - timedelta(hours=1) active_count = sum(1 for slm in all_slms if slm.slm_last_check and slm.slm_last_check > one_hour_ago) return templates.TemplateResponse("partials/slm_stats.html", { "request": request, "total_count": len(all_slms), "deployed_count": deployed_count, "benched_count": benched_count, "active_count": active_count, "retired_count": retired_count }) @router.get("/units", response_class=HTMLResponse) async def get_slm_units( request: Request, db: Session = Depends(get_db), search: str = Query(None), project: str = Query(None), include_measurement: bool = Query(False), ): """ Get list of SLM units for the sidebar. Returns HTML partial with unit cards. """ query = db.query(RosterUnit).filter_by(device_type="slm") # Filter by project if provided if project: query = query.filter(RosterUnit.project_id == project) # Filter by search term if provided if search: search_term = f"%{search}%" query = query.filter( (RosterUnit.id.like(search_term)) | (RosterUnit.slm_model.like(search_term)) | (RosterUnit.address.like(search_term)) ) units = query.order_by( RosterUnit.retired.asc(), RosterUnit.deployed.desc(), RosterUnit.id.asc() ).all() one_hour_ago = datetime.utcnow() - timedelta(hours=1) for unit in units: unit.is_recent = bool(unit.slm_last_check and unit.slm_last_check > one_hour_ago) if include_measurement: async def fetch_measurement_state(client: httpx.AsyncClient, unit_id: str) -> str | None: try: response = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state") if response.status_code == 200: return response.json().get("measurement_state") except Exception: return None return None deployed_units = [unit for unit in units if unit.deployed and not unit.retired] if deployed_units: async with httpx.AsyncClient(timeout=3.0) as client: tasks = [fetch_measurement_state(client, unit.id) for unit in deployed_units] results = await asyncio.gather(*tasks, return_exceptions=True) for unit, state in zip(deployed_units, results): if isinstance(state, Exception): unit.measurement_state = None else: unit.measurement_state = state return templates.TemplateResponse("partials/slm_device_list.html", { "request": request, "units": units }) @router.get("/live-view/{unit_id}", response_class=HTMLResponse) async def get_live_view(request: Request, unit_id: str, db: Session = Depends(get_db)): """ Get live view panel for a specific SLM unit. Returns HTML partial with live metrics and chart. """ # Get unit from database unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="slm").first() if not unit: return templates.TemplateResponse("partials/slm_live_view_error.html", { "request": request, "error": f"Unit {unit_id} not found" }) # Get modem information if assigned modem = None modem_ip = None if unit.deployed_with_modem_id: modem = db.query(RosterUnit).filter_by(id=unit.deployed_with_modem_id, device_type="modem").first() if modem: modem_ip = modem.ip_address else: logger.warning(f"SLM {unit_id} is assigned to modem {unit.deployed_with_modem_id} but modem not found") # Fallback to direct slm_host if no modem assigned (backward compatibility) if not modem_ip and unit.slm_host: modem_ip = unit.slm_host logger.info(f"Using legacy slm_host for {unit_id}: {modem_ip}") # Try to get current status from SLMM current_status = None measurement_state = None is_measuring = False try: async with httpx.AsyncClient(timeout=10.0) as client: # Get measurement state state_response = await client.get( f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state" ) if state_response.status_code == 200: state_data = state_response.json() measurement_state = state_data.get("measurement_state", "Unknown") is_measuring = state_data.get("is_measuring", False) # Get live status (measurement_start_time is already stored in SLMM database) status_response = await client.get( f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live" ) if status_response.status_code == 200: status_data = status_response.json() current_status = status_data.get("data", {}) except Exception as e: logger.error(f"Failed to get status for {unit_id}: {e}") return templates.TemplateResponse("partials/slm_live_view.html", { "request": request, "unit": unit, "modem": modem, "modem_ip": modem_ip, "current_status": current_status, "measurement_state": measurement_state, "is_measuring": is_measuring }) @router.post("/control/{unit_id}/{action}") async def control_slm(unit_id: str, action: str): """ Send control commands to SLM (start, stop, pause, resume, reset). Proxies to SLMM backend. """ valid_actions = ["start", "stop", "pause", "resume", "reset"] if action not in valid_actions: return {"status": "error", "detail": f"Invalid action. Must be one of: {valid_actions}"} try: async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( f"{SLMM_BASE_URL}/api/nl43/{unit_id}/{action}" ) if response.status_code == 200: return response.json() else: return { "status": "error", "detail": f"SLMM returned status {response.status_code}" } except Exception as e: logger.error(f"Failed to control {unit_id}: {e}") return { "status": "error", "detail": str(e) } @router.get("/config/{unit_id}", response_class=HTMLResponse) async def get_slm_config(request: Request, unit_id: str, db: Session = Depends(get_db)): """ Get configuration form for a specific SLM unit. Returns HTML partial with configuration form. """ unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="slm").first() if not unit: return HTMLResponse( content=f'