Files
seaweedFS/weed/admin/view/app/file_browser.templ
Chris Lu e6ee293c17 Add table operations test (#8241)
* Add Trino blog operations test

* Update test/s3tables/catalog_trino/trino_blog_operations_test.go

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* feat: add table bucket path helpers and filer operations

- Add table object root and table location mapping directories
- Implement ensureDirectory, upsertFile, deleteEntryIfExists helpers
- Support table location bucket mapping for S3 access

* feat: manage table bucket object roots on creation/deletion

- Create .objects directory for table buckets on creation
- Clean up table object bucket paths on deletion
- Enable S3 operations on table bucket object roots

* feat: add table location mapping for Iceberg REST

- Track table location bucket mappings when tables are created/updated/deleted
- Enable location-based routing for S3 operations on table data

* feat: route S3 operations to table bucket object roots

- Route table-s3 bucket names to mapped table paths
- Route table buckets to object root directories
- Support table location bucket mapping lookup

* feat: emit table-s3 locations from Iceberg REST

- Generate unique table-s3 bucket names with UUID suffix
- Store table metadata under table bucket paths
- Return table-s3 locations for Trino compatibility

* fix: handle missing directories in S3 list operations

- Propagate ErrNotFound from ListEntries for non-existent directories
- Treat missing directories as empty results for list operations
- Fixes Trino non-empty location checks on table creation

* test: improve Trino CSV parsing for single-value results

- Sanitize Trino output to skip jline warnings
- Handle single-value CSV results without header rows
- Strip quotes from numeric values in tests

* refactor: use bucket path helpers throughout S3 API

- Replace direct bucket path operations with helper functions
- Leverage centralized table bucket routing logic
- Improve maintainability with consistent path resolution

* fix: add table bucket cache and improve filer error handling

- Cache table bucket lookups to reduce filer overhead on repeated checks
- Use filer_pb.CreateEntry and filer_pb.UpdateEntry helpers to check resp.Error
- Fix delete order in handler_bucket_get_list_delete: delete table object before directory
- Make location mapping errors best-effort: log and continue, don't fail API
- Update table location mappings to delete stale prior bucket mappings on update
- Add 1-second sleep before timestamp time travel query to ensure timestamps are in past
- Fix CSV parsing: examine all lines, not skip first; handle single-value rows

* fix: properly handle stale metadata location mapping cleanup

- Capture oldMetadataLocation before mutation in handleUpdateTable
- Update updateTableLocationMapping to accept both old and new locations
- Use passed-in oldMetadataLocation to detect location changes
- Delete stale mapping only when location actually changes
- Pass empty string for oldLocation in handleCreateTable (new tables have no prior mapping)
- Improve logging to show old -> new location transitions

* refactor: cleanup imports and cache design

- Remove unused 'sync' import from bucket_paths.go
- Use filer_pb.UpdateEntry helper in setExtendedAttribute and deleteExtendedAttribute for consistent error handling
- Add dedicated tableBucketCache map[string]bool to BucketRegistry instead of mixing concerns with metadataCache
- Improve cache separation: table buckets cache is now separate from bucket metadata cache

* fix: improve cache invalidation and add transient error handling

Cache invalidation (critical fix):
- Add tableLocationCache to BucketRegistry for location mapping lookups
- Clear tableBucketCache and tableLocationCache in RemoveBucketMetadata
- Prevents stale cache entries when buckets are deleted/recreated

Transient error handling:
- Only cache table bucket lookups when conclusive (found or ErrNotFound)
- Skip caching on transient errors (network, permission, etc)
- Prevents marking real table buckets as non-table due to transient failures

Performance optimization:
- Cache tableLocationDir results to avoid repeated filer RPCs on hot paths
- tableLocationDir now checks cache before making expensive filer lookups
- Cache stores empty string for 'not found' to avoid redundant lookups

Code clarity:
- Add comment to deleteDirectory explaining DeleteEntry response lacks Error field

* go fmt

* fix: mirror transient error handling in tableLocationDir and optimize bucketDir

Transient error handling:
- tableLocationDir now only caches definitive results
- Mirrors isTableBucket behavior to prevent treating transient errors as permanent misses
- Improves reliability on flaky systems or during recovery

Performance optimization:
- bucketDir avoids redundant isTableBucket call via bucketRoot
- Directly use s3a.option.BucketsPath for regular buckets
- Saves one cache lookup for every non-table bucket operation

* fix: revert bucketDir optimization to preserve bucketRoot logic

The optimization to directly use BucketsPath bypassed bucketRoot's logic
and caused issues with S3 list operations on delimiter+prefix cases.

Revert to using path.Join(s3a.bucketRoot(bucket), bucket) which properly
handles all bucket types and ensures consistent path resolution across
the codebase.

The slight performance cost of an extra cache lookup is worth the correctness
and consistency benefits.

* feat: move table buckets under /buckets

Add a table-bucket marker attribute, reuse bucket metadata cache for table bucket detection, and update list/validation/UI/test paths to treat table buckets as /buckets entries.

* Fix S3 Tables code review issues

- handler_bucket_create.go: Fix bucket existence check to properly validate
  entryResp.Entry before setting s3BucketExists flag (nil Entry should not
  indicate existing bucket)
- bucket_paths.go: Add clarifying comment to bucketRoot() explaining unified
  buckets root path for all bucket types
- file_browser_data.go: Optimize by extracting table bucket check early to
  avoid redundant WithFilerClient call

* Fix list prefix delimiter handling

* Handle list errors conservatively

* Fix Trino FOR TIMESTAMP query - use past timestamp

Iceberg requires the timestamp to be strictly in the past.
Use current_timestamp - interval '1' second instead of current_timestamp.

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-07 13:27:47 -08:00

777 lines
26 KiB
Plaintext

package app
import (
"fmt"
"path/filepath"
"strings"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
)
script changePageSize(path string, lastFileName string) {
window.location.href = '/files?path=' + encodeURIComponent(path) + '&lastFileName=' + encodeURIComponent(lastFileName) + '&limit=' + this.value
}
templ FileBrowser(data dash.FileBrowserData) {
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">
if data.IsTableBucketPath && data.TableBucketName != "" {
<i class="fas fa-table me-2"></i>Table Bucket: {data.TableBucketName}
} else if data.IsBucketPath && data.BucketName != "" {
<i class="fas fa-cube me-2"></i>S3 Bucket: {data.BucketName}
} else {
<i class="fas fa-folder-open me-2"></i>File Browser
}
</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
if data.IsTableBucketPath && data.TableBucketName != "" {
<a href="/object-store/s3tables/buckets" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Back to Table Buckets
</a>
} else if data.IsBucketPath && data.BucketName != "" {
<a href="/object-store/buckets" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Back to Buckets
</a>
}
<button type="button" class="btn btn-sm btn-outline-primary" onclick="createFolder()">
<i class="fas fa-folder-plus me-1"></i>New Folder
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="uploadFile()">
<i class="fas fa-upload me-1"></i>Upload
</button>
<button type="button" class="btn btn-sm btn-outline-danger" id="deleteSelectedBtn" onclick="confirmDeleteSelected()" style="display: none;">
<i class="fas fa-trash me-1"></i>Delete Selected
</button>
<button type="button" class="btn btn-sm btn-outline-info" onclick="exportFileList()">
<i class="fas fa-download me-1"></i>Export
</button>
</div>
</div>
</div>
<!-- Breadcrumb Navigation -->
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
for i, crumb := range data.Breadcrumbs {
if i == len(data.Breadcrumbs)-1 {
<li class="breadcrumb-item active" aria-current="page">
<a href={ templ.SafeURL(fmt.Sprintf("/files?path=%s", crumb.Path)) } class="text-decoration-none">
{ crumb.Name }
</a>
</li>
} else {
<li class="breadcrumb-item">
<a href={ templ.SafeURL(fmt.Sprintf("/files?path=%s", crumb.Path)) } class="text-decoration-none">
if crumb.Name == "Root" {
<i class="fas fa-home me-1"></i>
}
{ crumb.Name }
</a>
</li>
}
}
</ol>
</nav>
<!-- File Listing -->
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex justify-content-between align-items-center flex-wrap">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-folder-open me-2"></i>
if data.CurrentPath == "/" {
<a href="/files?path=/" class="text-decoration-none text-primary">Root Directory</a>
} else if data.CurrentPath == "/buckets" {
<a href="/files?path=/buckets" class="text-decoration-none text-primary">Object Store Buckets Directory</a>
<a href="/object-store/buckets" class="btn btn-sm btn-outline-primary ms-2">
<i class="fas fa-cube me-1"></i>Manage Buckets
</a>
} else {
<a href={ templ.SafeURL(fmt.Sprintf("/files?path=%s", data.CurrentPath)) } class="text-decoration-none text-primary">{ filepath.Base(data.CurrentPath) }</a>
}
</h6>
<!-- Compact pagination controls -->
<div class="d-flex align-items-center gap-2 ms-auto">
<div class="d-flex align-items-center">
<label class="me-1 mb-0 small text-muted">Show:</label>
<select class="form-select form-select-sm" style="width: 70px; font-size: 0.875rem;" onchange={ changePageSize(data.CurrentPath, data.CurrentLastFileName) }>
<option value="20" selected?={ data.PageSize == 20 }>20</option>
<option value="50" selected?={ data.PageSize == 50 }>50</option>
<option value="100" selected?={ data.PageSize == 100 }>100</option>
<option value="200" selected?={ data.PageSize == 200 }>200</option>
</select>
</div>
<div class="btn-group btn-group-sm" role="group">
if data.HasNextPage {
<a href={ templ.SafeURL(fmt.Sprintf("/files?path=%s&lastFileName=%s&limit=%d", data.CurrentPath, data.LastFileName, data.PageSize)) } class="btn btn-outline-primary" title="Next page">
Next <i class="fas fa-angle-right"></i>
</a>
} else {
<button class="btn btn-outline-secondary" disabled title="Next page">
Next <i class="fas fa-angle-right"></i>
</button>
}
if data.ParentPath != data.CurrentPath {
<a href={ templ.SafeURL(fmt.Sprintf("/files?path=%s", data.ParentPath)) } class="btn btn-outline-secondary" title="Go up one directory">
<i class="fas fa-arrow-up"></i> Up
</a>
}
</div>
</div>
</div>
<div class="card-body">
if len(data.Entries) > 0 {
<div class="table-responsive">
<table class="table table-hover" id="fileTable">
<thead>
<tr>
<th width="40px">
<input type="checkbox" id="selectAll" onchange="toggleSelectAll()">
</th>
<th>Name</th>
<th>Size</th>
<th>Type</th>
<th>Modified</th>
<th>Permissions</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
for _, entry := range data.Entries {
<tr>
<td>
<input type="checkbox" class="file-checkbox" value={ entry.FullPath }>
</td>
<td>
<div class="d-flex align-items-center">
if entry.IsDirectory {
<i class="fas fa-folder text-warning me-2"></i>
<a href={ templ.SafeURL(fmt.Sprintf("/files?path=%s", entry.FullPath)) } class="text-decoration-none">
{ entry.Name }
</a>
} else {
<i class={ fmt.Sprintf("fas %s text-muted me-2", getFileIcon(entry.Mime)) }></i>
<span>{ entry.Name }</span>
}
</div>
</td>
<td>
if entry.IsDirectory {
<span class="text-muted">—</span>
} else {
{ formatBytes(entry.Size) }
}
</td>
<td>
<span class="badge bg-light text-dark">
if entry.IsDirectory {
Directory
} else {
{ getMimeDisplayName(entry.Mime) }
}
</span>
</td>
<td>
if !entry.ModTime.IsZero() {
{ entry.ModTime.Format("2006-01-02 15:04") }
} else {
<span class="text-muted">—</span>
}
</td>
<td>
<code class="small permissions-display" data-mode={ entry.Mode } data-is-directory={ fmt.Sprintf("%t", entry.IsDirectory) }>{ entry.Mode }</code>
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
if !entry.IsDirectory {
<button type="button" class="btn btn-outline-primary btn-sm" title="Download" data-action="download" data-path={ entry.FullPath }>
<i class="fas fa-download"></i>
</button>
<button type="button" class="btn btn-outline-info btn-sm" title="View" data-action="view" data-path={ entry.FullPath }>
<i class="fas fa-eye"></i>
</button>
}
<button type="button" class="btn btn-outline-secondary btn-sm" title="Properties" data-action="properties" data-path={ entry.FullPath }>
<i class="fas fa-info-circle"></i>
</button>
<button type="button" class="btn btn-outline-danger btn-sm" title="Delete" data-action="delete" data-path={ entry.FullPath }>
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
} else {
<div class="text-center py-5">
<i class="fas fa-folder-open fa-3x text-muted mb-3"></i>
<h5 class="text-muted">Empty Directory</h5>
<p class="text-muted">This directory contains no files or subdirectories.</p>
</div>
}
</div>
</div>
<!-- Last Updated -->
<!-- Pagination Controls (Bottom) -->
<div class="row mb-3">
<div class="col-md-6">
<div class="d-flex align-items-center">
<label class="me-2 mb-0">Show:</label>
<select class="form-select form-select-sm" style="width: auto;" onchange={ changePageSize(data.CurrentPath, data.CurrentLastFileName) }>
<option value="20" selected?={ data.PageSize == 20 }>20</option>
<option value="50" selected?={ data.PageSize == 50 }>50</option>
<option value="100" selected?={ data.PageSize == 100 }>100</option>
<option value="200" selected?={ data.PageSize == 200 }>200</option>
</select>
<span class="ms-2 text-muted">entries per page</span>
</div>
</div>
<div class="col-md-6 text-end">
<div class="btn-group btn-group-sm" role="group">
if data.HasNextPage {
<a href={ templ.SafeURL(fmt.Sprintf("/files?path=%s&lastFileName=%s&limit=%d", data.CurrentPath, data.LastFileName, data.PageSize)) } class="btn btn-outline-primary">
Next <i class="fas fa-angle-right ms-1"></i>
</a>
} else {
<button class="btn btn-outline-secondary" disabled>
Next <i class="fas fa-angle-right ms-1"></i>
</button>
}
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<small class="text-muted">
<i class="fas fa-clock me-1"></i>
Last updated: { data.LastUpdated.Format("2006-01-02 15:04:05") }
</small>
</div>
</div>
<!-- Create Folder Modal -->
<div class="modal fade" id="createFolderModal" tabindex="-1" aria-labelledby="createFolderModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createFolderModalLabel">
<i class="fas fa-folder-plus me-2"></i>Create New Folder
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="createFolderForm">
<div class="mb-3">
<label for="folderName" class="form-label">Folder Name</label>
<input type="text" class="form-control" id="folderName" name="folderName" required
placeholder="Enter folder name" maxlength="255">
<div class="form-text">
Folder names cannot contain / or \ characters.
</div>
</div>
<input type="hidden" id="currentPath" name="currentPath" value={ data.CurrentPath }>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="submitCreateFolder()">
<i class="fas fa-folder-plus me-1"></i>Create Folder
</button>
</div>
</div>
</div>
</div>
<!-- Upload File Modal -->
<div class="modal fade" id="uploadFileModal" tabindex="-1" aria-labelledby="uploadFileModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="uploadFileModalLabel">
<i class="fas fa-upload me-2"></i>Upload Files
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="uploadFileForm" enctype="multipart/form-data">
<div class="mb-3">
<label for="fileInput" class="form-label">Select Files</label>
<input type="file" class="form-control" id="fileInput" name="files" multiple required>
<div class="form-text">
Choose one or more files to upload to the current directory. You can select multiple files by holding Ctrl (Cmd on Mac) while clicking.
</div>
</div>
<input type="hidden" id="uploadPath" name="path" value={ data.CurrentPath }>
<!-- File List Preview -->
<div id="fileListPreview" class="mb-3" style="display: none;">
<label class="form-label">Selected Files:</label>
<div id="selectedFilesList" class="border rounded p-2 bg-light">
<!-- Files will be listed here -->
</div>
</div>
<!-- Upload Progress -->
<div class="mb-3" id="uploadProgress" style="display: none;">
<label class="form-label">Upload Progress:</label>
<div class="progress mb-2">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">0%</div>
</div>
<div id="uploadStatus" class="small text-muted">
Preparing upload...
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="submitUploadFile()">
<i class="fas fa-upload me-1"></i>Upload Files
</button>
</div>
</div>
</div>
</div>
<!-- JavaScript for file browser functionality -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Format permissions in the main table
document.querySelectorAll('.permissions-display').forEach(element => {
const mode = element.getAttribute('data-mode');
const isDirectory = element.getAttribute('data-is-directory') === 'true';
if (mode) {
element.textContent = formatPermissions(mode, isDirectory);
}
});
// Handle file browser action buttons (download, view, properties, delete)
document.addEventListener('click', function(e) {
const button = e.target.closest('[data-action]');
if (!button) return;
const action = button.getAttribute('data-action');
const path = button.getAttribute('data-path');
if (!path) return;
switch(action) {
case 'download':
downloadFile(path);
break;
case 'view':
viewFile(path);
break;
case 'properties':
showFileProperties(path);
break;
case 'delete':
const fileName = path.split('/').pop();
showDeleteConfirm(fileName, function() {
deleteFile(path);
}, `Are you sure you want to delete "${fileName}"? This action cannot be undone.`);
break;
}
});
// Initialize file manager event handlers from admin.js
if (typeof setupFileManagerEventHandlers === 'function') {
setupFileManagerEventHandlers();
}
});
// File browser specific functions
function downloadFile(path) {
// Open download URL in new tab
window.open('/api/files/download?path=' + encodeURIComponent(path), '_blank');
}
function viewFile(path) {
// Open file viewer in new tab
window.open('/api/files/view?path=' + encodeURIComponent(path), '_blank');
}
function showFileProperties(path) {
// Fetch file properties and show in modal
fetch('/api/files/properties?path=' + encodeURIComponent(path))
.then(response => response.json())
.then(data => {
if (data.error) {
showAlert('Error loading file properties: ' + data.error, 'error');
} else {
displayFileProperties(data);
}
})
.catch(error => {
console.error('Error fetching file properties:', error);
showAlert('Error loading file properties: ' + error.message, 'error');
});
}
function displayFileProperties(data) {
// Create a comprehensive modal for file properties
const modalHtml = '<div class="modal fade" id="filePropertiesModal" tabindex="-1">' +
'<div class="modal-dialog modal-lg">' +
'<div class="modal-content">' +
'<div class="modal-header">' +
'<h5 class="modal-title"><i class="fas fa-info-circle me-2"></i>Properties: ' + (data.name || 'Unknown') + '</h5>' +
'<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' +
'</div>' +
'<div class="modal-body">' +
createFilePropertiesContent(data) +
'</div>' +
'<div class="modal-footer">' +
'<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
// Remove existing modal if present
const existingModal = document.getElementById('filePropertiesModal');
if (existingModal) {
existingModal.remove();
}
// Add modal to body and show
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modal = new bootstrap.Modal(document.getElementById('filePropertiesModal'));
modal.show();
// Remove modal when hidden
document.getElementById('filePropertiesModal').addEventListener('hidden.bs.modal', function() {
this.remove();
});
}
function createFilePropertiesContent(data) {
let html = '<div class="row">' +
'<div class="col-12">' +
'<h6 class="text-primary"><i class="fas fa-file me-1"></i>Basic Information</h6>' +
'<table class="table table-sm">' +
'<tr><td style="width: 120px;"><strong>Name:</strong></td><td>' + (data.name || 'N/A') + '</td></tr>' +
'<tr><td><strong>Full Path:</strong></td><td><code class="text-break">' + (data.full_path || 'N/A') + '</code></td></tr>' +
'<tr><td><strong>Type:</strong></td><td>' + (data.is_directory ? 'Directory' : 'File') + '</td></tr>';
if (!data.is_directory) {
html += '<tr><td><strong>Size:</strong></td><td>' + (data.size_formatted || (data.size ? formatBytes(data.size) : 'N/A')) + '</td></tr>' +
'<tr><td><strong>MIME Type:</strong></td><td>' + (data.mime_type || 'N/A') + '</td></tr>';
}
html += '</table>' +
'</div>' +
'</div>' +
'<div class="row">' +
'<div class="col-md-6">' +
'<h6 class="text-primary"><i class="fas fa-clock me-1"></i>Timestamps</h6>' +
'<table class="table table-sm">';
if (data.modified_time) {
html += '<tr><td><strong>Modified:</strong></td><td>' + data.modified_time + '</td></tr>';
}
if (data.created_time) {
html += '<tr><td><strong>Created:</strong></td><td>' + data.created_time + '</td></tr>';
}
html += '</table>' +
'</div>' +
'<div class="col-md-6">' +
'<h6 class="text-primary"><i class="fas fa-shield-alt me-1"></i>Permissions</h6>' +
'<table class="table table-sm">';
if (data.file_mode) {
const rwxPermissions = formatPermissions(data.file_mode, data.is_directory);
html += '<tr><td><strong>Permissions:</strong></td><td><code>' + rwxPermissions + '</code></td></tr>';
}
if (data.uid !== undefined) {
html += '<tr><td><strong>User ID:</strong></td><td>' + data.uid + '</td></tr>';
}
if (data.gid !== undefined) {
html += '<tr><td><strong>Group ID:</strong></td><td>' + data.gid + '</td></tr>';
}
html += '</table>' +
'</div>' +
'</div>';
// Add advanced info
html += '<div class="row">' +
'<div class="col-12">' +
'<h6 class="text-primary"><i class="fas fa-cog me-1"></i>Advanced</h6>' +
'<table class="table table-sm">';
if (data.chunk_count) {
html += '<tr><td style="width: 120px;"><strong>Chunks:</strong></td><td>' + data.chunk_count + '</td></tr>';
}
if (data.ttl_formatted) {
html += '<tr><td><strong>TTL:</strong></td><td>' + data.ttl_formatted + '</td></tr>';
}
html += '</table>' +
'</div>' +
'</div>';
// Add chunk details if available (show top 5)
if (data.chunks && data.chunks.length > 0) {
const chunksToShow = data.chunks.slice(0, 5);
html += '<div class="row mt-3">' +
'<div class="col-12">' +
'<h6 class="text-primary"><i class="fas fa-puzzle-piece me-1"></i>Chunk Details' +
(data.chunk_count > 5 ? ' (Top 5 of ' + data.chunk_count + ')' : ' (' + data.chunk_count + ')') +
'</h6>' +
'<div class="table-responsive" style="max-height: 200px; overflow-y: auto;">' +
'<table class="table table-sm table-striped">' +
'<thead>' +
'<tr>' +
'<th>File ID</th>' +
'<th>Offset</th>' +
'<th>Size</th>' +
'<th>ETag</th>' +
'</tr>' +
'</thead>' +
'<tbody>';
chunksToShow.forEach(chunk => {
html += '<tr>' +
'<td><code class="small">' + (chunk.file_id || 'N/A') + '</code></td>' +
'<td>' + formatBytes(chunk.offset || 0) + '</td>' +
'<td>' + formatBytes(chunk.size || 0) + '</td>' +
'<td><code class="small">' + (chunk.e_tag || 'N/A') + '</code></td>' +
'</tr>';
});
html += '</tbody>' +
'</table>' +
'</div>' +
'</div>' +
'</div>';
}
// Add extended attributes if present
if (data.extended && Object.keys(data.extended).length > 0) {
html += '<div class="row">' +
'<div class="col-12">' +
'<h6 class="text-primary"><i class="fas fa-tags me-1"></i>Extended Attributes</h6>' +
'<table class="table table-sm">';
for (const [key, value] of Object.entries(data.extended)) {
html += '<tr><td><strong>' + key + ':</strong></td><td>' + value + '</td></tr>';
}
html += '</table>' +
'</div>' +
'</div>';
}
return html;
}
function uploadFile() {
const modal = new bootstrap.Modal(document.getElementById('uploadFileModal'));
modal.show();
}
function toggleSelectAll() {
const selectAllCheckbox = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.file-checkbox');
checkboxes.forEach(checkbox => {
checkbox.checked = selectAllCheckbox.checked;
});
updateDeleteSelectedButton();
}
function updateDeleteSelectedButton() {
const checkboxes = document.querySelectorAll('.file-checkbox:checked');
const deleteBtn = document.getElementById('deleteSelectedBtn');
if (checkboxes.length > 0) {
deleteBtn.style.display = 'inline-block';
} else {
deleteBtn.style.display = 'none';
}
}
// Helper function to format bytes
function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// Helper function to format permissions in rwxrwxrwx format
function formatPermissions(mode, isDirectory) {
// Check if mode is already in rwxrwxrwx format (e.g., "drwxr-xr-x" or "-rw-r--r--")
if (mode && (mode.startsWith('d') || mode.startsWith('-') || mode.startsWith('l')) && mode.length === 10) {
return mode; // Already formatted
}
// Convert to number - could be octal string or decimal
let permissions;
if (typeof mode === 'string') {
// Try parsing as octal first, then decimal
if (mode.startsWith('0') && mode.length <= 4) {
permissions = parseInt(mode, 8);
} else {
permissions = parseInt(mode, 10);
}
} else {
permissions = parseInt(mode, 10);
}
if (isNaN(permissions)) {
return isDirectory ? 'drwxr-xr-x' : '-rw-r--r--'; // Default fallback
}
// Handle Go's os.ModeDir conversion
// Go's os.ModeDir is 0x80000000 (2147483648), but Unix S_IFDIR is 0o40000 (16384)
let fileType = '-';
// Check for Go's os.ModeDir flag
if (permissions & 0x80000000) {
fileType = 'd';
}
// Check for standard Unix file type bits
else if ((permissions & 0xF000) === 0x4000) { // S_IFDIR (0o40000)
fileType = 'd';
} else if ((permissions & 0xF000) === 0x8000) { // S_IFREG (0o100000)
fileType = '-';
} else if ((permissions & 0xF000) === 0xA000) { // S_IFLNK (0o120000)
fileType = 'l';
} else if ((permissions & 0xF000) === 0x2000) { // S_IFCHR (0o020000)
fileType = 'c';
} else if ((permissions & 0xF000) === 0x6000) { // S_IFBLK (0o060000)
fileType = 'b';
} else if ((permissions & 0xF000) === 0x1000) { // S_IFIFO (0o010000)
fileType = 'p';
} else if ((permissions & 0xF000) === 0xC000) { // S_IFSOCK (0o140000)
fileType = 's';
}
// Fallback to isDirectory parameter if file type detection fails
else if (isDirectory) {
fileType = 'd';
}
// Permission bits (always use the lower 12 bits for permissions)
const owner = (permissions >> 6) & 7;
const group = (permissions >> 3) & 7;
const others = permissions & 7;
// Convert number to rwx format
function numToRwx(num) {
const r = (num & 4) ? 'r' : '-';
const w = (num & 2) ? 'w' : '-';
const x = (num & 1) ? 'x' : '-';
return r + w + x;
}
return fileType + numToRwx(owner) + numToRwx(group) + numToRwx(others);
}
function exportFileList() {
// Simple CSV export of file list
const rows = Array.from(document.querySelectorAll('#fileTable tbody tr')).map(row => {
const cells = row.querySelectorAll('td');
if (cells.length > 1) {
return {
name: cells[1].textContent.trim(),
size: cells[2].textContent.trim(),
type: cells[3].textContent.trim(),
modified: cells[4].textContent.trim(),
permissions: cells[5].textContent.trim()
};
}
return null;
}).filter(row => row !== null);
const csvContent = "data:text/csv;charset=utf-8," +
"Name,Size,Type,Modified,Permissions\n" +
rows.map(r => '"' + r.name + '","' + r.size + '","' + r.type + '","' + r.modified + '","' + r.permissions + '"').join("\n");
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
link.setAttribute("download", "files.csv");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
// Handle file checkbox changes
document.addEventListener('change', function(e) {
if (e.target.classList.contains('file-checkbox')) {
updateDeleteSelectedButton();
}
});
</script>
}
func getFileIcon(mime string) string {
switch {
case strings.HasPrefix(mime, "image/"):
return "fa-image"
case strings.HasPrefix(mime, "video/"):
return "fa-video"
case strings.HasPrefix(mime, "audio/"):
return "fa-music"
case strings.HasPrefix(mime, "text/"):
return "fa-file-text"
case mime == "application/pdf":
return "fa-file-pdf"
case mime == "application/zip" || strings.Contains(mime, "archive"):
return "fa-file-archive"
case mime == "application/json":
return "fa-file-code"
case strings.Contains(mime, "script") || strings.Contains(mime, "javascript"):
return "fa-file-code"
default:
return "fa-file"
}
}
func getMimeDisplayName(mime string) string {
switch mime {
case "text/plain":
return "Text"
case "text/html":
return "HTML"
case "application/json":
return "JSON"
case "application/pdf":
return "PDF"
case "image/jpeg":
return "JPEG"
case "image/png":
return "PNG"
case "image/gif":
return "GIF"
case "video/mp4":
return "MP4"
case "audio/mpeg":
return "MP3"
case "application/zip":
return "ZIP"
default:
if strings.HasPrefix(mime, "image/") {
return "Image"
} else if strings.HasPrefix(mime, "video/") {
return "Video"
} else if strings.HasPrefix(mime, "audio/") {
return "Audio"
} else if strings.HasPrefix(mime, "text/") {
return "Text"
}
return "File"
}
}