- Updated all instances of device_type from "sound_level_meter" to "slm" across the codebase. - Enhanced documentation to reflect the new device type standardization. - Added migration script to convert legacy device types in the database. - Updated relevant API endpoints, models, and frontend templates to use the new device type. - Ensured backward compatibility by deprecating the old device type without data loss.
381 lines
14 KiB
Python
381 lines
14 KiB
Python
"""
|
|
SLM Dashboard Router
|
|
|
|
Provides API endpoints for the Sound Level Meters dashboard page.
|
|
"""
|
|
|
|
from fastapi import APIRouter, Request, Depends, Query
|
|
from fastapi.templating import Jinja2Templates
|
|
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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"])
|
|
templates = Jinja2Templates(directory="templates")
|
|
|
|
# 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)
|
|
|
|
# If measuring, sync start time from FTP to database (fixes wrong timestamps)
|
|
if is_measuring:
|
|
try:
|
|
sync_response = await client.post(
|
|
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/sync-start-time",
|
|
timeout=10.0
|
|
)
|
|
if sync_response.status_code == 200:
|
|
sync_data = sync_response.json()
|
|
logger.info(f"Synced start time for {unit_id}: {sync_data.get('message')}")
|
|
else:
|
|
logger.warning(f"Failed to sync start time for {unit_id}: {sync_response.status_code}")
|
|
except Exception as e:
|
|
# Don't fail the whole request if sync fails
|
|
logger.warning(f"Could not sync start time for {unit_id}: {e}")
|
|
|
|
# Get live status (now with corrected start time)
|
|
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'<div class="text-red-500">Unit {unit_id} not found</div>',
|
|
status_code=404
|
|
)
|
|
|
|
return templates.TemplateResponse("partials/slm_config_form.html", {
|
|
"request": request,
|
|
"unit": unit
|
|
})
|
|
|
|
|
|
@router.post("/config/{unit_id}")
|
|
async def save_slm_config(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
|
"""
|
|
Save SLM configuration.
|
|
Updates unit parameters in the database.
|
|
"""
|
|
unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="slm").first()
|
|
|
|
if not unit:
|
|
return {"status": "error", "detail": f"Unit {unit_id} not found"}
|
|
|
|
try:
|
|
# Get form data
|
|
form_data = await request.form()
|
|
|
|
# Update SLM-specific fields
|
|
unit.slm_model = form_data.get("slm_model") or None
|
|
unit.slm_serial_number = form_data.get("slm_serial_number") or None
|
|
unit.slm_frequency_weighting = form_data.get("slm_frequency_weighting") or None
|
|
unit.slm_time_weighting = form_data.get("slm_time_weighting") or None
|
|
unit.slm_measurement_range = form_data.get("slm_measurement_range") or None
|
|
|
|
# Update network configuration
|
|
modem_id = form_data.get("deployed_with_modem_id")
|
|
unit.deployed_with_modem_id = modem_id if modem_id else None
|
|
|
|
# Always update TCP and FTP ports (used regardless of modem assignment)
|
|
unit.slm_tcp_port = int(form_data.get("slm_tcp_port")) if form_data.get("slm_tcp_port") else None
|
|
unit.slm_ftp_port = int(form_data.get("slm_ftp_port")) if form_data.get("slm_ftp_port") else None
|
|
|
|
# Only update direct IP if no modem is assigned
|
|
if not modem_id:
|
|
unit.slm_host = form_data.get("slm_host") or None
|
|
else:
|
|
# Clear legacy direct IP field when modem is assigned
|
|
unit.slm_host = None
|
|
|
|
db.commit()
|
|
logger.info(f"Updated configuration for SLM {unit_id}")
|
|
|
|
# Sync updated configuration to SLMM cache
|
|
logger.info(f"Syncing SLM {unit_id} config changes to SLMM cache...")
|
|
result = await sync_slm_to_slmm_cache(
|
|
unit_id=unit_id,
|
|
host=unit.slm_host, # Use the updated host from Terra-View
|
|
tcp_port=unit.slm_tcp_port,
|
|
ftp_port=unit.slm_ftp_port,
|
|
deployed_with_modem_id=unit.deployed_with_modem_id, # Resolve modem IP if assigned
|
|
db=db
|
|
)
|
|
|
|
if not result["success"]:
|
|
logger.warning(f"SLMM cache sync warning for {unit_id}: {result['message']}")
|
|
# Config still saved in Terra-View (source of truth)
|
|
|
|
return {"status": "success", "unit_id": unit_id}
|
|
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Failed to save config for {unit_id}: {e}")
|
|
return {"status": "error", "detail": str(e)}
|
|
|
|
|
|
@router.get("/test-modem/{modem_id}")
|
|
async def test_modem_connection(modem_id: str, db: Session = Depends(get_db)):
|
|
"""
|
|
Test modem connectivity with a simple ping/health check.
|
|
Returns response time and connection status.
|
|
"""
|
|
import subprocess
|
|
import time
|
|
|
|
# Get modem from database
|
|
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
|
|
|
|
if not modem:
|
|
return {"status": "error", "detail": f"Modem {modem_id} not found"}
|
|
|
|
if not modem.ip_address:
|
|
return {"status": "error", "detail": f"Modem {modem_id} has no IP address configured"}
|
|
|
|
try:
|
|
# Ping the modem (1 packet, 2 second timeout)
|
|
start_time = time.time()
|
|
result = subprocess.run(
|
|
["ping", "-c", "1", "-W", "2", modem.ip_address],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=3
|
|
)
|
|
response_time = int((time.time() - start_time) * 1000) # Convert to milliseconds
|
|
|
|
if result.returncode == 0:
|
|
return {
|
|
"status": "success",
|
|
"modem_id": modem_id,
|
|
"ip_address": modem.ip_address,
|
|
"response_time": response_time,
|
|
"message": "Modem is responding to ping"
|
|
}
|
|
else:
|
|
return {
|
|
"status": "error",
|
|
"modem_id": modem_id,
|
|
"ip_address": modem.ip_address,
|
|
"detail": "Modem not responding to ping"
|
|
}
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return {
|
|
"status": "error",
|
|
"modem_id": modem_id,
|
|
"ip_address": modem.ip_address,
|
|
"detail": "Ping timeout (> 2 seconds)"
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Failed to ping modem {modem_id}: {e}")
|
|
return {
|
|
"status": "error",
|
|
"modem_id": modem_id,
|
|
"detail": str(e)
|
|
}
|