diff --git a/CHANGELOG.md b/CHANGELOG.md index a2bedec..4bf82e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,59 @@ All notable changes to Seismo Fleet Manager will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.4.1] - 2026 1-5 -### Added -- **SLM integration**: Sound Level meters are now managable in SFM +## [0.4.2] - 2026-01-05 -## Fixed ## -- Fixed an issue where unit status wat loading from a saved cache and not based on when it was actually heard from last. Unit status is now accurate. +### Added +- **SLM Configuration Interface**: Sound Level Meters can now be configured directly from the SLM dashboard + - Configuration modal with comprehensive SLM parameter editing + - TCP port configuration for SLM control connections (default: 2255) + - FTP port configuration for SLM data retrieval (default: 21) + - Modem assignment for network access or direct IP connection support + - Test Modem button with ping-based connectivity verification (shows IP and response time) + - Test SLM Connection button for end-to-end connectivity validation + - Dynamic form fields that hide/show based on modem selection +- **SLM Dashboard Endpoints**: New API routes for SLM management + - `GET /api/slm-dashboard/config/{unit_id}` - Load SLM configuration form + - `POST /api/slm-dashboard/config/{unit_id}` - Save SLM configuration + - `GET /api/slm-dashboard/test-modem/{modem_id}` - Ping modem for connectivity test +- **Database Schema Updates**: Added `slm_ftp_port` column to roster table + - Migration script: `scripts/add_slm_ftp_port.py` + - Supports both TCP (control) and FTP (data) port configuration per SLM unit +- **Docker Environment Enhancements**: + - Added `iputils-ping` and `curl` packages to Docker image for network diagnostics + - Health check endpoint support via curl + +### Fixed +- **Form Validation**: Fixed 400 Bad Request error when adding modem units + - Form fields for device-specific parameters now properly disabled when hidden + - Empty string values for integer fields no longer cause validation failures + - JavaScript now disables hidden form sections to prevent unwanted data submission +- **Unit Status Accuracy**: Fixed issue where unit status was loading from a saved cache instead of actual last-heard time + - Unit status now accurately reflects real-time connectivity + - Status determination based on actual `slm_last_check` timestamp + +### Changed +- **Roster Form Behavior**: Device-specific form fields are now disabled (not just hidden) when not applicable + - Prevents SLM fields from submitting when adding modems + - Prevents modem fields from submitting when adding SLMs + - Cleaner form submissions with only relevant data +- **Port Field Handling**: Backend now accepts port fields as strings and converts to integers + - Handles empty string values gracefully + - Proper type conversion with None fallback for empty values + +### Technical Details +- Added `setFieldsDisabled()` helper function for managing form field state +- Updated `toggleDeviceFields()` and `toggleEditDeviceFields()` to disable/enable fields +- Backend type conversion: `slm_tcp_port` and `slm_ftp_port` accept strings, convert to int with empty string handling +- Modem ping uses subprocess with 1 packet, 2-second timeout, returns response time in milliseconds +- Configuration form uses 3-column grid layout for TCP Port, FTP Port, and Direct IP fields + +## [0.4.1] - 2026-01-05 +### Added +- **SLM Integration**: Sound Level Meters are now manageable in SFM + +### Fixed +- Fixed an issue where unit status was loading from a saved cache and not based on when it was actually heard from last. Unit status is now accurate. ## [0.4.0] - 2025-12-16 @@ -301,6 +348,8 @@ No database migration required for v0.4.0. All new features use existing databas - Photo management per unit - Automated status categorization (OK/Pending/Missing) +[0.4.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.1...v0.4.2 +[0.4.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.0...v0.4.1 [0.4.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.3...v0.4.0 [0.3.3]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.2...v0.3.3 [0.3.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.1...v0.3.2 diff --git a/Dockerfile b/Dockerfile index d567991..3486caa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,11 @@ FROM python:3.11-slim # Set working directory WORKDIR /app +# Install system dependencies (ping for network diagnostics) +RUN apt-get update && \ + apt-get install -y --no-install-recommends iputils-ping curl && \ + rm -rf /var/lib/apt/lists/* + # Copy requirements first for better caching COPY requirements.txt . diff --git a/README.md b/README.md index 513e01b..e3d447b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Seismo Fleet Manager v0.4.1 +# Seismo Fleet Manager v0.4.2 Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard. ## Features diff --git a/backend/main.py b/backend/main.py index ed55a49..6442ab3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,15 +1,24 @@ import os +import logging from fastapi import FastAPI, Request, Depends from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse, FileResponse, JSONResponse +from fastapi.exceptions import RequestValidationError from sqlalchemy.orm import Session from typing import List, Dict from pydantic import BaseModel +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + from backend.database import engine, Base, get_db -from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs, activity, slmm, slm_ui +from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard from backend.services.snapshot import emit_status_snapshot from backend.models import IgnoredUnit @@ -20,13 +29,23 @@ Base.metadata.create_all(bind=engine) ENVIRONMENT = os.getenv("ENVIRONMENT", "production") # Initialize FastAPI app -VERSION = "0.4.0" +VERSION = "0.4.2" app = FastAPI( title="Seismo Fleet Manager", description="Backend API for managing seismograph fleet status", version=VERSION ) +# Add validation error handler to log details +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + logger.error(f"Validation error on {request.url}: {exc.errors()}") + logger.error(f"Body: {await request.body()}") + return JSONResponse( + status_code=400, + content={"detail": exc.errors()} + ) + # Configure CORS app.add_middleware( CORSMiddleware, @@ -70,6 +89,8 @@ app.include_router(dashboard_tabs.router) app.include_router(activity.router) app.include_router(slmm.router) app.include_router(slm_ui.router) +app.include_router(slm_dashboard.router) +app.include_router(seismo_dashboard.router) from backend.routers import settings app.include_router(settings.router) @@ -109,6 +130,18 @@ async def settings_page(request: Request): return templates.TemplateResponse("settings.html", {"request": request}) +@app.get("/sound-level-meters", response_class=HTMLResponse) +async def sound_level_meters_page(request: Request): + """Sound Level Meters management dashboard""" + return templates.TemplateResponse("sound_level_meters.html", {"request": request}) + + +@app.get("/seismographs", response_class=HTMLResponse) +async def seismographs_page(request: Request): + """Seismographs management dashboard""" + return templates.TemplateResponse("seismographs.html", {"request": request}) + + # ===== PWA ROUTES ===== @app.get("/sw.js") @@ -358,6 +391,127 @@ async def unknown_emitters_partial(request: Request): }) +@app.get("/partials/devices-all", response_class=HTMLResponse) +async def devices_all_partial(request: Request): + """Unified partial template for ALL devices with comprehensive filtering support""" + from datetime import datetime + snapshot = emit_status_snapshot() + + units_list = [] + + # Add deployed/active units + for unit_id, unit_data in snapshot["active"].items(): + units_list.append({ + "id": unit_id, + "status": unit_data.get("status", "Unknown"), + "age": unit_data.get("age", "N/A"), + "last_seen": unit_data.get("last", "Never"), + "deployed": True, + "retired": False, + "ignored": False, + "note": unit_data.get("note", ""), + "device_type": unit_data.get("device_type", "seismograph"), + "address": unit_data.get("address", ""), + "coordinates": unit_data.get("coordinates", ""), + "project_id": unit_data.get("project_id", ""), + "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"), + "ip_address": unit_data.get("ip_address"), + "phone_number": unit_data.get("phone_number"), + "hardware_model": unit_data.get("hardware_model"), + }) + + # Add benched units + for unit_id, unit_data in snapshot["benched"].items(): + units_list.append({ + "id": unit_id, + "status": unit_data.get("status", "N/A"), + "age": unit_data.get("age", "N/A"), + "last_seen": unit_data.get("last", "Never"), + "deployed": False, + "retired": False, + "ignored": False, + "note": unit_data.get("note", ""), + "device_type": unit_data.get("device_type", "seismograph"), + "address": unit_data.get("address", ""), + "coordinates": unit_data.get("coordinates", ""), + "project_id": unit_data.get("project_id", ""), + "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"), + "ip_address": unit_data.get("ip_address"), + "phone_number": unit_data.get("phone_number"), + "hardware_model": unit_data.get("hardware_model"), + }) + + # Add retired units + for unit_id, unit_data in snapshot["retired"].items(): + units_list.append({ + "id": unit_id, + "status": "Retired", + "age": "N/A", + "last_seen": "N/A", + "deployed": False, + "retired": True, + "ignored": False, + "note": unit_data.get("note", ""), + "device_type": unit_data.get("device_type", "seismograph"), + "address": unit_data.get("address", ""), + "coordinates": unit_data.get("coordinates", ""), + "project_id": unit_data.get("project_id", ""), + "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"), + "ip_address": unit_data.get("ip_address"), + "phone_number": unit_data.get("phone_number"), + "hardware_model": unit_data.get("hardware_model"), + }) + + # Add ignored units + for unit_id, unit_data in snapshot.get("ignored", {}).items(): + units_list.append({ + "id": unit_id, + "status": "Ignored", + "age": "N/A", + "last_seen": "N/A", + "deployed": False, + "retired": False, + "ignored": True, + "note": unit_data.get("note", unit_data.get("reason", "")), + "device_type": unit_data.get("device_type", "unknown"), + "address": "", + "coordinates": "", + "project_id": "", + "last_calibrated": None, + "next_calibration_due": None, + "deployed_with_modem_id": None, + "ip_address": None, + "phone_number": None, + "hardware_model": None, + }) + + # Sort by status category, then by ID + def sort_key(unit): + # Priority: deployed (active) -> benched -> retired -> ignored + if unit["deployed"]: + return (0, unit["id"]) + elif not unit["retired"] and not unit["ignored"]: + return (1, unit["id"]) + elif unit["retired"]: + return (2, unit["id"]) + else: + return (3, unit["id"]) + + units_list.sort(key=sort_key) + + return templates.TemplateResponse("partials/devices_table.html", { + "request": request, + "units": units_list, + "timestamp": datetime.now().strftime("%H:%M:%S") + }) + + @app.get("/health") def health_check(): """Health check endpoint""" diff --git a/backend/models.py b/backend/models.py index e80c49c..b872a40 100644 --- a/backend/models.py +++ b/backend/models.py @@ -39,7 +39,9 @@ class RosterUnit(Base): # Seismograph-specific fields (nullable for modems and SLMs) last_calibrated = Column(Date, nullable=True) next_calibration_due = Column(Date, nullable=True) - deployed_with_modem_id = Column(String, nullable=True) # FK to another RosterUnit + + # Modem assignment (shared by seismographs and SLMs) + deployed_with_modem_id = Column(String, nullable=True) # FK to another RosterUnit (device_type=modem) # Modem-specific fields (nullable for seismographs and SLMs) ip_address = Column(String, nullable=True) @@ -49,6 +51,7 @@ class RosterUnit(Base): # Sound Level Meter-specific fields (nullable for seismographs and modems) slm_host = Column(String, nullable=True) # Device IP or hostname slm_tcp_port = Column(Integer, nullable=True) # TCP control port (default 2255) + slm_ftp_port = Column(Integer, nullable=True) # FTP data retrieval port (default 21) slm_model = Column(String, nullable=True) # NL-43, NL-53, etc. slm_serial_number = Column(String, nullable=True) # Device serial number slm_frequency_weighting = Column(String, nullable=True) # A, C, Z diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py index a495885..dd0c192 100644 --- a/backend/routers/roster_edit.py +++ b/backend/routers/roster_edit.py @@ -1,13 +1,21 @@ -from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File +from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File, Request +from fastapi.exceptions import RequestValidationError from sqlalchemy.orm import Session from datetime import datetime, date import csv import io +import logging +import httpx +import os from backend.database import get_db from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory router = APIRouter(prefix="/api/roster", tags=["roster-edit"]) +logger = logging.getLogger(__name__) + +# SLMM backend URL for syncing device configs to cache +SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") def record_history(db: Session, unit_id: str, change_type: str, field_name: str = None, @@ -37,13 +45,98 @@ def get_or_create_roster_unit(db: Session, unit_id: str): return unit +async def sync_slm_to_slmm_cache( + unit_id: str, + host: str = None, + tcp_port: int = None, + ftp_port: int = None, + ftp_username: str = None, + ftp_password: str = None, + deployed_with_modem_id: str = None, + db: Session = None +) -> dict: + """ + Sync SLM device configuration to SLMM backend cache. + + Terra-View is the source of truth for device configs. This function updates + SLMM's config cache (NL43Config table) so SLMM can look up device connection + info by unit_id without Terra-View passing host:port with every request. + + Args: + unit_id: Unique identifier for the SLM device + host: Direct IP address/hostname OR will be resolved from modem + tcp_port: TCP control port (default: 2255) + ftp_port: FTP port (default: 21) + ftp_username: FTP username (optional) + ftp_password: FTP password (optional) + deployed_with_modem_id: If set, resolve modem IP as host + db: Database session for modem lookup + + Returns: + dict: {"success": bool, "message": str} + """ + # Resolve host from modem if assigned + if deployed_with_modem_id and db: + modem = db.query(RosterUnit).filter_by( + id=deployed_with_modem_id, + device_type="modem" + ).first() + if modem and modem.ip_address: + host = modem.ip_address + logger.info(f"Resolved host from modem {deployed_with_modem_id}: {host}") + + # Validate required fields + if not host: + logger.warning(f"Cannot sync SLM {unit_id} to SLMM: no host/IP address provided") + return {"success": False, "message": "No host IP address available"} + + # Set defaults + tcp_port = tcp_port or 2255 + ftp_port = ftp_port or 21 + + # Build SLMM cache payload + config_payload = { + "host": host, + "tcp_port": tcp_port, + "tcp_enabled": True, + "ftp_enabled": bool(ftp_username and ftp_password), + "web_enabled": False + } + + if ftp_username and ftp_password: + config_payload["ftp_username"] = ftp_username + config_payload["ftp_password"] = ftp_password + + # Call SLMM cache update API + slmm_url = f"{SLMM_BASE_URL}/api/nl43/{unit_id}/config" + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.put(slmm_url, json=config_payload) + + if response.status_code in [200, 201]: + logger.info(f"Successfully synced SLM {unit_id} to SLMM cache") + return {"success": True, "message": "Device config cached in SLMM"} + else: + logger.error(f"SLMM cache sync failed for {unit_id}: HTTP {response.status_code}") + return {"success": False, "message": f"SLMM returned status {response.status_code}"} + + except httpx.ConnectError: + logger.error(f"Cannot connect to SLMM service at {SLMM_BASE_URL}") + return {"success": False, "message": "SLMM service unavailable"} + + except Exception as e: + logger.error(f"Error syncing SLM {unit_id} to SLMM: {e}") + return {"success": False, "message": str(e)} + + @router.post("/add") -def add_roster_unit( +async def add_roster_unit( id: str = Form(...), device_type: str = Form("seismograph"), unit_type: str = Form("series3"), - deployed: bool = Form(False), - retired: bool = Form(False), + deployed: str = Form(None), + retired: str = Form(None), note: str = Form(""), project_id: str = Form(None), location: str = Form(None), @@ -57,8 +150,27 @@ def add_roster_unit( ip_address: str = Form(None), phone_number: str = Form(None), hardware_model: str = Form(None), + # Sound Level Meter-specific fields + slm_host: str = Form(None), + slm_tcp_port: str = Form(None), + slm_ftp_port: str = Form(None), + slm_model: str = Form(None), + slm_serial_number: str = Form(None), + slm_frequency_weighting: str = Form(None), + slm_time_weighting: str = Form(None), + slm_measurement_range: str = Form(None), db: Session = Depends(get_db) ): + logger.info(f"Adding unit: id={id}, device_type={device_type}, deployed={deployed}, retired={retired}") + + # Convert boolean strings to actual booleans + deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False + retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False + + # Convert port strings to integers + slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None + slm_ftp_port_int = int(slm_ftp_port) if slm_ftp_port and slm_ftp_port.strip() else None + if db.query(RosterUnit).filter(RosterUnit.id == id).first(): raise HTTPException(status_code=400, detail="Unit already exists") @@ -81,8 +193,8 @@ def add_roster_unit( id=id, device_type=device_type, unit_type=unit_type, - deployed=deployed, - retired=retired, + deployed=deployed_bool, + retired=retired_bool, note=note, project_id=project_id, location=location, @@ -97,12 +209,56 @@ def add_roster_unit( ip_address=ip_address if ip_address else None, phone_number=phone_number if phone_number else None, hardware_model=hardware_model if hardware_model else None, + # Sound Level Meter-specific fields + slm_host=slm_host if slm_host else None, + slm_tcp_port=slm_tcp_port_int, + slm_ftp_port=slm_ftp_port_int, + slm_model=slm_model if slm_model else None, + slm_serial_number=slm_serial_number if slm_serial_number else None, + slm_frequency_weighting=slm_frequency_weighting if slm_frequency_weighting else None, + slm_time_weighting=slm_time_weighting if slm_time_weighting else None, + slm_measurement_range=slm_measurement_range if slm_measurement_range else None, ) db.add(unit) db.commit() + + # If sound level meter, sync config to SLMM cache + if device_type == "sound_level_meter": + logger.info(f"Syncing SLM {id} config to SLMM cache...") + result = await sync_slm_to_slmm_cache( + unit_id=id, + host=slm_host, + tcp_port=slm_tcp_port_int, + ftp_port=slm_ftp_port_int, + deployed_with_modem_id=deployed_with_modem_id, + db=db + ) + + if not result["success"]: + logger.warning(f"SLMM cache sync warning for {id}: {result['message']}") + # Don't fail the operation - device is still added to Terra-View roster + # User can manually sync later or SLMM will be synced on next config update + return {"message": "Unit added", "id": id, "device_type": device_type} +@router.get("/modems") +def get_modems_list(db: Session = Depends(get_db)): + """Get list of all modem units for dropdown selection""" + modems = db.query(RosterUnit).filter_by(device_type="modem", retired=False).order_by(RosterUnit.id).all() + + return [ + { + "id": modem.id, + "ip_address": modem.ip_address, + "phone_number": modem.phone_number, + "hardware_model": modem.hardware_model, + "deployed": modem.deployed + } + for modem in modems + ] + + @router.get("/{unit_id}") def get_roster_unit(unit_id: str, db: Session = Depends(get_db)): """Get a single roster unit by ID""" @@ -127,6 +283,14 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)): "ip_address": unit.ip_address or "", "phone_number": unit.phone_number or "", "hardware_model": unit.hardware_model or "", + "slm_host": unit.slm_host or "", + "slm_tcp_port": unit.slm_tcp_port or "", + "slm_ftp_port": unit.slm_ftp_port or "", + "slm_model": unit.slm_model or "", + "slm_serial_number": unit.slm_serial_number or "", + "slm_frequency_weighting": unit.slm_frequency_weighting or "", + "slm_time_weighting": unit.slm_time_weighting or "", + "slm_measurement_range": unit.slm_measurement_range or "", } @@ -135,8 +299,8 @@ def edit_roster_unit( unit_id: str, device_type: str = Form("seismograph"), unit_type: str = Form("series3"), - deployed: bool = Form(False), - retired: bool = Form(False), + deployed: str = Form(None), + retired: str = Form(None), note: str = Form(""), project_id: str = Form(None), location: str = Form(None), @@ -150,12 +314,29 @@ def edit_roster_unit( ip_address: str = Form(None), phone_number: str = Form(None), hardware_model: str = Form(None), + # Sound Level Meter-specific fields + slm_host: str = Form(None), + slm_tcp_port: str = Form(None), + slm_ftp_port: str = Form(None), + slm_model: str = Form(None), + slm_serial_number: str = Form(None), + slm_frequency_weighting: str = Form(None), + slm_time_weighting: str = Form(None), + slm_measurement_range: str = Form(None), db: Session = Depends(get_db) ): unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first() if not unit: raise HTTPException(status_code=404, detail="Unit not found") + # Convert boolean strings to actual booleans + deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False + retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False + + # Convert port strings to integers + slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None + slm_ftp_port_int = int(slm_ftp_port) if slm_ftp_port and slm_ftp_port.strip() else None + # Parse date fields if provided last_cal_date = None if last_calibrated: @@ -179,8 +360,8 @@ def edit_roster_unit( # Update all fields unit.device_type = device_type unit.unit_type = unit_type - unit.deployed = deployed - unit.retired = retired + unit.deployed = deployed_bool + unit.retired = retired_bool unit.note = note unit.project_id = project_id unit.location = location @@ -198,6 +379,16 @@ def edit_roster_unit( unit.phone_number = phone_number if phone_number else None unit.hardware_model = hardware_model if hardware_model else None + # Sound Level Meter-specific fields + unit.slm_host = slm_host if slm_host else None + unit.slm_tcp_port = slm_tcp_port_int + unit.slm_ftp_port = slm_ftp_port_int + unit.slm_model = slm_model if slm_model else None + unit.slm_serial_number = slm_serial_number if slm_serial_number else None + unit.slm_frequency_weighting = slm_frequency_weighting if slm_frequency_weighting else None + unit.slm_time_weighting = slm_time_weighting if slm_time_weighting else None + unit.slm_measurement_range = slm_measurement_range if slm_measurement_range else None + # Record history entries for changed fields if old_note != note: record_history(db, unit_id, "note_change", "note", old_note, note, "manual") diff --git a/backend/routers/seismo_dashboard.py b/backend/routers/seismo_dashboard.py new file mode 100644 index 0000000..e54a814 --- /dev/null +++ b/backend/routers/seismo_dashboard.py @@ -0,0 +1,81 @@ +""" +Seismograph Dashboard API Router +Provides endpoints for the seismograph-specific dashboard +""" + +from fastapi import APIRouter, Request, Depends, Query +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session +from backend.database import get_db +from backend.models import RosterUnit + +router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"]) +templates = Jinja2Templates(directory="templates") + + +@router.get("/stats", response_class=HTMLResponse) +async def get_seismo_stats(request: Request, db: Session = Depends(get_db)): + """ + Returns HTML partial with seismograph statistics summary + """ + # Get all seismograph units + seismos = db.query(RosterUnit).filter_by( + device_type="seismograph", + retired=False + ).all() + + total = len(seismos) + deployed = sum(1 for s in seismos if s.deployed) + benched = sum(1 for s in seismos if not s.deployed) + + # Count modems assigned to deployed seismographs + with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id) + without_modem = deployed - with_modem + + return templates.TemplateResponse( + "partials/seismo_stats.html", + { + "request": request, + "total": total, + "deployed": deployed, + "benched": benched, + "with_modem": with_modem, + "without_modem": without_modem + } + ) + + +@router.get("/units", response_class=HTMLResponse) +async def get_seismo_units( + request: Request, + db: Session = Depends(get_db), + search: str = Query(None) +): + """ + Returns HTML partial with filterable seismograph unit list + """ + query = db.query(RosterUnit).filter_by( + device_type="seismograph", + retired=False + ) + + # Apply search filter + if search: + search_lower = search.lower() + query = query.filter( + (RosterUnit.id.ilike(f"%{search}%")) | + (RosterUnit.note.ilike(f"%{search}%")) | + (RosterUnit.address.ilike(f"%{search}%")) + ) + + seismos = query.order_by(RosterUnit.id).all() + + return templates.TemplateResponse( + "partials/seismo_unit_list.html", + { + "request": request, + "units": seismos, + "search": search or "" + } + ) diff --git a/backend/routers/slm_dashboard.py b/backend/routers/slm_dashboard.py new file mode 100644 index 0000000..3d9c0df --- /dev/null +++ b/backend/routers/slm_dashboard.py @@ -0,0 +1,328 @@ +""" +SLM Dashboard Router + +Provides API endpoints for the Sound Level Meters dashboard page. +""" + +from fastapi import APIRouter, Request, Depends, Query +from fastapi.templating import Jinja2Templates +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session +from sqlalchemy import func +from datetime import datetime, timedelta +import httpx +import logging +import os + +from backend.database import get_db +from backend.models import RosterUnit +from backend.routers.roster_edit import sync_slm_to_slmm_cache + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"]) +templates = Jinja2Templates(directory="templates") + +# SLMM backend URL - configurable via environment variable +SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") + + +@router.get("/stats", response_class=HTMLResponse) +async def get_slm_stats(request: Request, db: Session = Depends(get_db)): + """ + Get summary statistics for SLM dashboard. + Returns HTML partial with stat cards. + """ + # Query all SLMs + all_slms = db.query(RosterUnit).filter_by(device_type="sound_level_meter").all() + + # Count deployed vs benched + deployed_count = sum(1 for slm in all_slms if slm.deployed and not slm.retired) + benched_count = sum(1 for slm in all_slms if not slm.deployed and not slm.retired) + retired_count = sum(1 for slm in all_slms if slm.retired) + + # Count recently active (checked in last hour) + one_hour_ago = datetime.utcnow() - timedelta(hours=1) + active_count = sum(1 for slm in all_slms + if slm.slm_last_check and slm.slm_last_check > one_hour_ago) + + return templates.TemplateResponse("partials/slm_stats.html", { + "request": request, + "total_count": len(all_slms), + "deployed_count": deployed_count, + "benched_count": benched_count, + "active_count": active_count, + "retired_count": retired_count + }) + + +@router.get("/units", response_class=HTMLResponse) +async def get_slm_units( + request: Request, + db: Session = Depends(get_db), + search: str = Query(None) +): + """ + Get list of SLM units for the sidebar. + Returns HTML partial with unit cards. + """ + query = db.query(RosterUnit).filter_by(device_type="sound_level_meter") + + # Filter by search term if provided + if search: + search_term = f"%{search}%" + query = query.filter( + (RosterUnit.id.like(search_term)) | + (RosterUnit.slm_model.like(search_term)) | + (RosterUnit.address.like(search_term)) + ) + + # Only show deployed units by default + units = query.filter_by(deployed=True, retired=False).order_by(RosterUnit.id).all() + + return templates.TemplateResponse("partials/slm_unit_list.html", { + "request": request, + "units": units + }) + + +@router.get("/live-view/{unit_id}", response_class=HTMLResponse) +async def get_live_view(request: Request, unit_id: str, db: Session = Depends(get_db)): + """ + Get live view panel for a specific SLM unit. + Returns HTML partial with live metrics and chart. + """ + # Get unit from database + unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first() + + if not unit: + return templates.TemplateResponse("partials/slm_live_view_error.html", { + "request": request, + "error": f"Unit {unit_id} not found" + }) + + # Get modem information if assigned + modem = None + modem_ip = None + if unit.deployed_with_modem_id: + modem = db.query(RosterUnit).filter_by(id=unit.deployed_with_modem_id, device_type="modem").first() + if modem: + modem_ip = modem.ip_address + else: + logger.warning(f"SLM {unit_id} is assigned to modem {unit.deployed_with_modem_id} but modem not found") + + # Fallback to direct slm_host if no modem assigned (backward compatibility) + if not modem_ip and unit.slm_host: + modem_ip = unit.slm_host + logger.info(f"Using legacy slm_host for {unit_id}: {modem_ip}") + + # Try to get current status from SLMM + current_status = None + measurement_state = None + is_measuring = False + + try: + async with httpx.AsyncClient(timeout=5.0) as client: + # Get measurement state + state_response = await client.get( + f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state" + ) + if state_response.status_code == 200: + state_data = state_response.json() + measurement_state = state_data.get("measurement_state", "Unknown") + is_measuring = state_data.get("is_measuring", False) + + # Get live status + status_response = await client.get( + f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live" + ) + if status_response.status_code == 200: + status_data = status_response.json() + current_status = status_data.get("data", {}) + except Exception as e: + logger.error(f"Failed to get status for {unit_id}: {e}") + + return templates.TemplateResponse("partials/slm_live_view.html", { + "request": request, + "unit": unit, + "modem": modem, + "modem_ip": modem_ip, + "current_status": current_status, + "measurement_state": measurement_state, + "is_measuring": is_measuring + }) + + +@router.post("/control/{unit_id}/{action}") +async def control_slm(unit_id: str, action: str): + """ + Send control commands to SLM (start, stop, pause, resume, reset). + Proxies to SLMM backend. + """ + valid_actions = ["start", "stop", "pause", "resume", "reset"] + + if action not in valid_actions: + return {"status": "error", "detail": f"Invalid action. Must be one of: {valid_actions}"} + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + f"{SLMM_BASE_URL}/api/nl43/{unit_id}/{action}" + ) + + if response.status_code == 200: + return response.json() + else: + return { + "status": "error", + "detail": f"SLMM returned status {response.status_code}" + } + except Exception as e: + logger.error(f"Failed to control {unit_id}: {e}") + return { + "status": "error", + "detail": str(e) + } + +@router.get("/config/{unit_id}", response_class=HTMLResponse) +async def get_slm_config(request: Request, unit_id: str, db: Session = Depends(get_db)): + """ + Get configuration form for a specific SLM unit. + Returns HTML partial with configuration form. + """ + unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first() + + if not unit: + return HTMLResponse( + content=f'
Unit {unit_id} not found
', + status_code=404 + ) + + return templates.TemplateResponse("partials/slm_config_form.html", { + "request": request, + "unit": unit + }) + + +@router.post("/config/{unit_id}") +async def save_slm_config(request: Request, unit_id: str, db: Session = Depends(get_db)): + """ + Save SLM configuration. + Updates unit parameters in the database. + """ + unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first() + + if not unit: + return {"status": "error", "detail": f"Unit {unit_id} not found"} + + try: + # Get form data + form_data = await request.form() + + # Update SLM-specific fields + unit.slm_model = form_data.get("slm_model") or None + unit.slm_serial_number = form_data.get("slm_serial_number") or None + unit.slm_frequency_weighting = form_data.get("slm_frequency_weighting") or None + unit.slm_time_weighting = form_data.get("slm_time_weighting") or None + unit.slm_measurement_range = form_data.get("slm_measurement_range") or None + + # Update network configuration + modem_id = form_data.get("deployed_with_modem_id") + unit.deployed_with_modem_id = modem_id if modem_id else None + + # Always update TCP and FTP ports (used regardless of modem assignment) + unit.slm_tcp_port = int(form_data.get("slm_tcp_port")) if form_data.get("slm_tcp_port") else None + unit.slm_ftp_port = int(form_data.get("slm_ftp_port")) if form_data.get("slm_ftp_port") else None + + # Only update direct IP if no modem is assigned + if not modem_id: + unit.slm_host = form_data.get("slm_host") or None + else: + # Clear legacy direct IP field when modem is assigned + unit.slm_host = None + + db.commit() + logger.info(f"Updated configuration for SLM {unit_id}") + + # Sync updated configuration to SLMM cache + logger.info(f"Syncing SLM {unit_id} config changes to SLMM cache...") + result = await sync_slm_to_slmm_cache( + unit_id=unit_id, + host=unit.slm_host, # Use the updated host from Terra-View + tcp_port=unit.slm_tcp_port, + ftp_port=unit.slm_ftp_port, + deployed_with_modem_id=unit.deployed_with_modem_id, # Resolve modem IP if assigned + db=db + ) + + if not result["success"]: + logger.warning(f"SLMM cache sync warning for {unit_id}: {result['message']}") + # Config still saved in Terra-View (source of truth) + + return {"status": "success", "unit_id": unit_id} + + except Exception as e: + db.rollback() + logger.error(f"Failed to save config for {unit_id}: {e}") + return {"status": "error", "detail": str(e)} + + +@router.get("/test-modem/{modem_id}") +async def test_modem_connection(modem_id: str, db: Session = Depends(get_db)): + """ + Test modem connectivity with a simple ping/health check. + Returns response time and connection status. + """ + import subprocess + import time + + # Get modem from database + 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"} + + if not modem.ip_address: + return {"status": "error", "detail": f"Modem {modem_id} has no IP address configured"} + + try: + # Ping the modem (1 packet, 2 second timeout) + start_time = time.time() + result = subprocess.run( + ["ping", "-c", "1", "-W", "2", modem.ip_address], + capture_output=True, + text=True, + timeout=3 + ) + response_time = int((time.time() - start_time) * 1000) # Convert to milliseconds + + if result.returncode == 0: + return { + "status": "success", + "modem_id": modem_id, + "ip_address": modem.ip_address, + "response_time": response_time, + "message": "Modem is responding to ping" + } + else: + return { + "status": "error", + "modem_id": modem_id, + "ip_address": modem.ip_address, + "detail": "Modem not responding to ping" + } + + except subprocess.TimeoutExpired: + return { + "status": "error", + "modem_id": modem_id, + "ip_address": modem.ip_address, + "detail": "Ping timeout (> 2 seconds)" + } + except Exception as e: + logger.error(f"Failed to ping modem {modem_id}: {e}") + return { + "status": "error", + "modem_id": modem_id, + "detail": str(e) + } diff --git a/backend/routers/slmm.py b/backend/routers/slmm.py index b075637..1c73f5e 100644 --- a/backend/routers/slmm.py +++ b/backend/routers/slmm.py @@ -5,9 +5,11 @@ Proxies requests from SFM to the standalone SLMM backend service. SLMM runs on port 8100 and handles NL43/NL53 sound level meter communication. """ -from fastapi import APIRouter, HTTPException, Request, Response +from fastapi import APIRouter, HTTPException, Request, Response, WebSocket, WebSocketDisconnect from fastapi.responses import StreamingResponse import httpx +import websockets +import asyncio import logging import os @@ -17,6 +19,8 @@ router = APIRouter(prefix="/api/slmm", tags=["slmm"]) # SLMM backend URL - configurable via environment variable SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") +# WebSocket URL derived from HTTP URL +SLMM_WS_BASE_URL = SLMM_BASE_URL.replace("http://", "ws://").replace("https://", "wss://") @router.get("/health") @@ -61,6 +65,173 @@ async def check_slmm_health(): } +# WebSocket routes MUST come before the catch-all route +@router.websocket("/{unit_id}/stream") +async def proxy_websocket_stream(websocket: WebSocket, unit_id: str): + """ + Proxy WebSocket connections to SLMM's /stream endpoint. + + This allows real-time streaming of measurement data from NL43 devices + through the SFM unified interface. + """ + await websocket.accept() + logger.info(f"WebSocket connection accepted for SLMM unit {unit_id}") + + # Build target WebSocket URL + target_ws_url = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/stream" + logger.info(f"Connecting to SLMM WebSocket: {target_ws_url}") + + backend_ws = None + + try: + # Connect to SLMM backend WebSocket + backend_ws = await websockets.connect(target_ws_url) + logger.info(f"Connected to SLMM backend WebSocket for {unit_id}") + + # Create tasks for bidirectional communication + async def forward_to_backend(): + """Forward messages from client to SLMM backend""" + try: + while True: + data = await websocket.receive_text() + await backend_ws.send(data) + except WebSocketDisconnect: + logger.info(f"Client WebSocket disconnected for {unit_id}") + except Exception as e: + logger.error(f"Error forwarding to backend: {e}") + + async def forward_to_client(): + """Forward messages from SLMM backend to client""" + try: + async for message in backend_ws: + await websocket.send_text(message) + except websockets.exceptions.ConnectionClosed: + logger.info(f"Backend WebSocket closed for {unit_id}") + except Exception as e: + logger.error(f"Error forwarding to client: {e}") + + # Run both forwarding tasks concurrently + await asyncio.gather( + forward_to_backend(), + forward_to_client(), + return_exceptions=True + ) + + except websockets.exceptions.WebSocketException as e: + logger.error(f"WebSocket error connecting to SLMM backend: {e}") + try: + await websocket.send_json({ + "error": "Failed to connect to SLMM backend", + "detail": str(e) + }) + except Exception: + pass + except Exception as e: + logger.error(f"Unexpected error in WebSocket proxy for {unit_id}: {e}") + try: + await websocket.send_json({ + "error": "Internal server error", + "detail": str(e) + }) + except Exception: + pass + finally: + # Clean up connections + if backend_ws: + try: + await backend_ws.close() + except Exception: + pass + try: + await websocket.close() + except Exception: + pass + logger.info(f"WebSocket proxy closed for {unit_id}") + + +@router.websocket("/{unit_id}/live") +async def proxy_websocket_live(websocket: WebSocket, unit_id: str): + """ + Proxy WebSocket connections to SLMM's /live endpoint. + + Alternative WebSocket endpoint that may be used by some frontend components. + """ + await websocket.accept() + logger.info(f"WebSocket connection accepted for SLMM unit {unit_id} (live endpoint)") + + # Build target WebSocket URL - try /stream endpoint as SLMM uses that for WebSocket + target_ws_url = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/stream" + logger.info(f"Connecting to SLMM WebSocket: {target_ws_url}") + + backend_ws = None + + try: + # Connect to SLMM backend WebSocket + backend_ws = await websockets.connect(target_ws_url) + logger.info(f"Connected to SLMM backend WebSocket for {unit_id} (live endpoint)") + + # Create tasks for bidirectional communication + async def forward_to_backend(): + """Forward messages from client to SLMM backend""" + try: + while True: + data = await websocket.receive_text() + await backend_ws.send(data) + except WebSocketDisconnect: + logger.info(f"Client WebSocket disconnected for {unit_id} (live)") + except Exception as e: + logger.error(f"Error forwarding to backend (live): {e}") + + async def forward_to_client(): + """Forward messages from SLMM backend to client""" + try: + async for message in backend_ws: + await websocket.send_text(message) + except websockets.exceptions.ConnectionClosed: + logger.info(f"Backend WebSocket closed for {unit_id} (live)") + except Exception as e: + logger.error(f"Error forwarding to client (live): {e}") + + # Run both forwarding tasks concurrently + await asyncio.gather( + forward_to_backend(), + forward_to_client(), + return_exceptions=True + ) + + except websockets.exceptions.WebSocketException as e: + logger.error(f"WebSocket error connecting to SLMM backend (live): {e}") + try: + await websocket.send_json({ + "error": "Failed to connect to SLMM backend", + "detail": str(e) + }) + except Exception: + pass + except Exception as e: + logger.error(f"Unexpected error in WebSocket proxy for {unit_id} (live): {e}") + try: + await websocket.send_json({ + "error": "Internal server error", + "detail": str(e) + }) + except Exception: + pass + finally: + # Clean up connections + if backend_ws: + try: + await backend_ws.close() + except Exception: + pass + try: + await websocket.close() + except Exception: + pass + logger.info(f"WebSocket proxy closed for {unit_id} (live)") + + +# HTTP catch-all route MUST come after specific routes (including WebSocket routes) @router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) async def proxy_to_slmm(path: str, request: Request): """ diff --git a/docker-compose.yml b/docker-compose.yml index cb16f59..ddb9e1d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,9 @@ services: - # --- PRODUCTION --- - seismo-backend: + # --- TERRA-VIEW PRODUCTION --- + terra-view-prod: build: . - container_name: seismo-fleet-manager + container_name: terra-view ports: - "8001:8001" volumes: @@ -11,8 +11,10 @@ services: environment: - PYTHONUNBUFFERED=1 - ENVIRONMENT=production - - SLMM_BASE_URL=http://172.19.0.1:8100 + - SLMM_BASE_URL=http://slmm:8100 restart: unless-stopped + depends_on: + - slmm healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8001/health"] interval: 30s @@ -20,10 +22,10 @@ services: retries: 3 start_period: 40s - # --- DEVELOPMENT --- - sfm-dev: + # --- TERRA-VIEW DEVELOPMENT --- + terra-view-dev: build: . - container_name: sfm-dev + container_name: terra-view-dev ports: - "1001:8001" volumes: @@ -31,7 +33,10 @@ services: environment: - PYTHONUNBUFFERED=1 - ENVIRONMENT=development + - SLMM_BASE_URL=http://slmm:8100 restart: unless-stopped + depends_on: + - slmm healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8001/health"] interval: 30s @@ -39,6 +44,28 @@ services: retries: 3 start_period: 40s + # --- SLMM (Sound Level Meter Manager) --- + slmm: + build: + context: ../../slmm + dockerfile: Dockerfile + container_name: slmm + ports: + - "8100:8100" + volumes: + - ../../slmm/data:/app/data + environment: + - PYTHONUNBUFFERED=1 + - PORT=8100 + - CORS_ORIGINS=* + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8100/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + volumes: data: data-dev: diff --git a/docs/DEVICE_TYPE_DASHBOARDS.md b/docs/DEVICE_TYPE_DASHBOARDS.md new file mode 100644 index 0000000..e6c8913 --- /dev/null +++ b/docs/DEVICE_TYPE_DASHBOARDS.md @@ -0,0 +1,276 @@ +# Device Type Dashboards + +This document describes the separate dashboard system for different device types in SFM. + +## Overview + +SFM now has dedicated dashboards for each device type: +- **Main Dashboard** (`/`) - Combined overview of all devices +- **Seismographs Dashboard** (`/seismographs`) - Seismograph-specific view +- **Sound Level Meters Dashboard** (`/sound-level-meters`) - SLM-specific view +- **Fleet Roster** (`/roster`) - All devices with filtering and management + +## Architecture + +### 1. Main Dashboard + +**Route**: `/` +**Template**: [templates/dashboard.html](../templates/dashboard.html) +**Purpose**: Combined overview showing all device types + +**Features**: +- Fleet summary card now includes device type breakdown +- Shows count of seismographs and SLMs separately +- Links to dedicated dashboards for each device type +- Shared components: map, alerts, recent photos, fleet status + +**Device Type Counts**: +The dashboard calculates device type counts in JavaScript from the snapshot data: + +```javascript +// Device type counts +let seismoCount = 0; +let slmCount = 0; +Object.values(data.units || {}).forEach(unit => { + if (unit.retired) return; // Don't count retired units + const deviceType = unit.device_type || 'seismograph'; + if (deviceType === 'seismograph') { + seismoCount++; + } else if (deviceType === 'sound_level_meter') { + slmCount++; + } +}); +``` + +### 2. Seismographs Dashboard + +**Route**: `/seismographs` +**Template**: [templates/seismographs.html](../templates/seismographs.html) +**Router**: [backend/routers/seismo_dashboard.py](../backend/routers/seismo_dashboard.py) + +**API Endpoints**: +- `GET /api/seismo-dashboard/stats` - Statistics summary (HTML partial) +- `GET /api/seismo-dashboard/units?search=` - Unit list with search (HTML partial) + +**Features**: +- Statistics cards (total, deployed, benched, with/without modem) +- Searchable unit list with real-time filtering +- Shows modem assignments +- Links to individual unit detail pages + +**Stats Calculation** ([backend/routers/seismo_dashboard.py:18](../backend/routers/seismo_dashboard.py#L18)): + +```python +seismos = db.query(RosterUnit).filter_by( + device_type="seismograph", + retired=False +).all() + +total = len(seismos) +deployed = sum(1 for s in seismos if s.deployed) +benched = sum(1 for s in seismos if not s.deployed) +with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id) +``` + +### 3. Sound Level Meters Dashboard + +**Route**: `/sound-level-meters` +**Template**: [templates/sound_level_meters.html](../templates/sound_level_meters.html) +**Router**: [backend/routers/slm_dashboard.py](../backend/routers/slm_dashboard.py) + +**API Endpoints**: +- `GET /api/slm-dashboard/stats` - Statistics summary (HTML partial) +- `GET /api/slm-dashboard/units?search=` - Unit list with search (HTML partial) +- `GET /api/slm-dashboard/live-view/{unit_id}` - Live view panel (HTML partial) + +**Features**: +- Statistics cards (total, deployed, benched, measuring) +- Searchable unit list +- Live view panel with real-time measurement data +- WebSocket integration for DRD streaming +- Shows modem assignments and IP resolution + +See [SOUND_LEVEL_METERS_DASHBOARD.md](SOUND_LEVEL_METERS_DASHBOARD.md) for detailed SLM dashboard documentation. + +## Navigation + +The sidebar navigation ([templates/base.html:116-128](../templates/base.html#L116-L128)) includes links to both dashboards: + +```html + + ... + Seismographs + + + + ... + Sound Level Meters + +``` + +Active page highlighting is automatic based on `request.url.path`. + +## Database Queries + +All dashboards filter by device type using SQLAlchemy: + +### Seismographs Query +```python +seismos = db.query(RosterUnit).filter_by( + device_type="seismograph", + retired=False +).all() +``` + +### Sound Level Meters Query +```python +slms = db.query(RosterUnit).filter_by( + device_type="sound_level_meter", + retired=False +).all() +``` + +### With Search Filter +```python +query = db.query(RosterUnit).filter_by( + device_type="seismograph", + retired=False +) + +if search: + query = query.filter( + (RosterUnit.id.ilike(f"%{search}%")) | + (RosterUnit.note.ilike(f"%{search}%")) | + (RosterUnit.address.ilike(f"%{search}%")) + ) +``` + +## UI Components + +### Stats Cards (Partials) + +**Seismograph Stats**: [templates/partials/seismo_stats.html](../templates/partials/seismo_stats.html) +- Total Seismographs +- Deployed +- Benched +- With Modem (showing X / Y deployed) + +**SLM Stats**: [templates/partials/slm_stats.html](../templates/partials/slm_stats.html) +- Total SLMs +- Deployed +- Benched +- Currently Measuring (from live status) + +### Unit Lists (Partials) + +**Seismograph List**: [templates/partials/seismo_unit_list.html](../templates/partials/seismo_unit_list.html) + +Table columns: +- Unit ID (link to detail page) +- Status (Deployed/Benched badge) +- Modem (link to modem unit) +- Location (address or coordinates) +- Notes +- Actions (View Details link) + +**SLM List**: [templates/partials/slm_unit_list.html](../templates/partials/slm_unit_list.html) + +Table columns: +- Unit ID (link to detail page) +- Model (NL-43, NL-53) +- Status (Deployed/Benched badge) +- Modem (link to modem unit) +- Location +- Actions (View Details, Live View) + +## HTMX Integration + +Both dashboards use HTMX for dynamic updates: + +### Auto-refresh Stats +```html +
+``` + +### Search with Debouncing +```html + +``` + +## Adding New Device Types + +To add support for a new device type (e.g., "modem"): + +1. **Create Router** (`backend/routers/modem_dashboard.py`): +```python +@router.get("/stats", response_class=HTMLResponse) +async def get_modem_stats(request: Request, db: Session = Depends(get_db)): + modems = db.query(RosterUnit).filter_by( + device_type="modem", + retired=False + ).all() + # Calculate stats and return partial +``` + +2. **Create Templates**: + - `templates/modems.html` - Main dashboard page + - `templates/partials/modem_stats.html` - Stats cards + - `templates/partials/modem_unit_list.html` - Unit list table + +3. **Register in main.py**: +```python +from backend.routers import modem_dashboard +app.include_router(modem_dashboard.router) + +@app.get("/modems", response_class=HTMLResponse) +async def modems_page(request: Request): + return templates.TemplateResponse("modems.html", {"request": request}) +``` + +4. **Add to Navigation** (`templates/base.html`): +```html + + ... + Modems + +``` + +5. **Update Main Dashboard** (`templates/dashboard.html`): +Add modem count to the device type breakdown: +```html +
+ Modems + -- +
+``` + +Update JavaScript to count modems: +```javascript +let modemCount = 0; +Object.values(data.units || {}).forEach(unit => { + if (unit.device_type === 'modem' && !unit.retired) { + modemCount++; + } +}); +document.getElementById('modem-count').textContent = modemCount; +``` + +## Benefits + +1. **Separation of Concerns**: Each device type has its own dedicated interface +2. **Scalability**: Easy to add new device types following the established pattern +3. **Performance**: Queries are filtered by device type, reducing data transfer +4. **User Experience**: Users can focus on specific device types without clutter +5. **Maintainability**: Each dashboard is self-contained and easy to modify + +## Related Documentation + +- [SOUND_LEVEL_METERS_DASHBOARD.md](SOUND_LEVEL_METERS_DASHBOARD.md) - SLM dashboard details +- [DEVICE_TYPE_SLM_SUPPORT.md](DEVICE_TYPE_SLM_SUPPORT.md) - Adding SLM device type support +- [MODEM_INTEGRATION.md](MODEM_INTEGRATION.md) - Modem assignment architecture diff --git a/docs/DEVICE_TYPE_SLM_SUPPORT.md b/docs/DEVICE_TYPE_SLM_SUPPORT.md new file mode 100644 index 0000000..1c0fd2d --- /dev/null +++ b/docs/DEVICE_TYPE_SLM_SUPPORT.md @@ -0,0 +1,159 @@ +# Sound Level Meter Device Type Support + +## Overview + +Added full support for "Sound Level Meter" as a device type in the roster management system. Users can now create, edit, and manage SLM units through the Fleet Roster interface. + +## Changes Made + +### 1. Frontend - Unit Detail Page + +**File**: `templates/unit_detail.html` + +#### Added Device Type Option +- Added "Sound Level Meter" option to device type dropdown (line 243) +- Value: `sound_level_meter` + +#### Added SLM-Specific Fields Section (lines 320-370) +New form fields for Sound Level Meter configuration: + +- **Host (IP Address)**: Device network address + - Field name: `slm_host` + - Example: `192.168.1.100` + +- **TCP Port**: Control port (default 2255) + - Field name: `slm_tcp_port` + - Type: number + +- **Model**: Device model designation + - Field name: `slm_model` + - Example: `NL-43`, `NL-53` + +- **Serial Number**: Manufacturer serial number + - Field name: `slm_serial_number` + +- **Frequency Weighting**: Sound measurement weighting curve + - Field name: `slm_frequency_weighting` + - Options: A-weighting, C-weighting, Z-weighting (Flat) + +- **Time Weighting**: Temporal averaging method + - Field name: `slm_time_weighting` + - Options: Fast (125ms), Slow (1s), Impulse (35ms) + +- **Measurement Range**: Device measurement capability + - Field name: `slm_measurement_range` + - Example: `30-130 dB` + +#### Updated JavaScript Functions + +**toggleDetailFields()** (lines 552-571) +- Now handles three device types: seismograph, modem, sound_level_meter +- Hides all device-specific sections, then shows only the relevant one +- Shows `slmFields` section when device type is `sound_level_meter` + +**populateEditForm()** (lines 527-558) +- Populates all 7 SLM-specific fields from unit data +- Sets empty string as default if field is null + +### 2. Backend - API Endpoints + +**File**: `backend/routers/roster_edit.py` + +#### Updated Add Unit Endpoint +`POST /api/roster/add` + +**New Parameters** (lines 61-67): +- `slm_host`: str (optional) +- `slm_tcp_port`: int (optional) +- `slm_model`: str (optional) +- `slm_serial_number`: str (optional) +- `slm_frequency_weighting`: str (optional) +- `slm_time_weighting`: str (optional) +- `slm_measurement_range`: str (optional) + +**Unit Creation** (lines 108-115): +All SLM fields are set when creating new unit. + +#### Updated Get Unit Endpoint +`GET /api/roster/{unit_id}` + +**New Response Fields** (lines 146-152): +Returns all 7 SLM fields in the response, with empty string as default if null. + +#### Updated Edit Unit Endpoint +`POST /api/roster/edit/{unit_id}` + +**New Parameters** (lines 177-183): +Same 7 SLM-specific parameters as add endpoint. + +**Unit Update** (lines 232-239): +All SLM fields are updated when editing existing unit. + +### 3. Database Schema + +**File**: `backend/models.py` + +The database schema already included SLM fields (no changes needed): +- All fields are nullable to support multiple device types +- Fields are only relevant when `device_type = "sound_level_meter"` + +## Usage + +### Creating a New SLM Unit + +1. Go to Fleet Roster page +2. Click "Add Unit" or edit an existing unit +3. Select "Sound Level Meter" from Device Type dropdown +4. Fill in SLM-specific fields (Host, Port, Model, etc.) +5. Save + +### Converting Existing Unit to SLM + +1. Open unit detail page +2. Click "Edit Unit" +3. Change Device Type to "Sound Level Meter" +4. SLM fields section will appear +5. Fill in required SLM configuration +6. Save changes + +### Field Visibility + +The form automatically shows/hides relevant fields based on device type: +- **Seismograph**: Shows calibration dates, modem deployment info +- **Modem**: Shows IP, phone number, hardware model +- **Sound Level Meter**: Shows host, port, model, serial, weightings, range + +## Integration with SLMM Dashboard + +Units with `device_type = "sound_level_meter"` will: +- Appear in the Sound Level Meters dashboard (`/sound-level-meters`) +- Be available for live monitoring and control +- Use the configured `slm_host` and `slm_tcp_port` for device communication + +## Testing + +Test SLM units have been added via `add_test_slms.py`: +- `nl43-001` - Deployed at construction site A +- `nl43-002` - Deployed at construction site B +- `nl53-001` - Deployed at residential area +- `nl43-003` - Benched for calibration + +You can edit any of these units to verify the form works correctly. + +## Files Modified + +1. `templates/unit_detail.html` - Added dropdown option, SLM fields section, updated JavaScript +2. `backend/routers/roster_edit.py` - Added SLM parameters to add/edit/get endpoints +3. `backend/models.py` - No changes (schema already supported SLM) + +## Backward Compatibility + +- Existing seismograph and modem units are unaffected +- All SLM fields are optional/nullable +- Forms gracefully handle units with missing device_type (defaults to seismograph) + +--- + +**Version**: 1.0.0 +**Date**: January 5, 2026 +**Related**: SOUND_LEVEL_METERS_DASHBOARD.md diff --git a/docs/DEV_DATABASE_SETUP.md b/docs/DEV_DATABASE_SETUP.md new file mode 100644 index 0000000..7d5c897 --- /dev/null +++ b/docs/DEV_DATABASE_SETUP.md @@ -0,0 +1,132 @@ +# DEV Database Setup Instructions + +## Current Situation + +The test SLM and modem data was accidentally added to the **PRODUCTION** database (`data/seismo_fleet.db`). + +**Good news**: I've already removed it! The production database is clean. + +**Issue**: The DEV database (`data-dev/seismo_fleet.db`) is: +1. Owned by root (read-only for your user) +2. Missing the SLM-specific columns in its schema + +## What You Need to Do + +### Step 1: Fix DEV Database Permissions + +Run this command to make the DEV database writable: + +```bash +cd /home/serversdown/sfm/seismo-fleet-manager +sudo chown serversdown:serversdown data-dev/seismo_fleet.db +sudo chmod 664 data-dev/seismo_fleet.db +``` + +### Step 2: Migrate DEV Database Schema + +Add the SLM columns to the DEV database: + +```bash +python3 scripts/migrate_dev_db.py +``` + +This will add these columns to the `roster` table: +- `slm_host` +- `slm_tcp_port` +- `slm_model` +- `slm_serial_number` +- `slm_frequency_weighting` +- `slm_time_weighting` +- `slm_measurement_range` +- `slm_last_check` + +### Step 3: Add Test Data to DEV + +Now you can safely add test data to the DEV database: + +```bash +# Add test SLMs +python3 scripts/add_test_slms.py + +# Add test modems and assign to SLMs +python3 scripts/add_test_modems.py +``` + +This will create: +- 4 test SLM units (nl43-001, nl43-002, nl53-001, nl43-003) +- 4 test modem units (modem-001, modem-002, modem-003, modem-004) +- Assign modems to the SLMs + +## Production Database Status + +✅ **Production database is CLEAN** - all test data has been removed. + +The production database (`data/seismo_fleet.db`) is ready for real production use. + +## Test Scripts + +All test scripts have been updated to use the DEV database: + +### Scripts Updated: +1. `scripts/add_test_slms.py` - Now uses `data-dev/seismo_fleet.db` +2. `scripts/add_test_modems.py` - Now uses `data-dev/seismo_fleet.db` + +### Scripts Created: +1. `scripts/remove_test_data_from_prod.py` - Removes test data from production (already run) +2. `scripts/update_dev_db_schema.py` - Updates schema (doesn't work for SQLite ALTER) +3. `scripts/migrate_dev_db.py` - Adds SLM columns to DEV database + +All helper scripts are located in the `scripts/` directory. See [scripts/README.md](../scripts/README.md) for detailed usage instructions. + +## Verification + +After running the steps above, verify everything worked: + +```bash +# Check DEV database has test data +sqlite3 data-dev/seismo_fleet.db "SELECT id, device_type FROM roster WHERE device_type IN ('sound_level_meter', 'modem');" +``` + +You should see: +``` +nl43-001|sound_level_meter +nl43-002|sound_level_meter +nl53-001|sound_level_meter +nl43-003|sound_level_meter +modem-001|modem +modem-002|modem +modem-003|modem +modem-004|modem +``` + +## Development vs Production + +### When to Use DEV Database + +To use the DEV database, set the environment variable: + +```bash +# Not implemented yet, but you could add this to database.py: +export DATABASE_ENV=dev +``` + +Or modify `backend/database.py` to check for an environment variable. + +### Current Setup + +Right now, the application always uses `data/seismo_fleet.db` (production). + +For development/testing, you could: +1. Point SFM to use the DEV database temporarily +2. Or keep test data in production (not recommended) +3. Or implement environment-based database selection + +## Summary + +- ✅ Production DB cleaned (no test data) +- ⚠️ DEV DB needs permission fix (run sudo commands above) +- ⚠️ DEV DB needs schema migration (run scripts/migrate_dev_db.py) +- ✅ Test scripts updated to use DEV DB +- ✅ All test data ready to be added to DEV DB + +Run the commands above and you'll be all set! diff --git a/docs/FIX_DEV_PERMISSIONS.md b/docs/FIX_DEV_PERMISSIONS.md new file mode 100644 index 0000000..311f277 --- /dev/null +++ b/docs/FIX_DEV_PERMISSIONS.md @@ -0,0 +1,62 @@ +# Fix DEV Database Permissions + +## The Problem + +SQLite needs write access to both the database file AND the directory it's in (to create temporary files like journals and WAL files). + +Currently: +- ✅ Database file: `data-dev/seismo_fleet.db` - permissions fixed +- ❌ Directory: `data-dev/` - still owned by root + +## The Fix + +Run this command to fix the directory ownership: + +```bash +sudo chown -R serversdown:serversdown /home/serversdown/sfm/seismo-fleet-manager/data-dev/ +``` + +Then run the migration again: + +```bash +python3 scripts/migrate_dev_db.py +``` + +## Full Setup Commands + +Here's the complete sequence: + +```bash +cd /home/serversdown/sfm/seismo-fleet-manager + +# Fix directory ownership (includes all files inside) +sudo chown -R serversdown:serversdown data-dev/ + +# Migrate schema +python3 scripts/migrate_dev_db.py + +# Add test data +python3 scripts/add_test_slms.py +python3 scripts/add_test_modems.py +``` + +## Verify It Worked + +After running the migration, you should see: + +``` +Migrating DEV database to add SLM columns... +============================================================ +✓ Added column: slm_host +✓ Added column: slm_tcp_port +✓ Added column: slm_model +✓ Added column: slm_serial_number +✓ Added column: slm_frequency_weighting +✓ Added column: slm_time_weighting +✓ Added column: slm_measurement_range +✓ Added column: slm_last_check +============================================================ +DEV database migration completed! +``` + +Then the test data scripts should work without errors! diff --git a/docs/MODEM_INTEGRATION.md b/docs/MODEM_INTEGRATION.md new file mode 100644 index 0000000..b0e5586 --- /dev/null +++ b/docs/MODEM_INTEGRATION.md @@ -0,0 +1,375 @@ +# Modem Integration System + +## Overview + +The modem integration system allows Sound Level Meters (SLMs) and Seismographs to be deployed with network connectivity modems. Instead of storing IP addresses directly on each device, units are assigned to modems which provide the network connection. This enables: + +- Centralized modem management and tracking +- IP address updates in one place +- Future modem API integration for diagnostics +- Proper asset tracking for network equipment + +## Architecture + +### Database Design + +**Modem Units**: Stored as `RosterUnit` with `device_type = "modem"` + +**Modem-Specific Fields**: +- `ip_address`: Network IP address or hostname +- `phone_number`: Cellular phone number (if applicable) +- `hardware_model`: Modem hardware (e.g., "Raven XTV", "Sierra Wireless AirLink") + +**Device Assignment**: +- Both SLMs and Seismographs use `deployed_with_modem_id` to reference their modem +- This is a foreign key to another `RosterUnit.id` where `device_type = "modem"` + +### How It Works + +1. **Create Modem Units** + - Add modems as regular roster units with `device_type = "modem"` + - Set IP address, phone number, and hardware model + - Deploy or bench modems like any other asset + +2. **Assign Devices to Modems** + - When editing an SLM or Seismograph, select modem from dropdown + - The `deployed_with_modem_id` field stores the modem ID + - IP address is fetched from the assigned modem at runtime + +3. **Runtime Resolution** + - When SLM dashboard needs to connect to a device: + 1. Load SLM unit data + 2. Check `deployed_with_modem_id` + 3. Fetch modem unit + 4. Use modem's `ip_address` for connection + 5. Fallback to legacy `slm_host` if no modem assigned + +## Implementation Details + +### Frontend Changes + +#### Unit Detail Page (`templates/unit_detail.html`) + +**SLM Fields** (lines 320-375): +- Removed direct "Host (IP Address)" field +- Added "Deployed With Modem" dropdown selector +- Dropdown populated with all active modems via JavaScript +- Shows modem ID, IP address, and hardware model in dropdown + +**JavaScript** (lines 456-485): +```javascript +async function loadModemsList() { + // Fetches all modems from /api/roster/modems + // Populates both seismograph and SLM modem dropdowns + // Shows format: "modem-001 (192.168.1.100) - Raven XTV" +} +``` + +#### SLM Dashboard (`backend/routers/slm_dashboard.py`) + +**Live View Endpoint** (lines 84-148): +```python +# Get modem information if assigned +modem = None +modem_ip = None +if unit.deployed_with_modem_id: + modem = db.query(RosterUnit).filter_by( + id=unit.deployed_with_modem_id, + device_type="modem" + ).first() + if modem: + modem_ip = modem.ip_address + +# Fallback to direct slm_host (backward compatibility) +if not modem_ip and unit.slm_host: + modem_ip = unit.slm_host +``` + +**Live View Template** (`templates/partials/slm_live_view.html`): +- Displays modem information in header +- Shows "via Modem: modem-001 (192.168.1.100)" +- Warning if no modem assigned + +### Backend Changes + +#### New API Endpoint (`backend/routers/roster_edit.py`) + +```python +GET /api/roster/modems +``` + +Returns list of all non-retired modem units: +```json +[ + { + "id": "modem-001", + "ip_address": "192.168.1.100", + "phone_number": "+1-555-0100", + "hardware_model": "Raven XTV", + "deployed": true + } +] +``` + +Used by frontend to populate modem selection dropdowns. + +#### Database Schema (`backend/models.py`) + +**Modem Assignment Field** (line 44): +```python +# Shared by seismographs and SLMs +deployed_with_modem_id = Column(String, nullable=True) +``` + +**Modem Fields** (lines 46-48): +```python +ip_address = Column(String, nullable=True) +phone_number = Column(String, nullable=True) +hardware_model = Column(String, nullable=True) +``` + +**Legacy SLM Fields** (kept for backward compatibility): +```python +slm_host = Column(String, nullable=True) # Deprecated - use modem instead +slm_tcp_port = Column(Integer, nullable=True) # Still used +``` + +## Usage + +### Creating Modem Units + +1. Go to Fleet Roster +2. Click "Add Unit" +3. Set Device Type to "Modem" +4. Fill in: + - Unit ID (e.g., "modem-001") + - IP Address (e.g., "192.168.1.100") + - Phone Number (if cellular) + - Hardware Model (e.g., "Raven XTV") + - Address/Coordinates (physical location) +5. Set Deployed status +6. Save + +### Assigning Modem to SLM + +1. Open SLM unit detail page +2. Click "Edit Unit" +3. Ensure Device Type is "Sound Level Meter" +4. In "Deployed With Modem" dropdown, select modem +5. Verify TCP Port (default 2255) +6. Save + +### Assigning Modem to Seismograph + +Same process - both device types use the same modem selection field. + +## Test Data + +Use the included script to create test modems: + +```bash +python3 add_test_modems.py +``` + +This creates: +- **modem-001**: 192.168.1.100, Raven XTV → assigned to nl43-001 +- **modem-002**: 192.168.1.101, Raven XTV → assigned to nl43-002 +- **modem-003**: 192.168.1.102, Sierra Wireless → assigned to nl53-001 +- **modem-004**: Spare modem (not deployed) + +## Benefits + +### For Operations + +1. **Centralized IP Management** + - Update modem IP once, affects all assigned devices + - Easy to track which modem serves which devices + - Inventory management for network equipment + +2. **Asset Tracking** + - Modems are first-class assets in the roster + - Track deployment status, location, notes + - Can bench/retire modems independently + +3. **Future Capabilities** + - Modem API integration (signal strength, data usage) + - Automatic IP updates from DHCP/cellular network + - Modem health monitoring + - Remote modem diagnostics + +### For Maintenance + +1. **Easier Troubleshooting** + - See which modem serves a device + - Check modem status separately + - Swap modems without reconfiguring devices + +2. **Configuration Changes** + - Change IP addresses system-wide + - Move devices between modems + - Test with backup modems + +## Migration from Legacy System + +### For Existing SLMs with Direct IP + +Legacy SLMs with `slm_host` set still work: +- System checks `deployed_with_modem_id` first +- Falls back to `slm_host` if no modem assigned +- Logs fallback usage for visibility + +### Migration Steps + +1. Create modem units for each IP address +2. Assign SLMs to their modems +3. System will use modem IP automatically +4. Legacy `slm_host` can be cleared (optional) + +Script `add_test_modems.py` demonstrates this: +```python +# Clear legacy field after modem assignment +slm.slm_host = None +slm.deployed_with_modem_id = "modem-001" +``` + +## Future Enhancements + +### Near-term + +1. **Modem Status Dashboard** + - List all modems with connection status + - Show which devices use each modem + - Signal strength, data usage indicators + +2. **Automatic IP Discovery** + - Query cellular provider API for modem IPs + - Auto-update IP addresses in database + - Alert on IP changes + +3. **Modem Health Monitoring** + - Ping modems periodically + - Check cellular signal quality + - Data usage tracking + +### Long-term + +1. **Modem API Integration** + - Direct modem management (Raven, Sierra APIs) + - Remote reboot capability + - Configuration backup/restore + - Firmware updates + +2. **Network Topology View** + - Graphical view of modem-device relationships + - Network health visualization + - Troubleshooting tools + +3. **Multi-Modem Support** + - Failover between modems + - Load balancing + - Automatic fallback on modem failure + +## API Reference + +### Get Modems List + +**Endpoint**: `GET /api/roster/modems` + +**Response**: +```json +[ + { + "id": "modem-001", + "ip_address": "192.168.1.100", + "phone_number": "+1-555-0100", + "hardware_model": "Raven XTV", + "deployed": true + } +] +``` + +**Used By**: +- Unit detail page (modem dropdown) +- Future modem management dashboard + +### Get Unit with Modem Info + +**Endpoint**: `GET /api/roster/{unit_id}` + +**Response** (for SLM): +```json +{ + "id": "nl43-001", + "device_type": "sound_level_meter", + "deployed_with_modem_id": "modem-001", + "slm_tcp_port": 2255, + "slm_model": "NL-43", + ... +} +``` + +Then fetch modem separately or use dashboard endpoint which resolves it automatically. + +### SLM Live View (with modem resolution) + +**Endpoint**: `GET /api/slm-dashboard/live-view/{unit_id}` + +**Process**: +1. Loads SLM unit +2. Resolves `deployed_with_modem_id` to modem unit +3. Extracts `modem.ip_address` +4. Uses IP to connect to SLMM backend +5. Returns live view HTML with modem info + +## Files Modified/Created + +### Modified +1. `backend/models.py` - Clarified modem assignment field +2. `templates/unit_detail.html` - Added modem selector for SLMs +3. `backend/routers/roster_edit.py` - Added modems list endpoint +4. `backend/routers/slm_dashboard.py` - Modem resolution logic +5. `templates/partials/slm_live_view.html` - Display modem info + +### Created +1. `add_test_modems.py` - Script to create test modems and assignments +2. `docs/MODEM_INTEGRATION.md` - This documentation + +## Troubleshooting + +### SLM shows "No modem assigned" + +**Cause**: Unit has no `deployed_with_modem_id` set + +**Solution**: +1. Edit the SLM unit +2. Select a modem from dropdown +3. Save + +### Modem dropdown is empty + +**Cause**: No modem units in database + +**Solution**: +1. Create modem units first +2. Set `device_type = "modem"` +3. Ensure they're not retired + +### Can't connect to SLM + +**Possible Causes**: +1. Modem IP is incorrect +2. Modem is offline +3. SLM TCP port is wrong +4. Network routing issue + +**Debug Steps**: +1. Check modem unit's IP address +2. Ping the modem IP +3. Check SLM's `slm_tcp_port` (default 2255) +4. Review logs for connection errors + +--- + +**Version**: 1.0.0 +**Date**: January 5, 2026 +**Related**: SOUND_LEVEL_METERS_DASHBOARD.md, DEVICE_TYPE_SLM_SUPPORT.md diff --git a/docs/SLM_CONFIGURATION.md b/docs/SLM_CONFIGURATION.md new file mode 100644 index 0000000..873d041 --- /dev/null +++ b/docs/SLM_CONFIGURATION.md @@ -0,0 +1,275 @@ +# SLM Configuration Interface + +This document describes the SLM configuration interface added to the Sound Level Meters dashboard. + +## Overview + +Sound Level Meters can now be configured directly from the dashboard without needing to navigate to the unit detail page. A configuration button appears on each SLM unit card on hover, opening a modal with all configurable parameters. + +## Features + +### 1. Quick Access Configuration Button + +- **Location**: Appears on each SLM unit card in the unit list +- **Behavior**: Shows on hover (desktop) or always visible (mobile) +- **Icon**: Gear/settings icon in top-right corner of unit card + +### 2. Configuration Modal + +The configuration modal provides a comprehensive interface for all SLM parameters: + +#### Device Information +- **Model**: Dropdown selection (NL-43, NL-53) +- **Serial Number**: Text input for device serial number + +#### Measurement Parameters +- **Frequency Weighting**: A, C, or Z (Linear) +- **Time Weighting**: Fast (125ms), Slow (1s), or Impulse +- **Measurement Range**: 30-130 dB, 40-140 dB, or 50-140 dB + +#### Network Configuration +- **Assigned Modem**: Dropdown list of available modems + - Shows modem ID and IP address + - Option for "No modem (direct connection)" +- **Direct IP Address**: Only shown when no modem assigned +- **TCP Port**: Only shown when no modem assigned (default: 502) + +### 3. Actions + +The modal provides three action buttons: + +- **Test Connection**: Tests network connectivity to the SLM + - Uses current form values (not saved values) + - Shows toast notification with results + - Green: Connection successful + - Yellow: Connection failed or device offline + - Red: Test error + +- **Cancel**: Closes modal without saving changes + +- **Save Configuration**: Saves all changes to database + - Shows success/error toast + - Refreshes unit list on success + - Auto-closes modal after 2 seconds + +## Implementation + +### Frontend Components + +#### Unit List Partial +**File**: [templates/partials/slm_unit_list.html](../templates/partials/slm_unit_list.html) + +```html + + +``` + +#### Configuration Modal +**File**: [templates/sound_level_meters.html](../templates/sound_level_meters.html#L73) + +```html + + +``` + +#### Configuration Form +**File**: [templates/partials/slm_config_form.html](../templates/partials/slm_config_form.html) + +Form fields mapped to database columns: +- `slm_model` → `unit.slm_model` +- `slm_serial_number` → `unit.slm_serial_number` +- `slm_frequency_weighting` → `unit.slm_frequency_weighting` +- `slm_time_weighting` → `unit.slm_time_weighting` +- `slm_measurement_range` → `unit.slm_measurement_range` +- `deployed_with_modem_id` → `unit.deployed_with_modem_id` +- `slm_host` → `unit.slm_host` (legacy, only if no modem) +- `slm_tcp_port` → `unit.slm_tcp_port` (legacy, only if no modem) + +### Backend Endpoints + +#### GET /api/slm-dashboard/config/{unit_id} +**File**: [backend/routers/slm_dashboard.py:184](../backend/routers/slm_dashboard.py#L184) + +Returns configuration form HTML partial with current unit values pre-populated. + +**Response**: HTML partial (slm_config_form.html) + +#### POST /api/slm-dashboard/config/{unit_id} +**File**: [backend/routers/slm_dashboard.py:203](../backend/routers/slm_dashboard.py#L203) + +Saves configuration changes to database. + +**Request**: Form data with configuration parameters + +**Response**: JSON +```json +{ + "status": "success", + "unit_id": "nl43-001" +} +``` + +**Behavior**: +- Updates all SLM-specific fields from form data +- If modem is assigned: clears legacy `slm_host` and `slm_tcp_port` +- If no modem: uses direct IP fields from form + +### JavaScript Functions + +#### openConfigModal(unitId) +**File**: [templates/sound_level_meters.html:127](../templates/sound_level_meters.html#L127) + +Opens configuration modal and loads form via HTMX. + +```javascript +function openConfigModal(unitId) { + const modal = document.getElementById('config-modal'); + modal.classList.remove('hidden'); + + htmx.ajax('GET', `/api/slm-dashboard/config/${unitId}`, { + target: '#config-modal-content', + swap: 'innerHTML' + }); +} +``` + +#### closeConfigModal() +**File**: [templates/sound_level_meters.html:136](../templates/sound_level_meters.html#L136) + +Closes configuration modal. + +#### handleConfigSave(event) +**File**: [templates/partials/slm_config_form.html:109](../templates/partials/slm_config_form.html#L109) + +Handles HTMX response after form submission: +- Shows success/error toast +- Refreshes unit list +- Auto-closes modal after 2 seconds + +#### testConnection(unitId) +**File**: [templates/partials/slm_config_form.html:129](../templates/partials/slm_config_form.html#L129) + +Tests connection to SLM unit: +```javascript +async function testConnection(unitId) { + const response = await fetch(`/api/slmm/${unitId}/status`); + const data = await response.json(); + + if (response.ok && data.status === 'online') { + // Show success toast + } else { + // Show warning toast + } +} +``` + +#### loadModemsForConfig() +**File**: [templates/partials/slm_config_form.html:87](../templates/partials/slm_config_form.html#L87) + +Loads available modems from `/api/roster/modems` and populates dropdown. + +## User Workflow + +### Configuring an SLM + +1. Navigate to Sound Level Meters dashboard ([/sound-level-meters](../sound-level-meters)) +2. Hover over desired SLM unit card in the list +3. Click the gear icon that appears in top-right corner +4. Configuration modal opens with current values pre-filled +5. Modify desired parameters: + - Update model/serial if needed + - Set measurement parameters (frequency/time weighting, range) + - Choose network configuration: + - **Option A**: Select a modem from dropdown (recommended) + - **Option B**: Enter direct IP address and port +6. (Optional) Click "Test Connection" to verify network settings +7. Click "Save Configuration" +8. Modal shows success message and auto-closes +9. Unit list refreshes to show updated information + +### Network Configuration Options + +**Modem Assignment (Recommended)**: +- Select modem from dropdown +- IP address automatically resolved from modem's `ip_address` field +- Direct IP/port fields hidden +- Enables modem tracking and management + +**Direct Connection (Legacy)**: +- Select "No modem (direct connection)" +- Enter IP address and TCP port manually +- Direct IP/port fields become visible +- Useful for temporary setups or non-modem connections + +## Database Schema + +The configuration interface updates these `roster` table columns: + +```sql +-- SLM-specific fields +slm_model VARCHAR -- Device model (NL-43, NL-53) +slm_serial_number VARCHAR -- Serial number +slm_frequency_weighting VARCHAR -- A, C, or Z weighting +slm_time_weighting VARCHAR -- Fast, Slow, or Impulse +slm_measurement_range VARCHAR -- Measurement range (30-130, 40-140, 50-140) + +-- Network configuration +deployed_with_modem_id VARCHAR -- FK to modem unit (preferred method) +slm_host VARCHAR -- Legacy direct IP (only if no modem) +slm_tcp_port INTEGER -- Legacy TCP port (only if no modem) +``` + +## UI/UX Design + +### Modal Behavior +- **Opens**: Via configure button on unit card +- **Closes**: + - Cancel button + - X button in header + - Escape key + - Clicking outside modal (on backdrop) +- **Auto-close**: After successful save (2 second delay) + +### Responsive Design +- **Desktop**: Configuration button appears on hover +- **Mobile**: Configuration button always visible +- **Modal**: Responsive width, scrollable on small screens +- **Form**: Two-column layout on desktop, single column on mobile + +### Visual Feedback +- **Loading**: Skeleton loader while form loads +- **Saving**: HTMX handles form submission +- **Success**: Green toast notification +- **Error**: Red toast notification +- **Testing**: Blue toast while testing, then green/yellow/red based on result + +### Accessibility +- **Keyboard**: Modal can be closed with Escape key +- **Focus**: Modal traps focus when open +- **Labels**: All form fields have proper labels +- **Colors**: Sufficient contrast in dark/light modes + +## Future Enhancements + +Potential improvements for future versions: + +1. **Bulk Configuration**: Configure multiple SLMs at once +2. **Configuration Templates**: Save and apply configuration presets +3. **Configuration History**: Track configuration changes over time +4. **Remote Configuration**: Push configuration directly to device via SLMM +5. **Validation**: Real-time validation of IP addresses and ports +6. **Advanced Settings**: Additional NL-43/NL-53 specific parameters +7. **Configuration Import/Export**: JSON/CSV configuration files + +## Related Documentation + +- [SOUND_LEVEL_METERS_DASHBOARD.md](SOUND_LEVEL_METERS_DASHBOARD.md) - Main SLM dashboard +- [MODEM_INTEGRATION.md](MODEM_INTEGRATION.md) - Modem assignment architecture +- [DEVICE_TYPE_SLM_SUPPORT.md](DEVICE_TYPE_SLM_SUPPORT.md) - SLM device type implementation diff --git a/docs/SOUND_LEVEL_METERS_DASHBOARD.md b/docs/SOUND_LEVEL_METERS_DASHBOARD.md new file mode 100644 index 0000000..9b00f62 --- /dev/null +++ b/docs/SOUND_LEVEL_METERS_DASHBOARD.md @@ -0,0 +1,333 @@ +# Sound Level Meters Dashboard + +## Overview + +The Sound Level Meters dashboard is a new feature in SFM (soon to be rebranded as Terra-view) that provides real-time monitoring and control of Rion NL-43/NL-53 sound level meters through the SLMM backend integration. + +## Features + +### 1. Dashboard Summary Statistics +- **Total Units**: Count of all SLM devices in the system +- **Deployed Units**: Active devices currently in the field +- **Active Now**: Units that have checked in within the last hour +- **Benched Units**: Devices not currently deployed + +### 2. Unit List (Sidebar) +- Searchable list of all deployed SLM units +- Real-time status indicators: + - 🟢 Green: Active (recently checked in) + - ⚪ Gray: No check-in data +- Quick unit information: + - Device model (NL-43, NL-53, etc.) + - Location/address + - Network address (IP:port) +- Click any unit to view its live data + +### 3. Live View Panel + +When a unit is selected, the live view panel displays: + +#### Control Buttons +- **Start**: Begin measurement +- **Pause**: Pause current measurement +- **Stop**: Stop measurement +- **Reset**: Reset measurement data +- **Start Live Stream**: Open WebSocket connection for real-time DRD data + +#### Real-time Metrics +- **Lp (Current)**: Instantaneous sound level in dB +- **Leq (Average)**: Equivalent continuous sound level +- **Lmax (Peak)**: Maximum sound level recorded +- **Lmin**: Minimum sound level recorded + +#### Live Chart +- Real-time line chart showing Lp and Leq over time +- 60-second rolling window (adjustable) +- Chart.js-powered visualization with dark mode support +- No animation for smooth real-time updates + +#### Device Information +- Battery level and power source +- Frequency weighting (A, C, Z) +- Time weighting (F, S, I) +- SD card remaining space + +## Architecture + +### Frontend Components + +#### Main Template +**File**: `templates/sound_level_meters.html` + +The main dashboard page that includes: +- Page header and navigation integration +- Stats summary section (auto-refreshes every 10s) +- Two-column layout: unit list (left) + live view (right) +- JavaScript functions for unit selection and WebSocket streaming + +#### Partial Templates + +1. **slm_stats.html** - Summary statistics cards + - Auto-loads on page load + - Refreshes every 10 seconds via HTMX + +2. **slm_unit_list.html** - Searchable unit list + - Auto-loads on page load + - Refreshes every 10 seconds via HTMX + - Supports search filtering + +3. **slm_live_view.html** - Live data panel for selected unit + - Loaded on-demand when unit is selected + - Includes Chart.js for visualization + - WebSocket connection for streaming data + +4. **slm_live_view_error.html** - Error state display + +### Backend Components + +#### Router: `backend/routers/slm_dashboard.py` + +**Endpoints:** + +```python +GET /api/slm-dashboard/stats +``` +Returns HTML partial with summary statistics. + +```python +GET /api/slm-dashboard/units?search={term} +``` +Returns HTML partial with filtered unit list. + +```python +GET /api/slm-dashboard/live-view/{unit_id} +``` +Returns HTML partial with live view panel for specific unit. +- Fetches unit details from database +- Queries SLMM API for current measurement state +- Queries SLMM API for live status (DOD data) + +```python +POST /api/slm-dashboard/control/{unit_id}/{action} +``` +Sends control commands to SLMM backend. +- Valid actions: start, stop, pause, resume, reset +- Proxies to `http://localhost:8100/api/nl43/{unit_id}/{action}` + +### Integration with SLMM + +The dashboard communicates with the SLMM backend service running on port 8100: + +**REST API Calls:** +- `GET /api/nl43/{unit_id}/measurement-state` - Check if measuring +- `GET /api/nl43/{unit_id}/live` - Get current DOD data +- `POST /api/nl43/{unit_id}/start|stop|pause|resume|reset` - Control commands + +**WebSocket Streaming:** +- `WS /api/nl43/{unit_id}/live` - Real-time DRD data stream +- Proxied through SFM at `/api/slmm/{unit_id}/live` +- Streams continuous measurement data for live charting + +### Database Schema + +**Table**: `roster` + +SLM-specific fields in the RosterUnit model: + +```python +device_type = "sound_level_meter" # Distinguishes SLMs from seismographs +slm_host = String # Device IP or hostname +slm_tcp_port = Integer # TCP control port (default 2255) +slm_model = String # NL-43, NL-53, etc. +slm_serial_number = String # Device serial number +slm_frequency_weighting = String # A, C, or Z weighting +slm_time_weighting = String # F (Fast), S (Slow), I (Impulse) +slm_measurement_range = String # e.g., "30-130 dB" +slm_last_check = DateTime # Last communication timestamp +``` + +## Navigation + +The Sound Level Meters page is accessible from: +- **URL**: `/sound-level-meters` +- **Sidebar**: "Sound Level Meters" menu item (between Fleet Roster and Projects) +- **Icon**: Speaker/sound wave SVG icon + +## Real-time Updates + +The dashboard uses three mechanisms for real-time updates: + +1. **HTMX Polling** (10-second intervals) + - Summary statistics + - Unit list + - Ensures data freshness even without user interaction + +2. **On-Demand Loading** (HTMX) + - Live view panel loads when unit is selected + - Control button responses + +3. **WebSocket Streaming** (continuous) + - Real-time DRD data for live charting + - User-initiated via "Start Live Stream" button + - Automatically closed on page unload or unit change + +## Measurement Duration Tracking + +**Important**: The NL-43/NL-53 devices do not expose measurement duration via their API. Elapsed time and interval counts are only visible on the device's on-screen display (OSD). + +**Solution**: Track measurement start time in your application when calling the `/start` endpoint: + +```javascript +// When starting measurement +const startTime = new Date(); +localStorage.setItem(`slm_${unitId}_start`, startTime.toISOString()); + +// Calculate elapsed time +const startTime = new Date(localStorage.getItem(`slm_${unitId}_start`)); +const elapsed = (new Date() - startTime) / 1000; // seconds +``` + +**Future Enhancement**: SLMM backend could store measurement start times in a database table to track duration across sessions. + +## Testing + +### Add Test Data + +Use the included script to add test SLM units: + +```bash +python3 add_test_slms.py +``` + +This creates: +- 3 deployed test units (nl43-001, nl43-002, nl53-001) +- 1 benched unit (nl43-003) + +### Running the Dashboard + +1. Start SLMM backend (port 8100): + ```bash + cd /home/serversdown/slmm + uvicorn main:app --host 0.0.0.0 --port 8100 + ``` + +2. Start SFM (port 8000): + ```bash + cd /home/serversdown/sfm/seismo-fleet-manager + uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload + ``` + +3. Access dashboard: + ``` + http://localhost:8000/sound-level-meters + ``` + +### Testing Without Physical Devices + +The dashboard will work without physical NL-43 devices connected: +- Unit list will display based on database records +- Live view will show connection errors (gracefully handled) +- Mock data can be added to SLMM for testing + +## Future Enhancements + +### Near-term +1. **Measurement Duration Tracking** + - Add database table to track measurement sessions + - Display elapsed time in live view + - Store start/stop timestamps + +2. **Historical Data View** + - Chart historical Leq intervals + - Export measurement data + - Comparison between units + +3. **Alerts & Thresholds** + - Configurable sound level alerts + - Email/SMS notifications when thresholds exceeded + - Visual indicators on dashboard + +### Long-term +1. **Map View** + - Display all SLMs on a map (like seismographs) + - Click map markers to view live data + - Color-coded by current sound level + +2. **Batch Operations** + - Start/stop multiple units simultaneously + - Synchronized measurements + - Group configurations + +3. **Advanced Analytics** + - Noise compliance reports + - Statistical summaries + - Trend analysis + +## Integration with Terra-view + +When SFM is rebranded to Terra-view: + +1. **Multi-Module Dashboard** + - Sound Level Meters module (this dashboard) + - Seismograph Fleet Manager module (existing) + - Future monitoring modules + +2. **Project Management** + - Link SLMs to projects + - Combined project view with seismographs + SLMs + - Project-level reporting + +3. **Unified Navigation** + - Top-level module switcher + - Consistent UI/UX across modules + - Shared authentication and settings + +## Technical Notes + +### HTMX Integration +The dashboard extensively uses HTMX for dynamic updates without full page reloads: +- `hx-get`: Fetch and swap content +- `hx-trigger`: Auto-refresh intervals +- `hx-swap`: Content replacement strategy +- `hx-target`: Specify update target + +### Dark Mode Support +All components support dark mode: +- Chart.js colors adapt to theme +- Tailwind dark: classes throughout +- Automatic theme detection + +### Performance Considerations +- WebSocket connections are per-unit (only one active at a time) +- Chart data limited to 60 points (1 minute) to prevent memory bloat +- Polling intervals balanced for responsiveness vs server load +- Lazy loading of live view panel (only when unit selected) + +## Files Modified/Created + +### New Files +- `templates/sound_level_meters.html` +- `templates/partials/slm_stats.html` +- `templates/partials/slm_unit_list.html` +- `templates/partials/slm_live_view.html` +- `templates/partials/slm_live_view_error.html` +- `backend/routers/slm_dashboard.py` +- `add_test_slms.py` +- `docs/SOUND_LEVEL_METERS_DASHBOARD.md` + +### Modified Files +- `backend/main.py` - Added route and router import +- `templates/base.html` - Added navigation menu item + +## Support + +For issues or questions: +- Check SLMM API documentation: `/home/serversdown/slmm/docs/API.md` +- Review SFM changelog: `CHANGELOG.md` +- Submit issues to project repository + +--- + +**Version**: 1.0.0 +**Created**: January 2026 +**Last Updated**: January 5, 2026 diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..34ca342 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,120 @@ +# Helper Scripts + +This directory contains helper scripts for database management and testing. + +## Database Migration Scripts + +### migrate_dev_db.py +Migrates the DEV database schema to add SLM-specific columns to the `roster` table. + +**Usage:** +```bash +cd /home/serversdown/sfm/seismo-fleet-manager +python3 scripts/migrate_dev_db.py +``` + +**What it does:** +- Adds 8 SLM-specific columns to the DEV database (data-dev/seismo_fleet.db) +- Columns: slm_host, slm_tcp_port, slm_model, slm_serial_number, slm_frequency_weighting, slm_time_weighting, slm_measurement_range, slm_last_check +- Safe to run multiple times (skips existing columns) + +### update_dev_db_schema.py +Inspects and displays the DEV database schema. + +**Usage:** +```bash +python3 scripts/update_dev_db_schema.py +``` + +**What it does:** +- Shows all tables in the DEV database +- Lists all columns in the roster table +- Useful for verifying schema after migrations + +## Test Data Scripts + +### add_test_slms.py +Adds test Sound Level Meter units to the DEV database. + +**Usage:** +```bash +python3 scripts/add_test_slms.py +``` + +**What it creates:** +- nl43-001: NL-43 SLM at Construction Site A +- nl43-002: NL-43 SLM at Construction Site B +- nl53-001: NL-53 SLM at Residential Area +- nl43-003: NL-43 SLM (not deployed, spare unit) + +### add_test_modems.py +Adds test modem units to the DEV database and assigns them to SLMs. + +**Usage:** +```bash +python3 scripts/add_test_modems.py +``` + +**What it creates:** +- modem-001, modem-002, modem-003: Deployed modems (Raven XTV and Sierra Wireless) +- modem-004: Spare modem (not deployed) + +**Modem assignments:** +- nl43-001 → modem-001 +- nl43-002 → modem-002 +- nl53-001 → modem-003 + +## Cleanup Scripts + +### remove_test_data_from_prod.py +**⚠️ PRODUCTION DATABASE CLEANUP** + +Removes test data from the production database (data/seismo_fleet.db). + +**Status:** Already executed successfully. Production database is clean. + +**What it removed:** +- All test SLM units (nl43-001, nl43-002, nl53-001, nl43-003) +- All test modem units (modem-001, modem-002, modem-003, modem-004) + +## Database Cloning + +### clone_db_to_dev.py +Clones the production database to create/update the DEV database. + +**Usage:** +```bash +python3 scripts/clone_db_to_dev.py +``` + +**What it does:** +- Copies data/seismo_fleet.db → data-dev/seismo_fleet.db +- Useful for syncing DEV database with production schema/data + +## Setup Sequence + +To set up a fresh DEV database with test data: + +```bash +cd /home/serversdown/sfm/seismo-fleet-manager + +# 1. Fix permissions (if needed) +sudo chown -R serversdown:serversdown data-dev/ + +# 2. Migrate schema +python3 scripts/migrate_dev_db.py + +# 3. Add test data +python3 scripts/add_test_slms.py +python3 scripts/add_test_modems.py + +# 4. Verify +sqlite3 data-dev/seismo_fleet.db "SELECT id, device_type FROM roster WHERE device_type IN ('sound_level_meter', 'modem');" +``` + +## Important Notes + +- **DEV Database**: `data-dev/seismo_fleet.db` - Used for development and testing +- **Production Database**: `data/seismo_fleet.db` - Used by the running application +- All test scripts are configured to use the DEV database only +- Never run test data scripts against production diff --git a/scripts/add_slm_ftp_port.py b/scripts/add_slm_ftp_port.py new file mode 100644 index 0000000..21d289b --- /dev/null +++ b/scripts/add_slm_ftp_port.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +""" +Add slm_ftp_port column to roster table for FTP data retrieval port +""" + +from sqlalchemy import create_engine, text +import os + +# Determine database based on environment +ENVIRONMENT = os.getenv("ENVIRONMENT", "production") +if ENVIRONMENT == "development": + DB_URL = "sqlite:///./data-dev/seismo_fleet.db" +else: + DB_URL = "sqlite:///./data/seismo_fleet.db" + +def add_ftp_port_column(): + print(f"Adding slm_ftp_port column to {DB_URL}...") + print("=" * 60) + + engine = create_engine(DB_URL, connect_args={"check_same_thread": False}) + + with engine.connect() as conn: + try: + # Try to add the column + conn.execute(text("ALTER TABLE roster ADD COLUMN slm_ftp_port INTEGER")) + conn.commit() + print("✓ Added column: slm_ftp_port (INTEGER)") + except Exception as e: + if "duplicate column name" in str(e).lower(): + print(" Column slm_ftp_port already exists, skipping") + else: + print(f"✗ Error adding slm_ftp_port: {e}") + raise + + print("=" * 60) + print("Migration completed!") + +if __name__ == "__main__": + add_ftp_port_column() diff --git a/scripts/add_test_modems.py b/scripts/add_test_modems.py new file mode 100755 index 0000000..f6fb922 --- /dev/null +++ b/scripts/add_test_modems.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +""" +Add test modem units and assign them to SLMs in DEV database +""" + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from backend.models import RosterUnit +from datetime import datetime + +# DEV database +DEV_DB_URL = "sqlite:///./data-dev/seismo_fleet.db" + +def add_test_modems(): + engine = create_engine(DEV_DB_URL, connect_args={"check_same_thread": False}) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + db = SessionLocal() + + # Test modems + test_modems = [ + { + "id": "modem-001", + "device_type": "modem", + "deployed": True, + "retired": False, + "note": "Raven XTV modem for Construction Site A", + "address": "123 Main St, Construction Site A", + "coordinates": "34.0522,-118.2437", + "ip_address": "192.168.1.100", + "phone_number": "+1-555-0100", + "hardware_model": "Raven XTV" + }, + { + "id": "modem-002", + "device_type": "modem", + "deployed": True, + "retired": False, + "note": "Raven XTV modem for Construction Site B", + "address": "456 Oak Ave, Construction Site B", + "coordinates": "34.0622,-118.2537", + "ip_address": "192.168.1.101", + "phone_number": "+1-555-0101", + "hardware_model": "Raven XTV" + }, + { + "id": "modem-003", + "device_type": "modem", + "deployed": True, + "retired": False, + "note": "Sierra Wireless modem for Residential Area", + "address": "789 Elm St, Residential Area", + "coordinates": "34.0722,-118.2637", + "ip_address": "192.168.1.102", + "phone_number": "+1-555-0102", + "hardware_model": "Sierra Wireless AirLink" + }, + { + "id": "modem-004", + "device_type": "modem", + "deployed": False, + "retired": False, + "note": "Spare modem - not deployed", + "ip_address": None, + "phone_number": "+1-555-0103", + "hardware_model": "Raven XTV" + } + ] + + for modem_data in test_modems: + # Check if modem already exists + existing = db.query(RosterUnit).filter_by(id=modem_data["id"]).first() + if existing: + print(f"Modem {modem_data['id']} already exists, skipping...") + continue + + modem = RosterUnit(**modem_data) + db.add(modem) + print(f"Added {modem_data['id']}") + + # Assign modems to existing SLMs + slm_modem_assignments = { + "nl43-001": "modem-001", + "nl43-002": "modem-002", + "nl53-001": "modem-003" + } + + for slm_id, modem_id in slm_modem_assignments.items(): + slm = db.query(RosterUnit).filter_by(id=slm_id).first() + if slm: + slm.deployed_with_modem_id = modem_id + # Remove legacy slm_host since we're using modems now + slm.slm_host = None + print(f"Assigned {slm_id} to {modem_id}") + else: + print(f"SLM {slm_id} not found, skipping assignment...") + + db.commit() + db.close() + print("\nTest modems added and assigned to SLMs successfully in DEV database (data-dev/seismo_fleet.db)!") + print("\nModem assignments:") + for slm_id, modem_id in slm_modem_assignments.items(): + print(f" {slm_id} → {modem_id}") + +if __name__ == "__main__": + add_test_modems() diff --git a/scripts/add_test_slms.py b/scripts/add_test_slms.py new file mode 100755 index 0000000..134f187 --- /dev/null +++ b/scripts/add_test_slms.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +Add test Sound Level Meter units to the DEV database +""" + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from backend.models import RosterUnit +from datetime import datetime + +# DEV database +DEV_DB_URL = "sqlite:///./data-dev/seismo_fleet.db" + +def add_test_slms(): + engine = create_engine(DEV_DB_URL, connect_args={"check_same_thread": False}) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + db = SessionLocal() + + test_slms = [ + { + "id": "nl43-001", + "device_type": "sound_level_meter", + "deployed": True, + "retired": False, + "note": "Test unit at construction site A", + "address": "123 Main St, Construction Site A", + "coordinates": "34.0522,-118.2437", + "slm_host": "192.168.1.100", + "slm_tcp_port": 2255, + "slm_model": "NL-43", + "slm_serial_number": "123456", + "slm_frequency_weighting": "A", + "slm_time_weighting": "F", + "slm_measurement_range": "30-130 dB", + "slm_last_check": datetime.utcnow() + }, + { + "id": "nl43-002", + "device_type": "sound_level_meter", + "deployed": True, + "retired": False, + "note": "Test unit at construction site B", + "address": "456 Oak Ave, Construction Site B", + "coordinates": "34.0622,-118.2537", + "slm_host": "192.168.1.101", + "slm_tcp_port": 2255, + "slm_model": "NL-43", + "slm_serial_number": "123457", + "slm_frequency_weighting": "A", + "slm_time_weighting": "S", + "slm_measurement_range": "30-130 dB", + "slm_last_check": datetime.utcnow() + }, + { + "id": "nl53-001", + "device_type": "sound_level_meter", + "deployed": True, + "retired": False, + "note": "Test unit at residential monitoring", + "address": "789 Elm St, Residential Area", + "coordinates": "34.0722,-118.2637", + "slm_host": "192.168.1.102", + "slm_tcp_port": 2255, + "slm_model": "NL-53", + "slm_serial_number": "234567", + "slm_frequency_weighting": "C", + "slm_time_weighting": "F", + "slm_measurement_range": "25-140 dB", + "slm_last_check": datetime.utcnow() + }, + { + "id": "nl43-003", + "device_type": "sound_level_meter", + "deployed": False, + "retired": False, + "note": "Benched for calibration", + "address": None, + "coordinates": None, + "slm_host": None, + "slm_tcp_port": None, + "slm_model": "NL-43", + "slm_serial_number": "123458", + "slm_frequency_weighting": "A", + "slm_time_weighting": "F", + "slm_measurement_range": "30-130 dB", + "slm_last_check": None + } + ] + + for slm_data in test_slms: + # Check if unit already exists + existing = db.query(RosterUnit).filter_by(id=slm_data["id"]).first() + if existing: + print(f"Unit {slm_data['id']} already exists, skipping...") + continue + + unit = RosterUnit(**slm_data) + db.add(unit) + print(f"Added {slm_data['id']}") + + db.commit() + db.close() + print("\nTest SLM units added successfully to DEV database (data-dev/seismo_fleet.db)!") + +if __name__ == "__main__": + add_test_slms() diff --git a/scripts/migrate_dev_db.py b/scripts/migrate_dev_db.py new file mode 100644 index 0000000..ab1a957 --- /dev/null +++ b/scripts/migrate_dev_db.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +""" +Migrate DEV database to add SLM-specific columns +""" + +from sqlalchemy import create_engine, text + +# DEV database +DEV_DB_URL = "sqlite:///./data-dev/seismo_fleet.db" + +def migrate_dev_database(): + print("Migrating DEV database to add SLM columns...") + print("=" * 60) + + engine = create_engine(DEV_DB_URL, connect_args={"check_same_thread": False}) + + # SLM columns to add + slm_columns = [ + ("slm_host", "VARCHAR"), + ("slm_tcp_port", "INTEGER"), + ("slm_model", "VARCHAR"), + ("slm_serial_number", "VARCHAR"), + ("slm_frequency_weighting", "VARCHAR"), + ("slm_time_weighting", "VARCHAR"), + ("slm_measurement_range", "VARCHAR"), + ("slm_last_check", "DATETIME"), + ] + + with engine.connect() as conn: + for column_name, column_type in slm_columns: + try: + # Try to add the column + conn.execute(text(f"ALTER TABLE roster ADD COLUMN {column_name} {column_type}")) + conn.commit() + print(f"✓ Added column: {column_name}") + except Exception as e: + if "duplicate column name" in str(e).lower(): + print(f" Column {column_name} already exists, skipping") + else: + print(f"✗ Error adding {column_name}: {e}") + + print("=" * 60) + print("DEV database migration completed!") + +if __name__ == "__main__": + migrate_dev_database() diff --git a/scripts/remove_test_data_from_prod.py b/scripts/remove_test_data_from_prod.py new file mode 100644 index 0000000..af6b2fd --- /dev/null +++ b/scripts/remove_test_data_from_prod.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +""" +Remove test SLMs and modems from PRODUCTION database +""" + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from backend.models import RosterUnit + +# PRODUCTION database +PROD_DB_URL = "sqlite:///./data/seismo_fleet.db" + +def remove_test_data(): + engine = create_engine(PROD_DB_URL, connect_args={"check_same_thread": False}) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + db = SessionLocal() + + # IDs to remove + test_slm_ids = ["nl43-001", "nl43-002", "nl53-001", "nl43-003"] + test_modem_ids = ["modem-001", "modem-002", "modem-003", "modem-004"] + + all_test_ids = test_slm_ids + test_modem_ids + + removed = [] + for unit_id in all_test_ids: + unit = db.query(RosterUnit).filter_by(id=unit_id).first() + if unit: + db.delete(unit) + removed.append(unit_id) + print(f"Removed {unit_id}") + + if removed: + db.commit() + print(f"\nRemoved {len(removed)} test units from PRODUCTION database") + else: + print("No test units found in production database") + + db.close() + +if __name__ == "__main__": + print("Removing test data from PRODUCTION database...") + print("=" * 60) + remove_test_data() + print("=" * 60) + print("Done! Production database is clean.") diff --git a/scripts/update_dev_db_schema.py b/scripts/update_dev_db_schema.py new file mode 100644 index 0000000..65f4333 --- /dev/null +++ b/scripts/update_dev_db_schema.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +""" +Update DEV database schema to match current models +""" + +from sqlalchemy import create_engine, inspect +from backend.models import Base + +# DEV database +DEV_DB_URL = "sqlite:///./data-dev/seismo_fleet.db" + +def update_schema(): + print("Updating DEV database schema...") + print("=" * 60) + + engine = create_engine(DEV_DB_URL, connect_args={"check_same_thread": False}) + + # Create all tables (will update existing tables with new columns) + Base.metadata.create_all(bind=engine) + + # Inspect to see what we have + inspector = inspect(engine) + tables = inspector.get_table_names() + + print(f"Tables in DEV database: {tables}") + + if 'roster' in tables: + columns = [col['name'] for col in inspector.get_columns('roster')] + print(f"\nColumns in roster table:") + for col in sorted(columns): + print(f" - {col}") + + print("=" * 60) + print("DEV database schema updated successfully!") + print("\nNote: SQLite doesn't support ALTER COLUMN, so existing") + print("columns won't be modified, but new columns will be added.") + +if __name__ == "__main__": + update_schema() diff --git a/sync_slms_to_slmm.py b/sync_slms_to_slmm.py new file mode 100755 index 0000000..9fe3451 --- /dev/null +++ b/sync_slms_to_slmm.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +""" +One-time script to sync existing SLM devices from Terra-View roster to SLMM cache. +Run this after implementing the automatic sync to backfill existing devices. +""" +import asyncio +import sys +import os + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from backend.database import SessionLocal +from backend.models import RosterUnit +from backend.routers.roster_edit import sync_slm_to_slmm_cache +import logging + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + + +async def sync_all_slms(): + """Sync all SLM devices from Terra-View roster to SLMM cache.""" + db = SessionLocal() + try: + # Get all SLM devices from Terra-View (source of truth) + slm_devices = db.query(RosterUnit).filter_by( + device_type="sound_level_meter" + ).all() + + logger.info(f"Found {len(slm_devices)} SLM devices in Terra-View roster") + + success_count = 0 + failed_count = 0 + + for device in slm_devices: + logger.info(f"\nProcessing: {device.id}") + logger.info(f" Host: {device.slm_host}") + logger.info(f" TCP Port: {device.slm_tcp_port}") + logger.info(f" Modem: {device.deployed_with_modem_id}") + + result = await sync_slm_to_slmm_cache( + unit_id=device.id, + host=device.slm_host, + tcp_port=device.slm_tcp_port, + ftp_port=device.slm_ftp_port, + deployed_with_modem_id=device.deployed_with_modem_id, + db=db + ) + + if result["success"]: + logger.info(f"✓ {device.id}: {result['message']}") + success_count += 1 + else: + logger.error(f"✗ {device.id}: {result['message']}") + failed_count += 1 + + logger.info(f"\n{'='*60}") + logger.info(f"Cache sync complete: {success_count} succeeded, {failed_count} failed") + logger.info(f"{'='*60}") + + finally: + db.close() + + +if __name__ == "__main__": + asyncio.run(sync_all_slms()) diff --git a/templates/base.html b/templates/base.html index 90ce252..645646d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -110,7 +110,21 @@ - Fleet Roster + Devices + + + + + + + Seismographs + + + + + + + Sound Level Meters @@ -180,7 +194,7 @@ - Roster + Devices
+

Deployed Status:

@@ -343,6 +364,21 @@ function updateDashboard(event) { document.getElementById('status-pending').textContent = data.summary?.pending ?? 0; document.getElementById('status-missing').textContent = data.summary?.missing ?? 0; + // ===== Device type counts ===== + let seismoCount = 0; + let slmCount = 0; + Object.values(data.units || {}).forEach(unit => { + if (unit.retired) return; // Don't count retired units + const deviceType = unit.device_type || 'seismograph'; + if (deviceType === 'seismograph') { + seismoCount++; + } else if (deviceType === 'sound_level_meter') { + slmCount++; + } + }); + 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) diff --git a/templates/partials/devices_table.html b/templates/partials/devices_table.html new file mode 100644 index 0000000..f9c9115 --- /dev/null +++ b/templates/partials/devices_table.html @@ -0,0 +1,449 @@ + + + + +
+ {% for unit in units %} +
+ +
+
+ {% if unit.status == 'OK' %} + + {% elif unit.status == 'Pending' %} + + {% elif unit.status == 'Missing' %} + + {% else %} + + {% endif %} + {{ unit.id }} +
+ + {% if unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %} + +
+ + +
+ {% if unit.device_type == 'modem' %} + + Modem + + {% else %} + + Seismograph + + {% endif %} +
+ + + {% if unit.address %} +
+ 📍 {{ unit.address }} +
+ {% elif unit.coordinates %} +
+ 📍 {{ unit.coordinates }} +
+ {% endif %} + + + {% if unit.project_id %} +
+ 🏗️ {{ unit.project_id }} +
+ {% endif %} + + +
+ 🕐 {{ unit.age }} +
+ + +
+ {% if unit.deployed %} + + ⚡ Deployed + + {% else %} + + 📦 Benched + + {% endif %} +
+ + +
+ Tap for details +
+
+ {% endfor %} + + +
+ Last updated: {{ timestamp }} +
+
+ + +
+ +
+ + +
+ + + + +
+

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ + + + diff --git a/templates/partials/seismo_stats.html b/templates/partials/seismo_stats.html new file mode 100644 index 0000000..16b10e5 --- /dev/null +++ b/templates/partials/seismo_stats.html @@ -0,0 +1,56 @@ +
+ +
+
+
+

Total Seismographs

+

{{ total }}

+
+ + + +
+
+ + +
+
+
+

Deployed

+

{{ deployed }}

+
+ + + +
+
+ + +
+
+
+

Benched

+

{{ benched }}

+
+ + + +
+
+ + +
+
+
+

With Modem

+

{{ with_modem }}/ {{ deployed }}

+ {% if without_modem > 0 %} +

{{ without_modem }} without modem

+ {% endif %} +
+ + + +
+
+
diff --git a/templates/partials/seismo_unit_list.html b/templates/partials/seismo_unit_list.html new file mode 100644 index 0000000..c1187e8 --- /dev/null +++ b/templates/partials/seismo_unit_list.html @@ -0,0 +1,97 @@ +{% if units %} +
+ + + + + + + + + + + + + {% for unit in units %} + + + + + + + + + {% endfor %} + +
Unit IDStatusModemLocationNotesActions
+ + {{ unit.id }} + + + {% if unit.deployed %} + + + + + Deployed + + {% else %} + + + + + Benched + + {% endif %} + + {% if unit.deployed_with_modem_id %} + + {{ unit.deployed_with_modem_id }} + + {% else %} + None + {% endif %} + + {% if unit.address %} + {{ unit.address }} + {% elif unit.coordinates %} + {{ unit.coordinates }} + {% else %} + + {% endif %} + + {% if unit.note %} + {{ unit.note }} + {% else %} + + {% endif %} + + + View Details → + +
+
+ +{% if search %} +
+ Found {{ units|length }} seismograph(s) matching "{{ search }}" +
+{% endif %} + +{% else %} +
+ + + +

No seismographs found

+ {% if search %} +

No seismographs match "{{ search }}"

+ + {% else %} +

Get started by adding a seismograph unit from the roster page.

+ {% endif %} +
+{% endif %} diff --git a/templates/partials/slm_config_form.html b/templates/partials/slm_config_form.html new file mode 100644 index 0000000..01c2b1a --- /dev/null +++ b/templates/partials/slm_config_form.html @@ -0,0 +1,288 @@ +
+ +
+

Unit: {{ unit.id }}

+

Configure measurement parameters for this sound level meter

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+
Network Configuration
+ +
+ +
+ + +
+

Select a modem for network access, or leave blank for direct IP connection

+
+ + +
+
+ + +

Control port

+
+
+ + +

Data transfer

+
+
+ + +

If no modem

+
+
+
+ + +
+ + + +
+
+ + diff --git a/templates/partials/slm_config_form_old.html b/templates/partials/slm_config_form_old.html new file mode 100644 index 0000000..fddd790 --- /dev/null +++ b/templates/partials/slm_config_form_old.html @@ -0,0 +1,215 @@ +
+ +
+

Unit: {{ unit.id }}

+

Configure measurement parameters for this sound level meter

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+
Network Configuration
+ +
+ + +

Select a modem for network access, or leave blank for direct IP connection

+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+ + + +
+
+ + diff --git a/templates/partials/slm_live_view.html b/templates/partials/slm_live_view.html new file mode 100644 index 0000000..c5ee656 --- /dev/null +++ b/templates/partials/slm_live_view.html @@ -0,0 +1,567 @@ + +
+ +
+
+

{{ unit.id }}

+

+ {% if unit.slm_model %}{{ unit.slm_model }}{% endif %} + {% if unit.slm_serial_number %} • S/N: {{ unit.slm_serial_number }}{% endif %} +

+ {% if modem %} +

+ via Modem: {{ modem.id }}{% if modem_ip %} ({{ modem_ip }}){% endif %} +

+ {% elif modem_ip %} +

+ Direct: {{ modem_ip }} +

+ {% else %} +

+ ⚠️ No modem assigned or IP configured +

+ {% endif %} +
+ + +
+ {% if is_measuring %} + + + Measuring + + {% else %} + + Stopped + + {% endif %} +
+
+ + +
+ + + + + + + + + + + +
+ + +
+
+

Lp (Instant)

+

+ {% if current_status and current_status.lp %}{{ current_status.lp }}{% else %}--{% endif %} +

+

dB

+
+ +
+

Leq (Average)

+

+ {% if current_status and current_status.leq %}{{ current_status.leq }}{% else %}--{% endif %} +

+

dB

+
+ +
+

Lmax (Max)

+

+ {% if current_status and current_status.lmax %}{{ current_status.lmax }}{% else %}--{% endif %} +

+

dB

+
+ +
+

Lmin (Min)

+

+ {% if current_status and current_status.lmin %}{{ current_status.lmin }}{% else %}--{% endif %} +

+

dB

+
+ +
+

Lpeak (Peak)

+

+ {% if current_status and current_status.lpeak %}{{ current_status.lpeak }}{% else %}--{% endif %} +

+

dB

+
+
+ + +
+ +
+ + +
+

Device Status

+
+ +
+
+ Battery + + + +
+
+ {% if current_status and current_status.battery_level %}{{ current_status.battery_level }}%{% else %}--{% endif %} +
+
+
+
+
+
+ + +
+
+ Power + + + +
+
+ {% if current_status and current_status.power_source %}{{ current_status.power_source }}{% else %}--{% endif %} +
+
+ + +
+
+ SD Card + + + +
+
+ {% if current_status and current_status.sd_remaining_mb %}{{ current_status.sd_remaining_mb }} MB{% else %}--{% endif %} +
+
+ {% if current_status and current_status.sd_free_ratio %}{{ current_status.sd_free_ratio }}% free{% else %}--{% endif %} +
+
+ + +
+
+ Last Update + + + +
+
+ Just now +
+
+ + Auto-refresh: 30s +
+
+
+
+
+ + + diff --git a/templates/partials/slm_live_view.html.backup b/templates/partials/slm_live_view.html.backup new file mode 100644 index 0000000..d10264d --- /dev/null +++ b/templates/partials/slm_live_view.html.backup @@ -0,0 +1,438 @@ + +
+ +
+
+

{{ unit.id }}

+

+ {% if unit.slm_model %}{{ unit.slm_model }}{% endif %} + {% if unit.slm_serial_number %} • S/N: {{ unit.slm_serial_number }}{% endif %} +

+ {% if modem %} +

+ via Modem: {{ modem.id }}{% if modem_ip %} ({{ modem_ip }}){% endif %} +

+ {% elif modem_ip %} +

+ Direct: {{ modem_ip }} +

+ {% else %} +

+ ⚠️ No modem assigned or IP configured +

+ {% endif %} +
+ + +
+ {% if is_measuring %} + + + Measuring + + {% else %} + + Stopped + + {% endif %} +
+
+ + +
+ + + + + + + + + + + +
+ + +
+
+

Lp (Instant)

+

+ {% if current_status and current_status.lp %}{{ current_status.lp }}{% else %}--{% endif %} +

+

dB

+
+ +
+

Leq (Average)

+

+ {% if current_status and current_status.leq %}{{ current_status.leq }}{% else %}--{% endif %} +

+

dB

+
+ +
+

Lmax (Max)

+

+ {% if current_status and current_status.lmax %}{{ current_status.lmax }}{% else %}--{% endif %} +

+

dB

+
+ +
+

Lmin (Min)

+

+ {% if current_status and current_status.lmin %}{{ current_status.lmin }}{% else %}--{% endif %} +

+

dB

+
+ +
+

Lpeak (Peak)

+

+ {% if current_status and current_status.lpeak %}{{ current_status.lpeak }}{% else %}--{% endif %} +

+

dB

+
+
+ + +
+ +
+ + +
+
+ Battery: + + {% if current_status and current_status.battery_level %}{{ current_status.battery_level }}%{% else %}--{% endif %} + +
+
+ Power: + + {% if current_status and current_status.power_source %}{{ current_status.power_source }}{% else %}--{% endif %} + +
+
+ Weighting: + + {% if unit.slm_frequency_weighting %}{{ unit.slm_frequency_weighting }}{% else %}--{% endif %} / + {% if unit.slm_time_weighting %}{{ unit.slm_time_weighting }}{% else %}--{% endif %} + +
+
+ SD Remaining: + + {% if current_status and current_status.sd_remaining_mb %}{{ current_status.sd_remaining_mb }} MB{% else %}--{% endif %} + +
+
+
+ + + diff --git a/templates/partials/slm_live_view_error.html b/templates/partials/slm_live_view_error.html new file mode 100644 index 0000000..f8c6dba --- /dev/null +++ b/templates/partials/slm_live_view_error.html @@ -0,0 +1,8 @@ + +
+ + + +

Error Loading Unit

+

{{ error }}

+
diff --git a/templates/partials/slm_stats.html b/templates/partials/slm_stats.html new file mode 100644 index 0000000..70ec8b2 --- /dev/null +++ b/templates/partials/slm_stats.html @@ -0,0 +1,61 @@ + + +
+
+
+

Total Units

+

{{ total_count }}

+
+
+ + + +
+
+
+ + +
+
+
+

Deployed

+

{{ deployed_count }}

+
+
+ + + +
+
+
+ + +
+
+
+

Active Now

+

{{ active_count }}

+
+
+ + + +
+
+

Checked in last hour

+
+ + +
+
+
+

Benched

+

{{ benched_count }}

+
+
+ + + +
+
+
diff --git a/templates/partials/slm_unit_list.html b/templates/partials/slm_unit_list.html new file mode 100644 index 0000000..b3f93cc --- /dev/null +++ b/templates/partials/slm_unit_list.html @@ -0,0 +1,73 @@ + +{% if units %} + {% for unit in units %} +
+ + + +
+
+ {{ unit.id }} + + {% if unit.slm_last_check %} + + {% else %} + + {% endif %} +
+ +
+ {% if unit.slm_model %} +
+ + + + {{ unit.slm_model }} +
+ {% endif %} + + {% if unit.address %} +
+ + + + + {{ unit.address }} +
+ {% endif %} + + {% if unit.deployed_with_modem_id %} +
+ + + + {{ unit.deployed_with_modem_id }} +
+ {% elif unit.slm_host %} +
+ + + + {{ unit.slm_host }}{% if unit.slm_tcp_port %}:{{ unit.slm_tcp_port }}{% endif %} +
+ {% endif %} +
+
+
+ {% endfor %} +{% else %} +
+ + + +

No sound level meters found

+

Add units from the Fleet Roster

+
+{% endif %} diff --git a/templates/roster.html b/templates/roster.html index d0f8752..765e1e9 100644 --- a/templates/roster.html +++ b/templates/roster.html @@ -1,20 +1,20 @@ {% extends "base.html" %} -{% block title %}Fleet Roster - Seismo Fleet Manager{% endblock %} +{% block title %}Devices - Seismo Fleet Manager{% endblock %} {% block content %}
-

Fleet Roster

-

Real-time status of all seismograph units

+

Devices

+

Manage all devices in your fleet

- +
- -
+ +
+
+ onkeyup="filterDevices()">
+ + +
+ +
+ Type: + + + + +
+ + +
+ Status: + + + + + +
+ + +
+ Health: + + + + +
+
+ + +
+ Showing 0 of 0 devices +
- -
- - - - - - - -
- - -
+
-

Loading roster data...

+

Loading devices...

@@ -114,9 +112,9 @@
- + placeholder="BE1234 or MODEM-001 (no spaces)">
@@ -205,6 +203,11 @@
+
+ + +
{ + input.disabled = disabled; + }); + } + // Toggle modem pairing field visibility (only for deployed seismographs) function toggleModemPairing() { const deviceType = document.getElementById('deviceTypeSelect').value; @@ -524,7 +548,29 @@ // Show success message alert('Unit added successfully!'); } else { - alert('Error adding unit. Please check the form and try again.'); + // Log detailed error information + console.error('Error adding unit:', { + status: event.detail.xhr.status, + response: event.detail.xhr.responseText, + headers: event.detail.xhr.getAllResponseHeaders() + }); + + // Try to parse error message from response + let errorMsg = 'Error adding unit. Please check the form and try again.'; + try { + const response = JSON.parse(event.detail.xhr.responseText); + if (response.detail) { + if (typeof response.detail === 'string') { + errorMsg = response.detail; + } else if (Array.isArray(response.detail)) { + errorMsg = response.detail.map(err => `${err.loc?.join('.')}: ${err.msg}`).join('\n'); + } + } + } catch (e) { + console.error('Could not parse error response:', e); + } + + alert(errorMsg); } }); @@ -547,10 +593,16 @@ if (deviceType === 'seismograph') { seismoFields.classList.remove('hidden'); modemFields.classList.add('hidden'); + // Enable seismograph fields, disable modem fields + setFieldsDisabled(seismoFields, false); + setFieldsDisabled(modemFields, true); toggleEditModemPairing(); } else { seismoFields.classList.add('hidden'); modemFields.classList.remove('hidden'); + // Enable modem fields, disable seismograph fields + setFieldsDisabled(seismoFields, true); + setFieldsDisabled(modemFields, false); } } @@ -872,33 +924,203 @@ } } - // Filter roster table based on search input - function filterRosterTable() { - const searchInput = document.getElementById('roster-search').value.toLowerCase(); - const table = document.querySelector('#roster-content table tbody'); + // ===== DEVICE FILTERING SYSTEM ===== - if (!table) return; + // Current active filters + let activeFilters = { + deviceType: 'all', + status: 'all', + health: 'all', + search: '' + }; - const rows = table.getElementsByTagName('tr'); + // Initialize filter button click handlers + document.addEventListener('DOMContentLoaded', function() { + // Device type filter buttons + document.querySelectorAll('.filter-device-type').forEach(btn => { + btn.addEventListener('click', function() { + // Update active state + document.querySelectorAll('.filter-device-type').forEach(b => b.classList.remove('active-filter')); + this.classList.add('active-filter'); - for (let row of rows) { - const cells = row.getElementsByTagName('td'); - if (cells.length === 0) continue; // Skip header or empty rows + // Update filter value + activeFilters.deviceType = this.dataset.value; - const unitId = cells[1]?.textContent?.toLowerCase() || ''; - const unitType = cells[2]?.textContent?.toLowerCase() || ''; - const note = cells[6]?.textContent?.toLowerCase() || ''; + // Apply filters + filterDevices(); + }); + }); - const matches = unitId.includes(searchInput) || - unitType.includes(searchInput) || - note.includes(searchInput); + // Status filter buttons + document.querySelectorAll('.filter-status').forEach(btn => { + btn.addEventListener('click', function() { + // Update active state + document.querySelectorAll('.filter-status').forEach(b => b.classList.remove('active-filter')); + this.classList.add('active-filter'); - row.style.display = matches ? '' : 'none'; + // Update filter value + activeFilters.status = this.dataset.value; + + // Toggle health filter visibility (hide for retired/ignored) + const healthGroup = document.getElementById('health-filter-group'); + if (this.dataset.value === 'retired' || this.dataset.value === 'ignored') { + healthGroup.style.display = 'none'; + } else { + healthGroup.style.display = 'flex'; + } + + // Apply filters + filterDevices(); + }); + }); + + // Health status filter buttons + document.querySelectorAll('.filter-health').forEach(btn => { + btn.addEventListener('click', function() { + // Update active state + document.querySelectorAll('.filter-health').forEach(b => b.classList.remove('active-filter')); + this.classList.add('active-filter'); + + // Update filter value + activeFilters.health = this.dataset.value; + + // Apply filters + filterDevices(); + }); + }); + }); + + // Main filter function - filters devices based on all active criteria + function filterDevices() { + const searchInput = document.getElementById('device-search')?.value.toLowerCase() || ''; + activeFilters.search = searchInput; + + const table = document.querySelector('#device-content table tbody'); + const cards = document.querySelectorAll('#device-content .device-card'); // For mobile view + + let visibleCount = 0; + let totalCount = 0; + + // Filter table rows (desktop view) + if (table) { + const rows = table.getElementsByTagName('tr'); + totalCount = rows.length; + + for (let row of rows) { + const cells = row.getElementsByTagName('td'); + if (cells.length === 0) continue; + + // Extract row data (adjust indices based on your table structure) + const status = cells[0]?.querySelector('.status-badge')?.textContent?.toLowerCase() || ''; + const deviceId = cells[1]?.textContent?.toLowerCase() || ''; + const deviceType = cells[2]?.textContent?.toLowerCase() || ''; + const note = cells[6]?.textContent?.toLowerCase() || ''; + + // Get data attributes for filtering + const rowDeviceType = row.dataset.deviceType || ''; + const rowStatus = row.dataset.status || ''; + const rowHealth = row.dataset.health || ''; + + // Apply filters + const matchesSearch = !searchInput || + deviceId.includes(searchInput) || + deviceType.includes(searchInput) || + note.includes(searchInput); + + const matchesDeviceType = activeFilters.deviceType === 'all' || + rowDeviceType === activeFilters.deviceType; + + const matchesStatus = activeFilters.status === 'all' || + rowStatus === activeFilters.status; + + const matchesHealth = activeFilters.health === 'all' || + rowHealth === activeFilters.health || + activeFilters.status === 'retired' || + activeFilters.status === 'ignored'; + + const isVisible = matchesSearch && matchesDeviceType && matchesStatus && matchesHealth; + + row.style.display = isVisible ? '' : 'none'; + if (isVisible) visibleCount++; + } } + + // Filter cards (mobile view) + if (cards.length > 0) { + totalCount = cards.length; + visibleCount = 0; + + cards.forEach(card => { + const cardDeviceType = card.dataset.deviceType || ''; + const cardStatus = card.dataset.status || ''; + const cardHealth = card.dataset.health || ''; + const cardText = card.textContent.toLowerCase(); + + const matchesSearch = !searchInput || cardText.includes(searchInput); + const matchesDeviceType = activeFilters.deviceType === 'all' || cardDeviceType === activeFilters.deviceType; + const matchesStatus = activeFilters.status === 'all' || cardStatus === activeFilters.status; + const matchesHealth = activeFilters.health === 'all' || cardHealth === activeFilters.health; + + const isVisible = matchesSearch && matchesDeviceType && matchesStatus && matchesHealth; + + card.style.display = isVisible ? '' : 'none'; + if (isVisible) visibleCount++; + }); + } + + // Update count display + document.getElementById('visible-count').textContent = visibleCount; + document.getElementById('total-count').textContent = totalCount; + } + + // Legacy function name for compatibility + function filterRosterTable() { + filterDevices(); }