56bd3041cf
feat: Location no longer assigned directly to unit, locations and coords are assigned to location only, unit only is deployed or benched.
432 lines
14 KiB
Python
432 lines
14 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.services.unit_location import get_active_location
|
|
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))
|
|
)
|
|
|
|
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
|
|
|
|
# Inherit location from the paired device's active assignment.
|
|
loc = get_active_location(db, modem.id) if paired else None
|
|
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": (loc or {}).get("address") or (loc or {}).get("name") or "",
|
|
"project_id": (loc or {}).get("project_id") or 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:
|
|
loc = get_active_location(db, device.id)
|
|
return {
|
|
"paired": True,
|
|
"device": {
|
|
"id": device.id,
|
|
"device_type": device.device_type,
|
|
"deployed": device.deployed,
|
|
"project_id": (loc or {}).get("project_id") or device.project_id,
|
|
"location": (loc or {}).get("address") or (loc or {}).get("name") or ""
|
|
}
|
|
}
|
|
|
|
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.
|
|
}
|
|
|
|
|
|
@router.get("/{modem_id}/pairable-devices")
|
|
async def get_pairable_devices(
|
|
modem_id: str,
|
|
db: Session = Depends(get_db),
|
|
search: str = Query(None),
|
|
hide_paired: bool = Query(True)
|
|
):
|
|
"""
|
|
Get list of devices (seismographs and SLMs) that can be paired with this modem.
|
|
Used by the device picker modal in unit_detail.html.
|
|
"""
|
|
# 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"}
|
|
|
|
# Query seismographs and SLMs
|
|
query = db.query(RosterUnit).filter(
|
|
RosterUnit.device_type.in_(["seismograph", "sound_level_meter"]),
|
|
RosterUnit.retired == False
|
|
)
|
|
|
|
# Filter by search term if provided
|
|
if search:
|
|
search_term = f"%{search}%"
|
|
query = query.filter(
|
|
(RosterUnit.id.ilike(search_term)) |
|
|
(RosterUnit.project_id.ilike(search_term)) |
|
|
(RosterUnit.note.ilike(search_term))
|
|
)
|
|
|
|
devices = query.order_by(
|
|
RosterUnit.deployed.desc(),
|
|
RosterUnit.device_type.asc(),
|
|
RosterUnit.id.asc()
|
|
).all()
|
|
|
|
# Build device list
|
|
device_list = []
|
|
for device in devices:
|
|
# Skip already paired devices if hide_paired is True
|
|
is_paired_to_other = (
|
|
device.deployed_with_modem_id is not None and
|
|
device.deployed_with_modem_id != modem_id
|
|
)
|
|
is_paired_to_this = device.deployed_with_modem_id == modem_id
|
|
|
|
if hide_paired and is_paired_to_other:
|
|
continue
|
|
|
|
loc = get_active_location(db, device.id)
|
|
device_list.append({
|
|
"id": device.id,
|
|
"device_type": device.device_type,
|
|
"deployed": device.deployed,
|
|
"project_id": (loc or {}).get("project_id") or device.project_id,
|
|
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
|
|
"note": device.note,
|
|
"paired_modem_id": device.deployed_with_modem_id,
|
|
"is_paired_to_this": is_paired_to_this,
|
|
"is_paired_to_other": is_paired_to_other
|
|
})
|
|
|
|
return {"devices": device_list, "modem_id": modem_id}
|
|
|
|
|
|
@router.post("/{modem_id}/pair")
|
|
async def pair_device_to_modem(
|
|
modem_id: str,
|
|
db: Session = Depends(get_db),
|
|
device_id: str = Query(..., description="ID of the device to pair")
|
|
):
|
|
"""
|
|
Pair a device (seismograph or SLM) to this modem.
|
|
Updates the device's deployed_with_modem_id field.
|
|
"""
|
|
# 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 the device
|
|
device = db.query(RosterUnit).filter(
|
|
RosterUnit.id == device_id,
|
|
RosterUnit.device_type.in_(["seismograph", "sound_level_meter"]),
|
|
RosterUnit.retired == False
|
|
).first()
|
|
if not device:
|
|
return {"status": "error", "detail": f"Device {device_id} not found"}
|
|
|
|
# Unpair any device currently paired to this modem
|
|
currently_paired = db.query(RosterUnit).filter(
|
|
RosterUnit.deployed_with_modem_id == modem_id
|
|
).all()
|
|
for paired_device in currently_paired:
|
|
paired_device.deployed_with_modem_id = None
|
|
|
|
# Pair the new device
|
|
device.deployed_with_modem_id = modem_id
|
|
db.commit()
|
|
|
|
return {
|
|
"status": "success",
|
|
"modem_id": modem_id,
|
|
"device_id": device_id,
|
|
"message": f"Device {device_id} paired to modem {modem_id}"
|
|
}
|
|
|
|
|
|
@router.post("/{modem_id}/unpair")
|
|
async def unpair_device_from_modem(modem_id: str, db: Session = Depends(get_db)):
|
|
"""
|
|
Unpair any device currently paired to this modem.
|
|
"""
|
|
# 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 and unpair device
|
|
device = db.query(RosterUnit).filter(
|
|
RosterUnit.deployed_with_modem_id == modem_id
|
|
).first()
|
|
|
|
if device:
|
|
old_device_id = device.id
|
|
device.deployed_with_modem_id = None
|
|
db.commit()
|
|
return {
|
|
"status": "success",
|
|
"modem_id": modem_id,
|
|
"unpaired_device_id": old_device_id,
|
|
"message": f"Device {old_device_id} unpaired from modem {modem_id}"
|
|
}
|
|
|
|
return {
|
|
"status": "success",
|
|
"modem_id": modem_id,
|
|
"message": "No device was paired to this modem"
|
|
}
|