- Implemented a new API router for managing report templates, including endpoints for listing, creating, retrieving, updating, and deleting templates. - Added a new HTML partial for a unified SLM settings modal, allowing users to configure SLM settings with dynamic modem selection and FTP credentials. - Created a report preview page with an editable data table using jspreadsheet, enabling users to modify report details and download the report as an Excel file.
2177 lines
100 KiB
HTML
2177 lines
100 KiB
HTML
<!-- Live View Panel for {{ unit.id }} -->
|
|
<div class="h-full flex flex-col">
|
|
<!-- Bootstrap data from server -->
|
|
<script id="slm-bootstrap-data" type="application/json">
|
|
{
|
|
"unit_id": "{{ unit.id }}",
|
|
"is_measuring": {{ 'true' if is_measuring else 'false' }},
|
|
"measurement_state": "{{ measurement_state }}",
|
|
"measurement_start_time": {% if current_status and current_status.measurement_start_time %}"{{ current_status.measurement_start_time }}"{% else %}null{% endif %}
|
|
}
|
|
</script>
|
|
|
|
<!-- 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();
|
|
|
|
// Only set start time if not already set (preserve existing start time)
|
|
const existingStartTime = localStorage.getItem(TIMER_STORAGE_KEY + unitId);
|
|
if (!existingStartTime) {
|
|
const startTime = Date.now();
|
|
localStorage.setItem(TIMER_STORAGE_KEY + unitId, startTime.toString());
|
|
console.log('No existing start time, setting to now:', new Date(startTime));
|
|
} else {
|
|
console.log('Using existing start time from localStorage:', new Date(parseInt(existingStartTime)));
|
|
}
|
|
|
|
// 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 {
|
|
console.log('Fetching FTP files from /NL-43...');
|
|
const response = await fetch(`/api/slmm/${unitId}/ftp/files?path=/NL-43`);
|
|
const result = await response.json();
|
|
|
|
console.log('FTP response status:', response.status);
|
|
console.log('FTP response data:', result);
|
|
|
|
if (result.status === 'ok' && result.files && result.files.length > 0) {
|
|
console.log(`Found ${result.files.length} files/folders`);
|
|
|
|
// Filter for directories only
|
|
const folders = result.files.filter(f => f.is_dir || f.type === 'directory');
|
|
console.log(`Found ${folders.length} folders:`, folders.map(f => f.name));
|
|
|
|
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));
|
|
console.log('✓ Storing start time in localStorage:', 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 in FTP response, starting timer from now');
|
|
startMeasurementTimer(unitId);
|
|
}
|
|
} else {
|
|
// FTP failed or no files - start from now
|
|
console.warn('✗ FTP request failed or returned no files:', result);
|
|
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) {
|
|
console.log('Parsing timestamp from folder:', folder.name);
|
|
|
|
// 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;
|
|
const timestamp = new Date(year, month - 1, day, hour, min, sec).getTime();
|
|
console.log(` ✓ Matched pattern YYYYMMDD_HHMMSS: ${new Date(timestamp)}`);
|
|
return timestamp;
|
|
}
|
|
|
|
// 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;
|
|
const timestamp = new Date(year, month - 1, day, hour, min, sec).getTime();
|
|
console.log(` ✓ Matched pattern YYYY-MM-DD_HH-MM-SS: ${new Date(timestamp)}`);
|
|
return timestamp;
|
|
}
|
|
|
|
console.log(' No pattern matched in folder name, trying modified_timestamp field');
|
|
|
|
// Try using modified_timestamp (ISO format from SLMM - already in UTC)
|
|
if (folder.modified_timestamp) {
|
|
// SLMM returns timestamps in UTC format without 'Z' suffix
|
|
// Append 'Z' to ensure browser parses as UTC
|
|
let utcTimestamp = folder.modified_timestamp;
|
|
if (!utcTimestamp.endsWith('Z')) {
|
|
utcTimestamp = utcTimestamp + 'Z';
|
|
}
|
|
const timestamp = new Date(utcTimestamp).getTime();
|
|
console.log(` ✓ Using modified_timestamp (UTC): ${folder.modified_timestamp} → ${new Date(timestamp).toISOString()} → ${new Date(timestamp).toString()}`);
|
|
return timestamp;
|
|
}
|
|
|
|
// Fallback to modified (string format)
|
|
if (folder.modified) {
|
|
const parsedTime = new Date(folder.modified).getTime();
|
|
if (!isNaN(parsedTime)) {
|
|
console.log(` ✓ Using modified field: ${new Date(parsedTime)}`);
|
|
return parsedTime;
|
|
}
|
|
console.log(` ✗ Could not parse modified field: ${folder.modified}`);
|
|
}
|
|
|
|
// Could not parse
|
|
console.log(' ✗ Could not parse any timestamp from folder');
|
|
return null;
|
|
}
|
|
|
|
|
|
// Apply status data to the DOM (used by bootstrap data and live refresh)
|
|
function applyDeviceStatusData(data) {
|
|
if (!data) {
|
|
return;
|
|
}
|
|
|
|
const batteryLevelRaw = data.battery_level ?? '--';
|
|
const batteryLevelElement = document.getElementById('battery-level');
|
|
if (batteryLevelElement) {
|
|
const displayBattery = batteryLevelRaw === '' || batteryLevelRaw === '--'
|
|
? '--'
|
|
: `${batteryLevelRaw}%`;
|
|
batteryLevelElement.textContent = displayBattery;
|
|
}
|
|
|
|
const batteryBar = document.getElementById('battery-bar');
|
|
if (batteryBar && batteryLevelRaw !== '' && batteryLevelRaw !== '--') {
|
|
const level = Number(batteryLevelRaw);
|
|
if (!Number.isNaN(level)) {
|
|
batteryBar.style.width = `${level}%`;
|
|
if (level > 50) {
|
|
batteryBar.className = 'bg-green-500 h-2 rounded-full transition-all';
|
|
} else if (level > 20) {
|
|
batteryBar.className = 'bg-yellow-500 h-2 rounded-full transition-all';
|
|
} else {
|
|
batteryBar.className = 'bg-red-500 h-2 rounded-full transition-all';
|
|
}
|
|
}
|
|
}
|
|
|
|
if (document.getElementById('power-source')) {
|
|
document.getElementById('power-source').textContent = data.power_source || '--';
|
|
}
|
|
|
|
if (document.getElementById('sd-remaining')) {
|
|
const sdRemainingRaw = data.sd_remaining_mb ?? '--';
|
|
document.getElementById('sd-remaining').textContent = sdRemainingRaw === '--'
|
|
? '--'
|
|
: `${sdRemainingRaw} MB`;
|
|
}
|
|
if (document.getElementById('sd-ratio')) {
|
|
const sdRatioRaw = data.sd_free_ratio ?? '--';
|
|
document.getElementById('sd-ratio').textContent = sdRatioRaw === '--'
|
|
? '--'
|
|
: `${sdRatioRaw}% free`;
|
|
}
|
|
}
|
|
|
|
|
|
// 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) {
|
|
applyDeviceStatusData(result.data);
|
|
|
|
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');
|
|
}
|
|
}
|
|
|
|
// Bootstrap data from server
|
|
const SLM_BOOTSTRAP_DATA = JSON.parse(document.getElementById('slm-bootstrap-data').textContent);
|
|
|
|
// Initialize immediately when script loads (HTMX loads this partial after DOMContentLoaded)
|
|
(async function() {
|
|
console.log('Initializing SLM live view...');
|
|
|
|
const unitId = SLM_BOOTSTRAP_DATA.unit_id;
|
|
const isMeasuring = SLM_BOOTSTRAP_DATA.is_measuring;
|
|
const measurementStartTime = SLM_BOOTSTRAP_DATA.measurement_start_time;
|
|
console.log('Is measuring:', isMeasuring);
|
|
console.log('Measurement start time from backend:', measurementStartTime);
|
|
|
|
// Start auto-refresh
|
|
startAutoRefresh();
|
|
|
|
// Load initial device settings with delays (NL-43 requires 1 second between commands)
|
|
// These are background updates, so we can space them out
|
|
setTimeout(() => getIndexNumber(unitId), 1500);
|
|
setTimeout(() => getDeviceClock(unitId), 3000);
|
|
setTimeout(() => getFrequencyWeighting(unitId), 4500);
|
|
setTimeout(() => getTimeWeighting(unitId), 6000);
|
|
|
|
// Initialize measurement timer if device is currently measuring
|
|
if (isMeasuring && measurementStartTime) {
|
|
// Backend has synced the start time from FTP, so database timestamp is now accurate
|
|
let utcTimestamp = measurementStartTime;
|
|
if (!utcTimestamp.endsWith('Z') && !utcTimestamp.includes('+') && !utcTimestamp.includes('-', 10)) {
|
|
utcTimestamp = utcTimestamp + 'Z';
|
|
}
|
|
|
|
const startTimeMs = new Date(utcTimestamp).getTime();
|
|
localStorage.setItem(TIMER_STORAGE_KEY + unitId, startTimeMs.toString());
|
|
|
|
console.log('✓ Timer initialized from synced database timestamp');
|
|
console.log(' Start time (UTC):', new Date(startTimeMs).toISOString());
|
|
console.log(' Start time (Local):', new Date(startTimeMs).toString());
|
|
|
|
startMeasurementTimer(unitId);
|
|
} else if (isMeasuring && !measurementStartTime) {
|
|
// Fallback: Measurement active but no start time - try FTP
|
|
console.warn('⚠ Measurement active but no start time, fetching from FTP...');
|
|
setTimeout(() => resumeMeasurementTimerIfNeeded(unitId, isMeasuring), 500);
|
|
}
|
|
})();
|
|
|
|
// 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();
|
|
});
|
|
// ========================================
|
|
// 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>
|
|
`;
|
|
}
|
|
|
|
// Generate unique ID for this folder listing
|
|
const listingId = 'ftp-listing-' + Date.now();
|
|
|
|
function escapeForAttribute(str) {
|
|
return String(str).replace(/'/g, "\\'").replace(/"/g, '"');
|
|
}
|
|
|
|
function getFileIcon(file) {
|
|
if (file.is_dir || file.type === 'directory') {
|
|
return '<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')) {
|
|
return '<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)$/)) {
|
|
return '<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>';
|
|
}
|
|
return '<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>';
|
|
}
|
|
|
|
// 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';
|
|
const icon = getFileIcon(file);
|
|
const sizeText = file.size ? formatFileSize(file.size) : '';
|
|
const dateText = file.modified || file.modified_time || '';
|
|
const canPreview = !isDir && (file.name.toLowerCase().match(/\.(csv|txt|log)$/));
|
|
const folderId = 'folder-' + fullPath.replace(/[^a-zA-Z0-9]/g, '-');
|
|
|
|
if (isDir) {
|
|
// Collapsible folder row
|
|
html += `
|
|
<div class="border border-gray-200 dark:border-gray-600 rounded mb-1">
|
|
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors cursor-pointer"
|
|
onclick="toggleFTPFolder('${unitId}', '${escapeForAttribute(fullPath)}', '${folderId}', this)">
|
|
<div class="flex items-center flex-1">
|
|
<svg class="w-4 h-4 mr-2 text-gray-400 transition-transform folder-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
|
</svg>
|
|
${icon}
|
|
<span class="text-gray-900 dark:text-white font-medium">${escapeHtml(file.name)}</span>
|
|
<span class="ml-2 text-xs text-gray-400 folder-status"></span>
|
|
</div>
|
|
<div class="flex items-center gap-3 flex-shrink-0 ml-4">
|
|
<span class="text-xs text-gray-500 hidden sm:inline">${dateText}</span>
|
|
<button onclick="event.stopPropagation(); downloadFTPFolderModal('${unitId}', '${escapeForAttribute(fullPath)}', '${escapeForAttribute(file.name)}', this)"
|
|
class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded transition-colors flex items-center"
|
|
title="Download entire folder as ZIP">
|
|
<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 ZIP</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="${folderId}" class="hidden pl-6 pr-2 pb-2 border-t border-gray-100 dark:border-gray-700">
|
|
<div class="text-sm text-gray-500 py-2">Click to load contents...</div>
|
|
</div>
|
|
</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>
|
|
`;
|
|
}
|
|
});
|
|
|
|
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 = '';
|
|
}
|
|
}
|
|
|
|
// Toggle folder expand/collapse and load contents
|
|
async function toggleFTPFolder(unitId, folderPath, folderId, headerElement) {
|
|
const contentDiv = document.getElementById(folderId);
|
|
const chevron = headerElement.querySelector('.folder-chevron');
|
|
const statusSpan = headerElement.querySelector('.folder-status');
|
|
|
|
if (!contentDiv) return;
|
|
|
|
const isExpanded = !contentDiv.classList.contains('hidden');
|
|
|
|
if (isExpanded) {
|
|
// Collapse
|
|
contentDiv.classList.add('hidden');
|
|
chevron.style.transform = 'rotate(0deg)';
|
|
} else {
|
|
// Expand and load contents if not already loaded
|
|
contentDiv.classList.remove('hidden');
|
|
chevron.style.transform = 'rotate(90deg)';
|
|
|
|
// Check if already loaded
|
|
if (contentDiv.dataset.loaded === 'true') {
|
|
return;
|
|
}
|
|
|
|
// Show loading state
|
|
contentDiv.innerHTML = '<div class="text-sm text-gray-500 py-2 flex items-center"><svg class="w-4 h-4 mr-2 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>Loading...</div>';
|
|
|
|
try {
|
|
const response = await fetch(`/api/slmm/${unitId}/ftp/files?path=${encodeURIComponent(folderPath)}`);
|
|
const result = await response.json();
|
|
|
|
if (result.status !== 'ok') {
|
|
throw new Error(result.detail || 'Failed to list files');
|
|
}
|
|
|
|
const files = result.files || [];
|
|
|
|
if (files.length === 0) {
|
|
contentDiv.innerHTML = '<div class="text-sm text-gray-500 py-2 italic">Empty folder</div>';
|
|
statusSpan.textContent = '(empty)';
|
|
contentDiv.dataset.loaded = 'true';
|
|
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);
|
|
});
|
|
|
|
// Update status with file count
|
|
const dirCount = files.filter(f => f.type === 'directory' || f.is_dir).length;
|
|
const fileCount = files.length - dirCount;
|
|
let statusText = [];
|
|
if (dirCount > 0) statusText.push(`${dirCount} folder${dirCount > 1 ? 's' : ''}`);
|
|
if (fileCount > 0) statusText.push(`${fileCount} file${fileCount > 1 ? 's' : ''}`);
|
|
statusSpan.textContent = `(${statusText.join(', ')})`;
|
|
|
|
function escapeForAttribute(str) {
|
|
return String(str).replace(/'/g, "\\'").replace(/"/g, '"');
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function getFileIcon(file) {
|
|
if (file.is_dir || file.type === 'directory') {
|
|
return '<svg class="w-4 h-4 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="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')) {
|
|
return '<svg class="w-4 h-4 mr-2 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)$/)) {
|
|
return '<svg class="w-4 h-4 mr-2 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>';
|
|
}
|
|
return '<svg class="w-4 h-4 mr-2 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>';
|
|
}
|
|
|
|
let html = '<div class="space-y-1 mt-2">';
|
|
|
|
files.forEach(file => {
|
|
const fullPath = file.path || `${folderPath}/${file.name}`;
|
|
const isDir = file.is_dir || file.type === 'directory';
|
|
const icon = getFileIcon(file);
|
|
const sizeText = file.size ? formatFileSize(file.size) : '';
|
|
const dateText = file.modified || file.modified_time || '';
|
|
const canPreview = !isDir && (file.name.toLowerCase().match(/\.(csv|txt|log)$/));
|
|
const subFolderId = 'folder-' + fullPath.replace(/[^a-zA-Z0-9]/g, '-');
|
|
|
|
if (isDir) {
|
|
// Nested collapsible folder
|
|
html += `
|
|
<div class="border border-gray-200 dark:border-gray-600 rounded">
|
|
<div class="flex items-center justify-between p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors cursor-pointer text-sm"
|
|
onclick="toggleFTPFolder('${unitId}', '${escapeForAttribute(fullPath)}', '${subFolderId}', this)">
|
|
<div class="flex items-center flex-1">
|
|
<svg class="w-3 h-3 mr-2 text-gray-400 transition-transform folder-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
|
</svg>
|
|
${icon}
|
|
<span class="text-gray-900 dark:text-white font-medium">${escapeHtml(file.name)}</span>
|
|
<span class="ml-2 text-xs text-gray-400 folder-status"></span>
|
|
</div>
|
|
<div class="flex items-center gap-2 flex-shrink-0 ml-2">
|
|
<button onclick="event.stopPropagation(); downloadFTPFolderModal('${unitId}', '${escapeForAttribute(fullPath)}', '${escapeForAttribute(file.name)}', this)"
|
|
class="px-2 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded transition-colors flex items-center"
|
|
title="Download entire folder as ZIP">
|
|
<svg class="w-3 h-3" 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>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="${subFolderId}" class="hidden pl-4 pr-2 pb-2 border-t border-gray-100 dark:border-gray-700">
|
|
<div class="text-sm text-gray-500 py-2">Click to load contents...</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
} else {
|
|
html += `
|
|
<div class="flex items-center justify-between p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors text-sm">
|
|
<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-2 flex-shrink-0 ml-2">
|
|
<span class="text-xs text-gray-500 hidden sm:inline">${sizeText}</span>
|
|
${canPreview ? `
|
|
<button onclick="previewFile('${unitId}', '${escapeForAttribute(fullPath)}', '${escapeForAttribute(file.name)}')"
|
|
class="px-2 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded transition-colors"
|
|
title="Preview file">
|
|
<svg class="w-3 h-3" 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-2 py-1 bg-seismo-orange hover:bg-orange-600 text-white text-xs rounded transition-colors"
|
|
title="Download to your computer">
|
|
<svg class="w-3 h-3" 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>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
});
|
|
|
|
html += '</div>';
|
|
contentDiv.innerHTML = html;
|
|
contentDiv.dataset.loaded = 'true';
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load folder contents:', error);
|
|
contentDiv.innerHTML = `<div class="text-sm text-red-500 py-2">Error: ${error.message}</div>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 downloadFTPFolderModal(unitId, folderPath, folderName, btnElement) {
|
|
// Get the button element - either passed as argument or from event
|
|
const downloadBtn = btnElement || event.target;
|
|
const originalText = downloadBtn.innerHTML;
|
|
|
|
try {
|
|
// Show download indicator
|
|
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-folder`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({ remote_path: folderPath })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
throw new Error(errorData.detail || 'Folder download failed');
|
|
}
|
|
|
|
// The response is a ZIP file
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${folderName}.zip`;
|
|
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-blue-600', 'bg-green-600');
|
|
setTimeout(() => {
|
|
downloadBtn.className = originalBtnClass;
|
|
}, 2000);
|
|
|
|
} catch (error) {
|
|
console.error('Failed to download folder:', error);
|
|
alert('Failed to download folder: ' + error.message);
|
|
// Reset button on error
|
|
downloadBtn.innerHTML = originalText;
|
|
downloadBtn.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>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- Unified SLM Settings Modal -->
|
|
{% include 'partials/slm_settings_modal.html' %}
|