diff --git a/backend/routers/slm_dashboard.py b/backend/routers/slm_dashboard.py index 3d9c0df..bcfe057 100644 --- a/backend/routers/slm_dashboard.py +++ b/backend/routers/slm_dashboard.py @@ -6,7 +6,7 @@ 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 fastapi.responses import HTMLResponse, JSONResponse from sqlalchemy.orm import Session from sqlalchemy import func from datetime import datetime, timedelta @@ -60,14 +60,20 @@ async def get_slm_stats(request: Request, db: Session = Depends(get_db)): async def get_slm_units( request: Request, db: Session = Depends(get_db), - search: str = Query(None) + search: str = Query(None), + project: str = Query(None) ): """ Get list of SLM units for the sidebar. Returns HTML partial with unit cards. + Supports filtering by search term and project. """ query = db.query(RosterUnit).filter_by(device_type="sound_level_meter") + # Filter by project if provided + if project: + query = query.filter(RosterUnit.project_id == project) + # Filter by search term if provided if search: search_term = f"%{search}%" @@ -326,3 +332,55 @@ async def test_modem_connection(modem_id: str, db: Session = Depends(get_db)): "modem_id": modem_id, "detail": str(e) } + + +@router.get("/diagnostics/{unit_id}", response_class=HTMLResponse) +async def get_diagnostics(request: Request, unit_id: str, db: Session = Depends(get_db)): + """ + Get compact diagnostics card for a specific SLM unit. + Returns HTML partial with key metrics only. + """ + unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first() + + if not unit: + return HTMLResponse( + content='
Unit not found
', + status_code=404 + ) + + # Get modem info + 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: + # Try modem_rx_host first (if it exists), then fall back to ip_address + modem_ip = getattr(modem, 'modem_rx_host', None) or modem.ip_address + elif unit.slm_host: + modem_ip = unit.slm_host + + return templates.TemplateResponse("partials/slm_diagnostics_card.html", { + "request": request, + "unit": unit, + "modem": modem, + "modem_ip": modem_ip + }) + + +@router.get("/projects") +async def get_projects(db: Session = Depends(get_db)): + """ + Get list of unique projects from deployed SLMs. + Returns JSON array of project names. + """ + projects = db.query(RosterUnit.project_id).filter( + RosterUnit.device_type == "sound_level_meter", + RosterUnit.deployed == True, + RosterUnit.retired == False, + RosterUnit.project_id.isnot(None) + ).distinct().order_by(RosterUnit.project_id).all() + + # Extract project names from query result tuples + project_list = [p[0] for p in projects if p[0]] + + return JSONResponse(content={"projects": project_list}) diff --git a/templates/partials/slm_diagnostics_card.html b/templates/partials/slm_diagnostics_card.html new file mode 100644 index 0000000..b5a77a8 --- /dev/null +++ b/templates/partials/slm_diagnostics_card.html @@ -0,0 +1,206 @@ + +
+ +
+
+

{{ unit.id }}

+

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

+
+ + + + Loading... + +
+ + +
+
+
+ + + + Connection +
+
+ {% if modem %} + via {{ modem.id }} + {% elif modem_ip %} + Direct: {{ modem_ip }} + {% else %} + Not configured + {% endif %} + +
+
+
+ + +
+
+

Lp (Instant)

+

--

+

dB

+
+ +
+

Leq (Average)

+

--

+

dB

+
+ +
+

Lmax (Max)

+

--

+

dB

+
+ +
+

Lmin (Min)

+

--

+

dB

+
+
+ + +
+
+
+ Battery + + + +
+
--
+
+
+
+
+ +
+
+ Power + + + +
+
--
+
+
+ + +
+
+
+ + + + Last Check-in +
+
+ {% if unit.slm_last_check %} + {{ unit.slm_last_check.strftime('%Y-%m-%d %H:%M:%S') }} + {% else %} + Never + {% endif %} +
+
+
+ + +
+ +
+
+ + diff --git a/templates/partials/slm_live_view.html b/templates/partials/slm_live_view.html index 54e2560..b1b11f8 100644 --- a/templates/partials/slm_live_view.html +++ b/templates/partials/slm_live_view.html @@ -686,8 +686,13 @@ async function controlUnit(unitId, action) { if (typeof window.refreshInterval === 'undefined') { window.refreshInterval = null; } -const REFRESH_INTERVAL_MS = 30000; // 30 seconds -const unit_id = '{{ unit.id }}'; +if (typeof window.REFRESH_INTERVAL_MS === 'undefined') { + window.REFRESH_INTERVAL_MS = 30000; // 30 seconds +} +if (typeof window.unit_id === 'undefined' || window.unit_id !== '{{ unit.id }}') { + // Keep HTMX reloads from reusing the old unit id + window.unit_id = '{{ unit.id }}'; +} function updateDeviceStatus() { fetch(`/api/slmm/${unit_id}/live`) @@ -755,7 +760,7 @@ function startAutoRefresh() { updateDeviceStatus(); // Set up interval - refreshInterval = setInterval(updateDeviceStatus, REFRESH_INTERVAL_MS); + refreshInterval = setInterval(updateDeviceStatus, window.REFRESH_INTERVAL_MS); console.log('Auto-refresh started (30s interval)'); } @@ -778,6 +783,12 @@ if (typeof window.timerInterval === 'undefined') { window.timerInterval = null; window.measurementStartTime = null; // ISO string from backend } +if (typeof window.timerSource === 'undefined') { + window.timerSource = null; +} +if (typeof window.lastFtpErrorTime === 'undefined') { + window.lastFtpErrorTime = null; +} // Format elapsed time as HH:MM:SS function formatElapsedTime(milliseconds) { @@ -818,42 +829,52 @@ function updateTimerDisplay() { async function syncTimerWithBackend(measurementState, measurementStartTime) { // Device returns "Start" when measuring, "Stop" when stopped const isMeasuring = measurementState === 'Start'; + const now = Date.now(); if (isMeasuring && measurementStartTime) { // Measurement is running - check both backend and FTP timestamps // Use whichever is earlier (older = actual measurement start) // First check FTP for potentially older timestamp - try { - const response = await fetch(`/api/slmm/${unit_id}/ftp/latest-measurement-time`); - const result = await response.json(); - - if (result.status === 'ok' && result.latest_timestamp) { - const backendTime = new Date(measurementStartTime + 'Z'); - const ftpTime = new Date(result.latest_timestamp + 'Z'); - - // Use the earlier timestamp (represents actual measurement start) - if (ftpTime < backendTime) { - window.measurementStartTime = result.latest_timestamp; - window.timerSource = 'ftp'; - console.log('Timer synced with FTP folder (earlier):', result.latest_folder, '@', result.latest_timestamp); - } else { - window.measurementStartTime = measurementStartTime; - window.timerSource = 'backend'; - console.log('Timer synced with backend state (earlier):', measurementStartTime); - } - } else { - // No FTP timestamp, use backend - window.measurementStartTime = measurementStartTime; - window.timerSource = 'backend'; - console.log('Timer synced with backend state:', measurementStartTime); - } - } catch (error) { - console.error('Failed to check FTP timestamp:', error); - // Fallback to backend on error + const shouldSkipFtp = window.lastFtpErrorTime && (now - window.lastFtpErrorTime < 10000); + if (shouldSkipFtp) { window.measurementStartTime = measurementStartTime; window.timerSource = 'backend'; - console.log('Timer synced with backend state (FTP check failed):', measurementStartTime); + console.log('Timer using backend state (skipping FTP due to recent error):', measurementStartTime); + } else { + try { + const response = await fetch(`/api/slmm/${unit_id}/ftp/latest-measurement-time`); + const result = await response.json(); + + if (result.status === 'ok' && result.latest_timestamp) { + const backendTime = new Date(measurementStartTime + 'Z'); + const ftpTime = new Date(result.latest_timestamp + 'Z'); + + // Use the earlier timestamp (represents actual measurement start) + if (ftpTime < backendTime) { + window.measurementStartTime = result.latest_timestamp; + window.timerSource = 'ftp'; + console.log('Timer synced with FTP folder (earlier):', result.latest_folder, '@', result.latest_timestamp); + } else { + window.measurementStartTime = measurementStartTime; + window.timerSource = 'backend'; + console.log('Timer synced with backend state (earlier):', measurementStartTime); + } + window.lastFtpErrorTime = null; + } else { + // No FTP timestamp, use backend + window.measurementStartTime = measurementStartTime; + window.timerSource = 'backend'; + console.log('Timer synced with backend state:', measurementStartTime); + } + } catch (error) { + console.error('Failed to check FTP timestamp:', error); + window.lastFtpErrorTime = now; + // Fallback to backend on error + window.measurementStartTime = measurementStartTime; + window.timerSource = 'backend'; + console.log('Timer synced with backend state (FTP check failed):', measurementStartTime); + } } // Start interval if not already running @@ -869,27 +890,34 @@ async function syncTimerWithBackend(measurementState, measurementStartTime) { // Try FTP fallback to get measurement start from latest folder timestamp if (!window.measurementStartTime || window.timerSource !== 'ftp') { console.log('Device measuring but no backend start time - checking FTP fallback...'); - try { - const response = await fetch(`/api/slmm/${unit_id}/ftp/latest-measurement-time`); - const result = await response.json(); + const skipFtp = window.lastFtpErrorTime && (Date.now() - window.lastFtpErrorTime < 10000); + if (!skipFtp) { + try { + const response = await fetch(`/api/slmm/${unit_id}/ftp/latest-measurement-time`); + const result = await response.json(); - if (result.status === 'ok' && result.latest_timestamp) { - window.measurementStartTime = result.latest_timestamp; - window.timerSource = 'ftp'; - console.log('Timer synced with FTP folder:', result.latest_folder, '@', result.latest_timestamp); + if (result.status === 'ok' && result.latest_timestamp) { + window.measurementStartTime = result.latest_timestamp; + window.timerSource = 'ftp'; + console.log('Timer synced with FTP folder:', result.latest_folder, '@', result.latest_timestamp); - // Start timer interval if not already running - if (!window.timerInterval) { - window.timerInterval = setInterval(updateTimerDisplay, 1000); - console.log('Timer display started (FTP source)'); + // Start timer interval if not already running + if (!window.timerInterval) { + window.timerInterval = setInterval(updateTimerDisplay, 1000); + console.log('Timer display started (FTP source)'); + } + + updateTimerDisplay(); + window.lastFtpErrorTime = null; + } else { + console.log('No FTP timestamp available'); } - - updateTimerDisplay(); - } else { - console.log('No FTP timestamp available'); + } catch (error) { + console.error('Failed to get FTP timestamp:', error); + window.lastFtpErrorTime = Date.now(); } - } catch (error) { - console.error('Failed to get FTP timestamp:', error); + } else { + console.log('Skipping FTP fallback due to recent error'); } } } else { diff --git a/templates/sound_level_meters.html b/templates/sound_level_meters.html index 0e48b7d..b00f27e 100644 --- a/templates/sound_level_meters.html +++ b/templates/sound_level_meters.html @@ -28,14 +28,28 @@

Active Units

-
- + + + + +
@@ -93,9 +107,94 @@ + + + {% endblock %}