287 lines
9.2 KiB
Python
287 lines
9.2 KiB
Python
"""
|
|
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('<p class="text-red-500">Modem not found</p>')
|
|
|
|
# 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.
|
|
}
|