2 Commits

Author SHA1 Message Date
serversdwn
62fd963c07 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.
2026-01-29 06:08:40 +00:00
serversdwn
5ee6f5eb28 feat: Enhance dashboard with filtering options and sync SLM status
- Added a new filtering system to the dashboard for device types and statuses.
- Implemented asynchronous SLM status synchronization to update the Emitter table.
- Updated the status snapshot endpoint to sync SLM status before generating the snapshot.
- Refactored the dashboard HTML to include filter controls and JavaScript for managing filter state.
- Improved the unit detail page to handle modem associations and cascade updates to paired devices.
- Removed redundant code related to syncing start time for measuring devices.
2026-01-28 20:02:10 +00:00
19 changed files with 2189 additions and 250 deletions

View File

@@ -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()
}) })

View File

@@ -2,20 +2,32 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Dict, Any from typing import Dict, Any
import asyncio
import logging
import random import random
from backend.database import get_db from backend.database import get_db
from backend.services.snapshot import emit_status_snapshot from backend.services.snapshot import emit_status_snapshot
from backend.services.slm_status_sync import sync_slm_status_to_emitters
router = APIRouter(prefix="/api", tags=["roster"]) router = APIRouter(prefix="/api", tags=["roster"])
logger = logging.getLogger(__name__)
@router.get("/status-snapshot") @router.get("/status-snapshot")
def get_status_snapshot(db: Session = Depends(get_db)): async def get_status_snapshot(db: Session = Depends(get_db)):
""" """
Calls emit_status_snapshot() to get current fleet status. Calls emit_status_snapshot() to get current fleet status.
This will be replaced with real Series3 emitter logic later. Syncs SLM status from SLMM before generating snapshot.
""" """
# Sync SLM status from SLMM (with timeout to prevent blocking)
try:
await asyncio.wait_for(sync_slm_status_to_emitters(), timeout=2.0)
except asyncio.TimeoutError:
logger.warning("SLM status sync timed out, using cached data")
except Exception as e:
logger.warning(f"SLM status sync failed: {e}")
return emit_status_snapshot() return emit_status_snapshot()

View File

@@ -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"
}

View File

@@ -167,23 +167,7 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
measurement_state = state_data.get("measurement_state", "Unknown") measurement_state = state_data.get("measurement_state", "Unknown")
is_measuring = state_data.get("is_measuring", False) is_measuring = state_data.get("is_measuring", False)
# If measuring, sync start time from FTP to database (fixes wrong timestamps) # Get live status (measurement_start_time is already stored in SLMM database)
if is_measuring:
try:
sync_response = await client.post(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/sync-start-time",
timeout=10.0
)
if sync_response.status_code == 200:
sync_data = sync_response.json()
logger.info(f"Synced start time for {unit_id}: {sync_data.get('message')}")
else:
logger.warning(f"Failed to sync start time for {unit_id}: {sync_response.status_code}")
except Exception as e:
# Don't fail the whole request if sync fails
logger.warning(f"Could not sync start time for {unit_id}: {e}")
# Get live status (now with corrected start time)
status_response = await client.get( status_response = await client.get(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live" f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
) )

View File

@@ -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
# ======================================================================== # ========================================================================

View File

@@ -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

View File

@@ -0,0 +1,125 @@
"""
SLM Status Synchronization Service
Syncs SLM device status from SLMM backend to Terra-View's Emitter table.
This bridges SLMM's polling data with Terra-View's status snapshot system.
SLMM tracks device reachability via background polling. This service
fetches that data and creates/updates Emitter records so SLMs appear
correctly in the dashboard status snapshot.
"""
import logging
from datetime import datetime, timezone
from typing import Dict, Any
from backend.database import get_db_session
from backend.models import Emitter
from backend.services.slmm_client import get_slmm_client, SLMMClientError
logger = logging.getLogger(__name__)
async def sync_slm_status_to_emitters() -> Dict[str, Any]:
"""
Fetch SLM status from SLMM and sync to Terra-View's Emitter table.
For each device in SLMM's polling status:
- If last_success exists, create/update Emitter with that timestamp
- If not reachable, update Emitter with last known timestamp (or None)
Returns:
Dict with synced_count, error_count, errors list
"""
client = get_slmm_client()
synced = 0
errors = []
try:
# Get polling status from SLMM
status_response = await client.get_polling_status()
# Handle nested response structure
data = status_response.get("data", status_response)
devices = data.get("devices", [])
if not devices:
logger.debug("No SLM devices in SLMM polling status")
return {"synced_count": 0, "error_count": 0, "errors": []}
db = get_db_session()
try:
for device in devices:
unit_id = device.get("unit_id")
if not unit_id:
continue
try:
# Get or create Emitter record
emitter = db.query(Emitter).filter(Emitter.id == unit_id).first()
# Determine last_seen from SLMM data
last_success_str = device.get("last_success")
is_reachable = device.get("is_reachable", False)
if last_success_str:
# Parse ISO format timestamp
last_seen = datetime.fromisoformat(
last_success_str.replace("Z", "+00:00")
)
# Convert to naive UTC for consistency with existing code
if last_seen.tzinfo:
last_seen = last_seen.astimezone(timezone.utc).replace(tzinfo=None)
else:
last_seen = None
# Status will be recalculated by snapshot.py based on time thresholds
# Just store a provisional status here
status = "OK" if is_reachable else "Missing"
# Store last error message if available
last_error = device.get("last_error") or ""
if emitter:
# Update existing record
emitter.last_seen = last_seen
emitter.status = status
emitter.unit_type = "slm"
emitter.last_file = last_error
else:
# Create new record
emitter = Emitter(
id=unit_id,
unit_type="slm",
last_seen=last_seen,
last_file=last_error,
status=status
)
db.add(emitter)
synced += 1
except Exception as e:
errors.append(f"{unit_id}: {str(e)}")
logger.error(f"Error syncing SLM {unit_id}: {e}")
db.commit()
finally:
db.close()
if synced > 0:
logger.info(f"Synced {synced} SLM device(s) to Emitter table")
except SLMMClientError as e:
logger.warning(f"Could not reach SLMM for status sync: {e}")
errors.append(f"SLMM unreachable: {str(e)}")
except Exception as e:
logger.error(f"Error in SLM status sync: {e}", exc_info=True)
errors.append(str(e))
return {
"synced_count": synced,
"error_count": len(errors),
"errors": errors
}

View File

@@ -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)

View File

@@ -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
} }
) )

View File

@@ -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,
@@ -146,6 +148,22 @@ def emit_status_snapshot():
"coordinates": "", "coordinates": "",
} }
# --- Derive modem status from paired devices ---
# Modems don't have their own check-in system, so we inherit status
# from whatever device they're paired with (seismograph or SLM)
for unit_id, unit_data in units.items():
if unit_data.get("device_type") == "modem" and not unit_data.get("retired"):
roster_unit = roster.get(unit_id)
if roster_unit and roster_unit.deployed_with_unit_id:
paired_unit_id = roster_unit.deployed_with_unit_id
paired_unit = units.get(paired_unit_id)
if paired_unit:
# Inherit status from paired device
unit_data["status"] = paired_unit.get("status", "Missing")
unit_data["age"] = paired_unit.get("age", "N/A")
unit_data["last"] = paired_unit.get("last")
unit_data["derived_from"] = paired_unit_id
# Separate buckets for UI # Separate buckets for UI
active_units = { active_units = {
uid: u for uid, u in units.items() uid: u for uid, u in units.items()

View File

@@ -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>

View File

@@ -187,6 +187,68 @@
</div> </div>
<!-- Dashboard Filters -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-4 mb-4" id="dashboard-filters-card">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Filter Dashboard</h3>
<button onclick="resetFilters()" class="text-xs text-gray-500 hover:text-seismo-orange dark:hover:text-seismo-orange transition-colors">
Reset Filters
</button>
</div>
<div class="flex flex-wrap gap-6">
<!-- Device Type Filters -->
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400 font-medium uppercase tracking-wide">Device Type</span>
<div class="flex gap-4">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" id="filter-seismograph" checked
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-slate-800"
onchange="applyFilters()">
<span class="text-sm text-gray-700 dark:text-gray-300">Seismographs</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" id="filter-slm" checked
class="rounded border-gray-300 text-purple-600 focus:ring-purple-500 dark:border-gray-600 dark:bg-slate-800"
onchange="applyFilters()">
<span class="text-sm text-gray-700 dark:text-gray-300">SLMs</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" id="filter-modem" checked
class="rounded border-gray-300 text-cyan-600 focus:ring-cyan-500 dark:border-gray-600 dark:bg-slate-800"
onchange="applyFilters()">
<span class="text-sm text-gray-700 dark:text-gray-300">Modems</span>
</label>
</div>
</div>
<!-- Status Filters -->
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400 font-medium uppercase tracking-wide">Status</span>
<div class="flex gap-4">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" id="filter-ok" checked
class="rounded border-gray-300 text-green-600 focus:ring-green-500 dark:border-gray-600 dark:bg-slate-800"
onchange="applyFilters()">
<span class="text-sm text-green-600 dark:text-green-400">OK</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" id="filter-pending" checked
class="rounded border-gray-300 text-yellow-600 focus:ring-yellow-500 dark:border-gray-600 dark:bg-slate-800"
onchange="applyFilters()">
<span class="text-sm text-yellow-600 dark:text-yellow-400">Pending</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" id="filter-missing" checked
class="rounded border-gray-300 text-red-600 focus:ring-red-500 dark:border-gray-600 dark:bg-slate-800"
onchange="applyFilters()">
<span class="text-sm text-red-600 dark:text-red-400">Missing</span>
</label>
</div>
</div>
</div>
</div>
<!-- Fleet Map --> <!-- Fleet Map -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 mb-8" id="fleet-map-card"> <div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 mb-8" id="fleet-map-card">
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-map')"> <div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-map')">
@@ -302,6 +364,254 @@
<script> <script>
// ===== Dashboard Filtering System =====
let currentSnapshotData = null; // Store latest snapshot data for re-filtering
// Filter state - tracks which device types and statuses to show
const filters = {
deviceTypes: {
seismograph: true,
sound_level_meter: true,
modem: true
},
statuses: {
OK: true,
Pending: true,
Missing: true
}
};
// Load saved filter preferences from localStorage
function loadFilterPreferences() {
const saved = localStorage.getItem('dashboardFilters');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.deviceTypes) Object.assign(filters.deviceTypes, parsed.deviceTypes);
if (parsed.statuses) Object.assign(filters.statuses, parsed.statuses);
} catch (e) {
console.error('Error loading filter preferences:', e);
}
}
// Sync checkboxes with loaded state
const seismoCheck = document.getElementById('filter-seismograph');
const slmCheck = document.getElementById('filter-slm');
const modemCheck = document.getElementById('filter-modem');
const okCheck = document.getElementById('filter-ok');
const pendingCheck = document.getElementById('filter-pending');
const missingCheck = document.getElementById('filter-missing');
if (seismoCheck) seismoCheck.checked = filters.deviceTypes.seismograph;
if (slmCheck) slmCheck.checked = filters.deviceTypes.sound_level_meter;
if (modemCheck) modemCheck.checked = filters.deviceTypes.modem;
if (okCheck) okCheck.checked = filters.statuses.OK;
if (pendingCheck) pendingCheck.checked = filters.statuses.Pending;
if (missingCheck) missingCheck.checked = filters.statuses.Missing;
}
// Save filter preferences to localStorage
function saveFilterPreferences() {
localStorage.setItem('dashboardFilters', JSON.stringify(filters));
}
// Apply filters - called when any checkbox changes
function applyFilters() {
// Update filter state from checkboxes
const seismoCheck = document.getElementById('filter-seismograph');
const slmCheck = document.getElementById('filter-slm');
const modemCheck = document.getElementById('filter-modem');
const okCheck = document.getElementById('filter-ok');
const pendingCheck = document.getElementById('filter-pending');
const missingCheck = document.getElementById('filter-missing');
if (seismoCheck) filters.deviceTypes.seismograph = seismoCheck.checked;
if (slmCheck) filters.deviceTypes.sound_level_meter = slmCheck.checked;
if (modemCheck) filters.deviceTypes.modem = modemCheck.checked;
if (okCheck) filters.statuses.OK = okCheck.checked;
if (pendingCheck) filters.statuses.Pending = pendingCheck.checked;
if (missingCheck) filters.statuses.Missing = missingCheck.checked;
saveFilterPreferences();
// Re-render with current data and filters
if (currentSnapshotData) {
renderFilteredDashboard(currentSnapshotData);
}
}
// Reset all filters to show everything
function resetFilters() {
filters.deviceTypes = { seismograph: true, sound_level_meter: true, modem: true };
filters.statuses = { OK: true, Pending: true, Missing: true };
// Update all checkboxes
const checkboxes = [
'filter-seismograph', 'filter-slm', 'filter-modem',
'filter-ok', 'filter-pending', 'filter-missing'
];
checkboxes.forEach(id => {
const el = document.getElementById(id);
if (el) el.checked = true;
});
saveFilterPreferences();
if (currentSnapshotData) {
renderFilteredDashboard(currentSnapshotData);
}
}
// Check if a unit passes the current filters
function unitPassesFilter(unit) {
const deviceType = unit.device_type || 'seismograph';
const status = unit.status || 'Missing';
// Check device type filter
if (!filters.deviceTypes[deviceType]) {
return false;
}
// Check status filter
if (!filters.statuses[status]) {
return false;
}
return true;
}
// Get display label for device type
function getDeviceTypeLabel(deviceType) {
switch(deviceType) {
case 'sound_level_meter': return 'SLM';
case 'modem': return 'Modem';
default: return 'Seismograph';
}
}
// Render dashboard with filtered data
function renderFilteredDashboard(data) {
// Filter active units for alerts
const filteredActive = {};
Object.entries(data.active || {}).forEach(([id, unit]) => {
if (unitPassesFilter(unit)) {
filteredActive[id] = unit;
}
});
// Update alerts with filtered data
updateAlertsFiltered(filteredActive);
// Update map with filtered data
updateFleetMapFiltered(data.units);
}
// Update the Recent Alerts section with filtering
function updateAlertsFiltered(filteredActive) {
const alertsList = document.getElementById('alerts-list');
const missingUnits = Object.entries(filteredActive).filter(([_, u]) => u.status === 'Missing');
if (!missingUnits.length) {
// Check if this is because of filters or genuinely no alerts
const anyMissing = currentSnapshotData && Object.values(currentSnapshotData.active || {}).some(u => u.status === 'Missing');
if (anyMissing) {
alertsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No alerts match current filters</p>';
} else {
alertsList.innerHTML = '<p class="text-sm text-green-600 dark:text-green-400">All units reporting normally</p>';
}
} else {
let alertsHtml = '';
missingUnits.forEach(([id, unit]) => {
const deviceLabel = getDeviceTypeLabel(unit.device_type);
alertsHtml += `
<div class="flex items-start space-x-2 text-sm">
<span class="w-2 h-2 rounded-full bg-red-500 mt-1.5"></span>
<div>
<a href="/unit/${id}" class="font-medium text-red-600 dark:text-red-400 hover:underline">${id}</a>
<span class="text-xs text-gray-500 ml-1">(${deviceLabel})</span>
<p class="text-gray-600 dark:text-gray-400">Missing for ${unit.age}</p>
</div>
</div>`;
});
alertsList.innerHTML = alertsHtml;
}
}
// Update map with filtered data
function updateFleetMapFiltered(allUnits) {
if (!fleetMap) return;
// Clear existing markers
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
fleetMarkers = [];
// Get deployed units with coordinates that pass the filter
const deployedUnits = Object.entries(allUnits || {})
.filter(([_, u]) => u.deployed && u.coordinates && unitPassesFilter(u));
if (deployedUnits.length === 0) {
return;
}
const bounds = [];
deployedUnits.forEach(([id, unit]) => {
const coords = parseLocation(unit.coordinates);
if (coords) {
const [lat, lon] = coords;
// Color based on status
const markerColor = unit.status === 'OK' ? 'green' :
unit.status === 'Pending' ? 'orange' : 'red';
// Different marker style per device type
const deviceType = unit.device_type || 'seismograph';
let radius = 8;
let weight = 2;
if (deviceType === 'modem') {
radius = 6;
weight = 2;
} else if (deviceType === 'sound_level_meter') {
radius = 8;
weight = 3;
}
const marker = L.circleMarker([lat, lon], {
radius: radius,
fillColor: markerColor,
color: '#fff',
weight: weight,
opacity: 1,
fillOpacity: 0.8
}).addTo(fleetMap);
// Popup with device type
const deviceLabel = getDeviceTypeLabel(deviceType);
marker.bindPopup(`
<div class="p-2">
<h3 class="font-bold text-lg">${id}</h3>
<p class="text-sm text-gray-600">${deviceLabel}</p>
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''}
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details</a>
</div>
`);
fleetMarkers.push(marker);
bounds.push([lat, lon]);
}
});
// Fit bounds if we have markers
if (bounds.length > 0) {
const padding = window.innerWidth < 768 ? [20, 20] : [50, 50];
fleetMap.fitBounds(bounds, { padding: padding });
fleetMapInitialized = true;
}
}
// Toggle card collapse/expand (mobile only) // Toggle card collapse/expand (mobile only)
function toggleCard(cardName) { function toggleCard(cardName) {
// Only work on mobile // Only work on mobile
@@ -366,8 +676,17 @@ if (document.readyState === 'loading') {
function updateDashboard(event) { function updateDashboard(event) {
try { try {
// Only process responses from /api/status-snapshot
const requestUrl = event.detail.xhr.responseURL || event.detail.pathInfo?.requestPath;
if (!requestUrl || !requestUrl.includes('/api/status-snapshot')) {
return; // Ignore responses from other endpoints (like /dashboard/todays-actions)
}
const data = JSON.parse(event.detail.xhr.response); const data = JSON.parse(event.detail.xhr.response);
// Store data for filter re-application
currentSnapshotData = data;
// Update "Last updated" timestamp with timezone // Update "Last updated" timestamp with timezone
const now = new Date(); const now = new Date();
const timezone = localStorage.getItem('timezone') || 'America/New_York'; const timezone = localStorage.getItem('timezone') || 'America/New_York';
@@ -379,7 +698,7 @@ function updateDashboard(event) {
timeZoneName: 'short' timeZoneName: 'short'
}); });
// ===== Fleet summary numbers ===== // ===== Fleet summary numbers (always unfiltered) =====
document.getElementById('total-units').textContent = data.summary?.total ?? 0; document.getElementById('total-units').textContent = data.summary?.total ?? 0;
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0; document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0; document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
@@ -387,9 +706,10 @@ function updateDashboard(event) {
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0; document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0; document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
// ===== Device type counts ===== // ===== Device type counts (always unfiltered) =====
let seismoCount = 0; let seismoCount = 0;
let slmCount = 0; let slmCount = 0;
let modemCount = 0;
Object.values(data.units || {}).forEach(unit => { Object.values(data.units || {}).forEach(unit => {
if (unit.retired) return; // Don't count retired units if (unit.retired) return; // Don't count retired units
const deviceType = unit.device_type || 'seismograph'; const deviceType = unit.device_type || 'seismograph';
@@ -397,46 +717,26 @@ function updateDashboard(event) {
seismoCount++; seismoCount++;
} else if (deviceType === 'sound_level_meter') { } else if (deviceType === 'sound_level_meter') {
slmCount++; slmCount++;
} else if (deviceType === 'modem') {
modemCount++;
} }
}); });
document.getElementById('seismo-count').textContent = seismoCount; document.getElementById('seismo-count').textContent = seismoCount;
document.getElementById('slm-count').textContent = slmCount; document.getElementById('slm-count').textContent = slmCount;
// ===== Alerts ===== // ===== Apply filters and render map + alerts =====
const alertsList = document.getElementById('alerts-list'); renderFilteredDashboard(data);
// Only show alerts for deployed units that are MISSING (not pending)
const missingUnits = Object.entries(data.active).filter(([_, u]) => u.status === 'Missing');
if (!missingUnits.length) {
alertsList.innerHTML =
'<p class="text-sm text-green-600 dark:text-green-400">✓ All units reporting normally</p>';
} else {
let alertsHtml = '';
missingUnits.forEach(([id, unit]) => {
alertsHtml += `
<div class="flex items-start space-x-2 text-sm">
<span class="w-2 h-2 rounded-full bg-red-500 mt-1.5"></span>
<div>
<a href="/unit/${id}" class="font-medium text-red-600 dark:text-red-400 hover:underline">${id}</a>
<p class="text-gray-600 dark:text-gray-400">Missing for ${unit.age}</p>
</div>
</div>`;
});
alertsList.innerHTML = alertsHtml;
}
// ===== Update Fleet Map =====
updateFleetMap(data);
} catch (err) { } catch (err) {
console.error("Dashboard update error:", err); console.error("Dashboard update error:", err);
} }
} }
// Handle tab switching // Handle tab switching and initialize components
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Load filter preferences
loadFilterPreferences();
const tabButtons = document.querySelectorAll('.tab-button'); const tabButtons = document.querySelectorAll('.tab-button');
tabButtons.forEach(button => { tabButtons.forEach(button => {
@@ -476,64 +776,6 @@ function initFleetMap() {
}, 100); }, 100);
} }
function updateFleetMap(data) {
if (!fleetMap) return;
// Clear existing markers
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
fleetMarkers = [];
// Get deployed units with coordinates data
const deployedUnits = Object.entries(data.units).filter(([_, u]) => u.deployed && u.coordinates);
if (deployedUnits.length === 0) {
return;
}
const bounds = [];
deployedUnits.forEach(([id, unit]) => {
const coords = parseLocation(unit.coordinates);
if (coords) {
const [lat, lon] = coords;
// Create marker with custom color based on status
const markerColor = unit.status === 'OK' ? 'green' : unit.status === 'Pending' ? 'orange' : 'red';
const marker = L.circleMarker([lat, lon], {
radius: 8,
fillColor: markerColor,
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8
}).addTo(fleetMap);
// Add popup with unit info
marker.bindPopup(`
<div class="p-2">
<h3 class="font-bold text-lg">${id}</h3>
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
<p class="text-sm">Type: ${unit.device_type}</p>
${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''}
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details →</a>
</div>
`);
fleetMarkers.push(marker);
bounds.push([lat, lon]);
}
});
// Fit map to show all markers
if (bounds.length > 0) {
// Use different padding for mobile vs desktop
const padding = window.innerWidth < 768 ? [20, 20] : [50, 50];
fleetMap.fitBounds(bounds, { padding: padding });
fleetMapInitialized = true;
}
}
function parseLocation(location) { function parseLocation(location) {
if (!location) return null; if (!location) return null;

View File

@@ -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
View 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 %}

View File

@@ -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}`;
} }
} }
} }

View File

@@ -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 %}

View File

@@ -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}`;
} }
} }
} }

View File

@@ -1154,11 +1154,20 @@
}); });
} }
// Check if any modal is currently open
function isAnyModalOpen() {
const modalIds = ['addUnitModal', 'editUnitModal', 'renameUnitModal', 'importModal', 'quickCreateProjectModal'];
return modalIds.some(id => {
const modal = document.getElementById(id);
return modal && !modal.classList.contains('hidden');
});
}
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Auto-refresh device list every 30 seconds (increased from 10s to reduce flicker) // Auto-refresh device list every 30 seconds (increased from 10s to reduce flicker)
setInterval(() => { setInterval(() => {
const deviceContent = document.getElementById('device-content'); const deviceContent = document.getElementById('device-content');
if (deviceContent && !document.querySelector('.modal:not(.hidden)')) { if (deviceContent && !isAnyModalOpen()) {
// Only auto-refresh if no modal is open // Only auto-refresh if no modal is open
refreshDeviceList(); refreshDeviceList();
} }

View File

@@ -43,7 +43,7 @@
</button> </button>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
<button id="editButton" onclick="window.location.href='/roster?edit=' + unitId" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-center gap-2"> <button id="editButton" onclick="enterEditMode()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg> </svg>
@@ -153,7 +153,12 @@
</div> </div>
<div> <div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployed With Modem</label> <label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployed With Modem</label>
<p id="viewDeployedWithModemId" class="mt-1 text-gray-900 dark:text-white font-medium">--</p> <p id="viewDeployedWithModemContainer" class="mt-1">
<a id="viewDeployedWithModemLink" href="#" class="text-seismo-orange hover:text-orange-600 font-medium hover:underline hidden">
<span id="viewDeployedWithModemText">--</span>
</a>
<span id="viewDeployedWithModemNoLink" class="text-gray-900 dark:text-white font-medium">--</span>
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -336,8 +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>
<input type="text" name="deployed_with_modem_id" id="deployedWithModemId" placeholder="Modem ID" <div class="flex gap-2">
class="w-full px-4 py-2 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"> <div class="flex-1">
{% 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>
@@ -411,11 +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>
<select name="deployed_with_modem_id" id="slmDeployedWithModemId" <div class="flex gap-2">
class="w-full px-4 py-2 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"> <div class="flex-1">
<option value="">No modem assigned</option> {% set picker_id = "-detail-slm" %}
<!-- Options will be populated by JavaScript --> {% set input_name = "deployed_with_modem_id" %}
</select> {% 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>
@@ -442,6 +468,54 @@
class="w-full px-4 py-2 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"></textarea> class="w-full px-4 py-2 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"></textarea>
</div> </div>
<!-- Cascade to Paired Device Section -->
<div id="detailCascadeSection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-4">
<div class="flex items-center gap-2 mb-3">
<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="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>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">
Also update paired device: <span id="detailPairedDeviceName" class="text-seismo-orange"></span>
</span>
</div>
<input type="hidden" name="cascade_to_unit_id" id="detailCascadeToUnitId" value="">
<div class="grid grid-cols-2 md:grid-cols-3 gap-2 bg-gray-50 dark:bg-slate-700/50 rounded-lg p-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_deployed" id="detailCascadeDeployed" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed status</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_retired" id="detailCascadeRetired" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Retired status</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_project" id="detailCascadeProject" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_location" id="detailCascadeLocation" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Address</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_coordinates" id="detailCascadeCoordinates" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Coordinates</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_note" id="detailCascadeNote" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Notes</span>
</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
Check the fields you want to sync to the paired device
</p>
</div>
<!-- Save/Cancel Buttons --> <!-- Save/Cancel Buttons -->
<div class="flex gap-3"> <div class="flex gap-3">
<button type="submit" class="flex-1 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors"> <button type="submit" class="flex-1 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors">
@@ -482,6 +556,44 @@ async function fetchProjectDisplay(projectId) {
return projectId; return projectId;
} }
// Fetch modem display name (combines id, ip_address, hardware_model)
// Also returns the actual modem ID if found (for updating picker value)
async function fetchModemDisplay(modemIdOrIp) {
if (!modemIdOrIp) return { display: '', modemId: '' };
try {
// First try direct lookup by ID
let response = await fetch(`/api/roster/${encodeURIComponent(modemIdOrIp)}`);
if (response.ok) {
const modem = await response.json();
const parts = [modem.id];
if (modem.ip_address) parts.push(`(${modem.ip_address})`);
if (modem.hardware_model) parts.push(`- ${modem.hardware_model}`);
return { display: parts.join(' ') || modemIdOrIp, modemId: modem.id };
}
// If not found, maybe it's an IP address - search for it
response = await fetch(`/api/roster/search/modems?q=${encodeURIComponent(modemIdOrIp)}`);
if (response.ok) {
// The search returns HTML, so we need to look up differently
// Try fetching all modems and find by IP
const modemsResponse = await fetch('/api/roster/modems');
if (modemsResponse.ok) {
const modems = await modemsResponse.json();
const modem = modems.find(m => m.ip_address === modemIdOrIp);
if (modem) {
const parts = [modem.id];
if (modem.ip_address) parts.push(`(${modem.ip_address})`);
if (modem.hardware_model) parts.push(`- ${modem.hardware_model}`);
return { display: parts.join(' ') || modemIdOrIp, modemId: modem.id };
}
}
}
} catch (e) {
console.error('Failed to fetch modem:', e);
}
return { display: modemIdOrIp, modemId: modemIdOrIp };
}
// Load unit data on page load // Load unit data on page load
async function loadUnitData() { async function loadUnitData() {
try { try {
@@ -634,7 +746,29 @@ function populateViewMode() {
// Seismograph fields // Seismograph fields
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--'; document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
document.getElementById('viewNextCalibrationDue').textContent = currentUnit.next_calibration_due || '--'; document.getElementById('viewNextCalibrationDue').textContent = currentUnit.next_calibration_due || '--';
document.getElementById('viewDeployedWithModemId').textContent = currentUnit.deployed_with_modem_id || '--';
// Deployed with modem - show as clickable link
const modemLink = document.getElementById('viewDeployedWithModemLink');
const modemNoLink = document.getElementById('viewDeployedWithModemNoLink');
const modemText = document.getElementById('viewDeployedWithModemText');
if (currentUnit.deployed_with_modem_id) {
// Fetch modem info to get the actual ID (in case stored as IP) and display text
fetchModemDisplay(currentUnit.deployed_with_modem_id).then(result => {
if (modemText) modemText.textContent = result.display;
if (modemLink) {
modemLink.href = `/unit/${encodeURIComponent(result.modemId)}`;
modemLink.classList.remove('hidden');
}
if (modemNoLink) modemNoLink.classList.add('hidden');
});
} else {
if (modemNoLink) {
modemNoLink.textContent = '--';
modemNoLink.classList.remove('hidden');
}
if (modemLink) modemLink.classList.add('hidden');
}
// Modem fields // Modem fields
document.getElementById('viewIpAddress').textContent = currentUnit.ip_address || '--'; document.getElementById('viewIpAddress').textContent = currentUnit.ip_address || '--';
@@ -689,7 +823,24 @@ function populateEditForm() {
// Seismograph fields // Seismograph fields
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || ''; document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';
document.getElementById('nextCalibrationDue').value = currentUnit.next_calibration_due || ''; document.getElementById('nextCalibrationDue').value = currentUnit.next_calibration_due || '';
document.getElementById('deployedWithModemId').value = currentUnit.deployed_with_modem_id || '';
// Populate modem picker for seismograph (uses -detail-seismo suffix)
const modemPickerValue = document.getElementById('modem-picker-value-detail-seismo');
const modemPickerSearch = document.getElementById('modem-picker-search-detail-seismo');
const modemPickerClear = document.getElementById('modem-picker-clear-detail-seismo');
if (currentUnit.deployed_with_modem_id) {
// Fetch modem display info (handles both modem ID and IP address lookups)
fetchModemDisplay(currentUnit.deployed_with_modem_id).then(result => {
// Update the hidden value with the actual modem ID (in case it was stored as IP)
if (modemPickerValue) modemPickerValue.value = result.modemId || currentUnit.deployed_with_modem_id;
if (modemPickerSearch) modemPickerSearch.value = result.display;
if (modemPickerClear) modemPickerClear.classList.remove('hidden');
});
} else {
if (modemPickerValue) modemPickerValue.value = '';
if (modemPickerSearch) modemPickerSearch.value = '';
if (modemPickerClear) modemPickerClear.classList.add('hidden');
}
// Modem fields // Modem fields
document.getElementById('ipAddress').value = currentUnit.ip_address || ''; document.getElementById('ipAddress').value = currentUnit.ip_address || '';
@@ -703,10 +854,69 @@ function populateEditForm() {
document.getElementById('slmFrequencyWeighting').value = currentUnit.slm_frequency_weighting || ''; document.getElementById('slmFrequencyWeighting').value = currentUnit.slm_frequency_weighting || '';
document.getElementById('slmTimeWeighting').value = currentUnit.slm_time_weighting || ''; document.getElementById('slmTimeWeighting').value = currentUnit.slm_time_weighting || '';
document.getElementById('slmMeasurementRange').value = currentUnit.slm_measurement_range || ''; document.getElementById('slmMeasurementRange').value = currentUnit.slm_measurement_range || '';
document.getElementById('slmDeployedWithModemId').value = currentUnit.deployed_with_modem_id || '';
// Populate modem picker for SLM (uses -detail-slm suffix)
const slmModemPickerValue = document.getElementById('modem-picker-value-detail-slm');
const slmModemPickerSearch = document.getElementById('modem-picker-search-detail-slm');
const slmModemPickerClear = document.getElementById('modem-picker-clear-detail-slm');
if (currentUnit.deployed_with_modem_id) {
// Fetch modem display info (handles both modem ID and IP address lookups)
fetchModemDisplay(currentUnit.deployed_with_modem_id).then(result => {
// Update the hidden value with the actual modem ID (in case it was stored as IP)
if (slmModemPickerValue) slmModemPickerValue.value = result.modemId || currentUnit.deployed_with_modem_id;
if (slmModemPickerSearch) slmModemPickerSearch.value = result.display;
if (slmModemPickerClear) slmModemPickerClear.classList.remove('hidden');
});
} else {
if (slmModemPickerValue) slmModemPickerValue.value = '';
if (slmModemPickerSearch) slmModemPickerSearch.value = '';
if (slmModemPickerClear) slmModemPickerClear.classList.add('hidden');
}
// Show/hide fields based on device type // Show/hide fields based on device type
toggleDetailFields(); toggleDetailFields();
// Check for paired device and show cascade section if applicable
checkAndShowCascadeSection();
}
// Check for paired device and show/hide cascade section
async function checkAndShowCascadeSection() {
const cascadeSection = document.getElementById('detailCascadeSection');
const cascadeToUnitId = document.getElementById('detailCascadeToUnitId');
const pairedDeviceName = document.getElementById('detailPairedDeviceName');
if (!cascadeSection) return;
// Reset cascade section
cascadeSection.classList.add('hidden');
if (cascadeToUnitId) cascadeToUnitId.value = '';
if (pairedDeviceName) pairedDeviceName.textContent = '';
// Reset checkboxes
['detailCascadeDeployed', 'detailCascadeRetired', 'detailCascadeProject',
'detailCascadeLocation', 'detailCascadeCoordinates', 'detailCascadeNote'].forEach(id => {
const checkbox = document.getElementById(id);
if (checkbox) checkbox.checked = false;
});
let pairedUnitId = null;
// Check based on device type
if (currentUnit.device_type === 'modem' && currentUnit.deployed_with_unit_id) {
// Modem is paired with a seismograph or SLM
pairedUnitId = currentUnit.deployed_with_unit_id;
} else if ((currentUnit.device_type === 'seismograph' || currentUnit.device_type === 'sound_level_meter') && currentUnit.deployed_with_modem_id) {
// Seismograph or SLM is paired with a modem
pairedUnitId = currentUnit.deployed_with_modem_id;
}
if (pairedUnitId) {
// Show cascade section
cascadeSection.classList.remove('hidden');
if (cascadeToUnitId) cascadeToUnitId.value = pairedUnitId;
if (pairedDeviceName) pairedDeviceName.textContent = pairedUnitId;
}
} }
// Toggle device-specific fields // Toggle device-specific fields
@@ -753,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}`, {
@@ -774,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!`)) {
@@ -1168,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" %}