feat: replace webui client-side name filter with server-side prefix filter

Remove the client-side search that filtered already-loaded objects by
name. Replace it with a prefix input that is appended to the current
path prefix and passed directly to the S3 ListObjectsV2 API, so
filtering is performed server-side and works correctly across all pages.

Fixes #2091
This commit is contained in:
Ben McClelland
2026-04-25 10:32:28 -07:00
parent 79441fa48e
commit cb609e40a6
2 changed files with 51 additions and 119 deletions

View File

@@ -167,7 +167,7 @@ under the License.
<svg class="w-4 h-4 text-charcoal-300 absolute left-3 top-1/2 transform -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input type="text" id="search-input" class="pl-9 pr-8 py-1.5 w-64 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-accent" placeholder="Filter by name...">
<input type="text" id="search-input" class="pl-9 pr-8 py-1.5 w-64 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-accent" placeholder="Filter by prefix...">
<button id="clear-search" class="hidden absolute right-2 top-1/2 transform -translate-y-1/2 text-charcoal-300 hover:text-charcoal" onclick="clearSearch()">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
@@ -1119,14 +1119,14 @@ under the License.
let bucketsList = [];
let selectedObjects = new Set();
let currentObjectKey = null; // For info modal
let searchTerm = ''; // Search filter
let searchPrefix = ''; // API prefix filter
// Pagination state
let currentPage = 1;
let loadObjectsAbortController = null;
let pageSize = 10;
let continuationTokens = [null]; // continuationTokens[i] = S3 token needed to fetch page i+1
let isTruncated = false;
let isReloadingFirstPageForSearch = false;
// Versioning state
let showVersions = false;
@@ -1585,6 +1585,10 @@ under the License.
async function loadObjects(continuationToken = null, isPageChange = false) {
if (!currentBucket) return;
if (loadObjectsAbortController) loadObjectsAbortController.abort();
loadObjectsAbortController = new AbortController();
const signal = loadObjectsAbortController.signal;
showTableLoading('objects-table', 6);
clearSelection();
if (!isPageChange) {
@@ -1594,7 +1598,7 @@ under the License.
updateBreadcrumb();
try {
const result = await api.listObjectsV2(currentBucket, currentPrefix, '/', pageSize, continuationToken);
const result = await api.listObjectsV2(currentBucket, currentPrefix + searchPrefix, '/', pageSize, continuationToken, signal);
isTruncated = result.isTruncated;
if (isTruncated && result.continuationToken) {
continuationTokens[currentPage] = result.continuationToken;
@@ -1611,7 +1615,7 @@ under the License.
deletedObjectsWithVersions = [];
if (currentBucketVersioningStatus === 'Enabled' || currentBucketVersioningStatus === 'Suspended') {
try {
const versionsResult = await api.listObjectVersions(currentBucket, currentPrefix);
const versionsResult = await api.listObjectVersions(currentBucket, currentPrefix + searchPrefix, '/', 1000, null, null, signal);
// Explicitly mark delete markers
const versions = versionsResult.versions.map(v => ({ ...v, isDeleteMarker: false }));
@@ -1675,6 +1679,7 @@ under the License.
renderObjects();
return true;
} catch (error) {
if (error.name === 'AbortError') return false;
console.error('Error loading objects:', error);
showToast('Error loading objects: ' + error.message, 'error');
showEmptyState('objects-table', 6, 'Error loading objects');
@@ -1700,69 +1705,31 @@ under the License.
return;
}
// Apply search filter
let filteredFolders = folders;
let filteredObjects = objects;
let filteredDeletedObjects = deletedObjectsWithVersions;
if (searchTerm) {
filteredFolders = folders.filter(f => {
const name = getFolderName(f.prefix);
return name.toLowerCase().includes(searchTerm);
});
filteredObjects = objects.filter(o => {
const name = getFileName(o.key);
return name.toLowerCase().includes(searchTerm);
});
filteredDeletedObjects = deletedObjectsWithVersions.filter(o => {
const name = getFileName(o.key);
return name.toLowerCase().includes(searchTerm);
});
}
// Empty folder or no results
if (filteredFolders.length === 0 && filteredObjects.length === 0 && filteredDeletedObjects.length === 0) {
if (folders.length === 0 && objects.length === 0 && deletedObjectsWithVersions.length === 0) {
document.getElementById('objects-pagination').classList.add('hidden');
if (searchTerm) {
tbody.innerHTML = `
<tr>
<td colspan="6" class="py-12 text-center">
<svg class="w-16 h-16 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<p class="text-lg font-medium text-charcoal">No results found</p>
<p class="text-sm text-charcoal-300 mt-1">No items match "${escapeHtml(searchTerm)}"</p>
<button onclick="clearSearch()" class="mt-4 px-4 py-2 border border-gray-200 hover:bg-gray-50 text-charcoal font-medium rounded-lg transition-colors">
Clear Filter
</button>
</td>
</tr>
`;
} else {
// Empty folder (no search active)
tbody.innerHTML = `
<tr>
<td colspan="6" class="py-12 text-center">
<svg class="w-16 h-16 text-gray-300 mx-auto mb-4" 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"/>
</svg>
<p class="text-lg font-medium text-charcoal">This folder is empty</p>
<p class="text-sm text-charcoal-300 mt-1">Upload files to get started</p>
<button onclick="openUploadDialog()" class="mt-4 px-4 py-2 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">
Upload Files
</button>
</td>
</tr>
`;
}
tbody.innerHTML = `
<tr>
<td colspan="6" class="py-12 text-center">
<svg class="w-16 h-16 text-gray-300 mx-auto mb-4" 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"/>
</svg>
<p class="text-lg font-medium text-charcoal">This folder is empty</p>
<p class="text-sm text-charcoal-300 mt-1">Upload files to get started</p>
<button onclick="openUploadDialog()" class="mt-4 px-4 py-2 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">
Upload Files
</button>
</td>
</tr>
`;
return;
}
// Count after filtering (objects.filter above may have removed the prefix key)
const totalVisible = filteredFolders.length + filteredObjects.length + filteredDeletedObjects.length;
const totalVisible = folders.length + objects.length + deletedObjectsWithVersions.length;
// Render folders first
filteredFolders.forEach(folder => {
folders.forEach(folder => {
const name = getFolderName(folder.prefix);
const row = document.createElement('tr');
row.className = 'file-row border-b border-gray-50 cursor-pointer';
@@ -1798,7 +1765,7 @@ under the License.
});
// Render objects
filteredObjects.forEach(obj => {
objects.forEach(obj => {
const name = getFileName(obj.key);
const versionCount = objectVersionCounts[obj.key] || 0;
const hasMultipleVersions = versionCount > 1;
@@ -1866,7 +1833,7 @@ under the License.
});
// Render deleted objects that still have versions
filteredDeletedObjects.forEach(obj => {
deletedObjectsWithVersions.forEach(obj => {
const name = getFileName(obj.key);
const versionCount = objectVersionCounts[obj.key] || 0;
@@ -1938,9 +1905,8 @@ under the License.
const startItem = (currentPage - 1) * pageSize + 1;
const endItem = (currentPage - 1) * pageSize + itemCount;
const searchNote = searchTerm ? ' (filtered)' : '';
document.getElementById('pagination-info').textContent =
itemCount > 0 ? `${startItem}${endItem}${isTruncated ? '+' : ''} items${searchNote}` : '';
itemCount > 0 ? `${startItem}${endItem}${isTruncated ? '+' : ''} items` : '';
document.getElementById('pagination-pages').textContent =
`Page ${currentPage}${isTruncated ? '' : ' (last)'}`;
@@ -1949,7 +1915,7 @@ under the License.
const nextBtn = document.getElementById('pagination-next');
const isFirst = currentPage <= 1;
const isLast = !isTruncated || !!searchTerm; // disable forward nav while search is active
const isLast = !isTruncated;
firstBtn.disabled = isFirst;
prevBtn.disabled = isFirst;
nextBtn.disabled = isLast;
@@ -1998,22 +1964,6 @@ under the License.
return loaded;
}
async function reloadFirstPageForSearch() {
if (isReloadingFirstPageForSearch) return;
const previousTokens = [...continuationTokens];
isReloadingFirstPageForSearch = true;
continuationTokens = [null];
try {
const loaded = await changePage(1);
if (!loaded) {
continuationTokens = previousTokens;
}
} finally {
isReloadingFirstPageForSearch = false;
}
}
// ============================================
@@ -3221,21 +3171,20 @@ under the License.
const searchInput = document.getElementById('search-input');
if (searchInput) {
searchInput.addEventListener('input', debounce((e) => {
searchTerm = e.target.value.toLowerCase();
searchPrefix = e.target.value;
const clearBtn = document.getElementById('clear-search');
if (searchTerm) {
if (searchPrefix) {
clearBtn.classList.remove('hidden');
} else {
clearBtn.classList.add('hidden');
}
if (currentBucket && (currentPage > 1 || isReloadingFirstPageForSearch)) {
reloadFirstPageForSearch();
return;
}
if (!currentBucket) return;
renderObjects();
}, 200));
currentPage = 1;
continuationTokens = [null];
loadObjects();
}, 300));
}
});
@@ -3244,7 +3193,7 @@ under the License.
// ============================================
function clearSearch() {
searchTerm = '';
searchPrefix = '';
const searchInput = document.getElementById('search-input');
const clearBtn = document.getElementById('clear-search');
if (searchInput) searchInput.value = '';
@@ -3252,12 +3201,9 @@ under the License.
if (!currentBucket) return;
if (currentPage > 1 || isReloadingFirstPageForSearch) {
reloadFirstPageForSearch();
return;
}
renderObjects();
currentPage = 1;
continuationTokens = [null];
loadObjects();
}
// ============================================
@@ -3478,23 +3424,8 @@ under the License.
const tbody = document.getElementById('objects-table');
tbody.innerHTML = '';
// Apply search filter
let filteredFolders = folders;
let filteredVersions = objectVersions;
if (searchTerm) {
filteredFolders = folders.filter(f => {
const name = getFolderName(f.prefix);
return name.toLowerCase().includes(searchTerm);
});
filteredVersions = objectVersions.filter(v => {
const name = getFileName(v.key);
return name.toLowerCase().includes(searchTerm);
});
}
// Empty state
if (filteredFolders.length === 0 && filteredVersions.length === 0) {
if (folders.length === 0 && objectVersions.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6" class="py-12 text-center">
@@ -3510,7 +3441,7 @@ under the License.
}
// Render folders first (same as normal view)
filteredFolders.forEach(folder => {
folders.forEach(folder => {
const name = getFolderName(folder.prefix);
const row = document.createElement('tr');
row.className = 'file-row border-b border-gray-50 cursor-pointer';
@@ -3538,7 +3469,7 @@ under the License.
});
// Render versions
filteredVersions.forEach(version => {
objectVersions.forEach(version => {
const name = getFileName(version.key);
const row = document.createElement('tr');
const isDeleteMarker = version.isDeleteMarker;

View File

@@ -697,7 +697,7 @@ class VersityAPI {
* @param {string} contentType - Content type for the request
* @param {Object} additionalHeaders - Additional headers to include
*/
async request(method, path, queryParams = {}, body = '', useAdminEndpoint = false, contentType = 'application/xml', additionalHeaders = {}) {
async request(method, path, queryParams = {}, body = '', useAdminEndpoint = false, contentType = 'application/xml', additionalHeaders = {}, signal = null) {
if (!this.credentials) {
throw new Error('Not authenticated');
}
@@ -714,6 +714,7 @@ class VersityAPI {
method,
headers,
body: body || undefined,
signal: signal || undefined,
});
} catch (e) {
// Browsers surface CORS blocks as a generic TypeError.
@@ -1047,7 +1048,7 @@ class VersityAPI {
/**
* List objects in a bucket (S3 ListObjectsV2)
*/
async listObjectsV2(bucket, prefix = '', delimiter = '/', maxKeys = 1000, continuationToken = null) {
async listObjectsV2(bucket, prefix = '', delimiter = '/', maxKeys = 1000, continuationToken = null, signal = null) {
const params = {
'list-type': '2',
'prefix': prefix,
@@ -1059,7 +1060,7 @@ class VersityAPI {
params['continuation-token'] = continuationToken;
}
const response = await this.request('GET', `/${bucket}`, params);
const response = await this.request('GET', `/${bucket}`, params, '', false, 'application/xml', {}, signal);
return this.parseListObjectsV2Response(response);
}
@@ -1615,7 +1616,7 @@ class VersityAPI {
/**
* List all versions of objects in a bucket
*/
async listObjectVersions(bucket, prefix = '', delimiter = '/', maxKeys = 1000, keyMarker = null, versionIdMarker = null) {
async listObjectVersions(bucket, prefix = '', delimiter = '/', maxKeys = 1000, keyMarker = null, versionIdMarker = null, signal = null) {
const params = {
versions: '',
prefix: prefix,
@@ -1626,7 +1627,7 @@ class VersityAPI {
if (keyMarker) params['key-marker'] = keyMarker;
if (versionIdMarker) params['version-id-marker'] = versionIdMarker;
const response = await this.request('GET', `/${bucket}`, params);
const response = await this.request('GET', `/${bucket}`, params, '', false, 'application/xml', {}, signal);
return this.parseListObjectVersionsResponse(response);
}