Files
terra-view/templates/partials/projects/ftp_browser.html
T
serversdown 7716a4b51d feat(reports): manual FTP "Download & Save" saves a parsed session
ftp-download-folder-to-server and ftp-download-to-server now route NRL data through the shared ingest (ingest_nrl_zip / _ingest_file_entries) instead of hand-rolling DataFile rows on a now/zero-duration session. Folder save requires the unit be assigned to a location; non-NRL single files keep the generic save path. The FTP browser popup now reports how long the measurement ran.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:12:15 +00:00

639 lines
34 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 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">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Download Files from SLMs</h2>
{% if units %}
<div class="space-y-6">
{% for unit_item in units %}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<!-- Unit Header -->
<div class="bg-gray-50 dark:bg-gray-900 px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ unit_item.unit.id }}
</h3>
{% if unit_item.location %}
<span class="text-xs text-gray-500 dark:text-gray-400">
@ {{ unit_item.location.name }}
</span>
{% endif %}
<span id="ftp-status-{{ unit_item.unit.id }}" class="px-2 py-1 text-xs font-medium rounded-full bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
Checking...
</span>
</div>
<div class="flex items-center gap-2">
<button onclick="showFTPSettings('{{ unit_item.unit.id }}')"
id="settings-ftp-{{ unit_item.unit.id }}"
class="px-3 py-1 text-xs bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-1"
title="Configure FTP credentials">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
Settings
</button>
<button onclick="enableFTP('{{ unit_item.unit.id }}')"
id="enable-ftp-{{ unit_item.unit.id }}"
class="px-3 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
disabled>
Enable FTP
</button>
<button onclick="disableFTP('{{ unit_item.unit.id }}')"
id="disable-ftp-{{ unit_item.unit.id }}"
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
disabled>
Disable FTP
</button>
<button onclick="loadFTPFiles('{{ unit_item.unit.id }}', '/NL-43')"
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"
disabled>
Browse Files
</button>
</div>
</div>
<!-- FTP File List -->
<div id="ftp-files-{{ unit_item.unit.id }}" class="hidden" data-location-id="{{ unit_item.location.id if unit_item.location else '' }}">
<div class="p-4">
<div class="flex items-center gap-2 mb-3">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>
<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 }}', '/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">
<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>
</svg>
</button>
</div>
<div id="ftp-file-list-{{ unit_item.unit.id }}" class="space-y-1">
<!-- Files will be loaded here -->
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
</svg>
<p>No units assigned to this project</p>
</div>
{% endif %}
</div>
<script>
async function checkFTPStatus(unitId) {
const statusSpan = document.getElementById(`ftp-status-${unitId}`);
const enableBtn = document.getElementById(`enable-ftp-${unitId}`);
const disableBtn = document.getElementById(`disable-ftp-${unitId}`);
const browseBtn = document.getElementById(`browse-ftp-${unitId}`);
try {
const response = await fetch(`/api/slmm/${unitId}/ftp/status`);
const data = await response.json();
if (data.ftp_enabled) {
statusSpan.textContent = 'FTP Enabled';
statusSpan.className = 'px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
enableBtn.disabled = true;
disableBtn.disabled = false;
browseBtn.disabled = false;
} else {
statusSpan.textContent = 'FTP Disabled';
statusSpan.className = 'px-2 py-1 text-xs font-medium rounded-full bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
enableBtn.disabled = false;
disableBtn.disabled = true;
browseBtn.disabled = true;
}
} catch (error) {
statusSpan.textContent = 'Error';
statusSpan.className = 'px-2 py-1 text-xs font-medium rounded-full bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
console.error('Error checking FTP status:', error);
}
}
async function enableFTP(unitId) {
const enableBtn = document.getElementById(`enable-ftp-${unitId}`);
enableBtn.disabled = true;
enableBtn.textContent = 'Enabling...';
try {
const response = await fetch(`/api/slmm/${unitId}/ftp/enable`, {
method: 'POST'
});
if (response.ok) {
await checkFTPStatus(unitId);
// Auto-load files after enabling
setTimeout(() => loadFTPFiles(unitId, '/NL-43'), 1000);
} else {
alert('Failed to enable FTP');
}
} catch (error) {
alert('Error enabling FTP: ' + error);
} finally {
enableBtn.textContent = 'Enable FTP';
enableBtn.disabled = false;
}
}
async function disableFTP(unitId) {
if (!confirm('Disable FTP on this unit? This will close the FTP connection.')) return;
const disableBtn = document.getElementById(`disable-ftp-${unitId}`);
disableBtn.disabled = true;
disableBtn.textContent = 'Disabling...';
try {
const response = await fetch(`/api/slmm/${unitId}/ftp/disable`, {
method: 'POST'
});
if (response.ok) {
await checkFTPStatus(unitId);
// Hide file list
document.getElementById(`ftp-files-${unitId}`).classList.add('hidden');
} else {
alert('Failed to disable FTP');
}
} catch (error) {
alert('Error disabling FTP: ' + error);
} finally {
disableBtn.textContent = 'Disable FTP';
disableBtn.disabled = false;
}
}
async function loadFTPFiles(unitId, path) {
const fileListDiv = document.getElementById(`ftp-file-list-${unitId}`);
const filesContainer = document.getElementById(`ftp-files-${unitId}`);
const currentPathSpan = document.getElementById(`current-path-${unitId}`);
// Show loading
fileListDiv.innerHTML = '<div class="text-center py-4 text-gray-500">Loading files...</div>';
filesContainer.classList.remove('hidden');
currentPathSpan.textContent = path;
try {
const response = await fetch(`/api/slmm/${unitId}/ftp/files?path=${encodeURIComponent(path)}`);
const data = await response.json();
if (!data.files || data.files.length === 0) {
fileListDiv.innerHTML = '<div class="text-center py-4 text-gray-500">No files found</div>';
return;
}
// Sort: directories first, then files
const sorted = data.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);
});
function escapeForAttribute(str) {
return String(str).replace(/'/g, "\\'").replace(/"/g, '&quot;');
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function getFileIcon(file) {
if (file.is_dir) {
return '<svg class="w-5 h-5 mr-3 text-blue-500" 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></svg>';
} else if (file.name.toLowerCase().endsWith('.csv')) {
return '<svg class="w-5 h-5 mr-3 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>';
} else if (file.name.toLowerCase().match(/\.(txt|log)$/)) {
return '<svg class="w-5 h-5 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>';
}
return '<svg class="w-5 h-5 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg>';
}
// Render file list with collapsible folders
let html = '<div class="space-y-1">';
for (const file of sorted) {
const icon = getFileIcon(file);
const sizeStr = file.is_dir ? '' : formatFileSize(file.size);
const folderId = 'proj-folder-' + file.path.replace(/[^a-zA-Z0-9]/g, '-');
if (file.is_dir) {
// Collapsible folder row
html += `
<div class="border border-gray-200 dark:border-gray-600 rounded mb-1">
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors cursor-pointer"
onclick="toggleFTPFolderProject('${unitId}', '${escapeForAttribute(file.path)}', '${folderId}', this)">
<div class="flex items-center flex-1">
<svg class="w-4 h-4 mr-2 text-gray-400 transition-transform folder-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
${icon}
<span class="text-gray-900 dark:text-white font-medium">${escapeHtml(file.name)}</span>
<span class="ml-2 text-xs text-gray-400 folder-status"></span>
</div>
<div class="flex items-center gap-2 flex-shrink-0 ml-4">
<span class="text-xs text-gray-500 hidden sm:inline">${file.modified || ''}</span>
<button onclick="event.stopPropagation(); downloadFolderToServer('${unitId}', '${escapeForAttribute(file.path)}', '${escapeForAttribute(file.name)}')"
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded hover:bg-seismo-navy transition-colors flex items-center"
title="Download folder from device to server and add to project database">
<svg class="w-3 h-3 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>
Download & Save
</button>
</div>
</div>
<div id="${folderId}" class="hidden pl-6 pr-2 pb-2 border-t border-gray-100 dark:border-gray-700">
<div class="text-sm text-gray-500 py-2">Click to load contents...</div>
</div>
</div>
`;
} else {
html += `
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors">
<div class="flex items-center flex-1 min-w-0">
${icon}
<span class="text-gray-900 dark:text-white truncate">${escapeHtml(file.name)}</span>
</div>
<div class="flex items-center gap-3 flex-shrink-0 ml-4">
<span class="text-xs text-gray-500 hidden sm:inline">${sizeStr}</span>
<span class="text-xs text-gray-500 hidden md:inline">${file.modified || ''}</span>
<button onclick="downloadToServer('${unitId}', '${escapeForAttribute(file.path)}', '${escapeForAttribute(file.name)}')"
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded hover:bg-seismo-navy transition-colors flex items-center"
title="Download file from device to server and add to project database">
<svg class="w-3 h-3 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>
Download & Save
</button>
</div>
</div>
`;
}
}
html += '</div>';
fileListDiv.innerHTML = html;
} catch (error) {
fileListDiv.innerHTML = '<div class="text-center py-4 text-red-500">Error loading files: ' + error + '</div>';
console.error('Error loading FTP files:', error);
}
}
// Toggle folder expand/collapse and load contents for Project FTP browser
async function toggleFTPFolderProject(unitId, folderPath, folderId, headerElement) {
const contentDiv = document.getElementById(folderId);
const chevron = headerElement.querySelector('.folder-chevron');
const statusSpan = headerElement.querySelector('.folder-status');
if (!contentDiv) return;
const isExpanded = !contentDiv.classList.contains('hidden');
if (isExpanded) {
// Collapse
contentDiv.classList.add('hidden');
chevron.style.transform = 'rotate(0deg)';
} else {
// Expand and load contents if not already loaded
contentDiv.classList.remove('hidden');
chevron.style.transform = 'rotate(90deg)';
// Check if already loaded
if (contentDiv.dataset.loaded === 'true') {
return;
}
// Show loading state
contentDiv.innerHTML = '<div class="text-sm text-gray-500 py-2 flex items-center"><svg class="w-4 h-4 mr-2 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>Loading...</div>';
try {
const response = await fetch(`/api/slmm/${unitId}/ftp/files?path=${encodeURIComponent(folderPath)}`);
const result = await response.json();
if (result.status !== 'ok' && !result.files) {
throw new Error(result.detail || 'Failed to list files');
}
const files = result.files || [];
if (files.length === 0) {
contentDiv.innerHTML = '<div class="text-sm text-gray-500 py-2 italic">Empty folder</div>';
statusSpan.textContent = '(empty)';
contentDiv.dataset.loaded = 'true';
return;
}
// Sort: directories first, then by name
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);
});
// Update status with file count
const dirCount = files.filter(f => f.is_dir).length;
const fileCount = files.length - dirCount;
let statusText = [];
if (dirCount > 0) statusText.push(`${dirCount} folder${dirCount > 1 ? 's' : ''}`);
if (fileCount > 0) statusText.push(`${fileCount} file${fileCount > 1 ? 's' : ''}`);
statusSpan.textContent = `(${statusText.join(', ')})`;
function escapeForAttribute(str) {
return String(str).replace(/'/g, "\\'").replace(/"/g, '&quot;');
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function getFileIcon(file) {
if (file.is_dir) {
return '<svg class="w-4 h-4 mr-2 text-blue-500" 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></svg>';
} else if (file.name.toLowerCase().endsWith('.csv')) {
return '<svg class="w-4 h-4 mr-2 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>';
} else if (file.name.toLowerCase().match(/\.(txt|log)$/)) {
return '<svg class="w-4 h-4 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>';
}
return '<svg class="w-4 h-4 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg>';
}
let html = '<div class="space-y-1 mt-2">';
files.forEach(file => {
const fullPath = file.path || `${folderPath}/${file.name}`;
const icon = getFileIcon(file);
const sizeText = file.size ? formatFileSize(file.size) : '';
const subFolderId = 'proj-folder-' + fullPath.replace(/[^a-zA-Z0-9]/g, '-');
if (file.is_dir) {
// Nested collapsible folder
html += `
<div class="border border-gray-200 dark:border-gray-600 rounded">
<div class="flex items-center justify-between p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors cursor-pointer text-sm"
onclick="toggleFTPFolderProject('${unitId}', '${escapeForAttribute(fullPath)}', '${subFolderId}', this)">
<div class="flex items-center flex-1">
<svg class="w-3 h-3 mr-2 text-gray-400 transition-transform folder-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
${icon}
<span class="text-gray-900 dark:text-white font-medium">${escapeHtml(file.name)}</span>
<span class="ml-2 text-xs text-gray-400 folder-status"></span>
</div>
<div class="flex items-center gap-2 flex-shrink-0 ml-2">
<button onclick="event.stopPropagation(); downloadFolderToServer('${unitId}', '${escapeForAttribute(fullPath)}', '${escapeForAttribute(file.name)}')"
class="px-2 py-1 bg-seismo-orange hover:bg-seismo-navy text-white text-xs rounded transition-colors flex items-center"
title="Download folder to server">
<svg class="w-3 h-3" 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>
</button>
</div>
</div>
<div id="${subFolderId}" class="hidden pl-4 pr-2 pb-2 border-t border-gray-100 dark:border-gray-700">
<div class="text-sm text-gray-500 py-2">Click to load contents...</div>
</div>
</div>
`;
} else {
html += `
<div class="flex items-center justify-between p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors text-sm">
<div class="flex items-center flex-1 min-w-0">
${icon}
<span class="text-gray-900 dark:text-white truncate">${escapeHtml(file.name)}</span>
</div>
<div class="flex items-center gap-2 flex-shrink-0 ml-2">
<span class="text-xs text-gray-500 hidden sm:inline">${sizeText}</span>
<button onclick="downloadToServer('${unitId}', '${escapeForAttribute(fullPath)}', '${escapeForAttribute(file.name)}')"
class="px-2 py-1 bg-seismo-orange hover:bg-seismo-navy text-white text-xs rounded transition-colors"
title="Download to server">
<svg class="w-3 h-3" 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>
</button>
</div>
</div>
`;
}
});
html += '</div>';
contentDiv.innerHTML = html;
contentDiv.dataset.loaded = 'true';
} catch (error) {
console.error('Failed to load folder contents:', error);
contentDiv.innerHTML = `<div class="text-sm text-red-500 py-2">Error: ${error.message}</div>`;
}
}
}
async function downloadFTPFile(unitId, remotePath, fileName) {
const btn = event.target;
btn.disabled = true;
btn.textContent = 'Downloading...';
try {
const response = await fetch(`/api/slmm/${unitId}/ftp/download`, {
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 = fileName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} else {
const errorData = await response.json();
alert('Download failed: ' + (errorData.detail || 'Unknown error'));
}
} catch (error) {
alert('Error downloading file: ' + error);
} finally {
btn.disabled = false;
btn.textContent = 'Download';
}
}
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 — surface how long the measurement ran
alert(`✓ Folder "${folderName}" saved!\n\n` +
(data.message || `${data.file_count} file(s) imported`) +
formatRunLength(data) +
`\n\nNow saved as a session in the Project Files section below.`);
// Refresh the unified files list
htmx.trigger('#unified-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) {
const btn = event.target;
const originalText = 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"><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>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-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
const sizeLine = `\nSize: ${formatFileSize(data.file_size)}`;
const msg = data.ingested
? `${fileName} imported as measurement data!` + formatRunLength(data) + sizeLine
: `${fileName} downloaded to server successfully!\n\nFile ID: ${data.file_id}` + sizeLine;
alert(msg);
// Refresh the unified files list
htmx.trigger('#unified-files', 'refresh');
} else {
alert('Download to server failed: ' + (data.detail || 'Unknown error'));
}
} catch (error) {
alert('Error downloading to server: ' + error);
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
}
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(2) + ' GB';
}
// Build a "how long did it run" line from an ingest response. Duration is
// timezone-independent (stop start), so it's the reliable number to show.
function formatRunLength(data) {
if (data.duration_seconds == null) return '';
const s = data.duration_seconds;
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
let txt = h > 0 ? `${h}h ${m}m` : `${m}m`;
return `\n\nRecorded for: ${txt}`;
}
// Check FTP status for all units on load
// Use setTimeout to ensure DOM elements exist when HTMX loads this partial
setTimeout(function() {
{% for unit_item in units %}
checkFTPStatus('{{ unit_item.unit.id }}');
{% endfor %}
}, 100);
</script>
<!-- Include the unified SLM Settings Modal -->
{% include 'partials/slm_settings_modal.html' %}