From cb609e40a6cc5938547571b4ae230edda97f940e Mon Sep 17 00:00:00 2001 From: Ben McClelland Date: Sat, 25 Apr 2026 10:32:28 -0700 Subject: [PATCH] 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 --- webui/web/explorer.html | 159 ++++++++++++---------------------------- webui/web/js/api.js | 11 +-- 2 files changed, 51 insertions(+), 119 deletions(-) diff --git a/webui/web/explorer.html b/webui/web/explorer.html index f9807aba..57235370 100644 --- a/webui/web/explorer.html +++ b/webui/web/explorer.html @@ -167,7 +167,7 @@ under the License. - + - - - `; - } else { - // Empty folder (no search active) - tbody.innerHTML = ` - - - - - -

This folder is empty

-

Upload files to get started

- - - - `; - } + tbody.innerHTML = ` + + + + + +

This folder is empty

+

Upload files to get started

+ + + + `; 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 = ` @@ -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; diff --git a/webui/web/js/api.js b/webui/web/js/api.js index b643c79e..005a7b17 100644 --- a/webui/web/js/api.js +++ b/webui/web/js/api.js @@ -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); }