From 545a9e9a121654f66d63d0e38576c0f213e815bf Mon Sep 17 00:00:00 2001 From: Ben McClelland Date: Mon, 20 Apr 2026 17:58:55 -0700 Subject: [PATCH] feat: add server-side pagination for webui object explorer The object listing now fetches pages from S3 directly using ListObjectsV2 continuation tokens rather than loading all objects at once. Users can navigate forward and back with first/prev/next buttons and choose how many rows to show per page (10/20/50/100/1000, defaulting to 10), which keeps the listing fast and responsive even in buckets with thousands of objects. Pagination resets automatically when navigating into a folder, running a search, or changing page size. While a search is active, forward navigation is disabled since search filters within the current page only, and the item count shows "(filtered)" to make that clear. When versioning is enabled, delete-marker rows are scoped to the current page's key range so they don't bleed in from adjacent pages. Fixes #2055 --- webui/web/explorer.html | 184 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 181 insertions(+), 3 deletions(-) diff --git a/webui/web/explorer.html b/webui/web/explorer.html index b2bc6a37..c06f7bdd 100644 --- a/webui/web/explorer.html +++ b/webui/web/explorer.html @@ -285,6 +285,42 @@ under the License. + + @@ -1085,6 +1121,13 @@ under the License. let currentObjectKey = null; // For info modal let searchTerm = ''; // Search filter + // Pagination state + let currentPage = 1; + 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; let currentBucketVersioningStatus = null; @@ -1528,15 +1571,23 @@ under the License. // Object Loading // ============================================ - async function loadObjects() { + async function loadObjects(continuationToken = null, isPageChange = false) { if (!currentBucket) return; showTableLoading('objects-table', 6); clearSelection(); + if (!isPageChange) { + currentPage = 1; + continuationTokens = [null]; + } updateBreadcrumb(); try { - const result = await api.listObjectsV2(currentBucket, currentPrefix); + const result = await api.listObjectsV2(currentBucket, currentPrefix, '/', pageSize, continuationToken); + isTruncated = result.isTruncated; + if (isTruncated && result.continuationToken) { + continuationTokens[currentPage] = result.continuationToken; + } folders = result.commonPrefixes || []; objects = result.contents || []; @@ -1594,11 +1645,29 @@ under the License. } } + // When paginating, restrict deleted-with-versions objects to the current page's + // key range, since listObjectVersions returns all versions (not page-bounded). + if (isTruncated || currentPage > 1) { + const allPageKeys = [ + ...folders.map(f => f.prefix), + ...objects.map(o => o.key) + ].sort(); + const pageFirstKey = allPageKeys[0]; + const pageLastKey = allPageKeys[allPageKeys.length - 1]; + deletedObjectsWithVersions = deletedObjectsWithVersions.filter(obj => { + if (currentPage > 1 && pageFirstKey && obj.key < pageFirstKey) return false; + if (isTruncated && pageLastKey && obj.key > pageLastKey) return false; + return true; + }); + } + renderObjects(); + return true; } catch (error) { console.error('Error loading objects:', error); showToast('Error loading objects: ' + error.message, 'error'); showEmptyState('objects-table', 6, 'Error loading objects'); + return false; } } @@ -1642,6 +1711,7 @@ under the License. // Empty folder or no results if (filteredFolders.length === 0 && filteredObjects.length === 0 && filteredDeletedObjects.length === 0) { + document.getElementById('objects-pagination').classList.add('hidden'); if (searchTerm) { tbody.innerHTML = ` @@ -1677,6 +1747,9 @@ under the License. return; } + // Count after filtering (objects.filter above may have removed the prefix key) + const totalVisible = filteredFolders.length + filteredObjects.length + filteredDeletedObjects.length; + // Render folders first filteredFolders.forEach(folder => { const name = getFolderName(folder.prefix); @@ -1839,6 +1912,96 @@ under the License. `; tbody.appendChild(row); }); + + // Show and update pagination bar + updatePagination(totalVisible); + } + + // ============================================ + // Pagination + // ============================================ + + function updatePagination(itemCount) { + const bar = document.getElementById('objects-pagination'); + bar.classList.remove('hidden'); + + 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}` : ''; + document.getElementById('pagination-pages').textContent = + `Page ${currentPage}${isTruncated ? '' : ' (last)'}`; + + const firstBtn = document.getElementById('pagination-first'); + const prevBtn = document.getElementById('pagination-prev'); + const nextBtn = document.getElementById('pagination-next'); + + const isFirst = currentPage <= 1; + const isLast = !isTruncated || !!searchTerm; // disable forward nav while search is active + firstBtn.disabled = isFirst; + prevBtn.disabled = isFirst; + nextBtn.disabled = isLast; + + // Sync the select to the current pageSize + const select = document.getElementById('page-size-select'); + if (select) select.value = String(pageSize); + } + + async function changePage(page) { + if (page < 1) return false; + if (page === currentPage) return false; + if (page > currentPage && !isTruncated) return false; + const token = continuationTokens[page - 1]; + if (token === undefined) return false; // token not yet known; can't jump + + const previousPage = currentPage; + currentPage = page; + const loaded = await loadObjects(token, true); + if (!loaded) { + currentPage = previousPage; + renderObjects(); + } + + return loaded; + } + + async function changePageSize(size) { + if (size === pageSize) return false; + + const previousPageSize = pageSize; + const previousPage = currentPage; + const previousTokens = [...continuationTokens]; + + pageSize = size; + currentPage = 1; + continuationTokens = [null]; + const loaded = await loadObjects(); + if (!loaded) { + pageSize = previousPageSize; + currentPage = previousPage; + continuationTokens = previousTokens; + renderObjects(); + } + + 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; + } } @@ -1920,6 +2083,7 @@ under the License. function navigateToFolder(prefix) { currentPrefix = prefix; + currentPage = 1; loadObjects(); updateUrl(); } @@ -3053,6 +3217,12 @@ under the License. } else { clearBtn.classList.add('hidden'); } + + if (currentBucket && (currentPage > 1 || isReloadingFirstPageForSearch)) { + reloadFirstPageForSearch(); + return; + } + renderObjects(); }, 200)); } @@ -3068,7 +3238,15 @@ under the License. const clearBtn = document.getElementById('clear-search'); if (searchInput) searchInput.value = ''; if (clearBtn) clearBtn.classList.add('hidden'); - if (currentBucket) renderObjects(); + + if (!currentBucket) return; + + if (currentPage > 1 || isReloadingFirstPageForSearch) { + reloadFirstPageForSearch(); + return; + } + + renderObjects(); } // ============================================