sfm-old-042 #6

Merged
serversdown merged 3 commits from sfm-old-042 into main 2026-01-12 11:22:08 -05:00
4 changed files with 513 additions and 155 deletions
Showing only changes of commit e1b965c24c - Show all commits

View File

@@ -6,7 +6,7 @@ Provides API endpoints for the Sound Level Meters dashboard page.
from fastapi import APIRouter, Request, Depends, Query from fastapi import APIRouter, Request, Depends, Query
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func from sqlalchemy import func
from datetime import datetime, timedelta 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( async def get_slm_units(
request: Request, request: Request,
db: Session = Depends(get_db), 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. Get list of SLM units for the sidebar.
Returns HTML partial with unit cards. Returns HTML partial with unit cards.
Supports filtering by search term and project.
""" """
query = db.query(RosterUnit).filter_by(device_type="sound_level_meter") 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 # Filter by search term if provided
if search: if search:
search_term = f"%{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, "modem_id": modem_id,
"detail": str(e) "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='<div class="p-6 text-center text-red-600">Unit not found</div>',
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})

View File

@@ -0,0 +1,206 @@
<!-- Compact Diagnostics Card for {{ unit.id }} -->
<div class="h-full flex flex-col p-6">
<!-- 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>
</div>
<!-- Status Badge -->
<span id="diag-status-badge" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium">
Loading...
</span>
</div>
<!-- Connection Status -->
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 mb-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<svg class="w-5 h-5 text-gray-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.05 3.636a1 1 0 010 1.414 7 7 0 000 9.9 1 1 0 11-1.414 1.414 9 9 0 010-12.728 1 1 0 011.414 0zm9.9 0a1 1 0 011.414 0 9 9 0 010 12.728 1 1 0 11-1.414-1.414 7 7 0 000-9.9 1 1 0 010-1.414zM7.879 6.464a1 1 0 010 1.414 3 3 0 000 4.243 1 1 0 11-1.415 1.414 5 5 0 010-7.07 1 1 0 011.415 0zm4.242 0a1 1 0 011.415 0 5 5 0 010 7.072 1 1 0 01-1.415-1.415 3 3 0 000-4.242 1 1 0 010-1.415zM10 9a1 1 0 011 1v.01a1 1 0 11-2 0V10a1 1 0 011-1z" clip-rule="evenodd"/>
</svg>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Connection</span>
</div>
<div>
{% if modem %}
<span class="text-sm text-gray-600 dark:text-gray-400">via {{ modem.id }}</span>
{% elif modem_ip %}
<span class="text-sm text-gray-600 dark:text-gray-400">Direct: {{ modem_ip }}</span>
{% else %}
<span class="text-sm text-red-600 dark:text-red-400">Not configured</span>
{% endif %}
<span id="connection-status" class="ml-2 w-2 h-2 bg-gray-400 rounded-full inline-block"></span>
</div>
</div>
</div>
<!-- Current Sound Levels -->
<div class="grid grid-cols-2 gap-3 mb-4">
<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="diag-lp" class="text-2xl font-bold text-blue-600 dark:text-blue-400">--</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="diag-leq" class="text-2xl font-bold text-green-600 dark:text-green-400">--</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="diag-lmax" class="text-2xl font-bold text-red-600 dark:text-red-400">--</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="diag-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">--</p>
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
</div>
</div>
<!-- Battery and Power -->
<div class="grid grid-cols-2 gap-3 mb-4">
<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">Battery</span>
<svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<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"/>
</svg>
</div>
<div id="diag-battery-level" class="text-xl font-bold text-gray-900 dark:text-white">--</div>
<div class="mt-2 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div id="diag-battery-bar" class="bg-gray-400 h-2 rounded-full transition-all" style="width: 0%"></div>
</div>
</div>
<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">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="diag-power-source" class="text-lg font-semibold text-gray-900 dark:text-white">--</div>
</div>
</div>
<!-- Last Check-in -->
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 mb-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<svg class="w-5 h-5 text-gray-500 mr-2" 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>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Last Check-in</span>
</div>
<div>
{% if unit.slm_last_check %}
<span class="text-sm text-gray-600 dark:text-gray-400">{{ unit.slm_last_check.strftime('%Y-%m-%d %H:%M:%S') }}</span>
{% else %}
<span class="text-sm text-gray-500 dark:text-gray-500">Never</span>
{% endif %}
</div>
</div>
</div>
<!-- Open Command Center Button -->
<div class="mt-auto">
<button onclick="openCommandCenter('{{ unit.id }}')"
class="w-full px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium flex items-center justify-center transition-colors">
<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="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
</svg>
Open Command Center
</button>
</div>
</div>
<script>
(function() {
const diagUnitId = '{{ unit.id }}';
// Clear any existing connections before starting new ones
window.SLMConnectionManager.setCurrentUnit(diagUnitId);
function updateDiagnosticsData() {
fetch(`/api/slmm/${diagUnitId}/live`)
.then(response => response.json())
.then(result => {
if (result.status === 'ok' && result.data) {
const data = result.data;
// Update status badge
const statusBadge = document.getElementById('diag-status-badge');
if (statusBadge) {
const isMeasuring = data.measurement_state === 'Start';
if (isMeasuring) {
statusBadge.className = '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';
statusBadge.innerHTML = '<span class="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>Measuring';
} else {
statusBadge.className = 'px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium';
statusBadge.textContent = 'Stopped';
}
}
// Update sound levels
['lp', 'leq', 'lmax', 'lmin'].forEach(metric => {
const el = document.getElementById(`diag-${metric}`);
if (el) el.textContent = data[metric] || '--';
});
// Update battery
const batteryEl = document.getElementById('diag-battery-level');
const batteryBar = document.getElementById('diag-battery-bar');
if (batteryEl && data.battery_level) {
const level = parseInt(data.battery_level);
batteryEl.textContent = `${level}%`;
if (batteryBar) {
batteryBar.style.width = `${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
const powerEl = document.getElementById('diag-power-source');
if (powerEl) powerEl.textContent = data.power_source || '--';
// Update connection status
const connStatus = document.getElementById('connection-status');
if (connStatus) {
connStatus.className = 'ml-2 w-2 h-2 bg-green-500 rounded-full inline-block';
}
}
})
.catch(error => {
console.error('Failed to refresh diagnostics:', error);
const connStatus = document.getElementById('connection-status');
if (connStatus) {
connStatus.className = 'ml-2 w-2 h-2 bg-red-500 rounded-full inline-block';
}
});
}
// Initial update
updateDiagnosticsData();
// Set up refresh interval and register it
const interval = setInterval(updateDiagnosticsData, 10000);
window.SLMConnectionManager.registerInterval(interval);
console.log(`Diagnostics card for ${diagUnitId} initialized`);
})();
</script>

View File

@@ -686,8 +686,13 @@ async function controlUnit(unitId, action) {
if (typeof window.refreshInterval === 'undefined') { if (typeof window.refreshInterval === 'undefined') {
window.refreshInterval = null; window.refreshInterval = null;
} }
const REFRESH_INTERVAL_MS = 30000; // 30 seconds if (typeof window.REFRESH_INTERVAL_MS === 'undefined') {
const unit_id = '{{ unit.id }}'; 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() { function updateDeviceStatus() {
fetch(`/api/slmm/${unit_id}/live`) fetch(`/api/slmm/${unit_id}/live`)
@@ -755,7 +760,7 @@ function startAutoRefresh() {
updateDeviceStatus(); updateDeviceStatus();
// Set up interval // Set up interval
refreshInterval = setInterval(updateDeviceStatus, REFRESH_INTERVAL_MS); refreshInterval = setInterval(updateDeviceStatus, window.REFRESH_INTERVAL_MS);
console.log('Auto-refresh started (30s interval)'); console.log('Auto-refresh started (30s interval)');
} }
@@ -778,6 +783,12 @@ if (typeof window.timerInterval === 'undefined') {
window.timerInterval = null; window.timerInterval = null;
window.measurementStartTime = null; // ISO string from backend 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 // Format elapsed time as HH:MM:SS
function formatElapsedTime(milliseconds) { function formatElapsedTime(milliseconds) {
@@ -818,12 +829,19 @@ function updateTimerDisplay() {
async function syncTimerWithBackend(measurementState, measurementStartTime) { async function syncTimerWithBackend(measurementState, measurementStartTime) {
// Device returns "Start" when measuring, "Stop" when stopped // Device returns "Start" when measuring, "Stop" when stopped
const isMeasuring = measurementState === 'Start'; const isMeasuring = measurementState === 'Start';
const now = Date.now();
if (isMeasuring && measurementStartTime) { if (isMeasuring && measurementStartTime) {
// Measurement is running - check both backend and FTP timestamps // Measurement is running - check both backend and FTP timestamps
// Use whichever is earlier (older = actual measurement start) // Use whichever is earlier (older = actual measurement start)
// First check FTP for potentially older timestamp // First check FTP for potentially older timestamp
const shouldSkipFtp = window.lastFtpErrorTime && (now - window.lastFtpErrorTime < 10000);
if (shouldSkipFtp) {
window.measurementStartTime = measurementStartTime;
window.timerSource = 'backend';
console.log('Timer using backend state (skipping FTP due to recent error):', measurementStartTime);
} else {
try { try {
const response = await fetch(`/api/slmm/${unit_id}/ftp/latest-measurement-time`); const response = await fetch(`/api/slmm/${unit_id}/ftp/latest-measurement-time`);
const result = await response.json(); const result = await response.json();
@@ -842,6 +860,7 @@ async function syncTimerWithBackend(measurementState, measurementStartTime) {
window.timerSource = 'backend'; window.timerSource = 'backend';
console.log('Timer synced with backend state (earlier):', measurementStartTime); console.log('Timer synced with backend state (earlier):', measurementStartTime);
} }
window.lastFtpErrorTime = null;
} else { } else {
// No FTP timestamp, use backend // No FTP timestamp, use backend
window.measurementStartTime = measurementStartTime; window.measurementStartTime = measurementStartTime;
@@ -850,11 +869,13 @@ async function syncTimerWithBackend(measurementState, measurementStartTime) {
} }
} catch (error) { } catch (error) {
console.error('Failed to check FTP timestamp:', error); console.error('Failed to check FTP timestamp:', error);
window.lastFtpErrorTime = now;
// Fallback to backend on error // Fallback to backend on error
window.measurementStartTime = measurementStartTime; window.measurementStartTime = measurementStartTime;
window.timerSource = 'backend'; window.timerSource = 'backend';
console.log('Timer synced with backend state (FTP check failed):', measurementStartTime); console.log('Timer synced with backend state (FTP check failed):', measurementStartTime);
} }
}
// Start interval if not already running // Start interval if not already running
if (!window.timerInterval) { if (!window.timerInterval) {
@@ -869,6 +890,8 @@ async function syncTimerWithBackend(measurementState, measurementStartTime) {
// Try FTP fallback to get measurement start from latest folder timestamp // Try FTP fallback to get measurement start from latest folder timestamp
if (!window.measurementStartTime || window.timerSource !== 'ftp') { if (!window.measurementStartTime || window.timerSource !== 'ftp') {
console.log('Device measuring but no backend start time - checking FTP fallback...'); console.log('Device measuring but no backend start time - checking FTP fallback...');
const skipFtp = window.lastFtpErrorTime && (Date.now() - window.lastFtpErrorTime < 10000);
if (!skipFtp) {
try { try {
const response = await fetch(`/api/slmm/${unit_id}/ftp/latest-measurement-time`); const response = await fetch(`/api/slmm/${unit_id}/ftp/latest-measurement-time`);
const result = await response.json(); const result = await response.json();
@@ -885,11 +908,16 @@ async function syncTimerWithBackend(measurementState, measurementStartTime) {
} }
updateTimerDisplay(); updateTimerDisplay();
window.lastFtpErrorTime = null;
} else { } else {
console.log('No FTP timestamp available'); console.log('No FTP timestamp available');
} }
} catch (error) { } catch (error) {
console.error('Failed to get FTP timestamp:', error); console.error('Failed to get FTP timestamp:', error);
window.lastFtpErrorTime = Date.now();
}
} else {
console.log('Skipping FTP fallback due to recent error');
} }
} }
} else { } else {

View File

@@ -28,14 +28,28 @@
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Active Units</h2> <h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Active Units</h2>
<!-- Search/Filter --> <!-- Search/Filter -->
<div class="mb-4"> <div class="mb-4 space-y-2">
<input type="text" <!-- Project Filter -->
<select id="project-filter"
name="project"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
hx-get="/api/slm-dashboard/units"
hx-trigger="change"
hx-target="#slm-list"
hx-include="#search-input, #project-filter">
<option value="">All Projects</option>
<!-- Will be populated dynamically -->
</select>
<!-- Search Input -->
<input id="search-input"
type="text"
placeholder="Search units..." placeholder="Search units..."
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
hx-get="/api/slm-dashboard/units" hx-get="/api/slm-dashboard/units"
hx-trigger="keyup changed delay:300ms" hx-trigger="keyup changed delay:300ms"
hx-target="#slm-list" hx-target="#slm-list"
hx-include="this" hx-include="#search-input, #project-filter"
name="search"> name="search">
</div> </div>
@@ -93,9 +107,94 @@
</div> </div>
</div> </div>
<!-- Command Center Modal -->
<div id="command-center-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-xl w-full max-w-7xl max-h-[90vh] overflow-y-auto">
<div class="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 px-6 py-4 flex items-center justify-between z-10">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Command Center</h2>
<button onclick="closeCommandCenter()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<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"/>
</svg>
</button>
</div>
<div id="command-center-content" class="p-6">
<!-- Command center will load here -->
</div>
</div>
</div>
</div>
<script> <script>
// Function to select a unit and load live view // Global Connection Manager - ensures only one SLM connection at a time
window.SLMConnectionManager = {
activeIntervals: [],
activeWebSocket: null,
currentUnitId: null,
// Clear all existing connections
clearAll: function() {
console.log('SLMConnectionManager: Clearing all connections');
// Clear all intervals
this.activeIntervals.forEach(interval => {
clearInterval(interval);
});
this.activeIntervals = [];
// Close WebSocket if exists
if (this.activeWebSocket) {
this.activeWebSocket.close();
this.activeWebSocket = null;
}
// Clear any global intervals that might exist
if (window.refreshInterval) {
clearInterval(window.refreshInterval);
window.refreshInterval = null;
}
if (window.timerInterval) {
clearInterval(window.timerInterval);
window.timerInterval = null;
}
if (window.diagRefreshInterval) {
clearInterval(window.diagRefreshInterval);
window.diagRefreshInterval = null;
}
console.log('SLMConnectionManager: All connections cleared');
},
// Register a new interval
registerInterval: function(intervalId) {
this.activeIntervals.push(intervalId);
},
// Register WebSocket
registerWebSocket: function(ws) {
if (this.activeWebSocket) {
this.activeWebSocket.close();
}
this.activeWebSocket = ws;
},
// Set current unit
setCurrentUnit: function(unitId) {
if (this.currentUnitId !== unitId) {
this.clearAll();
this.currentUnitId = unitId;
}
}
};
// Function to select a unit and load DIAGNOSTICS CARD (not full command center)
function selectUnit(unitId) { function selectUnit(unitId) {
console.log(`Selecting unit: ${unitId}`);
// Clear all existing connections
window.SLMConnectionManager.clearAll();
// Remove active state from all items // Remove active state from all items
document.querySelectorAll('.slm-unit-item').forEach(item => { document.querySelectorAll('.slm-unit-item').forEach(item => {
item.classList.remove('bg-seismo-orange', 'text-white'); item.classList.remove('bg-seismo-orange', 'text-white');
@@ -106,13 +205,53 @@ function selectUnit(unitId) {
event.currentTarget.classList.remove('bg-gray-100', 'dark:bg-gray-700'); event.currentTarget.classList.remove('bg-gray-100', 'dark:bg-gray-700');
event.currentTarget.classList.add('bg-seismo-orange', 'text-white'); event.currentTarget.classList.add('bg-seismo-orange', 'text-white');
// Load live view for this unit // Load DIAGNOSTICS CARD (not full live view)
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, { htmx.ajax('GET', `/api/slm-dashboard/diagnostics/${unitId}`, {
target: '#live-view-panel', target: '#live-view-panel',
swap: 'innerHTML' swap: 'innerHTML'
}); });
} }
// Open command center in modal
function openCommandCenter(unitId) {
console.log(`Opening command center for: ${unitId}`);
// Clear diagnostics refresh before opening modal
window.SLMConnectionManager.clearAll();
const modal = document.getElementById('command-center-modal');
modal.classList.remove('hidden');
// Load full command center
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
target: '#command-center-content',
swap: 'innerHTML'
});
}
// Close command center modal
function closeCommandCenter() {
console.log('Closing command center');
// Clear all command center connections
window.SLMConnectionManager.clearAll();
document.getElementById('command-center-modal').classList.add('hidden');
// Reload the diagnostics card for the currently selected unit
const activeUnit = document.querySelector('.slm-unit-item.bg-seismo-orange');
if (activeUnit) {
const unitIdMatch = activeUnit.getAttribute('onclick').match(/selectUnit\('(.+?)'\)/);
if (unitIdMatch) {
const unitId = unitIdMatch[1];
htmx.ajax('GET', `/api/slm-dashboard/diagnostics/${unitId}`, {
target: '#live-view-panel',
swap: 'innerHTML'
});
}
}
}
// Configuration modal functions // Configuration modal functions
function openConfigModal(unitId) { function openConfigModal(unitId) {
const modal = document.getElementById('config-modal'); const modal = document.getElementById('config-modal');
@@ -129,121 +268,48 @@ function closeConfigModal() {
document.getElementById('config-modal').classList.add('hidden'); document.getElementById('config-modal').classList.add('hidden');
} }
// Close modal on escape key // Close modals on escape key
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
closeConfigModal(); closeConfigModal();
closeCommandCenter();
} }
}); });
// Close modal when clicking outside // Close modals when clicking outside
document.getElementById('config-modal')?.addEventListener('click', function(e) { document.getElementById('config-modal')?.addEventListener('click', function(e) {
if (e.target === this) { if (e.target === this) {
closeConfigModal(); closeConfigModal();
} }
}); });
// Initialize WebSocket for selected unit document.getElementById('command-center-modal')?.addEventListener('click', function(e) {
let currentWebSocket = null; if (e.target === this) {
closeCommandCenter();
function initLiveDataStream(unitId) {
// Close existing connection if any
if (currentWebSocket) {
currentWebSocket.close();
} }
});
// WebSocket URL for SLMM backend via proxy // Load projects for filter dropdown on page load
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; document.addEventListener('DOMContentLoaded', function() {
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`; fetch('/api/slm-dashboard/projects')
.then(response => response.json())
currentWebSocket = new WebSocket(wsUrl); .then(data => {
const projectFilter = document.getElementById('project-filter');
currentWebSocket.onopen = function() { if (projectFilter && data.projects) {
console.log('WebSocket connected'); data.projects.forEach(project => {
// Toggle button visibility const option = document.createElement('option');
const startBtn = document.getElementById('start-stream-btn'); option.value = project;
const stopBtn = document.getElementById('stop-stream-btn'); option.textContent = project;
if (startBtn) startBtn.style.display = 'none'; projectFilter.appendChild(option);
if (stopBtn) stopBtn.style.display = 'flex'; });
};
currentWebSocket.onmessage = function(event) {
const data = JSON.parse(event.data);
updateLiveChart(data);
updateLiveMetrics(data);
};
currentWebSocket.onerror = function(error) {
console.error('WebSocket error:', error);
};
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 (currentWebSocket) {
currentWebSocket.close();
currentWebSocket = null;
}
}
// Update live chart with new data point
let chartData = {
timestamps: [],
lp: [],
leq: []
};
function updateLiveChart(data) {
const now = new Date();
chartData.timestamps.push(now.toLocaleTimeString());
chartData.lp.push(parseFloat(data.lp || 0));
chartData.leq.push(parseFloat(data.leq || 0));
// Keep only last 60 data points (1 minute at 1 sample/sec)
if (chartData.timestamps.length > 60) {
chartData.timestamps.shift();
chartData.lp.shift();
chartData.leq.shift();
}
// Update chart (using Chart.js if available)
if (window.liveChart) {
window.liveChart.data.labels = chartData.timestamps;
window.liveChart.data.datasets[0].data = chartData.lp;
window.liveChart.data.datasets[1].data = chartData.leq;
window.liveChart.update('none'); // Update without animation for smooth real-time
}
}
function updateLiveMetrics(data) {
// Update metric displays
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 || '--';
}
} }
})
.catch(error => console.error('Failed to load projects:', error));
});
// Cleanup on page unload // Cleanup on page unload
window.addEventListener('beforeunload', function() { window.addEventListener('beforeunload', function() {
if (currentWebSocket) { window.SLMConnectionManager.clearAll();
currentWebSocket.close();
}
}); });
</script> </script>
{% endblock %} {% endblock %}