Compare commits
12 Commits
7ce0f6115d
...
b47e69e609
| Author | SHA1 | Date | |
|---|---|---|---|
| b47e69e609 | |||
| 1cb25b6c17 | |||
|
|
e515bff1a9 | ||
|
|
f296806fd1 | ||
|
|
24da5ab79f | ||
|
|
305540f564 | ||
|
|
639b485c28 | ||
|
|
d78bafb76e | ||
|
|
8373cff10d | ||
|
|
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.services.snapshot import emit_status_snapshot
|
||||
from backend.models import IgnoredUnit
|
||||
from backend.utils.timezone import get_user_timezone
|
||||
|
||||
# Create database tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -223,6 +224,67 @@ async def modems_page(request: Request):
|
||||
return templates.TemplateResponse("modems.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/pair-devices", response_class=HTMLResponse)
|
||||
async def pair_devices_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Device pairing page - two-column layout for pairing recorders with modems.
|
||||
"""
|
||||
from backend.models import RosterUnit
|
||||
|
||||
# Get all non-retired recorders (seismographs and SLMs)
|
||||
recorders = db.query(RosterUnit).filter(
|
||||
RosterUnit.retired == False,
|
||||
RosterUnit.device_type.in_(["seismograph", "slm", None]) # None defaults to seismograph
|
||||
).order_by(RosterUnit.id).all()
|
||||
|
||||
# Get all non-retired modems
|
||||
modems = db.query(RosterUnit).filter(
|
||||
RosterUnit.retired == False,
|
||||
RosterUnit.device_type == "modem"
|
||||
).order_by(RosterUnit.id).all()
|
||||
|
||||
# Build existing pairings list
|
||||
pairings = []
|
||||
for recorder in recorders:
|
||||
if recorder.deployed_with_modem_id:
|
||||
modem = next((m for m in modems if m.id == recorder.deployed_with_modem_id), None)
|
||||
pairings.append({
|
||||
"recorder_id": recorder.id,
|
||||
"recorder_type": (recorder.device_type or "seismograph").upper(),
|
||||
"modem_id": recorder.deployed_with_modem_id,
|
||||
"modem_ip": modem.ip_address if modem else None
|
||||
})
|
||||
|
||||
# Convert to dicts for template
|
||||
recorders_data = [
|
||||
{
|
||||
"id": r.id,
|
||||
"device_type": r.device_type or "seismograph",
|
||||
"deployed": r.deployed,
|
||||
"deployed_with_modem_id": r.deployed_with_modem_id
|
||||
}
|
||||
for r in recorders
|
||||
]
|
||||
|
||||
modems_data = [
|
||||
{
|
||||
"id": m.id,
|
||||
"deployed": m.deployed,
|
||||
"deployed_with_unit_id": m.deployed_with_unit_id,
|
||||
"ip_address": m.ip_address,
|
||||
"phone_number": m.phone_number
|
||||
}
|
||||
for m in modems
|
||||
]
|
||||
|
||||
return templates.TemplateResponse("pair_devices.html", {
|
||||
"request": request,
|
||||
"recorders": recorders_data,
|
||||
"modems": modems_data,
|
||||
"pairings": pairings
|
||||
})
|
||||
|
||||
|
||||
@app.get("/projects", response_class=HTMLResponse)
|
||||
async def projects_page(request: Request):
|
||||
"""Projects management and overview"""
|
||||
@@ -587,6 +649,7 @@ async def devices_all_partial(request: Request):
|
||||
"last_calibrated": unit_data.get("last_calibrated"),
|
||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
|
||||
"ip_address": unit_data.get("ip_address"),
|
||||
"phone_number": unit_data.get("phone_number"),
|
||||
"hardware_model": unit_data.get("hardware_model"),
|
||||
@@ -610,6 +673,7 @@ async def devices_all_partial(request: Request):
|
||||
"last_calibrated": unit_data.get("last_calibrated"),
|
||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
|
||||
"ip_address": unit_data.get("ip_address"),
|
||||
"phone_number": unit_data.get("phone_number"),
|
||||
"hardware_model": unit_data.get("hardware_model"),
|
||||
@@ -633,6 +697,7 @@ async def devices_all_partial(request: Request):
|
||||
"last_calibrated": unit_data.get("last_calibrated"),
|
||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
|
||||
"ip_address": unit_data.get("ip_address"),
|
||||
"phone_number": unit_data.get("phone_number"),
|
||||
"hardware_model": unit_data.get("hardware_model"),
|
||||
@@ -656,6 +721,7 @@ async def devices_all_partial(request: Request):
|
||||
"last_calibrated": None,
|
||||
"next_calibration_due": None,
|
||||
"deployed_with_modem_id": None,
|
||||
"deployed_with_unit_id": None,
|
||||
"ip_address": None,
|
||||
"phone_number": None,
|
||||
"hardware_model": None,
|
||||
@@ -678,7 +744,8 @@ async def devices_all_partial(request: Request):
|
||||
return templates.TemplateResponse("partials/devices_table.html", {
|
||||
"request": request,
|
||||
"units": units_list,
|
||||
"timestamp": datetime.now().strftime("%H:%M:%S")
|
||||
"timestamp": datetime.now().strftime("%H:%M:%S"),
|
||||
"user_timezone": get_user_timezone()
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@ class ScheduledAction(Base):
|
||||
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id
|
||||
unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (nullable if location-based)
|
||||
|
||||
action_type = Column(String, nullable=False) # start, stop, download, calibrate
|
||||
action_type = Column(String, nullable=False) # start, stop, download, cycle, calibrate
|
||||
device_type = Column(String, nullable=False) # "slm" | "seismograph"
|
||||
|
||||
scheduled_time = Column(DateTime, nullable=False, index=True)
|
||||
|
||||
@@ -284,3 +284,146 @@ async def get_modem_diagnostics(modem_id: str, db: Session = Depends(get_db)):
|
||||
"carrier": None,
|
||||
"connection_type": None # LTE, 5G, etc.
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{modem_id}/pairable-devices")
|
||||
async def get_pairable_devices(
|
||||
modem_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
search: str = Query(None),
|
||||
hide_paired: bool = Query(True)
|
||||
):
|
||||
"""
|
||||
Get list of devices (seismographs and SLMs) that can be paired with this modem.
|
||||
Used by the device picker modal in unit_detail.html.
|
||||
"""
|
||||
# Check modem exists
|
||||
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
|
||||
if not modem:
|
||||
return {"status": "error", "detail": f"Modem {modem_id} not found"}
|
||||
|
||||
# Query seismographs and SLMs
|
||||
query = db.query(RosterUnit).filter(
|
||||
RosterUnit.device_type.in_(["seismograph", "sound_level_meter"]),
|
||||
RosterUnit.retired == False
|
||||
)
|
||||
|
||||
# Filter by search term if provided
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.filter(
|
||||
(RosterUnit.id.ilike(search_term)) |
|
||||
(RosterUnit.project_id.ilike(search_term)) |
|
||||
(RosterUnit.location.ilike(search_term)) |
|
||||
(RosterUnit.address.ilike(search_term)) |
|
||||
(RosterUnit.note.ilike(search_term))
|
||||
)
|
||||
|
||||
devices = query.order_by(
|
||||
RosterUnit.deployed.desc(),
|
||||
RosterUnit.device_type.asc(),
|
||||
RosterUnit.id.asc()
|
||||
).all()
|
||||
|
||||
# Build device list
|
||||
device_list = []
|
||||
for device in devices:
|
||||
# Skip already paired devices if hide_paired is True
|
||||
is_paired_to_other = (
|
||||
device.deployed_with_modem_id is not None and
|
||||
device.deployed_with_modem_id != modem_id
|
||||
)
|
||||
is_paired_to_this = device.deployed_with_modem_id == modem_id
|
||||
|
||||
if hide_paired and is_paired_to_other:
|
||||
continue
|
||||
|
||||
device_list.append({
|
||||
"id": device.id,
|
||||
"device_type": device.device_type,
|
||||
"deployed": device.deployed,
|
||||
"project_id": device.project_id,
|
||||
"location": device.location or device.address,
|
||||
"note": device.note,
|
||||
"paired_modem_id": device.deployed_with_modem_id,
|
||||
"is_paired_to_this": is_paired_to_this,
|
||||
"is_paired_to_other": is_paired_to_other
|
||||
})
|
||||
|
||||
return {"devices": device_list, "modem_id": modem_id}
|
||||
|
||||
|
||||
@router.post("/{modem_id}/pair")
|
||||
async def pair_device_to_modem(
|
||||
modem_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
device_id: str = Query(..., description="ID of the device to pair")
|
||||
):
|
||||
"""
|
||||
Pair a device (seismograph or SLM) to this modem.
|
||||
Updates the device's deployed_with_modem_id field.
|
||||
"""
|
||||
# Check modem exists
|
||||
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
|
||||
if not modem:
|
||||
return {"status": "error", "detail": f"Modem {modem_id} not found"}
|
||||
|
||||
# Find the device
|
||||
device = db.query(RosterUnit).filter(
|
||||
RosterUnit.id == device_id,
|
||||
RosterUnit.device_type.in_(["seismograph", "sound_level_meter"]),
|
||||
RosterUnit.retired == False
|
||||
).first()
|
||||
if not device:
|
||||
return {"status": "error", "detail": f"Device {device_id} not found"}
|
||||
|
||||
# Unpair any device currently paired to this modem
|
||||
currently_paired = db.query(RosterUnit).filter(
|
||||
RosterUnit.deployed_with_modem_id == modem_id
|
||||
).all()
|
||||
for paired_device in currently_paired:
|
||||
paired_device.deployed_with_modem_id = None
|
||||
|
||||
# Pair the new device
|
||||
device.deployed_with_modem_id = modem_id
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"modem_id": modem_id,
|
||||
"device_id": device_id,
|
||||
"message": f"Device {device_id} paired to modem {modem_id}"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{modem_id}/unpair")
|
||||
async def unpair_device_from_modem(modem_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Unpair any device currently paired to this modem.
|
||||
"""
|
||||
# Check modem exists
|
||||
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
|
||||
if not modem:
|
||||
return {"status": "error", "detail": f"Modem {modem_id} not found"}
|
||||
|
||||
# Find and unpair device
|
||||
device = db.query(RosterUnit).filter(
|
||||
RosterUnit.deployed_with_modem_id == modem_id
|
||||
).first()
|
||||
|
||||
if device:
|
||||
old_device_id = device.id
|
||||
device.deployed_with_modem_id = None
|
||||
db.commit()
|
||||
return {
|
||||
"status": "success",
|
||||
"modem_id": modem_id,
|
||||
"unpaired_device_id": old_device_id,
|
||||
"message": f"Device {old_device_id} unpaired from modem {modem_id}"
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"modem_id": modem_id,
|
||||
"message": "No device was paired to this modem"
|
||||
}
|
||||
|
||||
@@ -330,19 +330,35 @@ async def disable_schedule(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Disable a schedule.
|
||||
Disable a schedule and cancel all its pending actions.
|
||||
"""
|
||||
service = get_recurring_schedule_service(db)
|
||||
|
||||
# Count pending actions before disabling (for response message)
|
||||
from sqlalchemy import and_
|
||||
from backend.models import ScheduledAction
|
||||
pending_count = db.query(ScheduledAction).filter(
|
||||
and_(
|
||||
ScheduledAction.execution_status == "pending",
|
||||
ScheduledAction.notes.like(f'%"schedule_id": "{schedule_id}"%'),
|
||||
)
|
||||
).count()
|
||||
|
||||
schedule = service.disable_schedule(schedule_id)
|
||||
|
||||
if not schedule:
|
||||
raise HTTPException(status_code=404, detail="Schedule not found")
|
||||
|
||||
message = "Schedule disabled"
|
||||
if pending_count > 0:
|
||||
message += f" and {pending_count} pending action(s) cancelled"
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"schedule_id": schedule.id,
|
||||
"enabled": schedule.enabled,
|
||||
"message": "Schedule disabled",
|
||||
"cancelled_actions": pending_count,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,20 +2,32 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
|
||||
from backend.database import get_db
|
||||
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"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@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.
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import os
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory
|
||||
from backend.services.slmm_sync import sync_slm_to_slmm
|
||||
|
||||
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -456,7 +457,7 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/edit/{unit_id}")
|
||||
def edit_roster_unit(
|
||||
async def edit_roster_unit(
|
||||
unit_id: str,
|
||||
device_type: str = Form("seismograph"),
|
||||
unit_type: str = Form("series3"),
|
||||
@@ -662,6 +663,16 @@ def edit_roster_unit(
|
||||
|
||||
db.commit()
|
||||
|
||||
# Sync SLM polling config to SLMM when deployed/retired status changes
|
||||
# This ensures benched units stop being polled
|
||||
if device_type == "slm" and (old_deployed != deployed_bool or old_retired != retired_bool):
|
||||
db.refresh(unit) # Refresh to get committed values
|
||||
try:
|
||||
await sync_slm_to_slmm(unit)
|
||||
logger.info(f"Synced SLM {unit_id} polling config to SLMM (deployed={deployed_bool}, retired={retired_bool})")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to sync SLM {unit_id} polling config to SLMM: {e}")
|
||||
|
||||
response = {"message": "Unit updated", "id": unit_id, "device_type": device_type}
|
||||
if cascaded_unit_id:
|
||||
response["cascaded_to"] = cascaded_unit_id
|
||||
@@ -669,7 +680,7 @@ def edit_roster_unit(
|
||||
|
||||
|
||||
@router.post("/set-deployed/{unit_id}")
|
||||
def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends(get_db)):
|
||||
async def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends(get_db)):
|
||||
unit = get_or_create_roster_unit(db, unit_id)
|
||||
old_deployed = unit.deployed
|
||||
unit.deployed = deployed
|
||||
@@ -690,11 +701,21 @@ def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Sync SLM polling config to SLMM when deployed status changes
|
||||
if unit.device_type == "slm" and old_deployed != deployed:
|
||||
db.refresh(unit)
|
||||
try:
|
||||
await sync_slm_to_slmm(unit)
|
||||
logger.info(f"Synced SLM {unit_id} polling config to SLMM (deployed={deployed})")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to sync SLM {unit_id} polling config to SLMM: {e}")
|
||||
|
||||
return {"message": "Updated", "id": unit_id, "deployed": deployed}
|
||||
|
||||
|
||||
@router.post("/set-retired/{unit_id}")
|
||||
def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(get_db)):
|
||||
async def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(get_db)):
|
||||
unit = get_or_create_roster_unit(db, unit_id)
|
||||
old_retired = unit.retired
|
||||
unit.retired = retired
|
||||
@@ -715,6 +736,16 @@ def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(g
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Sync SLM polling config to SLMM when retired status changes
|
||||
if unit.device_type == "slm" and old_retired != retired:
|
||||
db.refresh(unit)
|
||||
try:
|
||||
await sync_slm_to_slmm(unit)
|
||||
logger.info(f"Synced SLM {unit_id} polling config to SLMM (retired={retired})")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to sync SLM {unit_id} polling config to SLMM: {e}")
|
||||
|
||||
return {"message": "Updated", "id": unit_id, "retired": retired}
|
||||
|
||||
|
||||
@@ -1156,3 +1187,145 @@ def delete_history_entry(history_id: int, db: Session = Depends(get_db)):
|
||||
db.delete(history_entry)
|
||||
db.commit()
|
||||
return {"message": "History entry deleted", "id": history_id}
|
||||
|
||||
|
||||
@router.post("/pair-devices")
|
||||
async def pair_devices(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a bidirectional pairing between a recorder (seismograph/SLM) and a modem.
|
||||
|
||||
Sets:
|
||||
- recorder.deployed_with_modem_id = modem_id
|
||||
- modem.deployed_with_unit_id = recorder_id
|
||||
|
||||
Also clears any previous pairings for both devices.
|
||||
"""
|
||||
data = await request.json()
|
||||
recorder_id = data.get("recorder_id")
|
||||
modem_id = data.get("modem_id")
|
||||
|
||||
if not recorder_id or not modem_id:
|
||||
raise HTTPException(status_code=400, detail="Both recorder_id and modem_id are required")
|
||||
|
||||
# Get or create the units
|
||||
recorder = db.query(RosterUnit).filter(RosterUnit.id == recorder_id).first()
|
||||
modem = db.query(RosterUnit).filter(RosterUnit.id == modem_id).first()
|
||||
|
||||
if not recorder:
|
||||
raise HTTPException(status_code=404, detail=f"Recorder {recorder_id} not found in roster")
|
||||
if not modem:
|
||||
raise HTTPException(status_code=404, detail=f"Modem {modem_id} not found in roster")
|
||||
|
||||
# Validate device types
|
||||
if recorder.device_type == "modem":
|
||||
raise HTTPException(status_code=400, detail=f"{recorder_id} is a modem, not a recorder")
|
||||
if modem.device_type != "modem":
|
||||
raise HTTPException(status_code=400, detail=f"{modem_id} is not a modem (type: {modem.device_type})")
|
||||
|
||||
# Clear any previous pairings
|
||||
# If recorder was paired with a different modem, clear that modem's link
|
||||
if recorder.deployed_with_modem_id and recorder.deployed_with_modem_id != modem_id:
|
||||
old_modem = db.query(RosterUnit).filter(RosterUnit.id == recorder.deployed_with_modem_id).first()
|
||||
if old_modem and old_modem.deployed_with_unit_id == recorder_id:
|
||||
record_history(db, old_modem.id, "update", "deployed_with_unit_id",
|
||||
old_modem.deployed_with_unit_id, None, "pair_devices", f"Cleared by new pairing")
|
||||
old_modem.deployed_with_unit_id = None
|
||||
|
||||
# If modem was paired with a different recorder, clear that recorder's link
|
||||
if modem.deployed_with_unit_id and modem.deployed_with_unit_id != recorder_id:
|
||||
old_recorder = db.query(RosterUnit).filter(RosterUnit.id == modem.deployed_with_unit_id).first()
|
||||
if old_recorder and old_recorder.deployed_with_modem_id == modem_id:
|
||||
record_history(db, old_recorder.id, "update", "deployed_with_modem_id",
|
||||
old_recorder.deployed_with_modem_id, None, "pair_devices", f"Cleared by new pairing")
|
||||
old_recorder.deployed_with_modem_id = None
|
||||
|
||||
# Record history for the pairing
|
||||
old_recorder_modem = recorder.deployed_with_modem_id
|
||||
old_modem_unit = modem.deployed_with_unit_id
|
||||
|
||||
# Set the new pairing
|
||||
recorder.deployed_with_modem_id = modem_id
|
||||
modem.deployed_with_unit_id = recorder_id
|
||||
|
||||
# Record history
|
||||
if old_recorder_modem != modem_id:
|
||||
record_history(db, recorder_id, "update", "deployed_with_modem_id",
|
||||
old_recorder_modem, modem_id, "pair_devices", f"Paired with modem")
|
||||
if old_modem_unit != recorder_id:
|
||||
record_history(db, modem_id, "update", "deployed_with_unit_id",
|
||||
old_modem_unit, recorder_id, "pair_devices", f"Paired with recorder")
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Paired {recorder_id} with modem {modem_id}")
|
||||
|
||||
# If SLM, sync to SLMM cache
|
||||
if recorder.device_type == "slm":
|
||||
await sync_slm_to_slmm_cache(
|
||||
unit_id=recorder_id,
|
||||
host=recorder.slm_host,
|
||||
tcp_port=recorder.slm_tcp_port,
|
||||
ftp_port=recorder.slm_ftp_port,
|
||||
deployed_with_modem_id=modem_id,
|
||||
db=db
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Paired {recorder_id} with {modem_id}",
|
||||
"recorder_id": recorder_id,
|
||||
"modem_id": modem_id
|
||||
}
|
||||
|
||||
|
||||
@router.post("/unpair-devices")
|
||||
async def unpair_devices(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Remove the bidirectional pairing between a recorder and modem.
|
||||
|
||||
Clears:
|
||||
- recorder.deployed_with_modem_id
|
||||
- modem.deployed_with_unit_id
|
||||
"""
|
||||
data = await request.json()
|
||||
recorder_id = data.get("recorder_id")
|
||||
modem_id = data.get("modem_id")
|
||||
|
||||
if not recorder_id or not modem_id:
|
||||
raise HTTPException(status_code=400, detail="Both recorder_id and modem_id are required")
|
||||
|
||||
recorder = db.query(RosterUnit).filter(RosterUnit.id == recorder_id).first()
|
||||
modem = db.query(RosterUnit).filter(RosterUnit.id == modem_id).first()
|
||||
|
||||
changes_made = False
|
||||
|
||||
if recorder and recorder.deployed_with_modem_id == modem_id:
|
||||
record_history(db, recorder_id, "update", "deployed_with_modem_id",
|
||||
recorder.deployed_with_modem_id, None, "unpair_devices", "Unpairing")
|
||||
recorder.deployed_with_modem_id = None
|
||||
changes_made = True
|
||||
|
||||
if modem and modem.deployed_with_unit_id == recorder_id:
|
||||
record_history(db, modem_id, "update", "deployed_with_unit_id",
|
||||
modem.deployed_with_unit_id, None, "unpair_devices", "Unpairing")
|
||||
modem.deployed_with_unit_id = None
|
||||
changes_made = True
|
||||
|
||||
if changes_made:
|
||||
db.commit()
|
||||
logger.info(f"Unpaired {recorder_id} from modem {modem_id}")
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Unpaired {recorder_id} from {modem_id}"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No pairing found between these devices"
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
is_measuring = state_data.get("is_measuring", False)
|
||||
|
||||
# If measuring, sync start time from FTP to database (fixes wrong timestamps)
|
||||
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)
|
||||
# Get live status (measurement_start_time is already stored in SLMM database)
|
||||
status_response = await client.get(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
|
||||
)
|
||||
|
||||
@@ -199,7 +199,7 @@ class AlertService:
|
||||
|
||||
Args:
|
||||
schedule_id: The ScheduledAction or RecurringSchedule ID
|
||||
action_type: start, stop, download
|
||||
action_type: start, stop, download, cycle
|
||||
unit_id: Related unit
|
||||
error_message: Error from execution
|
||||
project_id: Related project
|
||||
@@ -235,7 +235,7 @@ class AlertService:
|
||||
|
||||
Args:
|
||||
schedule_id: The ScheduledAction ID
|
||||
action_type: start, stop, download
|
||||
action_type: start, stop, download, cycle
|
||||
unit_id: Related unit
|
||||
project_id: Related project
|
||||
location_id: Related location
|
||||
|
||||
@@ -289,6 +289,74 @@ class DeviceController:
|
||||
else:
|
||||
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||
|
||||
# ========================================================================
|
||||
# FTP Control
|
||||
# ========================================================================
|
||||
|
||||
async def enable_ftp(
|
||||
self,
|
||||
unit_id: str,
|
||||
device_type: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Enable FTP server on device.
|
||||
|
||||
Must be called before downloading files.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
device_type: "slm" | "seismograph"
|
||||
|
||||
Returns:
|
||||
Response dict with status
|
||||
"""
|
||||
if device_type == "slm":
|
||||
try:
|
||||
return await self.slmm_client.enable_ftp(unit_id)
|
||||
except SLMMClientError as e:
|
||||
raise DeviceControllerError(f"SLMM error: {str(e)}")
|
||||
|
||||
elif device_type == "seismograph":
|
||||
return {
|
||||
"status": "not_implemented",
|
||||
"message": "Seismograph FTP not yet implemented",
|
||||
"unit_id": unit_id,
|
||||
}
|
||||
|
||||
else:
|
||||
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||
|
||||
async def disable_ftp(
|
||||
self,
|
||||
unit_id: str,
|
||||
device_type: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Disable FTP server on device.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
device_type: "slm" | "seismograph"
|
||||
|
||||
Returns:
|
||||
Response dict with status
|
||||
"""
|
||||
if device_type == "slm":
|
||||
try:
|
||||
return await self.slmm_client.disable_ftp(unit_id)
|
||||
except SLMMClientError as e:
|
||||
raise DeviceControllerError(f"SLMM error: {str(e)}")
|
||||
|
||||
elif device_type == "seismograph":
|
||||
return {
|
||||
"status": "not_implemented",
|
||||
"message": "Seismograph FTP not yet implemented",
|
||||
"unit_id": unit_id,
|
||||
}
|
||||
|
||||
else:
|
||||
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||
|
||||
# ========================================================================
|
||||
# Device Configuration
|
||||
# ========================================================================
|
||||
|
||||
@@ -169,8 +169,25 @@ class RecurringScheduleService:
|
||||
return self.update_schedule(schedule_id, enabled=True)
|
||||
|
||||
def disable_schedule(self, schedule_id: str) -> Optional[RecurringSchedule]:
|
||||
"""Disable a schedule."""
|
||||
return self.update_schedule(schedule_id, enabled=False)
|
||||
"""Disable a schedule and cancel its pending actions."""
|
||||
schedule = self.update_schedule(schedule_id, enabled=False)
|
||||
if schedule:
|
||||
# Cancel all pending actions generated by this schedule
|
||||
pending_actions = self.db.query(ScheduledAction).filter(
|
||||
and_(
|
||||
ScheduledAction.execution_status == "pending",
|
||||
ScheduledAction.notes.like(f'%"schedule_id": "{schedule_id}"%'),
|
||||
)
|
||||
).all()
|
||||
|
||||
for action in pending_actions:
|
||||
action.execution_status = "cancelled"
|
||||
|
||||
if pending_actions:
|
||||
self.db.commit()
|
||||
logger.info(f"Cancelled {len(pending_actions)} pending actions for disabled schedule {schedule.name}")
|
||||
|
||||
return schedule
|
||||
|
||||
def generate_actions_for_schedule(
|
||||
self,
|
||||
@@ -384,73 +401,33 @@ class RecurringScheduleService:
|
||||
if cycle_utc <= now_utc:
|
||||
continue
|
||||
|
||||
# Check if action already exists
|
||||
if self._action_exists(schedule.project_id, schedule.location_id, "stop", cycle_utc):
|
||||
# Check if cycle action already exists
|
||||
if self._action_exists(schedule.project_id, schedule.location_id, "cycle", cycle_utc):
|
||||
continue
|
||||
|
||||
# Build notes with metadata
|
||||
stop_notes = json.dumps({
|
||||
# Build notes with metadata for cycle action
|
||||
cycle_notes = json.dumps({
|
||||
"schedule_name": schedule.name,
|
||||
"schedule_id": schedule.id,
|
||||
"cycle_type": "daily",
|
||||
"include_download": schedule.include_download,
|
||||
"auto_increment_index": schedule.auto_increment_index,
|
||||
})
|
||||
|
||||
# Create STOP action
|
||||
stop_action = ScheduledAction(
|
||||
# Create single CYCLE action that handles stop -> download -> start
|
||||
# The scheduler's _execute_cycle method handles the full workflow with delays
|
||||
cycle_action = ScheduledAction(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=schedule.project_id,
|
||||
location_id=schedule.location_id,
|
||||
unit_id=unit_id,
|
||||
action_type="stop",
|
||||
action_type="cycle",
|
||||
device_type=schedule.device_type,
|
||||
scheduled_time=cycle_utc,
|
||||
execution_status="pending",
|
||||
notes=stop_notes,
|
||||
notes=cycle_notes,
|
||||
)
|
||||
actions.append(stop_action)
|
||||
|
||||
# Create DOWNLOAD action if enabled (1 minute after stop)
|
||||
if schedule.include_download:
|
||||
download_time = cycle_utc + timedelta(minutes=1)
|
||||
download_notes = json.dumps({
|
||||
"schedule_name": schedule.name,
|
||||
"schedule_id": schedule.id,
|
||||
"cycle_type": "daily",
|
||||
})
|
||||
download_action = ScheduledAction(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=schedule.project_id,
|
||||
location_id=schedule.location_id,
|
||||
unit_id=unit_id,
|
||||
action_type="download",
|
||||
device_type=schedule.device_type,
|
||||
scheduled_time=download_time,
|
||||
execution_status="pending",
|
||||
notes=download_notes,
|
||||
)
|
||||
actions.append(download_action)
|
||||
|
||||
# Create START action (2 minutes after stop, or 1 minute after download)
|
||||
start_offset = 2 if schedule.include_download else 1
|
||||
start_time = cycle_utc + timedelta(minutes=start_offset)
|
||||
start_notes = json.dumps({
|
||||
"schedule_name": schedule.name,
|
||||
"schedule_id": schedule.id,
|
||||
"cycle_type": "daily",
|
||||
"auto_increment_index": schedule.auto_increment_index,
|
||||
})
|
||||
start_action = ScheduledAction(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=schedule.project_id,
|
||||
location_id=schedule.location_id,
|
||||
unit_id=unit_id,
|
||||
action_type="start",
|
||||
device_type=schedule.device_type,
|
||||
scheduled_time=start_time,
|
||||
execution_status="pending",
|
||||
notes=start_notes,
|
||||
)
|
||||
actions.append(start_action)
|
||||
actions.append(cycle_action)
|
||||
|
||||
return actions
|
||||
|
||||
|
||||
@@ -185,6 +185,8 @@ class SchedulerService:
|
||||
response = await self._execute_stop(action, unit_id, db)
|
||||
elif action.action_type == "download":
|
||||
response = await self._execute_download(action, unit_id, db)
|
||||
elif action.action_type == "cycle":
|
||||
response = await self._execute_cycle(action, unit_id, db)
|
||||
else:
|
||||
raise Exception(f"Unknown action type: {action.action_type}")
|
||||
|
||||
@@ -350,7 +352,14 @@ class SchedulerService:
|
||||
unit_id: str,
|
||||
db: Session,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a 'download' action."""
|
||||
"""Execute a 'download' action.
|
||||
|
||||
This handles standalone download actions (not part of stop_cycle).
|
||||
The workflow is:
|
||||
1. Enable FTP on device
|
||||
2. Download current measurement folder
|
||||
3. (Optionally disable FTP - left enabled for now)
|
||||
"""
|
||||
# Get project and location info for file path
|
||||
location = db.query(MonitoringLocation).filter_by(id=action.location_id).first()
|
||||
project = db.query(Project).filter_by(id=action.project_id).first()
|
||||
@@ -358,8 +367,8 @@ class SchedulerService:
|
||||
if not location or not project:
|
||||
raise Exception("Project or location not found")
|
||||
|
||||
# Build destination path
|
||||
# Example: data/Projects/{project-id}/sound/{location-name}/session-{timestamp}/
|
||||
# Build destination path (for logging/metadata reference)
|
||||
# Actual download location is managed by SLMM (data/downloads/{unit_id}/)
|
||||
session_timestamp = datetime.utcnow().strftime("%Y-%m-%d-%H%M")
|
||||
location_type_dir = "sound" if action.device_type == "slm" else "vibration"
|
||||
|
||||
@@ -368,12 +377,23 @@ class SchedulerService:
|
||||
f"{location.name}/session-{session_timestamp}/"
|
||||
)
|
||||
|
||||
# Download files via device controller
|
||||
# Step 1: Disable FTP first to reset any stale connection state
|
||||
# Then enable FTP on device
|
||||
logger.info(f"Resetting FTP on {unit_id} for download (disable then enable)")
|
||||
try:
|
||||
await self.device_controller.disable_ftp(unit_id, action.device_type)
|
||||
except Exception as e:
|
||||
logger.warning(f"FTP disable failed (may already be off): {e}")
|
||||
await self.device_controller.enable_ftp(unit_id, action.device_type)
|
||||
|
||||
# Step 2: Download current measurement folder
|
||||
# The slmm_client.download_files() now automatically determines the correct
|
||||
# folder based on the device's current index number
|
||||
response = await self.device_controller.download_files(
|
||||
unit_id,
|
||||
action.device_type,
|
||||
destination_path,
|
||||
files=None, # Download all files
|
||||
files=None, # Download all files in current measurement folder
|
||||
)
|
||||
|
||||
# TODO: Create DataFile records for downloaded files
|
||||
@@ -384,6 +404,200 @@ class SchedulerService:
|
||||
"device_response": response,
|
||||
}
|
||||
|
||||
async def _execute_cycle(
|
||||
self,
|
||||
action: ScheduledAction,
|
||||
unit_id: str,
|
||||
db: Session,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a full 'cycle' action: stop -> download -> start.
|
||||
|
||||
This combines stop, download, and start into a single action with
|
||||
appropriate delays between steps to ensure device stability.
|
||||
|
||||
Workflow:
|
||||
0. Pause background polling to prevent command conflicts
|
||||
1. Stop measurement (wait 10s)
|
||||
2. Disable FTP to reset state (wait 10s)
|
||||
3. Enable FTP (wait 10s)
|
||||
4. Download current measurement folder
|
||||
5. Wait 30s for device to settle
|
||||
6. Start new measurement cycle
|
||||
7. Re-enable background polling
|
||||
|
||||
Total time: ~70-90 seconds depending on download size
|
||||
"""
|
||||
logger.info(f"[CYCLE] === Starting full cycle for {unit_id} ===")
|
||||
|
||||
result = {
|
||||
"status": "cycle_complete",
|
||||
"steps": {},
|
||||
"old_session_id": None,
|
||||
"new_session_id": None,
|
||||
"polling_paused": False,
|
||||
}
|
||||
|
||||
# Step 0: Pause background polling for this device to prevent command conflicts
|
||||
# NL-43 devices only support one TCP connection at a time
|
||||
logger.info(f"[CYCLE] Step 0: Pausing background polling for {unit_id}")
|
||||
polling_was_enabled = False
|
||||
try:
|
||||
if action.device_type == "slm":
|
||||
# Get current polling state to restore later
|
||||
from backend.services.slmm_client import get_slmm_client
|
||||
slmm = get_slmm_client()
|
||||
try:
|
||||
polling_config = await slmm.get_device_polling_config(unit_id)
|
||||
polling_was_enabled = polling_config.get("poll_enabled", False)
|
||||
except Exception:
|
||||
polling_was_enabled = True # Assume enabled if can't check
|
||||
|
||||
# Disable polling during cycle
|
||||
await slmm.update_device_polling_config(unit_id, poll_enabled=False)
|
||||
result["polling_paused"] = True
|
||||
logger.info(f"[CYCLE] Background polling paused for {unit_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[CYCLE] Failed to pause polling (continuing anyway): {e}")
|
||||
|
||||
try:
|
||||
# Step 1: Stop measurement
|
||||
logger.info(f"[CYCLE] Step 1/7: Stopping measurement on {unit_id}")
|
||||
try:
|
||||
stop_response = await self.device_controller.stop_recording(unit_id, action.device_type)
|
||||
result["steps"]["stop"] = {"success": True, "response": stop_response}
|
||||
logger.info(f"[CYCLE] Measurement stopped, waiting 10s...")
|
||||
except Exception as e:
|
||||
logger.warning(f"[CYCLE] Stop failed (may already be stopped): {e}")
|
||||
result["steps"]["stop"] = {"success": False, "error": str(e)}
|
||||
|
||||
await asyncio.sleep(10)
|
||||
|
||||
# Step 2: Disable FTP to reset any stale state
|
||||
logger.info(f"[CYCLE] Step 2/7: Disabling FTP on {unit_id}")
|
||||
try:
|
||||
await self.device_controller.disable_ftp(unit_id, action.device_type)
|
||||
result["steps"]["ftp_disable"] = {"success": True}
|
||||
logger.info(f"[CYCLE] FTP disabled, waiting 10s...")
|
||||
except Exception as e:
|
||||
logger.warning(f"[CYCLE] FTP disable failed (may already be off): {e}")
|
||||
result["steps"]["ftp_disable"] = {"success": False, "error": str(e)}
|
||||
|
||||
await asyncio.sleep(10)
|
||||
|
||||
# Step 3: Enable FTP
|
||||
logger.info(f"[CYCLE] Step 3/7: Enabling FTP on {unit_id}")
|
||||
try:
|
||||
await self.device_controller.enable_ftp(unit_id, action.device_type)
|
||||
result["steps"]["ftp_enable"] = {"success": True}
|
||||
logger.info(f"[CYCLE] FTP enabled, waiting 10s...")
|
||||
except Exception as e:
|
||||
logger.error(f"[CYCLE] FTP enable failed: {e}")
|
||||
result["steps"]["ftp_enable"] = {"success": False, "error": str(e)}
|
||||
# Continue anyway - download will fail but we can still try to start
|
||||
|
||||
await asyncio.sleep(10)
|
||||
|
||||
# Step 4: Download current measurement folder
|
||||
logger.info(f"[CYCLE] Step 4/7: Downloading measurement data from {unit_id}")
|
||||
location = db.query(MonitoringLocation).filter_by(id=action.location_id).first()
|
||||
project = db.query(Project).filter_by(id=action.project_id).first()
|
||||
|
||||
if location and project:
|
||||
session_timestamp = datetime.utcnow().strftime("%Y-%m-%d-%H%M")
|
||||
location_type_dir = "sound" if action.device_type == "slm" else "vibration"
|
||||
destination_path = (
|
||||
f"data/Projects/{project.id}/{location_type_dir}/"
|
||||
f"{location.name}/session-{session_timestamp}/"
|
||||
)
|
||||
|
||||
try:
|
||||
download_response = await self.device_controller.download_files(
|
||||
unit_id,
|
||||
action.device_type,
|
||||
destination_path,
|
||||
files=None,
|
||||
)
|
||||
result["steps"]["download"] = {"success": True, "response": download_response}
|
||||
logger.info(f"[CYCLE] Download complete")
|
||||
except Exception as e:
|
||||
logger.error(f"[CYCLE] Download failed: {e}")
|
||||
result["steps"]["download"] = {"success": False, "error": str(e)}
|
||||
else:
|
||||
result["steps"]["download"] = {"success": False, "error": "Project or location not found"}
|
||||
|
||||
# Close out the old recording session
|
||||
active_session = db.query(RecordingSession).filter(
|
||||
and_(
|
||||
RecordingSession.location_id == action.location_id,
|
||||
RecordingSession.unit_id == unit_id,
|
||||
RecordingSession.status == "recording",
|
||||
)
|
||||
).first()
|
||||
|
||||
if active_session:
|
||||
active_session.stopped_at = datetime.utcnow()
|
||||
active_session.status = "completed"
|
||||
active_session.duration_seconds = int(
|
||||
(active_session.stopped_at - active_session.started_at).total_seconds()
|
||||
)
|
||||
result["old_session_id"] = active_session.id
|
||||
|
||||
# Step 5: Wait for device to settle before starting new measurement
|
||||
logger.info(f"[CYCLE] Step 5/7: Waiting 30s for device to settle...")
|
||||
await asyncio.sleep(30)
|
||||
|
||||
# Step 6: Start new measurement cycle
|
||||
logger.info(f"[CYCLE] Step 6/7: Starting new measurement on {unit_id}")
|
||||
try:
|
||||
cycle_response = await self.device_controller.start_cycle(
|
||||
unit_id,
|
||||
action.device_type,
|
||||
sync_clock=True,
|
||||
)
|
||||
result["steps"]["start"] = {"success": True, "response": cycle_response}
|
||||
|
||||
# Create new recording session
|
||||
new_session = RecordingSession(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=action.project_id,
|
||||
location_id=action.location_id,
|
||||
unit_id=unit_id,
|
||||
session_type="sound" if action.device_type == "slm" else "vibration",
|
||||
started_at=datetime.utcnow(),
|
||||
status="recording",
|
||||
session_metadata=json.dumps({
|
||||
"scheduled_action_id": action.id,
|
||||
"cycle_response": cycle_response,
|
||||
"action_type": "cycle",
|
||||
}),
|
||||
)
|
||||
db.add(new_session)
|
||||
result["new_session_id"] = new_session.id
|
||||
|
||||
logger.info(f"[CYCLE] New measurement started, session {new_session.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[CYCLE] Start failed: {e}")
|
||||
result["steps"]["start"] = {"success": False, "error": str(e)}
|
||||
raise # Re-raise to mark the action as failed
|
||||
|
||||
finally:
|
||||
# Step 7: Re-enable background polling (always runs, even on failure)
|
||||
if result.get("polling_paused") and polling_was_enabled:
|
||||
logger.info(f"[CYCLE] Step 7/7: Re-enabling background polling for {unit_id}")
|
||||
try:
|
||||
if action.device_type == "slm":
|
||||
from backend.services.slmm_client import get_slmm_client
|
||||
slmm = get_slmm_client()
|
||||
await slmm.update_device_polling_config(unit_id, poll_enabled=True)
|
||||
logger.info(f"[CYCLE] Background polling re-enabled for {unit_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"[CYCLE] Failed to re-enable polling: {e}")
|
||||
# Don't raise - cycle completed, just log the error
|
||||
|
||||
logger.info(f"[CYCLE] === Cycle complete for {unit_id} ===")
|
||||
return result
|
||||
|
||||
# ========================================================================
|
||||
# Recurring Schedule Generation
|
||||
# ========================================================================
|
||||
|
||||
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
|
||||
}
|
||||
@@ -109,7 +109,8 @@ class SLMMClient:
|
||||
f"SLMM operation failed: {error_detail}"
|
||||
)
|
||||
except Exception as e:
|
||||
raise SLMMClientError(f"Unexpected error: {str(e)}")
|
||||
error_msg = str(e) if str(e) else type(e).__name__
|
||||
raise SLMMClientError(f"Unexpected error: {error_msg}")
|
||||
|
||||
# ========================================================================
|
||||
# Unit Management
|
||||
@@ -478,9 +479,124 @@ class SLMMClient:
|
||||
return await self._request("GET", f"/{unit_id}/settings")
|
||||
|
||||
# ========================================================================
|
||||
# Data Download (Future)
|
||||
# FTP Control
|
||||
# ========================================================================
|
||||
|
||||
async def enable_ftp(self, unit_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Enable FTP server on device.
|
||||
|
||||
Must be called before downloading files. FTP and TCP can work in tandem.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
|
||||
Returns:
|
||||
Dict with status message
|
||||
"""
|
||||
return await self._request("POST", f"/{unit_id}/ftp/enable")
|
||||
|
||||
async def disable_ftp(self, unit_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Disable FTP server on device.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
|
||||
Returns:
|
||||
Dict with status message
|
||||
"""
|
||||
return await self._request("POST", f"/{unit_id}/ftp/disable")
|
||||
|
||||
async def get_ftp_status(self, unit_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get FTP server status on device.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
|
||||
Returns:
|
||||
Dict with ftp_enabled status
|
||||
"""
|
||||
return await self._request("GET", f"/{unit_id}/ftp/status")
|
||||
|
||||
# ========================================================================
|
||||
# Data Download
|
||||
# ========================================================================
|
||||
|
||||
async def download_file(
|
||||
self,
|
||||
unit_id: str,
|
||||
remote_path: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Download a single file from unit via FTP.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
remote_path: Path on device to download (e.g., "/NL43_DATA/measurement.wav")
|
||||
|
||||
Returns:
|
||||
Binary file content (as response)
|
||||
"""
|
||||
data = {"remote_path": remote_path}
|
||||
return await self._request("POST", f"/{unit_id}/ftp/download", data=data)
|
||||
|
||||
async def download_folder(
|
||||
self,
|
||||
unit_id: str,
|
||||
remote_path: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Download an entire folder from unit via FTP as a ZIP archive.
|
||||
|
||||
Useful for downloading complete measurement sessions (e.g., Auto_0000 folders).
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
remote_path: Folder path on device to download (e.g., "/NL43_DATA/Auto_0000")
|
||||
|
||||
Returns:
|
||||
Dict with local_path, folder_name, file_count, zip_size_bytes
|
||||
"""
|
||||
data = {"remote_path": remote_path}
|
||||
return await self._request("POST", f"/{unit_id}/ftp/download-folder", data=data)
|
||||
|
||||
async def download_current_measurement(
|
||||
self,
|
||||
unit_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Download the current measurement folder based on device's index number.
|
||||
|
||||
This is the recommended method for scheduled downloads - it automatically
|
||||
determines which folder to download based on the device's current store index.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
|
||||
Returns:
|
||||
Dict with local_path, folder_name, file_count, zip_size_bytes, index_number
|
||||
"""
|
||||
# Get current index number from device
|
||||
index_info = await self.get_index_number(unit_id)
|
||||
index_number_raw = index_info.get("index_number", 0)
|
||||
|
||||
# Convert to int - device returns string like "0000" or "0001"
|
||||
try:
|
||||
index_number = int(index_number_raw)
|
||||
except (ValueError, TypeError):
|
||||
index_number = 0
|
||||
|
||||
# Format as Auto_XXXX folder name
|
||||
folder_name = f"Auto_{index_number:04d}"
|
||||
remote_path = f"/NL43_DATA/{folder_name}"
|
||||
|
||||
# Download the folder
|
||||
result = await self.download_folder(unit_id, remote_path)
|
||||
result["index_number"] = index_number
|
||||
return result
|
||||
|
||||
async def download_files(
|
||||
self,
|
||||
unit_id: str,
|
||||
@@ -488,23 +604,24 @@ class SLMMClient:
|
||||
files: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Download files from unit via FTP.
|
||||
Download measurement files from unit via FTP.
|
||||
|
||||
NOTE: This endpoint doesn't exist in SLMM yet. Will need to implement.
|
||||
This method automatically determines the current measurement folder and downloads it.
|
||||
The destination_path parameter is logged for reference but actual download location
|
||||
is managed by SLMM (data/downloads/{unit_id}/).
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
destination_path: Local path to save files
|
||||
files: List of filenames to download, or None for all
|
||||
destination_path: Reference path (for logging/metadata, not used by SLMM)
|
||||
files: Ignored - always downloads the current measurement folder
|
||||
|
||||
Returns:
|
||||
Dict with downloaded files list and metadata
|
||||
Dict with download result including local_path, folder_name, etc.
|
||||
"""
|
||||
data = {
|
||||
"destination_path": destination_path,
|
||||
"files": files or "all",
|
||||
}
|
||||
return await self._request("POST", f"/{unit_id}/ftp/download", data=data)
|
||||
# Use the new method that automatically determines what to download
|
||||
result = await self.download_current_measurement(unit_id)
|
||||
result["requested_destination"] = destination_path
|
||||
return result
|
||||
|
||||
# ========================================================================
|
||||
# Cycle Commands (for scheduled automation)
|
||||
|
||||
@@ -36,6 +36,10 @@ async def sync_slm_to_slmm(unit: RosterUnit) -> bool:
|
||||
logger.warning(f"SLM {unit.id} has no host configured, skipping SLMM sync")
|
||||
return False
|
||||
|
||||
# Disable polling if unit is benched (deployed=False) or retired
|
||||
# Only actively deployed units should be polled
|
||||
should_poll = unit.deployed and not unit.retired
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.put(
|
||||
@@ -47,8 +51,8 @@ async def sync_slm_to_slmm(unit: RosterUnit) -> bool:
|
||||
"ftp_enabled": True,
|
||||
"ftp_username": "USER", # Default NL43 credentials
|
||||
"ftp_password": "0000",
|
||||
"poll_enabled": not unit.retired, # Disable polling for retired units
|
||||
"poll_interval_seconds": 60, # Default interval
|
||||
"poll_enabled": should_poll, # Disable polling for benched or retired units
|
||||
"poll_interval_seconds": 3600, # Default to 1 hour polling
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ def emit_status_snapshot():
|
||||
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
||||
"next_calibration_due": r.next_calibration_due.isoformat() if r.next_calibration_due else None,
|
||||
"deployed_with_modem_id": r.deployed_with_modem_id,
|
||||
"deployed_with_unit_id": r.deployed_with_unit_id,
|
||||
"ip_address": r.ip_address,
|
||||
"phone_number": r.phone_number,
|
||||
"hardware_model": r.hardware_model,
|
||||
@@ -137,6 +138,7 @@ def emit_status_snapshot():
|
||||
"last_calibrated": None,
|
||||
"next_calibration_due": None,
|
||||
"deployed_with_modem_id": None,
|
||||
"deployed_with_unit_id": None,
|
||||
"ip_address": None,
|
||||
"phone_number": None,
|
||||
"hardware_model": None,
|
||||
@@ -146,6 +148,34 @@ def emit_status_snapshot():
|
||||
"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
|
||||
active_units = {
|
||||
uid: u for uid, u in units.items()
|
||||
|
||||
@@ -137,6 +137,13 @@
|
||||
Modems
|
||||
</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 %}">
|
||||
<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>
|
||||
|
||||
@@ -187,6 +187,68 @@
|
||||
|
||||
</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 -->
|
||||
<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')">
|
||||
@@ -302,6 +364,255 @@
|
||||
|
||||
|
||||
<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]);
|
||||
}
|
||||
});
|
||||
|
||||
// Only fit bounds on initial load, not on subsequent updates
|
||||
// This preserves the user's current map view when auto-refreshing
|
||||
if (bounds.length > 0 && !fleetMapInitialized) {
|
||||
const padding = window.innerWidth < 768 ? [20, 20] : [50, 50];
|
||||
fleetMap.fitBounds(bounds, { padding: padding });
|
||||
fleetMapInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle card collapse/expand (mobile only)
|
||||
function toggleCard(cardName) {
|
||||
// Only work on mobile
|
||||
@@ -366,8 +677,17 @@ if (document.readyState === 'loading') {
|
||||
|
||||
function updateDashboard(event) {
|
||||
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);
|
||||
|
||||
// Store data for filter re-application
|
||||
currentSnapshotData = data;
|
||||
|
||||
// Update "Last updated" timestamp with timezone
|
||||
const now = new Date();
|
||||
const timezone = localStorage.getItem('timezone') || 'America/New_York';
|
||||
@@ -379,7 +699,7 @@ function updateDashboard(event) {
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
|
||||
// ===== Fleet summary numbers =====
|
||||
// ===== Fleet summary numbers (always unfiltered) =====
|
||||
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
||||
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
||||
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
|
||||
@@ -387,9 +707,10 @@ function updateDashboard(event) {
|
||||
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
||||
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
||||
|
||||
// ===== Device type counts =====
|
||||
// ===== Device type counts (always unfiltered) =====
|
||||
let seismoCount = 0;
|
||||
let slmCount = 0;
|
||||
let modemCount = 0;
|
||||
Object.values(data.units || {}).forEach(unit => {
|
||||
if (unit.retired) return; // Don't count retired units
|
||||
const deviceType = unit.device_type || 'seismograph';
|
||||
@@ -397,46 +718,26 @@ function updateDashboard(event) {
|
||||
seismoCount++;
|
||||
} else if (deviceType === 'sound_level_meter') {
|
||||
slmCount++;
|
||||
} else if (deviceType === 'modem') {
|
||||
modemCount++;
|
||||
}
|
||||
});
|
||||
document.getElementById('seismo-count').textContent = seismoCount;
|
||||
document.getElementById('slm-count').textContent = slmCount;
|
||||
|
||||
// ===== Alerts =====
|
||||
const alertsList = document.getElementById('alerts-list');
|
||||
// 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);
|
||||
// ===== Apply filters and render map + alerts =====
|
||||
renderFilteredDashboard(data);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Dashboard update error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tab switching
|
||||
// Handle tab switching and initialize components
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Load filter preferences
|
||||
loadFilterPreferences();
|
||||
|
||||
const tabButtons = document.querySelectorAll('.tab-button');
|
||||
|
||||
tabButtons.forEach(button => {
|
||||
@@ -476,64 +777,6 @@ function initFleetMap() {
|
||||
}, 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) {
|
||||
if (!location) return null;
|
||||
|
||||
|
||||
@@ -55,13 +55,7 @@
|
||||
hx-get="/api/modem-dashboard/units"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML">
|
||||
<div class="animate-pulse space-y-4">
|
||||
<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>
|
||||
<p class="text-gray-500 dark:text-gray-400">Loading modems...</p>
|
||||
</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 %}
|
||||
@@ -60,7 +60,9 @@
|
||||
data-note="{{ unit.note if unit.note else '' }}">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center space-x-2">
|
||||
{% if unit.status == 'OK' %}
|
||||
{% if not unit.deployed %}
|
||||
<span class="w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
||||
{% elif unit.status == 'OK' %}
|
||||
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
||||
{% elif unit.status == 'Pending' %}
|
||||
<span class="w-3 h-3 rounded-full bg-yellow-500" title="Pending"></span>
|
||||
@@ -104,8 +106,13 @@
|
||||
{% if unit.phone_number %}
|
||||
<div>{{ unit.phone_number }}</div>
|
||||
{% endif %}
|
||||
{% if unit.hardware_model %}
|
||||
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
|
||||
{% if unit.deployed_with_unit_id %}
|
||||
<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 %}
|
||||
{% else %}
|
||||
{% if unit.next_calibration_due %}
|
||||
@@ -126,7 +133,7 @@
|
||||
</div>
|
||||
</td>
|
||||
<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 class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm
|
||||
@@ -203,7 +210,9 @@
|
||||
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
{% if unit.status == 'OK' %}
|
||||
{% if not unit.deployed %}
|
||||
<span class="w-4 h-4 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
||||
{% elif unit.status == 'OK' %}
|
||||
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
||||
{% elif unit.status == 'Pending' %}
|
||||
<span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
|
||||
@@ -230,6 +239,10 @@
|
||||
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||
Modem
|
||||
</span>
|
||||
{% elif unit.device_type == 'slm' %}
|
||||
<span class="px-2 py-1 rounded-full bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 text-xs font-medium">
|
||||
SLM
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||
Seismograph
|
||||
@@ -345,6 +358,39 @@
|
||||
</style>
|
||||
|
||||
<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
|
||||
const timestampElement = document.getElementById('last-updated');
|
||||
if (timestampElement) {
|
||||
@@ -365,20 +411,23 @@
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
})();
|
||||
|
||||
// Sorting state
|
||||
let currentSort = { column: null, direction: 'asc' };
|
||||
// Sorting state (needs to persist across swaps)
|
||||
if (typeof window.currentSort === 'undefined') {
|
||||
window.currentSort = { column: null, direction: 'asc' };
|
||||
}
|
||||
|
||||
function sortTable(column) {
|
||||
const tbody = document.getElementById('roster-tbody');
|
||||
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
||||
|
||||
// Determine sort direction
|
||||
if (currentSort.column === column) {
|
||||
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
if (window.currentSort.column === column) {
|
||||
window.currentSort.direction = window.currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
currentSort.column = column;
|
||||
currentSort.direction = 'asc';
|
||||
window.currentSort.column = column;
|
||||
window.currentSort.direction = 'asc';
|
||||
}
|
||||
|
||||
// Sort rows
|
||||
@@ -406,8 +455,8 @@
|
||||
bVal = bVal.toLowerCase();
|
||||
}
|
||||
|
||||
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
|
||||
if (aVal < bVal) return window.currentSort.direction === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return window.currentSort.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
@@ -443,10 +492,10 @@
|
||||
});
|
||||
|
||||
// Set current indicator
|
||||
if (currentSort.column) {
|
||||
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
|
||||
if (window.currentSort.column) {
|
||||
const indicator = document.querySelector(`.sort-indicator[data-column="${window.currentSort.column}"]`);
|
||||
if (indicator) {
|
||||
indicator.className = `sort-indicator ${currentSort.direction}`;
|
||||
indicator.className = `sort-indicator ${window.currentSort.direction}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +1,127 @@
|
||||
<!-- Modem List -->
|
||||
{% 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 %}
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/unit/{{ modem.id }}" class="font-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 }}
|
||||
</a>
|
||||
{% 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 %}
|
||||
</div>
|
||||
|
||||
{% if modem.ip_address %}
|
||||
<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 -->
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
{% 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" %}
|
||||
<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" %}
|
||||
<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" %}
|
||||
<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 %}
|
||||
</div>
|
||||
|
||||
<!-- Paired Device -->
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm">
|
||||
{% 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 %}
|
||||
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<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">
|
||||
<a href="/unit/{{ modem.paired_device.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{{ 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>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">None</span>
|
||||
{% endif %}
|
||||
|
||||
<!-- Location if available -->
|
||||
{% if modem.location or modem.project_id %}
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="mt-3 flex gap-2">
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button onclick="pingModem('{{ 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
|
||||
</button>
|
||||
<a href="/unit/{{ modem.id }}"
|
||||
class="text-xs px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-300 rounded transition-colors">
|
||||
Details
|
||||
<a href="/unit/{{ modem.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
View →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Ping Result (hidden by default) -->
|
||||
<div id="ping-result-{{ modem.id }}" class="mt-2 text-xs hidden"></div>
|
||||
</div>
|
||||
<div id="ping-result-{{ modem.id }}" class="mt-1 text-xs hidden"></div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</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 %}
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% if device %}
|
||||
<div class="flex items-center gap-4 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div class="bg-green-100 dark:bg-green-900/30 p-3 rounded-lg">
|
||||
{% if device.device_type == "slm" %}
|
||||
{% if device.device_type == "slm" or device.device_type == "sound_level_meter" %}
|
||||
<svg class="w-6 h-6 text-green-600 dark:text-green-400" 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>
|
||||
@@ -18,7 +18,7 @@
|
||||
{{ device.id }}
|
||||
</a>
|
||||
<div class="flex items-center gap-2 mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span class="capitalize">{{ device.device_type }}</span>
|
||||
<span class="capitalize">{{ device.device_type | replace("_", " ") }}</span>
|
||||
{% if device.project_id %}
|
||||
<span class="text-gray-400">|</span>
|
||||
<span>{{ device.project_id }}</span>
|
||||
@@ -30,11 +30,17 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="openModemPairDeviceModal()"
|
||||
class="px-3 py-1.5 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors">
|
||||
Edit Pairing
|
||||
</button>
|
||||
<a href="/unit/{{ device.id }}" class="text-gray-400 hover:text-seismo-orange transition-colors">
|
||||
<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="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
@@ -47,5 +53,12 @@
|
||||
<p class="text-gray-600 dark:text-gray-400">No device currently paired</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-500">This modem is available for assignment</p>
|
||||
</div>
|
||||
<button onclick="openModemPairDeviceModal()"
|
||||
class="px-4 py-2 text-sm bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-center gap-2">
|
||||
<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="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 Device
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
188
templates/partials/projects/file_list.html
Normal file
188
templates/partials/projects/file_list.html
Normal file
@@ -0,0 +1,188 @@
|
||||
<!-- File List for NRL - Simple flat list of files with session info -->
|
||||
{% if files %}
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for file_data in files %}
|
||||
{% set file = file_data.file %}
|
||||
{% set session = file_data.session %}
|
||||
|
||||
<div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors group">
|
||||
<!-- File Icon -->
|
||||
{% if file.file_type == 'audio' %}
|
||||
<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
|
||||
</svg>
|
||||
{% elif file.file_type == 'archive' %}
|
||||
<svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||
</svg>
|
||||
{% elif file.file_type == 'log' %}
|
||||
<svg class="w-6 h-6 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
{% elif file.file_type == 'image' %}
|
||||
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
{% elif file.file_type == 'measurement' %}
|
||||
<svg class="w-6 h-6 text-emerald-500" 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>
|
||||
{% else %}
|
||||
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
{% endif %}
|
||||
|
||||
<!-- File Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-medium text-gray-900 dark:text-white truncate">
|
||||
{{ file.file_path.split('/')[-1] if file.file_path else 'Unknown' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
<!-- File Type Badge -->
|
||||
<span class="px-1.5 py-0.5 rounded font-medium
|
||||
{% if file.file_type == 'audio' %}bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300
|
||||
{% elif file.file_type == 'data' %}bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300
|
||||
{% elif file.file_type == 'measurement' %}bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300
|
||||
{% elif file.file_type == 'log' %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300
|
||||
{% elif file.file_type == 'archive' %}bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300
|
||||
{% elif file.file_type == 'image' %}bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300
|
||||
{% else %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
||||
{{ file.file_type or 'unknown' }}
|
||||
</span>
|
||||
|
||||
{# Leq vs Lp badge for RND files #}
|
||||
{% if file.file_path and '_Leq_' in file.file_path %}
|
||||
<span class="px-1.5 py-0.5 rounded font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
Leq (15-min avg)
|
||||
</span>
|
||||
{% elif file.file_path and '_Lp' in file.file_path and file.file_path.endswith('.rnd') %}
|
||||
<span class="px-1.5 py-0.5 rounded font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300">
|
||||
Lp (instant)
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<!-- File Size -->
|
||||
<span class="mx-1">•</span>
|
||||
{% if file.file_size_bytes %}
|
||||
{% if file.file_size_bytes < 1024 %}
|
||||
{{ file.file_size_bytes }} B
|
||||
{% elif file.file_size_bytes < 1048576 %}
|
||||
{{ "%.1f"|format(file.file_size_bytes / 1024) }} KB
|
||||
{% elif file.file_size_bytes < 1073741824 %}
|
||||
{{ "%.1f"|format(file.file_size_bytes / 1048576) }} MB
|
||||
{% else %}
|
||||
{{ "%.2f"|format(file.file_size_bytes / 1073741824) }} GB
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Unknown size
|
||||
{% endif %}
|
||||
|
||||
<!-- Session Info -->
|
||||
{% if session %}
|
||||
<span class="mx-1">•</span>
|
||||
<span class="text-gray-400">Session: {{ session.started_at|local_datetime if session.started_at else 'Unknown' }}</span>
|
||||
{% endif %}
|
||||
|
||||
<!-- Download Time -->
|
||||
{% if file.downloaded_at %}
|
||||
<span class="mx-1">•</span>
|
||||
{{ file.downloaded_at|local_datetime }}
|
||||
{% endif %}
|
||||
|
||||
<!-- Checksum Indicator -->
|
||||
{% if file.checksum %}
|
||||
<span class="mx-1" title="SHA256: {{ file.checksum[:16] }}...">
|
||||
<svg class="w-3 h-3 inline text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-2">
|
||||
{% if file.file_type == 'measurement' or (file.file_path and file.file_path.endswith('.rnd')) %}
|
||||
<a href="/api/projects/{{ project_id }}/files/{{ file.id }}/view-rnd"
|
||||
onclick="event.stopPropagation();"
|
||||
class="px-3 py-1 text-xs bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center">
|
||||
<svg class="w-4 h-4 inline mr-1" 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>
|
||||
View
|
||||
</a>
|
||||
{% endif %}
|
||||
{# Only show Report button for Leq files #}
|
||||
{% if file.file_path and '_Leq_' in file.file_path %}
|
||||
<a href="/api/projects/{{ project_id }}/files/{{ file.id }}/generate-report"
|
||||
onclick="event.stopPropagation();"
|
||||
class="px-3 py-1 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center"
|
||||
title="Generate Excel Report">
|
||||
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
Report
|
||||
</a>
|
||||
{% endif %}
|
||||
<button onclick="event.stopPropagation(); downloadFile('{{ file.id }}')"
|
||||
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
Download
|
||||
</button>
|
||||
<button onclick="event.stopPropagation(); confirmDeleteFile('{{ file.id }}', '{{ file.file_path.split('/')[-1] if file.file_path else 'Unknown' }}')"
|
||||
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
title="Delete this file">
|
||||
<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Empty State -->
|
||||
<div class="px-6 py-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No files downloaded yet</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">
|
||||
Files will appear here once they are downloaded from the sound level meter
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function downloadFile(fileId) {
|
||||
window.location.href = `/api/projects/{{ project_id }}/files/${fileId}/download`;
|
||||
}
|
||||
|
||||
function confirmDeleteFile(fileId, fileName) {
|
||||
if (confirm(`Are you sure you want to delete "${fileName}"?\n\nThis action cannot be undone.`)) {
|
||||
deleteFile(fileId);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFile(fileId) {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/{{ project_id }}/files/${fileId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
alert(`Failed to delete file: ${data.detail || 'Unknown error'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Error deleting file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -114,7 +114,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-3" id="cycle-timing">
|
||||
At <span id="preview-time">00:00</span>: Stop → Download (1 min) → Start (2 min)
|
||||
At <span id="preview-time">00:00</span>: Stop → Download → Start (~70 sec total)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,12 +132,12 @@ document.getElementById('include_download').addEventListener('change', function(
|
||||
downloadStep.style.display = 'flex';
|
||||
downloadArrow.style.display = 'block';
|
||||
startStepNum.textContent = '3';
|
||||
cycleTiming.innerHTML = `At <span id="preview-time">${timeValue}</span>: Stop → Download (1 min) → Start (2 min)`;
|
||||
cycleTiming.innerHTML = `At <span id="preview-time">${timeValue}</span>: Stop → Download → Start (~70 sec total)`;
|
||||
} else {
|
||||
downloadStep.style.display = 'none';
|
||||
downloadArrow.style.display = 'none';
|
||||
startStepNum.textContent = '2';
|
||||
cycleTiming.innerHTML = `At <span id="preview-time">${timeValue}</span>: Stop → Start (1 min)`;
|
||||
cycleTiming.innerHTML = `At <span id="preview-time">${timeValue}</span>: Stop → Start (~40 sec total)`;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -58,7 +58,9 @@
|
||||
data-note="{{ unit.note if unit.note else '' }}">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center space-x-2">
|
||||
{% if unit.status == 'OK' %}
|
||||
{% if not unit.deployed %}
|
||||
<span class="w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
||||
{% elif unit.status == 'OK' %}
|
||||
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
||||
{% elif unit.status == 'Pending' %}
|
||||
<span class="w-3 h-3 rounded-full bg-yellow-500" title="Pending"></span>
|
||||
@@ -83,6 +85,10 @@
|
||||
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||
Modem
|
||||
</span>
|
||||
{% elif unit.device_type == 'slm' %}
|
||||
<span class="px-2 py-1 rounded-full bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 text-xs font-medium">
|
||||
SLM
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||
Seismograph
|
||||
@@ -195,7 +201,9 @@
|
||||
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
{% if unit.status == 'OK' %}
|
||||
{% if not unit.deployed %}
|
||||
<span class="w-4 h-4 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
||||
{% elif unit.status == 'OK' %}
|
||||
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
||||
{% elif unit.status == 'Pending' %}
|
||||
<span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
|
||||
@@ -222,6 +230,10 @@
|
||||
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||
Modem
|
||||
</span>
|
||||
{% elif unit.device_type == 'slm' %}
|
||||
<span class="px-2 py-1 rounded-full bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 text-xs font-medium">
|
||||
SLM
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||
Seismograph
|
||||
@@ -337,6 +349,7 @@
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// Update timestamp
|
||||
const timestampElement = document.getElementById('last-updated');
|
||||
if (timestampElement) {
|
||||
@@ -357,20 +370,23 @@
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
})();
|
||||
|
||||
// Sorting state
|
||||
let currentSort = { column: null, direction: 'asc' };
|
||||
// Sorting state (needs to persist across swaps)
|
||||
if (typeof window.currentSort === 'undefined') {
|
||||
window.currentSort = { column: null, direction: 'asc' };
|
||||
}
|
||||
|
||||
function sortTable(column) {
|
||||
const tbody = document.getElementById('roster-tbody');
|
||||
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
||||
|
||||
// Determine sort direction
|
||||
if (currentSort.column === column) {
|
||||
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
if (window.currentSort.column === column) {
|
||||
window.currentSort.direction = window.currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
currentSort.column = column;
|
||||
currentSort.direction = 'asc';
|
||||
window.currentSort.column = column;
|
||||
window.currentSort.direction = 'asc';
|
||||
}
|
||||
|
||||
// Sort rows
|
||||
@@ -398,8 +414,8 @@
|
||||
bVal = bVal.toLowerCase();
|
||||
}
|
||||
|
||||
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
|
||||
if (aVal < bVal) return window.currentSort.direction === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return window.currentSort.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
@@ -435,10 +451,10 @@
|
||||
});
|
||||
|
||||
// Set current indicator
|
||||
if (currentSort.column) {
|
||||
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
|
||||
if (window.currentSort.column) {
|
||||
const indicator = document.querySelector(`.sort-indicator[data-column="${window.currentSort.column}"]`);
|
||||
if (indicator) {
|
||||
indicator.className = `sort-indicator ${currentSort.direction}`;
|
||||
indicator.className = `sort-indicator ${window.currentSort.direction}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,6 +311,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="settings-success" class="hidden text-sm text-green-600 dark:text-green-400"></div>
|
||||
<div id="settings-error" class="hidden text-sm text-red-600"></div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
@@ -606,6 +607,9 @@ function switchTab(tabName) {
|
||||
button.classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||
button.classList.add('border-seismo-orange', 'text-seismo-orange');
|
||||
}
|
||||
|
||||
// Persist active tab in URL hash so refresh stays on this tab
|
||||
history.replaceState(null, '', `#${tabName}`);
|
||||
}
|
||||
|
||||
// Load project details
|
||||
@@ -677,12 +681,20 @@ document.getElementById('project-settings-form').addEventListener('submit', asyn
|
||||
throw new Error('Failed to update project');
|
||||
}
|
||||
|
||||
// Reload page to show updated data
|
||||
window.location.reload();
|
||||
// Refresh header and dashboard without full page reload
|
||||
refreshProjectDashboard();
|
||||
|
||||
// Show success feedback
|
||||
const successEl = document.getElementById('settings-success');
|
||||
successEl.textContent = 'Settings saved.';
|
||||
successEl.classList.remove('hidden');
|
||||
document.getElementById('settings-error').classList.add('hidden');
|
||||
setTimeout(() => successEl.classList.add('hidden'), 3000);
|
||||
} catch (err) {
|
||||
const errorEl = document.getElementById('settings-error');
|
||||
errorEl.textContent = err.message || 'Failed to update project.';
|
||||
errorEl.classList.remove('hidden');
|
||||
document.getElementById('settings-success').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1251,9 +1263,16 @@ document.getElementById('schedule-modal')?.addEventListener('click', function(e)
|
||||
}
|
||||
});
|
||||
|
||||
// Load project details on page load
|
||||
// Load project details on page load and restore active tab from URL hash
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadProjectDetails();
|
||||
|
||||
// Restore tab from URL hash (e.g. #schedules, #settings)
|
||||
const hash = window.location.hash.replace('#', '');
|
||||
const validTabs = ['overview', 'locations', 'units', 'schedules', 'sessions', 'data', 'settings'];
|
||||
if (hash && validTabs.includes(hash)) {
|
||||
switchTab(hash);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
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">
|
||||
<option value="seismograph">Seismograph</option>
|
||||
<option value="modem">Modem</option>
|
||||
<option value="sound_level_meter">Sound Level Meter</option>
|
||||
<option value="slm">Sound Level Meter</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -178,8 +178,13 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Hardware Model</label>
|
||||
<input type="text" name="hardware_model" placeholder="e.g., Raven XTV"
|
||||
<select name="hardware_model"
|
||||
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">
|
||||
<option value="">Select model...</option>
|
||||
<option value="RV50">RV50</option>
|
||||
<option value="RV55">RV55</option>
|
||||
<option value="RX55">RX55</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployment Type</label>
|
||||
@@ -206,21 +211,6 @@
|
||||
<input type="text" name="slm_model" placeholder="NL-43"
|
||||
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>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Host/IP Address</label>
|
||||
<input type="text" name="slm_host" placeholder="192.168.1.100"
|
||||
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>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
|
||||
<input type="number" name="slm_tcp_port" placeholder="2255"
|
||||
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>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">FTP Port</label>
|
||||
<input type="number" name="slm_ftp_port" placeholder="21"
|
||||
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>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Serial Number</label>
|
||||
<input type="text" name="slm_serial_number" placeholder="SN123456"
|
||||
@@ -244,6 +234,12 @@
|
||||
<option value="I">I (Impulse)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="slmModemPairingField" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||
{% set picker_id = "-add-slm" %}
|
||||
{% include "partials/modem_picker.html" with context %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">SLM connects via modem's IP address</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -301,7 +297,7 @@
|
||||
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">
|
||||
<option value="seismograph">Seismograph</option>
|
||||
<option value="modem">Modem</option>
|
||||
<option value="sound_level_meter">Sound Level Meter</option>
|
||||
<option value="slm">Sound Level Meter</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -360,8 +356,13 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Hardware Model</label>
|
||||
<input type="text" name="hardware_model" id="editHardwareModel" placeholder="e.g., Raven XTV"
|
||||
<select name="hardware_model" id="editHardwareModel"
|
||||
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">
|
||||
<option value="">Select model...</option>
|
||||
<option value="RV50">RV50</option>
|
||||
<option value="RV55">RV55</option>
|
||||
<option value="RX55">RX55</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployment Type</label>
|
||||
@@ -388,21 +389,6 @@
|
||||
<input type="text" name="slm_model" id="editSlmModel" placeholder="NL-43"
|
||||
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>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Host/IP Address</label>
|
||||
<input type="text" name="slm_host" id="editSlmHost" placeholder="192.168.1.100"
|
||||
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>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
|
||||
<input type="number" name="slm_tcp_port" id="editSlmTcpPort" placeholder="2255"
|
||||
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>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">FTP Port</label>
|
||||
<input type="number" name="slm_ftp_port" id="editSlmFtpPort" placeholder="21"
|
||||
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>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Serial Number</label>
|
||||
<input type="text" name="slm_serial_number" id="editSlmSerialNumber" placeholder="SN123456"
|
||||
@@ -428,6 +414,12 @@
|
||||
<option value="I">I (Impulse)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="editSlmModemPairingField" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||
{% set picker_id = "-edit-slm" %}
|
||||
{% include "partials/modem_picker.html" with context %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">SLM connects via modem's IP address</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -641,7 +633,7 @@
|
||||
setFieldsDisabled(seismoFields, true);
|
||||
setFieldsDisabled(modemFields, false);
|
||||
setFieldsDisabled(slmFields, true);
|
||||
} else if (deviceType === 'sound_level_meter') {
|
||||
} else if (deviceType === 'slm') {
|
||||
seismoFields.classList.add('hidden');
|
||||
modemFields.classList.add('hidden');
|
||||
slmFields.classList.remove('hidden');
|
||||
@@ -649,6 +641,7 @@
|
||||
setFieldsDisabled(seismoFields, true);
|
||||
setFieldsDisabled(modemFields, true);
|
||||
setFieldsDisabled(slmFields, false);
|
||||
toggleModemPairing(); // Check if modem pairing should be shown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,17 +654,26 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle modem pairing field visibility (only for deployed seismographs)
|
||||
// Toggle modem pairing field visibility (only for deployed seismographs and SLMs)
|
||||
function toggleModemPairing() {
|
||||
const deviceType = document.getElementById('deviceTypeSelect').value;
|
||||
const deployedCheckbox = document.getElementById('deployedCheckbox');
|
||||
const modemPairingField = document.getElementById('modemPairingField');
|
||||
const slmModemPairingField = document.getElementById('slmModemPairingField');
|
||||
|
||||
// Seismograph modem pairing
|
||||
if (deviceType === 'seismograph' && deployedCheckbox.checked) {
|
||||
modemPairingField.classList.remove('hidden');
|
||||
} else {
|
||||
modemPairingField.classList.add('hidden');
|
||||
}
|
||||
|
||||
// SLM modem pairing
|
||||
if (deviceType === 'slm' && deployedCheckbox.checked) {
|
||||
slmModemPairingField.classList.remove('hidden');
|
||||
} else {
|
||||
slmModemPairingField.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Add unknown unit to roster
|
||||
@@ -816,13 +818,14 @@
|
||||
setFieldsDisabled(seismoFields, true);
|
||||
setFieldsDisabled(modemFields, false);
|
||||
setFieldsDisabled(slmFields, true);
|
||||
} else if (deviceType === 'sound_level_meter') {
|
||||
} else if (deviceType === 'slm') {
|
||||
seismoFields.classList.add('hidden');
|
||||
modemFields.classList.add('hidden');
|
||||
slmFields.classList.remove('hidden');
|
||||
setFieldsDisabled(seismoFields, true);
|
||||
setFieldsDisabled(modemFields, true);
|
||||
setFieldsDisabled(slmFields, false);
|
||||
toggleEditModemPairing(); // Check if modem pairing should be shown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -831,12 +834,21 @@
|
||||
const deviceType = document.getElementById('editDeviceTypeSelect').value;
|
||||
const deployedCheckbox = document.getElementById('editDeployedCheckbox');
|
||||
const modemPairingField = document.getElementById('editModemPairingField');
|
||||
const slmModemPairingField = document.getElementById('editSlmModemPairingField');
|
||||
|
||||
// Seismograph modem pairing
|
||||
if (deviceType === 'seismograph' && deployedCheckbox.checked) {
|
||||
modemPairingField.classList.remove('hidden');
|
||||
} else {
|
||||
modemPairingField.classList.add('hidden');
|
||||
}
|
||||
|
||||
// SLM modem pairing
|
||||
if (deviceType === 'slm' && deployedCheckbox.checked) {
|
||||
slmModemPairingField.classList.remove('hidden');
|
||||
} else {
|
||||
slmModemPairingField.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Edit Unit - Fetch data and populate form
|
||||
@@ -911,7 +923,7 @@
|
||||
// Modem fields
|
||||
document.getElementById('editIpAddress').value = unit.ip_address;
|
||||
document.getElementById('editPhoneNumber').value = unit.phone_number;
|
||||
document.getElementById('editHardwareModel').value = unit.hardware_model;
|
||||
document.getElementById('editHardwareModel').value = unit.hardware_model || '';
|
||||
document.getElementById('editDeploymentType').value = unit.deployment_type || '';
|
||||
|
||||
// Populate unit picker for modem (uses -edit-modem suffix)
|
||||
@@ -940,13 +952,36 @@
|
||||
|
||||
// SLM fields
|
||||
document.getElementById('editSlmModel').value = unit.slm_model || '';
|
||||
document.getElementById('editSlmHost').value = unit.slm_host || '';
|
||||
document.getElementById('editSlmTcpPort').value = unit.slm_tcp_port || '';
|
||||
document.getElementById('editSlmFtpPort').value = unit.slm_ftp_port || '';
|
||||
document.getElementById('editSlmSerialNumber').value = unit.slm_serial_number || '';
|
||||
document.getElementById('editSlmFrequencyWeighting').value = unit.slm_frequency_weighting || '';
|
||||
document.getElementById('editSlmTimeWeighting').value = unit.slm_time_weighting || '';
|
||||
|
||||
// Populate SLM modem picker (uses -edit-slm suffix)
|
||||
const slmModemPickerValue = document.getElementById('modem-picker-value-edit-slm');
|
||||
const slmModemPickerSearch = document.getElementById('modem-picker-search-edit-slm');
|
||||
const slmModemPickerClear = document.getElementById('modem-picker-clear-edit-slm');
|
||||
if (slmModemPickerValue) slmModemPickerValue.value = unit.deployed_with_modem_id || '';
|
||||
if (unit.deployed_with_modem_id && unit.device_type === 'slm') {
|
||||
// Fetch modem display (ID + IP + note)
|
||||
fetch(`/api/roster/${unit.deployed_with_modem_id}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(modem => {
|
||||
if (modem && slmModemPickerSearch) {
|
||||
let display = modem.id;
|
||||
if (modem.ip_address) display += ` - ${modem.ip_address}`;
|
||||
if (modem.note) display += ` - ${modem.note}`;
|
||||
slmModemPickerSearch.value = display;
|
||||
if (slmModemPickerClear) slmModemPickerClear.classList.remove('hidden');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (slmModemPickerSearch) slmModemPickerSearch.value = unit.deployed_with_modem_id;
|
||||
});
|
||||
} else {
|
||||
if (slmModemPickerSearch) slmModemPickerSearch.value = '';
|
||||
if (slmModemPickerClear) slmModemPickerClear.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Cascade section - show if there's a paired device
|
||||
const cascadeSection = document.getElementById('editCascadeSection');
|
||||
const cascadeToUnitId = document.getElementById('editCascadeToUnitId');
|
||||
@@ -1154,11 +1189,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() {
|
||||
// Auto-refresh device list every 30 seconds (increased from 10s to reduce flicker)
|
||||
setInterval(() => {
|
||||
const deviceContent = document.getElementById('device-content');
|
||||
if (deviceContent && !document.querySelector('.modal:not(.hidden)')) {
|
||||
if (deviceContent && !isAnyModalOpen()) {
|
||||
// Only auto-refresh if no modal is open
|
||||
refreshDeviceList();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user