update ftp browser, enable folder downloads (local), reimplemented timer. Enhanced project view

This commit is contained in:
serversdwn
2026-01-14 21:59:22 +00:00
parent d349af9444
commit 7971092509
4 changed files with 469 additions and 81 deletions

View File

@@ -744,6 +744,132 @@ async def ftp_download_to_server(
) )
@router.post("/{project_id}/ftp-download-folder-to-server")
async def ftp_download_folder_to_server(
project_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""
Download an entire folder from an SLM to the server via FTP as a ZIP file.
Creates a DataFile record and stores the ZIP in data/Projects/{project_id}/
"""
import httpx
import os
import hashlib
from pathlib import Path
from backend.models import DataFile
data = await request.json()
unit_id = data.get("unit_id")
remote_path = data.get("remote_path")
location_id = data.get("location_id")
if not unit_id or not remote_path:
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
# Get or create active session for this location/unit
session = db.query(RecordingSession).filter(
and_(
RecordingSession.project_id == project_id,
RecordingSession.location_id == location_id,
RecordingSession.unit_id == unit_id,
RecordingSession.status.in_(["recording", "paused"])
)
).first()
# If no active session, create one
if not session:
session = RecordingSession(
id=str(uuid.uuid4()),
project_id=project_id,
location_id=location_id,
unit_id=unit_id,
status="completed",
started_at=datetime.utcnow(),
stopped_at=datetime.utcnow(),
notes="Auto-created for FTP folder download"
)
db.add(session)
db.commit()
db.refresh(session)
# Download folder from SLMM
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
try:
async with httpx.AsyncClient(timeout=600.0) as client: # Longer timeout for folders
response = await client.post(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/ftp/download-folder",
json={"remote_path": remote_path}
)
if not response.is_success:
raise HTTPException(
status_code=response.status_code,
detail=f"Failed to download folder from SLMM: {response.text}"
)
# Extract folder name from remote_path
folder_name = os.path.basename(remote_path.rstrip('/'))
filename = f"{folder_name}.zip"
# Create directory structure: data/Projects/{project_id}/{session_id}/
project_dir = Path(f"data/Projects/{project_id}/{session.id}")
project_dir.mkdir(parents=True, exist_ok=True)
# Save ZIP file to disk
file_path = project_dir / filename
file_content = response.content
with open(file_path, 'wb') as f:
f.write(file_content)
# Calculate checksum
checksum = hashlib.sha256(file_content).hexdigest()
# Create DataFile record
data_file = DataFile(
id=str(uuid.uuid4()),
session_id=session.id,
file_path=str(file_path.relative_to("data")), # Store relative to data/
file_type='archive', # ZIP archives
file_size_bytes=len(file_content),
downloaded_at=datetime.utcnow(),
checksum=checksum,
file_metadata=json.dumps({
"source": "ftp_folder",
"remote_path": remote_path,
"unit_id": unit_id,
"location_id": location_id,
"folder_name": folder_name,
})
)
db.add(data_file)
db.commit()
return {
"success": True,
"message": f"Downloaded folder {folder_name} to server as ZIP",
"file_id": data_file.id,
"file_path": str(file_path),
"file_size": len(file_content),
}
except httpx.TimeoutException:
raise HTTPException(
status_code=504,
detail="Timeout downloading folder from SLM (large folders may take a while)"
)
except Exception as e:
logger.error(f"Error downloading folder to server: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to download folder to server: {str(e)}"
)
# ============================================================================ # ============================================================================
# Project Types # Project Types
# ============================================================================ # ============================================================================

View File

@@ -158,7 +158,7 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
is_measuring = False is_measuring = False
try: try:
async with httpx.AsyncClient(timeout=5.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
# Get measurement state # Get measurement state
state_response = await client.get( state_response = await client.get(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state" f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
@@ -168,7 +168,23 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
measurement_state = state_data.get("measurement_state", "Unknown") measurement_state = state_data.get("measurement_state", "Unknown")
is_measuring = state_data.get("is_measuring", False) is_measuring = state_data.get("is_measuring", False)
# Get live status # If measuring, sync start time from FTP to database (fixes wrong timestamps)
if is_measuring:
try:
sync_response = await client.post(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/sync-start-time",
timeout=10.0
)
if sync_response.status_code == 200:
sync_data = sync_response.json()
logger.info(f"Synced start time for {unit_id}: {sync_data.get('message')}")
else:
logger.warning(f"Failed to sync start time for {unit_id}: {sync_response.status_code}")
except Exception as e:
# Don't fail the whole request if sync fails
logger.warning(f"Could not sync start time for {unit_id}: {e}")
# Get live status (now with corrected start time)
status_response = await client.get( status_response = await client.get(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live" f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
) )

View File

@@ -1,4 +1,4 @@
<!-- FTP File Browser for SLMs --> <!-- FTP File Browser for SLMs v2.0 - Folder Download Support -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Download Files from SLMs</h2> <h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Download Files from SLMs</h2>
@@ -34,7 +34,7 @@
disabled> disabled>
Disable FTP Disable FTP
</button> </button>
<button onclick="loadFTPFiles('{{ unit_item.unit.id }}', '/NL43_DATA')" <button onclick="loadFTPFiles('{{ unit_item.unit.id }}', '/NL-43')"
id="browse-ftp-{{ unit_item.unit.id }}" id="browse-ftp-{{ unit_item.unit.id }}"
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors" class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors"
disabled> disabled>
@@ -50,8 +50,8 @@
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path> <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> </svg>
<span id="current-path-{{ unit_item.unit.id }}" class="text-sm font-mono text-gray-600 dark:text-gray-400">/NL43_DATA</span> <span id="current-path-{{ unit_item.unit.id }}" class="text-sm font-mono text-gray-600 dark:text-gray-400">/NL-43</span>
<button onclick="loadFTPFiles('{{ unit_item.unit.id }}', '/NL43_DATA')" <button onclick="loadFTPFiles('{{ unit_item.unit.id }}', '/NL-43')"
class="ml-auto text-xs px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600"> class="ml-auto text-xs px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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 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> <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>
@@ -127,7 +127,7 @@ async function enableFTP(unitId) {
if (response.ok) { if (response.ok) {
await checkFTPStatus(unitId); await checkFTPStatus(unitId);
// Auto-load files after enabling // Auto-load files after enabling
setTimeout(() => loadFTPFiles(unitId, '/NL43_DATA'), 1000); setTimeout(() => loadFTPFiles(unitId, '/NL-43'), 1000);
} else { } else {
alert('Failed to enable FTP'); alert('Failed to enable FTP');
} }
@@ -200,18 +200,34 @@ async function loadFTPFiles(unitId, path) {
: '<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"></path></svg>'; : '<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"></path></svg>';
const sizeStr = file.is_dir ? '' : formatFileSize(file.size); const sizeStr = file.is_dir ? '' : formatFileSize(file.size);
const clickAction = file.is_dir
? `onclick="loadFTPFiles('${unitId}', '${file.path}')"`
: '';
html += ` html += `
<div class="flex items-center gap-3 px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 rounded ${file.is_dir ? 'cursor-pointer' : ''}" ${clickAction}> <div class="flex items-center gap-3 px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 rounded">
${icon} ${icon}
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0 ${file.is_dir ? 'cursor-pointer' : ''}" ${file.is_dir ? `onclick="loadFTPFiles('${unitId}', '${file.path}')"` : ''}>
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">${file.name}</div> <div class="text-sm font-medium text-gray-900 dark:text-white truncate">${file.name}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">${file.modified}${sizeStr ? ' • ' + sizeStr : ''}</div> <div class="text-xs text-gray-500 dark:text-gray-400">${file.modified}${sizeStr ? ' • ' + sizeStr : ''}</div>
</div> </div>
${!file.is_dir ? ` ${file.is_dir ? `
<div class="flex items-center gap-2">
<button onclick="event.stopPropagation(); downloadFolderToServer('${unitId}', '${file.path}', '${file.name}')"
class="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
title="Download entire folder to server and add to database">
<svg class="w-3 h-3 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"></path>
</svg>
To Server (ZIP)
</button>
<button onclick="event.stopPropagation(); downloadFTPFolder('${unitId}', '${file.path}', '${file.name}')"
class="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
title="Download entire folder as ZIP to your computer">
<svg class="w-3 h-3 inline 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>
To Browser (ZIP)
</button>
</div>
` : `
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button onclick="downloadToServer('${unitId}', '${file.path}', '${file.name}')" <button onclick="downloadToServer('${unitId}', '${file.path}', '${file.name}')"
class="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors" class="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
@@ -230,7 +246,7 @@ async function loadFTPFiles(unitId, path) {
To Browser To Browser
</button> </button>
</div> </div>
` : ''} `}
</div> </div>
`; `;
} }
@@ -280,6 +296,90 @@ async function downloadFTPFile(unitId, remotePath, fileName) {
} }
} }
async function downloadFTPFolder(unitId, remotePath, folderName) {
const btn = event.target;
const originalHTML = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<svg class="w-3 h-3 inline mr-1 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>Downloading...';
try {
const response = await fetch(`/api/slmm/${unitId}/ftp/download-folder`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
remote_path: remotePath
})
});
if (response.ok) {
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();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
// Show success message
alert(`✓ Folder "${folderName}" downloaded successfully as ZIP file!`);
} else {
const errorData = await response.json();
alert('Folder download failed: ' + (errorData.detail || 'Unknown error'));
}
} catch (error) {
alert('Error downloading folder: ' + error);
} finally {
btn.disabled = false;
btn.innerHTML = originalHTML;
}
}
async function downloadFolderToServer(unitId, remotePath, folderName) {
const btn = event.target;
const originalHTML = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<svg class="w-3 h-3 inline mr-1 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>Downloading...';
// Get location_id from the unit's data attribute
const unitContainer = btn.closest('[id^="ftp-files-"]');
const locationId = unitContainer.dataset.locationId;
try {
const response = await fetch(`/api/projects/{{ project_id }}/ftp-download-folder-to-server`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
unit_id: unitId,
remote_path: remotePath,
location_id: locationId
})
});
const data = await response.json();
if (response.ok) {
// Show success message
alert(`✓ Folder "${folderName}" downloaded to server successfully as ZIP!\n\nFile ID: ${data.file_id}\nSize: ${formatFileSize(data.file_size)}`);
// Refresh the downloaded files list
htmx.trigger('#project-files', 'refresh');
} else {
alert('Folder download to server failed: ' + (data.detail || 'Unknown error'));
}
} catch (error) {
alert('Error downloading folder to server: ' + error);
} finally {
btn.disabled = false;
btn.innerHTML = originalHTML;
}
}
async function downloadToServer(unitId, remotePath, fileName) { async function downloadToServer(unitId, remotePath, fileName) {
const btn = event.target; const btn = event.target;
const originalText = btn.innerHTML; const originalText = btn.innerHTML;

View File

@@ -1,5 +1,15 @@
<!-- Live View Panel for {{ unit.id }} --> <!-- Live View Panel for {{ unit.id }} -->
<div class="h-full flex flex-col"> <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 --> <!-- Header -->
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<div> <div>
@@ -201,7 +211,7 @@
</div> </div>
<div class="mt-2 bg-gray-200 dark:bg-gray-700 rounded-full h-2"> <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" <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> </div>
</div> </div>
@@ -934,9 +944,15 @@ function startMeasurementTimer(unitId) {
// Stop any existing timer // Stop any existing timer
stopMeasurementTimer(); stopMeasurementTimer();
// Record start time in localStorage // 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(); const startTime = Date.now();
localStorage.setItem(TIMER_STORAGE_KEY + unitId, startTime.toString()); 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 // Show timer container
const container = document.getElementById('elapsed-time-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 // Fetch measurement start time from last folder on FTP
async function fetchStartTimeFromFTP(unitId) { async function fetchStartTimeFromFTP(unitId) {
try { try {
console.log('Fetching FTP files from /NL-43...');
const response = await fetch(`/api/slmm/${unitId}/ftp/files?path=/NL-43`); const response = await fetch(`/api/slmm/${unitId}/ftp/files?path=/NL-43`);
const result = await response.json(); 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) { if (result.status === 'ok' && result.files && result.files.length > 0) {
console.log(`Found ${result.files.length} files/folders`);
// Filter for directories only // Filter for directories only
const folders = result.files.filter(f => f.is_dir || f.type === 'directory'); 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) { if (folders.length > 0) {
// Sort by modified timestamp (newest first) or by name // Sort by modified timestamp (newest first) or by name
@@ -1050,26 +1073,27 @@ async function fetchStartTimeFromFTP(unitId) {
const startTime = parseFolderTimestamp(lastFolder); const startTime = parseFolderTimestamp(lastFolder);
if (startTime) { 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()); localStorage.setItem(TIMER_STORAGE_KEY + unitId, startTime.toString());
startMeasurementTimer(unitId); startMeasurementTimer(unitId);
} else { } else {
// Can't parse folder time - start from now // 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); startMeasurementTimer(unitId);
} }
} else { } else {
// No folders found - start from now // 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); startMeasurementTimer(unitId);
} }
} else { } else {
// FTP failed or no files - start from now // 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); startMeasurementTimer(unitId);
} }
} catch (error) { } catch (error) {
console.error('Error fetching start time from FTP:', error); console.error('Error fetching start time from FTP:', error);
// Fallback - start from now // Fallback - start from now
startMeasurementTimer(unitId); startMeasurementTimer(unitId);
} }
@@ -1077,6 +1101,8 @@ async function fetchStartTimeFromFTP(unitId) {
// Parse timestamp from folder name or modified time // Parse timestamp from folder name or modified time
function parseFolderTimestamp(folder) { function parseFolderTimestamp(folder) {
console.log('Parsing timestamp from folder:', folder.name);
// Try parsing from folder name first // Try parsing from folder name first
// Expected formats: YYYYMMDD_HHMMSS or YYYY-MM-DD_HH-MM-SS // Expected formats: YYYYMMDD_HHMMSS or YYYY-MM-DD_HH-MM-SS
const name = folder.name; const name = folder.name;
@@ -1086,7 +1112,9 @@ function parseFolderTimestamp(folder) {
const match1 = name.match(pattern1); const match1 = name.match(pattern1);
if (match1) { if (match1) {
const [_, year, month, day, hour, min, sec] = 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) // 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); const match2 = name.match(pattern2);
if (match2) { if (match2) {
const [_, year, month, day, hour, min, sec] = 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) { 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) // Fallback to modified (string format)
if (folder.modified) { if (folder.modified) {
const parsedTime = new Date(folder.modified).getTime(); const parsedTime = new Date(folder.modified).getTime();
if (!isNaN(parsedTime)) { if (!isNaN(parsedTime)) {
console.log(` ✓ Using modified field: ${new Date(parsedTime)}`);
return parsedTime; return parsedTime;
} }
console.log(` ✗ Could not parse modified field: ${folder.modified}`);
} }
// Could not parse // Could not parse
console.log(' ✗ Could not parse any timestamp from folder');
return null; 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 // Auto-refresh status every 30 seconds
let refreshInterval; let refreshInterval;
const REFRESH_INTERVAL_MS = 30000; // 30 seconds const REFRESH_INTERVAL_MS = 30000; // 30 seconds
@@ -1125,46 +1217,8 @@ function updateDeviceStatus() {
.then(response => response.json()) .then(response => response.json())
.then(result => { .then(result => {
if (result.status === 'ok' && result.data) { 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')) { if (document.getElementById('last-update')) {
const now = new Date(); const now = new Date();
document.getElementById('last-update').textContent = now.toLocaleTimeString(); document.getElementById('last-update').textContent = now.toLocaleTimeString();
@@ -1199,21 +1253,51 @@ function stopAutoRefresh() {
} }
} }
// Start auto-refresh and load initial data when page loads // Bootstrap data from server
document.addEventListener('DOMContentLoaded', function() { 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(); startAutoRefresh();
// Load initial device settings // Load initial device settings with delays (NL-43 requires 1 second between commands)
const unitId = '{{ unit.id }}'; // These are background updates, so we can space them out
getDeviceClock(unitId); setTimeout(() => getIndexNumber(unitId), 1500);
getIndexNumber(unitId); setTimeout(() => getDeviceClock(unitId), 3000);
getFrequencyWeighting(unitId); setTimeout(() => getFrequencyWeighting(unitId), 4500);
getTimeWeighting(unitId); setTimeout(() => getTimeWeighting(unitId), 6000);
// Resume measurement timer if device is currently measuring // Initialize measurement timer if device is currently measuring
const isMeasuring = {{ 'true' if is_measuring else 'false' }}; if (isMeasuring && measurementStartTime) {
resumeMeasurementTimerIfNeeded(unitId, isMeasuring); // 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 // Cleanup on page unload
window.addEventListener('beforeunload', function() { window.addEventListener('beforeunload', function() {
@@ -1485,12 +1569,22 @@ async function loadFTPFiles(unitId, path) {
if (isDir) { if (isDir) {
html += ` 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 justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors group">
<div class="flex items-center flex-1"> <div class="flex items-center flex-1 cursor-pointer" onclick="loadFTPFiles('${unitId}', '${escapeForAttribute(fullPath)}')">
${icon} ${icon}
<span class="text-gray-900 dark:text-white font-medium">${escapeHtml(file.name)}</span> <span class="text-gray-900 dark:text-white font-medium">${escapeHtml(file.name)}</span>
</div> </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> </div>
`; `;
} else { } 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) { async function downloadToServer(unitId, filePath, fileName) {
try { try {
const response = await fetch(`/api/slmm/${unitId}/ftp/download`, { const response = await fetch(`/api/slmm/${unitId}/ftp/download`, {