feat: Manual sound data uploads, standalone SLM type added.(no modem mode), Smart uploading with fuzzy name matching enabled.

This commit is contained in:
2026-02-25 00:43:47 +00:00
parent 8e292b1aca
commit 291fa8e862
6 changed files with 685 additions and 6 deletions

View File

@@ -70,7 +70,7 @@
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
Settings
</button>
{% if assigned_unit %}
{% if assigned_unit and connection_mode == 'connected' %}
<button onclick="switchTab('command')"
data-tab="command"
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
@@ -214,23 +214,54 @@
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between">
<div>
{% if connection_mode == 'connected' %}
<p class="text-sm text-gray-600 dark:text-gray-400">Active Session</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-2">
{% if active_session %}
<span class="text-green-600 dark:text-green-400">Recording</span>
<span class="text-green-600 dark:text-green-400">Monitoring</span>
{% else %}
<span class="text-gray-500">Idle</span>
{% endif %}
</p>
{% else %}
<p class="text-sm text-gray-600 dark:text-gray-400">Mode</p>
<p class="text-lg font-semibold mt-2">
<span class="text-amber-600 dark:text-amber-400">Offline / Manual</span>
</p>
{% endif %}
</div>
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
{% if connection_mode == 'connected' %}
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" 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>
{% else %}
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" 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-8l-4-4m0 0L8 8m4-4v12"></path>
</svg>
{% endif %}
</div>
</div>
</div>
</div>
{% if connection_mode == 'connected' and assigned_unit %}
<!-- Live Status Row (connected NRLs only) -->
<div class="mt-6">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Live Status</h3>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ assigned_unit.id }}</span>
</div>
<div id="nrl-live-status"
hx-get="/api/projects/{{ project_id }}/nrl/{{ location_id }}/live-status"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<div class="text-center py-4 text-gray-500 text-sm">Loading status&hellip;</div>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Settings Tab -->
@@ -281,8 +312,8 @@
</div>
</div>
<!-- Command Center Tab -->
{% if assigned_unit %}
<!-- Command Center Tab (connected NRLs only) -->
{% if assigned_unit and connection_mode == 'connected' %}
<div id="command-tab" class="tab-panel hidden">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">

View File

@@ -0,0 +1,89 @@
<!-- Live Status Card content for connected NRLs -->
{% if error and not status %}
<div class="flex items-center gap-3 text-gray-500 dark:text-gray-400">
<svg class="w-5 h-5 text-amber-500 flex-shrink-0" 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>
<span class="text-sm">{{ error }}</span>
</div>
{% elif status %}
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
<!-- Measurement State -->
<div class="flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 mb-1">State</span>
{% set state = status.get('measurement_state', 'unknown') if status is mapping else 'unknown' %}
{% if state in ('measuring', 'recording') %}
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-green-600 dark:text-green-400">
<span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
Measuring
</span>
{% elif state == 'paused' %}
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-yellow-600 dark:text-yellow-400">
<span class="w-2 h-2 bg-yellow-500 rounded-full"></span>
Paused
</span>
{% elif state == 'stopped' %}
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-gray-600 dark:text-gray-400">
<span class="w-2 h-2 bg-gray-400 rounded-full"></span>
Stopped
</span>
{% else %}
<span class="text-sm text-gray-500 dark:text-gray-400 capitalize">{{ state }}</span>
{% endif %}
</div>
<!-- Lp (instantaneous) -->
<div class="flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 mb-1">Lp (dB)</span>
{% set lp = status.get('lp') if status is mapping else None %}
<span class="text-xl font-bold text-gray-900 dark:text-white">
{% if lp is not none %}{{ "%.1f"|format(lp) }}{% else %}&mdash;{% endif %}
</span>
</div>
<!-- Battery -->
<div class="flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 mb-1">Battery</span>
{% set batt = status.get('battery_level') if status is mapping else None %}
{% if batt is not none %}
<span class="text-sm font-semibold
{% if batt >= 60 %}text-green-600 dark:text-green-400
{% elif batt >= 30 %}text-yellow-600 dark:text-yellow-400
{% else %}text-red-600 dark:text-red-400{% endif %}">
{{ batt }}%
</span>
{% else %}
<span class="text-sm text-gray-500">&mdash;</span>
{% endif %}
</div>
<!-- Last Seen -->
<div class="flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 mb-1">Last Seen</span>
{% set last_seen = status.get('last_seen') if status is mapping else None %}
{% if last_seen %}
<span class="text-sm text-gray-700 dark:text-gray-300">{{ last_seen|local_datetime }}</span>
{% else %}
<span class="text-sm text-gray-500">&mdash;</span>
{% endif %}
</div>
</div>
{% if unit %}
<div class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700 flex items-center justify-between">
<span class="text-xs text-gray-400 dark:text-gray-500">
Unit: {{ unit.id }}
{% if unit.slm_model %} &bull; {{ unit.slm_model }}{% endif %}
</span>
<a href="/slm/{{ unit.id }}"
class="text-xs text-seismo-orange hover:text-seismo-navy transition-colors">
Open Unit &rarr;
</a>
</div>
{% endif %}
{% else %}
<div class="text-sm text-gray-500 dark:text-gray-400">No status data available.</div>
{% endif %}

View File

@@ -230,6 +230,13 @@
Project Files
</h2>
<div class="flex items-center gap-3">
<button onclick="toggleUploadAll()"
class="px-3 py-2 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1.5">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
</svg>
Upload All
</button>
<button onclick="htmx.trigger('#unified-files', 'refresh')"
class="px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -241,6 +248,37 @@
</div>
</div>
<!-- Upload All Panel -->
<div id="upload-all-panel" class="hidden border-b border-gray-200 dark:border-gray-700">
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-800/50">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Bulk Import — Select Folder</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Select your data folder directly — no zipping needed. Expected structure:
<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">[date]/[NRL name]/[Auto_####]/</code>.
NRL folders are matched to locations by name. MP3s are stored; Excel exports are skipped.
</p>
<div class="flex flex-wrap items-center gap-3">
<input type="file" id="upload-all-input"
webkitdirectory directory multiple
class="block text-sm text-gray-500 dark:text-gray-400
file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
hover:file:bg-seismo-navy file:cursor-pointer" />
<button onclick="submitUploadAll()"
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
Import
</button>
<button onclick="toggleUploadAll()"
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
Cancel
</button>
<span id="upload-all-status" class="text-sm hidden"></span>
</div>
<!-- Result summary -->
<div id="upload-all-results" class="hidden mt-3 text-sm space-y-1"></div>
</div>
</div>
<div id="unified-files"
hx-get="/api/projects/{{ project_id }}/files-unified"
hx-trigger="load, refresh from:#unified-files"
@@ -402,7 +440,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Type</label>
<select name="location_type" id="location-type"
<select name="location_type" id="location-type" onchange="updateConnectionModeVisibility()"
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">
<option value="sound">Sound</option>
<option value="vibration">Vibration</option>
@@ -415,6 +453,29 @@
</div>
</div>
<!-- Connection Mode — sound locations only -->
<div id="connection-mode-field">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Connection Mode</label>
<div class="grid grid-cols-2 gap-3">
<label class="flex items-start gap-3 p-3 border-2 border-seismo-orange rounded-lg cursor-pointer bg-orange-50 dark:bg-orange-900/10" id="mode-connected-label">
<input type="radio" name="connection_mode" value="connected" checked
class="mt-0.5 text-seismo-orange" onchange="updateModeLabels()">
<div>
<div class="font-medium text-gray-900 dark:text-white text-sm">Connected</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Remote unit accessible via modem. Supports live control and FTP download.</div>
</div>
</label>
<label class="flex items-start gap-3 p-3 border-2 border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer" id="mode-offline-label">
<input type="radio" name="connection_mode" value="offline"
class="mt-0.5 text-seismo-orange" onchange="updateModeLabels()">
<div>
<div class="font-medium text-gray-900 dark:text-white text-sm">Offline / Manual Upload</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">No network access. Data collected from SD card and uploaded manually.</div>
</div>
</label>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
<input type="text" name="address" id="location-address"
@@ -794,6 +855,33 @@ function refreshProjectDashboard() {
}
// Location modal functions
function updateConnectionModeVisibility() {
const locType = document.getElementById('location-type').value;
const field = document.getElementById('connection-mode-field');
if (field) field.classList.toggle('hidden', locType !== 'sound');
}
function updateModeLabels() {
const connected = document.querySelector('input[name="connection_mode"][value="connected"]');
const offline = document.querySelector('input[name="connection_mode"][value="offline"]');
const connLabel = document.getElementById('mode-connected-label');
const offLabel = document.getElementById('mode-offline-label');
if (!connected || !connLabel || !offLabel) return;
const activeClasses = ['border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/10'];
const inactiveClasses = ['border-gray-300', 'dark:border-gray-600'];
if (connected.checked) {
connLabel.classList.add(...activeClasses);
connLabel.classList.remove(...inactiveClasses);
offLabel.classList.remove(...activeClasses);
offLabel.classList.add(...inactiveClasses);
} else {
offLabel.classList.add(...activeClasses);
offLabel.classList.remove(...inactiveClasses);
connLabel.classList.remove(...activeClasses);
connLabel.classList.add(...inactiveClasses);
}
}
function openLocationModal(defaultType) {
editingLocationId = null;
document.getElementById('location-modal-title').textContent = 'Add Location';
@@ -802,6 +890,9 @@ function openLocationModal(defaultType) {
document.getElementById('location-description').value = '';
document.getElementById('location-address').value = '';
document.getElementById('location-coordinates').value = '';
// Reset connection mode to connected
const connectedRadio = document.querySelector('input[name="connection_mode"][value="connected"]');
if (connectedRadio) { connectedRadio.checked = true; updateModeLabels(); }
const locationTypeSelect = document.getElementById('location-type');
const locationTypeWrapper = locationTypeSelect.closest('div');
if (projectTypeId === 'sound_monitoring') {
@@ -817,6 +908,7 @@ function openLocationModal(defaultType) {
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
locationTypeSelect.value = defaultType || 'sound';
}
updateConnectionModeVisibility();
document.getElementById('location-error').classList.add('hidden');
document.getElementById('location-modal').classList.remove('hidden');
}
@@ -830,6 +922,11 @@ function openEditLocationModal(button) {
document.getElementById('location-description').value = data.description || '';
document.getElementById('location-address').value = data.address || '';
document.getElementById('location-coordinates').value = data.coordinates || '';
// Restore connection mode from metadata
let savedMode = 'connected';
try { savedMode = JSON.parse(data.location_metadata || '{}').connection_mode || 'connected'; } catch(e) {}
const modeRadio = document.querySelector(`input[name="connection_mode"][value="${savedMode}"]`);
if (modeRadio) { modeRadio.checked = true; updateModeLabels(); }
const locationTypeSelect = document.getElementById('location-type');
const locationTypeWrapper = locationTypeSelect.closest('div');
if (projectTypeId === 'sound_monitoring') {
@@ -845,6 +942,7 @@ function openEditLocationModal(button) {
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
locationTypeSelect.value = data.location_type || 'sound';
}
updateConnectionModeVisibility();
document.getElementById('location-error').classList.add('hidden');
document.getElementById('location-modal').classList.remove('hidden');
}
@@ -867,6 +965,8 @@ document.getElementById('location-form').addEventListener('submit', async functi
locationType = 'vibration';
}
const connectionMode = document.querySelector('input[name="connection_mode"]:checked')?.value || 'connected';
try {
if (editingLocationId) {
const payload = {
@@ -874,7 +974,8 @@ document.getElementById('location-form').addEventListener('submit', async functi
description: description || null,
address: address || null,
coordinates: coordinates || null,
location_type: locationType
location_type: locationType,
location_metadata: JSON.stringify({ connection_mode: connectionMode }),
};
const response = await fetch(`/api/projects/${projectId}/locations/${editingLocationId}`, {
method: 'PUT',
@@ -892,6 +993,7 @@ document.getElementById('location-form').addEventListener('submit', async functi
formData.append('address', address);
formData.append('coordinates', coordinates);
formData.append('location_type', locationType);
formData.append('location_metadata', JSON.stringify({ connection_mode: connectionMode }));
const response = await fetch(`/api/projects/${projectId}/locations/create`, {
method: 'POST',
body: formData
@@ -1473,6 +1575,88 @@ document.getElementById('schedule-modal')?.addEventListener('click', function(e)
}
});
// ── Upload All ───────────────────────────────────────────────────────────────
function toggleUploadAll() {
const panel = document.getElementById('upload-all-panel');
panel.classList.toggle('hidden');
if (!panel.classList.contains('hidden')) {
document.getElementById('upload-all-status').textContent = '';
document.getElementById('upload-all-status').className = 'text-sm hidden';
document.getElementById('upload-all-results').classList.add('hidden');
document.getElementById('upload-all-results').innerHTML = '';
document.getElementById('upload-all-input').value = '';
}
}
async function submitUploadAll() {
const input = document.getElementById('upload-all-input');
const status = document.getElementById('upload-all-status');
const resultsEl = document.getElementById('upload-all-results');
if (!input.files.length) {
alert('Please select a folder to upload.');
return;
}
const formData = new FormData();
for (const f of input.files) {
// webkitRelativePath gives the path relative to the selected folder root
formData.append('files', f);
formData.append('paths', f.webkitRelativePath || f.name);
}
status.textContent = `Uploading ${input.files.length} files\u2026`;
status.className = 'text-sm text-gray-500';
resultsEl.classList.add('hidden');
try {
const response = await fetch(
`/api/projects/{{ project_id }}/upload-all`,
{ method: 'POST', body: formData }
);
const data = await response.json();
if (response.ok) {
const s = data.sessions_created;
const f = data.files_imported;
status.textContent = `\u2713 Imported ${f} file${f !== 1 ? 's' : ''} across ${s} session${s !== 1 ? 's' : ''}`;
status.className = 'text-sm text-green-600 dark:text-green-400';
input.value = '';
// Build results summary
let html = '';
if (data.sessions && data.sessions.length) {
html += '<div class="font-medium text-gray-700 dark:text-gray-300 mb-1">Sessions created:</div>';
html += '<ul class="space-y-0.5 ml-2">';
for (const sess of data.sessions) {
html += `<li class="text-xs text-gray-600 dark:text-gray-400">\u2022 <span class="font-medium">${sess.location_name}</span> &mdash; ${sess.files} files`;
if (sess.leq_files || sess.lp_files) html += ` (${sess.leq_files} Leq, ${sess.lp_files} Lp)`;
if (sess.store_name) html += ` &mdash; ${sess.store_name}`;
html += '</li>';
}
html += '</ul>';
}
if (data.unmatched_folders && data.unmatched_folders.length) {
html += `<div class="mt-2 text-xs text-amber-600 dark:text-amber-400">\u26a0 Unmatched folders (no NRL location found): ${data.unmatched_folders.join(', ')}</div>`;
}
if (html) {
resultsEl.innerHTML = html;
resultsEl.classList.remove('hidden');
}
// Refresh the unified files view
htmx.trigger(document.getElementById('unified-files'), 'refresh');
} else {
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
status.className = 'text-sm text-red-600 dark:text-red-400';
}
} catch (err) {
status.textContent = `Error: ${err.message}`;
status.className = 'text-sm text-red-600 dark:text-red-400';
}
}
// Load project details on page load and restore active tab from URL hash
document.addEventListener('DOMContentLoaded', function() {
loadProjectDetails();