update ftp browser, enable folder downloads (local), reimplemented timer. Enhanced project view
This commit is contained in:
@@ -1,5 +1,15 @@
|
||||
<!-- 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>
|
||||
@@ -201,7 +211,7 @@
|
||||
</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 %}">
|
||||
style="width: {% if current_status and current_status.battery_level %}{{ current_status.battery_level }}{% else %}0{% endif %}%">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -934,9 +944,15 @@ function startMeasurementTimer(unitId) {
|
||||
// Stop any existing timer
|
||||
stopMeasurementTimer();
|
||||
|
||||
// Record start time in localStorage
|
||||
const startTime = Date.now();
|
||||
localStorage.setItem(TIMER_STORAGE_KEY + unitId, startTime.toString());
|
||||
// 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');
|
||||
@@ -1023,12 +1039,19 @@ async function resumeMeasurementTimerIfNeeded(unitId, isMeasuring) {
|
||||
// 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
|
||||
@@ -1050,26 +1073,27 @@ async function fetchStartTimeFromFTP(unitId) {
|
||||
const startTime = parseFolderTimestamp(lastFolder);
|
||||
|
||||
if (startTime) {
|
||||
console.log('Parsed start time from folder:', new Date(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');
|
||||
console.warn('✗ Could not parse folder timestamp, starting timer from now');
|
||||
startMeasurementTimer(unitId);
|
||||
}
|
||||
} else {
|
||||
// No folders found - start from now
|
||||
console.warn('No measurement folders found, starting timer from now');
|
||||
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('Could not access FTP, starting timer 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);
|
||||
console.error('✗ Error fetching start time from FTP:', error);
|
||||
// Fallback - start from now
|
||||
startMeasurementTimer(unitId);
|
||||
}
|
||||
@@ -1077,6 +1101,8 @@ async function fetchStartTimeFromFTP(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;
|
||||
@@ -1086,7 +1112,9 @@ function parseFolderTimestamp(folder) {
|
||||
const match1 = name.match(pattern1);
|
||||
if (match1) {
|
||||
const [_, year, month, day, hour, min, sec] = match1;
|
||||
return new Date(year, month - 1, day, hour, min, sec).getTime();
|
||||
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)
|
||||
@@ -1094,27 +1122,91 @@ function parseFolderTimestamp(folder) {
|
||||
const match2 = name.match(pattern2);
|
||||
if (match2) {
|
||||
const [_, year, month, day, hour, min, sec] = match2;
|
||||
return new Date(year, month - 1, day, hour, min, sec).getTime();
|
||||
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;
|
||||
}
|
||||
|
||||
// Try using modified_timestamp (ISO format from SLMM)
|
||||
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) {
|
||||
return new Date(folder.modified_timestamp).getTime();
|
||||
// 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
|
||||
@@ -1125,46 +1217,8 @@ function updateDeviceStatus() {
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.status === 'ok' && result.data) {
|
||||
const data = result.data;
|
||||
applyDeviceStatusData(result.data);
|
||||
|
||||
// Update battery
|
||||
if (document.getElementById('battery-level')) {
|
||||
const batteryLevel = data.battery_level || '--';
|
||||
document.getElementById('battery-level').textContent = batteryLevel === '--' ? '--' : `${batteryLevel}%`;
|
||||
|
||||
// Update battery bar
|
||||
const batteryBar = document.getElementById('battery-bar');
|
||||
if (batteryBar && batteryLevel !== '--') {
|
||||
const level = parseInt(batteryLevel);
|
||||
batteryBar.style.width = `${level}%`;
|
||||
|
||||
// Color based on level
|
||||
if (level > 50) {
|
||||
batteryBar.className = 'bg-green-500 h-2 rounded-full transition-all';
|
||||
} else if (level > 20) {
|
||||
batteryBar.className = 'bg-yellow-500 h-2 rounded-full transition-all';
|
||||
} else {
|
||||
batteryBar.className = 'bg-red-500 h-2 rounded-full transition-all';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update power source
|
||||
if (document.getElementById('power-source')) {
|
||||
document.getElementById('power-source').textContent = data.power_source || '--';
|
||||
}
|
||||
|
||||
// Update SD card info
|
||||
if (document.getElementById('sd-remaining')) {
|
||||
const sdRemaining = data.sd_remaining_mb || '--';
|
||||
document.getElementById('sd-remaining').textContent = sdRemaining === '--' ? '--' : `${sdRemaining} MB`;
|
||||
}
|
||||
if (document.getElementById('sd-ratio')) {
|
||||
const sdRatio = data.sd_free_ratio || '--';
|
||||
document.getElementById('sd-ratio').textContent = sdRatio === '--' ? '--' : `${sdRatio}% free`;
|
||||
}
|
||||
|
||||
// Update last update timestamp
|
||||
if (document.getElementById('last-update')) {
|
||||
const now = new Date();
|
||||
document.getElementById('last-update').textContent = now.toLocaleTimeString();
|
||||
@@ -1199,21 +1253,51 @@ function stopAutoRefresh() {
|
||||
}
|
||||
}
|
||||
|
||||
// Start auto-refresh and load initial data when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 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
|
||||
const unitId = '{{ unit.id }}';
|
||||
getDeviceClock(unitId);
|
||||
getIndexNumber(unitId);
|
||||
getFrequencyWeighting(unitId);
|
||||
getTimeWeighting(unitId);
|
||||
// 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);
|
||||
|
||||
// Resume measurement timer if device is currently measuring
|
||||
const isMeasuring = {{ 'true' if is_measuring else 'false' }};
|
||||
resumeMeasurementTimerIfNeeded(unitId, isMeasuring);
|
||||
});
|
||||
// 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() {
|
||||
@@ -1485,12 +1569,22 @@ async function loadFTPFiles(unitId, path) {
|
||||
|
||||
if (isDir) {
|
||||
html += `
|
||||
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded cursor-pointer transition-colors" onclick="loadFTPFiles('${unitId}', '${escapeForAttribute(fullPath)}')">
|
||||
<div class="flex items-center flex-1">
|
||||
<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 cursor-pointer" onclick="loadFTPFiles('${unitId}', '${escapeForAttribute(fullPath)}')">
|
||||
${icon}
|
||||
<span class="text-gray-900 dark:text-white font-medium">${escapeHtml(file.name)}</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500">${dateText}</span>
|
||||
<div 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>
|
||||
`;
|
||||
} else {
|
||||
@@ -1593,6 +1687,58 @@ async function downloadFTPFile(unitId, filePath, fileName) {
|
||||
}
|
||||
}
|
||||
|
||||
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`, {
|
||||
|
||||
Reference in New Issue
Block a user