SLM config now sync to SLMM, SLMM caches configs for speed

This commit is contained in:
serversdwn
2026-01-07 18:33:58 +00:00
parent 6d34e543fe
commit c30d7fac22
12 changed files with 1893 additions and 124 deletions

View File

@@ -1,13 +1,22 @@
import os import os
import logging
from fastapi import FastAPI, Request, Depends from fastapi import FastAPI, Request, Depends
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
from fastapi.exceptions import RequestValidationError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Dict from typing import List, Dict
from pydantic import BaseModel 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.database import engine, Base, get_db
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard 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.services.snapshot import emit_status_snapshot
@@ -27,6 +36,16 @@ app = FastAPI(
version=VERSION 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 # Configure CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -372,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") @app.get("/health")
def health_check(): def health_check():
"""Health check endpoint""" """Health check endpoint"""

View File

@@ -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 sqlalchemy.orm import Session
from datetime import datetime, date from datetime import datetime, date
import csv import csv
import io import io
import logging
import httpx
import os
from backend.database import get_db from backend.database import get_db
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory
router = APIRouter(prefix="/api/roster", tags=["roster-edit"]) 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, 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 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") @router.post("/add")
def add_roster_unit( async def add_roster_unit(
id: str = Form(...), id: str = Form(...),
device_type: str = Form("seismograph"), device_type: str = Form("seismograph"),
unit_type: str = Form("series3"), unit_type: str = Form("series3"),
deployed: bool = Form(False), deployed: str = Form(None),
retired: bool = Form(False), retired: str = Form(None),
note: str = Form(""), note: str = Form(""),
project_id: str = Form(None), project_id: str = Form(None),
location: str = Form(None), location: str = Form(None),
@@ -68,9 +161,11 @@ def add_roster_unit(
slm_measurement_range: str = Form(None), slm_measurement_range: str = Form(None),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
import logging logger.info(f"Adding unit: id={id}, device_type={device_type}, deployed={deployed}, retired={retired}")
logger = logging.getLogger(__name__)
logger.info(f"Adding unit: id={id}, device_type={device_type}, slm_tcp_port={slm_tcp_port}, slm_ftp_port={slm_ftp_port}") # 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 # 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_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
@@ -98,8 +193,8 @@ def add_roster_unit(
id=id, id=id,
device_type=device_type, device_type=device_type,
unit_type=unit_type, unit_type=unit_type,
deployed=deployed, deployed=deployed_bool,
retired=retired, retired=retired_bool,
note=note, note=note,
project_id=project_id, project_id=project_id,
location=location, location=location,
@@ -126,6 +221,24 @@ def add_roster_unit(
) )
db.add(unit) db.add(unit)
db.commit() 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} return {"message": "Unit added", "id": id, "device_type": device_type}
@@ -186,8 +299,8 @@ def edit_roster_unit(
unit_id: str, unit_id: str,
device_type: str = Form("seismograph"), device_type: str = Form("seismograph"),
unit_type: str = Form("series3"), unit_type: str = Form("series3"),
deployed: bool = Form(False), deployed: str = Form(None),
retired: bool = Form(False), retired: str = Form(None),
note: str = Form(""), note: str = Form(""),
project_id: str = Form(None), project_id: str = Form(None),
location: str = Form(None), location: str = Form(None),
@@ -216,6 +329,10 @@ def edit_roster_unit(
if not unit: if not unit:
raise HTTPException(status_code=404, detail="Unit not found") 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 # 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_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 slm_ftp_port_int = int(slm_ftp_port) if slm_ftp_port and slm_ftp_port.strip() else None
@@ -243,8 +360,8 @@ def edit_roster_unit(
# Update all fields # Update all fields
unit.device_type = device_type unit.device_type = device_type
unit.unit_type = unit_type unit.unit_type = unit_type
unit.deployed = deployed unit.deployed = deployed_bool
unit.retired = retired unit.retired = retired_bool
unit.note = note unit.note = note
unit.project_id = project_id unit.project_id = project_id
unit.location = location unit.location = location

View File

@@ -12,15 +12,20 @@ from sqlalchemy import func
from datetime import datetime, timedelta from datetime import datetime, timedelta
import httpx import httpx
import logging import logging
import os
from backend.database import get_db from backend.database import get_db
from backend.models import RosterUnit from backend.models import RosterUnit
from backend.routers.roster_edit import sync_slm_to_slmm_cache
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"]) router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"])
templates = Jinja2Templates(directory="templates") 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) @router.get("/stats", response_class=HTMLResponse)
async def get_slm_stats(request: Request, db: Session = Depends(get_db)): async def get_slm_stats(request: Request, db: Session = Depends(get_db)):
@@ -120,7 +125,7 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
async with httpx.AsyncClient(timeout=5.0) as client: async with httpx.AsyncClient(timeout=5.0) as client:
# Get measurement state # Get measurement state
state_response = await client.get( state_response = await client.get(
f"http://localhost:8100/api/nl43/{unit_id}/measurement-state" f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
) )
if state_response.status_code == 200: if state_response.status_code == 200:
state_data = state_response.json() state_data = state_response.json()
@@ -129,7 +134,7 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
# Get live status # Get live status
status_response = await client.get( status_response = await client.get(
f"http://localhost:8100/api/nl43/{unit_id}/live" f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
) )
if status_response.status_code == 200: if status_response.status_code == 200:
status_data = status_response.json() status_data = status_response.json()
@@ -162,7 +167,7 @@ async def control_slm(unit_id: str, action: str):
try: try:
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post( response = await client.post(
f"http://localhost:8100/api/nl43/{unit_id}/{action}" f"{SLMM_BASE_URL}/api/nl43/{unit_id}/{action}"
) )
if response.status_code == 200: if response.status_code == 200:
@@ -239,6 +244,21 @@ async def save_slm_config(request: Request, unit_id: str, db: Session = Depends(
db.commit() db.commit()
logger.info(f"Updated configuration for SLM {unit_id}") 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} return {"status": "success", "unit_id": unit_id}
except Exception as e: except Exception as e:

View File

@@ -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. 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 from fastapi.responses import StreamingResponse
import httpx import httpx
import websockets
import asyncio
import logging import logging
import os import os
@@ -17,6 +19,8 @@ router = APIRouter(prefix="/api/slmm", tags=["slmm"])
# SLMM backend URL - configurable via environment variable # SLMM backend URL - configurable via environment variable
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") 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") @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"]) @router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy_to_slmm(path: str, request: Request): async def proxy_to_slmm(path: str, request: Request):
""" """

View File

@@ -1,9 +1,9 @@
services: services:
# --- PRODUCTION --- # --- TERRA-VIEW PRODUCTION ---
seismo-backend: terra-view-prod:
build: . build: .
container_name: seismo-fleet-manager container_name: terra-view
ports: ports:
- "8001:8001" - "8001:8001"
volumes: volumes:
@@ -11,8 +11,10 @@ services:
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- ENVIRONMENT=production - ENVIRONMENT=production
- SLMM_BASE_URL=http://172.19.0.1:8100 - SLMM_BASE_URL=http://slmm:8100
restart: unless-stopped restart: unless-stopped
depends_on:
- slmm
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8001/health"] test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
interval: 30s interval: 30s
@@ -20,10 +22,10 @@ services:
retries: 3 retries: 3
start_period: 40s start_period: 40s
# --- DEVELOPMENT --- # --- TERRA-VIEW DEVELOPMENT ---
sfm-dev: terra-view-dev:
build: . build: .
container_name: sfm-dev container_name: terra-view-dev
ports: ports:
- "1001:8001" - "1001:8001"
volumes: volumes:
@@ -31,7 +33,10 @@ services:
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- ENVIRONMENT=development - ENVIRONMENT=development
- SLMM_BASE_URL=http://slmm:8100
restart: unless-stopped restart: unless-stopped
depends_on:
- slmm
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8001/health"] test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
interval: 30s interval: 30s
@@ -39,6 +44,28 @@ services:
retries: 3 retries: 3
start_period: 40s 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: volumes:
data: data:
data-dev: data-dev:

67
sync_slms_to_slmm.py Executable file
View File

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

View File

@@ -110,7 +110,7 @@
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
</svg> </svg>
Fleet Roster Devices
</a> </a>
<a href="/seismographs" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/seismographs' %}bg-gray-100 dark:bg-gray-700{% endif %}"> <a href="/seismographs" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/seismographs' %}bg-gray-100 dark:bg-gray-700{% endif %}">
@@ -194,7 +194,7 @@
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
</svg> </svg>
<span>Roster</span> <span>Devices</span>
</button> </button>
<button class="bottom-nav-btn" data-href="/settings" onclick="window.location.href='/settings'"> <button class="bottom-nav-btn" data-href="/settings" onclick="window.location.href='/settings'">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -0,0 +1,449 @@
<!-- Desktop Table View -->
<div class="hidden md:block rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
<table id="roster-table" class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('status')">
<div class="flex items-center gap-1">
Status
<span class="sort-indicator" data-column="status"></span>
</div>
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('id')">
<div class="flex items-center gap-1">
Unit ID
<span class="sort-indicator" data-column="id"></span>
</div>
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('type')">
<div class="flex items-center gap-1">
Type
<span class="sort-indicator" data-column="type"></span>
</div>
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Details
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('last_seen')">
<div class="flex items-center gap-1">
Last Seen
<span class="sort-indicator" data-column="last_seen"></span>
</div>
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('age')">
<div class="flex items-center gap-1">
Age
<span class="sort-indicator" data-column="age"></span>
</div>
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('note')">
<div class="flex items-center gap-1">
Note
<span class="sort-indicator" data-column="note"></span>
</div>
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody id="roster-tbody" class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
{% for unit in units %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
data-device-type="{{ unit.device_type }}"
data-status="{% if unit.deployed %}deployed{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
data-health="{{ unit.status }}"
data-id="{{ unit.id }}"
data-type="{{ unit.device_type }}"
data-last-seen="{{ unit.last_seen }}"
data-age="{{ unit.age }}"
data-note="{{ unit.note if unit.note else '' }}">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center space-x-2">
{% if unit.status == 'OK' %}
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
{% elif unit.status == 'Pending' %}
<span class="w-3 h-3 rounded-full bg-yellow-500" title="Pending"></span>
{% else %}
<span class="w-3 h-3 rounded-full bg-red-500" title="Missing"></span>
{% endif %}
{% if unit.deployed %}
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
{% else %}
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
{% endif %}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<a href="/unit/{{ unit.id }}" class="text-sm font-medium text-seismo-orange hover:text-seismo-burgundy hover:underline">
{{ unit.id }}
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if unit.device_type == 'modem' %}
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
Modem
</span>
{% else %}
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
Seismograph
</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-xs text-gray-600 dark:text-gray-400 space-y-1">
{% if unit.device_type == 'modem' %}
{% if unit.ip_address %}
<div><span class="font-mono">{{ unit.ip_address }}</span></div>
{% endif %}
{% if unit.phone_number %}
<div>{{ unit.phone_number }}</div>
{% endif %}
{% if unit.hardware_model %}
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
{% endif %}
{% else %}
{% if unit.next_calibration_due %}
<div>
<span class="text-gray-500 dark:text-gray-500">Cal Due:</span>
<span class="font-medium">{{ unit.next_calibration_due }}</span>
</div>
{% endif %}
{% if unit.deployed_with_modem_id %}
<div>
<span class="text-gray-500 dark:text-gray-500">Modem:</span>
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-seismo-orange hover:underline font-medium">
{{ unit.deployed_with_modem_id }}
</a>
</div>
{% endif %}
{% endif %}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-500 dark:text-gray-400">{{ unit.last_seen }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm
{% if unit.status == 'Missing' %}text-red-600 dark:text-red-400 font-semibold
{% elif unit.status == 'Pending' %}text-yellow-600 dark:text-yellow-400
{% else %}text-gray-500 dark:text-gray-400
{% endif %}">
{{ unit.age }}
</div>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs" title="{{ unit.note }}">
{{ unit.note if unit.note else '-' }}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex justify-end gap-2">
<button onclick="editUnit('{{ unit.id }}')"
class="text-seismo-orange hover:text-seismo-burgundy p-1" title="Edit">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
</button>
{% if unit.deployed %}
<button onclick="toggleDeployed('{{ unit.id }}', false)"
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 p-1" title="Mark as Benched">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg>
</button>
{% else %}
<button onclick="toggleDeployed('{{ unit.id }}', true)"
class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 p-1" title="Mark as Deployed">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</button>
{% endif %}
<button onclick="moveToIgnore('{{ unit.id }}')"
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 p-1" title="Move to Ignore List">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
</svg>
</button>
<button onclick="deleteUnit('{{ unit.id }}')"
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 p-1" title="Delete Unit">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Last updated indicator -->
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs text-gray-500 dark:text-gray-400 text-right">
Last updated: <span id="last-updated">{{ timestamp }}</span>
</div>
</div>
<!-- Mobile Card View -->
<div class="md:hidden space-y-3">
{% for unit in units %}
<div class="unit-card device-card"
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
data-device-type="{{ unit.device_type }}"
data-status="{% if unit.deployed %}deployed{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
data-health="{{ unit.status }}"
data-unit-id="{{ unit.id }}"
data-age="{{ unit.age }}">
<!-- Header: Status Dot + Unit ID + Status Badge -->
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
{% if unit.status == 'OK' %}
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
{% elif unit.status == 'Pending' %}
<span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
{% elif unit.status == 'Missing' %}
<span class="w-4 h-4 rounded-full bg-red-500" title="Missing"></span>
{% else %}
<span class="w-4 h-4 rounded-full bg-gray-400" title="No Data"></span>
{% endif %}
<span class="font-bold text-lg text-seismo-orange dark:text-seismo-orange">{{ unit.id }}</span>
</div>
<span class="px-3 py-1 rounded-full text-xs font-medium
{% if unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
{% endif %}">
{% if unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
</span>
</div>
<!-- Type Badge -->
<div class="mb-2">
{% if unit.device_type == 'modem' %}
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
Modem
</span>
{% else %}
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
Seismograph
</span>
{% endif %}
</div>
<!-- Location -->
{% if unit.address %}
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
📍 {{ unit.address }}
</div>
{% elif unit.coordinates %}
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
📍 {{ unit.coordinates }}
</div>
{% endif %}
<!-- Project ID -->
{% if unit.project_id %}
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
🏗️ {{ unit.project_id }}
</div>
{% endif %}
<!-- Last Seen -->
<div class="text-sm text-gray-500 dark:text-gray-500 mt-2">
🕐 {{ unit.age }}
</div>
<!-- Deployed/Benched Indicator -->
<div class="mt-2">
{% if unit.deployed %}
<span class="text-xs text-blue-600 dark:text-blue-400">
⚡ Deployed
</span>
{% else %}
<span class="text-xs text-gray-500 dark:text-gray-500">
📦 Benched
</span>
{% endif %}
</div>
<!-- Tap Hint -->
<div class="text-xs text-gray-400 mt-2 text-center border-t border-gray-200 dark:border-gray-700 pt-2">
Tap for details
</div>
</div>
{% endfor %}
<!-- Mobile Last Updated -->
<div class="text-xs text-gray-500 dark:text-gray-400 text-center py-2">
Last updated: <span id="last-updated-mobile">{{ timestamp }}</span>
</div>
</div>
<!-- Unit Detail Modal -->
<div id="unitModal" class="unit-modal">
<!-- Backdrop -->
<div class="unit-modal-backdrop" onclick="closeUnitModal(event)"></div>
<!-- Modal Content -->
<div class="unit-modal-content">
<!-- Handle Bar (Mobile Only) -->
<div class="modal-handle"></div>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 id="modalUnitId" class="text-xl font-bold text-gray-900 dark:text-white"></h3>
<button onclick="closeUnitModal(event)" data-close-modal class="w-10 h-10 flex items-center justify-center text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<!-- Content -->
<div id="modalContent" class="p-6">
<!-- Content will be populated by JavaScript -->
</div>
<!-- Actions -->
<div class="p-6 border-t border-gray-200 dark:border-gray-700 space-y-3">
<button id="modalEditBtn" class="w-full h-12 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors">
Edit Unit
</button>
<div class="grid grid-cols-2 gap-3">
<button id="modalDeployBtn" class="h-12 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
Deploy/Bench
</button>
<button id="modalDeleteBtn" class="h-12 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
Delete
</button>
</div>
</div>
</div>
</div>
<style>
.sort-indicator::after {
content: '⇅';
opacity: 0.3;
font-size: 12px;
}
.sort-indicator.asc::after {
content: '↑';
opacity: 1;
}
.sort-indicator.desc::after {
content: '↓';
opacity: 1;
}
</style>
<script>
// Update timestamp
const timestampElement = document.getElementById('last-updated');
if (timestampElement) {
timestampElement.textContent = new Date().toLocaleTimeString();
}
const timestampMobileElement = document.getElementById('last-updated-mobile');
if (timestampMobileElement) {
timestampMobileElement.textContent = new Date().toLocaleTimeString();
}
// Keep a lightweight status map around for the mobile modal
const rosterUnits = {{ units | tojson }};
window.rosterStatusMap = rosterUnits.reduce((acc, unit) => {
acc[unit.id] = {
status: unit.status || 'Unknown',
age: unit.age || 'N/A',
last: unit.last_seen || 'Never'
};
return acc;
}, {});
// Sorting state
let currentSort = { column: null, direction: 'asc' };
function sortTable(column) {
const tbody = document.getElementById('roster-tbody');
const rows = Array.from(tbody.getElementsByTagName('tr'));
// Determine sort direction
if (currentSort.column === column) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.column = column;
currentSort.direction = 'asc';
}
// Sort rows
rows.sort((a, b) => {
let aVal = a.getAttribute(`data-${column}`) || '';
let bVal = b.getAttribute(`data-${column}`) || '';
// Special handling for different column types
if (column === 'age') {
// Parse age strings like "2h 15m" or "45m" or "3d 5h"
aVal = parseAge(aVal);
bVal = parseAge(bVal);
} else if (column === 'status') {
// Sort by status priority: Missing > Pending > OK
const statusOrder = { 'Missing': 0, 'Pending': 1, 'OK': 2, '': 3 };
aVal = statusOrder[aVal] !== undefined ? statusOrder[aVal] : 999;
bVal = statusOrder[bVal] !== undefined ? statusOrder[bVal] : 999;
} else if (column === 'last_seen') {
// Sort by date
aVal = new Date(aVal).getTime() || 0;
bVal = new Date(bVal).getTime() || 0;
} else {
// String comparison (case-insensitive)
aVal = aVal.toLowerCase();
bVal = bVal.toLowerCase();
}
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
return 0;
});
// Re-append rows in sorted order
rows.forEach(row => tbody.appendChild(row));
// Update sort indicators
updateSortIndicators();
}
function parseAge(ageStr) {
// Parse age strings like "2h 15m", "45m", "3d 5h", "2w 3d"
if (!ageStr) return 0;
let totalMinutes = 0;
const weeks = ageStr.match(/(\d+)w/);
const days = ageStr.match(/(\d+)d/);
const hours = ageStr.match(/(\d+)h/);
const minutes = ageStr.match(/(\d+)m/);
if (weeks) totalMinutes += parseInt(weeks[1]) * 7 * 24 * 60;
if (days) totalMinutes += parseInt(days[1]) * 24 * 60;
if (hours) totalMinutes += parseInt(hours[1]) * 60;
if (minutes) totalMinutes += parseInt(minutes[1]);
return totalMinutes;
}
function updateSortIndicators() {
// Clear all indicators
document.querySelectorAll('.sort-indicator').forEach(indicator => {
indicator.className = 'sort-indicator';
});
// Set current indicator
if (currentSort.column) {
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
if (indicator) {
indicator.className = `sort-indicator ${currentSort.direction}`;
}
}
}
</script>

View File

@@ -140,32 +140,73 @@
<canvas id="liveChart"></canvas> <canvas id="liveChart"></canvas>
</div> </div>
<!-- Device Info --> <!-- Device Status Cards -->
<div class="mt-4 grid grid-cols-2 gap-4 text-sm"> <div class="mt-6">
<div> <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Device Status</h3>
<span class="text-gray-600 dark:text-gray-400">Battery:</span> <div class="grid grid-cols-4 gap-4">
<span class="font-medium text-gray-900 dark:text-white ml-2"> <!-- Battery Status -->
{% if current_status and current_status.battery_level %}{{ current_status.battery_level }}%{% else %}--{% endif %} <div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
</span> <div class="flex items-center justify-between mb-2">
</div> <span class="text-xs text-gray-600 dark:text-gray-400">Battery</span>
<div> <svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<span class="text-gray-600 dark:text-gray-400">Power:</span> <path d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V4a2 2 0 00-2-2H6zm0 2h8v12H6V4zm7 2a1 1 0 011 1v6a1 1 0 11-2 0V7a1 1 0 011-1z"/>
<span class="font-medium text-gray-900 dark:text-white ml-2"> </svg>
{% if current_status and current_status.power_source %}{{ current_status.power_source }}{% else %}--{% endif %} </div>
</span> <div id="battery-level" class="text-2xl font-bold text-gray-900 dark:text-white">
</div> {% if current_status and current_status.battery_level %}{{ current_status.battery_level }}%{% else %}--{% endif %}
<div> </div>
<span class="text-gray-600 dark:text-gray-400">Weighting:</span> <div class="mt-2 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<span class="font-medium text-gray-900 dark:text-white ml-2"> <div id="battery-bar" class="bg-green-500 h-2 rounded-full transition-all"
{% if unit.slm_frequency_weighting %}{{ unit.slm_frequency_weighting }}{% else %}--{% endif %} / style="width: {% if current_status and current_status.battery_level %}{{ current_status.battery_level }}%{% else %}0%{% endif %}">
{% if unit.slm_time_weighting %}{{ unit.slm_time_weighting }}{% else %}--{% endif %} </div>
</span> </div>
</div> </div>
<div>
<span class="text-gray-600 dark:text-gray-400">SD Remaining:</span> <!-- Power Source -->
<span class="font-medium text-gray-900 dark:text-white ml-2"> <div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
{% if current_status and current_status.sd_remaining_mb %}{{ current_status.sd_remaining_mb }} MB{% else %}--{% endif %} <div class="flex items-center justify-between mb-2">
</span> <span class="text-xs text-gray-600 dark:text-gray-400">Power</span>
<svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd"/>
</svg>
</div>
<div id="power-source" class="text-lg font-semibold text-gray-900 dark:text-white">
{% if current_status and current_status.power_source %}{{ current_status.power_source }}{% else %}--{% endif %}
</div>
</div>
<!-- SD Card Space -->
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-600 dark:text-gray-400">SD Card</span>
<svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M3 4a2 2 0 012-2h10a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V4zm2 0v12h10V4H5z"/>
</svg>
</div>
<div id="sd-remaining" class="text-lg font-semibold text-gray-900 dark:text-white">
{% if current_status and current_status.sd_remaining_mb %}{{ current_status.sd_remaining_mb }} MB{% else %}--{% endif %}
</div>
<div id="sd-ratio" class="text-xs text-gray-500 dark:text-gray-400">
{% if current_status and current_status.sd_free_ratio %}{{ current_status.sd_free_ratio }}% free{% else %}--{% endif %}
</div>
</div>
<!-- Last Update -->
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-600 dark:text-gray-400">Last Update</span>
<svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
</svg>
</div>
<div id="last-update" class="text-xs font-medium text-gray-700 dark:text-gray-300">
Just now
</div>
<div id="auto-refresh-indicator" class="mt-2 flex items-center text-xs text-green-600 dark:text-green-400">
<span class="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
Auto-refresh: 30s
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -429,6 +470,94 @@ async function controlUnit(unitId, action) {
} }
} }
// Auto-refresh status every 30 seconds
let refreshInterval;
const REFRESH_INTERVAL_MS = 30000; // 30 seconds
const unit_id = '{{ unit.id }}';
function updateDeviceStatus() {
fetch(`/api/slmm/${unit_id}/live`)
.then(response => response.json())
.then(result => {
if (result.status === 'ok' && result.data) {
const data = result.data;
// Update battery
if (document.getElementById('battery-level')) {
const batteryLevel = data.battery_level || '--';
document.getElementById('battery-level').textContent = batteryLevel === '--' ? '--' : `${batteryLevel}%`;
// Update battery bar
const batteryBar = document.getElementById('battery-bar');
if (batteryBar && batteryLevel !== '--') {
const level = parseInt(batteryLevel);
batteryBar.style.width = `${level}%`;
// Color based on level
if (level > 50) {
batteryBar.className = 'bg-green-500 h-2 rounded-full transition-all';
} else if (level > 20) {
batteryBar.className = 'bg-yellow-500 h-2 rounded-full transition-all';
} else {
batteryBar.className = 'bg-red-500 h-2 rounded-full transition-all';
}
}
}
// Update power source
if (document.getElementById('power-source')) {
document.getElementById('power-source').textContent = data.power_source || '--';
}
// Update SD card info
if (document.getElementById('sd-remaining')) {
const sdRemaining = data.sd_remaining_mb || '--';
document.getElementById('sd-remaining').textContent = sdRemaining === '--' ? '--' : `${sdRemaining} MB`;
}
if (document.getElementById('sd-ratio')) {
const sdRatio = data.sd_free_ratio || '--';
document.getElementById('sd-ratio').textContent = sdRatio === '--' ? '--' : `${sdRatio}% free`;
}
// Update last update timestamp
if (document.getElementById('last-update')) {
const now = new Date();
document.getElementById('last-update').textContent = now.toLocaleTimeString();
}
}
})
.catch(error => {
console.error('Failed to refresh device status:', error);
// Update last update with error indicator
if (document.getElementById('last-update')) {
document.getElementById('last-update').textContent = 'Update failed';
}
});
}
// Start auto-refresh
function startAutoRefresh() {
// Initial update
updateDeviceStatus();
// Set up interval
refreshInterval = setInterval(updateDeviceStatus, REFRESH_INTERVAL_MS);
console.log('Auto-refresh started (30s interval)');
}
// Stop auto-refresh
function stopAutoRefresh() {
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
console.log('Auto-refresh stopped');
}
}
// Start auto-refresh when page loads
document.addEventListener('DOMContentLoaded', startAutoRefresh);
// Cleanup on page unload // Cleanup on page unload
window.addEventListener('beforeunload', function() { window.addEventListener('beforeunload', function() {
if (window.currentWebSocket) { if (window.currentWebSocket) {

View File

@@ -0,0 +1,438 @@
<!-- Live View Panel for {{ unit.id }} -->
<div class="h-full flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ unit.id }}</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">
{% if unit.slm_model %}{{ unit.slm_model }}{% endif %}
{% if unit.slm_serial_number %} • S/N: {{ unit.slm_serial_number }}{% endif %}
</p>
{% if modem %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
via Modem: {{ modem.id }}{% if modem_ip %} ({{ modem_ip }}){% endif %}
</p>
{% elif modem_ip %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Direct: {{ modem_ip }}
</p>
{% else %}
<p class="text-xs text-red-500 dark:text-red-400 mt-1">
⚠️ No modem assigned or IP configured
</p>
{% endif %}
</div>
<!-- Measurement Status Badge -->
<div>
{% if is_measuring %}
<span class="px-4 py-2 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400 rounded-lg font-medium flex items-center">
<span class="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
Measuring
</span>
{% else %}
<span class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium">
Stopped
</span>
{% endif %}
</div>
</div>
<!-- Control Buttons -->
<div class="flex gap-2 mb-6">
<button onclick="controlUnit('{{ unit.id }}', 'start')"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Start
</button>
<button onclick="controlUnit('{{ unit.id }}', 'pause')"
class="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Pause
</button>
<button onclick="controlUnit('{{ unit.id }}', 'stop')"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"></path>
</svg>
Stop
</button>
<button onclick="controlUnit('{{ unit.id }}', 'reset')"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Reset
</button>
<button id="start-stream-btn" onclick="initLiveDataStream('{{ unit.id }}')"
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg flex items-center ml-auto">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
Start Live Stream
</button>
<button id="stop-stream-btn" onclick="stopLiveDataStream()" style="display: none;"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center ml-auto">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"></path>
</svg>
Stop Live Stream
</button>
</div>
<!-- Current Metrics -->
<div class="grid grid-cols-5 gap-4 mb-6">
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lp (Instant)</p>
<p id="live-lp" class="text-2xl font-bold text-blue-600 dark:text-blue-400">
{% if current_status and current_status.lp %}{{ current_status.lp }}{% else %}--{% endif %}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
</div>
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Leq (Average)</p>
<p id="live-leq" class="text-2xl font-bold text-green-600 dark:text-green-400">
{% if current_status and current_status.leq %}{{ current_status.leq }}{% else %}--{% endif %}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
</div>
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4">
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmax (Max)</p>
<p id="live-lmax" class="text-2xl font-bold text-red-600 dark:text-red-400">
{% if current_status and current_status.lmax %}{{ current_status.lmax }}{% else %}--{% endif %}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
</div>
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmin (Min)</p>
<p id="live-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">
{% if current_status and current_status.lmin %}{{ current_status.lmin }}{% else %}--{% endif %}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
</div>
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lpeak (Peak)</p>
<p id="live-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400">
{% if current_status and current_status.lpeak %}{{ current_status.lpeak }}{% else %}--{% endif %}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
</div>
</div>
<!-- Live Chart -->
<div class="flex-1 bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4" style="min-height: 400px;">
<canvas id="liveChart"></canvas>
</div>
<!-- Device Info -->
<div class="mt-4 grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-600 dark:text-gray-400">Battery:</span>
<span class="font-medium text-gray-900 dark:text-white ml-2">
{% if current_status and current_status.battery_level %}{{ current_status.battery_level }}%{% else %}--{% endif %}
</span>
</div>
<div>
<span class="text-gray-600 dark:text-gray-400">Power:</span>
<span class="font-medium text-gray-900 dark:text-white ml-2">
{% if current_status and current_status.power_source %}{{ current_status.power_source }}{% else %}--{% endif %}
</span>
</div>
<div>
<span class="text-gray-600 dark:text-gray-400">Weighting:</span>
<span class="font-medium text-gray-900 dark:text-white ml-2">
{% if unit.slm_frequency_weighting %}{{ unit.slm_frequency_weighting }}{% else %}--{% endif %} /
{% if unit.slm_time_weighting %}{{ unit.slm_time_weighting }}{% else %}--{% endif %}
</span>
</div>
<div>
<span class="text-gray-600 dark:text-gray-400">SD Remaining:</span>
<span class="font-medium text-gray-900 dark:text-white ml-2">
{% if current_status and current_status.sd_remaining_mb %}{{ current_status.sd_remaining_mb }} MB{% else %}--{% endif %}
</span>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
// Initialize Chart.js for live data visualization
function initializeChart() {
// Wait for Chart.js to load
if (typeof Chart === 'undefined') {
console.log('Waiting for Chart.js to load...');
setTimeout(initializeChart, 100);
return;
}
console.log('Chart.js loaded, version:', Chart.version);
const canvas = document.getElementById('liveChart');
if (!canvas) {
console.error('Chart canvas not found');
return;
}
console.log('Canvas found:', canvas);
// Destroy existing chart if it exists
if (window.liveChart && typeof window.liveChart.destroy === 'function') {
console.log('Destroying existing chart');
window.liveChart.destroy();
}
const ctx = canvas.getContext('2d');
console.log('Creating new chart...');
// Dark mode detection
const isDarkMode = document.documentElement.classList.contains('dark');
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
const textColor = isDarkMode ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)';
window.liveChart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [
{
label: 'Lp (Instantaneous)',
data: [],
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.3,
borderWidth: 2,
pointRadius: 0
},
{
label: 'Leq (Equivalent)',
data: [],
borderColor: 'rgb(34, 197, 94)',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
tension: 0.3,
borderWidth: 2,
pointRadius: 0
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: {
intersect: false,
mode: 'index'
},
scales: {
x: {
display: true,
grid: {
color: gridColor
},
ticks: {
color: textColor,
maxTicksLimit: 10
}
},
y: {
display: true,
title: {
display: true,
text: 'Sound Level (dB)',
color: textColor
},
grid: {
color: gridColor
},
ticks: {
color: textColor
},
min: 30,
max: 130
}
},
plugins: {
legend: {
labels: {
color: textColor
}
}
}
}
});
console.log('Chart created successfully:', window.liveChart);
}
// Initialize chart when DOM is ready
console.log('Executing initializeChart...');
initializeChart();
// WebSocket management (use global scope to avoid redeclaration)
if (typeof window.currentWebSocket === 'undefined') {
window.currentWebSocket = null;
}
function initLiveDataStream(unitId) {
// Close existing connection if any
if (window.currentWebSocket) {
window.currentWebSocket.close();
}
// Reset chart data
if (window.chartData) {
window.chartData.timestamps = [];
window.chartData.lp = [];
window.chartData.leq = [];
}
if (window.liveChart && window.liveChart.data && window.liveChart.data.datasets) {
window.liveChart.data.labels = [];
window.liveChart.data.datasets[0].data = [];
window.liveChart.data.datasets[1].data = [];
window.liveChart.update();
}
// WebSocket URL for SLMM backend via proxy
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`;
window.currentWebSocket = new WebSocket(wsUrl);
window.currentWebSocket.onopen = function() {
console.log('WebSocket connected');
// Toggle button visibility
const startBtn = document.getElementById('start-stream-btn');
const stopBtn = document.getElementById('stop-stream-btn');
if (startBtn) startBtn.style.display = 'none';
if (stopBtn) stopBtn.style.display = 'flex';
};
window.currentWebSocket.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
console.log('WebSocket data received:', data);
updateLiveMetrics(data);
updateLiveChart(data);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
window.currentWebSocket.onerror = function(error) {
console.error('WebSocket error:', error);
};
window.currentWebSocket.onclose = function() {
console.log('WebSocket closed');
// Toggle button visibility
const startBtn = document.getElementById('start-stream-btn');
const stopBtn = document.getElementById('stop-stream-btn');
if (startBtn) startBtn.style.display = 'flex';
if (stopBtn) stopBtn.style.display = 'none';
};
}
function stopLiveDataStream() {
if (window.currentWebSocket) {
window.currentWebSocket.close();
window.currentWebSocket = null;
}
}
// Update metrics display
function updateLiveMetrics(data) {
if (document.getElementById('live-lp')) {
document.getElementById('live-lp').textContent = data.lp || '--';
}
if (document.getElementById('live-leq')) {
document.getElementById('live-leq').textContent = data.leq || '--';
}
if (document.getElementById('live-lmax')) {
document.getElementById('live-lmax').textContent = data.lmax || '--';
}
if (document.getElementById('live-lmin')) {
document.getElementById('live-lmin').textContent = data.lmin || '--';
}
if (document.getElementById('live-lpeak')) {
document.getElementById('live-lpeak').textContent = data.lpeak || '--';
}
}
// Chart data storage (use global scope to avoid redeclaration)
if (typeof window.chartData === 'undefined') {
window.chartData = {
timestamps: [],
lp: [],
leq: []
};
}
// Update live chart
function updateLiveChart(data) {
const now = new Date();
window.chartData.timestamps.push(now.toLocaleTimeString());
window.chartData.lp.push(parseFloat(data.lp || 0));
window.chartData.leq.push(parseFloat(data.leq || 0));
// Keep only last 60 data points
if (window.chartData.timestamps.length > 60) {
window.chartData.timestamps.shift();
window.chartData.lp.shift();
window.chartData.leq.shift();
}
// Update chart if available
if (window.liveChart) {
window.liveChart.data.labels = window.chartData.timestamps;
window.liveChart.data.datasets[0].data = window.chartData.lp;
window.liveChart.data.datasets[1].data = window.chartData.leq;
window.liveChart.update('none');
}
}
// Control function
async function controlUnit(unitId, action) {
try {
const response = await fetch(`/api/slm-dashboard/control/${unitId}/${action}`, {
method: 'POST'
});
const result = await response.json();
if (result.status === 'ok') {
// Reload the live view to update status
setTimeout(() => {
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
target: '#live-view-panel',
swap: 'innerHTML'
});
}, 500);
} else {
alert(`Error: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Failed to control unit: ${error.message}`);
}
}
// Cleanup on page unload
window.addEventListener('beforeunload', function() {
if (window.currentWebSocket) {
window.currentWebSocket.close();
}
});
</script>

View File

@@ -1,20 +1,20 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Fleet Roster - Seismo Fleet Manager{% endblock %} {% block title %}Devices - Seismo Fleet Manager{% endblock %}
{% block content %} {% block content %}
<div class="mb-8"> <div class="mb-8">
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<div> <div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Fleet Roster</h1> <h1 class="text-3xl font-bold text-gray-900 dark:text-white">Devices</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Real-time status of all seismograph units</p> <p class="text-gray-600 dark:text-gray-400 mt-1">Manage all devices in your fleet</p>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
<button onclick="openAddUnitModal()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg flex items-center gap-2 transition-colors"> <button onclick="openAddUnitModal()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg flex items-center gap-2 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg> </svg>
Add Unit Add Device
</button> </button>
<button onclick="openImportModal()" class="px-4 py-2 bg-seismo-navy hover:bg-blue-800 text-white rounded-lg flex items-center gap-2 transition-colors"> <button onclick="openImportModal()" class="px-4 py-2 bg-seismo-navy hover:bg-blue-800 text-white rounded-lg flex items-center gap-2 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -31,69 +31,67 @@
<!-- Loading placeholder --> <!-- Loading placeholder -->
</div> </div>
<!-- Fleet Roster with Tabs --> <!-- Devices View with Filters -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6"> <div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<!-- Search Bar --> <!-- Filter Controls -->
<div class="mb-4"> <div class="mb-6 space-y-4">
<!-- Search Bar -->
<div class="relative"> <div class="relative">
<input <input
type="text" type="text"
id="roster-search" id="device-search"
placeholder="Search by Unit ID, Type, or Note..." placeholder="Search by Device ID, Type, or Note..."
class="w-full px-4 py-2 pl-10 pr-4 text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-seismo-orange" class="w-full px-4 py-2 pl-10 pr-4 text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-seismo-orange"
onkeyup="filterRosterTable()"> onkeyup="filterDevices()">
<svg class="absolute left-3 top-3 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="absolute left-3 top-3 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg> </svg>
</div> </div>
<!-- Filter Pills -->
<div class="flex flex-wrap gap-3">
<!-- Device Type Filter -->
<div class="flex gap-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 self-center">Type:</span>
<button class="filter-btn filter-device-type active-filter" data-value="all">All</button>
<button class="filter-btn filter-device-type" data-value="seismograph">Seismographs</button>
<button class="filter-btn filter-device-type" data-value="modem">Modems</button>
<button class="filter-btn filter-device-type" data-value="sound_level_meter">SLMs</button>
</div>
<!-- Status Filter -->
<div class="flex gap-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 self-center">Status:</span>
<button class="filter-btn filter-status active-filter" data-value="all">All</button>
<button class="filter-btn filter-status" data-value="deployed">Deployed</button>
<button class="filter-btn filter-status" data-value="benched">Benched</button>
<button class="filter-btn filter-status" data-value="retired">Retired</button>
<button class="filter-btn filter-status" data-value="ignored">Ignored</button>
</div>
<!-- Health Status Filter (for non-retired/ignored devices) -->
<div class="flex gap-2" id="health-filter-group">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 self-center">Health:</span>
<button class="filter-btn filter-health active-filter" data-value="all">All</button>
<button class="filter-btn filter-health" data-value="ok">OK</button>
<button class="filter-btn filter-health" data-value="pending">Pending</button>
<button class="filter-btn filter-health" data-value="missing">Missing</button>
</div>
</div>
<!-- Results Count -->
<div class="text-sm text-gray-600 dark:text-gray-400">
Showing <span id="visible-count">0</span> of <span id="total-count">0</span> devices
</div>
</div> </div>
<!-- Tab Bar --> <!-- Device List Container -->
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-4"> <div id="device-content"
<button hx-get="/partials/devices-all"
class="px-4 py-2 text-sm font-medium roster-tab-button active-roster-tab"
data-endpoint="/partials/roster-deployed"
hx-get="/partials/roster-deployed"
hx-target="#roster-content"
hx-swap="innerHTML">
Deployed
</button>
<button
class="px-4 py-2 text-sm font-medium roster-tab-button"
data-endpoint="/partials/roster-benched"
hx-get="/partials/roster-benched"
hx-target="#roster-content"
hx-swap="innerHTML">
Benched
</button>
<button
class="px-4 py-2 text-sm font-medium roster-tab-button"
data-endpoint="/partials/roster-retired"
hx-get="/partials/roster-retired"
hx-target="#roster-content"
hx-swap="innerHTML">
Retired
</button>
<button
class="px-4 py-2 text-sm font-medium roster-tab-button"
data-endpoint="/partials/roster-ignored"
hx-get="/partials/roster-ignored"
hx-target="#roster-content"
hx-swap="innerHTML">
Ignored
</button>
</div>
<!-- Tab Content Target -->
<div id="roster-content"
hx-get="/partials/roster-deployed"
hx-trigger="load" hx-trigger="load"
hx-swap="innerHTML"> hx-swap="innerHTML">
<p class="text-gray-500 dark:text-gray-400">Loading roster data...</p> <p class="text-gray-500 dark:text-gray-400">Loading devices...</p>
</div> </div>
</div> </div>
@@ -114,9 +112,9 @@
<form id="addUnitForm" hx-post="/api/roster/add" hx-swap="none" class="p-6 space-y-4"> <form id="addUnitForm" hx-post="/api/roster/add" hx-swap="none" class="p-6 space-y-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Unit ID *</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Unit ID *</label>
<input type="text" name="id" required <input type="text" name="id" required pattern="[^\s]+" title="Unit ID cannot contain spaces"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange" class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"
placeholder="BE1234"> placeholder="BE1234 or MODEM-001 (no spaces)">
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Device Type *</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Device Type *</label>
@@ -550,7 +548,29 @@
// Show success message // Show success message
alert('Unit added successfully!'); alert('Unit added successfully!');
} else { } 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);
} }
}); });
@@ -904,33 +924,203 @@
} }
} }
// Filter roster table based on search input // ===== DEVICE FILTERING SYSTEM =====
function filterRosterTable() {
const searchInput = document.getElementById('roster-search').value.toLowerCase();
const table = document.querySelector('#roster-content table tbody');
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) { // Update filter value
const cells = row.getElementsByTagName('td'); activeFilters.deviceType = this.dataset.value;
if (cells.length === 0) continue; // Skip header or empty rows
const unitId = cells[1]?.textContent?.toLowerCase() || ''; // Apply filters
const unitType = cells[2]?.textContent?.toLowerCase() || ''; filterDevices();
const note = cells[6]?.textContent?.toLowerCase() || ''; });
});
const matches = unitId.includes(searchInput) || // Status filter buttons
unitType.includes(searchInput) || document.querySelectorAll('.filter-status').forEach(btn => {
note.includes(searchInput); 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();
} }
</script> </script>
<style> <style>
/* Filter Button Styles */
.filter-btn {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.2s;
background-color: #f3f4f6; /* gray-100 */
color: #6b7280; /* gray-500 */
border: 1px solid #e5e7eb; /* gray-200 */
cursor: pointer;
}
.filter-btn:hover {
background-color: #e5e7eb; /* gray-200 */
color: #374151; /* gray-700 */
}
.filter-btn.active-filter {
background-color: #f48b1c; /* seismo-orange */
color: white;
border-color: #f48b1c;
}
/* Dark mode filter buttons */
@media (prefers-color-scheme: dark) {
.filter-btn {
background-color: #374151; /* gray-700 */
color: #9ca3af; /* gray-400 */
border-color: #4b5563; /* gray-600 */
}
.filter-btn:hover {
background-color: #4b5563; /* gray-600 */
color: #e5e7eb; /* gray-200 */
}
.filter-btn.active-filter {
background-color: #f48b1c;
color: white;
border-color: #f48b1c;
}
}
/* Legacy tab button styles (keeping for modals and other uses) */
.roster-tab-button { .roster-tab-button {
color: #6b7280; /* gray-500 */ color: #6b7280; /* gray-500 */
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;

View File

@@ -158,6 +158,15 @@ function initLiveDataStream(unitId) {
currentWebSocket = new WebSocket(wsUrl); currentWebSocket = new WebSocket(wsUrl);
currentWebSocket.onopen = function() {
console.log('WebSocket connected');
// Toggle button visibility
const startBtn = document.getElementById('start-stream-btn');
const stopBtn = document.getElementById('stop-stream-btn');
if (startBtn) startBtn.style.display = 'none';
if (stopBtn) stopBtn.style.display = 'flex';
};
currentWebSocket.onmessage = function(event) { currentWebSocket.onmessage = function(event) {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
updateLiveChart(data); updateLiveChart(data);
@@ -170,9 +179,21 @@ function initLiveDataStream(unitId) {
currentWebSocket.onclose = function() { currentWebSocket.onclose = function() {
console.log('WebSocket closed'); console.log('WebSocket closed');
// Toggle button visibility
const startBtn = document.getElementById('start-stream-btn');
const stopBtn = document.getElementById('stop-stream-btn');
if (startBtn) startBtn.style.display = 'flex';
if (stopBtn) stopBtn.style.display = 'none';
}; };
} }
function stopLiveDataStream() {
if (currentWebSocket) {
currentWebSocket.close();
currentWebSocket = null;
}
}
// Update live chart with new data point // Update live chart with new data point
let chartData = { let chartData = {
timestamps: [], timestamps: [],