Files
terra-view/templates/partials/slm_live_view.html
serversdwn d349af9444 Add Sound Level Meter support to roster management
- Updated roster.html to include a new option for Sound Level Meter in the device type selection.
- Added specific fields for Sound Level Meter information, including model, host/IP address, TCP and FTP ports, serial number, frequency weighting, and time weighting.
- Enhanced JavaScript to handle the visibility and state of Sound Level Meter fields based on the selected device type.
- Modified the unit editing functionality to populate Sound Level Meter fields with existing data when editing a unit.
- Updated settings.html to change the deployment status display from badges to radio buttons for better user interaction.
- Adjusted the toggleDeployed function to accept the new state directly instead of the current state.
- Changed the edit button in unit_detail.html to redirect to the roster edit page with the appropriate unit ID.
2026-01-14 21:59:13 +00:00

2087 lines
94 KiB
HTML

<!-- 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>
<!-- Status and Actions -->
<div class="flex items-center gap-3">
<!-- Settings Gear -->
<button onclick="openSettingsModal('{{ unit.id }}')"
class="p-2 text-gray-600 dark:text-gray-400 hover:text-seismo-orange dark:hover:text-seismo-orange rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Unit Settings">
<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="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>
<!-- FTP Browser -->
<button onclick="openFTPBrowser('{{ unit.id }}')"
class="p-2 text-gray-600 dark:text-gray-400 hover:text-seismo-orange dark:hover:text-seismo-orange rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Browse Files (FTP)">
<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="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
</svg>
</button>
<!-- Measurement Status Badge -->
<div class="flex flex-col items-end gap-2">
<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>
<!-- Elapsed Time Display -->
<div id="elapsed-time-container" class="{% if not is_measuring %}hidden{% endif %}">
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span id="elapsed-time" class="font-mono font-medium text-gray-900 dark:text-white">00:00:00</span>
</div>
</div>
</div>
</div>
</div>
<!-- Measurement Controls -->
<div class="mb-6 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Measurement Control</h3>
<div class="flex gap-2 flex-wrap">
<button onclick="startMeasurementWithCheck('{{ unit.id }}')"
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="controlUnit('{{ unit.id }}', 'store')"
class="px-4 py-2 bg-blue-600 hover:bg-blue-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="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"></path>
</svg>
Store
</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>
</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 Status Cards -->
<div class="mt-6">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Device Status</h3>
<div class="grid grid-cols-4 gap-4">
<!-- Battery Status -->
<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="battery-level" class="text-2xl font-bold text-gray-900 dark:text-white">
{% if current_status and current_status.battery_level %}{{ current_status.battery_level }}%{% else %}--{% endif %}
</div>
<div class="mt-2 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div id="battery-bar" class="bg-green-500 h-2 rounded-full transition-all"
style="width: {% if current_status and current_status.battery_level %}{{ current_status.battery_level }}%{% else %}0%{% endif %}">
</div>
</div>
</div>
<!-- Power Source -->
<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="power-source" class="text-lg font-semibold text-gray-900 dark:text-white">
{% if current_status and current_status.power_source %}{{ current_status.power_source }}{% else %}--{% endif %}
</div>
</div>
<!-- SD Card Space -->
<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">SD Card</span>
<svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M3 4a2 2 0 012-2h10a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V4zm2 0v12h10V4H5z"/>
</svg>
</div>
<div id="sd-remaining" class="text-lg font-semibold text-gray-900 dark:text-white">
{% if current_status and current_status.sd_remaining_mb %}{{ current_status.sd_remaining_mb }} MB{% else %}--{% endif %}
</div>
<div id="sd-ratio" class="text-xs text-gray-500 dark:text-gray-400">
{% if current_status and current_status.sd_free_ratio %}{{ current_status.sd_free_ratio }}% free{% else %}--{% endif %}
</div>
</div>
<!-- Last Update -->
<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">Last Update</span>
<svg class="w-4 h-4 text-gray-500" 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>
</div>
<div id="last-update" class="text-xs font-medium text-gray-700 dark:text-gray-300">
Just now
</div>
<div id="auto-refresh-indicator" class="mt-2 flex items-center text-xs text-green-600 dark:text-green-400">
<span class="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
Auto-refresh: 30s
</div>
</div>
</div>
</div>
<!-- Device Settings & Commands -->
<div class="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Store Name & Clock -->
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">Store Name & Time</h3>
<!-- Store Name (Index Number) -->
<div class="mb-4">
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-2">Store Name (Index Number)</label>
<div class="flex items-center gap-2">
<input type="number" id="index-number-input"
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"
min="0" max="9999" placeholder="0000">
<button onclick="getIndexNumber('{{ unit.id }}')"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">
Get
</button>
<button onclick="setIndexNumber('{{ unit.id }}')"
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg text-sm">
Set
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Range: 0000-9999. Used for file numbering.</p>
<div id="index-overwrite-warning" class="hidden mt-2 p-2 bg-red-100 dark:bg-red-900/30 border border-red-300 dark:border-red-700 rounded text-xs text-red-800 dark:text-red-400">
⚠️ <strong>Warning:</strong> Data exists at this index. Starting measurement will overwrite previous data!
</div>
</div>
<!-- Device Clock -->
<div>
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-2">Device Clock</label>
<div class="flex items-center gap-2">
<div id="device-clock" class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-700/50 text-gray-900 dark:text-white font-mono text-sm">
--
</div>
<button onclick="getDeviceClock('{{ unit.id }}')"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">
Refresh
</button>
<button onclick="syncDeviceClock('{{ unit.id }}')"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm">
Sync
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Sync sets device clock to match server time.</p>
</div>
</div>
<!-- Measurement Settings -->
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">Measurement Settings</h3>
<!-- Frequency Weighting -->
<div class="mb-4">
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-2">Frequency Weighting</label>
<div class="flex items-center gap-2">
<select id="frequency-weighting-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="">--</option>
<option value="A">A</option>
<option value="C">C</option>
<option value="Z">Z</option>
</select>
<button onclick="getFrequencyWeighting('{{ unit.id }}')"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">
Get
</button>
<button onclick="setFrequencyWeighting('{{ unit.id }}')"
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg text-sm">
Set
</button>
</div>
</div>
<!-- Time Weighting -->
<div class="mb-4">
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-2">Time Weighting</label>
<div class="flex items-center gap-2">
<select id="time-weighting-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="">--</option>
<option value="F">F (Fast)</option>
<option value="S">S (Slow)</option>
<option value="I">I (Impulse)</option>
</select>
<button onclick="getTimeWeighting('{{ unit.id }}')"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">
Get
</button>
<button onclick="setTimeWeighting('{{ unit.id }}')"
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg text-sm">
Set
</button>
</div>
</div>
<!-- All Settings Query -->
<div>
<button onclick="getAllSettings('{{ unit.id }}')"
class="w-full px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm flex items-center justify-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 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"></path>
</svg>
Query All Settings
</button>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 text-center">View all device settings for diagnostics</p>
</div>
</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') {
// Handle timer based on action
if (action === 'start') {
startMeasurementTimer(unitId);
} else if (action === 'stop' || action === 'reset') {
stopMeasurementTimer();
clearMeasurementStartTime(unitId);
}
// Note: pause does not stop timer - it keeps running
// Reload the live view to update status
setTimeout(() => {
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
target: '#slm-command-center',
swap: 'innerHTML'
});
}, 500);
} else {
alert(`Error: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Failed to control unit: ${error.message}`);
}
}
// Start measurement with overwrite check
async function startMeasurementWithCheck(unitId) {
try {
// Check for overwrite risk
const checkResponse = await fetch(`/api/slmm/${unitId}/overwrite-check`);
const checkResult = await checkResponse.json();
console.log('Overwrite check result:', checkResult);
if (checkResult.status === 'ok') {
// API returns data directly, not nested under .data
const overwriteStatus = checkResult.overwrite_status;
const willOverwrite = checkResult.will_overwrite;
if (willOverwrite === true || overwriteStatus === 'Exist') {
// Data exists - warn user
const confirmed = confirm(
`⚠️ WARNING: Data exists at the current store index!\n\n` +
`Overwrite Status: ${overwriteStatus}\n\n` +
`Starting measurement will OVERWRITE previous data.\n\n` +
`Are you sure you want to continue?`
);
if (!confirmed) {
return; // User cancelled
}
}
}
// Proceed with start
await controlUnit(unitId, 'start');
} catch (error) {
console.error('Overwrite check failed:', error);
// Still allow start, but warn user
const proceed = confirm(
'Could not verify overwrite status.\n\n' +
'Do you want to start measurement anyway?'
);
if (proceed) {
await controlUnit(unitId, 'start');
}
}
}
// Index Number (Store Name) functions
async function getIndexNumber(unitId) {
try {
const response = await fetch(`/api/slmm/${unitId}/index-number`);
const result = await response.json();
if (result.status === 'ok') {
const indexNumber = result.data?.index_number || result.index_number;
document.getElementById('index-number-input').value = parseInt(indexNumber);
// Check for overwrite risk at this index
await checkOverwriteStatus(unitId);
} else {
alert(`Failed to get index number: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Failed to get index number: ${error.message}`);
}
}
async function setIndexNumber(unitId) {
const input = document.getElementById('index-number-input');
const indexValue = parseInt(input.value);
if (isNaN(indexValue) || indexValue < 0 || indexValue > 9999) {
alert('Please enter a valid index number (0-9999)');
return;
}
try {
const response = await fetch(`/api/slmm/${unitId}/index-number`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ index: indexValue })
});
const result = await response.json();
if (result.status === 'ok') {
alert(`Index number set to ${String(indexValue).padStart(4, '0')}`);
// Check for overwrite risk at new index
await checkOverwriteStatus(unitId);
} else {
alert(`Failed to set index number: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Failed to set index number: ${error.message}`);
}
}
async function checkOverwriteStatus(unitId) {
try {
const response = await fetch(`/api/slmm/${unitId}/overwrite-check`);
const result = await response.json();
console.log('Overwrite status check:', result);
const warningDiv = document.getElementById('index-overwrite-warning');
if (result.status === 'ok') {
// API returns data directly, not nested under .data
const overwriteStatus = result.overwrite_status;
const willOverwrite = result.will_overwrite;
if (willOverwrite === true || overwriteStatus === 'Exist') {
warningDiv.classList.remove('hidden');
} else {
warningDiv.classList.add('hidden');
}
} else {
warningDiv.classList.add('hidden');
}
} catch (error) {
console.error('Failed to check overwrite status:', error);
// Hide warning on error
const warningDiv = document.getElementById('index-overwrite-warning');
if (warningDiv) {
warningDiv.classList.add('hidden');
}
}
}
// Device Clock functions
async function getDeviceClock(unitId) {
try {
const response = await fetch(`/api/slmm/${unitId}/clock`);
const result = await response.json();
if (result.status === 'ok') {
const clockValue = result.data?.clock || result.clock;
document.getElementById('device-clock').textContent = clockValue;
} else {
alert(`Failed to get device clock: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Failed to get device clock: ${error.message}`);
}
}
async function syncDeviceClock(unitId) {
try {
// Format current time for NL43: YYYY/MM/DD,HH:MM:SS
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const datetime = `${year}/${month}/${day},${hours}:${minutes}:${seconds}`;
const response = await fetch(`/api/slmm/${unitId}/clock`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ datetime: datetime })
});
const result = await response.json();
if (result.status === 'ok') {
alert('Device clock synchronized successfully!');
await getDeviceClock(unitId);
} else {
alert(`Failed to sync clock: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Failed to sync clock: ${error.message}`);
}
}
// Frequency Weighting functions
async function getFrequencyWeighting(unitId) {
try {
const response = await fetch(`/api/slmm/${unitId}/frequency-weighting?channel=Main`);
const result = await response.json();
if (result.status === 'ok') {
const weighting = result.data?.frequency_weighting || result.frequency_weighting;
document.getElementById('frequency-weighting-select').value = weighting;
} else {
alert(`Failed to get frequency weighting: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Failed to get frequency weighting: ${error.message}`);
}
}
async function setFrequencyWeighting(unitId) {
const select = document.getElementById('frequency-weighting-select');
const weighting = select.value;
if (!weighting) {
alert('Please select a frequency weighting');
return;
}
try {
const response = await fetch(`/api/slmm/${unitId}/frequency-weighting`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ weighting: weighting, channel: 'Main' })
});
const result = await response.json();
if (result.status === 'ok') {
alert(`Frequency weighting set to ${weighting}`);
} else {
alert(`Failed to set frequency weighting: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Failed to set frequency weighting: ${error.message}`);
}
}
// Time Weighting functions
async function getTimeWeighting(unitId) {
try {
const response = await fetch(`/api/slmm/${unitId}/time-weighting?channel=Main`);
const result = await response.json();
if (result.status === 'ok') {
const weighting = result.data?.time_weighting || result.time_weighting;
document.getElementById('time-weighting-select').value = weighting;
} else {
alert(`Failed to get time weighting: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Failed to get time weighting: ${error.message}`);
}
}
async function setTimeWeighting(unitId) {
const select = document.getElementById('time-weighting-select');
const weighting = select.value;
if (!weighting) {
alert('Please select a time weighting');
return;
}
try {
const response = await fetch(`/api/slmm/${unitId}/time-weighting`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ weighting: weighting, channel: 'Main' })
});
const result = await response.json();
if (result.status === 'ok') {
alert(`Time weighting set to ${weighting}`);
} else {
alert(`Failed to set time weighting: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Failed to set time weighting: ${error.message}`);
}
}
// Get All Settings
async function getAllSettings(unitId) {
try {
const response = await fetch(`/api/slmm/${unitId}/settings/all`);
const result = await response.json();
if (result.status === 'ok') {
const settings = result.data?.settings || result.settings;
// Format settings for display
let message = 'Current Device Settings:\n\n';
for (const [key, value] of Object.entries(settings)) {
const label = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
message += `${label}: ${value}\n`;
}
alert(message);
} else {
alert(`Failed to get settings: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Failed to get settings: ${error.message}`);
}
}
// ========================================
// Measurement Timer
// ========================================
let measurementTimerInterval = null;
const TIMER_STORAGE_KEY = 'slm_measurement_start_';
function startMeasurementTimer(unitId) {
// Stop any existing timer
stopMeasurementTimer();
// Record start time in localStorage
const startTime = Date.now();
localStorage.setItem(TIMER_STORAGE_KEY + unitId, startTime.toString());
// Show timer container
const container = document.getElementById('elapsed-time-container');
if (container) {
container.classList.remove('hidden');
}
// Update timer immediately
updateElapsedTime(unitId);
// Update every second
measurementTimerInterval = setInterval(() => {
updateElapsedTime(unitId);
}, 1000);
console.log('Measurement timer started for', unitId);
}
function stopMeasurementTimer() {
if (measurementTimerInterval) {
clearInterval(measurementTimerInterval);
measurementTimerInterval = null;
}
// Hide timer container
const container = document.getElementById('elapsed-time-container');
if (container) {
container.classList.add('hidden');
}
console.log('Measurement timer stopped');
}
function updateElapsedTime(unitId) {
const startTimeStr = localStorage.getItem(TIMER_STORAGE_KEY + unitId);
if (!startTimeStr) {
return;
}
const startTime = parseInt(startTimeStr);
const now = Date.now();
const elapsedMs = now - startTime;
// Convert to HH:MM:SS
const hours = Math.floor(elapsedMs / (1000 * 60 * 60));
const minutes = Math.floor((elapsedMs % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((elapsedMs % (1000 * 60)) / 1000);
const timeString =
String(hours).padStart(2, '0') + ':' +
String(minutes).padStart(2, '0') + ':' +
String(seconds).padStart(2, '0');
const display = document.getElementById('elapsed-time');
if (display) {
display.textContent = timeString;
}
}
function clearMeasurementStartTime(unitId) {
localStorage.removeItem(TIMER_STORAGE_KEY + unitId);
console.log('Cleared measurement start time for', unitId);
}
// Resume timer if measurement is in progress
async function resumeMeasurementTimerIfNeeded(unitId, isMeasuring) {
const startTimeStr = localStorage.getItem(TIMER_STORAGE_KEY + unitId);
if (isMeasuring && startTimeStr) {
// Measurement is active and we have a start time - resume timer
startMeasurementTimer(unitId);
} else if (!isMeasuring && startTimeStr) {
// Measurement stopped but we have a start time - clear it
clearMeasurementStartTime(unitId);
stopMeasurementTimer();
} else if (isMeasuring && !startTimeStr) {
// Measurement is active but no start time recorded
// Try to get start time from last folder on FTP
console.log('Measurement active but no start time - fetching from FTP...');
await fetchStartTimeFromFTP(unitId);
}
}
// Fetch measurement start time from last folder on FTP
async function fetchStartTimeFromFTP(unitId) {
try {
const response = await fetch(`/api/slmm/${unitId}/ftp/files?path=/NL-43`);
const result = await response.json();
if (result.status === 'ok' && result.files && result.files.length > 0) {
// Filter for directories only
const folders = result.files.filter(f => f.is_dir || f.type === 'directory');
if (folders.length > 0) {
// Sort by modified timestamp (newest first) or by name
folders.sort((a, b) => {
// Try sorting by modified_timestamp first (ISO format)
if (a.modified_timestamp && b.modified_timestamp) {
return new Date(b.modified_timestamp) - new Date(a.modified_timestamp);
}
// Fall back to sorting by name (descending, assuming YYYYMMDD_HHMMSS format)
return b.name.localeCompare(a.name);
});
const lastFolder = folders[0];
console.log('Last measurement folder:', lastFolder.name);
console.log('Folder details:', lastFolder);
// Try to parse timestamp from folder name
// Common formats: YYYYMMDD_HHMMSS, YYYY-MM-DD_HH-MM-SS, or use modified time
const startTime = parseFolderTimestamp(lastFolder);
if (startTime) {
console.log('Parsed start time from folder:', new Date(startTime));
localStorage.setItem(TIMER_STORAGE_KEY + unitId, startTime.toString());
startMeasurementTimer(unitId);
} else {
// Can't parse folder time - start from now
console.warn('Could not parse folder timestamp, starting timer from now');
startMeasurementTimer(unitId);
}
} else {
// No folders found - start from now
console.warn('No measurement folders found, starting timer from now');
startMeasurementTimer(unitId);
}
} else {
// FTP failed or no files - start from now
console.warn('Could not access FTP, starting timer from now');
startMeasurementTimer(unitId);
}
} catch (error) {
console.error('Error fetching start time from FTP:', error);
// Fallback - start from now
startMeasurementTimer(unitId);
}
}
// Parse timestamp from folder name or modified time
function parseFolderTimestamp(folder) {
// Try parsing from folder name first
// Expected formats: YYYYMMDD_HHMMSS or YYYY-MM-DD_HH-MM-SS
const name = folder.name;
// Pattern: YYYYMMDD_HHMMSS (e.g., 20250114_143052)
const pattern1 = /(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})/;
const match1 = name.match(pattern1);
if (match1) {
const [_, year, month, day, hour, min, sec] = match1;
return new Date(year, month - 1, day, hour, min, sec).getTime();
}
// Pattern: YYYY-MM-DD_HH-MM-SS (e.g., 2025-01-14_14-30-52)
const pattern2 = /(\d{4})-(\d{2})-(\d{2})[_T](\d{2})-(\d{2})-(\d{2})/;
const match2 = name.match(pattern2);
if (match2) {
const [_, year, month, day, hour, min, sec] = match2;
return new Date(year, month - 1, day, hour, min, sec).getTime();
}
// Try using modified_timestamp (ISO format from SLMM)
if (folder.modified_timestamp) {
return new Date(folder.modified_timestamp).getTime();
}
// Fallback to modified (string format)
if (folder.modified) {
const parsedTime = new Date(folder.modified).getTime();
if (!isNaN(parsedTime)) {
return parsedTime;
}
}
// Could not parse
return null;
}
// Auto-refresh status every 30 seconds
let refreshInterval;
const REFRESH_INTERVAL_MS = 30000; // 30 seconds
const unit_id = '{{ unit.id }}';
function updateDeviceStatus() {
fetch(`/api/slmm/${unit_id}/live`)
.then(response => response.json())
.then(result => {
if (result.status === 'ok' && result.data) {
const data = result.data;
// Update battery
if (document.getElementById('battery-level')) {
const batteryLevel = data.battery_level || '--';
document.getElementById('battery-level').textContent = batteryLevel === '--' ? '--' : `${batteryLevel}%`;
// Update battery bar
const batteryBar = document.getElementById('battery-bar');
if (batteryBar && batteryLevel !== '--') {
const level = parseInt(batteryLevel);
batteryBar.style.width = `${level}%`;
// Color based on 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
if (document.getElementById('power-source')) {
document.getElementById('power-source').textContent = data.power_source || '--';
}
// Update SD card info
if (document.getElementById('sd-remaining')) {
const sdRemaining = data.sd_remaining_mb || '--';
document.getElementById('sd-remaining').textContent = sdRemaining === '--' ? '--' : `${sdRemaining} MB`;
}
if (document.getElementById('sd-ratio')) {
const sdRatio = data.sd_free_ratio || '--';
document.getElementById('sd-ratio').textContent = sdRatio === '--' ? '--' : `${sdRatio}% free`;
}
// Update last update timestamp
if (document.getElementById('last-update')) {
const now = new Date();
document.getElementById('last-update').textContent = now.toLocaleTimeString();
}
}
})
.catch(error => {
console.error('Failed to refresh device status:', error);
// Update last update with error indicator
if (document.getElementById('last-update')) {
document.getElementById('last-update').textContent = 'Update failed';
}
});
}
// Start auto-refresh
function startAutoRefresh() {
// Initial update
updateDeviceStatus();
// Set up interval
refreshInterval = setInterval(updateDeviceStatus, REFRESH_INTERVAL_MS);
console.log('Auto-refresh started (30s interval)');
}
// Stop auto-refresh
function stopAutoRefresh() {
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
console.log('Auto-refresh stopped');
}
}
// Start auto-refresh and load initial data when page loads
document.addEventListener('DOMContentLoaded', function() {
startAutoRefresh();
// Load initial device settings
const unitId = '{{ unit.id }}';
getDeviceClock(unitId);
getIndexNumber(unitId);
getFrequencyWeighting(unitId);
getTimeWeighting(unitId);
// Resume measurement timer if device is currently measuring
const isMeasuring = {{ 'true' if is_measuring else 'false' }};
resumeMeasurementTimerIfNeeded(unitId, isMeasuring);
});
// Cleanup on page unload
window.addEventListener('beforeunload', function() {
if (window.currentWebSocket) {
window.currentWebSocket.close();
}
// Timer will resume on next page load if measurement is still active
stopMeasurementTimer();
});
// ========================================
// Settings Modal
// ========================================
async function openSettingsModal(unitId) {
const modal = document.getElementById('settings-modal');
const errorDiv = document.getElementById('settings-error');
const successDiv = document.getElementById('settings-success');
// Clear previous messages
errorDiv.classList.add('hidden');
successDiv.classList.add('hidden');
// Store unit ID
document.getElementById('settings-unit-id').value = unitId;
// Load current SLMM config
try {
const response = await fetch(`/api/slmm/${unitId}/config`);
if (!response.ok) {
throw new Error('Failed to load configuration');
}
const result = await response.json();
const config = result.data || {};
// Populate form fields
document.getElementById('settings-host').value = config.host || '';
document.getElementById('settings-tcp-port').value = config.tcp_port || 2255;
document.getElementById('settings-ftp-port').value = config.ftp_port || 21;
document.getElementById('settings-ftp-username').value = config.ftp_username || '';
document.getElementById('settings-ftp-password').value = config.ftp_password || '';
document.getElementById('settings-tcp-enabled').checked = config.tcp_enabled !== false;
document.getElementById('settings-ftp-enabled').checked = config.ftp_enabled === true;
document.getElementById('settings-web-enabled').checked = config.web_enabled === true;
modal.classList.remove('hidden');
} catch (error) {
console.error('Failed to load SLMM config:', error);
errorDiv.textContent = 'Failed to load configuration: ' + error.message;
errorDiv.classList.remove('hidden');
modal.classList.remove('hidden');
}
}
function closeSettingsModal() {
document.getElementById('settings-modal').classList.add('hidden');
}
document.getElementById('settings-form').addEventListener('submit', async function(e) {
e.preventDefault();
const unitId = document.getElementById('settings-unit-id').value;
const errorDiv = document.getElementById('settings-error');
const successDiv = document.getElementById('settings-success');
errorDiv.classList.add('hidden');
successDiv.classList.add('hidden');
// Gather form data
const configData = {
host: document.getElementById('settings-host').value.trim(),
tcp_port: parseInt(document.getElementById('settings-tcp-port').value),
ftp_port: parseInt(document.getElementById('settings-ftp-port').value),
ftp_username: document.getElementById('settings-ftp-username').value.trim() || null,
ftp_password: document.getElementById('settings-ftp-password').value || null,
tcp_enabled: document.getElementById('settings-tcp-enabled').checked,
ftp_enabled: document.getElementById('settings-ftp-enabled').checked,
web_enabled: document.getElementById('settings-web-enabled').checked
};
// Validation
if (!configData.host) {
errorDiv.textContent = 'Host/IP address is required';
errorDiv.classList.remove('hidden');
return;
}
if (configData.tcp_port < 1 || configData.tcp_port > 65535) {
errorDiv.textContent = 'TCP port must be between 1 and 65535';
errorDiv.classList.remove('hidden');
return;
}
if (configData.ftp_port < 1 || configData.ftp_port > 65535) {
errorDiv.textContent = 'FTP port must be between 1 and 65535';
errorDiv.classList.remove('hidden');
return;
}
try {
const response = await fetch(`/api/slmm/${unitId}/config`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(configData)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to update configuration');
}
successDiv.textContent = 'Configuration saved successfully!';
successDiv.classList.remove('hidden');
// Close modal after 1.5 seconds
setTimeout(() => {
closeSettingsModal();
// Optionally reload the page to reflect changes
// window.location.reload();
}, 1500);
} catch (error) {
errorDiv.textContent = error.message;
errorDiv.classList.remove('hidden');
}
});
// ========================================
// FTP Browser Modal
// ========================================
async function openFTPBrowser(unitId) {
const modal = document.getElementById('ftp-modal');
document.getElementById('ftp-unit-id').value = unitId;
modal.classList.remove('hidden');
// Check FTP status and update UI
await updateFTPStatus(unitId);
loadFTPFiles(unitId, '/');
}
async function updateFTPStatus(unitId) {
const statusBadge = document.getElementById('ftp-status-badge');
// Show checking state
statusBadge.innerHTML = '<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-gray-500 mr-2"></div>Checking...';
statusBadge.className = 'flex items-center text-xs px-3 py-1 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400';
try {
const response = await fetch(`/api/slmm/${unitId}/ftp/status`);
const result = await response.json();
if (result.status === 'ok' && result.ftp_enabled) {
statusBadge.innerHTML = '<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>FTP Enabled';
statusBadge.className = 'flex items-center text-xs px-3 py-1 rounded bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400';
} else {
statusBadge.innerHTML = '<span class="w-2 h-2 bg-amber-500 rounded-full mr-2"></span>FTP Disabled';
statusBadge.className = 'flex items-center text-xs px-3 py-1 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-400';
}
} catch (error) {
statusBadge.innerHTML = '<span class="w-2 h-2 bg-gray-500 rounded-full mr-2"></span>Status Unknown';
statusBadge.className = 'flex items-center text-xs px-3 py-1 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400';
}
}
function closeFTPBrowser() {
document.getElementById('ftp-modal').classList.add('hidden');
}
async function loadFTPFiles(unitId, path) {
const container = document.getElementById('ftp-files-list');
const pathDisplay = document.getElementById('ftp-current-path');
const errorDiv = document.getElementById('ftp-error');
// Update path display
pathDisplay.textContent = path || '/';
// Show loading state
container.innerHTML = '<div class="text-center py-8 text-gray-500"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-2"></div>Loading files...</div>';
errorDiv.classList.add('hidden');
try {
const response = await fetch(`/api/slmm/${unitId}/ftp/files?path=${encodeURIComponent(path)}`);
const result = await response.json();
if (response.status === 502) {
// FTP connection failed - likely not enabled or network issue
const detail = result.detail || 'Connection failed';
errorDiv.innerHTML = `
<div>
<div class="flex items-center justify-between mb-3">
<div class="flex-1">
<p class="font-medium">FTP Connection Failed</p>
<p class="text-sm mt-1">${detail}</p>
</div>
<button onclick="enableFTP('${unitId}')"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-orange-600 transition-colors whitespace-nowrap">
Enable FTP
</button>
</div>
<div class="text-xs bg-blue-50 dark:bg-blue-900/20 p-3 rounded">
<p class="font-medium text-blue-800 dark:text-blue-400 mb-1">Troubleshooting:</p>
<ul class="list-disc list-inside space-y-1 text-blue-700 dark:text-blue-300">
<li>Click "Enable FTP" to activate FTP on the device</li>
<li>Ensure the device is powered on and connected to the network</li>
<li>Check that port 21 (FTP) is not blocked by firewalls</li>
<li>Verify the modem/IP address is correct in unit settings</li>
</ul>
</div>
</div>
`;
errorDiv.classList.remove('hidden');
container.innerHTML = '';
return;
}
if (result.status !== 'ok') {
throw new Error(result.detail || 'Failed to list files');
}
// SLMM returns 'files' not 'data'
const files = result.files || result.data || [];
if (files.length === 0) {
container.innerHTML = '<div class="text-center py-8 text-gray-500">No files found</div>';
return;
}
// Sort: directories first, then by name
files.sort((a, b) => {
if (a.type === 'directory' && b.type !== 'directory') return -1;
if (a.type !== 'directory' && b.type === 'directory') return 1;
return a.name.localeCompare(b.name);
});
let html = '<div class="space-y-1">';
// Add parent directory link if not at root
if (path && path !== '/') {
const parentPath = path.split('/').slice(0, -1).join('/') || '/';
html += `
<div class="flex items-center p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded cursor-pointer" onclick="loadFTPFiles('${unitId}', '${parentPath}')">
<svg class="w-5 h-5 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
<span class="text-gray-600 dark:text-gray-400">..</span>
</div>
`;
}
// Add files and directories
files.forEach(file => {
const fullPath = file.path || (path === '/' ? `/${file.name}` : `${path}/${file.name}`);
const isDir = file.is_dir || file.type === 'directory';
// Determine file type icon and color
let icon, iconColor = 'text-gray-400';
if (isDir) {
icon = '<svg class="w-5 h-5 mr-3 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path></svg>';
} else if (file.name.toLowerCase().endsWith('.csv')) {
icon = '<svg class="w-5 h-5 mr-3 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>';
} else if (file.name.toLowerCase().match(/\.(txt|log)$/)) {
icon = '<svg class="w-5 h-5 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>';
} else {
icon = '<svg class="w-5 h-5 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg>';
}
const sizeText = file.size ? formatFileSize(file.size) : '';
const dateText = file.modified || file.modified_time || '';
const canPreview = !isDir && (file.name.toLowerCase().match(/\.(csv|txt|log)$/));
if (isDir) {
html += `
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded cursor-pointer transition-colors" onclick="loadFTPFiles('${unitId}', '${escapeForAttribute(fullPath)}')">
<div class="flex items-center flex-1">
${icon}
<span class="text-gray-900 dark:text-white font-medium">${escapeHtml(file.name)}</span>
</div>
<span class="text-xs text-gray-500">${dateText}</span>
</div>
`;
} else {
html += `
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors group">
<div class="flex items-center flex-1 min-w-0">
${icon}
<span class="text-gray-900 dark:text-white truncate">${escapeHtml(file.name)}</span>
</div>
<div class="flex items-center gap-3 flex-shrink-0 ml-4">
<span class="text-xs text-gray-500 hidden sm:inline">${sizeText}</span>
<span class="text-xs text-gray-500 hidden md:inline">${dateText}</span>
${canPreview ? `
<button onclick="previewFile('${unitId}', '${escapeForAttribute(fullPath)}', '${escapeForAttribute(file.name)}')"
class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded transition-colors flex items-center"
title="Preview file">
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
</button>
` : ''}
<button onclick="downloadFTPFile('${unitId}', '${escapeForAttribute(fullPath)}', '${escapeForAttribute(file.name)}')"
class="px-3 py-1 bg-seismo-orange hover:bg-orange-600 text-white text-sm rounded transition-colors flex items-center"
title="Download to your computer">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
<span class="hidden lg:inline">Download</span>
</button>
</div>
</div>
`;
}
});
function escapeForAttribute(str) {
return String(str).replace(/'/g, "\\'").replace(/"/g, '&quot;');
}
html += '</div>';
container.innerHTML = html;
} catch (error) {
console.error('Failed to load FTP files:', error);
errorDiv.textContent = error.message;
errorDiv.classList.remove('hidden');
container.innerHTML = '';
}
}
async function downloadFTPFile(unitId, filePath, fileName) {
try {
// Show download indicator
const downloadBtn = event.target;
const originalText = downloadBtn.innerHTML;
downloadBtn.innerHTML = '<svg class="w-4 h-4 animate-spin" 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>';
downloadBtn.disabled = true;
const response = await fetch(`/api/slmm/${unitId}/ftp/download`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ remote_path: filePath })
});
if (!response.ok) {
throw new Error('Download failed');
}
// The response is a file, so we need to create a download link
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName || filePath.split('/').pop();
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
// Reset button
downloadBtn.innerHTML = originalText;
downloadBtn.disabled = false;
// Show success message briefly
const originalBtnClass = downloadBtn.className;
downloadBtn.className = downloadBtn.className.replace('bg-seismo-orange', 'bg-green-600');
setTimeout(() => {
downloadBtn.className = originalBtnClass;
}, 2000);
} catch (error) {
console.error('Failed to download file:', error);
alert('Failed to download file: ' + error.message);
// Reset button on error
if (event.target) {
event.target.innerHTML = originalText;
event.target.disabled = false;
}
}
}
async function downloadToServer(unitId, filePath, fileName) {
try {
const response = await fetch(`/api/slmm/${unitId}/ftp/download`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ remote_path: filePath, save_to_server: true })
});
const result = await response.json();
if (result.status === 'ok') {
alert(`File saved to server at: ${result.local_path}`);
} else {
throw new Error(result.detail || 'Failed to save to server');
}
} catch (error) {
console.error('Failed to save file to server:', error);
alert('Failed to save file to server: ' + error.message);
}
}
async function previewFile(unitId, filePath, fileName) {
const modal = document.getElementById('preview-modal');
const previewContent = document.getElementById('preview-content');
const previewTitle = document.getElementById('preview-title');
previewTitle.textContent = fileName;
previewContent.innerHTML = '<div class="text-center py-8"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-2"></div>Loading preview...</div>';
modal.classList.remove('hidden');
try {
const response = await fetch(`/api/slmm/${unitId}/ftp/download`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ remote_path: filePath })
});
if (!response.ok) {
throw new Error('Failed to load file');
}
const blob = await response.blob();
const text = await blob.text();
// Check file type for syntax highlighting
const isCSV = fileName.toLowerCase().endsWith('.csv');
const isTXT = fileName.toLowerCase().endsWith('.txt') || fileName.toLowerCase().endsWith('.log');
if (isCSV) {
// Parse and display CSV as table
const lines = text.split('\n').filter(l => l.trim());
if (lines.length > 0) {
const headers = lines[0].split(',');
let tableHTML = '<div class="overflow-x-auto"><table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700"><thead class="bg-gray-50 dark:bg-gray-800"><tr>';
headers.forEach(h => {
tableHTML += `<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">${h.trim()}</th>`;
});
tableHTML += '</tr></thead><tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">';
for (let i = 1; i < Math.min(lines.length, 101); i++) {
const cells = lines[i].split(',');
tableHTML += '<tr>';
cells.forEach(c => {
tableHTML += `<td class="px-4 py-2 text-sm text-gray-900 dark:text-gray-100">${c.trim()}</td>`;
});
tableHTML += '</tr>';
}
tableHTML += '</tbody></table></div>';
if (lines.length > 101) {
tableHTML += `<p class="text-sm text-gray-500 mt-4 text-center">Showing first 100 rows of ${lines.length - 1} total rows</p>`;
}
previewContent.innerHTML = tableHTML;
}
} else {
// Display as plain text with line numbers
const lines = text.split('\n');
const maxLines = 1000;
const displayLines = lines.slice(0, maxLines);
let preHTML = '<pre class="text-xs font-mono bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto"><code>';
displayLines.forEach((line, i) => {
preHTML += `<span class="text-gray-500">${String(i + 1).padStart(4, ' ')}</span> ${escapeHtml(line)}\n`;
});
preHTML += '</code></pre>';
if (lines.length > maxLines) {
preHTML += `<p class="text-sm text-gray-500 mt-4 text-center">Showing first ${maxLines} lines of ${lines.length} total lines</p>`;
}
previewContent.innerHTML = preHTML;
}
} catch (error) {
console.error('Failed to preview file:', error);
previewContent.innerHTML = `<div class="text-center py-8 text-red-500">Failed to load preview: ${error.message}</div>`;
}
}
function closePreviewModal() {
document.getElementById('preview-modal').classList.add('hidden');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function enableFTP(unitId) {
const errorDiv = document.getElementById('ftp-error');
const container = document.getElementById('ftp-files-list');
// Show loading state
errorDiv.innerHTML = '<div class="flex items-center"><div class="animate-spin rounded-full h-5 w-5 border-b-2 border-seismo-orange mr-3"></div>Enabling FTP on device...</div>';
errorDiv.classList.remove('hidden');
try {
const response = await fetch(`/api/slmm/${unitId}/ftp/enable`, {
method: 'POST'
});
const result = await response.json();
if (result.status !== 'ok') {
throw new Error(result.detail || 'Failed to enable FTP');
}
// Success - wait a moment then try loading files again
errorDiv.innerHTML = '<div class="flex items-center text-green-600 dark:text-green-400"><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="M5 13l4 4L19 7"></path></svg>FTP enabled successfully. Loading files...</div>';
setTimeout(() => {
loadFTPFiles(unitId, '/');
}, 2000);
} catch (error) {
console.error('Failed to enable FTP:', error);
errorDiv.innerHTML = `
<div>
<p class="font-medium text-red-600 dark:text-red-400">Failed to enable FTP</p>
<p class="text-sm mt-1">${error.message}</p>
<button onclick="loadFTPFiles('${unitId}', '/')"
class="mt-2 px-3 py-1 bg-gray-600 text-white text-sm rounded hover:bg-gray-700 transition-colors">
Retry Connection
</button>
</div>
`;
errorDiv.classList.remove('hidden');
}
}
async function enableFTPFromHeader() {
const unitId = document.getElementById('ftp-unit-id').value;
const statusBadge = document.getElementById('ftp-status-badge');
const errorDiv = document.getElementById('ftp-error');
// Show enabling state
statusBadge.innerHTML = '<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-seismo-orange mr-2"></div>Enabling...';
statusBadge.className = 'flex items-center text-xs px-3 py-1 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-400';
errorDiv.classList.add('hidden');
try {
const response = await fetch(`/api/slmm/${unitId}/ftp/enable`, {
method: 'POST'
});
const result = await response.json();
if (result.status !== 'ok') {
throw new Error(result.detail || 'Failed to enable FTP');
}
// Update status badge
statusBadge.innerHTML = '<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>FTP Enabled';
statusBadge.className = 'flex items-center text-xs px-3 py-1 rounded bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400';
// Show success message and refresh files
errorDiv.innerHTML = '<div class="flex items-center text-green-600 dark:text-green-400"><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="M5 13l4 4L19 7"></path></svg>FTP enabled successfully</div>';
errorDiv.classList.remove('hidden');
setTimeout(() => {
errorDiv.classList.add('hidden');
loadFTPFiles(unitId, '/');
}, 2000);
} catch (error) {
console.error('Failed to enable FTP:', error);
statusBadge.innerHTML = '<span class="w-2 h-2 bg-red-500 rounded-full mr-2"></span>Enable Failed';
statusBadge.className = 'flex items-center text-xs px-3 py-1 rounded bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-400';
errorDiv.innerHTML = `<p class="font-medium">Failed to enable FTP:</p><p class="text-sm mt-1">${error.message}</p>`;
errorDiv.classList.remove('hidden');
}
}
async function disableFTPFromHeader() {
const unitId = document.getElementById('ftp-unit-id').value;
const statusBadge = document.getElementById('ftp-status-badge');
const errorDiv = document.getElementById('ftp-error');
// Show disabling state
statusBadge.innerHTML = '<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-amber-500 mr-2"></div>Disabling...';
statusBadge.className = 'flex items-center text-xs px-3 py-1 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-400';
errorDiv.classList.add('hidden');
try {
const response = await fetch(`/api/slmm/${unitId}/ftp/disable`, {
method: 'POST'
});
const result = await response.json();
if (result.status !== 'ok') {
throw new Error(result.detail || 'Failed to disable FTP');
}
// Update status badge
statusBadge.innerHTML = '<span class="w-2 h-2 bg-amber-500 rounded-full mr-2"></span>FTP Disabled';
statusBadge.className = 'flex items-center text-xs px-3 py-1 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-400';
// Show success message and clear files
errorDiv.innerHTML = '<div class="flex items-center text-amber-600 dark:text-amber-400"><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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>FTP disabled successfully</div>';
errorDiv.classList.remove('hidden');
document.getElementById('ftp-files-list').innerHTML = '<div class="text-center py-8 text-gray-500">FTP is disabled. Enable it to browse files.</div>';
setTimeout(() => {
errorDiv.classList.add('hidden');
}, 3000);
} catch (error) {
console.error('Failed to disable FTP:', error);
statusBadge.innerHTML = '<span class="w-2 h-2 bg-red-500 rounded-full mr-2"></span>Disable Failed';
statusBadge.className = 'flex items-center text-xs px-3 py-1 rounded bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-400';
errorDiv.innerHTML = `<p class="font-medium">Failed to disable FTP:</p><p class="text-sm mt-1">${error.message}</p>`;
errorDiv.classList.remove('hidden');
}
}
function refreshFTPFiles() {
const unitId = document.getElementById('ftp-unit-id').value;
const currentPath = document.getElementById('ftp-current-path').textContent;
// Update status
updateFTPStatus(unitId);
// Reload files at current path
loadFTPFiles(unitId, currentPath);
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
// Close modals on Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeSettingsModal();
closeFTPBrowser();
}
});
// Close modals when clicking outside
document.getElementById('settings-modal')?.addEventListener('click', function(e) {
if (e.target === this) {
closeSettingsModal();
}
});
document.getElementById('ftp-modal')?.addEventListener('click', function(e) {
if (e.target === this) {
closeFTPBrowser();
}
});
document.getElementById('preview-modal')?.addEventListener('click', function(e) {
if (e.target === this) {
closePreviewModal();
}
});
</script>
<!-- Settings Modal -->
<div id="settings-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center overflow-y-auto">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl m-4 my-8">
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 class="text-xl font-bold text-gray-900 dark:text-white">SLM Configuration</h3>
<button onclick="closeSettingsModal()" 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>
<form id="settings-form" class="p-6 space-y-6">
<input type="hidden" id="settings-unit-id">
<!-- Network Configuration -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">Network Configuration</h4>
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Host / IP Address</label>
<input type="text" id="settings-host"
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"
placeholder="e.g., 192.168.1.100" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
<input type="number" id="settings-tcp-port"
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"
placeholder="2255" min="1" max="65535" required>
<p class="text-xs text-gray-500 mt-1">Default: 2255 for NL-43/NL-53</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" id="settings-ftp-port"
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"
placeholder="21" min="1" max="65535" required>
<p class="text-xs text-gray-500 mt-1">Standard FTP port (default: 21)</p>
</div>
</div>
</div>
<!-- FTP Credentials -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">FTP Credentials</h4>
<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">Username</label>
<input type="text" id="settings-ftp-username"
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"
placeholder="anonymous">
<p class="text-xs text-gray-500 mt-1">Leave blank for anonymous</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Password</label>
<input type="password" id="settings-ftp-password"
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"
placeholder="••••••••">
<p class="text-xs text-gray-500 mt-1">Leave blank for anonymous</p>
</div>
</div>
</div>
<!-- Protocol Toggles -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">Protocol Settings</h4>
<div class="space-y-3">
<label class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
<div>
<span class="font-medium text-gray-900 dark:text-white">TCP Communication</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Enable TCP control commands</p>
</div>
<input type="checkbox" id="settings-tcp-enabled"
class="w-5 h-5 text-seismo-orange rounded border-gray-300 dark:border-gray-600 focus:ring-seismo-orange">
</label>
<label class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
<div>
<span class="font-medium text-gray-900 dark:text-white">FTP File Transfer</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Enable FTP file browsing and downloads</p>
</div>
<input type="checkbox" id="settings-ftp-enabled"
class="w-5 h-5 text-seismo-orange rounded border-gray-300 dark:border-gray-600 focus:ring-seismo-orange">
</label>
<label class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
<div>
<span class="font-medium text-gray-900 dark:text-white">Web Interface</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Enable web UI access (future feature)</p>
</div>
<input type="checkbox" id="settings-web-enabled"
class="w-5 h-5 text-seismo-orange rounded border-gray-300 dark:border-gray-600 focus:ring-seismo-orange">
</label>
</div>
</div>
<div id="settings-error" class="hidden text-sm p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg"></div>
<div id="settings-success" class="hidden text-sm p-3 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 rounded-lg"></div>
<div class="flex justify-end gap-3 pt-2 border-t border-gray-200 dark:border-gray-700">
<button type="button" onclick="closeSettingsModal()"
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button type="submit"
class="px-6 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium">
Save Configuration
</button>
</div>
</form>
</div>
</div>
<!-- FTP Browser Modal -->
<div id="ftp-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden m-4 flex flex-col">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-bold text-gray-900 dark:text-white">FTP File Browser</h3>
<button onclick="closeFTPBrowser()" 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 class="flex items-center justify-between">
<div class="flex items-center gap-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
Path: <span id="ftp-current-path" class="font-mono">/</span>
</p>
<div id="ftp-status-badge" class="flex items-center text-xs px-3 py-1 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-gray-500 mr-2"></div>
Checking...
</div>
</div>
<div class="flex items-center gap-2">
<button id="ftp-enable-btn" onclick="enableFTPFromHeader()"
class="px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg flex items-center transition-colors">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
Enable FTP
</button>
<button id="ftp-disable-btn" onclick="disableFTPFromHeader()"
class="px-3 py-1.5 bg-amber-600 hover:bg-amber-700 text-white text-sm rounded-lg flex items-center transition-colors">
<svg class="w-4 h-4 mr-1.5" 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>
Disable FTP
</button>
<button id="ftp-refresh-btn" onclick="refreshFTPFiles()"
class="px-3 py-1.5 bg-seismo-orange hover:bg-orange-600 text-white text-sm rounded-lg flex items-center transition-colors">
<svg class="w-4 h-4 mr-1.5" 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>
Refresh
</button>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto p-6">
<input type="hidden" id="ftp-unit-id">
<div id="ftp-error" class="hidden mb-4 p-4 bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-400 rounded-lg text-sm"></div>
<div id="ftp-files-list">
<!-- Files will be loaded here -->
</div>
</div>
</div>
</div>
<!-- File Preview Modal -->
<div id="preview-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between flex-shrink-0">
<h3 class="text-xl font-bold text-gray-900 dark:text-white flex items-center">
<svg class="w-6 h-6 mr-2 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
<span id="preview-title">File Preview</span>
</h3>
<button onclick="closePreviewModal()" 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 class="flex-1 overflow-y-auto p-6 bg-gray-50 dark:bg-gray-900" id="preview-content">
<!-- Preview content will be loaded here -->
</div>
</div>
</div>