From 62fd963c076d604236b38cab4478029eea1380a6 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Thu, 29 Jan 2026 06:08:40 +0000 Subject: [PATCH] Add: pair_devices.html template for device pairing interface Fix: - Polling intervals for SLMM. -modem view now list - device pairing much improved. -various other tweaks through out UI. --- backend/main.py | 69 +++- backend/routers/roster_edit.py | 179 +++++++- backend/services/device_controller.py | 68 ++++ backend/services/scheduler.py | 23 +- backend/services/slmm_client.py | 132 +++++- backend/services/slmm_sync.py | 8 +- backend/services/snapshot.py | 2 + templates/base.html | 7 + templates/modems.html | 8 +- templates/pair_devices.html | 566 ++++++++++++++++++++++++++ templates/partials/devices_table.html | 69 +++- templates/partials/modem_list.html | 190 +++++---- templates/partials/roster_table.html | 26 +- templates/unit_detail.html | 288 ++++++++++++- 14 files changed, 1499 insertions(+), 136 deletions(-) create mode 100644 templates/pair_devices.html diff --git a/backend/main.py b/backend/main.py index c09adbd..c4f77e4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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.services.snapshot import emit_status_snapshot from backend.models import IgnoredUnit +from backend.utils.timezone import get_user_timezone # Create database tables Base.metadata.create_all(bind=engine) @@ -223,6 +224,67 @@ async def modems_page(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) async def projects_page(request: Request): """Projects management and overview""" @@ -587,6 +649,7 @@ async def devices_all_partial(request: Request): "last_calibrated": unit_data.get("last_calibrated"), "next_calibration_due": unit_data.get("next_calibration_due"), "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"), "phone_number": unit_data.get("phone_number"), "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"), "next_calibration_due": unit_data.get("next_calibration_due"), "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"), "phone_number": unit_data.get("phone_number"), "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"), "next_calibration_due": unit_data.get("next_calibration_due"), "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"), "phone_number": unit_data.get("phone_number"), "hardware_model": unit_data.get("hardware_model"), @@ -656,6 +721,7 @@ async def devices_all_partial(request: Request): "last_calibrated": None, "next_calibration_due": None, "deployed_with_modem_id": None, + "deployed_with_unit_id": None, "ip_address": None, "phone_number": None, "hardware_model": None, @@ -678,7 +744,8 @@ async def devices_all_partial(request: Request): return templates.TemplateResponse("partials/devices_table.html", { "request": request, "units": units_list, - "timestamp": datetime.now().strftime("%H:%M:%S") + "timestamp": datetime.now().strftime("%H:%M:%S"), + "user_timezone": get_user_timezone() }) diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py index f8e6f7f..0f939b7 100644 --- a/backend/routers/roster_edit.py +++ b/backend/routers/roster_edit.py @@ -10,6 +10,7 @@ import os from backend.database import get_db 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"]) 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}") -def edit_roster_unit( +async def edit_roster_unit( unit_id: str, device_type: str = Form("seismograph"), unit_type: str = Form("series3"), @@ -662,6 +663,16 @@ def edit_roster_unit( 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} if cascaded_unit_id: response["cascaded_to"] = cascaded_unit_id @@ -669,7 +680,7 @@ def edit_roster_unit( @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) old_deployed = unit.deployed unit.deployed = deployed @@ -690,11 +701,21 @@ def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends ) 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} @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) old_retired = unit.retired unit.retired = retired @@ -715,6 +736,16 @@ def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(g ) 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} @@ -1156,3 +1187,145 @@ def delete_history_entry(history_id: int, db: Session = Depends(get_db)): db.delete(history_entry) db.commit() 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" + } diff --git a/backend/services/device_controller.py b/backend/services/device_controller.py index 82ae6fb..615cb32 100644 --- a/backend/services/device_controller.py +++ b/backend/services/device_controller.py @@ -289,6 +289,74 @@ class DeviceController: else: 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 # ======================================================================== diff --git a/backend/services/scheduler.py b/backend/services/scheduler.py index 866ec64..c05e467 100644 --- a/backend/services/scheduler.py +++ b/backend/services/scheduler.py @@ -350,7 +350,14 @@ class SchedulerService: unit_id: str, db: Session, ) -> 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 location = db.query(MonitoringLocation).filter_by(id=action.location_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: raise Exception("Project or location not found") - # Build destination path - # Example: data/Projects/{project-id}/sound/{location-name}/session-{timestamp}/ + # Build destination path (for logging/metadata reference) + # Actual download location is managed by SLMM (data/downloads/{unit_id}/) session_timestamp = datetime.utcnow().strftime("%Y-%m-%d-%H%M") location_type_dir = "sound" if action.device_type == "slm" else "vibration" @@ -368,12 +375,18 @@ class SchedulerService: 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( unit_id, action.device_type, destination_path, - files=None, # Download all files + files=None, # Download all files in current measurement folder ) # TODO: Create DataFile records for downloaded files diff --git a/backend/services/slmm_client.py b/backend/services/slmm_client.py index a242c12..ea30e77 100644 --- a/backend/services/slmm_client.py +++ b/backend/services/slmm_client.py @@ -478,9 +478,118 @@ class SLMMClient: 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( self, unit_id: str, @@ -488,23 +597,24 @@ class SLMMClient: files: Optional[List[str]] = None, ) -> 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: unit_id: Unit identifier - destination_path: Local path to save files - files: List of filenames to download, or None for all + destination_path: Reference path (for logging/metadata, not used by SLMM) + files: Ignored - always downloads the current measurement folder Returns: - Dict with downloaded files list and metadata + Dict with download result including local_path, folder_name, etc. """ - data = { - "destination_path": destination_path, - "files": files or "all", - } - return await self._request("POST", f"/{unit_id}/ftp/download", data=data) + # Use the new method that automatically determines what to download + result = await self.download_current_measurement(unit_id) + result["requested_destination"] = destination_path + return result # ======================================================================== # Cycle Commands (for scheduled automation) diff --git a/backend/services/slmm_sync.py b/backend/services/slmm_sync.py index 78667f0..f90184d 100644 --- a/backend/services/slmm_sync.py +++ b/backend/services/slmm_sync.py @@ -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") 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: async with httpx.AsyncClient(timeout=5.0) as client: response = await client.put( @@ -47,8 +51,8 @@ async def sync_slm_to_slmm(unit: RosterUnit) -> bool: "ftp_enabled": True, "ftp_username": "USER", # Default NL43 credentials "ftp_password": "0000", - "poll_enabled": not unit.retired, # Disable polling for retired units - "poll_interval_seconds": 60, # Default interval + "poll_enabled": should_poll, # Disable polling for benched or retired units + "poll_interval_seconds": 3600, # Default to 1 hour polling } ) diff --git a/backend/services/snapshot.py b/backend/services/snapshot.py index 2fc23f8..856cdc5 100644 --- a/backend/services/snapshot.py +++ b/backend/services/snapshot.py @@ -108,6 +108,7 @@ def emit_status_snapshot(): "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, "deployed_with_modem_id": r.deployed_with_modem_id, + "deployed_with_unit_id": r.deployed_with_unit_id, "ip_address": r.ip_address, "phone_number": r.phone_number, "hardware_model": r.hardware_model, @@ -137,6 +138,7 @@ def emit_status_snapshot(): "last_calibrated": None, "next_calibration_due": None, "deployed_with_modem_id": None, + "deployed_with_unit_id": None, "ip_address": None, "phone_number": None, "hardware_model": None, diff --git a/templates/base.html b/templates/base.html index 1ddabe5..8d70e07 100644 --- a/templates/base.html +++ b/templates/base.html @@ -137,6 +137,13 @@ Modems + + + + + Pair Devices + + diff --git a/templates/modems.html b/templates/modems.html index 882cdb6..46dce54 100644 --- a/templates/modems.html +++ b/templates/modems.html @@ -55,13 +55,7 @@ hx-get="/api/modem-dashboard/units" hx-trigger="load, every 30s" hx-swap="innerHTML"> -
-
-
-
-
-
-
+

Loading modems...

diff --git a/templates/pair_devices.html b/templates/pair_devices.html new file mode 100644 index 0000000..c732780 --- /dev/null +++ b/templates/pair_devices.html @@ -0,0 +1,566 @@ +{% extends "base.html" %} + +{% block title %}Pair Devices - Terra-View{% endblock %} + +{% block content %} +
+ +
+

Pair Devices

+

+ Select a recorder (seismograph or SLM) and a modem to create a bidirectional pairing. +

+
+ + +
+
+
+
+ Recorder: + None selected +
+ + + +
+ Modem: + None selected +
+
+
+ + +
+
+
+ + +
+ +
+
+
+

+ + + + Recorders + ({{ recorders|length }}) +

+
+ +
+ +
+ + +
+
+
+
+
+ {% for unit in recorders %} +
+
+
+
+ {% if unit.device_type == 'slm' %} + + + + {% else %} + + + + {% endif %} +
+
+
{{ unit.id }}
+
+ {{ unit.device_type|capitalize }} + {% if not unit.deployed %}(Benched){% endif %} +
+
+
+
+ {% if unit.deployed_with_modem_id %} + + → {{ unit.deployed_with_modem_id }} + + {% endif %} +
+ +
+
+
+
+ {% else %} +
+ No recorders found in roster +
+ {% endfor %} +
+
+
+ + +
+
+
+

+ + + + Modems + ({{ modems|length }}) +

+
+ +
+ +
+ + +
+
+
+
+
+ {% for unit in modems %} +
+
+
+
+ + + +
+
+
{{ unit.id }}
+
+ {% if unit.ip_address %}{{ unit.ip_address }}{% 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 %}(Benched){% endif %} +
+
+
+
+ {% if unit.deployed_with_unit_id %} + + ← {{ unit.deployed_with_unit_id }} + + {% endif %} +
+ +
+
+
+
+ {% else %} +
+ No modems found in roster +
+ {% endfor %} +
+
+
+
+ + +
+
+

+ + + + Existing Pairings + ({{ pairings|length }}) +

+
+
+
+ {% for pairing in pairings %} +
+
+
+ + {{ pairing.recorder_id }} + + {{ pairing.recorder_type }} +
+ + + +
+ + {{ pairing.modem_id }} + + {% if pairing.modem_ip %} + {{ pairing.modem_ip }} + {% endif %} +
+
+ +
+ {% else %} +
+ No pairings found. Select a recorder and modem above to create one. +
+ {% endfor %} +
+
+
+
+ + +
+ + + + +{% endblock %} diff --git a/templates/partials/devices_table.html b/templates/partials/devices_table.html index ed5aca6..798d5b7 100644 --- a/templates/partials/devices_table.html +++ b/templates/partials/devices_table.html @@ -104,8 +104,13 @@ {% if unit.phone_number %}
{{ unit.phone_number }}
{% endif %} - {% if unit.hardware_model %} -
{{ unit.hardware_model }}
+ {% if unit.deployed_with_unit_id %} +
+ Linked: + + {{ unit.deployed_with_unit_id }} + +
{% endif %} {% else %} {% if unit.next_calibration_due %} @@ -126,7 +131,7 @@ -
{{ unit.last_seen }}
+
{{ unit.last_seen }}
{ + const isoDate = cell.getAttribute('data-iso'); + cell.textContent = formatLastSeenLocal(isoDate); + }); + // Update timestamp const timestampElement = document.getElementById('last-updated'); if (timestampElement) { @@ -365,20 +403,23 @@ }; return acc; }, {}); +})(); - // Sorting state - let currentSort = { column: null, direction: 'asc' }; + // Sorting state (needs to persist across swaps) + if (typeof window.currentSort === 'undefined') { + window.currentSort = { column: null, direction: 'asc' }; + } function sortTable(column) { const tbody = document.getElementById('roster-tbody'); const rows = Array.from(tbody.getElementsByTagName('tr')); // Determine sort direction - if (currentSort.column === column) { - currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc'; + if (window.currentSort.column === column) { + window.currentSort.direction = window.currentSort.direction === 'asc' ? 'desc' : 'asc'; } else { - currentSort.column = column; - currentSort.direction = 'asc'; + window.currentSort.column = column; + window.currentSort.direction = 'asc'; } // Sort rows @@ -406,8 +447,8 @@ bVal = bVal.toLowerCase(); } - if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1; - if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1; + if (aVal < bVal) return window.currentSort.direction === 'asc' ? -1 : 1; + if (aVal > bVal) return window.currentSort.direction === 'asc' ? 1 : -1; return 0; }); @@ -443,10 +484,10 @@ }); // Set current indicator - if (currentSort.column) { - const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`); + if (window.currentSort.column) { + const indicator = document.querySelector(`.sort-indicator[data-column="${window.currentSort.column}"]`); if (indicator) { - indicator.className = `sort-indicator ${currentSort.direction}`; + indicator.className = `sort-indicator ${window.currentSort.direction}`; } } } diff --git a/templates/partials/modem_list.html b/templates/partials/modem_list.html index 72525d5..3826795 100644 --- a/templates/partials/modem_list.html +++ b/templates/partials/modem_list.html @@ -1,89 +1,127 @@ {% if modems %} -
- {% for modem in modems %} -
-
-
-
- - {{ modem.id }} - - {% if modem.hardware_model %} - {{ modem.hardware_model }} +
+ + + + + + + + + + + + + + {% for modem in modems %} + + + + + + + + + + {% endfor %} + +
Unit IDStatusIP AddressPhonePaired DeviceLocationActions
+
+ + {{ modem.id }} + + {% if modem.hardware_model %} + ({{ modem.hardware_model }}) + {% endif %} +
+
+ {% if modem.status == "retired" %} + + Retired + + {% elif modem.status == "benched" %} + + Benched + + {% elif modem.status == "in_use" %} + + In Use + + {% elif modem.status == "spare" %} + + Spare + + {% else %} + + — + {% endif %} - - - {% if modem.ip_address %} -

{{ modem.ip_address }}

- {% else %} -

No IP configured

- {% endif %} - - {% if modem.phone_number %} -

{{ modem.phone_number }}

- {% endif %} - - - - {% if modem.status == "retired" %} - Retired - {% elif modem.status == "benched" %} - Benched - {% elif modem.status == "in_use" %} - In Use - {% elif modem.status == "spare" %} - Spare - {% endif %} - - - - {% if modem.paired_device %} - - {% endif %} - - - {% if modem.location or modem.project_id %} -
- {% if modem.project_id %} - {{ modem.project_id }} - {% endif %} - {% if modem.location %} - {{ modem.location }} - {% endif %} -
- {% endif %} - - -
- - - Details - -
- - - - - {% endfor %} +
+ {% if modem.ip_address %} + {{ modem.ip_address }} + {% else %} + + {% endif %} + + {% if modem.phone_number %} + {{ modem.phone_number }} + {% else %} + + {% endif %} + + {% if modem.paired_device %} + + {{ modem.paired_device.id }} + ({{ modem.paired_device.device_type }}) + + {% else %} + None + {% endif %} + + {% if modem.project_id %} + {{ modem.project_id }} + {% endif %} + {% if modem.location %} + {{ modem.location }} + {% elif not modem.project_id %} + + {% endif %} + +
+ + + View → + +
+ + +
+ +{% if search %} +
+ Found {{ modems|length }} modem(s) matching "{{ search }}" +
+{% endif %} + {% else %}

No modems found

+ {% if search %} + + {% else %}

Add modems from the Fleet Roster

+ {% endif %}
{% endif %} diff --git a/templates/partials/roster_table.html b/templates/partials/roster_table.html index 720f158..08e4292 100644 --- a/templates/partials/roster_table.html +++ b/templates/partials/roster_table.html @@ -337,6 +337,7 @@ + + + {% include "partials/project_create_modal.html" %}