Add:
- pair_devices.html template for device pairing interface - SLMM device control lock prevents flooding nl43. Fix: - Polling intervals for SLMM. - modem view now list - device pairing much improved. - various other tweaks through out UI. - SLMM Scheduled downloads fixed.
This commit is contained in:
@@ -21,6 +21,7 @@ from backend.database import engine, Base, get_db
|
|||||||
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, projects, project_locations, scheduler, modem_dashboard
|
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, projects, project_locations, scheduler, modem_dashboard
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
from backend.models import IgnoredUnit
|
from backend.models import IgnoredUnit
|
||||||
|
from backend.utils.timezone import get_user_timezone
|
||||||
|
|
||||||
# Create database tables
|
# Create database tables
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
@@ -223,6 +224,67 @@ async def modems_page(request: Request):
|
|||||||
return templates.TemplateResponse("modems.html", {"request": request})
|
return templates.TemplateResponse("modems.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/pair-devices", response_class=HTMLResponse)
|
||||||
|
async def pair_devices_page(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Device pairing page - two-column layout for pairing recorders with modems.
|
||||||
|
"""
|
||||||
|
from backend.models import RosterUnit
|
||||||
|
|
||||||
|
# Get all non-retired recorders (seismographs and SLMs)
|
||||||
|
recorders = db.query(RosterUnit).filter(
|
||||||
|
RosterUnit.retired == False,
|
||||||
|
RosterUnit.device_type.in_(["seismograph", "slm", None]) # None defaults to seismograph
|
||||||
|
).order_by(RosterUnit.id).all()
|
||||||
|
|
||||||
|
# Get all non-retired modems
|
||||||
|
modems = db.query(RosterUnit).filter(
|
||||||
|
RosterUnit.retired == False,
|
||||||
|
RosterUnit.device_type == "modem"
|
||||||
|
).order_by(RosterUnit.id).all()
|
||||||
|
|
||||||
|
# Build existing pairings list
|
||||||
|
pairings = []
|
||||||
|
for recorder in recorders:
|
||||||
|
if recorder.deployed_with_modem_id:
|
||||||
|
modem = next((m for m in modems if m.id == recorder.deployed_with_modem_id), None)
|
||||||
|
pairings.append({
|
||||||
|
"recorder_id": recorder.id,
|
||||||
|
"recorder_type": (recorder.device_type or "seismograph").upper(),
|
||||||
|
"modem_id": recorder.deployed_with_modem_id,
|
||||||
|
"modem_ip": modem.ip_address if modem else None
|
||||||
|
})
|
||||||
|
|
||||||
|
# Convert to dicts for template
|
||||||
|
recorders_data = [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"device_type": r.device_type or "seismograph",
|
||||||
|
"deployed": r.deployed,
|
||||||
|
"deployed_with_modem_id": r.deployed_with_modem_id
|
||||||
|
}
|
||||||
|
for r in recorders
|
||||||
|
]
|
||||||
|
|
||||||
|
modems_data = [
|
||||||
|
{
|
||||||
|
"id": m.id,
|
||||||
|
"deployed": m.deployed,
|
||||||
|
"deployed_with_unit_id": m.deployed_with_unit_id,
|
||||||
|
"ip_address": m.ip_address,
|
||||||
|
"phone_number": m.phone_number
|
||||||
|
}
|
||||||
|
for m in modems
|
||||||
|
]
|
||||||
|
|
||||||
|
return templates.TemplateResponse("pair_devices.html", {
|
||||||
|
"request": request,
|
||||||
|
"recorders": recorders_data,
|
||||||
|
"modems": modems_data,
|
||||||
|
"pairings": pairings
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/projects", response_class=HTMLResponse)
|
@app.get("/projects", response_class=HTMLResponse)
|
||||||
async def projects_page(request: Request):
|
async def projects_page(request: Request):
|
||||||
"""Projects management and overview"""
|
"""Projects management and overview"""
|
||||||
@@ -587,6 +649,7 @@ async def devices_all_partial(request: Request):
|
|||||||
"last_calibrated": unit_data.get("last_calibrated"),
|
"last_calibrated": unit_data.get("last_calibrated"),
|
||||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||||
|
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
|
||||||
"ip_address": unit_data.get("ip_address"),
|
"ip_address": unit_data.get("ip_address"),
|
||||||
"phone_number": unit_data.get("phone_number"),
|
"phone_number": unit_data.get("phone_number"),
|
||||||
"hardware_model": unit_data.get("hardware_model"),
|
"hardware_model": unit_data.get("hardware_model"),
|
||||||
@@ -610,6 +673,7 @@ async def devices_all_partial(request: Request):
|
|||||||
"last_calibrated": unit_data.get("last_calibrated"),
|
"last_calibrated": unit_data.get("last_calibrated"),
|
||||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||||
|
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
|
||||||
"ip_address": unit_data.get("ip_address"),
|
"ip_address": unit_data.get("ip_address"),
|
||||||
"phone_number": unit_data.get("phone_number"),
|
"phone_number": unit_data.get("phone_number"),
|
||||||
"hardware_model": unit_data.get("hardware_model"),
|
"hardware_model": unit_data.get("hardware_model"),
|
||||||
@@ -633,6 +697,7 @@ async def devices_all_partial(request: Request):
|
|||||||
"last_calibrated": unit_data.get("last_calibrated"),
|
"last_calibrated": unit_data.get("last_calibrated"),
|
||||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||||
|
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
|
||||||
"ip_address": unit_data.get("ip_address"),
|
"ip_address": unit_data.get("ip_address"),
|
||||||
"phone_number": unit_data.get("phone_number"),
|
"phone_number": unit_data.get("phone_number"),
|
||||||
"hardware_model": unit_data.get("hardware_model"),
|
"hardware_model": unit_data.get("hardware_model"),
|
||||||
@@ -656,6 +721,7 @@ async def devices_all_partial(request: Request):
|
|||||||
"last_calibrated": None,
|
"last_calibrated": None,
|
||||||
"next_calibration_due": None,
|
"next_calibration_due": None,
|
||||||
"deployed_with_modem_id": None,
|
"deployed_with_modem_id": None,
|
||||||
|
"deployed_with_unit_id": None,
|
||||||
"ip_address": None,
|
"ip_address": None,
|
||||||
"phone_number": None,
|
"phone_number": None,
|
||||||
"hardware_model": None,
|
"hardware_model": None,
|
||||||
@@ -678,7 +744,8 @@ async def devices_all_partial(request: Request):
|
|||||||
return templates.TemplateResponse("partials/devices_table.html", {
|
return templates.TemplateResponse("partials/devices_table.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"units": units_list,
|
"units": units_list,
|
||||||
"timestamp": datetime.now().strftime("%H:%M:%S")
|
"timestamp": datetime.now().strftime("%H:%M:%S"),
|
||||||
|
"user_timezone": get_user_timezone()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import os
|
|||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory
|
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory
|
||||||
|
from backend.services.slmm_sync import sync_slm_to_slmm
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -456,7 +457,7 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/edit/{unit_id}")
|
@router.post("/edit/{unit_id}")
|
||||||
def edit_roster_unit(
|
async def edit_roster_unit(
|
||||||
unit_id: str,
|
unit_id: str,
|
||||||
device_type: str = Form("seismograph"),
|
device_type: str = Form("seismograph"),
|
||||||
unit_type: str = Form("series3"),
|
unit_type: str = Form("series3"),
|
||||||
@@ -662,6 +663,16 @@ def edit_roster_unit(
|
|||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# Sync SLM polling config to SLMM when deployed/retired status changes
|
||||||
|
# This ensures benched units stop being polled
|
||||||
|
if device_type == "slm" and (old_deployed != deployed_bool or old_retired != retired_bool):
|
||||||
|
db.refresh(unit) # Refresh to get committed values
|
||||||
|
try:
|
||||||
|
await sync_slm_to_slmm(unit)
|
||||||
|
logger.info(f"Synced SLM {unit_id} polling config to SLMM (deployed={deployed_bool}, retired={retired_bool})")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to sync SLM {unit_id} polling config to SLMM: {e}")
|
||||||
|
|
||||||
response = {"message": "Unit updated", "id": unit_id, "device_type": device_type}
|
response = {"message": "Unit updated", "id": unit_id, "device_type": device_type}
|
||||||
if cascaded_unit_id:
|
if cascaded_unit_id:
|
||||||
response["cascaded_to"] = cascaded_unit_id
|
response["cascaded_to"] = cascaded_unit_id
|
||||||
@@ -669,7 +680,7 @@ def edit_roster_unit(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/set-deployed/{unit_id}")
|
@router.post("/set-deployed/{unit_id}")
|
||||||
def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends(get_db)):
|
async def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends(get_db)):
|
||||||
unit = get_or_create_roster_unit(db, unit_id)
|
unit = get_or_create_roster_unit(db, unit_id)
|
||||||
old_deployed = unit.deployed
|
old_deployed = unit.deployed
|
||||||
unit.deployed = deployed
|
unit.deployed = deployed
|
||||||
@@ -690,11 +701,21 @@ def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends
|
|||||||
)
|
)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# Sync SLM polling config to SLMM when deployed status changes
|
||||||
|
if unit.device_type == "slm" and old_deployed != deployed:
|
||||||
|
db.refresh(unit)
|
||||||
|
try:
|
||||||
|
await sync_slm_to_slmm(unit)
|
||||||
|
logger.info(f"Synced SLM {unit_id} polling config to SLMM (deployed={deployed})")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to sync SLM {unit_id} polling config to SLMM: {e}")
|
||||||
|
|
||||||
return {"message": "Updated", "id": unit_id, "deployed": deployed}
|
return {"message": "Updated", "id": unit_id, "deployed": deployed}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/set-retired/{unit_id}")
|
@router.post("/set-retired/{unit_id}")
|
||||||
def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(get_db)):
|
async def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(get_db)):
|
||||||
unit = get_or_create_roster_unit(db, unit_id)
|
unit = get_or_create_roster_unit(db, unit_id)
|
||||||
old_retired = unit.retired
|
old_retired = unit.retired
|
||||||
unit.retired = retired
|
unit.retired = retired
|
||||||
@@ -715,6 +736,16 @@ def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(g
|
|||||||
)
|
)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# Sync SLM polling config to SLMM when retired status changes
|
||||||
|
if unit.device_type == "slm" and old_retired != retired:
|
||||||
|
db.refresh(unit)
|
||||||
|
try:
|
||||||
|
await sync_slm_to_slmm(unit)
|
||||||
|
logger.info(f"Synced SLM {unit_id} polling config to SLMM (retired={retired})")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to sync SLM {unit_id} polling config to SLMM: {e}")
|
||||||
|
|
||||||
return {"message": "Updated", "id": unit_id, "retired": retired}
|
return {"message": "Updated", "id": unit_id, "retired": retired}
|
||||||
|
|
||||||
|
|
||||||
@@ -1156,3 +1187,145 @@ def delete_history_entry(history_id: int, db: Session = Depends(get_db)):
|
|||||||
db.delete(history_entry)
|
db.delete(history_entry)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "History entry deleted", "id": history_id}
|
return {"message": "History entry deleted", "id": history_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pair-devices")
|
||||||
|
async def pair_devices(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a bidirectional pairing between a recorder (seismograph/SLM) and a modem.
|
||||||
|
|
||||||
|
Sets:
|
||||||
|
- recorder.deployed_with_modem_id = modem_id
|
||||||
|
- modem.deployed_with_unit_id = recorder_id
|
||||||
|
|
||||||
|
Also clears any previous pairings for both devices.
|
||||||
|
"""
|
||||||
|
data = await request.json()
|
||||||
|
recorder_id = data.get("recorder_id")
|
||||||
|
modem_id = data.get("modem_id")
|
||||||
|
|
||||||
|
if not recorder_id or not modem_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Both recorder_id and modem_id are required")
|
||||||
|
|
||||||
|
# Get or create the units
|
||||||
|
recorder = db.query(RosterUnit).filter(RosterUnit.id == recorder_id).first()
|
||||||
|
modem = db.query(RosterUnit).filter(RosterUnit.id == modem_id).first()
|
||||||
|
|
||||||
|
if not recorder:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Recorder {recorder_id} not found in roster")
|
||||||
|
if not modem:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Modem {modem_id} not found in roster")
|
||||||
|
|
||||||
|
# Validate device types
|
||||||
|
if recorder.device_type == "modem":
|
||||||
|
raise HTTPException(status_code=400, detail=f"{recorder_id} is a modem, not a recorder")
|
||||||
|
if modem.device_type != "modem":
|
||||||
|
raise HTTPException(status_code=400, detail=f"{modem_id} is not a modem (type: {modem.device_type})")
|
||||||
|
|
||||||
|
# Clear any previous pairings
|
||||||
|
# If recorder was paired with a different modem, clear that modem's link
|
||||||
|
if recorder.deployed_with_modem_id and recorder.deployed_with_modem_id != modem_id:
|
||||||
|
old_modem = db.query(RosterUnit).filter(RosterUnit.id == recorder.deployed_with_modem_id).first()
|
||||||
|
if old_modem and old_modem.deployed_with_unit_id == recorder_id:
|
||||||
|
record_history(db, old_modem.id, "update", "deployed_with_unit_id",
|
||||||
|
old_modem.deployed_with_unit_id, None, "pair_devices", f"Cleared by new pairing")
|
||||||
|
old_modem.deployed_with_unit_id = None
|
||||||
|
|
||||||
|
# If modem was paired with a different recorder, clear that recorder's link
|
||||||
|
if modem.deployed_with_unit_id and modem.deployed_with_unit_id != recorder_id:
|
||||||
|
old_recorder = db.query(RosterUnit).filter(RosterUnit.id == modem.deployed_with_unit_id).first()
|
||||||
|
if old_recorder and old_recorder.deployed_with_modem_id == modem_id:
|
||||||
|
record_history(db, old_recorder.id, "update", "deployed_with_modem_id",
|
||||||
|
old_recorder.deployed_with_modem_id, None, "pair_devices", f"Cleared by new pairing")
|
||||||
|
old_recorder.deployed_with_modem_id = None
|
||||||
|
|
||||||
|
# Record history for the pairing
|
||||||
|
old_recorder_modem = recorder.deployed_with_modem_id
|
||||||
|
old_modem_unit = modem.deployed_with_unit_id
|
||||||
|
|
||||||
|
# Set the new pairing
|
||||||
|
recorder.deployed_with_modem_id = modem_id
|
||||||
|
modem.deployed_with_unit_id = recorder_id
|
||||||
|
|
||||||
|
# Record history
|
||||||
|
if old_recorder_modem != modem_id:
|
||||||
|
record_history(db, recorder_id, "update", "deployed_with_modem_id",
|
||||||
|
old_recorder_modem, modem_id, "pair_devices", f"Paired with modem")
|
||||||
|
if old_modem_unit != recorder_id:
|
||||||
|
record_history(db, modem_id, "update", "deployed_with_unit_id",
|
||||||
|
old_modem_unit, recorder_id, "pair_devices", f"Paired with recorder")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Paired {recorder_id} with modem {modem_id}")
|
||||||
|
|
||||||
|
# If SLM, sync to SLMM cache
|
||||||
|
if recorder.device_type == "slm":
|
||||||
|
await sync_slm_to_slmm_cache(
|
||||||
|
unit_id=recorder_id,
|
||||||
|
host=recorder.slm_host,
|
||||||
|
tcp_port=recorder.slm_tcp_port,
|
||||||
|
ftp_port=recorder.slm_ftp_port,
|
||||||
|
deployed_with_modem_id=modem_id,
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Paired {recorder_id} with {modem_id}",
|
||||||
|
"recorder_id": recorder_id,
|
||||||
|
"modem_id": modem_id
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/unpair-devices")
|
||||||
|
async def unpair_devices(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Remove the bidirectional pairing between a recorder and modem.
|
||||||
|
|
||||||
|
Clears:
|
||||||
|
- recorder.deployed_with_modem_id
|
||||||
|
- modem.deployed_with_unit_id
|
||||||
|
"""
|
||||||
|
data = await request.json()
|
||||||
|
recorder_id = data.get("recorder_id")
|
||||||
|
modem_id = data.get("modem_id")
|
||||||
|
|
||||||
|
if not recorder_id or not modem_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Both recorder_id and modem_id are required")
|
||||||
|
|
||||||
|
recorder = db.query(RosterUnit).filter(RosterUnit.id == recorder_id).first()
|
||||||
|
modem = db.query(RosterUnit).filter(RosterUnit.id == modem_id).first()
|
||||||
|
|
||||||
|
changes_made = False
|
||||||
|
|
||||||
|
if recorder and recorder.deployed_with_modem_id == modem_id:
|
||||||
|
record_history(db, recorder_id, "update", "deployed_with_modem_id",
|
||||||
|
recorder.deployed_with_modem_id, None, "unpair_devices", "Unpairing")
|
||||||
|
recorder.deployed_with_modem_id = None
|
||||||
|
changes_made = True
|
||||||
|
|
||||||
|
if modem and modem.deployed_with_unit_id == recorder_id:
|
||||||
|
record_history(db, modem_id, "update", "deployed_with_unit_id",
|
||||||
|
modem.deployed_with_unit_id, None, "unpair_devices", "Unpairing")
|
||||||
|
modem.deployed_with_unit_id = None
|
||||||
|
changes_made = True
|
||||||
|
|
||||||
|
if changes_made:
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Unpaired {recorder_id} from modem {modem_id}")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Unpaired {recorder_id} from {modem_id}"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "No pairing found between these devices"
|
||||||
|
}
|
||||||
|
|||||||
@@ -289,6 +289,74 @@ class DeviceController:
|
|||||||
else:
|
else:
|
||||||
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# FTP Control
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def enable_ftp(
|
||||||
|
self,
|
||||||
|
unit_id: str,
|
||||||
|
device_type: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Enable FTP server on device.
|
||||||
|
|
||||||
|
Must be called before downloading files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
device_type: "slm" | "seismograph"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response dict with status
|
||||||
|
"""
|
||||||
|
if device_type == "slm":
|
||||||
|
try:
|
||||||
|
return await self.slmm_client.enable_ftp(unit_id)
|
||||||
|
except SLMMClientError as e:
|
||||||
|
raise DeviceControllerError(f"SLMM error: {str(e)}")
|
||||||
|
|
||||||
|
elif device_type == "seismograph":
|
||||||
|
return {
|
||||||
|
"status": "not_implemented",
|
||||||
|
"message": "Seismograph FTP not yet implemented",
|
||||||
|
"unit_id": unit_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||||
|
|
||||||
|
async def disable_ftp(
|
||||||
|
self,
|
||||||
|
unit_id: str,
|
||||||
|
device_type: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Disable FTP server on device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
device_type: "slm" | "seismograph"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response dict with status
|
||||||
|
"""
|
||||||
|
if device_type == "slm":
|
||||||
|
try:
|
||||||
|
return await self.slmm_client.disable_ftp(unit_id)
|
||||||
|
except SLMMClientError as e:
|
||||||
|
raise DeviceControllerError(f"SLMM error: {str(e)}")
|
||||||
|
|
||||||
|
elif device_type == "seismograph":
|
||||||
|
return {
|
||||||
|
"status": "not_implemented",
|
||||||
|
"message": "Seismograph FTP not yet implemented",
|
||||||
|
"unit_id": unit_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# Device Configuration
|
# Device Configuration
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|||||||
@@ -350,7 +350,14 @@ class SchedulerService:
|
|||||||
unit_id: str,
|
unit_id: str,
|
||||||
db: Session,
|
db: Session,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Execute a 'download' action."""
|
"""Execute a 'download' action.
|
||||||
|
|
||||||
|
This handles standalone download actions (not part of stop_cycle).
|
||||||
|
The workflow is:
|
||||||
|
1. Enable FTP on device
|
||||||
|
2. Download current measurement folder
|
||||||
|
3. (Optionally disable FTP - left enabled for now)
|
||||||
|
"""
|
||||||
# Get project and location info for file path
|
# Get project and location info for file path
|
||||||
location = db.query(MonitoringLocation).filter_by(id=action.location_id).first()
|
location = db.query(MonitoringLocation).filter_by(id=action.location_id).first()
|
||||||
project = db.query(Project).filter_by(id=action.project_id).first()
|
project = db.query(Project).filter_by(id=action.project_id).first()
|
||||||
@@ -358,8 +365,8 @@ class SchedulerService:
|
|||||||
if not location or not project:
|
if not location or not project:
|
||||||
raise Exception("Project or location not found")
|
raise Exception("Project or location not found")
|
||||||
|
|
||||||
# Build destination path
|
# Build destination path (for logging/metadata reference)
|
||||||
# Example: data/Projects/{project-id}/sound/{location-name}/session-{timestamp}/
|
# Actual download location is managed by SLMM (data/downloads/{unit_id}/)
|
||||||
session_timestamp = datetime.utcnow().strftime("%Y-%m-%d-%H%M")
|
session_timestamp = datetime.utcnow().strftime("%Y-%m-%d-%H%M")
|
||||||
location_type_dir = "sound" if action.device_type == "slm" else "vibration"
|
location_type_dir = "sound" if action.device_type == "slm" else "vibration"
|
||||||
|
|
||||||
@@ -368,12 +375,18 @@ class SchedulerService:
|
|||||||
f"{location.name}/session-{session_timestamp}/"
|
f"{location.name}/session-{session_timestamp}/"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Download files via device controller
|
# Step 1: Enable FTP on device
|
||||||
|
logger.info(f"Enabling FTP on {unit_id} for download")
|
||||||
|
await self.device_controller.enable_ftp(unit_id, action.device_type)
|
||||||
|
|
||||||
|
# Step 2: Download current measurement folder
|
||||||
|
# The slmm_client.download_files() now automatically determines the correct
|
||||||
|
# folder based on the device's current index number
|
||||||
response = await self.device_controller.download_files(
|
response = await self.device_controller.download_files(
|
||||||
unit_id,
|
unit_id,
|
||||||
action.device_type,
|
action.device_type,
|
||||||
destination_path,
|
destination_path,
|
||||||
files=None, # Download all files
|
files=None, # Download all files in current measurement folder
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: Create DataFile records for downloaded files
|
# TODO: Create DataFile records for downloaded files
|
||||||
|
|||||||
@@ -478,9 +478,118 @@ class SLMMClient:
|
|||||||
return await self._request("GET", f"/{unit_id}/settings")
|
return await self._request("GET", f"/{unit_id}/settings")
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# Data Download (Future)
|
# FTP Control
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|
||||||
|
async def enable_ftp(self, unit_id: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Enable FTP server on device.
|
||||||
|
|
||||||
|
Must be called before downloading files. FTP and TCP can work in tandem.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with status message
|
||||||
|
"""
|
||||||
|
return await self._request("POST", f"/{unit_id}/ftp/enable")
|
||||||
|
|
||||||
|
async def disable_ftp(self, unit_id: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Disable FTP server on device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with status message
|
||||||
|
"""
|
||||||
|
return await self._request("POST", f"/{unit_id}/ftp/disable")
|
||||||
|
|
||||||
|
async def get_ftp_status(self, unit_id: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get FTP server status on device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with ftp_enabled status
|
||||||
|
"""
|
||||||
|
return await self._request("GET", f"/{unit_id}/ftp/status")
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Data Download
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def download_file(
|
||||||
|
self,
|
||||||
|
unit_id: str,
|
||||||
|
remote_path: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Download a single file from unit via FTP.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
remote_path: Path on device to download (e.g., "/NL43_DATA/measurement.wav")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Binary file content (as response)
|
||||||
|
"""
|
||||||
|
data = {"remote_path": remote_path}
|
||||||
|
return await self._request("POST", f"/{unit_id}/ftp/download", data=data)
|
||||||
|
|
||||||
|
async def download_folder(
|
||||||
|
self,
|
||||||
|
unit_id: str,
|
||||||
|
remote_path: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Download an entire folder from unit via FTP as a ZIP archive.
|
||||||
|
|
||||||
|
Useful for downloading complete measurement sessions (e.g., Auto_0000 folders).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
remote_path: Folder path on device to download (e.g., "/NL43_DATA/Auto_0000")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with local_path, folder_name, file_count, zip_size_bytes
|
||||||
|
"""
|
||||||
|
data = {"remote_path": remote_path}
|
||||||
|
return await self._request("POST", f"/{unit_id}/ftp/download-folder", data=data)
|
||||||
|
|
||||||
|
async def download_current_measurement(
|
||||||
|
self,
|
||||||
|
unit_id: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Download the current measurement folder based on device's index number.
|
||||||
|
|
||||||
|
This is the recommended method for scheduled downloads - it automatically
|
||||||
|
determines which folder to download based on the device's current store index.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with local_path, folder_name, file_count, zip_size_bytes, index_number
|
||||||
|
"""
|
||||||
|
# Get current index number from device
|
||||||
|
index_info = await self.get_index_number(unit_id)
|
||||||
|
index_number = index_info.get("index_number", 0)
|
||||||
|
|
||||||
|
# Format as Auto_XXXX folder name
|
||||||
|
folder_name = f"Auto_{index_number:04d}"
|
||||||
|
remote_path = f"/NL43_DATA/{folder_name}"
|
||||||
|
|
||||||
|
# Download the folder
|
||||||
|
result = await self.download_folder(unit_id, remote_path)
|
||||||
|
result["index_number"] = index_number
|
||||||
|
return result
|
||||||
|
|
||||||
async def download_files(
|
async def download_files(
|
||||||
self,
|
self,
|
||||||
unit_id: str,
|
unit_id: str,
|
||||||
@@ -488,23 +597,24 @@ class SLMMClient:
|
|||||||
files: Optional[List[str]] = None,
|
files: Optional[List[str]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Download files from unit via FTP.
|
Download measurement files from unit via FTP.
|
||||||
|
|
||||||
NOTE: This endpoint doesn't exist in SLMM yet. Will need to implement.
|
This method automatically determines the current measurement folder and downloads it.
|
||||||
|
The destination_path parameter is logged for reference but actual download location
|
||||||
|
is managed by SLMM (data/downloads/{unit_id}/).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
unit_id: Unit identifier
|
unit_id: Unit identifier
|
||||||
destination_path: Local path to save files
|
destination_path: Reference path (for logging/metadata, not used by SLMM)
|
||||||
files: List of filenames to download, or None for all
|
files: Ignored - always downloads the current measurement folder
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with downloaded files list and metadata
|
Dict with download result including local_path, folder_name, etc.
|
||||||
"""
|
"""
|
||||||
data = {
|
# Use the new method that automatically determines what to download
|
||||||
"destination_path": destination_path,
|
result = await self.download_current_measurement(unit_id)
|
||||||
"files": files or "all",
|
result["requested_destination"] = destination_path
|
||||||
}
|
return result
|
||||||
return await self._request("POST", f"/{unit_id}/ftp/download", data=data)
|
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# Cycle Commands (for scheduled automation)
|
# Cycle Commands (for scheduled automation)
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ async def sync_slm_to_slmm(unit: RosterUnit) -> bool:
|
|||||||
logger.warning(f"SLM {unit.id} has no host configured, skipping SLMM sync")
|
logger.warning(f"SLM {unit.id} has no host configured, skipping SLMM sync")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Disable polling if unit is benched (deployed=False) or retired
|
||||||
|
# Only actively deployed units should be polled
|
||||||
|
should_poll = unit.deployed and not unit.retired
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
response = await client.put(
|
response = await client.put(
|
||||||
@@ -47,8 +51,8 @@ async def sync_slm_to_slmm(unit: RosterUnit) -> bool:
|
|||||||
"ftp_enabled": True,
|
"ftp_enabled": True,
|
||||||
"ftp_username": "USER", # Default NL43 credentials
|
"ftp_username": "USER", # Default NL43 credentials
|
||||||
"ftp_password": "0000",
|
"ftp_password": "0000",
|
||||||
"poll_enabled": not unit.retired, # Disable polling for retired units
|
"poll_enabled": should_poll, # Disable polling for benched or retired units
|
||||||
"poll_interval_seconds": 60, # Default interval
|
"poll_interval_seconds": 3600, # Default to 1 hour polling
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ def emit_status_snapshot():
|
|||||||
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
||||||
"next_calibration_due": r.next_calibration_due.isoformat() if r.next_calibration_due else None,
|
"next_calibration_due": r.next_calibration_due.isoformat() if r.next_calibration_due else None,
|
||||||
"deployed_with_modem_id": r.deployed_with_modem_id,
|
"deployed_with_modem_id": r.deployed_with_modem_id,
|
||||||
|
"deployed_with_unit_id": r.deployed_with_unit_id,
|
||||||
"ip_address": r.ip_address,
|
"ip_address": r.ip_address,
|
||||||
"phone_number": r.phone_number,
|
"phone_number": r.phone_number,
|
||||||
"hardware_model": r.hardware_model,
|
"hardware_model": r.hardware_model,
|
||||||
@@ -137,6 +138,7 @@ def emit_status_snapshot():
|
|||||||
"last_calibrated": None,
|
"last_calibrated": None,
|
||||||
"next_calibration_due": None,
|
"next_calibration_due": None,
|
||||||
"deployed_with_modem_id": None,
|
"deployed_with_modem_id": None,
|
||||||
|
"deployed_with_unit_id": None,
|
||||||
"ip_address": None,
|
"ip_address": None,
|
||||||
"phone_number": None,
|
"phone_number": None,
|
||||||
"hardware_model": None,
|
"hardware_model": None,
|
||||||
|
|||||||
@@ -137,6 +137,13 @@
|
|||||||
Modems
|
Modems
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href="/pair-devices" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/pair-devices' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
||||||
|
</svg>
|
||||||
|
Pair Devices
|
||||||
|
</a>
|
||||||
|
|
||||||
<a href="/projects" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path.startswith('/projects') %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
<a href="/projects" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path.startswith('/projects') %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
||||||
|
|||||||
@@ -55,13 +55,7 @@
|
|||||||
hx-get="/api/modem-dashboard/units"
|
hx-get="/api/modem-dashboard/units"
|
||||||
hx-trigger="load, every 30s"
|
hx-trigger="load, every 30s"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<div class="animate-pulse space-y-4">
|
<p class="text-gray-500 dark:text-gray-400">Loading modems...</p>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
<div class="bg-gray-200 dark:bg-gray-700 h-40 rounded-lg"></div>
|
|
||||||
<div class="bg-gray-200 dark:bg-gray-700 h-40 rounded-lg"></div>
|
|
||||||
<div class="bg-gray-200 dark:bg-gray-700 h-40 rounded-lg"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
566
templates/pair_devices.html
Normal file
566
templates/pair_devices.html
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Pair Devices - Terra-View{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Pair Devices</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Select a recorder (seismograph or SLM) and a modem to create a bidirectional pairing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selection Summary Bar -->
|
||||||
|
<div id="selection-bar" class="mb-6 p-4 bg-white dark:bg-slate-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<div class="flex items-center gap-6">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Recorder:</span>
|
||||||
|
<span id="selected-recorder" class="font-mono font-medium text-gray-900 dark:text-white">None selected</span>
|
||||||
|
</div>
|
||||||
|
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"></path>
|
||||||
|
</svg>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Modem:</span>
|
||||||
|
<span id="selected-modem" class="font-mono font-medium text-gray-900 dark:text-white">None selected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button id="clear-selection-btn"
|
||||||
|
onclick="clearSelection()"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<button id="pair-btn"
|
||||||
|
onclick="pairDevices()"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-seismo-orange rounded-lg hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled>
|
||||||
|
Pair Devices
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Two Column Layout -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- Left Column: Recorders (Seismographs + SLMs) -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
Recorders
|
||||||
|
<span id="recorder-count" class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ recorders|length }})</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<!-- Recorder Search & Filters -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<input type="text" id="recorder-search" placeholder="Search by ID..."
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange focus:border-seismo-orange"
|
||||||
|
oninput="filterRecorders()">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" id="recorder-hide-paired" onchange="filterRecorders()" class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
|
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400">Hide paired</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" id="recorder-deployed-only" onchange="filterRecorders()" class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
|
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400">Deployed only</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-[600px] overflow-y-auto">
|
||||||
|
<div id="recorders-list" class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for unit in recorders %}
|
||||||
|
<div class="device-row recorder-row p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors"
|
||||||
|
data-id="{{ unit.id }}"
|
||||||
|
data-deployed="{{ unit.deployed|lower }}"
|
||||||
|
data-paired-with="{{ unit.deployed_with_modem_id or '' }}"
|
||||||
|
data-device-type="{{ unit.device_type }}"
|
||||||
|
onclick="selectRecorder('{{ unit.id }}')">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full flex items-center justify-center
|
||||||
|
{% if unit.device_type == 'slm' %}bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400
|
||||||
|
{% else %}bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400{% endif %}">
|
||||||
|
{% if unit.device_type == 'slm' %}
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||||
|
</svg>
|
||||||
|
{% else %}
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-mono font-medium text-gray-900 dark:text-white">{{ unit.id }}</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ unit.device_type|capitalize }}
|
||||||
|
{% if not unit.deployed %}<span class="text-yellow-600 dark:text-yellow-400">(Benched)</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if unit.deployed_with_modem_id %}
|
||||||
|
<span class="px-2 py-1 text-xs rounded-full bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400">
|
||||||
|
→ {{ unit.deployed_with_modem_id }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="w-5 h-5 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center selection-indicator">
|
||||||
|
<svg class="w-3 h-3 text-seismo-orange hidden check-icon" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
No recorders found in roster
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Modems -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||||
|
</svg>
|
||||||
|
Modems
|
||||||
|
<span id="modem-count" class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ modems|length }})</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<!-- Modem Search & Filters -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<input type="text" id="modem-search" placeholder="Search by ID, IP, or phone..."
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange focus:border-seismo-orange"
|
||||||
|
oninput="filterModems()">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" id="modem-hide-paired" onchange="filterModems()" class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
|
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400">Hide paired</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" id="modem-deployed-only" onchange="filterModems()" class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
|
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400">Deployed only</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-[600px] overflow-y-auto">
|
||||||
|
<div id="modems-list" class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for unit in modems %}
|
||||||
|
<div class="device-row modem-row p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors"
|
||||||
|
data-id="{{ unit.id }}"
|
||||||
|
data-deployed="{{ unit.deployed|lower }}"
|
||||||
|
data-paired-with="{{ unit.deployed_with_unit_id or '' }}"
|
||||||
|
data-ip="{{ unit.ip_address or '' }}"
|
||||||
|
data-phone="{{ unit.phone_number or '' }}"
|
||||||
|
onclick="selectModem('{{ unit.id }}')">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center text-amber-600 dark:text-amber-400">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-mono font-medium text-gray-900 dark:text-white">{{ unit.id }}</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{% if unit.ip_address %}<span class="font-mono">{{ unit.ip_address }}</span>{% endif %}
|
||||||
|
{% if unit.phone_number %}{% if unit.ip_address %} · {% endif %}{{ unit.phone_number }}{% endif %}
|
||||||
|
{% if not unit.ip_address and not unit.phone_number %}Modem{% endif %}
|
||||||
|
{% if not unit.deployed %}<span class="text-yellow-600 dark:text-yellow-400">(Benched)</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if unit.deployed_with_unit_id %}
|
||||||
|
<span class="px-2 py-1 text-xs rounded-full bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400">
|
||||||
|
← {{ unit.deployed_with_unit_id }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="w-5 h-5 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center selection-indicator">
|
||||||
|
<svg class="w-3 h-3 text-seismo-orange hidden check-icon" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
No modems found in roster
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing Pairings Section -->
|
||||||
|
<div class="mt-8 bg-white dark:bg-slate-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
||||||
|
</svg>
|
||||||
|
Existing Pairings
|
||||||
|
<span id="pairing-count" class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ pairings|length }})</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-[400px] overflow-y-auto">
|
||||||
|
<div id="pairings-list" class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for pairing in pairings %}
|
||||||
|
<div class="pairing-row p-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="px-2 py-1 text-sm font-mono rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400">
|
||||||
|
{{ pairing.recorder_id }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ pairing.recorder_type }}</span>
|
||||||
|
</div>
|
||||||
|
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"></path>
|
||||||
|
</svg>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="px-2 py-1 text-sm font-mono rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400">
|
||||||
|
{{ pairing.modem_id }}
|
||||||
|
</span>
|
||||||
|
{% if pairing.modem_ip %}
|
||||||
|
<span class="text-xs font-mono text-gray-500 dark:text-gray-400">{{ pairing.modem_ip }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="unpairDevices('{{ pairing.recorder_id }}', '{{ pairing.modem_id }}')"
|
||||||
|
class="p-2 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 rounded-lg transition-colors"
|
||||||
|
title="Unpair devices">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
No pairings found. Select a recorder and modem above to create one.
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast notification -->
|
||||||
|
<div id="toast" class="fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg transform translate-y-full opacity-0 transition-all duration-300 z-50"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let selectedRecorder = null;
|
||||||
|
let selectedModem = null;
|
||||||
|
|
||||||
|
function selectRecorder(id) {
|
||||||
|
// Deselect previous
|
||||||
|
document.querySelectorAll('.recorder-row').forEach(row => {
|
||||||
|
row.classList.remove('bg-seismo-orange/10', 'dark:bg-seismo-orange/20');
|
||||||
|
row.querySelector('.selection-indicator').classList.remove('border-seismo-orange', 'bg-seismo-orange');
|
||||||
|
row.querySelector('.selection-indicator').classList.add('border-gray-300', 'dark:border-gray-600');
|
||||||
|
row.querySelector('.check-icon').classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle selection
|
||||||
|
if (selectedRecorder === id) {
|
||||||
|
selectedRecorder = null;
|
||||||
|
document.getElementById('selected-recorder').textContent = 'None selected';
|
||||||
|
} else {
|
||||||
|
selectedRecorder = id;
|
||||||
|
document.getElementById('selected-recorder').textContent = id;
|
||||||
|
|
||||||
|
// Highlight selected
|
||||||
|
const row = document.querySelector(`.recorder-row[data-id="${id}"]`);
|
||||||
|
if (row) {
|
||||||
|
row.classList.add('bg-seismo-orange/10', 'dark:bg-seismo-orange/20');
|
||||||
|
row.querySelector('.selection-indicator').classList.remove('border-gray-300', 'dark:border-gray-600');
|
||||||
|
row.querySelector('.selection-indicator').classList.add('border-seismo-orange', 'bg-seismo-orange');
|
||||||
|
row.querySelector('.check-icon').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectModem(id) {
|
||||||
|
// Deselect previous
|
||||||
|
document.querySelectorAll('.modem-row').forEach(row => {
|
||||||
|
row.classList.remove('bg-seismo-orange/10', 'dark:bg-seismo-orange/20');
|
||||||
|
row.querySelector('.selection-indicator').classList.remove('border-seismo-orange', 'bg-seismo-orange');
|
||||||
|
row.querySelector('.selection-indicator').classList.add('border-gray-300', 'dark:border-gray-600');
|
||||||
|
row.querySelector('.check-icon').classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle selection
|
||||||
|
if (selectedModem === id) {
|
||||||
|
selectedModem = null;
|
||||||
|
document.getElementById('selected-modem').textContent = 'None selected';
|
||||||
|
} else {
|
||||||
|
selectedModem = id;
|
||||||
|
document.getElementById('selected-modem').textContent = id;
|
||||||
|
|
||||||
|
// Highlight selected
|
||||||
|
const row = document.querySelector(`.modem-row[data-id="${id}"]`);
|
||||||
|
if (row) {
|
||||||
|
row.classList.add('bg-seismo-orange/10', 'dark:bg-seismo-orange/20');
|
||||||
|
row.querySelector('.selection-indicator').classList.remove('border-gray-300', 'dark:border-gray-600');
|
||||||
|
row.querySelector('.selection-indicator').classList.add('border-seismo-orange', 'bg-seismo-orange');
|
||||||
|
row.querySelector('.check-icon').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateButtons() {
|
||||||
|
const pairBtn = document.getElementById('pair-btn');
|
||||||
|
const clearBtn = document.getElementById('clear-selection-btn');
|
||||||
|
|
||||||
|
pairBtn.disabled = !(selectedRecorder && selectedModem);
|
||||||
|
clearBtn.disabled = !(selectedRecorder || selectedModem);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
if (selectedRecorder) selectRecorder(selectedRecorder);
|
||||||
|
if (selectedModem) selectModem(selectedModem);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterRecorders() {
|
||||||
|
const searchTerm = document.getElementById('recorder-search').value.toLowerCase().trim();
|
||||||
|
const hidePaired = document.getElementById('recorder-hide-paired').checked;
|
||||||
|
const deployedOnly = document.getElementById('recorder-deployed-only').checked;
|
||||||
|
|
||||||
|
let visibleRecorders = 0;
|
||||||
|
|
||||||
|
document.querySelectorAll('.recorder-row').forEach(row => {
|
||||||
|
const id = row.dataset.id.toLowerCase();
|
||||||
|
const pairedWith = row.dataset.pairedWith;
|
||||||
|
const deployed = row.dataset.deployed === 'true';
|
||||||
|
|
||||||
|
let show = true;
|
||||||
|
if (searchTerm && !id.includes(searchTerm)) show = false;
|
||||||
|
if (hidePaired && pairedWith) show = false;
|
||||||
|
if (deployedOnly && !deployed) show = false;
|
||||||
|
|
||||||
|
row.style.display = show ? '' : 'none';
|
||||||
|
if (show) visibleRecorders++;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('recorder-count').textContent = `(${visibleRecorders})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterModems() {
|
||||||
|
const searchTerm = document.getElementById('modem-search').value.toLowerCase().trim();
|
||||||
|
const hidePaired = document.getElementById('modem-hide-paired').checked;
|
||||||
|
const deployedOnly = document.getElementById('modem-deployed-only').checked;
|
||||||
|
|
||||||
|
let visibleModems = 0;
|
||||||
|
|
||||||
|
document.querySelectorAll('.modem-row').forEach(row => {
|
||||||
|
const id = row.dataset.id.toLowerCase();
|
||||||
|
const ip = (row.dataset.ip || '').toLowerCase();
|
||||||
|
const phone = (row.dataset.phone || '').toLowerCase();
|
||||||
|
const pairedWith = row.dataset.pairedWith;
|
||||||
|
const deployed = row.dataset.deployed === 'true';
|
||||||
|
|
||||||
|
let show = true;
|
||||||
|
if (searchTerm && !id.includes(searchTerm) && !ip.includes(searchTerm) && !phone.includes(searchTerm)) show = false;
|
||||||
|
if (hidePaired && pairedWith) show = false;
|
||||||
|
if (deployedOnly && !deployed) show = false;
|
||||||
|
|
||||||
|
row.style.display = show ? '' : 'none';
|
||||||
|
if (show) visibleModems++;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('modem-count').textContent = `(${visibleModems})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveScrollPositions() {
|
||||||
|
const recordersList = document.getElementById('recorders-list').parentElement;
|
||||||
|
const modemsList = document.getElementById('modems-list').parentElement;
|
||||||
|
const pairingsList = document.getElementById('pairings-list').parentElement;
|
||||||
|
|
||||||
|
sessionStorage.setItem('pairDevices_recorderScroll', recordersList.scrollTop);
|
||||||
|
sessionStorage.setItem('pairDevices_modemScroll', modemsList.scrollTop);
|
||||||
|
sessionStorage.setItem('pairDevices_pairingScroll', pairingsList.scrollTop);
|
||||||
|
|
||||||
|
// Save recorder filter state
|
||||||
|
sessionStorage.setItem('pairDevices_recorderSearch', document.getElementById('recorder-search').value);
|
||||||
|
sessionStorage.setItem('pairDevices_recorderHidePaired', document.getElementById('recorder-hide-paired').checked);
|
||||||
|
sessionStorage.setItem('pairDevices_recorderDeployedOnly', document.getElementById('recorder-deployed-only').checked);
|
||||||
|
|
||||||
|
// Save modem filter state
|
||||||
|
sessionStorage.setItem('pairDevices_modemSearch', document.getElementById('modem-search').value);
|
||||||
|
sessionStorage.setItem('pairDevices_modemHidePaired', document.getElementById('modem-hide-paired').checked);
|
||||||
|
sessionStorage.setItem('pairDevices_modemDeployedOnly', document.getElementById('modem-deployed-only').checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreScrollPositions() {
|
||||||
|
const recorderScroll = sessionStorage.getItem('pairDevices_recorderScroll');
|
||||||
|
const modemScroll = sessionStorage.getItem('pairDevices_modemScroll');
|
||||||
|
const pairingScroll = sessionStorage.getItem('pairDevices_pairingScroll');
|
||||||
|
|
||||||
|
if (recorderScroll) {
|
||||||
|
document.getElementById('recorders-list').parentElement.scrollTop = parseInt(recorderScroll);
|
||||||
|
}
|
||||||
|
if (modemScroll) {
|
||||||
|
document.getElementById('modems-list').parentElement.scrollTop = parseInt(modemScroll);
|
||||||
|
}
|
||||||
|
if (pairingScroll) {
|
||||||
|
document.getElementById('pairings-list').parentElement.scrollTop = parseInt(pairingScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore recorder filter state
|
||||||
|
const recorderSearch = sessionStorage.getItem('pairDevices_recorderSearch');
|
||||||
|
const recorderHidePaired = sessionStorage.getItem('pairDevices_recorderHidePaired');
|
||||||
|
const recorderDeployedOnly = sessionStorage.getItem('pairDevices_recorderDeployedOnly');
|
||||||
|
|
||||||
|
if (recorderSearch) document.getElementById('recorder-search').value = recorderSearch;
|
||||||
|
if (recorderHidePaired === 'true') document.getElementById('recorder-hide-paired').checked = true;
|
||||||
|
if (recorderDeployedOnly === 'true') document.getElementById('recorder-deployed-only').checked = true;
|
||||||
|
|
||||||
|
// Restore modem filter state
|
||||||
|
const modemSearch = sessionStorage.getItem('pairDevices_modemSearch');
|
||||||
|
const modemHidePaired = sessionStorage.getItem('pairDevices_modemHidePaired');
|
||||||
|
const modemDeployedOnly = sessionStorage.getItem('pairDevices_modemDeployedOnly');
|
||||||
|
|
||||||
|
if (modemSearch) document.getElementById('modem-search').value = modemSearch;
|
||||||
|
if (modemHidePaired === 'true') document.getElementById('modem-hide-paired').checked = true;
|
||||||
|
if (modemDeployedOnly === 'true') document.getElementById('modem-deployed-only').checked = true;
|
||||||
|
|
||||||
|
// Apply filters if any were set
|
||||||
|
if (recorderSearch || recorderHidePaired === 'true' || recorderDeployedOnly === 'true') {
|
||||||
|
filterRecorders();
|
||||||
|
}
|
||||||
|
if (modemSearch || modemHidePaired === 'true' || modemDeployedOnly === 'true') {
|
||||||
|
filterModems();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear stored values
|
||||||
|
sessionStorage.removeItem('pairDevices_recorderScroll');
|
||||||
|
sessionStorage.removeItem('pairDevices_modemScroll');
|
||||||
|
sessionStorage.removeItem('pairDevices_pairingScroll');
|
||||||
|
sessionStorage.removeItem('pairDevices_recorderSearch');
|
||||||
|
sessionStorage.removeItem('pairDevices_recorderHidePaired');
|
||||||
|
sessionStorage.removeItem('pairDevices_recorderDeployedOnly');
|
||||||
|
sessionStorage.removeItem('pairDevices_modemSearch');
|
||||||
|
sessionStorage.removeItem('pairDevices_modemHidePaired');
|
||||||
|
sessionStorage.removeItem('pairDevices_modemDeployedOnly');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore scroll positions on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', restoreScrollPositions);
|
||||||
|
|
||||||
|
async function pairDevices() {
|
||||||
|
if (!selectedRecorder || !selectedModem) return;
|
||||||
|
|
||||||
|
const pairBtn = document.getElementById('pair-btn');
|
||||||
|
pairBtn.disabled = true;
|
||||||
|
pairBtn.textContent = 'Pairing...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/roster/pair-devices', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
recorder_id: selectedRecorder,
|
||||||
|
modem_id: selectedModem
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showToast(`Paired ${selectedRecorder} with ${selectedModem}`, 'success');
|
||||||
|
// Save scroll positions before reload
|
||||||
|
saveScrollPositions();
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
} else {
|
||||||
|
showToast(result.detail || 'Failed to pair devices', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Error pairing devices: ' + error.message, 'error');
|
||||||
|
} finally {
|
||||||
|
pairBtn.disabled = false;
|
||||||
|
pairBtn.textContent = 'Pair Devices';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unpairDevices(recorderId, modemId) {
|
||||||
|
if (!confirm(`Unpair ${recorderId} from ${modemId}?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/roster/unpair-devices', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
recorder_id: recorderId,
|
||||||
|
modem_id: modemId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showToast(`Unpaired ${recorderId} from ${modemId}`, 'success');
|
||||||
|
// Save scroll positions before reload
|
||||||
|
saveScrollPositions();
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
} else {
|
||||||
|
showToast(result.detail || 'Failed to unpair devices', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Error unpairing devices: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
const toast = document.getElementById('toast');
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.className = 'fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg transform transition-all duration-300 z-50';
|
||||||
|
|
||||||
|
if (type === 'success') {
|
||||||
|
toast.classList.add('bg-green-500', 'text-white');
|
||||||
|
} else if (type === 'error') {
|
||||||
|
toast.classList.add('bg-red-500', 'text-white');
|
||||||
|
} else {
|
||||||
|
toast.classList.add('bg-gray-800', 'text-white');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show
|
||||||
|
toast.classList.remove('translate-y-full', 'opacity-0');
|
||||||
|
|
||||||
|
// Hide after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add('translate-y-full', 'opacity-0');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bg-seismo-orange\/10 {
|
||||||
|
background-color: rgb(249 115 22 / 0.1);
|
||||||
|
}
|
||||||
|
.dark\:bg-seismo-orange\/20:is(.dark *) {
|
||||||
|
background-color: rgb(249 115 22 / 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -104,8 +104,13 @@
|
|||||||
{% if unit.phone_number %}
|
{% if unit.phone_number %}
|
||||||
<div>{{ unit.phone_number }}</div>
|
<div>{{ unit.phone_number }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if unit.hardware_model %}
|
{% if unit.deployed_with_unit_id %}
|
||||||
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
|
<div>
|
||||||
|
<span class="text-gray-500 dark:text-gray-500">Linked:</span>
|
||||||
|
<a href="/unit/{{ unit.deployed_with_unit_id }}" class="text-seismo-orange hover:underline font-medium">
|
||||||
|
{{ unit.deployed_with_unit_id }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if unit.next_calibration_due %}
|
{% if unit.next_calibration_due %}
|
||||||
@@ -126,7 +131,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">{{ unit.last_seen }}</div>
|
<div class="text-sm text-gray-500 dark:text-gray-400 last-seen-cell" data-iso="{{ unit.last_seen }}">{{ unit.last_seen }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="text-sm
|
<div class="text-sm
|
||||||
@@ -345,6 +350,39 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
(function() {
|
||||||
|
// User's configured timezone from settings (defaults to America/New_York)
|
||||||
|
const userTimezone = '{{ user_timezone | default("America/New_York") }}';
|
||||||
|
|
||||||
|
// Format ISO timestamp to human-readable format in user's timezone
|
||||||
|
function formatLastSeenLocal(isoString) {
|
||||||
|
if (!isoString || isoString === 'Never' || isoString === 'N/A') {
|
||||||
|
return isoString || 'Never';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const date = new Date(isoString);
|
||||||
|
if (isNaN(date.getTime())) return isoString;
|
||||||
|
|
||||||
|
// Format in user's configured timezone
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
timeZone: userTimezone,
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return isoString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format all last-seen cells on page load
|
||||||
|
document.querySelectorAll('.last-seen-cell').forEach(cell => {
|
||||||
|
const isoDate = cell.getAttribute('data-iso');
|
||||||
|
cell.textContent = formatLastSeenLocal(isoDate);
|
||||||
|
});
|
||||||
|
|
||||||
// Update timestamp
|
// Update timestamp
|
||||||
const timestampElement = document.getElementById('last-updated');
|
const timestampElement = document.getElementById('last-updated');
|
||||||
if (timestampElement) {
|
if (timestampElement) {
|
||||||
@@ -365,20 +403,23 @@
|
|||||||
};
|
};
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
})();
|
||||||
|
|
||||||
// Sorting state
|
// Sorting state (needs to persist across swaps)
|
||||||
let currentSort = { column: null, direction: 'asc' };
|
if (typeof window.currentSort === 'undefined') {
|
||||||
|
window.currentSort = { column: null, direction: 'asc' };
|
||||||
|
}
|
||||||
|
|
||||||
function sortTable(column) {
|
function sortTable(column) {
|
||||||
const tbody = document.getElementById('roster-tbody');
|
const tbody = document.getElementById('roster-tbody');
|
||||||
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
||||||
|
|
||||||
// Determine sort direction
|
// Determine sort direction
|
||||||
if (currentSort.column === column) {
|
if (window.currentSort.column === column) {
|
||||||
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
window.currentSort.direction = window.currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
} else {
|
} else {
|
||||||
currentSort.column = column;
|
window.currentSort.column = column;
|
||||||
currentSort.direction = 'asc';
|
window.currentSort.direction = 'asc';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort rows
|
// Sort rows
|
||||||
@@ -406,8 +447,8 @@
|
|||||||
bVal = bVal.toLowerCase();
|
bVal = bVal.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
|
if (aVal < bVal) return window.currentSort.direction === 'asc' ? -1 : 1;
|
||||||
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
|
if (aVal > bVal) return window.currentSort.direction === 'asc' ? 1 : -1;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -443,10 +484,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Set current indicator
|
// Set current indicator
|
||||||
if (currentSort.column) {
|
if (window.currentSort.column) {
|
||||||
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
|
const indicator = document.querySelector(`.sort-indicator[data-column="${window.currentSort.column}"]`);
|
||||||
if (indicator) {
|
if (indicator) {
|
||||||
indicator.className = `sort-indicator ${currentSort.direction}`;
|
indicator.className = `sort-indicator ${window.currentSort.direction}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +1,127 @@
|
|||||||
<!-- Modem List -->
|
<!-- Modem List -->
|
||||||
{% if modems %}
|
{% if modems %}
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="overflow-x-auto">
|
||||||
{% for modem in modems %}
|
<table class="w-full">
|
||||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors">
|
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<tr>
|
||||||
<div class="min-w-0 flex-1">
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Unit ID</th>
|
||||||
<div class="flex items-center gap-2">
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Status</th>
|
||||||
<a href="/unit/{{ modem.id }}" class="font-semibold text-gray-900 dark:text-white hover:text-seismo-orange">
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">IP Address</th>
|
||||||
{{ modem.id }}
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Phone</th>
|
||||||
</a>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Paired Device</th>
|
||||||
{% if modem.hardware_model %}
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Location</th>
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ modem.hardware_model }}</span>
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for modem in modems %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="/unit/{{ modem.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
{{ modem.id }}
|
||||||
|
</a>
|
||||||
|
{% if modem.hardware_model %}
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">({{ modem.hardware_model }})</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
{% if modem.status == "retired" %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
Retired
|
||||||
|
</span>
|
||||||
|
{% elif modem.status == "benched" %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
||||||
|
Benched
|
||||||
|
</span>
|
||||||
|
{% elif modem.status == "in_use" %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||||
|
In Use
|
||||||
|
</span>
|
||||||
|
{% elif modem.status == "spare" %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
|
||||||
|
Spare
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm">
|
||||||
{% if modem.ip_address %}
|
{% if modem.ip_address %}
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 font-mono mt-1">{{ modem.ip_address }}</p>
|
<span class="font-mono text-gray-900 dark:text-gray-300">{{ modem.ip_address }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-sm text-red-500 mt-1">No IP configured</p>
|
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</td>
|
||||||
{% if modem.phone_number %}
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ modem.phone_number }}</p>
|
{% if modem.phone_number %}
|
||||||
{% endif %}
|
{{ modem.phone_number }}
|
||||||
</div>
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||||
<!-- Status Badge -->
|
{% endif %}
|
||||||
{% if modem.status == "retired" %}
|
</td>
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Retired</span>
|
<td class="px-4 py-3 whitespace-nowrap text-sm">
|
||||||
{% elif modem.status == "benched" %}
|
{% if modem.paired_device %}
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">Benched</span>
|
<a href="/unit/{{ modem.paired_device.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
{% elif modem.status == "in_use" %}
|
{{ modem.paired_device.id }}
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">In Use</span>
|
<span class="text-gray-500 dark:text-gray-400">({{ modem.paired_device.device_type }})</span>
|
||||||
{% elif modem.status == "spare" %}
|
</a>
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Spare</span>
|
{% else %}
|
||||||
{% endif %}
|
<span class="text-gray-400 dark:text-gray-600">None</span>
|
||||||
</div>
|
{% endif %}
|
||||||
|
</td>
|
||||||
<!-- Paired Device -->
|
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
|
||||||
{% if modem.paired_device %}
|
{% if modem.project_id %}
|
||||||
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
<span class="bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 rounded text-xs mr-1">{{ modem.project_id }}</span>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">Paired with:</p>
|
{% endif %}
|
||||||
<a href="/unit/{{ modem.paired_device.id }}" class="text-sm text-seismo-orange hover:underline">
|
{% if modem.location %}
|
||||||
{{ modem.paired_device.id }}
|
<span class="truncate max-w-xs inline-block" title="{{ modem.location }}">{{ modem.location }}</span>
|
||||||
<span class="text-gray-500">({{ modem.paired_device.device_type }})</span>
|
{% elif not modem.project_id %}
|
||||||
</a>
|
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||||
</div>
|
{% endif %}
|
||||||
{% endif %}
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
||||||
<!-- Location if available -->
|
<div class="flex items-center justify-end gap-2">
|
||||||
{% if modem.location or modem.project_id %}
|
<button onclick="pingModem('{{ modem.id }}')"
|
||||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
id="ping-btn-{{ modem.id }}"
|
||||||
{% if modem.project_id %}
|
class="text-xs px-2 py-1 bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 dark:text-blue-300 rounded transition-colors">
|
||||||
<span class="bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 rounded">{{ modem.project_id }}</span>
|
Ping
|
||||||
{% endif %}
|
</button>
|
||||||
{% if modem.location %}
|
<a href="/unit/{{ modem.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
{{ modem.location }}
|
View →
|
||||||
{% endif %}
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
<!-- Ping Result (hidden by default) -->
|
||||||
|
<div id="ping-result-{{ modem.id }}" class="mt-1 text-xs hidden"></div>
|
||||||
<!-- Quick Actions -->
|
</td>
|
||||||
<div class="mt-3 flex gap-2">
|
</tr>
|
||||||
<button onclick="pingModem('{{ modem.id }}')"
|
{% endfor %}
|
||||||
id="ping-btn-{{ modem.id }}"
|
</tbody>
|
||||||
class="text-xs px-3 py-1.5 bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 dark:text-blue-300 rounded transition-colors">
|
</table>
|
||||||
Ping
|
|
||||||
</button>
|
|
||||||
<a href="/unit/{{ modem.id }}"
|
|
||||||
class="text-xs px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-300 rounded transition-colors">
|
|
||||||
Details
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ping Result (hidden by default) -->
|
|
||||||
<div id="ping-result-{{ modem.id }}" class="mt-2 text-xs hidden"></div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if search %}
|
||||||
|
<div class="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Found {{ modems|length }} modem(s) matching "{{ search }}"
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||||
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<p>No modems found</p>
|
<p>No modems found</p>
|
||||||
|
{% if search %}
|
||||||
|
<button onclick="document.getElementById('modem-search').value = ''; htmx.trigger('#modem-search', 'keyup');"
|
||||||
|
class="mt-3 text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
Clear search
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
<p class="text-sm mt-1">Add modems from the <a href="/roster" class="text-seismo-orange hover:underline">Fleet Roster</a></p>
|
<p class="text-sm mt-1">Add modems from the <a href="/roster" class="text-seismo-orange hover:underline">Fleet Roster</a></p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -337,6 +337,7 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
(function() {
|
||||||
// Update timestamp
|
// Update timestamp
|
||||||
const timestampElement = document.getElementById('last-updated');
|
const timestampElement = document.getElementById('last-updated');
|
||||||
if (timestampElement) {
|
if (timestampElement) {
|
||||||
@@ -357,20 +358,23 @@
|
|||||||
};
|
};
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
})();
|
||||||
|
|
||||||
// Sorting state
|
// Sorting state (needs to persist across swaps)
|
||||||
let currentSort = { column: null, direction: 'asc' };
|
if (typeof window.currentSort === 'undefined') {
|
||||||
|
window.currentSort = { column: null, direction: 'asc' };
|
||||||
|
}
|
||||||
|
|
||||||
function sortTable(column) {
|
function sortTable(column) {
|
||||||
const tbody = document.getElementById('roster-tbody');
|
const tbody = document.getElementById('roster-tbody');
|
||||||
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
||||||
|
|
||||||
// Determine sort direction
|
// Determine sort direction
|
||||||
if (currentSort.column === column) {
|
if (window.currentSort.column === column) {
|
||||||
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
window.currentSort.direction = window.currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
} else {
|
} else {
|
||||||
currentSort.column = column;
|
window.currentSort.column = column;
|
||||||
currentSort.direction = 'asc';
|
window.currentSort.direction = 'asc';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort rows
|
// Sort rows
|
||||||
@@ -398,8 +402,8 @@
|
|||||||
bVal = bVal.toLowerCase();
|
bVal = bVal.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
|
if (aVal < bVal) return window.currentSort.direction === 'asc' ? -1 : 1;
|
||||||
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
|
if (aVal > bVal) return window.currentSort.direction === 'asc' ? 1 : -1;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -435,10 +439,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Set current indicator
|
// Set current indicator
|
||||||
if (currentSort.column) {
|
if (window.currentSort.column) {
|
||||||
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
|
const indicator = document.querySelector(`.sort-indicator[data-column="${window.currentSort.column}"]`);
|
||||||
if (indicator) {
|
if (indicator) {
|
||||||
indicator.className = `sort-indicator ${currentSort.direction}`;
|
indicator.className = `sort-indicator ${window.currentSort.direction}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -341,9 +341,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||||
{% set picker_id = "-detail-seismo" %}
|
<div class="flex gap-2">
|
||||||
{% set input_name = "deployed_with_modem_id" %}
|
<div class="flex-1">
|
||||||
{% include "partials/modem_picker.html" with context %}
|
{% set picker_id = "-detail-seismo" %}
|
||||||
|
{% set input_name = "deployed_with_modem_id" %}
|
||||||
|
{% include "partials/modem_picker.html" with context %}
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="openPairDeviceModal('seismograph')"
|
||||||
|
class="px-3 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors flex items-center gap-1"
|
||||||
|
title="Pair with modem">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -417,9 +428,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||||
{% set picker_id = "-detail-slm" %}
|
<div class="flex gap-2">
|
||||||
{% set input_name = "deployed_with_modem_id" %}
|
<div class="flex-1">
|
||||||
{% include "partials/modem_picker.html" with context %}
|
{% set picker_id = "-detail-slm" %}
|
||||||
|
{% set input_name = "deployed_with_modem_id" %}
|
||||||
|
{% include "partials/modem_picker.html" with context %}
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="openPairDeviceModal('sound_level_meter')"
|
||||||
|
class="px-3 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors flex items-center gap-1"
|
||||||
|
title="Pair with modem">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select the modem that provides network connectivity for this SLM</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select the modem that provides network connectivity for this SLM</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -941,6 +963,16 @@ document.getElementById('editForm').addEventListener('submit', async function(e)
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const formData = new FormData(this);
|
const formData = new FormData(this);
|
||||||
|
const deviceType = formData.get('device_type');
|
||||||
|
|
||||||
|
// Fix: FormData contains both modem picker hidden inputs (seismo and slm).
|
||||||
|
// We need to ensure only the correct one is submitted based on device type.
|
||||||
|
// Delete all deployed_with_modem_id entries and re-add the correct one.
|
||||||
|
const modemId = getCorrectModemPickerValue(deviceType);
|
||||||
|
formData.delete('deployed_with_modem_id');
|
||||||
|
if (modemId) {
|
||||||
|
formData.append('deployed_with_modem_id', modemId);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/roster/edit/${unitId}`, {
|
const response = await fetch(`/api/roster/edit/${unitId}`, {
|
||||||
@@ -962,6 +994,19 @@ document.getElementById('editForm').addEventListener('submit', async function(e)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get the correct modem picker value based on device type
|
||||||
|
function getCorrectModemPickerValue(deviceType) {
|
||||||
|
if (deviceType === 'seismograph') {
|
||||||
|
const picker = document.getElementById('modem-picker-value-detail-seismo');
|
||||||
|
return picker ? picker.value : '';
|
||||||
|
} else if (deviceType === 'sound_level_meter') {
|
||||||
|
const picker = document.getElementById('modem-picker-value-detail-slm');
|
||||||
|
return picker ? picker.value : '';
|
||||||
|
}
|
||||||
|
// Modems don't have a deployed_with_modem_id
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
// Delete unit
|
// Delete unit
|
||||||
async function deleteUnit() {
|
async function deleteUnit() {
|
||||||
if (!confirm(`Are you sure you want to PERMANENTLY delete unit ${unitId}?\n\nThis action cannot be undone!`)) {
|
if (!confirm(`Are you sure you want to PERMANENTLY delete unit ${unitId}?\n\nThis action cannot be undone!`)) {
|
||||||
@@ -1356,8 +1401,239 @@ loadUnitData().then(() => {
|
|||||||
loadPhotos();
|
loadPhotos();
|
||||||
loadUnitHistory();
|
loadUnitHistory();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== Pair Device Modal Functions =====
|
||||||
|
let pairModalModems = []; // Cache loaded modems
|
||||||
|
let pairModalDeviceType = ''; // Current device type
|
||||||
|
|
||||||
|
function openPairDeviceModal(deviceType) {
|
||||||
|
const modal = document.getElementById('pairDeviceModal');
|
||||||
|
const searchInput = document.getElementById('pairModemSearch');
|
||||||
|
const hideBenchedCheckbox = document.getElementById('pairHideBenched');
|
||||||
|
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
pairModalDeviceType = deviceType;
|
||||||
|
|
||||||
|
// Reset search and filter
|
||||||
|
if (searchInput) searchInput.value = '';
|
||||||
|
if (hideBenchedCheckbox) hideBenchedCheckbox.checked = false;
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Focus search input
|
||||||
|
setTimeout(() => {
|
||||||
|
if (searchInput) searchInput.focus();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Load available modems
|
||||||
|
loadAvailableModems();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePairDeviceModal() {
|
||||||
|
const modal = document.getElementById('pairDeviceModal');
|
||||||
|
if (modal) modal.classList.add('hidden');
|
||||||
|
pairModalModems = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAvailableModems() {
|
||||||
|
const listContainer = document.getElementById('pairModemList');
|
||||||
|
listContainer.innerHTML = '<div class="text-center py-4"><div class="animate-spin inline-block w-6 h-6 border-2 border-seismo-orange border-t-transparent rounded-full"></div><p class="mt-2 text-sm text-gray-500">Loading modems...</p></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/roster/modems');
|
||||||
|
if (!response.ok) throw new Error('Failed to load modems');
|
||||||
|
|
||||||
|
pairModalModems = await response.json();
|
||||||
|
|
||||||
|
if (pairModalModems.length === 0) {
|
||||||
|
listContainer.innerHTML = '<p class="text-center py-4 text-gray-500">No modems found in roster</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the list
|
||||||
|
renderModemList();
|
||||||
|
} catch (error) {
|
||||||
|
listContainer.innerHTML = `<p class="text-center py-4 text-red-500">Error: ${error.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterPairModemList() {
|
||||||
|
renderModemList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModemList() {
|
||||||
|
const listContainer = document.getElementById('pairModemList');
|
||||||
|
const searchInput = document.getElementById('pairModemSearch');
|
||||||
|
const hideBenchedCheckbox = document.getElementById('pairHideBenched');
|
||||||
|
|
||||||
|
const searchTerm = (searchInput?.value || '').toLowerCase().trim();
|
||||||
|
const hideBenched = hideBenchedCheckbox?.checked || false;
|
||||||
|
|
||||||
|
// Filter modems
|
||||||
|
let filteredModems = pairModalModems.filter(modem => {
|
||||||
|
// Filter by benched status
|
||||||
|
if (hideBenched && !modem.deployed) return false;
|
||||||
|
|
||||||
|
// Filter by search term
|
||||||
|
if (searchTerm) {
|
||||||
|
const searchFields = [
|
||||||
|
modem.id,
|
||||||
|
modem.ip_address || '',
|
||||||
|
modem.phone_number || '',
|
||||||
|
modem.note || ''
|
||||||
|
].join(' ').toLowerCase();
|
||||||
|
if (!searchFields.includes(searchTerm)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filteredModems.length === 0) {
|
||||||
|
listContainer.innerHTML = '<p class="text-center py-4 text-gray-500">No modems match your criteria</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build modem list
|
||||||
|
let html = '';
|
||||||
|
for (const modem of filteredModems) {
|
||||||
|
const displayParts = [modem.id];
|
||||||
|
if (modem.ip_address) displayParts.push(`(${modem.ip_address})`);
|
||||||
|
if (modem.note) displayParts.push(`- ${modem.note.substring(0, 30)}${modem.note.length > 30 ? '...' : ''}`);
|
||||||
|
|
||||||
|
const deployedBadge = modem.deployed
|
||||||
|
? '<span class="px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 text-xs rounded">Deployed</span>'
|
||||||
|
: '<span class="px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 text-xs rounded">Benched</span>';
|
||||||
|
|
||||||
|
const pairedBadge = modem.deployed_with_unit_id
|
||||||
|
? `<span class="px-2 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs rounded">Paired: ${modem.deployed_with_unit_id}</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-0"
|
||||||
|
onclick="selectModemForPairing('${modem.id}', '${displayParts.join(' ').replace(/'/g, "\\'")}')">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white">
|
||||||
|
<span class="text-seismo-orange">${modem.id}</span>
|
||||||
|
${modem.ip_address ? `<span class="text-gray-400 ml-2 font-mono text-sm">${modem.ip_address}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${modem.note ? `<div class="text-sm text-gray-500 dark:text-gray-400 truncate">${modem.note}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 ml-3">
|
||||||
|
${deployedBadge}
|
||||||
|
${pairedBadge}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
listContainer.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectModemForPairing(modemId, displayText) {
|
||||||
|
// Update the correct picker based on device type
|
||||||
|
let pickerId = '';
|
||||||
|
if (pairModalDeviceType === 'seismograph') {
|
||||||
|
pickerId = '-detail-seismo';
|
||||||
|
} else if (pairModalDeviceType === 'sound_level_meter') {
|
||||||
|
pickerId = '-detail-slm';
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueInput = document.getElementById('modem-picker-value' + pickerId);
|
||||||
|
const searchInput = document.getElementById('modem-picker-search' + pickerId);
|
||||||
|
const clearBtn = document.getElementById('modem-picker-clear' + pickerId);
|
||||||
|
|
||||||
|
if (valueInput) valueInput.value = modemId;
|
||||||
|
if (searchInput) searchInput.value = displayText;
|
||||||
|
if (clearBtn) clearBtn.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
closePairDeviceModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear pairing (unpair device from modem)
|
||||||
|
function clearPairing(deviceType) {
|
||||||
|
let pickerId = '';
|
||||||
|
if (deviceType === 'seismograph') {
|
||||||
|
pickerId = '-detail-seismo';
|
||||||
|
} else if (deviceType === 'sound_level_meter') {
|
||||||
|
pickerId = '-detail-slm';
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueInput = document.getElementById('modem-picker-value' + pickerId);
|
||||||
|
const searchInput = document.getElementById('modem-picker-search' + pickerId);
|
||||||
|
const clearBtn = document.getElementById('modem-picker-clear' + pickerId);
|
||||||
|
|
||||||
|
if (valueInput) valueInput.value = '';
|
||||||
|
if (searchInput) searchInput.value = '';
|
||||||
|
if (clearBtn) clearBtn.classList.add('hidden');
|
||||||
|
|
||||||
|
closePairDeviceModal();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Pair Device Modal -->
|
||||||
|
<div id="pairDeviceModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||||
|
<div class="flex items-center justify-center min-h-screen px-4">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div class="fixed inset-0 bg-black/50" onclick="closePairDeviceModal()"></div>
|
||||||
|
|
||||||
|
<!-- Modal Content -->
|
||||||
|
<div class="relative bg-white dark:bg-slate-800 rounded-xl shadow-xl max-w-lg w-full max-h-[80vh] overflow-hidden">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Pair with Modem</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Select a modem to pair with this device</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="closePairDeviceModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search and Filter -->
|
||||||
|
<div class="px-6 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-900/50">
|
||||||
|
<div class="flex gap-3 items-center">
|
||||||
|
<!-- Search Input -->
|
||||||
|
<div class="flex-1 relative">
|
||||||
|
<input type="text"
|
||||||
|
id="pairModemSearch"
|
||||||
|
placeholder="Search by ID, IP, or note..."
|
||||||
|
oninput="filterPairModemList()"
|
||||||
|
class="w-full px-4 py-2 pl-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange focus:border-seismo-orange text-sm">
|
||||||
|
<svg class="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<!-- Hide Benched Toggle -->
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer whitespace-nowrap">
|
||||||
|
<input type="checkbox"
|
||||||
|
id="pairHideBenched"
|
||||||
|
onchange="filterPairModemList()"
|
||||||
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Deployed only</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modem List -->
|
||||||
|
<div id="pairModemList" class="max-h-80 overflow-y-auto">
|
||||||
|
<!-- Populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-900">
|
||||||
|
<button onclick="closePairDeviceModal()" class="w-full px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Include Project Create Modal for inline project creation -->
|
<!-- Include Project Create Modal for inline project creation -->
|
||||||
{% include "partials/project_create_modal.html" %}
|
{% include "partials/project_create_modal.html" %}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user