Compare commits
3 Commits
6492fdff82
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4957a08198 | ||
|
|
05482bd903 | ||
|
|
5ee6f5eb28 |
@@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -289,6 +289,74 @@ class DeviceController:
|
|||||||
else:
|
else:
|
||||||
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# FTP Control
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def enable_ftp(
|
||||||
|
self,
|
||||||
|
unit_id: str,
|
||||||
|
device_type: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Enable FTP server on device.
|
||||||
|
|
||||||
|
Must be called before downloading files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
device_type: "slm" | "seismograph"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response dict with status
|
||||||
|
"""
|
||||||
|
if device_type == "slm":
|
||||||
|
try:
|
||||||
|
return await self.slmm_client.enable_ftp(unit_id)
|
||||||
|
except SLMMClientError as e:
|
||||||
|
raise DeviceControllerError(f"SLMM error: {str(e)}")
|
||||||
|
|
||||||
|
elif device_type == "seismograph":
|
||||||
|
return {
|
||||||
|
"status": "not_implemented",
|
||||||
|
"message": "Seismograph FTP not yet implemented",
|
||||||
|
"unit_id": unit_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||||
|
|
||||||
|
async def disable_ftp(
|
||||||
|
self,
|
||||||
|
unit_id: str,
|
||||||
|
device_type: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Disable FTP server on device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
device_type: "slm" | "seismograph"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response dict with status
|
||||||
|
"""
|
||||||
|
if device_type == "slm":
|
||||||
|
try:
|
||||||
|
return await self.slmm_client.disable_ftp(unit_id)
|
||||||
|
except SLMMClientError as e:
|
||||||
|
raise DeviceControllerError(f"SLMM error: {str(e)}")
|
||||||
|
|
||||||
|
elif device_type == "seismograph":
|
||||||
|
return {
|
||||||
|
"status": "not_implemented",
|
||||||
|
"message": "Seismograph FTP not yet implemented",
|
||||||
|
"unit_id": unit_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# Device Configuration
|
# Device Configuration
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|||||||
@@ -350,7 +350,14 @@ class SchedulerService:
|
|||||||
unit_id: str,
|
unit_id: str,
|
||||||
db: Session,
|
db: Session,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Execute a 'download' action."""
|
"""Execute a 'download' action.
|
||||||
|
|
||||||
|
This handles standalone download actions (not part of stop_cycle).
|
||||||
|
The workflow is:
|
||||||
|
1. Enable FTP on device
|
||||||
|
2. Download current measurement folder
|
||||||
|
3. (Optionally disable FTP - left enabled for now)
|
||||||
|
"""
|
||||||
# Get project and location info for file path
|
# Get project and location info for file path
|
||||||
location = db.query(MonitoringLocation).filter_by(id=action.location_id).first()
|
location = db.query(MonitoringLocation).filter_by(id=action.location_id).first()
|
||||||
project = db.query(Project).filter_by(id=action.project_id).first()
|
project = db.query(Project).filter_by(id=action.project_id).first()
|
||||||
@@ -358,8 +365,8 @@ class SchedulerService:
|
|||||||
if not location or not project:
|
if not location or not project:
|
||||||
raise Exception("Project or location not found")
|
raise Exception("Project or location not found")
|
||||||
|
|
||||||
# Build destination path
|
# Build destination path (for logging/metadata reference)
|
||||||
# Example: data/Projects/{project-id}/sound/{location-name}/session-{timestamp}/
|
# Actual download location is managed by SLMM (data/downloads/{unit_id}/)
|
||||||
session_timestamp = datetime.utcnow().strftime("%Y-%m-%d-%H%M")
|
session_timestamp = datetime.utcnow().strftime("%Y-%m-%d-%H%M")
|
||||||
location_type_dir = "sound" if action.device_type == "slm" else "vibration"
|
location_type_dir = "sound" if action.device_type == "slm" else "vibration"
|
||||||
|
|
||||||
@@ -368,12 +375,18 @@ class SchedulerService:
|
|||||||
f"{location.name}/session-{session_timestamp}/"
|
f"{location.name}/session-{session_timestamp}/"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Download files via device controller
|
# Step 1: Enable FTP on device
|
||||||
|
logger.info(f"Enabling FTP on {unit_id} for download")
|
||||||
|
await self.device_controller.enable_ftp(unit_id, action.device_type)
|
||||||
|
|
||||||
|
# Step 2: Download current measurement folder
|
||||||
|
# The slmm_client.download_files() now automatically determines the correct
|
||||||
|
# folder based on the device's current index number
|
||||||
response = await self.device_controller.download_files(
|
response = await self.device_controller.download_files(
|
||||||
unit_id,
|
unit_id,
|
||||||
action.device_type,
|
action.device_type,
|
||||||
destination_path,
|
destination_path,
|
||||||
files=None, # Download all files
|
files=None, # Download all files in current measurement folder
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: Create DataFile records for downloaded files
|
# TODO: Create DataFile records for downloaded files
|
||||||
|
|||||||
125
backend/services/slm_status_sync.py
Normal file
125
backend/services/slm_status_sync.py
Normal 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
|
||||||
|
}
|
||||||
@@ -478,9 +478,118 @@ class SLMMClient:
|
|||||||
return await self._request("GET", f"/{unit_id}/settings")
|
return await self._request("GET", f"/{unit_id}/settings")
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# Data Download (Future)
|
# FTP Control
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|
||||||
|
async def enable_ftp(self, unit_id: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Enable FTP server on device.
|
||||||
|
|
||||||
|
Must be called before downloading files. FTP and TCP can work in tandem.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with status message
|
||||||
|
"""
|
||||||
|
return await self._request("POST", f"/{unit_id}/ftp/enable")
|
||||||
|
|
||||||
|
async def disable_ftp(self, unit_id: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Disable FTP server on device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with status message
|
||||||
|
"""
|
||||||
|
return await self._request("POST", f"/{unit_id}/ftp/disable")
|
||||||
|
|
||||||
|
async def get_ftp_status(self, unit_id: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get FTP server status on device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with ftp_enabled status
|
||||||
|
"""
|
||||||
|
return await self._request("GET", f"/{unit_id}/ftp/status")
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Data Download
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def download_file(
|
||||||
|
self,
|
||||||
|
unit_id: str,
|
||||||
|
remote_path: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Download a single file from unit via FTP.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
remote_path: Path on device to download (e.g., "/NL43_DATA/measurement.wav")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Binary file content (as response)
|
||||||
|
"""
|
||||||
|
data = {"remote_path": remote_path}
|
||||||
|
return await self._request("POST", f"/{unit_id}/ftp/download", data=data)
|
||||||
|
|
||||||
|
async def download_folder(
|
||||||
|
self,
|
||||||
|
unit_id: str,
|
||||||
|
remote_path: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Download an entire folder from unit via FTP as a ZIP archive.
|
||||||
|
|
||||||
|
Useful for downloading complete measurement sessions (e.g., Auto_0000 folders).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
remote_path: Folder path on device to download (e.g., "/NL43_DATA/Auto_0000")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with local_path, folder_name, file_count, zip_size_bytes
|
||||||
|
"""
|
||||||
|
data = {"remote_path": remote_path}
|
||||||
|
return await self._request("POST", f"/{unit_id}/ftp/download-folder", data=data)
|
||||||
|
|
||||||
|
async def download_current_measurement(
|
||||||
|
self,
|
||||||
|
unit_id: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Download the current measurement folder based on device's index number.
|
||||||
|
|
||||||
|
This is the recommended method for scheduled downloads - it automatically
|
||||||
|
determines which folder to download based on the device's current store index.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with local_path, folder_name, file_count, zip_size_bytes, index_number
|
||||||
|
"""
|
||||||
|
# Get current index number from device
|
||||||
|
index_info = await self.get_index_number(unit_id)
|
||||||
|
index_number = index_info.get("index_number", 0)
|
||||||
|
|
||||||
|
# Format as Auto_XXXX folder name
|
||||||
|
folder_name = f"Auto_{index_number:04d}"
|
||||||
|
remote_path = f"/NL43_DATA/{folder_name}"
|
||||||
|
|
||||||
|
# Download the folder
|
||||||
|
result = await self.download_folder(unit_id, remote_path)
|
||||||
|
result["index_number"] = index_number
|
||||||
|
return result
|
||||||
|
|
||||||
async def download_files(
|
async def download_files(
|
||||||
self,
|
self,
|
||||||
unit_id: str,
|
unit_id: str,
|
||||||
@@ -488,23 +597,24 @@ class SLMMClient:
|
|||||||
files: Optional[List[str]] = None,
|
files: Optional[List[str]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Download files from unit via FTP.
|
Download measurement files from unit via FTP.
|
||||||
|
|
||||||
NOTE: This endpoint doesn't exist in SLMM yet. Will need to implement.
|
This method automatically determines the current measurement folder and downloads it.
|
||||||
|
The destination_path parameter is logged for reference but actual download location
|
||||||
|
is managed by SLMM (data/downloads/{unit_id}/).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
unit_id: Unit identifier
|
unit_id: Unit identifier
|
||||||
destination_path: Local path to save files
|
destination_path: Reference path (for logging/metadata, not used by SLMM)
|
||||||
files: List of filenames to download, or None for all
|
files: Ignored - always downloads the current measurement folder
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with downloaded files list and metadata
|
Dict with download result including local_path, folder_name, etc.
|
||||||
"""
|
"""
|
||||||
data = {
|
# Use the new method that automatically determines what to download
|
||||||
"destination_path": destination_path,
|
result = await self.download_current_measurement(unit_id)
|
||||||
"files": files or "all",
|
result["requested_destination"] = destination_path
|
||||||
}
|
return result
|
||||||
return await self._request("POST", f"/{unit_id}/ftp/download", data=data)
|
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# Cycle Commands (for scheduled automation)
|
# Cycle Commands (for scheduled automation)
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ async def sync_slm_to_slmm(unit: RosterUnit) -> bool:
|
|||||||
logger.warning(f"SLM {unit.id} has no host configured, skipping SLMM sync")
|
logger.warning(f"SLM {unit.id} has no host configured, skipping SLMM sync")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Disable polling if unit is benched (deployed=False) or retired
|
||||||
|
# Only actively deployed units should be polled
|
||||||
|
should_poll = unit.deployed and not unit.retired
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
response = await client.put(
|
response = await client.put(
|
||||||
@@ -47,8 +51,8 @@ async def sync_slm_to_slmm(unit: RosterUnit) -> bool:
|
|||||||
"ftp_enabled": True,
|
"ftp_enabled": True,
|
||||||
"ftp_username": "USER", # Default NL43 credentials
|
"ftp_username": "USER", # Default NL43 credentials
|
||||||
"ftp_password": "0000",
|
"ftp_password": "0000",
|
||||||
"poll_enabled": not unit.retired, # Disable polling for retired units
|
"poll_enabled": should_poll, # Disable polling for benched or retired units
|
||||||
"poll_interval_seconds": 60, # Default interval
|
"poll_interval_seconds": 3600, # Default to 1 hour polling
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ def emit_status_snapshot():
|
|||||||
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
||||||
"next_calibration_due": r.next_calibration_due.isoformat() if r.next_calibration_due else None,
|
"next_calibration_due": r.next_calibration_due.isoformat() if r.next_calibration_due else None,
|
||||||
"deployed_with_modem_id": r.deployed_with_modem_id,
|
"deployed_with_modem_id": r.deployed_with_modem_id,
|
||||||
|
"deployed_with_unit_id": r.deployed_with_unit_id,
|
||||||
"ip_address": r.ip_address,
|
"ip_address": r.ip_address,
|
||||||
"phone_number": r.phone_number,
|
"phone_number": r.phone_number,
|
||||||
"hardware_model": r.hardware_model,
|
"hardware_model": r.hardware_model,
|
||||||
@@ -137,6 +138,7 @@ def emit_status_snapshot():
|
|||||||
"last_calibrated": None,
|
"last_calibrated": None,
|
||||||
"next_calibration_due": None,
|
"next_calibration_due": None,
|
||||||
"deployed_with_modem_id": None,
|
"deployed_with_modem_id": None,
|
||||||
|
"deployed_with_unit_id": None,
|
||||||
"ip_address": None,
|
"ip_address": None,
|
||||||
"phone_number": None,
|
"phone_number": None,
|
||||||
"hardware_model": None,
|
"hardware_model": None,
|
||||||
@@ -146,6 +148,34 @@ 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)
|
||||||
|
# Check both directions: modem.deployed_with_unit_id OR device.deployed_with_modem_id
|
||||||
|
for unit_id, unit_data in units.items():
|
||||||
|
if unit_data.get("device_type") == "modem" and not unit_data.get("retired"):
|
||||||
|
paired_unit_id = None
|
||||||
|
roster_unit = roster.get(unit_id)
|
||||||
|
|
||||||
|
# First, check if modem has deployed_with_unit_id set
|
||||||
|
if roster_unit and roster_unit.deployed_with_unit_id:
|
||||||
|
paired_unit_id = roster_unit.deployed_with_unit_id
|
||||||
|
else:
|
||||||
|
# Fallback: check if any device has this modem in deployed_with_modem_id
|
||||||
|
for other_id, other_roster in roster.items():
|
||||||
|
if other_roster.deployed_with_modem_id == unit_id:
|
||||||
|
paired_unit_id = other_id
|
||||||
|
break
|
||||||
|
|
||||||
|
if paired_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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -55,13 +55,7 @@
|
|||||||
hx-get="/api/modem-dashboard/units"
|
hx-get="/api/modem-dashboard/units"
|
||||||
hx-trigger="load, every 30s"
|
hx-trigger="load, every 30s"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<div class="animate-pulse space-y-4">
|
<p class="text-gray-500 dark:text-gray-400">Loading modems...</p>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
<div class="bg-gray-200 dark:bg-gray-700 h-40 rounded-lg"></div>
|
|
||||||
<div class="bg-gray-200 dark:bg-gray-700 h-40 rounded-lg"></div>
|
|
||||||
<div class="bg-gray-200 dark:bg-gray-700 h-40 rounded-lg"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
566
templates/pair_devices.html
Normal file
566
templates/pair_devices.html
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Pair Devices - Terra-View{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Pair Devices</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Select a recorder (seismograph or SLM) and a modem to create a bidirectional pairing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selection Summary Bar -->
|
||||||
|
<div id="selection-bar" class="mb-6 p-4 bg-white dark:bg-slate-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<div class="flex items-center gap-6">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Recorder:</span>
|
||||||
|
<span id="selected-recorder" class="font-mono font-medium text-gray-900 dark:text-white">None selected</span>
|
||||||
|
</div>
|
||||||
|
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"></path>
|
||||||
|
</svg>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Modem:</span>
|
||||||
|
<span id="selected-modem" class="font-mono font-medium text-gray-900 dark:text-white">None selected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button id="clear-selection-btn"
|
||||||
|
onclick="clearSelection()"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<button id="pair-btn"
|
||||||
|
onclick="pairDevices()"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-seismo-orange rounded-lg hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled>
|
||||||
|
Pair Devices
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Two Column Layout -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- Left Column: Recorders (Seismographs + SLMs) -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
Recorders
|
||||||
|
<span id="recorder-count" class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ recorders|length }})</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<!-- Recorder Search & Filters -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<input type="text" id="recorder-search" placeholder="Search by ID..."
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange focus:border-seismo-orange"
|
||||||
|
oninput="filterRecorders()">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" id="recorder-hide-paired" onchange="filterRecorders()" class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
|
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400">Hide paired</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" id="recorder-deployed-only" onchange="filterRecorders()" class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
|
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400">Deployed only</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-[600px] overflow-y-auto">
|
||||||
|
<div id="recorders-list" class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for unit in recorders %}
|
||||||
|
<div class="device-row recorder-row p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors"
|
||||||
|
data-id="{{ unit.id }}"
|
||||||
|
data-deployed="{{ unit.deployed|lower }}"
|
||||||
|
data-paired-with="{{ unit.deployed_with_modem_id or '' }}"
|
||||||
|
data-device-type="{{ unit.device_type }}"
|
||||||
|
onclick="selectRecorder('{{ unit.id }}')">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full flex items-center justify-center
|
||||||
|
{% if unit.device_type == 'slm' %}bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400
|
||||||
|
{% else %}bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400{% endif %}">
|
||||||
|
{% if unit.device_type == 'slm' %}
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||||
|
</svg>
|
||||||
|
{% else %}
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-mono font-medium text-gray-900 dark:text-white">{{ unit.id }}</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ unit.device_type|capitalize }}
|
||||||
|
{% if not unit.deployed %}<span class="text-yellow-600 dark:text-yellow-400">(Benched)</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if unit.deployed_with_modem_id %}
|
||||||
|
<span class="px-2 py-1 text-xs rounded-full bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400">
|
||||||
|
→ {{ unit.deployed_with_modem_id }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="w-5 h-5 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center selection-indicator">
|
||||||
|
<svg class="w-3 h-3 text-seismo-orange hidden check-icon" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
No recorders found in roster
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Modems -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||||
|
</svg>
|
||||||
|
Modems
|
||||||
|
<span id="modem-count" class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ modems|length }})</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<!-- Modem Search & Filters -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<input type="text" id="modem-search" placeholder="Search by ID, IP, or phone..."
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange focus:border-seismo-orange"
|
||||||
|
oninput="filterModems()">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" id="modem-hide-paired" onchange="filterModems()" class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
|
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400">Hide paired</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" id="modem-deployed-only" onchange="filterModems()" class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
|
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400">Deployed only</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-[600px] overflow-y-auto">
|
||||||
|
<div id="modems-list" class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for unit in modems %}
|
||||||
|
<div class="device-row modem-row p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors"
|
||||||
|
data-id="{{ unit.id }}"
|
||||||
|
data-deployed="{{ unit.deployed|lower }}"
|
||||||
|
data-paired-with="{{ unit.deployed_with_unit_id or '' }}"
|
||||||
|
data-ip="{{ unit.ip_address or '' }}"
|
||||||
|
data-phone="{{ unit.phone_number or '' }}"
|
||||||
|
onclick="selectModem('{{ unit.id }}')">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center text-amber-600 dark:text-amber-400">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-mono font-medium text-gray-900 dark:text-white">{{ unit.id }}</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{% if unit.ip_address %}<span class="font-mono">{{ unit.ip_address }}</span>{% endif %}
|
||||||
|
{% if unit.phone_number %}{% if unit.ip_address %} · {% endif %}{{ unit.phone_number }}{% endif %}
|
||||||
|
{% if not unit.ip_address and not unit.phone_number %}Modem{% endif %}
|
||||||
|
{% if not unit.deployed %}<span class="text-yellow-600 dark:text-yellow-400">(Benched)</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if unit.deployed_with_unit_id %}
|
||||||
|
<span class="px-2 py-1 text-xs rounded-full bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400">
|
||||||
|
← {{ unit.deployed_with_unit_id }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="w-5 h-5 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center selection-indicator">
|
||||||
|
<svg class="w-3 h-3 text-seismo-orange hidden check-icon" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
No modems found in roster
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing Pairings Section -->
|
||||||
|
<div class="mt-8 bg-white dark:bg-slate-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
||||||
|
</svg>
|
||||||
|
Existing Pairings
|
||||||
|
<span id="pairing-count" class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ pairings|length }})</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-[400px] overflow-y-auto">
|
||||||
|
<div id="pairings-list" class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for pairing in pairings %}
|
||||||
|
<div class="pairing-row p-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="px-2 py-1 text-sm font-mono rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400">
|
||||||
|
{{ pairing.recorder_id }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ pairing.recorder_type }}</span>
|
||||||
|
</div>
|
||||||
|
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"></path>
|
||||||
|
</svg>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="px-2 py-1 text-sm font-mono rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400">
|
||||||
|
{{ pairing.modem_id }}
|
||||||
|
</span>
|
||||||
|
{% if pairing.modem_ip %}
|
||||||
|
<span class="text-xs font-mono text-gray-500 dark:text-gray-400">{{ pairing.modem_ip }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="unpairDevices('{{ pairing.recorder_id }}', '{{ pairing.modem_id }}')"
|
||||||
|
class="p-2 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 rounded-lg transition-colors"
|
||||||
|
title="Unpair devices">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
No pairings found. Select a recorder and modem above to create one.
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast notification -->
|
||||||
|
<div id="toast" class="fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg transform translate-y-full opacity-0 transition-all duration-300 z-50"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let selectedRecorder = null;
|
||||||
|
let selectedModem = null;
|
||||||
|
|
||||||
|
function selectRecorder(id) {
|
||||||
|
// Deselect previous
|
||||||
|
document.querySelectorAll('.recorder-row').forEach(row => {
|
||||||
|
row.classList.remove('bg-seismo-orange/10', 'dark:bg-seismo-orange/20');
|
||||||
|
row.querySelector('.selection-indicator').classList.remove('border-seismo-orange', 'bg-seismo-orange');
|
||||||
|
row.querySelector('.selection-indicator').classList.add('border-gray-300', 'dark:border-gray-600');
|
||||||
|
row.querySelector('.check-icon').classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle selection
|
||||||
|
if (selectedRecorder === id) {
|
||||||
|
selectedRecorder = null;
|
||||||
|
document.getElementById('selected-recorder').textContent = 'None selected';
|
||||||
|
} else {
|
||||||
|
selectedRecorder = id;
|
||||||
|
document.getElementById('selected-recorder').textContent = id;
|
||||||
|
|
||||||
|
// Highlight selected
|
||||||
|
const row = document.querySelector(`.recorder-row[data-id="${id}"]`);
|
||||||
|
if (row) {
|
||||||
|
row.classList.add('bg-seismo-orange/10', 'dark:bg-seismo-orange/20');
|
||||||
|
row.querySelector('.selection-indicator').classList.remove('border-gray-300', 'dark:border-gray-600');
|
||||||
|
row.querySelector('.selection-indicator').classList.add('border-seismo-orange', 'bg-seismo-orange');
|
||||||
|
row.querySelector('.check-icon').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectModem(id) {
|
||||||
|
// Deselect previous
|
||||||
|
document.querySelectorAll('.modem-row').forEach(row => {
|
||||||
|
row.classList.remove('bg-seismo-orange/10', 'dark:bg-seismo-orange/20');
|
||||||
|
row.querySelector('.selection-indicator').classList.remove('border-seismo-orange', 'bg-seismo-orange');
|
||||||
|
row.querySelector('.selection-indicator').classList.add('border-gray-300', 'dark:border-gray-600');
|
||||||
|
row.querySelector('.check-icon').classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle selection
|
||||||
|
if (selectedModem === id) {
|
||||||
|
selectedModem = null;
|
||||||
|
document.getElementById('selected-modem').textContent = 'None selected';
|
||||||
|
} else {
|
||||||
|
selectedModem = id;
|
||||||
|
document.getElementById('selected-modem').textContent = id;
|
||||||
|
|
||||||
|
// Highlight selected
|
||||||
|
const row = document.querySelector(`.modem-row[data-id="${id}"]`);
|
||||||
|
if (row) {
|
||||||
|
row.classList.add('bg-seismo-orange/10', 'dark:bg-seismo-orange/20');
|
||||||
|
row.querySelector('.selection-indicator').classList.remove('border-gray-300', 'dark:border-gray-600');
|
||||||
|
row.querySelector('.selection-indicator').classList.add('border-seismo-orange', 'bg-seismo-orange');
|
||||||
|
row.querySelector('.check-icon').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateButtons() {
|
||||||
|
const pairBtn = document.getElementById('pair-btn');
|
||||||
|
const clearBtn = document.getElementById('clear-selection-btn');
|
||||||
|
|
||||||
|
pairBtn.disabled = !(selectedRecorder && selectedModem);
|
||||||
|
clearBtn.disabled = !(selectedRecorder || selectedModem);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
if (selectedRecorder) selectRecorder(selectedRecorder);
|
||||||
|
if (selectedModem) selectModem(selectedModem);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterRecorders() {
|
||||||
|
const searchTerm = document.getElementById('recorder-search').value.toLowerCase().trim();
|
||||||
|
const hidePaired = document.getElementById('recorder-hide-paired').checked;
|
||||||
|
const deployedOnly = document.getElementById('recorder-deployed-only').checked;
|
||||||
|
|
||||||
|
let visibleRecorders = 0;
|
||||||
|
|
||||||
|
document.querySelectorAll('.recorder-row').forEach(row => {
|
||||||
|
const id = row.dataset.id.toLowerCase();
|
||||||
|
const pairedWith = row.dataset.pairedWith;
|
||||||
|
const deployed = row.dataset.deployed === 'true';
|
||||||
|
|
||||||
|
let show = true;
|
||||||
|
if (searchTerm && !id.includes(searchTerm)) show = false;
|
||||||
|
if (hidePaired && pairedWith) show = false;
|
||||||
|
if (deployedOnly && !deployed) show = false;
|
||||||
|
|
||||||
|
row.style.display = show ? '' : 'none';
|
||||||
|
if (show) visibleRecorders++;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('recorder-count').textContent = `(${visibleRecorders})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterModems() {
|
||||||
|
const searchTerm = document.getElementById('modem-search').value.toLowerCase().trim();
|
||||||
|
const hidePaired = document.getElementById('modem-hide-paired').checked;
|
||||||
|
const deployedOnly = document.getElementById('modem-deployed-only').checked;
|
||||||
|
|
||||||
|
let visibleModems = 0;
|
||||||
|
|
||||||
|
document.querySelectorAll('.modem-row').forEach(row => {
|
||||||
|
const id = row.dataset.id.toLowerCase();
|
||||||
|
const ip = (row.dataset.ip || '').toLowerCase();
|
||||||
|
const phone = (row.dataset.phone || '').toLowerCase();
|
||||||
|
const pairedWith = row.dataset.pairedWith;
|
||||||
|
const deployed = row.dataset.deployed === 'true';
|
||||||
|
|
||||||
|
let show = true;
|
||||||
|
if (searchTerm && !id.includes(searchTerm) && !ip.includes(searchTerm) && !phone.includes(searchTerm)) show = false;
|
||||||
|
if (hidePaired && pairedWith) show = false;
|
||||||
|
if (deployedOnly && !deployed) show = false;
|
||||||
|
|
||||||
|
row.style.display = show ? '' : 'none';
|
||||||
|
if (show) visibleModems++;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('modem-count').textContent = `(${visibleModems})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveScrollPositions() {
|
||||||
|
const recordersList = document.getElementById('recorders-list').parentElement;
|
||||||
|
const modemsList = document.getElementById('modems-list').parentElement;
|
||||||
|
const pairingsList = document.getElementById('pairings-list').parentElement;
|
||||||
|
|
||||||
|
sessionStorage.setItem('pairDevices_recorderScroll', recordersList.scrollTop);
|
||||||
|
sessionStorage.setItem('pairDevices_modemScroll', modemsList.scrollTop);
|
||||||
|
sessionStorage.setItem('pairDevices_pairingScroll', pairingsList.scrollTop);
|
||||||
|
|
||||||
|
// Save recorder filter state
|
||||||
|
sessionStorage.setItem('pairDevices_recorderSearch', document.getElementById('recorder-search').value);
|
||||||
|
sessionStorage.setItem('pairDevices_recorderHidePaired', document.getElementById('recorder-hide-paired').checked);
|
||||||
|
sessionStorage.setItem('pairDevices_recorderDeployedOnly', document.getElementById('recorder-deployed-only').checked);
|
||||||
|
|
||||||
|
// Save modem filter state
|
||||||
|
sessionStorage.setItem('pairDevices_modemSearch', document.getElementById('modem-search').value);
|
||||||
|
sessionStorage.setItem('pairDevices_modemHidePaired', document.getElementById('modem-hide-paired').checked);
|
||||||
|
sessionStorage.setItem('pairDevices_modemDeployedOnly', document.getElementById('modem-deployed-only').checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreScrollPositions() {
|
||||||
|
const recorderScroll = sessionStorage.getItem('pairDevices_recorderScroll');
|
||||||
|
const modemScroll = sessionStorage.getItem('pairDevices_modemScroll');
|
||||||
|
const pairingScroll = sessionStorage.getItem('pairDevices_pairingScroll');
|
||||||
|
|
||||||
|
if (recorderScroll) {
|
||||||
|
document.getElementById('recorders-list').parentElement.scrollTop = parseInt(recorderScroll);
|
||||||
|
}
|
||||||
|
if (modemScroll) {
|
||||||
|
document.getElementById('modems-list').parentElement.scrollTop = parseInt(modemScroll);
|
||||||
|
}
|
||||||
|
if (pairingScroll) {
|
||||||
|
document.getElementById('pairings-list').parentElement.scrollTop = parseInt(pairingScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore recorder filter state
|
||||||
|
const recorderSearch = sessionStorage.getItem('pairDevices_recorderSearch');
|
||||||
|
const recorderHidePaired = sessionStorage.getItem('pairDevices_recorderHidePaired');
|
||||||
|
const recorderDeployedOnly = sessionStorage.getItem('pairDevices_recorderDeployedOnly');
|
||||||
|
|
||||||
|
if (recorderSearch) document.getElementById('recorder-search').value = recorderSearch;
|
||||||
|
if (recorderHidePaired === 'true') document.getElementById('recorder-hide-paired').checked = true;
|
||||||
|
if (recorderDeployedOnly === 'true') document.getElementById('recorder-deployed-only').checked = true;
|
||||||
|
|
||||||
|
// Restore modem filter state
|
||||||
|
const modemSearch = sessionStorage.getItem('pairDevices_modemSearch');
|
||||||
|
const modemHidePaired = sessionStorage.getItem('pairDevices_modemHidePaired');
|
||||||
|
const modemDeployedOnly = sessionStorage.getItem('pairDevices_modemDeployedOnly');
|
||||||
|
|
||||||
|
if (modemSearch) document.getElementById('modem-search').value = modemSearch;
|
||||||
|
if (modemHidePaired === 'true') document.getElementById('modem-hide-paired').checked = true;
|
||||||
|
if (modemDeployedOnly === 'true') document.getElementById('modem-deployed-only').checked = true;
|
||||||
|
|
||||||
|
// Apply filters if any were set
|
||||||
|
if (recorderSearch || recorderHidePaired === 'true' || recorderDeployedOnly === 'true') {
|
||||||
|
filterRecorders();
|
||||||
|
}
|
||||||
|
if (modemSearch || modemHidePaired === 'true' || modemDeployedOnly === 'true') {
|
||||||
|
filterModems();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear stored values
|
||||||
|
sessionStorage.removeItem('pairDevices_recorderScroll');
|
||||||
|
sessionStorage.removeItem('pairDevices_modemScroll');
|
||||||
|
sessionStorage.removeItem('pairDevices_pairingScroll');
|
||||||
|
sessionStorage.removeItem('pairDevices_recorderSearch');
|
||||||
|
sessionStorage.removeItem('pairDevices_recorderHidePaired');
|
||||||
|
sessionStorage.removeItem('pairDevices_recorderDeployedOnly');
|
||||||
|
sessionStorage.removeItem('pairDevices_modemSearch');
|
||||||
|
sessionStorage.removeItem('pairDevices_modemHidePaired');
|
||||||
|
sessionStorage.removeItem('pairDevices_modemDeployedOnly');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore scroll positions on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', restoreScrollPositions);
|
||||||
|
|
||||||
|
async function pairDevices() {
|
||||||
|
if (!selectedRecorder || !selectedModem) return;
|
||||||
|
|
||||||
|
const pairBtn = document.getElementById('pair-btn');
|
||||||
|
pairBtn.disabled = true;
|
||||||
|
pairBtn.textContent = 'Pairing...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/roster/pair-devices', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
recorder_id: selectedRecorder,
|
||||||
|
modem_id: selectedModem
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showToast(`Paired ${selectedRecorder} with ${selectedModem}`, 'success');
|
||||||
|
// Save scroll positions before reload
|
||||||
|
saveScrollPositions();
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
} else {
|
||||||
|
showToast(result.detail || 'Failed to pair devices', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Error pairing devices: ' + error.message, 'error');
|
||||||
|
} finally {
|
||||||
|
pairBtn.disabled = false;
|
||||||
|
pairBtn.textContent = 'Pair Devices';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unpairDevices(recorderId, modemId) {
|
||||||
|
if (!confirm(`Unpair ${recorderId} from ${modemId}?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/roster/unpair-devices', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
recorder_id: recorderId,
|
||||||
|
modem_id: modemId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showToast(`Unpaired ${recorderId} from ${modemId}`, 'success');
|
||||||
|
// Save scroll positions before reload
|
||||||
|
saveScrollPositions();
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
} else {
|
||||||
|
showToast(result.detail || 'Failed to unpair devices', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Error unpairing devices: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
const toast = document.getElementById('toast');
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.className = 'fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg transform transition-all duration-300 z-50';
|
||||||
|
|
||||||
|
if (type === 'success') {
|
||||||
|
toast.classList.add('bg-green-500', 'text-white');
|
||||||
|
} else if (type === 'error') {
|
||||||
|
toast.classList.add('bg-red-500', 'text-white');
|
||||||
|
} else {
|
||||||
|
toast.classList.add('bg-gray-800', 'text-white');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show
|
||||||
|
toast.classList.remove('translate-y-full', 'opacity-0');
|
||||||
|
|
||||||
|
// Hide after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add('translate-y-full', 'opacity-0');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bg-seismo-orange\/10 {
|
||||||
|
background-color: rgb(249 115 22 / 0.1);
|
||||||
|
}
|
||||||
|
.dark\:bg-seismo-orange\/20:is(.dark *) {
|
||||||
|
background-color: rgb(249 115 22 / 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -104,8 +104,13 @@
|
|||||||
{% if unit.phone_number %}
|
{% if unit.phone_number %}
|
||||||
<div>{{ unit.phone_number }}</div>
|
<div>{{ unit.phone_number }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if unit.hardware_model %}
|
{% if unit.deployed_with_unit_id %}
|
||||||
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
|
<div>
|
||||||
|
<span class="text-gray-500 dark:text-gray-500">Linked:</span>
|
||||||
|
<a href="/unit/{{ unit.deployed_with_unit_id }}" class="text-seismo-orange hover:underline font-medium">
|
||||||
|
{{ unit.deployed_with_unit_id }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if unit.next_calibration_due %}
|
{% if unit.next_calibration_due %}
|
||||||
@@ -126,7 +131,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">{{ unit.last_seen }}</div>
|
<div class="text-sm text-gray-500 dark:text-gray-400 last-seen-cell" data-iso="{{ unit.last_seen }}">{{ unit.last_seen }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="text-sm
|
<div class="text-sm
|
||||||
@@ -345,6 +350,39 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
(function() {
|
||||||
|
// User's configured timezone from settings (defaults to America/New_York)
|
||||||
|
const userTimezone = '{{ user_timezone | default("America/New_York") }}';
|
||||||
|
|
||||||
|
// Format ISO timestamp to human-readable format in user's timezone
|
||||||
|
function formatLastSeenLocal(isoString) {
|
||||||
|
if (!isoString || isoString === 'Never' || isoString === 'N/A') {
|
||||||
|
return isoString || 'Never';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const date = new Date(isoString);
|
||||||
|
if (isNaN(date.getTime())) return isoString;
|
||||||
|
|
||||||
|
// Format in user's configured timezone
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
timeZone: userTimezone,
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return isoString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format all last-seen cells on page load
|
||||||
|
document.querySelectorAll('.last-seen-cell').forEach(cell => {
|
||||||
|
const isoDate = cell.getAttribute('data-iso');
|
||||||
|
cell.textContent = formatLastSeenLocal(isoDate);
|
||||||
|
});
|
||||||
|
|
||||||
// Update timestamp
|
// Update timestamp
|
||||||
const timestampElement = document.getElementById('last-updated');
|
const timestampElement = document.getElementById('last-updated');
|
||||||
if (timestampElement) {
|
if (timestampElement) {
|
||||||
@@ -365,20 +403,23 @@
|
|||||||
};
|
};
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
})();
|
||||||
|
|
||||||
// Sorting state
|
// Sorting state (needs to persist across swaps)
|
||||||
let currentSort = { column: null, direction: 'asc' };
|
if (typeof window.currentSort === 'undefined') {
|
||||||
|
window.currentSort = { column: null, direction: 'asc' };
|
||||||
|
}
|
||||||
|
|
||||||
function sortTable(column) {
|
function sortTable(column) {
|
||||||
const tbody = document.getElementById('roster-tbody');
|
const tbody = document.getElementById('roster-tbody');
|
||||||
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
||||||
|
|
||||||
// Determine sort direction
|
// Determine sort direction
|
||||||
if (currentSort.column === column) {
|
if (window.currentSort.column === column) {
|
||||||
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
window.currentSort.direction = window.currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
} else {
|
} else {
|
||||||
currentSort.column = column;
|
window.currentSort.column = column;
|
||||||
currentSort.direction = 'asc';
|
window.currentSort.direction = 'asc';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort rows
|
// Sort rows
|
||||||
@@ -406,8 +447,8 @@
|
|||||||
bVal = bVal.toLowerCase();
|
bVal = bVal.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
|
if (aVal < bVal) return window.currentSort.direction === 'asc' ? -1 : 1;
|
||||||
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
|
if (aVal > bVal) return window.currentSort.direction === 'asc' ? 1 : -1;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -443,10 +484,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Set current indicator
|
// Set current indicator
|
||||||
if (currentSort.column) {
|
if (window.currentSort.column) {
|
||||||
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
|
const indicator = document.querySelector(`.sort-indicator[data-column="${window.currentSort.column}"]`);
|
||||||
if (indicator) {
|
if (indicator) {
|
||||||
indicator.className = `sort-indicator ${currentSort.direction}`;
|
indicator.className = `sort-indicator ${window.currentSort.direction}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +1,127 @@
|
|||||||
<!-- Modem List -->
|
<!-- Modem List -->
|
||||||
{% if modems %}
|
{% if modems %}
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<tr>
|
||||||
|
<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>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Status</th>
|
||||||
|
<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>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Phone</th>
|
||||||
|
<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>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Location</th>
|
||||||
|
<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 %}
|
{% for modem in modems %}
|
||||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors">
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<a href="/unit/{{ modem.id }}" class="font-semibold text-gray-900 dark:text-white hover:text-seismo-orange">
|
<a href="/unit/{{ modem.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
{{ modem.id }}
|
{{ modem.id }}
|
||||||
</a>
|
</a>
|
||||||
{% if modem.hardware_model %}
|
{% if modem.hardware_model %}
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ modem.hardware_model }}</span>
|
<span class="text-xs text-gray-500 dark:text-gray-400">({{ modem.hardware_model }})</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</td>
|
||||||
{% if modem.ip_address %}
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 font-mono mt-1">{{ modem.ip_address }}</p>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-sm text-red-500 mt-1">No IP configured</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if modem.phone_number %}
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ modem.phone_number }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status Badge -->
|
|
||||||
{% if modem.status == "retired" %}
|
{% if modem.status == "retired" %}
|
||||||
<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>
|
<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" %}
|
{% elif modem.status == "benched" %}
|
||||||
<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>
|
<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" %}
|
{% elif modem.status == "in_use" %}
|
||||||
<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="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" %}
|
{% elif modem.status == "spare" %}
|
||||||
<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>
|
<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">
|
||||||
<!-- Paired Device -->
|
{% if modem.ip_address %}
|
||||||
|
<span class="font-mono text-gray-900 dark:text-gray-300">{{ modem.ip_address }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
||||||
|
{% if modem.phone_number %}
|
||||||
|
{{ modem.phone_number }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm">
|
||||||
{% if modem.paired_device %}
|
{% if modem.paired_device %}
|
||||||
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
<a href="/unit/{{ modem.paired_device.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">Paired with:</p>
|
|
||||||
<a href="/unit/{{ modem.paired_device.id }}" class="text-sm text-seismo-orange hover:underline">
|
|
||||||
{{ modem.paired_device.id }}
|
{{ modem.paired_device.id }}
|
||||||
<span class="text-gray-500">({{ modem.paired_device.device_type }})</span>
|
<span class="text-gray-500 dark:text-gray-400">({{ modem.paired_device.device_type }})</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">None</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</td>
|
||||||
<!-- Location if available -->
|
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
|
||||||
{% if modem.location or modem.project_id %}
|
|
||||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{% if modem.project_id %}
|
{% if modem.project_id %}
|
||||||
<span class="bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 rounded">{{ modem.project_id }}</span>
|
<span class="bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 rounded text-xs mr-1">{{ modem.project_id }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if modem.location %}
|
{% if modem.location %}
|
||||||
{{ modem.location }}
|
<span class="truncate max-w-xs inline-block" title="{{ modem.location }}">{{ modem.location }}</span>
|
||||||
|
{% elif not modem.project_id %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</td>
|
||||||
{% endif %}
|
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
<!-- Quick Actions -->
|
|
||||||
<div class="mt-3 flex gap-2">
|
|
||||||
<button onclick="pingModem('{{ modem.id }}')"
|
<button onclick="pingModem('{{ modem.id }}')"
|
||||||
id="ping-btn-{{ modem.id }}"
|
id="ping-btn-{{ modem.id }}"
|
||||||
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">
|
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">
|
||||||
Ping
|
Ping
|
||||||
</button>
|
</button>
|
||||||
<a href="/unit/{{ modem.id }}"
|
<a href="/unit/{{ modem.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
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">
|
View →
|
||||||
Details
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ping Result (hidden by default) -->
|
<!-- Ping Result (hidden by default) -->
|
||||||
<div id="ping-result-{{ modem.id }}" class="mt-2 text-xs hidden"></div>
|
<div id="ping-result-{{ modem.id }}" class="mt-1 text-xs hidden"></div>
|
||||||
</div>
|
</td>
|
||||||
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if search %}
|
||||||
|
<div class="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Found {{ modems|length }} modem(s) matching "{{ search }}"
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||||
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<p>No modems found</p>
|
<p>No modems found</p>
|
||||||
|
{% if search %}
|
||||||
|
<button onclick="document.getElementById('modem-search').value = ''; htmx.trigger('#modem-search', 'keyup');"
|
||||||
|
class="mt-3 text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
Clear search
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
<p class="text-sm mt-1">Add modems from the <a href="/roster" class="text-seismo-orange hover:underline">Fleet Roster</a></p>
|
<p class="text-sm mt-1">Add modems from the <a href="/roster" class="text-seismo-orange hover:underline">Fleet Roster</a></p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -337,6 +337,7 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
(function() {
|
||||||
// Update timestamp
|
// Update timestamp
|
||||||
const timestampElement = document.getElementById('last-updated');
|
const timestampElement = document.getElementById('last-updated');
|
||||||
if (timestampElement) {
|
if (timestampElement) {
|
||||||
@@ -357,20 +358,23 @@
|
|||||||
};
|
};
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
})();
|
||||||
|
|
||||||
// Sorting state
|
// Sorting state (needs to persist across swaps)
|
||||||
let currentSort = { column: null, direction: 'asc' };
|
if (typeof window.currentSort === 'undefined') {
|
||||||
|
window.currentSort = { column: null, direction: 'asc' };
|
||||||
|
}
|
||||||
|
|
||||||
function sortTable(column) {
|
function sortTable(column) {
|
||||||
const tbody = document.getElementById('roster-tbody');
|
const tbody = document.getElementById('roster-tbody');
|
||||||
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
||||||
|
|
||||||
// Determine sort direction
|
// Determine sort direction
|
||||||
if (currentSort.column === column) {
|
if (window.currentSort.column === column) {
|
||||||
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
window.currentSort.direction = window.currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
} else {
|
} else {
|
||||||
currentSort.column = column;
|
window.currentSort.column = column;
|
||||||
currentSort.direction = 'asc';
|
window.currentSort.direction = 'asc';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort rows
|
// Sort rows
|
||||||
@@ -398,8 +402,8 @@
|
|||||||
bVal = bVal.toLowerCase();
|
bVal = bVal.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
|
if (aVal < bVal) return window.currentSort.direction === 'asc' ? -1 : 1;
|
||||||
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
|
if (aVal > bVal) return window.currentSort.direction === 'asc' ? 1 : -1;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -435,10 +439,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Set current indicator
|
// Set current indicator
|
||||||
if (currentSort.column) {
|
if (window.currentSort.column) {
|
||||||
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
|
const indicator = document.querySelector(`.sort-indicator[data-column="${window.currentSort.column}"]`);
|
||||||
if (indicator) {
|
if (indicator) {
|
||||||
indicator.className = `sort-indicator ${currentSort.direction}`;
|
indicator.className = `sort-indicator ${window.currentSort.direction}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" %}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user