Files
terra-view/templates/partials/projects/schedule_oneoff.html
serversdown 7516bbea70 feat: add manual SD card data upload for offline NRLs; rename RecordingSession to MonitoringSession
- Add POST /api/projects/{project_id}/nrl/{location_id}/upload-data endpoint
  accepting a ZIP or multi-file select of .rnd/.rnh files from an SD card.
  Parses .rnh metadata for session start/stop times, serial number, and store
  name. Creates a MonitoringSession (no unit assignment required) and DataFile
  records for each measurement file.

- Add Upload Data button and collapsible upload panel to the NRL detail Data
  Files tab, with inline success/error feedback and automatic file list refresh
  via HTMX after import.

- Rename RecordingSession -> MonitoringSession throughout the codebase
  (models.py, projects.py, project_locations.py, scheduler.py, roster_rename.py,
  main.py, init_projects_db.py, scripts/rename_unit.py). DB table renamed from
  recording_sessions to monitoring_sessions; old indexes dropped and recreated.

- Update all template UI copy from Recording Sessions to Monitoring Sessions
  (nrl_detail, projects/detail, session_list, schedule_oneoff, roster).

- Add backend/migrate_rename_recording_to_monitoring_sessions.py for applying
  the table rename on production databases before deploying this build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 19:54:40 +00:00

205 lines
8.9 KiB
HTML

<!-- One-Off Recording Schedule Editor -->
<!-- Used for single start/stop recordings with a specific date+time range -->
<div id="schedule-oneoff-editor" class="space-y-4">
<div class="mb-4">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">One-Off Recording</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">
Schedule a single monitoring session with a specific start and end time.
Duration can be between 15 minutes and 24 hours.
</p>
</div>
<!-- Info box -->
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<div class="flex gap-3">
<svg class="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
<div class="text-sm text-amber-700 dark:text-amber-300">
<p class="font-medium mb-1">How it works:</p>
<ol class="list-decimal list-inside space-y-1 text-xs">
<li>At the start time, the measurement will <strong>start</strong></li>
<li>At the end time, the measurement will <strong>stop</strong></li>
<li>If enabled, data will be <strong>downloaded</strong> via FTP after stop</li>
<li>The schedule will be <strong>auto-disabled</strong> after completion</li>
</ol>
</div>
</div>
</div>
<!-- Start date/time -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Start Date & Time
</label>
<input type="datetime-local"
name="start_datetime"
id="oneoff_start_datetime"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-seismo-orange focus:border-seismo-orange">
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2">
Must be in the future
</p>
</div>
<!-- End date/time -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
End Date & Time
</label>
<input type="datetime-local"
name="end_datetime"
id="oneoff_end_datetime"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-seismo-orange focus:border-seismo-orange">
</div>
<!-- Duration preview -->
<div id="oneoff-duration-preview" class="hidden bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Duration: </span>
<span id="oneoff-duration-text" class="text-sm text-gray-600 dark:text-gray-400"></span>
</div>
</div>
<p id="oneoff-duration-error" class="hidden text-xs text-red-500 mt-2"></p>
</div>
<!-- Download option -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox"
name="include_download"
id="include_download_oneoff"
class="rounded text-seismo-orange focus:ring-seismo-orange mt-0.5"
checked>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
Download data after recording ends
</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
When enabled, measurement data will be downloaded via FTP after the recording stops.
</p>
</div>
</label>
</div>
<!-- Auto-increment index option -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox"
name="auto_increment_index"
id="auto_increment_index_oneoff"
class="rounded text-seismo-orange focus:ring-seismo-orange mt-0.5"
checked>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
Auto-increment store index before start
</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
When enabled, the store/index number is incremented before starting.
This prevents "overwrite existing data?" prompts on the device.
</p>
</div>
</label>
</div>
</div>
<script>
// Set min datetime to now (prevent past selections)
function setMinDatetime() {
const now = new Date();
now.setMinutes(Math.ceil(now.getMinutes() / 15) * 15);
now.setSeconds(0);
now.setMilliseconds(0);
const minStr = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}T${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
document.getElementById('oneoff_start_datetime').min = minStr;
document.getElementById('oneoff_end_datetime').min = minStr;
}
setMinDatetime();
// Update duration preview when dates change
function updateDurationPreview() {
const startInput = document.getElementById('oneoff_start_datetime');
const endInput = document.getElementById('oneoff_end_datetime');
const preview = document.getElementById('oneoff-duration-preview');
const durationText = document.getElementById('oneoff-duration-text');
const errorText = document.getElementById('oneoff-duration-error');
if (!startInput.value || !endInput.value) {
preview.classList.add('hidden');
return;
}
const start = new Date(startInput.value);
const end = new Date(endInput.value);
const diffMs = end - start;
const diffMinutes = diffMs / (1000 * 60);
preview.classList.remove('hidden');
errorText.classList.add('hidden');
if (diffMinutes <= 0) {
durationText.textContent = 'Invalid';
errorText.textContent = 'End time must be after start time.';
errorText.classList.remove('hidden');
return;
}
if (diffMinutes < 15) {
const mins = Math.round(diffMinutes);
durationText.textContent = `${mins} minute${mins !== 1 ? 's' : ''}`;
errorText.textContent = 'Minimum duration is 15 minutes.';
errorText.classList.remove('hidden');
return;
}
if (diffMinutes > 1440) {
const hours = Math.round(diffMinutes / 60 * 10) / 10;
durationText.textContent = `${hours} hours`;
errorText.textContent = 'Maximum duration is 24 hours.';
errorText.classList.remove('hidden');
return;
}
// Valid duration
if (diffMinutes < 60) {
durationText.textContent = `${Math.round(diffMinutes)} minutes`;
} else {
const hours = Math.floor(diffMinutes / 60);
const mins = Math.round(diffMinutes % 60);
durationText.textContent = mins > 0
? `${hours} hour${hours !== 1 ? 's' : ''} ${mins} min`
: `${hours} hour${hours !== 1 ? 's' : ''}`;
}
}
document.getElementById('oneoff_start_datetime').addEventListener('change', function() {
// Auto-set end to start + 1 hour if end is empty
const endInput = document.getElementById('oneoff_end_datetime');
if (!endInput.value) {
const start = new Date(this.value);
start.setHours(start.getHours() + 1);
endInput.value = start.toISOString().slice(0, 16);
}
// Update min of end input
endInput.min = this.value;
updateDurationPreview();
});
document.getElementById('oneoff_end_datetime').addEventListener('change', updateDurationPreview);
// Function to get one-off data as object (called by parent form)
function getOneOffData() {
return {
start_datetime: document.getElementById('oneoff_start_datetime').value,
end_datetime: document.getElementById('oneoff_end_datetime').value,
include_download: document.getElementById('include_download_oneoff').checked,
auto_increment_index: document.getElementById('auto_increment_index_oneoff').checked,
};
}
</script>