0.4.2 - Early implementation of SLMs. WIP.

This commit is contained in:
serversdwn
2026-01-06 07:50:58 +00:00
parent 96cb27ef83
commit 4d74eda65f
36 changed files with 4211 additions and 12 deletions

View File

@@ -113,6 +113,20 @@
Fleet Roster
</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 %}">
<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 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
Seismographs
</a>
<a href="/sound-level-meters" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/sound-level-meters' %}bg-gray-100 dark:bg-gray-700{% endif %}">
<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="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
</svg>
Sound Level Meters
</a>
<a href="#" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 opacity-50 cursor-not-allowed">
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>

View File

@@ -57,6 +57,27 @@
<span class="text-gray-600 dark:text-gray-400">Benched</span>
<span id="benched-units" class="text-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">By Device Type:</p>
<div class="flex justify-between items-center mb-1">
<div class="flex items-center">
<svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
<a href="/seismographs" class="text-sm text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a>
</div>
<span id="seismo-count" class="font-semibold text-blue-600 dark:text-blue-400">--</span>
</div>
<div class="flex justify-between items-center mb-2">
<div class="flex items-center">
<svg class="w-4 h-4 mr-1.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
</svg>
<a href="/sound-level-meters" class="text-sm text-gray-600 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a>
</div>
<span id="slm-count" class="font-semibold text-purple-600 dark:text-purple-400">--</span>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Deployed Status:</p>
<div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)">
@@ -343,6 +364,21 @@ function updateDashboard(event) {
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
// ===== Device type counts =====
let seismoCount = 0;
let slmCount = 0;
Object.values(data.units || {}).forEach(unit => {
if (unit.retired) return; // Don't count retired units
const deviceType = unit.device_type || 'seismograph';
if (deviceType === 'seismograph') {
seismoCount++;
} else if (deviceType === 'sound_level_meter') {
slmCount++;
}
});
document.getElementById('seismo-count').textContent = seismoCount;
document.getElementById('slm-count').textContent = slmCount;
// ===== Alerts =====
const alertsList = document.getElementById('alerts-list');
// Only show alerts for deployed units that are MISSING (not pending)

View File

@@ -0,0 +1,56 @@
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- Total Seismographs -->
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-600 dark:text-gray-400 text-sm">Total Seismographs</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">{{ total }}</p>
</div>
<svg class="w-12 h-12 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
</div>
</div>
<!-- Deployed -->
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-600 dark:text-gray-400 text-sm">Deployed</p>
<p class="text-3xl font-bold text-green-600 dark:text-green-400 mt-2">{{ deployed }}</p>
</div>
<svg class="w-12 h-12 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
</div>
<!-- Benched -->
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-600 dark:text-gray-400 text-sm">Benched</p>
<p class="text-3xl font-bold text-gray-600 dark:text-gray-400 mt-2">{{ benched }}</p>
</div>
<svg class="w-12 h-12 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
</div>
</div>
<!-- With Modem -->
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-600 dark:text-gray-400 text-sm">With Modem</p>
<p class="text-3xl font-bold text-blue-600 dark:text-blue-400 mt-2">{{ with_modem }}<span class="text-base text-gray-500">/ {{ deployed }}</span></p>
{% if without_modem > 0 %}
<p class="text-xs text-orange-600 dark:text-orange-400 mt-1">{{ without_modem }} without modem</p>
{% endif %}
</div>
<svg class="w-12 h-12 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
</svg>
</div>
</div>
</div>

View File

@@ -0,0 +1,97 @@
{% if units %}
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Unit ID</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Status</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Modem</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Location</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Notes</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for unit in units %}
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
<td class="px-4 py-3 whitespace-nowrap">
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
{{ unit.id }}
</a>
</td>
<td class="px-4 py-3 whitespace-nowrap">
{% if unit.deployed %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
Deployed
</span>
{% else %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"></path>
</svg>
Benched
</span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
{% if unit.deployed_with_modem_id %}
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
{{ unit.deployed_with_modem_id }}
</a>
{% else %}
<span class="text-gray-400 dark:text-gray-600">None</span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
{% if unit.address %}
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
{% elif unit.coordinates %}
<span class="text-gray-500 dark:text-gray-400">{{ unit.coordinates }}</span>
{% else %}
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400">
{% if unit.note %}
<span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span>
{% else %}
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
<a href="/unit/{{ unit.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
View Details →
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if search %}
<div class="mt-4 text-sm text-gray-600 dark:text-gray-400">
Found {{ units|length }} seismograph(s) matching "{{ search }}"
</div>
{% endif %}
{% else %}
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No seismographs found</h3>
{% if search %}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">No seismographs match "{{ search }}"</p>
<button onclick="document.getElementById('seismo-search').value = ''; htmx.trigger('#seismo-search', 'keyup');"
class="mt-3 text-blue-600 dark:text-blue-400 hover:underline">
Clear search
</button>
{% else %}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by adding a seismograph unit from the roster page.</p>
{% endif %}
</div>
{% endif %}

View File

@@ -0,0 +1,288 @@
<form id="slm-config-form"
hx-post="/api/slm-dashboard/config/{{ unit.id }}"
hx-swap="none"
hx-on::after-request="handleConfigSave(event)">
<div class="mb-6">
<h4 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Unit: {{ unit.id }}</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">Configure measurement parameters for this sound level meter</p>
</div>
<!-- Model & Serial -->
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Model</label>
<select name="slm_model" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">Select model...</option>
<option value="NL-43" {% if unit.slm_model == 'NL-43' %}selected{% endif %}>NL-43</option>
<option value="NL-53" {% if unit.slm_model == 'NL-53' %}selected{% endif %}>NL-53</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Serial Number</label>
<input type="text" name="slm_serial_number" value="{{ unit.slm_serial_number or '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="e.g., SN123456">
</div>
</div>
<!-- Frequency & Time Weighting -->
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency Weighting</label>
<select name="slm_frequency_weighting" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">Select...</option>
<option value="A" {% if unit.slm_frequency_weighting == 'A' %}selected{% endif %}>A-weighting</option>
<option value="C" {% if unit.slm_frequency_weighting == 'C' %}selected{% endif %}>C-weighting</option>
<option value="Z" {% if unit.slm_frequency_weighting == 'Z' %}selected{% endif %}>Z-weighting (Linear)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Weighting</label>
<select name="slm_time_weighting" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">Select...</option>
<option value="Fast" {% if unit.slm_time_weighting == 'Fast' %}selected{% endif %}>Fast (125ms)</option>
<option value="Slow" {% if unit.slm_time_weighting == 'Slow' %}selected{% endif %}>Slow (1s)</option>
<option value="Impulse" {% if unit.slm_time_weighting == 'Impulse' %}selected{% endif %}>Impulse</option>
</select>
</div>
</div>
<!-- Measurement Range -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Measurement Range</label>
<select name="slm_measurement_range" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">Select range...</option>
<option value="30-130" {% if unit.slm_measurement_range == '30-130' %}selected{% endif %}>30-130 dB</option>
<option value="40-140" {% if unit.slm_measurement_range == '40-140' %}selected{% endif %}>40-140 dB</option>
<option value="50-140" {% if unit.slm_measurement_range == '50-140' %}selected{% endif %}>50-140 dB</option>
</select>
</div>
<!-- Network Configuration -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-6 mb-4">
<h5 class="text-md font-semibold text-gray-900 dark:text-white mb-3">Network Configuration</h5>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Assigned Modem</label>
<div class="flex gap-2">
<select name="deployed_with_modem_id" id="config-modem-select" class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">No modem (direct connection)</option>
<!-- Options loaded via JavaScript -->
</select>
<button type="button" id="test-modem-btn" onclick="testModemConnection()"
class="px-4 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg transition-colors {% if not unit.deployed_with_modem_id %}opacity-50 cursor-not-allowed{% endif %}"
{% if not unit.deployed_with_modem_id %}disabled{% endif %}
title="Ping modem to test connectivity">
<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="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
</svg>
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select a modem for network access, or leave blank for direct IP connection</p>
</div>
<!-- Port Configuration (always visible) -->
<div class="grid grid-cols-3 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
<input type="number" name="slm_tcp_port" value="{{ unit.slm_tcp_port or '2255' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
placeholder="2255">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Control port</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">FTP Port</label>
<input type="number" name="slm_ftp_port" value="{{ unit.slm_ftp_port or '21' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
placeholder="21">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Data transfer</p>
</div>
<div id="direct-ip-field" class="{% if unit.deployed_with_modem_id %}hidden{% endif %}">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Direct IP</label>
<input type="text" name="slm_host" value="{{ unit.slm_host or '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
placeholder="192.168.1.100">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">If no modem</p>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex justify-end gap-3 mt-6">
<button type="button" onclick="closeConfigModal()"
class="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">
Cancel
</button>
<button type="button" onclick="testSLMConnection('{{ unit.id }}')"
class="px-4 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg transition-colors">
Test SLM Connection
</button>
<button type="submit"
class="px-4 py-2 text-white bg-seismo-orange hover:bg-seismo-burgundy rounded-lg transition-colors">
Save Configuration
</button>
</div>
</form>
<script>
// Load modems list for dropdown
async function loadModemsForConfig() {
try {
const response = await fetch('/api/roster/modems');
const modems = await response.json();
const select = document.getElementById('config-modem-select');
const currentValue = '{{ unit.deployed_with_modem_id or "" }}';
// Keep the "No modem" option
modems.forEach(modem => {
const option = document.createElement('option');
option.value = modem.id;
const ipText = modem.ip_address ? ' (' + modem.ip_address + ')' : '';
option.textContent = modem.id + ipText;
if (modem.id === currentValue) {
option.selected = true;
}
select.appendChild(option);
});
} catch (error) {
console.error('Failed to load modems:', error);
}
}
// Toggle direct IP field and test modem button based on modem selection
document.getElementById('config-modem-select')?.addEventListener('change', function() {
const directIpField = document.getElementById('direct-ip-field');
const testModemBtn = document.getElementById('test-modem-btn');
if (this.value === '') {
directIpField.classList.remove('hidden');
testModemBtn.disabled = true;
testModemBtn.classList.add('opacity-50', 'cursor-not-allowed');
} else {
directIpField.classList.add('hidden');
testModemBtn.disabled = false;
testModemBtn.classList.remove('opacity-50', 'cursor-not-allowed');
}
});
// Handle save response
function handleConfigSave(event) {
if (event.detail.successful) {
// Show success message
const toast = document.createElement('div');
toast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
toast.textContent = 'Configuration saved successfully!';
document.body.appendChild(toast);
setTimeout(() => {
toast.remove();
closeConfigModal();
// Refresh the unit list
htmx.trigger('#slm-list', 'load');
}, 2000);
} else {
// Show error message
const toast = document.createElement('div');
toast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
toast.textContent = 'Failed to save configuration';
document.body.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
}
// Test connection to modem (health ping)
async function testModemConnection() {
const modemSelect = document.getElementById('config-modem-select');
const modemId = modemSelect.value;
if (!modemId) {
alert('Please select a modem first');
return;
}
const toast = document.createElement('div');
toast.className = 'fixed bottom-4 right-4 bg-blue-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
toast.textContent = 'Pinging modem...';
document.body.appendChild(toast);
try {
const response = await fetch('/api/slm-dashboard/test-modem/' + modemId);
const data = await response.json();
toast.remove();
const resultToast = document.createElement('div');
if (response.ok && data.status === 'success') {
resultToast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
const ipAddr = data.ip_address || modemId;
const respTime = data.response_time || 'N/A';
resultToast.innerHTML = '✓ Modem responding!<br><span class="text-xs">' + ipAddr + ' - ' + respTime + 'ms</span>';
} else {
resultToast.className = 'fixed bottom-4 right-4 bg-yellow-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
resultToast.textContent = '⚠ Modem not responding: ' + (data.detail || 'Unknown error');
}
document.body.appendChild(resultToast);
setTimeout(() => {
resultToast.remove();
}, 4000);
} catch (error) {
toast.remove();
const errorToast = document.createElement('div');
errorToast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
errorToast.textContent = '✗ Failed to ping modem: ' + error.message;
document.body.appendChild(errorToast);
setTimeout(() => {
errorToast.remove();
}, 3000);
}
}
// Test connection to SLM
async function testSLMConnection(unitId) {
const toast = document.createElement('div');
toast.className = 'fixed bottom-4 right-4 bg-blue-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
toast.textContent = 'Testing SLM connection...';
document.body.appendChild(toast);
try {
const response = await fetch('/api/slmm/' + unitId + '/status');
const data = await response.json();
toast.remove();
const resultToast = document.createElement('div');
if (response.ok && data.status === 'online') {
resultToast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
resultToast.textContent = '✓ SLM connection successful! ' + (data.model || 'SLM') + ' responding';
} else {
resultToast.className = 'fixed bottom-4 right-4 bg-yellow-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
resultToast.textContent = '⚠ SLM connection failed or device offline';
}
document.body.appendChild(resultToast);
setTimeout(() => {
resultToast.remove();
}, 3000);
} catch (error) {
toast.remove();
const errorToast = document.createElement('div');
errorToast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
errorToast.textContent = '✗ SLM connection test failed: ' + error.message;
document.body.appendChild(errorToast);
setTimeout(() => {
errorToast.remove();
}, 3000);
}
}
// Load modems on page load
loadModemsForConfig();
</script>

View File

@@ -0,0 +1,215 @@
<form id="slm-config-form"
hx-post="/api/slm-dashboard/config/{{ unit.id }}"
hx-swap="none"
hx-on::after-request="handleConfigSave(event)">
<div class="mb-6">
<h4 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Unit: {{ unit.id }}</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">Configure measurement parameters for this sound level meter</p>
</div>
<!-- Model & Serial -->
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Model</label>
<select name="slm_model" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">Select model...</option>
<option value="NL-43" {% if unit.slm_model == 'NL-43' %}selected{% endif %}>NL-43</option>
<option value="NL-53" {% if unit.slm_model == 'NL-53' %}selected{% endif %}>NL-53</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Serial Number</label>
<input type="text" name="slm_serial_number" value="{{ unit.slm_serial_number or '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="e.g., SN123456">
</div>
</div>
<!-- Frequency & Time Weighting -->
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency Weighting</label>
<select name="slm_frequency_weighting" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">Select...</option>
<option value="A" {% if unit.slm_frequency_weighting == 'A' %}selected{% endif %}>A-weighting</option>
<option value="C" {% if unit.slm_frequency_weighting == 'C' %}selected{% endif %}>C-weighting</option>
<option value="Z" {% if unit.slm_frequency_weighting == 'Z' %}selected{% endif %}>Z-weighting (Linear)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Weighting</label>
<select name="slm_time_weighting" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">Select...</option>
<option value="Fast" {% if unit.slm_time_weighting == 'Fast' %}selected{% endif %}>Fast (125ms)</option>
<option value="Slow" {% if unit.slm_time_weighting == 'Slow' %}selected{% endif %}>Slow (1s)</option>
<option value="Impulse" {% if unit.slm_time_weighting == 'Impulse' %}selected{% endif %}>Impulse</option>
</select>
</div>
</div>
<!-- Measurement Range -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Measurement Range</label>
<select name="slm_measurement_range" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">Select range...</option>
<option value="30-130" {% if unit.slm_measurement_range == '30-130' %}selected{% endif %}>30-130 dB</option>
<option value="40-140" {% if unit.slm_measurement_range == '40-140' %}selected{% endif %}>40-140 dB</option>
<option value="50-140" {% if unit.slm_measurement_range == '50-140' %}selected{% endif %}>50-140 dB</option>
</select>
</div>
<!-- Network Configuration -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-6 mb-4">
<h5 class="text-md font-semibold text-gray-900 dark:text-white mb-3">Network Configuration</h5>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Assigned Modem</label>
<select name="deployed_with_modem_id" id="config-modem-select" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">No modem (direct connection)</option>
<!-- Options loaded via JavaScript -->
</select>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select a modem for network access, or leave blank for direct IP connection</p>
</div>
<!-- Legacy direct connection (shown only if no modem selected) -->
<div id="direct-connection-fields" class="{% if unit.deployed_with_modem_id %}hidden{% endif %}">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Direct IP Address</label>
<input type="text" name="slm_host" value="{{ unit.slm_host or '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
placeholder="192.168.1.100">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
<input type="number" name="slm_tcp_port" value="{{ unit.slm_tcp_port or '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
placeholder="502">
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex justify-end gap-3 mt-6">
<button type="button" onclick="closeConfigModal()"
class="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">
Cancel
</button>
<button type="button" onclick="testConnection('{{ unit.id }}')"
class="px-4 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg transition-colors">
Test Connection
</button>
<button type="submit"
class="px-4 py-2 text-white bg-seismo-orange hover:bg-seismo-burgundy rounded-lg transition-colors">
Save Configuration
</button>
</div>
</form>
<script>
// Load modems list for dropdown
async function loadModemsForConfig() {
try {
const response = await fetch('/api/roster/modems');
const modems = await response.json();
const select = document.getElementById('config-modem-select');
const currentValue = '{{ unit.deployed_with_modem_id or "" }}';
// Keep the "No modem" option
modems.forEach(modem => {
const option = document.createElement('option');
option.value = modem.id;
option.textContent = `${modem.id}${modem.ip_address ? ' (' + modem.ip_address + ')' : ''}`;
if (modem.id === currentValue) {
option.selected = true;
}
select.appendChild(option);
});
} catch (error) {
console.error('Failed to load modems:', error);
}
}
// Toggle direct connection fields based on modem selection
document.getElementById('config-modem-select')?.addEventListener('change', function() {
const directFields = document.getElementById('direct-connection-fields');
if (this.value === '') {
directFields.classList.remove('hidden');
} else {
directFields.classList.add('hidden');
}
});
// Handle save response
function handleConfigSave(event) {
if (event.detail.successful) {
// Show success message
const toast = document.createElement('div');
toast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
toast.textContent = 'Configuration saved successfully!';
document.body.appendChild(toast);
setTimeout(() => {
toast.remove();
closeConfigModal();
// Refresh the unit list
htmx.trigger('#slm-list', 'load');
}, 2000);
} else {
// Show error message
const toast = document.createElement('div');
toast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
toast.textContent = 'Failed to save configuration';
document.body.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
}
// Test connection to SLM
async function testConnection(unitId) {
const toast = document.createElement('div');
toast.className = 'fixed bottom-4 right-4 bg-blue-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
toast.textContent = 'Testing connection...';
document.body.appendChild(toast);
try {
const response = await fetch(`/api/slmm/${unitId}/status`);
const data = await response.json();
toast.remove();
const resultToast = document.createElement('div');
if (response.ok && data.status === 'online') {
resultToast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
resultToast.textContent = `✓ Connection successful! ${data.model || 'SLM'} responding`;
} else {
resultToast.className = 'fixed bottom-4 right-4 bg-yellow-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
resultToast.textContent = `Connection failed or device offline`;
}
document.body.appendChild(resultToast);
setTimeout(() => {
resultToast.remove();
}, 3000);
} catch (error) {
toast.remove();
const errorToast = document.createElement('div');
errorToast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
errorToast.textContent = '✗ Connection test failed';
document.body.appendChild(errorToast);
setTimeout(() => {
errorToast.remove();
}, 3000);
}
}
// Load modems on page load
loadModemsForConfig();
</script>

View File

@@ -0,0 +1,260 @@
<!-- 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 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>
</div>
<!-- Current Metrics -->
<div class="grid grid-cols-4 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 (Current)</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 (Peak)</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</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>
<!-- Live Chart -->
<div class="flex-1 bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4">
<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
const ctx = document.getElementById('liveChart').getContext('2d');
// 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 (Current Level)',
data: [],
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.3,
borderWidth: 2,
pointRadius: 0
},
{
label: 'Leq (Average)',
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
}
}
}
}
});
// 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}`);
}
}
</script>

View File

@@ -0,0 +1,8 @@
<!-- Error State for Live View -->
<div class="flex flex-col items-center justify-center h-[600px] text-red-500 dark:text-red-400">
<svg class="w-24 h-24 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<p class="text-lg font-medium">Error Loading Unit</p>
<p class="text-sm mt-2 text-gray-600 dark:text-gray-400">{{ error }}</p>
</div>

View File

@@ -0,0 +1,61 @@
<!-- Summary stat cards -->
<!-- Total Units Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Total Units</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ total_count }}</p>
</div>
<div class="bg-blue-100 dark:bg-blue-900/30 p-3 rounded-lg">
<svg class="w-8 h-8 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
</div>
</div>
</div>
<!-- Deployed Units Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Deployed</p>
<p class="text-3xl font-bold text-green-600 dark:text-green-400 mt-1">{{ deployed_count }}</p>
</div>
<div class="bg-green-100 dark:bg-green-900/30 p-3 rounded-lg">
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
</div>
</div>
<!-- Active Now Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Active Now</p>
<p class="text-3xl font-bold text-seismo-orange mt-1">{{ active_count }}</p>
</div>
<div class="bg-orange-100 dark:bg-orange-900/30 p-3 rounded-lg">
<svg class="w-8 h-8 text-seismo-orange" 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>
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">Checked in last hour</p>
</div>
<!-- Benched Units Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Benched</p>
<p class="text-3xl font-bold text-gray-500 dark:text-gray-400 mt-1">{{ benched_count }}</p>
</div>
<div class="bg-gray-200 dark:bg-gray-700 p-3 rounded-lg">
<svg class="w-8 h-8 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
</div>
</div>
</div>

View File

@@ -0,0 +1,73 @@
<!-- SLM Unit List -->
{% if units %}
{% for unit in units %}
<div class="slm-unit-item bg-gray-100 dark:bg-gray-700 rounded-lg p-4 transition-colors relative group">
<!-- Configure button (appears on hover) -->
<button onclick="event.stopPropagation(); openConfigModal('{{ unit.id }}');"
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-full p-1.5 hover:bg-gray-200 dark:hover:bg-gray-600 z-10"
title="Configure {{ unit.id }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</button>
<div class="cursor-pointer" onclick="selectUnit('{{ unit.id }}')">
<div class="flex items-center justify-between mb-2">
<span class="font-semibold">{{ unit.id }}</span>
<!-- Status indicator: green=active, yellow=recent, red=old, gray=never -->
{% if unit.slm_last_check %}
<span class="w-2 h-2 bg-green-500 rounded-full" title="Active"></span>
{% else %}
<span class="w-2 h-2 bg-gray-400 rounded-full" title="No check-in"></span>
{% endif %}
</div>
<div class="text-sm space-y-1">
{% if unit.slm_model %}
<div class="flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
</svg>
{{ unit.slm_model }}
</div>
{% endif %}
{% if unit.address %}
<div class="flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<span class="truncate">{{ unit.address }}</span>
</div>
{% endif %}
{% if unit.deployed_with_modem_id %}
<div class="flex items-center font-mono text-xs">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
</svg>
{{ unit.deployed_with_modem_id }}
</div>
{% elif unit.slm_host %}
<div class="flex items-center font-mono text-xs">
<svg class="w-4 h-4 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 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
</svg>
{{ unit.slm_host }}{% if unit.slm_tcp_port %}:{{ unit.slm_tcp_port }}{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
<p>No sound level meters found</p>
<p class="text-sm mt-1">Add units from the Fleet Roster</p>
</div>
{% endif %}

View File

@@ -205,6 +205,11 @@
<input type="number" name="slm_tcp_port" placeholder="2255"
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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">FTP Port</label>
<input type="number" name="slm_ftp_port" placeholder="21"
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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Serial Number</label>
<input type="text" name="slm_serial_number" placeholder="SN123456"
@@ -438,18 +443,39 @@
seismoFields.classList.remove('hidden');
modemFields.classList.add('hidden');
slmFields.classList.add('hidden');
// Enable seismograph fields, disable others
setFieldsDisabled(seismoFields, false);
setFieldsDisabled(modemFields, true);
setFieldsDisabled(slmFields, true);
toggleModemPairing(); // Check if modem pairing should be shown
} else if (deviceType === 'modem') {
seismoFields.classList.add('hidden');
modemFields.classList.remove('hidden');
slmFields.classList.add('hidden');
// Enable modem fields, disable others
setFieldsDisabled(seismoFields, true);
setFieldsDisabled(modemFields, false);
setFieldsDisabled(slmFields, true);
} else if (deviceType === 'sound_level_meter') {
seismoFields.classList.add('hidden');
modemFields.classList.add('hidden');
slmFields.classList.remove('hidden');
// Enable SLM fields, disable others
setFieldsDisabled(seismoFields, true);
setFieldsDisabled(modemFields, true);
setFieldsDisabled(slmFields, false);
}
}
// Helper function to disable/enable all inputs in a container
function setFieldsDisabled(container, disabled) {
if (!container) return;
const inputs = container.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
input.disabled = disabled;
});
}
// Toggle modem pairing field visibility (only for deployed seismographs)
function toggleModemPairing() {
const deviceType = document.getElementById('deviceTypeSelect').value;
@@ -547,10 +573,16 @@
if (deviceType === 'seismograph') {
seismoFields.classList.remove('hidden');
modemFields.classList.add('hidden');
// Enable seismograph fields, disable modem fields
setFieldsDisabled(seismoFields, false);
setFieldsDisabled(modemFields, true);
toggleEditModemPairing();
} else {
seismoFields.classList.add('hidden');
modemFields.classList.remove('hidden');
// Enable modem fields, disable seismograph fields
setFieldsDisabled(seismoFields, true);
setFieldsDisabled(modemFields, false);
}
}

View File

@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block title %}Seismographs - Seismo Fleet Manager{% endblock %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Seismographs</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Manage and monitor seismograph units</p>
</div>
<!-- Auto-refresh stats every 30 seconds -->
<div hx-get="/api/seismo-dashboard/stats"
hx-trigger="load, every 30s"
hx-swap="innerHTML"
class="mb-8">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-600 dark:text-gray-400 text-sm">Loading...</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">--</p>
</div>
</div>
</div>
</div>
</div>
<!-- Seismograph List -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<div class="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">All Seismographs</h2>
<!-- Search Box -->
<div class="relative">
<input
type="text"
id="seismo-search"
placeholder="Search seismographs..."
class="w-full sm:w-64 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
hx-get="/api/seismo-dashboard/units"
hx-trigger="keyup changed delay:300ms"
hx-target="#seismo-units-list"
hx-include="[name='search']"
name="search"
/>
<svg class="absolute right-3 top-2.5 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>
</svg>
</div>
</div>
<!-- Units List (loaded via HTMX) -->
<div id="seismo-units-list"
hx-get="/api/seismo-dashboard/units"
hx-trigger="load"
hx-swap="innerHTML">
<p class="text-gray-500 dark:text-gray-400">Loading seismographs...</p>
</div>
</div>
<script>
// Clear search input on escape key
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('seismo-search');
if (searchInput) {
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
this.value = '';
htmx.trigger(this, 'keyup');
}
});
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,228 @@
{% extends "base.html" %}
{% block title %}Sound Level Meters - Seismo Fleet Manager{% endblock %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Sound Level Meters</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Monitor and manage sound level measurement devices</p>
</div>
<!-- Summary Stats -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"
hx-get="/api/slm-dashboard/stats"
hx-trigger="load, every 10s"
hx-swap="innerHTML">
<!-- Stats will be loaded here -->
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
</div>
<!-- Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- SLM List -->
<div class="lg:col-span-1">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Active Units</h2>
<!-- Search/Filter -->
<div class="mb-4">
<input type="text"
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"
hx-get="/api/slm-dashboard/units"
hx-trigger="keyup changed delay:300ms"
hx-target="#slm-list"
hx-include="this"
name="search">
</div>
<!-- SLM List -->
<div id="slm-list"
class="space-y-2 max-h-[600px] overflow-y-auto"
hx-get="/api/slm-dashboard/units"
hx-trigger="load, every 10s"
hx-swap="innerHTML">
<!-- Loading skeleton -->
<div class="animate-pulse space-y-2">
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
</div>
</div>
</div>
</div>
<!-- Live View Panel -->
<div class="lg:col-span-2">
<div id="live-view-panel" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<!-- Initial state - no unit selected -->
<div class="flex flex-col items-center justify-center h-[600px] text-gray-400 dark:text-gray-500">
<svg class="w-24 h-24 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
</svg>
<p class="text-lg font-medium">No unit selected</p>
<p class="text-sm mt-2">Select a sound level meter from the list to view live data</p>
</div>
</div>
</div>
</div>
<!-- Configuration Modal -->
<div id="config-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white dark:bg-slate-800 rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white">Configure SLM</h3>
<button onclick="closeConfigModal()" 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"></path>
</svg>
</button>
</div>
<div id="config-modal-content">
<!-- Content loaded via HTMX -->
<div class="animate-pulse space-y-4">
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
</div>
</div>
</div>
</div>
<script>
// Function to select a unit and load live view
function selectUnit(unitId) {
// Remove active state from all items
document.querySelectorAll('.slm-unit-item').forEach(item => {
item.classList.remove('bg-seismo-orange', 'text-white');
item.classList.add('bg-gray-100', 'dark:bg-gray-700');
});
// Add active state to clicked item
event.currentTarget.classList.remove('bg-gray-100', 'dark:bg-gray-700');
event.currentTarget.classList.add('bg-seismo-orange', 'text-white');
// Load live view for this unit
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
target: '#live-view-panel',
swap: 'innerHTML'
});
}
// Configuration modal functions
function openConfigModal(unitId) {
const modal = document.getElementById('config-modal');
modal.classList.remove('hidden');
// Load configuration form via HTMX
htmx.ajax('GET', `/api/slm-dashboard/config/${unitId}`, {
target: '#config-modal-content',
swap: 'innerHTML'
});
}
function closeConfigModal() {
document.getElementById('config-modal').classList.add('hidden');
}
// Close modal on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeConfigModal();
}
});
// Close modal when clicking outside
document.getElementById('config-modal')?.addEventListener('click', function(e) {
if (e.target === this) {
closeConfigModal();
}
});
// Initialize WebSocket for selected unit
let currentWebSocket = null;
function initLiveDataStream(unitId) {
// Close existing connection if any
if (currentWebSocket) {
currentWebSocket.close();
}
// 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`;
currentWebSocket = new WebSocket(wsUrl);
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');
};
}
// 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 || '--';
}
}
// Cleanup on page unload
window.addEventListener('beforeunload', function() {
if (currentWebSocket) {
currentWebSocket.close();
}
});
</script>
{% endblock %}

View File

@@ -240,6 +240,7 @@
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">
<option value="seismograph">Seismograph</option>
<option value="modem">Modem</option>
<option value="sound_level_meter">Sound Level Meter</option>
</select>
</div>
@@ -316,6 +317,63 @@
</div>
</div>
<!-- Sound Level Meter Fields -->
<div id="slmFields" class="hidden space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Sound Level Meter Information</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Model</label>
<input type="text" name="slm_model" id="slmModel" placeholder="NL-43, NL-53, etc."
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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Serial Number</label>
<input type="text" name="slm_serial_number" id="slmSerialNumber" placeholder="123456"
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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency Weighting</label>
<select name="slm_frequency_weighting" id="slmFrequencyWeighting"
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">
<option value="">Not set</option>
<option value="A">A-weighting</option>
<option value="C">C-weighting</option>
<option value="Z">Z-weighting (Flat)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Weighting</label>
<select name="slm_time_weighting" id="slmTimeWeighting"
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">
<option value="">Not set</option>
<option value="F">Fast (125ms)</option>
<option value="S">Slow (1s)</option>
<option value="I">Impulse (35ms)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Measurement Range</label>
<input type="text" name="slm_measurement_range" id="slmMeasurementRange" placeholder="30-130 dB"
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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port (on modem)</label>
<input type="number" name="slm_tcp_port" id="slmTcpPort" placeholder="2255"
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">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Default: 2255</p>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
<select name="deployed_with_modem_id" id="slmDeployedWithModemId"
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">
<option value="">No modem assigned</option>
<!-- Options will be populated by JavaScript -->
</select>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select the modem that provides network connectivity for this SLM</p>
</div>
</div>
</div>
<!-- Checkboxes -->
<div class="flex items-center gap-6 border-t border-gray-200 dark:border-gray-700 pt-4">
<label class="flex items-center gap-2 cursor-pointer">
@@ -373,6 +431,9 @@ async function loadUnitData() {
currentSnapshot = await snapshotResponse.json();
}
// Load modems list for dropdown
await loadModemsList();
// Populate views
populateViewMode();
populateEditForm();
@@ -391,6 +452,38 @@ async function loadUnitData() {
}
}
// Load list of modems for dropdown
async function loadModemsList() {
try {
const response = await fetch('/api/roster/modems');
if (response.ok) {
const modems = await response.json();
// Populate both seismograph and SLM modem dropdowns
const seismoDropdown = document.getElementById('deployedWithModemId');
const slmDropdown = document.getElementById('slmDeployedWithModemId');
// Clear existing options (except the first "No modem" option)
[seismoDropdown, slmDropdown].forEach(dropdown => {
if (!dropdown) return;
while (dropdown.options.length > 1) {
dropdown.remove(1);
}
// Add modem options
modems.forEach(modem => {
const option = document.createElement('option');
option.value = modem.id;
option.textContent = `${modem.id}${modem.ip_address ? ' (' + modem.ip_address + ')' : ''}${modem.hardware_model ? ' - ' + modem.hardware_model : ''}`;
dropdown.appendChild(option);
});
});
}
} catch (error) {
console.error('Failed to load modems list:', error);
}
}
// Populate view mode (read-only display)
function populateViewMode() {
// Update page title and store unit ID for copy function
@@ -491,6 +584,15 @@ function populateEditForm() {
document.getElementById('phoneNumber').value = currentUnit.phone_number || '';
document.getElementById('hardwareModel').value = currentUnit.hardware_model || '';
// Sound Level Meter fields
document.getElementById('slmTcpPort').value = currentUnit.slm_tcp_port || '';
document.getElementById('slmModel').value = currentUnit.slm_model || '';
document.getElementById('slmSerialNumber').value = currentUnit.slm_serial_number || '';
document.getElementById('slmFrequencyWeighting').value = currentUnit.slm_frequency_weighting || '';
document.getElementById('slmTimeWeighting').value = currentUnit.slm_time_weighting || '';
document.getElementById('slmMeasurementRange').value = currentUnit.slm_measurement_range || '';
document.getElementById('slmDeployedWithModemId').value = currentUnit.deployed_with_modem_id || '';
// Show/hide fields based on device type
toggleDetailFields();
}
@@ -500,13 +602,20 @@ function toggleDetailFields() {
const deviceType = document.getElementById('deviceType').value;
const seismoFields = document.getElementById('seismographFields');
const modemFields = document.getElementById('modemFields');
const slmFields = document.getElementById('slmFields');
// Hide all device-specific fields first
seismoFields.classList.add('hidden');
modemFields.classList.add('hidden');
slmFields.classList.add('hidden');
// Show the relevant fields
if (deviceType === 'seismograph') {
seismoFields.classList.remove('hidden');
modemFields.classList.add('hidden');
} else {
seismoFields.classList.add('hidden');
} else if (deviceType === 'modem') {
modemFields.classList.remove('hidden');
} else if (deviceType === 'sound_level_meter') {
slmFields.classList.remove('hidden');
}
}