From ee025f1f34bcab34d1ebac57e405ad53671f9196 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Fri, 9 Jan 2026 19:41:16 +0000 Subject: [PATCH 1/3] old leftovers from pre merge --- backend/main.py | 4 +- docker-compose.yml | 19 +- templates/partials/slm_live_view.html | 931 +++++++++++++++++++++++++- 3 files changed, 926 insertions(+), 28 deletions(-) diff --git a/backend/main.py b/backend/main.py index 6442ab3..b471ec2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -524,4 +524,6 @@ def health_check(): if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8001) + import os + port = int(os.getenv("PORT", 8001)) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/docker-compose.yml b/docker-compose.yml index ddb9e1d..1c94b56 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,14 +4,14 @@ services: terra-view-prod: build: . container_name: terra-view - ports: - - "8001:8001" + network_mode: host volumes: - ./data:/app/data environment: - PYTHONUNBUFFERED=1 - ENVIRONMENT=production - - SLMM_BASE_URL=http://slmm:8100 + - PORT=8001 + - SLMM_BASE_URL=http://localhost:8100 restart: unless-stopped depends_on: - slmm @@ -26,19 +26,21 @@ services: terra-view-dev: build: . container_name: terra-view-dev - ports: - - "1001:8001" + network_mode: host volumes: - ./data-dev:/app/data environment: - PYTHONUNBUFFERED=1 - ENVIRONMENT=development - - SLMM_BASE_URL=http://slmm:8100 + - PORT=1001 + - SLMM_BASE_URL=http://localhost:8100 restart: unless-stopped depends_on: - slmm + profiles: + - dev healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8001/health"] + test: ["CMD", "curl", "-f", "http://localhost:1001/health"] interval: 30s timeout: 10s retries: 3 @@ -50,8 +52,7 @@ services: context: ../../slmm dockerfile: Dockerfile container_name: slmm - ports: - - "8100:8100" + network_mode: host volumes: - ../../slmm/data:/app/data environment: diff --git a/templates/partials/slm_live_view.html b/templates/partials/slm_live_view.html index c5ee656..54e2560 100644 --- a/templates/partials/slm_live_view.html +++ b/templates/partials/slm_live_view.html @@ -23,18 +23,31 @@ {% endif %} - -
- {% if is_measuring %} - - - Measuring - - {% else %} - - Stopped - - {% endif %} + +
+
+ +
+
+ + + + 00:00:00 +
+
+ + + {% if is_measuring %} + + + Measuring + + {% else %} + + Stopped + + {% endif %} +
@@ -209,6 +222,171 @@ + + +
+

Device Settings

+ + +
+
+
+ + + + Device Clock +
+ +
+ +
+ Set the device clock manually or sync to your computer's current time. Device must be stopped to change clock. +
+ +
+ + +
+ + +
+ + +
+
+
+ + + + Store Name (Index) +
+
+ ---- + +
+
+ +
+ Set the file index number (0000-9999) for the next measurement storage. Requirements: Device must be stopped and FTP must be disabled to change this setting. +
+ + + +
+ + + +
+ + +
+
+ + +
+
+

Measurement Data Files

+
+ + + +
+
+ + + + + +
+ +
+ + + + / +
+ + +
+
+ + + +

Click "Refresh" to load files from the device

+
+
+ + + + + + +
+
@@ -236,9 +414,14 @@ function initializeChart() { if (window.liveChart && typeof window.liveChart.destroy === 'function') { console.log('Destroying existing chart'); window.liveChart.destroy(); + window.liveChart = null; } const ctx = canvas.getContext('2d'); + if (!ctx) { + console.error('Failed to get 2D context from canvas'); + return; + } console.log('Creating new chart...'); // Dark mode detection @@ -246,7 +429,8 @@ function initializeChart() { 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, { + try { + window.liveChart = new Chart(ctx, { type: 'line', data: { labels: [], @@ -317,12 +501,33 @@ function initializeChart() { } }); - console.log('Chart created successfully:', window.liveChart); + console.log('Chart created successfully:', window.liveChart); + } catch (error) { + console.error('Failed to create chart:', error); + // Display error message in the chart container + const container = canvas.parentElement; + if (container) { + container.innerHTML = ` +
+
+ + + +

Chart failed to load

+

Try refreshing the page

+
+
+ `; + } + } } // Initialize chart when DOM is ready console.log('Executing initializeChart...'); -initializeChart(); +// Use a small delay to ensure DOM is fully ready +setTimeout(() => { + initializeChart(); +}, 50); // WebSocket management (use global scope to avoid redeclaration) if (typeof window.currentWebSocket === 'undefined') { @@ -455,13 +660,19 @@ async function controlUnit(unitId, action) { const result = await response.json(); if (result.status === 'ok') { - // Reload the live view to update status + // Give device more time to change state before checking + // Start/Stop commands need time to propagate + setTimeout(() => { + checkMeasurementState(); + }, 1500); + + // Reload the live view to update all status displays setTimeout(() => { htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, { target: '#live-view-panel', swap: 'innerHTML' }); - }, 500); + }, 2000); } else { alert(`Error: ${result.detail || 'Unknown error'}`); } @@ -472,7 +683,9 @@ async function controlUnit(unitId, action) { // Auto-refresh status every 30 seconds -let refreshInterval; +if (typeof window.refreshInterval === 'undefined') { + window.refreshInterval = null; +} const REFRESH_INTERVAL_MS = 30000; // 30 seconds const unit_id = '{{ unit.id }}'; @@ -556,6 +769,182 @@ function stopAutoRefresh() { } // Start auto-refresh when page loads + + +// Backend-Synced Measurement Timer +// Uses measurement_start_time from backend (tracked via state transitions) +// This works even for manually-started measurements! +if (typeof window.timerInterval === 'undefined') { + window.timerInterval = null; + window.measurementStartTime = null; // ISO string from backend +} + +// Format elapsed time as HH:MM:SS +function formatElapsedTime(milliseconds) { + const totalSeconds = Math.floor(milliseconds / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + return hours.toString().padStart(2, '0') + ':' + minutes.toString().padStart(2, '0') + ':' + seconds.toString().padStart(2, '0'); +} + +// Update timer display based on backend measurement_start_time +function updateTimerDisplay() { + const timerElement = document.getElementById('timer-display'); + if (!timerElement) { + console.error('Timer display element not found!'); + return; + } + + if (!window.measurementStartTime) { + timerElement.textContent = '00:00:00'; + return; + } + + // Calculate elapsed time from backend-provided start time (UTC) + // Ensure the timestamp is treated as UTC by adding 'Z' if not present + let timestamp = window.measurementStartTime; + if (!timestamp.endsWith('Z') && !timestamp.includes('+')) { + timestamp = timestamp + 'Z'; + } + const startTime = new Date(timestamp); + const elapsed = Date.now() - startTime.getTime(); + const formattedTime = formatElapsedTime(elapsed); + timerElement.textContent = formattedTime; +} + +// Sync timer with backend data (with FTP fallback) +async function syncTimerWithBackend(measurementState, measurementStartTime) { + // Device returns "Start" when measuring, "Stop" when stopped + const isMeasuring = measurementState === 'Start'; + + if (isMeasuring && measurementStartTime) { + // Measurement is running - check both backend and FTP timestamps + // Use whichever is earlier (older = actual measurement start) + + // First check FTP for potentially older timestamp + try { + const response = await fetch(`/api/slmm/${unit_id}/ftp/latest-measurement-time`); + const result = await response.json(); + + if (result.status === 'ok' && result.latest_timestamp) { + const backendTime = new Date(measurementStartTime + 'Z'); + const ftpTime = new Date(result.latest_timestamp + 'Z'); + + // Use the earlier timestamp (represents actual measurement start) + if (ftpTime < backendTime) { + window.measurementStartTime = result.latest_timestamp; + window.timerSource = 'ftp'; + console.log('Timer synced with FTP folder (earlier):', result.latest_folder, '@', result.latest_timestamp); + } else { + window.measurementStartTime = measurementStartTime; + window.timerSource = 'backend'; + console.log('Timer synced with backend state (earlier):', measurementStartTime); + } + } else { + // No FTP timestamp, use backend + window.measurementStartTime = measurementStartTime; + window.timerSource = 'backend'; + console.log('Timer synced with backend state:', measurementStartTime); + } + } catch (error) { + console.error('Failed to check FTP timestamp:', error); + // Fallback to backend on error + window.measurementStartTime = measurementStartTime; + window.timerSource = 'backend'; + console.log('Timer synced with backend state (FTP check failed):', measurementStartTime); + } + + // Start interval if not already running + if (!window.timerInterval) { + window.timerInterval = setInterval(updateTimerDisplay, 1000); + console.log('Timer display started'); + } + + // Update display immediately + updateTimerDisplay(); + } else if (isMeasuring && !measurementStartTime) { + // Device is measuring but backend doesn't have start time yet + // Try FTP fallback to get measurement start from latest folder timestamp + if (!window.measurementStartTime || window.timerSource !== 'ftp') { + console.log('Device measuring but no backend start time - checking FTP fallback...'); + try { + const response = await fetch(`/api/slmm/${unit_id}/ftp/latest-measurement-time`); + const result = await response.json(); + + if (result.status === 'ok' && result.latest_timestamp) { + window.measurementStartTime = result.latest_timestamp; + window.timerSource = 'ftp'; + console.log('Timer synced with FTP folder:', result.latest_folder, '@', result.latest_timestamp); + + // Start timer interval if not already running + if (!window.timerInterval) { + window.timerInterval = setInterval(updateTimerDisplay, 1000); + console.log('Timer display started (FTP source)'); + } + + updateTimerDisplay(); + } else { + console.log('No FTP timestamp available'); + } + } catch (error) { + console.error('Failed to get FTP timestamp:', error); + } + } + } else { + // Not measuring - stop timer + if (window.timerInterval) { + clearInterval(window.timerInterval); + window.timerInterval = null; + console.log('Timer display stopped'); + } + window.measurementStartTime = null; + window.timerSource = null; + document.getElementById('timer-display').textContent = '00:00:00'; + } +} + +// Check measurement state and sync timer +async function checkMeasurementState() { + try { + const response = await fetch(`/api/slmm/${unit_id}/live`); + const result = await response.json(); + + if (result.status === 'ok' && result.data) { + await syncTimerWithBackend( + result.data.measurement_state, + result.data.measurement_start_time + ); + } + } catch (error) { + console.error('Failed to check measurement state:', error); + } +} + +// Initialize timer immediately (this partial is loaded via HTMX, so DOMContentLoaded may have already fired) +if (typeof window.timerInitialized === 'undefined') { + window.timerInitialized = true; + + // Execute initialization immediately + (async function() { + console.log('Timer initialization: Starting immediately'); + + // Check measurement state immediately + await checkMeasurementState(); + + // Load store name (index number) + await loadStoreName(); + + // Check FTP status and update buttons + await checkFTPStatus(); + + // Check measurement state periodically (every 5 seconds) + setInterval(checkMeasurementState, 5000); + + console.log('Timer initialization: Complete, polling every 5 seconds'); + })(); +} document.addEventListener('DOMContentLoaded', startAutoRefresh); // Cleanup on page unload @@ -564,4 +953,510 @@ window.addEventListener('beforeunload', function() { window.currentWebSocket.close(); } }); + + +// File Browser Functions +if (typeof window.currentPath === 'undefined') { + window.currentPath = '/'; +} + +// Format file size +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]; +} + +// Format date +function formatDate(dateStr) { + try { + const date = new Date(dateStr); + return date.toLocaleString(); + } catch (e) { + return dateStr; + } +} + +// Enable FTP on device +async function enableFTP() { + try { + const response = await fetch(`/api/slmm/${unit_id}/ftp/enable`, { + method: 'POST' + }); + const result = await response.json(); + + if (result.status === 'ok') { + document.getElementById('ftp-status').classList.add('hidden'); + updateFTPButtons(true); + refreshFileList(); + } else { + alert('Failed to enable FTP: ' + (result.detail || 'Unknown error')); + } + } catch (error) { + alert('Failed to enable FTP: ' + error.message); + } +} + +async function disableFTP() { + try { + const response = await fetch(`/api/slmm/${unit_id}/ftp/disable`, { + method: 'POST' + }); + const result = await response.json(); + + if (result.status === 'ok') { + updateFTPButtons(false); + alert('FTP disabled. TCP commands are now available.'); + } else { + alert('Failed to disable FTP: ' + (result.detail || 'Unknown error')); + } + } catch (error) { + alert('Failed to disable FTP: ' + error.message); + } +} + +function updateFTPButtons(ftpEnabled) { + const enableBtn = document.getElementById('enable-ftp-btn'); + const disableBtn = document.getElementById('disable-ftp-btn'); + + console.log('updateFTPButtons called with ftpEnabled:', ftpEnabled); + console.log('Enable button element:', enableBtn); + console.log('Disable button element:', disableBtn); + + if (enableBtn && disableBtn) { + if (ftpEnabled) { + enableBtn.classList.add('hidden'); + disableBtn.classList.remove('hidden'); + console.log('FTP is enabled - showing Disable FTP button'); + } else { + enableBtn.classList.remove('hidden'); + disableBtn.classList.add('hidden'); + console.log('FTP is disabled - showing Enable FTP button'); + } + } else { + console.error('FTP button elements not found in DOM'); + } +} + +async function checkFTPStatus() { + console.log('checkFTPStatus: Checking FTP status for unit_id:', unit_id); + try { + const response = await fetch(`/api/slmm/${unit_id}/ftp/status`); + const result = await response.json(); + + console.log('checkFTPStatus: Response:', result); + + if (result.status === 'ok') { + console.log('checkFTPStatus: FTP enabled status:', result.ftp_enabled); + updateFTPButtons(result.ftp_enabled); + } else { + console.error('checkFTPStatus: Unexpected response status:', result.status); + } + } catch (error) { + console.error('checkFTPStatus: Failed to check FTP status:', error); + } +} + +// Clock Management Functions +async function syncClockToNow() { + const now = new Date(); + // Format as YYYY-MM-DDTHH:mm for datetime-local input + const localDateTime = new Date(now.getTime() - (now.getTimezoneOffset() * 60000)).toISOString().slice(0, 16); + + // Update the input field + document.getElementById('clock-input').value = localDateTime; + + // Auto-submit + await setDeviceClock(); +} + +async function setDeviceClock() { + const clockInput = document.getElementById('clock-input'); + const statusDiv = document.getElementById('clock-status'); + + if (!clockInput.value) { + showClockStatus('Please select a date and time', 'error'); + return; + } + + // Convert datetime-local to NL43 format: YYYY/MM/DD,HH:MM:SS + const dateTime = new Date(clockInput.value); + const nl43Format = dateTime.getFullYear() + '/' + + String(dateTime.getMonth() + 1).padStart(2, '0') + '/' + + String(dateTime.getDate()).padStart(2, '0') + ',' + + String(dateTime.getHours()).padStart(2, '0') + ':' + + String(dateTime.getMinutes()).padStart(2, '0') + ':' + + String(dateTime.getSeconds()).padStart(2, '0'); + + showClockStatus('Setting device clock...', 'info'); + + try { + const response = await fetch(`/api/slmm/${unit_id}/clock`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ datetime: nl43Format }) + }); + + const result = await response.json(); + + if (response.ok && result.status === 'ok') { + showClockStatus('✓ Clock set successfully to ' + dateTime.toLocaleString(), 'success'); + } else { + showClockStatus('✗ Failed: ' + (result.detail || 'Unknown error'), 'error'); + } + } catch (error) { + showClockStatus('✗ Failed: ' + error.message, 'error'); + } +} + +function showClockStatus(message, type) { + const statusDiv = document.getElementById('clock-status'); + statusDiv.textContent = message; + statusDiv.classList.remove('hidden', 'text-green-600', 'text-red-600', 'text-blue-600'); + + if (type === 'success') { + statusDiv.classList.add('text-green-600', 'dark:text-green-400'); + } else if (type === 'error') { + statusDiv.classList.add('text-red-600', 'dark:text-red-400'); + } else { + statusDiv.classList.add('text-blue-600', 'dark:text-blue-400'); + } +} + +// Store Name (Index) Management Functions +async function loadStoreName() { + try { + const response = await fetch(`/api/slmm/${unit_id}/index-number`); + const result = await response.json(); + + if (response.ok && result.status === 'ok') { + const indexNum = result.index_number; + document.getElementById('store-name-display').textContent = indexNum; + document.getElementById('store-name-input').value = parseInt(indexNum); + + // Check overwrite status after loading + await checkOverwriteStatus(); + } else { + console.error('Failed to load store name:', result); + } + } catch (error) { + console.error('Error loading store name:', error); + } +} + +async function setStoreName() { + const input = document.getElementById('store-name-input'); + const statusDiv = document.getElementById('store-name-status'); + + const index = parseInt(input.value); + + if (isNaN(index) || index < 0 || index > 9999) { + showStoreNameStatus('Please enter a number between 0 and 9999', 'error'); + return; + } + + showStoreNameStatus('Setting store name...', 'info'); + + try { + const response = await fetch(`/api/slmm/${unit_id}/index-number`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ index: index }) + }); + + const result = await response.json(); + + if (response.ok && result.status === 'ok') { + showStoreNameStatus('✓ Store name set successfully to ' + index.toString().padStart(4, '0'), 'success'); + // Update display + document.getElementById('store-name-display').textContent = index.toString().padStart(4, '0'); + // Check overwrite status for new index + await checkOverwriteStatus(); + } else { + showStoreNameStatus('✗ Failed: ' + (result.detail || 'Unknown error'), 'error'); + } + } catch (error) { + showStoreNameStatus('✗ Failed: ' + error.message, 'error'); + } +} + +function showStoreNameStatus(message, type) { + const statusDiv = document.getElementById('store-name-status'); + statusDiv.textContent = message; + statusDiv.classList.remove('hidden', 'text-green-600', 'text-red-600', 'text-blue-600'); + + if (type === 'success') { + statusDiv.classList.add('text-green-600', 'dark:text-green-400'); + } else if (type === 'error') { + statusDiv.classList.add('text-red-600', 'dark:text-red-400'); + } else { + statusDiv.classList.add('text-blue-600', 'dark:text-blue-400'); + } +} + +// Check Overwrite Status +async function checkOverwriteStatus() { + try { + const response = await fetch(`/api/slmm/${unit_id}/overwrite-check`); + const result = await response.json(); + + const warningDiv = document.getElementById('overwrite-warning'); + const indicator = document.getElementById('overwrite-indicator'); + + if (response.ok && result.status === 'ok') { + if (result.will_overwrite) { + // Show warning + warningDiv.classList.remove('hidden'); + indicator.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'dark:bg-green-900', 'dark:text-green-200'); + indicator.classList.add('bg-yellow-100', 'text-yellow-800', 'dark:bg-yellow-900', 'dark:text-yellow-200'); + indicator.textContent = 'Data Exists'; + } else { + // Hide warning + warningDiv.classList.add('hidden'); + indicator.classList.remove('hidden', 'bg-yellow-100', 'text-yellow-800', 'dark:bg-yellow-900', 'dark:text-yellow-200'); + indicator.classList.add('bg-green-100', 'text-green-800', 'dark:bg-green-900', 'dark:text-green-200'); + indicator.textContent = 'Safe'; + } + } else { + // Hide on error + warningDiv.classList.add('hidden'); + indicator.classList.add('hidden'); + } + } catch (error) { + console.error('Error checking overwrite status:', error); + } +} + +// Auto-increment to find next available index +async function autoIncrementStoreName() { + showStoreNameStatus('Finding next available index...', 'info'); + + try { + const currentIndexResponse = await fetch(`/api/slmm/${unit_id}/index-number`); + const currentIndexResult = await currentIndexResponse.json(); + + if (!currentIndexResponse.ok || currentIndexResult.status !== 'ok') { + showStoreNameStatus('✗ Failed to get current index', 'error'); + return; + } + + let currentIndex = parseInt(currentIndexResult.index_number); + let foundEmpty = false; + let checkedCount = 0; + const maxChecks = 100; // Safety limit + + // Start from next index + currentIndex++; + + while (!foundEmpty && checkedCount < maxChecks && currentIndex <= 9999) { + // Set the index + const setResponse = await fetch(`/api/slmm/${unit_id}/index-number`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ index: currentIndex }) + }); + + if (!setResponse.ok) { + currentIndex++; + checkedCount++; + continue; + } + + // Check if data exists at this index + const checkResponse = await fetch(`/api/slmm/${unit_id}/overwrite-check`); + const checkResult = await checkResponse.json(); + + if (checkResponse.ok && checkResult.status === 'ok') { + if (!checkResult.will_overwrite) { + // Found empty slot! + foundEmpty = true; + document.getElementById('store-name-display').textContent = currentIndex.toString().padStart(4, '0'); + document.getElementById('store-name-input').value = currentIndex; + showStoreNameStatus(`✓ Found empty slot at index ${currentIndex.toString().padStart(4, '0')}`, 'success'); + await checkOverwriteStatus(); + break; + } + } + + currentIndex++; + checkedCount++; + } + + if (!foundEmpty) { + if (currentIndex > 9999) { + showStoreNameStatus('✗ No empty slots found (reached max 9999)', 'error'); + } else { + showStoreNameStatus(`✗ No empty slot found in next ${maxChecks} indices`, 'error'); + } + } + } catch (error) { + showStoreNameStatus('✗ Error: ' + error.message, 'error'); + } +} + +// Navigate to directory +function navigateToDir(path) { + window.currentPath = path; + document.getElementById('current-path').textContent = path; + loadFiles(path); +} + +// Go up one directory +function navigateUp() { + const parts = window.currentPath.split('/').filter(p => p); + parts.pop(); + const newPath = '/' + parts.join('/'); + navigateToDir(newPath || '/'); +} + +// Download file +async function downloadFile(path, filename) { + try { + const response = await fetch(`/api/slmm/${unit_id}/ftp/download`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ remote_path: path }) + }); + + if (!response.ok) { + throw new Error('Download failed'); + } + + // Create blob and download + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } catch (error) { + alert('Failed to download file: ' + error.message); + } +} + +// Load files from path +async function loadFiles(path) { + const fileList = document.getElementById('file-list'); + const loading = document.getElementById('file-list-loading'); + const error = document.getElementById('file-list-error'); + const ftpStatus = document.getElementById('ftp-status'); + + // Show loading state + fileList.classList.add('hidden'); + loading.classList.remove('hidden'); + error.classList.add('hidden'); + + try { + const response = await fetch(`/api/slmm/${unit_id}/ftp/files?path=${encodeURIComponent(path)}`); + const result = await response.json(); + + if (result.status === 'ok') { + ftpStatus.classList.add('hidden'); + displayFiles(result.files || []); + } else { + throw new Error(result.detail || 'Failed to load files'); + } + } catch (err) { + loading.classList.add('hidden'); + + // Check if it's an FTP disabled error + if (err.message.includes('FTP') || err.message.includes('502')) { + ftpStatus.classList.remove('hidden'); + fileList.classList.remove('hidden'); + fileList.innerHTML = '
FTP is disabled on the device
'; + } else { + error.classList.remove('hidden'); + document.getElementById('file-list-error-text').textContent = err.message; + fileList.classList.remove('hidden'); + } + } +} + +// Display files in the list +function displayFiles(files) { + const fileList = document.getElementById('file-list'); + const loading = document.getElementById('file-list-loading'); + + loading.classList.add('hidden'); + fileList.classList.remove('hidden'); + + if (files.length === 0) { + fileList.innerHTML = '
No files found
'; + return; + } + + // Sort: directories first, then files + files.sort((a, b) => { + if (a.is_dir && !b.is_dir) return -1; + if (!a.is_dir && b.is_dir) return 1; + return a.name.localeCompare(b.name); + }); + + let html = ''; + + // Add "up" button if not at root + if (window.currentPath !== '/') { + html += ` +
+ + + + .. +
+ `; + } + + files.forEach(file => { + const icon = file.is_dir ? + '' : + ''; + + const clickHandler = file.is_dir ? + `navigateToDir('${file.path}')` : + `downloadFile('${file.path}', '${file.name}')`; + + html += ` +
+
+ ${icon} +
+

${file.name}

+

+ ${file.is_dir ? 'Folder' : formatFileSize(file.size)} + ${file.modified_timestamp ? ' • ' + formatDate(file.modified_timestamp) : ''} +

+
+
+ ${!file.is_dir ? ` + + ` : ''} +
+ `; + }); + + fileList.innerHTML = html; +} + +// Refresh file list +function refreshFileList() { + loadFiles(window.currentPath); +} From e1b965c24ce34c38a4696e1e381d040f76e16f78 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Mon, 12 Jan 2026 04:42:08 +0000 Subject: [PATCH 2/3] SLM dashboard rework, diagnostics and command pages added --- backend/routers/slm_dashboard.py | 62 ++++- templates/partials/slm_diagnostics_card.html | 206 ++++++++++++++ templates/partials/slm_live_view.html | 124 +++++---- templates/sound_level_meters.html | 276 ++++++++++++------- 4 files changed, 513 insertions(+), 155 deletions(-) create mode 100644 templates/partials/slm_diagnostics_card.html diff --git a/backend/routers/slm_dashboard.py b/backend/routers/slm_dashboard.py index 3d9c0df..bcfe057 100644 --- a/backend/routers/slm_dashboard.py +++ b/backend/routers/slm_dashboard.py @@ -6,7 +6,7 @@ Provides API endpoints for the Sound Level Meters dashboard page. from fastapi import APIRouter, Request, Depends, Query from fastapi.templating import Jinja2Templates -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, JSONResponse from sqlalchemy.orm import Session from sqlalchemy import func from datetime import datetime, timedelta @@ -60,14 +60,20 @@ async def get_slm_stats(request: Request, db: Session = Depends(get_db)): async def get_slm_units( request: Request, db: Session = Depends(get_db), - search: str = Query(None) + search: str = Query(None), + project: str = Query(None) ): """ Get list of SLM units for the sidebar. Returns HTML partial with unit cards. + Supports filtering by search term and project. """ query = db.query(RosterUnit).filter_by(device_type="sound_level_meter") + # Filter by project if provided + if project: + query = query.filter(RosterUnit.project_id == project) + # Filter by search term if provided if search: search_term = f"%{search}%" @@ -326,3 +332,55 @@ async def test_modem_connection(modem_id: str, db: Session = Depends(get_db)): "modem_id": modem_id, "detail": str(e) } + + +@router.get("/diagnostics/{unit_id}", response_class=HTMLResponse) +async def get_diagnostics(request: Request, unit_id: str, db: Session = Depends(get_db)): + """ + Get compact diagnostics card for a specific SLM unit. + Returns HTML partial with key metrics only. + """ + unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first() + + if not unit: + return HTMLResponse( + content='
Unit not found
', + status_code=404 + ) + + # Get modem info + modem = None + modem_ip = None + if unit.deployed_with_modem_id: + modem = db.query(RosterUnit).filter_by(id=unit.deployed_with_modem_id, device_type="modem").first() + if modem: + # Try modem_rx_host first (if it exists), then fall back to ip_address + modem_ip = getattr(modem, 'modem_rx_host', None) or modem.ip_address + elif unit.slm_host: + modem_ip = unit.slm_host + + return templates.TemplateResponse("partials/slm_diagnostics_card.html", { + "request": request, + "unit": unit, + "modem": modem, + "modem_ip": modem_ip + }) + + +@router.get("/projects") +async def get_projects(db: Session = Depends(get_db)): + """ + Get list of unique projects from deployed SLMs. + Returns JSON array of project names. + """ + projects = db.query(RosterUnit.project_id).filter( + RosterUnit.device_type == "sound_level_meter", + RosterUnit.deployed == True, + RosterUnit.retired == False, + RosterUnit.project_id.isnot(None) + ).distinct().order_by(RosterUnit.project_id).all() + + # Extract project names from query result tuples + project_list = [p[0] for p in projects if p[0]] + + return JSONResponse(content={"projects": project_list}) diff --git a/templates/partials/slm_diagnostics_card.html b/templates/partials/slm_diagnostics_card.html new file mode 100644 index 0000000..b5a77a8 --- /dev/null +++ b/templates/partials/slm_diagnostics_card.html @@ -0,0 +1,206 @@ + +
+ +
+
+

{{ unit.id }}

+

+ {% if unit.slm_model %}{{ unit.slm_model }}{% endif %} + {% if unit.slm_serial_number %} • S/N: {{ unit.slm_serial_number }}{% endif %} +

+
+ + + + Loading... + +
+ + +
+
+
+ + + + Connection +
+
+ {% if modem %} + via {{ modem.id }} + {% elif modem_ip %} + Direct: {{ modem_ip }} + {% else %} + Not configured + {% endif %} + +
+
+
+ + +
+
+

Lp (Instant)

+

--

+

dB

+
+ +
+

Leq (Average)

+

--

+

dB

+
+ +
+

Lmax (Max)

+

--

+

dB

+
+ +
+

Lmin (Min)

+

--

+

dB

+
+
+ + +
+
+
+ Battery + + + +
+
--
+
+
+
+
+ +
+
+ Power + + + +
+
--
+
+
+ + +
+
+
+ + + + Last Check-in +
+
+ {% if unit.slm_last_check %} + {{ unit.slm_last_check.strftime('%Y-%m-%d %H:%M:%S') }} + {% else %} + Never + {% endif %} +
+
+
+ + +
+ +
+
+ + diff --git a/templates/partials/slm_live_view.html b/templates/partials/slm_live_view.html index 54e2560..b1b11f8 100644 --- a/templates/partials/slm_live_view.html +++ b/templates/partials/slm_live_view.html @@ -686,8 +686,13 @@ async function controlUnit(unitId, action) { if (typeof window.refreshInterval === 'undefined') { window.refreshInterval = null; } -const REFRESH_INTERVAL_MS = 30000; // 30 seconds -const unit_id = '{{ unit.id }}'; +if (typeof window.REFRESH_INTERVAL_MS === 'undefined') { + window.REFRESH_INTERVAL_MS = 30000; // 30 seconds +} +if (typeof window.unit_id === 'undefined' || window.unit_id !== '{{ unit.id }}') { + // Keep HTMX reloads from reusing the old unit id + window.unit_id = '{{ unit.id }}'; +} function updateDeviceStatus() { fetch(`/api/slmm/${unit_id}/live`) @@ -755,7 +760,7 @@ function startAutoRefresh() { updateDeviceStatus(); // Set up interval - refreshInterval = setInterval(updateDeviceStatus, REFRESH_INTERVAL_MS); + refreshInterval = setInterval(updateDeviceStatus, window.REFRESH_INTERVAL_MS); console.log('Auto-refresh started (30s interval)'); } @@ -778,6 +783,12 @@ if (typeof window.timerInterval === 'undefined') { window.timerInterval = null; window.measurementStartTime = null; // ISO string from backend } +if (typeof window.timerSource === 'undefined') { + window.timerSource = null; +} +if (typeof window.lastFtpErrorTime === 'undefined') { + window.lastFtpErrorTime = null; +} // Format elapsed time as HH:MM:SS function formatElapsedTime(milliseconds) { @@ -818,42 +829,52 @@ function updateTimerDisplay() { async function syncTimerWithBackend(measurementState, measurementStartTime) { // Device returns "Start" when measuring, "Stop" when stopped const isMeasuring = measurementState === 'Start'; + const now = Date.now(); if (isMeasuring && measurementStartTime) { // Measurement is running - check both backend and FTP timestamps // Use whichever is earlier (older = actual measurement start) // First check FTP for potentially older timestamp - try { - const response = await fetch(`/api/slmm/${unit_id}/ftp/latest-measurement-time`); - const result = await response.json(); - - if (result.status === 'ok' && result.latest_timestamp) { - const backendTime = new Date(measurementStartTime + 'Z'); - const ftpTime = new Date(result.latest_timestamp + 'Z'); - - // Use the earlier timestamp (represents actual measurement start) - if (ftpTime < backendTime) { - window.measurementStartTime = result.latest_timestamp; - window.timerSource = 'ftp'; - console.log('Timer synced with FTP folder (earlier):', result.latest_folder, '@', result.latest_timestamp); - } else { - window.measurementStartTime = measurementStartTime; - window.timerSource = 'backend'; - console.log('Timer synced with backend state (earlier):', measurementStartTime); - } - } else { - // No FTP timestamp, use backend - window.measurementStartTime = measurementStartTime; - window.timerSource = 'backend'; - console.log('Timer synced with backend state:', measurementStartTime); - } - } catch (error) { - console.error('Failed to check FTP timestamp:', error); - // Fallback to backend on error + const shouldSkipFtp = window.lastFtpErrorTime && (now - window.lastFtpErrorTime < 10000); + if (shouldSkipFtp) { window.measurementStartTime = measurementStartTime; window.timerSource = 'backend'; - console.log('Timer synced with backend state (FTP check failed):', measurementStartTime); + console.log('Timer using backend state (skipping FTP due to recent error):', measurementStartTime); + } else { + try { + const response = await fetch(`/api/slmm/${unit_id}/ftp/latest-measurement-time`); + const result = await response.json(); + + if (result.status === 'ok' && result.latest_timestamp) { + const backendTime = new Date(measurementStartTime + 'Z'); + const ftpTime = new Date(result.latest_timestamp + 'Z'); + + // Use the earlier timestamp (represents actual measurement start) + if (ftpTime < backendTime) { + window.measurementStartTime = result.latest_timestamp; + window.timerSource = 'ftp'; + console.log('Timer synced with FTP folder (earlier):', result.latest_folder, '@', result.latest_timestamp); + } else { + window.measurementStartTime = measurementStartTime; + window.timerSource = 'backend'; + console.log('Timer synced with backend state (earlier):', measurementStartTime); + } + window.lastFtpErrorTime = null; + } else { + // No FTP timestamp, use backend + window.measurementStartTime = measurementStartTime; + window.timerSource = 'backend'; + console.log('Timer synced with backend state:', measurementStartTime); + } + } catch (error) { + console.error('Failed to check FTP timestamp:', error); + window.lastFtpErrorTime = now; + // Fallback to backend on error + window.measurementStartTime = measurementStartTime; + window.timerSource = 'backend'; + console.log('Timer synced with backend state (FTP check failed):', measurementStartTime); + } } // Start interval if not already running @@ -869,27 +890,34 @@ async function syncTimerWithBackend(measurementState, measurementStartTime) { // Try FTP fallback to get measurement start from latest folder timestamp if (!window.measurementStartTime || window.timerSource !== 'ftp') { console.log('Device measuring but no backend start time - checking FTP fallback...'); - try { - const response = await fetch(`/api/slmm/${unit_id}/ftp/latest-measurement-time`); - const result = await response.json(); + const skipFtp = window.lastFtpErrorTime && (Date.now() - window.lastFtpErrorTime < 10000); + if (!skipFtp) { + try { + const response = await fetch(`/api/slmm/${unit_id}/ftp/latest-measurement-time`); + const result = await response.json(); - if (result.status === 'ok' && result.latest_timestamp) { - window.measurementStartTime = result.latest_timestamp; - window.timerSource = 'ftp'; - console.log('Timer synced with FTP folder:', result.latest_folder, '@', result.latest_timestamp); + if (result.status === 'ok' && result.latest_timestamp) { + window.measurementStartTime = result.latest_timestamp; + window.timerSource = 'ftp'; + console.log('Timer synced with FTP folder:', result.latest_folder, '@', result.latest_timestamp); - // Start timer interval if not already running - if (!window.timerInterval) { - window.timerInterval = setInterval(updateTimerDisplay, 1000); - console.log('Timer display started (FTP source)'); + // Start timer interval if not already running + if (!window.timerInterval) { + window.timerInterval = setInterval(updateTimerDisplay, 1000); + console.log('Timer display started (FTP source)'); + } + + updateTimerDisplay(); + window.lastFtpErrorTime = null; + } else { + console.log('No FTP timestamp available'); } - - updateTimerDisplay(); - } else { - console.log('No FTP timestamp available'); + } catch (error) { + console.error('Failed to get FTP timestamp:', error); + window.lastFtpErrorTime = Date.now(); } - } catch (error) { - console.error('Failed to get FTP timestamp:', error); + } else { + console.log('Skipping FTP fallback due to recent error'); } } } else { diff --git a/templates/sound_level_meters.html b/templates/sound_level_meters.html index 0e48b7d..b00f27e 100644 --- a/templates/sound_level_meters.html +++ b/templates/sound_level_meters.html @@ -28,14 +28,28 @@

Active Units

-
- + + + + +
@@ -93,9 +107,94 @@ + + + {% endblock %} From b9a3da8487fbbc8f8b970d48885320da80be3de2 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Mon, 12 Jan 2026 16:21:14 +0000 Subject: [PATCH 3/3] terra-view toplevel rebrand. Names changed all around --- .dockerignore | 2 +- CHANGELOG.md | 4 ++-- README.md | 4 ++-- backend/main.py | 6 +++--- backend/routers/slmm.py | 6 +++--- backend/static/offline-db.js | 4 ++-- backend/static/sw.js | 14 +++++++------- sfm.code-workspace => terra-view.code-workspace | 0 8 files changed, 20 insertions(+), 20 deletions(-) rename sfm.code-workspace => terra-view.code-workspace (100%) diff --git a/.dockerignore b/.dockerignore index f26b729..2e4edce 100644 --- a/.dockerignore +++ b/.dockerignore @@ -35,7 +35,7 @@ data/ .DS_Store Thumbs.db .claude -sfm.code-workspace +terra-view.code-workspace # Tests (optional) tests/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bf82e8..3976fd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -All notable changes to Seismo Fleet Manager will be documented in this file. +All notable changes to Terra-View will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). @@ -54,7 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.4.1] - 2026-01-05 ### Added -- **SLM Integration**: Sound Level Meters are now manageable in SFM +- **SLM Integration**: Sound Level Meters are now manageable in Terra-View ### Fixed - Fixed an issue where unit status was loading from a saved cache and not based on when it was actually heard from last. Unit status is now accurate. diff --git a/README.md b/README.md index e3d447b..90fdec4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -# Seismo Fleet Manager v0.4.2 -Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard. +# Terra-View v0.4.2 +Backend API and HTMX-powered web interface for Terra-View - a unified fleet management system. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard. Terra-View supports seismographs (SFM module), sound level meters, field modems, and other monitoring devices. ## Features diff --git a/backend/main.py b/backend/main.py index b471ec2..6204e6e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -31,8 +31,8 @@ ENVIRONMENT = os.getenv("ENVIRONMENT", "production") # Initialize FastAPI app VERSION = "0.4.2" app = FastAPI( - title="Seismo Fleet Manager", - description="Backend API for managing seismograph fleet status", + title="Terra-View", + description="Backend API for Terra-View fleet management system", version=VERSION ) @@ -516,7 +516,7 @@ async def devices_all_partial(request: Request): def health_check(): """Health check endpoint""" return { - "message": f"Seismo Fleet Manager v{VERSION}", + "message": f"Terra-View v{VERSION}", "status": "running", "version": VERSION } diff --git a/backend/routers/slmm.py b/backend/routers/slmm.py index 1c73f5e..1385beb 100644 --- a/backend/routers/slmm.py +++ b/backend/routers/slmm.py @@ -1,7 +1,7 @@ """ SLMM (Sound Level Meter Manager) Proxy Router -Proxies requests from SFM to the standalone SLMM backend service. +Proxies requests from Terra-View to the standalone SLMM backend service. SLMM runs on port 8100 and handles NL43/NL53 sound level meter communication. """ @@ -72,7 +72,7 @@ async def proxy_websocket_stream(websocket: WebSocket, unit_id: str): Proxy WebSocket connections to SLMM's /stream endpoint. This allows real-time streaming of measurement data from NL43 devices - through the SFM unified interface. + through the Terra-View unified interface. """ await websocket.accept() logger.info(f"WebSocket connection accepted for SLMM unit {unit_id}") @@ -237,7 +237,7 @@ async def proxy_to_slmm(path: str, request: Request): """ Proxy all requests to the SLMM backend service. - This allows SFM to act as a unified frontend for all device types, + This allows Terra-View to act as a unified frontend for all device types, while SLMM remains a standalone backend service. """ # Build target URL diff --git a/backend/static/offline-db.js b/backend/static/offline-db.js index 61281f9..ceb1847 100644 --- a/backend/static/offline-db.js +++ b/backend/static/offline-db.js @@ -1,9 +1,9 @@ -/* IndexedDB wrapper for offline data storage in SFM */ +/* IndexedDB wrapper for offline data storage in Terra-View */ /* Handles unit data, status snapshots, and pending edit queue */ class OfflineDB { constructor() { - this.dbName = 'sfm-offline-db'; + this.dbName = 'terra-view-offline-db'; this.version = 1; this.db = null; } diff --git a/backend/static/sw.js b/backend/static/sw.js index 2c24788..b69476a 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -1,10 +1,10 @@ -/* Service Worker for Seismo Fleet Manager PWA */ +/* Service Worker for Terra-View PWA */ /* Network-first strategy with cache fallback for real-time data */ const CACHE_VERSION = 'v1'; -const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`; -const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`; -const DATA_CACHE = `sfm-data-${CACHE_VERSION}`; +const STATIC_CACHE = `terra-view-static-${CACHE_VERSION}`; +const DYNAMIC_CACHE = `terra-view-dynamic-${CACHE_VERSION}`; +const DATA_CACHE = `terra-view-data-${CACHE_VERSION}`; // Files to precache (critical app shell) const STATIC_FILES = [ @@ -137,7 +137,7 @@ async function networkFirstStrategy(request, cacheName) { - Offline - SFM + Offline - Terra-View