Update main to 0.5.1. See changelog. #18

Merged
serversdown merged 16 commits from dev into main 2026-01-27 22:29:57 -05:00
3 changed files with 593 additions and 198 deletions
Showing only changes of commit ff38b74548 - Show all commits

View File

@@ -185,49 +185,90 @@ async function loadFTPFiles(unitId, path) {
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}); });
// Render file list function escapeForAttribute(str) {
let html = ''; return String(str).replace(/'/g, "\\'").replace(/"/g, '"');
for (const file of sorted) {
const icon = file.is_dir
? '<svg class="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"></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);
html += `
<div class="flex items-center gap-3 px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 rounded">
${icon}
<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-xs text-gray-500 dark:text-gray-400">${file.modified}${sizeStr ? ' • ' + sizeStr : ''}</div>
</div>
${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-seismo-orange text-white rounded hover:bg-seismo-navy transition-colors"
title="Download folder from device to server and add to project 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="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 class="flex items-center gap-2">
<button onclick="downloadToServer('${unitId}', '${file.path}', '${file.name}')"
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded hover:bg-seismo-navy transition-colors"
title="Download file from device to server and add to project 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="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>
`;
} }
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; fileListDiv.innerHTML = html;
} catch (error) { } catch (error) {
fileListDiv.innerHTML = '<div class="text-center py-4 text-red-500">Error loading files: ' + error + '</div>'; fileListDiv.innerHTML = '<div class="text-center py-4 text-red-500">Error loading files: ' + error + '</div>';
@@ -235,6 +276,156 @@ async function loadFTPFiles(unitId, path) {
} }
} }
// 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) { async function downloadFTPFile(unitId, remotePath, fileName) {
const btn = event.target; const btn = event.target;
btn.disabled = true; btn.disabled = true;

View File

@@ -1,4 +1,4 @@
<!-- Unified Files View - Database + Filesystem --> <!-- Unified Files View - Database + Filesystem - Collapsible Sessions -->
{% if sessions %} {% if sessions %}
<div class="divide-y divide-gray-200 dark:divide-gray-700"> <div class="divide-y divide-gray-200 dark:divide-gray-700">
{% for session_data in sessions %} {% for session_data in sessions %}
@@ -8,146 +8,154 @@
{% set files = session_data.files %} {% set files = session_data.files %}
{% if files %} {% if files %}
<!-- Session Header --> <!-- Session Container -->
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-900/50"> <div class="session-container">
<div class="flex items-center justify-between"> <!-- Session Header - Clickable to expand/collapse -->
<div class="flex items-center gap-3"> <div class="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors"
<svg class="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20"> onclick="toggleSession('session-{{ session.id }}', this)">
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"></path> <div class="flex items-center justify-between">
</svg> <div class="flex items-center gap-3">
<div> <!-- Chevron indicator -->
<div class="font-semibold text-gray-900 dark:text-white"> <svg class="w-4 h-4 text-gray-400 transition-transform session-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{{ session.started_at.strftime('%Y-%m-%d %H:%M') if session.started_at else 'Unknown Date' }} <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</div> </svg>
<div class="text-xs text-gray-500 dark:text-gray-400"> <svg class="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
{% if unit %}{{ unit.id }}{% else %}Unknown Unit{% endif %} <path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"></path>
{% if location %} @ {{ location.name }}{% endif %} </svg>
<span class="mx-2"></span> <div>
{{ files|length }} file{{ 's' if files|length != 1 else '' }} <div class="font-semibold text-gray-900 dark:text-white">
{{ session.started_at.strftime('%Y-%m-%d %H:%M') if session.started_at else 'Unknown Date' }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{% if unit %}{{ unit.id }}{% else %}Unknown Unit{% endif %}
{% if location %} @ {{ location.name }}{% endif %}
<span class="mx-2"></span>
{{ files|length }} file{{ 's' if files|length != 1 else '' }}
</div>
</div> </div>
</div> </div>
</div> <div class="flex items-center gap-2">
<div class="flex items-center gap-2"> <span class="px-2 py-1 text-xs rounded-full
<span class="px-2 py-1 text-xs rounded-full {% if session.status == 'recording' %}bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
{% if session.status == 'recording' %}bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 {% elif session.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300
{% elif session.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 {% elif session.status == 'paused' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300
{% elif session.status == 'paused' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 {% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}"> {{ session.status or 'unknown' }}
{{ session.status or 'unknown' }} </span>
</span> </div>
</div> </div>
</div> </div>
</div>
<!-- Files List --> <!-- Files List - Hidden by default -->
<div class="px-6 py-2"> <div id="session-{{ session.id }}" class="hidden px-6 py-2 border-t border-gray-100 dark:border-gray-700">
<div class="space-y-1"> <div class="space-y-1">
{% for file_data in files %} {% for file_data in files %}
{% set file = file_data.file %} {% set file = file_data.file %}
{% set exists = file_data.exists_on_disk %} {% set exists = file_data.exists_on_disk %}
{% set metadata = file_data.metadata %} {% set metadata = file_data.metadata %}
<div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-lg transition-colors group"> <div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-lg transition-colors group">
<!-- File Icon --> <!-- File Icon -->
{% if file.file_type == 'audio' %} {% if file.file_type == 'audio' %}
<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
</svg> </svg>
{% elif file.file_type == 'archive' %} {% elif file.file_type == 'archive' %}
<svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg> </svg>
{% elif file.file_type == 'log' %} {% elif file.file_type == 'log' %}
<svg class="w-6 h-6 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-gray-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> <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> </svg>
{% elif file.file_type == 'image' %} {% elif file.file_type == 'image' %}
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg> </svg>
{% else %} {% else %}
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 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> <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> </svg>
{% endif %} {% endif %}
<!-- File Info --> <!-- File Info -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="font-medium text-gray-900 dark:text-white truncate"> <div class="font-medium text-gray-900 dark:text-white truncate">
{{ file.file_path.split('/')[-1] if file.file_path else 'Unknown' }} {{ file.file_path.split('/')[-1] if file.file_path else 'Unknown' }}
</div> </div>
{% if not exists %} {% if not exists %}
<span class="px-2 py-0.5 text-xs bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded"> <span class="px-2 py-0.5 text-xs bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded">
Missing on disk Missing on disk
</span> </span>
{% endif %}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
<!-- File Type Badge -->
<span class="px-1.5 py-0.5 rounded font-medium
{% if file.file_type == 'audio' %}bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300
{% elif file.file_type == 'data' %}bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300
{% elif file.file_type == 'log' %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300
{% elif file.file_type == 'archive' %}bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300
{% elif file.file_type == 'image' %}bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300
{% else %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300{% endif %}">
{{ file.file_type or 'unknown' }}
</span>
<!-- File Size -->
<span class="mx-1"></span>
{% if file.file_size_bytes %}
{% if file.file_size_bytes < 1024 %}
{{ file.file_size_bytes }} B
{% elif file.file_size_bytes < 1048576 %}
{{ "%.1f"|format(file.file_size_bytes / 1024) }} KB
{% elif file.file_size_bytes < 1073741824 %}
{{ "%.1f"|format(file.file_size_bytes / 1048576) }} MB
{% else %}
{{ "%.2f"|format(file.file_size_bytes / 1073741824) }} GB
{% endif %} {% endif %}
{% else %} </div>
Unknown size <div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{% endif %} <!-- File Type Badge -->
<span class="px-1.5 py-0.5 rounded font-medium
{% if file.file_type == 'audio' %}bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300
{% elif file.file_type == 'data' %}bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300
{% elif file.file_type == 'log' %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300
{% elif file.file_type == 'archive' %}bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300
{% elif file.file_type == 'image' %}bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300
{% else %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300{% endif %}">
{{ file.file_type or 'unknown' }}
</span>
<!-- Download Time --> <!-- File Size -->
{% if file.downloaded_at %} <span class="mx-1"></span>
<span class="mx-1"></span> {% if file.file_size_bytes %}
{{ file.downloaded_at.strftime('%Y-%m-%d %H:%M') }} {% if file.file_size_bytes < 1024 %}
{% endif %} {{ file.file_size_bytes }} B
{% elif file.file_size_bytes < 1048576 %}
{{ "%.1f"|format(file.file_size_bytes / 1024) }} KB
{% elif file.file_size_bytes < 1073741824 %}
{{ "%.1f"|format(file.file_size_bytes / 1048576) }} MB
{% else %}
{{ "%.2f"|format(file.file_size_bytes / 1073741824) }} GB
{% endif %}
{% else %}
Unknown size
{% endif %}
<!-- Source Info from Metadata --> <!-- Download Time -->
{% if metadata.unit_id %} {% if file.downloaded_at %}
<span class="mx-1"></span> <span class="mx-1"></span>
from {{ metadata.unit_id }} {{ file.downloaded_at.strftime('%Y-%m-%d %H:%M') }}
{% endif %} {% endif %}
<!-- Checksum Indicator --> <!-- Source Info from Metadata -->
{% if file.checksum %} {% if metadata.unit_id %}
<span class="mx-1" title="SHA256: {{ file.checksum[:16] }}..."> <span class="mx-1"></span>
<svg class="w-3 h-3 inline text-green-600" fill="currentColor" viewBox="0 0 20 20"> from {{ metadata.unit_id }}
<path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path> {% endif %}
</svg>
</span> <!-- Checksum Indicator -->
{% endif %} {% if file.checksum %}
<span class="mx-1" title="SHA256: {{ file.checksum[:16] }}...">
<svg class="w-3 h-3 inline text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
</span>
{% endif %}
</div>
</div> </div>
</div>
<!-- Download Button --> <!-- Download Button -->
{% if exists %} {% if exists %}
<div class="opacity-0 group-hover:opacity-100 transition-opacity"> <div class="opacity-0 group-hover:opacity-100 transition-opacity">
<button onclick="downloadFile('{{ file.id }}')" <button onclick="event.stopPropagation(); downloadFile('{{ file.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">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 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> <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> </svg>
Download Download
</button> </button>
</div>
{% endif %}
</div> </div>
{% endif %} {% endfor %}
</div> </div>
{% endfor %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -167,6 +175,25 @@
{% endif %} {% endif %}
<script> <script>
function toggleSession(sessionId, headerElement) {
const contentDiv = document.getElementById(sessionId);
const chevron = headerElement.querySelector('.session-chevron');
if (!contentDiv) return;
const isExpanded = !contentDiv.classList.contains('hidden');
if (isExpanded) {
// Collapse
contentDiv.classList.add('hidden');
chevron.style.transform = 'rotate(0deg)';
} else {
// Expand
contentDiv.classList.remove('hidden');
chevron.style.transform = 'rotate(90deg)';
}
}
function downloadFile(fileId) { function downloadFile(fileId) {
window.location.href = `/api/projects/{{ project_id }}/files/${fileId}/download`; window.location.href = `/api/projects/{{ project_id }}/files/${fileId}/download`;
} }

View File

@@ -1546,44 +1546,62 @@ async function loadFTPFiles(unitId, path) {
`; `;
} }
// Generate unique ID for this folder listing
const listingId = 'ftp-listing-' + Date.now();
function escapeForAttribute(str) {
return String(str).replace(/'/g, "\\'").replace(/"/g, '&quot;');
}
function getFileIcon(file) {
if (file.is_dir || file.type === 'directory') {
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>';
}
// Add files and directories // Add files and directories
files.forEach(file => { files.forEach(file => {
const fullPath = file.path || (path === '/' ? `/${file.name}` : `${path}/${file.name}`); const fullPath = file.path || (path === '/' ? `/${file.name}` : `${path}/${file.name}`);
const isDir = file.is_dir || file.type === 'directory'; const isDir = file.is_dir || file.type === 'directory';
const icon = getFileIcon(file);
// Determine file type icon and color
let icon, iconColor = 'text-gray-400';
if (isDir) {
icon = '<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')) {
icon = '<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)$/)) {
icon = '<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>';
} else {
icon = '<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>';
}
const sizeText = file.size ? formatFileSize(file.size) : ''; const sizeText = file.size ? formatFileSize(file.size) : '';
const dateText = file.modified || file.modified_time || ''; const dateText = file.modified || file.modified_time || '';
const canPreview = !isDir && (file.name.toLowerCase().match(/\.(csv|txt|log)$/)); const canPreview = !isDir && (file.name.toLowerCase().match(/\.(csv|txt|log)$/));
const folderId = 'folder-' + fullPath.replace(/[^a-zA-Z0-9]/g, '-');
if (isDir) { if (isDir) {
// Collapsible folder row
html += ` html += `
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors group"> <div class="border border-gray-200 dark:border-gray-600 rounded mb-1">
<div class="flex items-center flex-1 cursor-pointer" 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 cursor-pointer"
${icon} onclick="toggleFTPFolder('${unitId}', '${escapeForAttribute(fullPath)}', '${folderId}', this)">
<span class="text-gray-900 dark:text-white font-medium">${escapeHtml(file.name)}</span> <div class="flex items-center flex-1">
</div> <svg class="w-4 h-4 mr-2 text-gray-400 transition-transform folder-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="flex items-center gap-3 flex-shrink-0 ml-4"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
<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> </svg>
<span class="hidden lg:inline">Download ZIP</span> ${icon}
</button> <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-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 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>
</div> </div>
`; `;
@@ -1621,10 +1639,6 @@ async function loadFTPFiles(unitId, path) {
} }
}); });
function escapeForAttribute(str) {
return String(str).replace(/'/g, "\\'").replace(/"/g, '&quot;');
}
html += '</div>'; html += '</div>';
container.innerHTML = html; container.innerHTML = html;
@@ -1636,6 +1650,169 @@ async function loadFTPFiles(unitId, path) {
} }
} }
// Toggle folder expand/collapse and load contents
async function toggleFTPFolder(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') {
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.type === 'directory' && b.type !== 'directory') return -1;
if (a.type !== 'directory' && b.type === 'directory') return 1;
return a.name.localeCompare(b.name);
});
// Update status with file count
const dirCount = files.filter(f => f.type === 'directory' || 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 || file.type === 'directory') {
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 isDir = file.is_dir || file.type === 'directory';
const icon = getFileIcon(file);
const sizeText = file.size ? formatFileSize(file.size) : '';
const dateText = file.modified || file.modified_time || '';
const canPreview = !isDir && (file.name.toLowerCase().match(/\.(csv|txt|log)$/));
const subFolderId = 'folder-' + fullPath.replace(/[^a-zA-Z0-9]/g, '-');
if (isDir) {
// 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="toggleFTPFolder('${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(); downloadFTPFolderModal('${unitId}', '${escapeForAttribute(fullPath)}', '${escapeForAttribute(file.name)}', this)"
class="px-2 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded transition-colors flex items-center"
title="Download entire folder as ZIP">
<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>
${canPreview ? `
<button onclick="previewFile('${unitId}', '${escapeForAttribute(fullPath)}', '${escapeForAttribute(file.name)}')"
class="px-2 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded transition-colors"
title="Preview file">
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
</button>
` : ''}
<button onclick="downloadFTPFile('${unitId}', '${escapeForAttribute(fullPath)}', '${escapeForAttribute(file.name)}')"
class="px-2 py-1 bg-seismo-orange hover:bg-orange-600 text-white text-xs rounded transition-colors"
title="Download to your computer">
<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, filePath, fileName) { async function downloadFTPFile(unitId, filePath, fileName) {
try { try {
// Show download indicator // Show download indicator