439 lines
17 KiB
Plaintext
439 lines
17 KiB
Plaintext
<!-- 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>
|