- Implemented a new API router for managing report templates, including endpoints for listing, creating, retrieving, updating, and deleting templates. - Added a new HTML partial for a unified SLM settings modal, allowing users to configure SLM settings with dynamic modem selection and FTP credentials. - Created a report preview page with an editable data table using jspreadsheet, enabling users to modify report details and download the report as an Excel file.
621 lines
33 KiB
HTML
621 lines
33 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
|
|
alert(`✓ Folder "${folderName}" downloaded successfully!\n\n${data.file_count} files extracted\nTotal size: ${formatFileSize(data.total_size)}\n\nFiles are now available 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
|
|
alert(`✓ ${fileName} downloaded to server successfully!\n\nFile ID: ${data.file_id}\nSize: ${formatFileSize(data.file_size)}`);
|
|
|
|
// 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';
|
|
}
|
|
|
|
// 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' %}
|