""" Modem Dashboard Router Provides API endpoints for the Field Modems management page. """ from fastapi import APIRouter, Request, Depends, Query from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session from datetime import datetime import subprocess import time import logging from backend.database import get_db from backend.models import RosterUnit from backend.templates_config import templates logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/modem-dashboard", tags=["modem-dashboard"]) @router.get("/stats", response_class=HTMLResponse) async def get_modem_stats(request: Request, db: Session = Depends(get_db)): """ Get summary statistics for modem dashboard. Returns HTML partial with stat cards. """ # Query all modems all_modems = db.query(RosterUnit).filter_by(device_type="modem").all() # Get IDs of modems that have devices paired to them paired_modem_ids = set() devices_with_modems = db.query(RosterUnit).filter( RosterUnit.deployed_with_modem_id.isnot(None), RosterUnit.retired == False ).all() for device in devices_with_modems: if device.deployed_with_modem_id: paired_modem_ids.add(device.deployed_with_modem_id) # Count categories total_count = len(all_modems) retired_count = sum(1 for m in all_modems if m.retired) # In use = deployed AND paired with a device in_use_count = sum(1 for m in all_modems if m.deployed and not m.retired and m.id in paired_modem_ids) # Spare = deployed but NOT paired (available for assignment) spare_count = sum(1 for m in all_modems if m.deployed and not m.retired and m.id not in paired_modem_ids) # Benched = not deployed and not retired benched_count = sum(1 for m in all_modems if not m.deployed and not m.retired) return templates.TemplateResponse("partials/modem_stats.html", { "request": request, "total_count": total_count, "in_use_count": in_use_count, "spare_count": spare_count, "benched_count": benched_count, "retired_count": retired_count }) @router.get("/units", response_class=HTMLResponse) async def get_modem_units( request: Request, db: Session = Depends(get_db), search: str = Query(None), filter_status: str = Query(None), # "in_use", "spare", "benched", "retired" ): """ Get list of modem units for the dashboard. Returns HTML partial with modem cards. """ query = db.query(RosterUnit).filter_by(device_type="modem") # Filter by search term if provided if search: search_term = f"%{search}%" query = query.filter( (RosterUnit.id.ilike(search_term)) | (RosterUnit.ip_address.ilike(search_term)) | (RosterUnit.hardware_model.ilike(search_term)) | (RosterUnit.phone_number.ilike(search_term)) | (RosterUnit.location.ilike(search_term)) ) modems = query.order_by( RosterUnit.retired.asc(), RosterUnit.deployed.desc(), RosterUnit.id.asc() ).all() # Get paired device info for each modem paired_devices = {} devices_with_modems = db.query(RosterUnit).filter( RosterUnit.deployed_with_modem_id.isnot(None), RosterUnit.retired == False ).all() for device in devices_with_modems: if device.deployed_with_modem_id: paired_devices[device.deployed_with_modem_id] = { "id": device.id, "device_type": device.device_type, "deployed": device.deployed } # Annotate modems with paired device info modem_list = [] for modem in modems: paired = paired_devices.get(modem.id) # Determine status category if modem.retired: status = "retired" elif not modem.deployed: status = "benched" elif paired: status = "in_use" else: status = "spare" # Apply filter if specified if filter_status and status != filter_status: continue modem_list.append({ "id": modem.id, "ip_address": modem.ip_address, "phone_number": modem.phone_number, "hardware_model": modem.hardware_model, "deployed": modem.deployed, "retired": modem.retired, "location": modem.location, "project_id": modem.project_id, "paired_device": paired, "status": status }) return templates.TemplateResponse("partials/modem_list.html", { "request": request, "modems": modem_list }) @router.get("/{modem_id}/paired-device") async def get_paired_device(modem_id: str, db: Session = Depends(get_db)): """ Get the device (SLM/seismograph) that is paired with this modem. Returns JSON with device info or null if not paired. """ # Check modem exists 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"} # Find device paired with this modem device = db.query(RosterUnit).filter( RosterUnit.deployed_with_modem_id == modem_id, RosterUnit.retired == False ).first() if device: return { "paired": True, "device": { "id": device.id, "device_type": device.device_type, "deployed": device.deployed, "project_id": device.project_id, "location": device.location or device.address } } return {"paired": False, "device": None} @router.get("/{modem_id}/paired-device-html", response_class=HTMLResponse) async def get_paired_device_html(modem_id: str, request: Request, db: Session = Depends(get_db)): """ Get HTML partial showing the device paired with this modem. Used by unit_detail.html for modems. """ # Check modem exists modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first() if not modem: return HTMLResponse('

Modem not found

') # Find device paired with this modem device = db.query(RosterUnit).filter( RosterUnit.deployed_with_modem_id == modem_id, RosterUnit.retired == False ).first() return templates.TemplateResponse("partials/modem_paired_device.html", { "request": request, "modem_id": modem_id, "device": device }) @router.get("/{modem_id}/ping") async def ping_modem(modem_id: str, db: Session = Depends(get_db)): """ Test modem connectivity with a simple ping. Returns response time and connection status. """ # 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_ms": response_time, "message": "Modem is responding" } 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" } except Exception as e: logger.error(f"Failed to ping modem {modem_id}: {e}") return { "status": "error", "modem_id": modem_id, "detail": str(e) } @router.get("/{modem_id}/diagnostics") async def get_modem_diagnostics(modem_id: str, db: Session = Depends(get_db)): """ Get modem diagnostics (signal strength, data usage, uptime). Currently returns placeholders. When ModemManager is available, this endpoint will query it for real diagnostics. """ 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"} # TODO: Query ModemManager backend when available return { "status": "unavailable", "message": "ModemManager integration not yet available", "modem_id": modem_id, "signal_strength_dbm": None, "data_usage_mb": None, "uptime_seconds": None, "carrier": None, "connection_type": None # LTE, 5G, etc. }