Merge dev v0.5.1 before 0.6 update with calender. #25

Merged
serversdown merged 11 commits from dev into main 2026-02-06 14:56:13 -05:00
28 changed files with 3401 additions and 379 deletions

View File

@@ -21,6 +21,7 @@ from backend.database import engine, Base, get_db
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, projects, project_locations, scheduler, modem_dashboard
from backend.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()
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -167,23 +167,7 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
measurement_state = state_data.get("measurement_state", "Unknown")
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"
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,6 +36,10 @@ async def sync_slm_to_slmm(unit: RosterUnit) -> bool:
logger.warning(f"SLM {unit.id} has no host configured, skipping SLMM sync")
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
}
)

View File

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

View File

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

View File

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

View File

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

@@ -0,0 +1,566 @@
{% extends "base.html" %}
{% block title %}Pair Devices - Terra-View{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Pair Devices</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Select a recorder (seismograph or SLM) and a modem to create a bidirectional pairing.
</p>
</div>
<!-- Selection Summary Bar -->
<div id="selection-bar" class="mb-6 p-4 bg-white dark:bg-slate-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between flex-wrap gap-4">
<div class="flex items-center gap-6">
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600 dark:text-gray-400">Recorder:</span>
<span id="selected-recorder" class="font-mono font-medium text-gray-900 dark:text-white">None selected</span>
</div>
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"></path>
</svg>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600 dark:text-gray-400">Modem:</span>
<span id="selected-modem" class="font-mono font-medium text-gray-900 dark:text-white">None selected</span>
</div>
</div>
<div class="flex items-center gap-3">
<button id="clear-selection-btn"
onclick="clearSelection()"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
disabled>
Clear
</button>
<button id="pair-btn"
onclick="pairDevices()"
class="px-4 py-2 text-sm font-medium text-white bg-seismo-orange rounded-lg hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
disabled>
Pair Devices
</button>
</div>
</div>
</div>
<!-- Two Column Layout -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column: Recorders (Seismographs + SLMs) -->
<div class="bg-white dark:bg-slate-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
Recorders
<span id="recorder-count" class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ recorders|length }})</span>
</h2>
</div>
<!-- Recorder Search & Filters -->
<div class="space-y-2">
<input type="text" id="recorder-search" placeholder="Search by ID..."
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange focus:border-seismo-orange"
oninput="filterRecorders()">
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="recorder-hide-paired" onchange="filterRecorders()" class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
<span class="text-xs text-gray-600 dark:text-gray-400">Hide paired</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="recorder-deployed-only" onchange="filterRecorders()" class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
<span class="text-xs text-gray-600 dark:text-gray-400">Deployed only</span>
</label>
</div>
</div>
</div>
<div class="max-h-[600px] overflow-y-auto">
<div id="recorders-list" class="divide-y divide-gray-200 dark:divide-gray-700">
{% for unit in recorders %}
<div class="device-row recorder-row p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors"
data-id="{{ unit.id }}"
data-deployed="{{ unit.deployed|lower }}"
data-paired-with="{{ unit.deployed_with_modem_id or '' }}"
data-device-type="{{ unit.device_type }}"
onclick="selectRecorder('{{ unit.id }}')">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full flex items-center justify-center
{% if unit.device_type == 'slm' %}bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400
{% else %}bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400{% endif %}">
{% if unit.device_type == 'slm' %}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
</svg>
{% else %}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
{% endif %}
</div>
<div>
<div class="font-mono font-medium text-gray-900 dark:text-white">{{ unit.id }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ unit.device_type|capitalize }}
{% if not unit.deployed %}<span class="text-yellow-600 dark:text-yellow-400">(Benched)</span>{% endif %}
</div>
</div>
</div>
<div class="flex items-center gap-2">
{% if unit.deployed_with_modem_id %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400">
→ {{ unit.deployed_with_modem_id }}
</span>
{% endif %}
<div class="w-5 h-5 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center selection-indicator">
<svg class="w-3 h-3 text-seismo-orange hidden check-icon" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
</div>
</div>
</div>
</div>
{% else %}
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
No recorders found in roster
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Right Column: Modems -->
<div class="bg-white dark:bg-slate-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
</svg>
Modems
<span id="modem-count" class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ modems|length }})</span>
</h2>
</div>
<!-- Modem Search & Filters -->
<div class="space-y-2">
<input type="text" id="modem-search" placeholder="Search by ID, IP, or phone..."
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange focus:border-seismo-orange"
oninput="filterModems()">
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="modem-hide-paired" onchange="filterModems()" class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
<span class="text-xs text-gray-600 dark:text-gray-400">Hide paired</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="modem-deployed-only" onchange="filterModems()" class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
<span class="text-xs text-gray-600 dark:text-gray-400">Deployed only</span>
</label>
</div>
</div>
</div>
<div class="max-h-[600px] overflow-y-auto">
<div id="modems-list" class="divide-y divide-gray-200 dark:divide-gray-700">
{% for unit in modems %}
<div class="device-row modem-row p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors"
data-id="{{ unit.id }}"
data-deployed="{{ unit.deployed|lower }}"
data-paired-with="{{ unit.deployed_with_unit_id or '' }}"
data-ip="{{ unit.ip_address or '' }}"
data-phone="{{ unit.phone_number or '' }}"
onclick="selectModem('{{ unit.id }}')">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center text-amber-600 dark:text-amber-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
</svg>
</div>
<div>
<div class="font-mono font-medium text-gray-900 dark:text-white">{{ unit.id }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{% if unit.ip_address %}<span class="font-mono">{{ unit.ip_address }}</span>{% endif %}
{% if unit.phone_number %}{% if unit.ip_address %} · {% endif %}{{ unit.phone_number }}{% endif %}
{% if not unit.ip_address and not unit.phone_number %}Modem{% endif %}
{% if not unit.deployed %}<span class="text-yellow-600 dark:text-yellow-400">(Benched)</span>{% endif %}
</div>
</div>
</div>
<div class="flex items-center gap-2">
{% if unit.deployed_with_unit_id %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400">
← {{ unit.deployed_with_unit_id }}
</span>
{% endif %}
<div class="w-5 h-5 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center selection-indicator">
<svg class="w-3 h-3 text-seismo-orange hidden check-icon" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
</div>
</div>
</div>
</div>
{% else %}
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
No modems found in roster
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Existing Pairings Section -->
<div class="mt-8 bg-white dark:bg-slate-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
Existing Pairings
<span id="pairing-count" class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ pairings|length }})</span>
</h2>
</div>
<div class="max-h-[400px] overflow-y-auto">
<div id="pairings-list" class="divide-y divide-gray-200 dark:divide-gray-700">
{% for pairing in pairings %}
<div class="pairing-row p-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50">
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="px-2 py-1 text-sm font-mono rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400">
{{ pairing.recorder_id }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ pairing.recorder_type }}</span>
</div>
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"></path>
</svg>
<div class="flex items-center gap-2">
<span class="px-2 py-1 text-sm font-mono rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400">
{{ pairing.modem_id }}
</span>
{% if pairing.modem_ip %}
<span class="text-xs font-mono text-gray-500 dark:text-gray-400">{{ pairing.modem_ip }}</span>
{% endif %}
</div>
</div>
<button onclick="unpairDevices('{{ pairing.recorder_id }}', '{{ pairing.modem_id }}')"
class="p-2 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 rounded-lg transition-colors"
title="Unpair devices">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
{% else %}
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
No pairings found. Select a recorder and modem above to create one.
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Toast notification -->
<div id="toast" class="fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg transform translate-y-full opacity-0 transition-all duration-300 z-50"></div>
<script>
let selectedRecorder = null;
let selectedModem = null;
function selectRecorder(id) {
// Deselect previous
document.querySelectorAll('.recorder-row').forEach(row => {
row.classList.remove('bg-seismo-orange/10', 'dark:bg-seismo-orange/20');
row.querySelector('.selection-indicator').classList.remove('border-seismo-orange', 'bg-seismo-orange');
row.querySelector('.selection-indicator').classList.add('border-gray-300', 'dark:border-gray-600');
row.querySelector('.check-icon').classList.add('hidden');
});
// Toggle selection
if (selectedRecorder === id) {
selectedRecorder = null;
document.getElementById('selected-recorder').textContent = 'None selected';
} else {
selectedRecorder = id;
document.getElementById('selected-recorder').textContent = id;
// Highlight selected
const row = document.querySelector(`.recorder-row[data-id="${id}"]`);
if (row) {
row.classList.add('bg-seismo-orange/10', 'dark:bg-seismo-orange/20');
row.querySelector('.selection-indicator').classList.remove('border-gray-300', 'dark:border-gray-600');
row.querySelector('.selection-indicator').classList.add('border-seismo-orange', 'bg-seismo-orange');
row.querySelector('.check-icon').classList.remove('hidden');
}
}
updateButtons();
}
function selectModem(id) {
// Deselect previous
document.querySelectorAll('.modem-row').forEach(row => {
row.classList.remove('bg-seismo-orange/10', 'dark:bg-seismo-orange/20');
row.querySelector('.selection-indicator').classList.remove('border-seismo-orange', 'bg-seismo-orange');
row.querySelector('.selection-indicator').classList.add('border-gray-300', 'dark:border-gray-600');
row.querySelector('.check-icon').classList.add('hidden');
});
// Toggle selection
if (selectedModem === id) {
selectedModem = null;
document.getElementById('selected-modem').textContent = 'None selected';
} else {
selectedModem = id;
document.getElementById('selected-modem').textContent = id;
// Highlight selected
const row = document.querySelector(`.modem-row[data-id="${id}"]`);
if (row) {
row.classList.add('bg-seismo-orange/10', 'dark:bg-seismo-orange/20');
row.querySelector('.selection-indicator').classList.remove('border-gray-300', 'dark:border-gray-600');
row.querySelector('.selection-indicator').classList.add('border-seismo-orange', 'bg-seismo-orange');
row.querySelector('.check-icon').classList.remove('hidden');
}
}
updateButtons();
}
function updateButtons() {
const pairBtn = document.getElementById('pair-btn');
const clearBtn = document.getElementById('clear-selection-btn');
pairBtn.disabled = !(selectedRecorder && selectedModem);
clearBtn.disabled = !(selectedRecorder || selectedModem);
}
function clearSelection() {
if (selectedRecorder) selectRecorder(selectedRecorder);
if (selectedModem) selectModem(selectedModem);
}
function filterRecorders() {
const searchTerm = document.getElementById('recorder-search').value.toLowerCase().trim();
const hidePaired = document.getElementById('recorder-hide-paired').checked;
const deployedOnly = document.getElementById('recorder-deployed-only').checked;
let visibleRecorders = 0;
document.querySelectorAll('.recorder-row').forEach(row => {
const id = row.dataset.id.toLowerCase();
const pairedWith = row.dataset.pairedWith;
const deployed = row.dataset.deployed === 'true';
let show = true;
if (searchTerm && !id.includes(searchTerm)) show = false;
if (hidePaired && pairedWith) show = false;
if (deployedOnly && !deployed) show = false;
row.style.display = show ? '' : 'none';
if (show) visibleRecorders++;
});
document.getElementById('recorder-count').textContent = `(${visibleRecorders})`;
}
function filterModems() {
const searchTerm = document.getElementById('modem-search').value.toLowerCase().trim();
const hidePaired = document.getElementById('modem-hide-paired').checked;
const deployedOnly = document.getElementById('modem-deployed-only').checked;
let visibleModems = 0;
document.querySelectorAll('.modem-row').forEach(row => {
const id = row.dataset.id.toLowerCase();
const ip = (row.dataset.ip || '').toLowerCase();
const phone = (row.dataset.phone || '').toLowerCase();
const pairedWith = row.dataset.pairedWith;
const deployed = row.dataset.deployed === 'true';
let show = true;
if (searchTerm && !id.includes(searchTerm) && !ip.includes(searchTerm) && !phone.includes(searchTerm)) show = false;
if (hidePaired && pairedWith) show = false;
if (deployedOnly && !deployed) show = false;
row.style.display = show ? '' : 'none';
if (show) visibleModems++;
});
document.getElementById('modem-count').textContent = `(${visibleModems})`;
}
function saveScrollPositions() {
const recordersList = document.getElementById('recorders-list').parentElement;
const modemsList = document.getElementById('modems-list').parentElement;
const pairingsList = document.getElementById('pairings-list').parentElement;
sessionStorage.setItem('pairDevices_recorderScroll', recordersList.scrollTop);
sessionStorage.setItem('pairDevices_modemScroll', modemsList.scrollTop);
sessionStorage.setItem('pairDevices_pairingScroll', pairingsList.scrollTop);
// Save recorder filter state
sessionStorage.setItem('pairDevices_recorderSearch', document.getElementById('recorder-search').value);
sessionStorage.setItem('pairDevices_recorderHidePaired', document.getElementById('recorder-hide-paired').checked);
sessionStorage.setItem('pairDevices_recorderDeployedOnly', document.getElementById('recorder-deployed-only').checked);
// Save modem filter state
sessionStorage.setItem('pairDevices_modemSearch', document.getElementById('modem-search').value);
sessionStorage.setItem('pairDevices_modemHidePaired', document.getElementById('modem-hide-paired').checked);
sessionStorage.setItem('pairDevices_modemDeployedOnly', document.getElementById('modem-deployed-only').checked);
}
function restoreScrollPositions() {
const recorderScroll = sessionStorage.getItem('pairDevices_recorderScroll');
const modemScroll = sessionStorage.getItem('pairDevices_modemScroll');
const pairingScroll = sessionStorage.getItem('pairDevices_pairingScroll');
if (recorderScroll) {
document.getElementById('recorders-list').parentElement.scrollTop = parseInt(recorderScroll);
}
if (modemScroll) {
document.getElementById('modems-list').parentElement.scrollTop = parseInt(modemScroll);
}
if (pairingScroll) {
document.getElementById('pairings-list').parentElement.scrollTop = parseInt(pairingScroll);
}
// Restore recorder filter state
const recorderSearch = sessionStorage.getItem('pairDevices_recorderSearch');
const recorderHidePaired = sessionStorage.getItem('pairDevices_recorderHidePaired');
const recorderDeployedOnly = sessionStorage.getItem('pairDevices_recorderDeployedOnly');
if (recorderSearch) document.getElementById('recorder-search').value = recorderSearch;
if (recorderHidePaired === 'true') document.getElementById('recorder-hide-paired').checked = true;
if (recorderDeployedOnly === 'true') document.getElementById('recorder-deployed-only').checked = true;
// Restore modem filter state
const modemSearch = sessionStorage.getItem('pairDevices_modemSearch');
const modemHidePaired = sessionStorage.getItem('pairDevices_modemHidePaired');
const modemDeployedOnly = sessionStorage.getItem('pairDevices_modemDeployedOnly');
if (modemSearch) document.getElementById('modem-search').value = modemSearch;
if (modemHidePaired === 'true') document.getElementById('modem-hide-paired').checked = true;
if (modemDeployedOnly === 'true') document.getElementById('modem-deployed-only').checked = true;
// Apply filters if any were set
if (recorderSearch || recorderHidePaired === 'true' || recorderDeployedOnly === 'true') {
filterRecorders();
}
if (modemSearch || modemHidePaired === 'true' || modemDeployedOnly === 'true') {
filterModems();
}
// Clear stored values
sessionStorage.removeItem('pairDevices_recorderScroll');
sessionStorage.removeItem('pairDevices_modemScroll');
sessionStorage.removeItem('pairDevices_pairingScroll');
sessionStorage.removeItem('pairDevices_recorderSearch');
sessionStorage.removeItem('pairDevices_recorderHidePaired');
sessionStorage.removeItem('pairDevices_recorderDeployedOnly');
sessionStorage.removeItem('pairDevices_modemSearch');
sessionStorage.removeItem('pairDevices_modemHidePaired');
sessionStorage.removeItem('pairDevices_modemDeployedOnly');
}
// Restore scroll positions on page load
document.addEventListener('DOMContentLoaded', restoreScrollPositions);
async function pairDevices() {
if (!selectedRecorder || !selectedModem) return;
const pairBtn = document.getElementById('pair-btn');
pairBtn.disabled = true;
pairBtn.textContent = 'Pairing...';
try {
const response = await fetch('/api/roster/pair-devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recorder_id: selectedRecorder,
modem_id: selectedModem
})
});
const result = await response.json();
if (response.ok) {
showToast(`Paired ${selectedRecorder} with ${selectedModem}`, 'success');
// Save scroll positions before reload
saveScrollPositions();
setTimeout(() => window.location.reload(), 500);
} else {
showToast(result.detail || 'Failed to pair devices', 'error');
}
} catch (error) {
showToast('Error pairing devices: ' + error.message, 'error');
} finally {
pairBtn.disabled = false;
pairBtn.textContent = 'Pair Devices';
}
}
async function unpairDevices(recorderId, modemId) {
if (!confirm(`Unpair ${recorderId} from ${modemId}?`)) return;
try {
const response = await fetch('/api/roster/unpair-devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recorder_id: recorderId,
modem_id: modemId
})
});
const result = await response.json();
if (response.ok) {
showToast(`Unpaired ${recorderId} from ${modemId}`, 'success');
// Save scroll positions before reload
saveScrollPositions();
setTimeout(() => window.location.reload(), 500);
} else {
showToast(result.detail || 'Failed to unpair devices', 'error');
}
} catch (error) {
showToast('Error unpairing devices: ' + error.message, 'error');
}
}
function showToast(message, type = 'info') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = 'fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg transform transition-all duration-300 z-50';
if (type === 'success') {
toast.classList.add('bg-green-500', 'text-white');
} else if (type === 'error') {
toast.classList.add('bg-red-500', 'text-white');
} else {
toast.classList.add('bg-gray-800', 'text-white');
}
// Show
toast.classList.remove('translate-y-full', 'opacity-0');
// Hide after 3 seconds
setTimeout(() => {
toast.classList.add('translate-y-full', 'opacity-0');
}, 3000);
}
</script>
<style>
.bg-seismo-orange\/10 {
background-color: rgb(249 115 22 / 0.1);
}
.dark\:bg-seismo-orange\/20:is(.dark *) {
background-color: rgb(249 115 22 / 0.2);
}
</style>
{% endblock %}

View File

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

View File

@@ -1,89 +1,127 @@
<!-- Modem List -->
{% if modems %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% 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">
<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">
{{ modem.id }}
</a>
{% if modem.hardware_model %}
<span class="text-xs text-gray-500 dark:text-gray-400">{{ modem.hardware_model }}</span>
<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 %}
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
<td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center gap-2">
<a href="/unit/{{ modem.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
{{ modem.id }}
</a>
{% if modem.hardware_model %}
<span class="text-xs text-gray-500 dark:text-gray-400">({{ modem.hardware_model }})</span>
{% endif %}
</div>
</td>
<td class="px-4 py-3 whitespace-nowrap">
{% if modem.status == "retired" %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
Retired
</span>
{% elif modem.status == "benched" %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
Benched
</span>
{% elif modem.status == "in_use" %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
In Use
</span>
{% elif modem.status == "spare" %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
Spare
</span>
{% else %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
</span>
{% endif %}
</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 -->
{% 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>
{% 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>
{% 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>
{% 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>
{% endif %}
</div>
<!-- Paired Device -->
{% 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">
{{ modem.paired_device.id }}
<span class="text-gray-500">({{ modem.paired_device.device_type }})</span>
</a>
</div>
{% endif %}
<!-- Location if available -->
{% if modem.location or modem.project_id %}
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{% if modem.project_id %}
<span class="bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 rounded">{{ modem.project_id }}</span>
{% endif %}
{% if modem.location %}
{{ modem.location }}
{% endif %}
</div>
{% endif %}
<!-- Quick Actions -->
<div class="mt-3 flex 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">
Ping
</button>
<a href="/unit/{{ modem.id }}"
class="text-xs px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-300 rounded transition-colors">
Details
</a>
</div>
<!-- Ping Result (hidden by default) -->
<div id="ping-result-{{ modem.id }}" class="mt-2 text-xs hidden"></div>
</div>
{% endfor %}
</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 %}
<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 dark:text-gray-400">({{ modem.paired_device.device_type }})</span>
</a>
{% else %}
<span class="text-gray-400 dark:text-gray-600">None</span>
{% endif %}
</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 text-xs mr-1">{{ modem.project_id }}</span>
{% endif %}
{% if 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 %}
</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-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-blue-600 dark:text-blue-400 hover:underline">
View →
</a>
</div>
<!-- Ping Result (hidden by default) -->
<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 %}

View File

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

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

View File

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

View File

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

View File

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

View File

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