7716a4b51d
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>
639 lines
34 KiB
HTML
639 lines
34 KiB
HTML
<!-- 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, '"');
|
||
}
|
||
|
||
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, '"');
|
||
}
|
||
|
||
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' %}
|