2 Commits

Author SHA1 Message Date
serversdwn
ff38b74548 feat: added collapsible view in project data files. 2026-01-19 21:31:22 +00:00
serversdwn
c8a030a3ba fixed project view title appearing as JSON string 2026-01-18 07:48:10 +00:00
5 changed files with 618 additions and 207 deletions

View File

@@ -347,11 +347,15 @@ async def get_project_dashboard(
# Project Types # Project Types
# ============================================================================ # ============================================================================
@router.get("/{project_id}/header", response_class=JSONResponse) @router.get("/{project_id}/header", response_class=HTMLResponse)
async def get_project_header(project_id: str, db: Session = Depends(get_db)): async def get_project_header(
project_id: str,
request: Request,
db: Session = Depends(get_db)
):
""" """
Get project header information for dynamic display. Get project header information for dynamic display.
Returns JSON with project name, status, and type. Returns HTML partial with project name, status, and type.
""" """
project = db.query(Project).filter_by(id=project_id).first() project = db.query(Project).filter_by(id=project_id).first()
if not project: if not project:
@@ -359,12 +363,10 @@ async def get_project_header(project_id: str, db: Session = Depends(get_db)):
project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first() project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first()
return JSONResponse({ return templates.TemplateResponse("partials/projects/project_header.html", {
"id": project.id, "request": request,
"name": project.name, "project": project,
"status": project.status, "project_type": project_type,
"project_type_id": project.project_type_id,
"project_type_name": project_type.name if project_type else None,
}) })

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

@@ -0,0 +1,14 @@
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ project.name }}</h1>
<div class="flex items-center gap-4">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
{% if project.status == 'active' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
{% elif project.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
{{ project.status|title }}
</span>
{% if project_type %}
<span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span>
{% endif %}
</div>
</div>

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