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
This commit is contained in:
Ben McClelland
2026-04-21 09:06:51 -07:00
parent 640a7c516d
commit 545a9e9a12
+181 -3
View File
@@ -285,6 +285,42 @@ under the License.
</tbody>
</table>
</div>
<!-- Pagination Bar -->
<div id="objects-pagination" class="hidden flex items-center justify-between px-4 py-3 border-t border-gray-100">
<div class="flex items-center gap-2">
<span class="text-sm text-charcoal-300" id="pagination-info"></span>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-sm text-charcoal-300">Rows per page:</span>
<select id="page-size-select" onchange="changePageSize(parseInt(this.value, 10))" class="text-sm border border-gray-200 rounded-lg px-2 py-1.5 text-charcoal focus:outline-none focus:border-accent bg-white">
<option value="10" selected>10</option>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="1000">1000</option>
</select>
</div>
<div class="flex items-center gap-1">
<button id="pagination-first" onclick="changePage(1)" class="p-1.5 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed" title="First page">
<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="M11 19l-7-7 7-7m8 14l-7-7 7-7"/>
</svg>
</button>
<button id="pagination-prev" onclick="changePage(currentPage - 1)" class="p-1.5 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed" title="Previous page">
<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="M15 19l-7-7 7-7"/>
</svg>
</button>
<span id="pagination-pages" class="text-sm text-charcoal px-2"></span>
<button id="pagination-next" onclick="changePage(currentPage + 1)" class="p-1.5 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed" title="Next page">
<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="M9 5l7 7-7 7"/>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
@@ -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 = `
<tr>
@@ -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();
}
// ============================================