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...
+
+
+
+
+
+
+
+
+ {% if modem %}
+ via {{ modem.id }}
+ {% elif modem_ip %}
+ Direct: {{ modem_ip }}
+ {% else %}
+ Not configured
+ {% endif %}
+
+
+
+
+
+
+
+
+
Lp (Instant)
+
--
+
dB
+
+
+
+
Leq (Average)
+
--
+
dB
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% 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 @@
+
+
+
+
+
+
Command Center
+
+
+
+
+
+
+
+
+
{% endblock %}