Merge pull request #2066 from versity/ben/paginated-explorer

feat: add server-side pagination for webui object explorer
This commit is contained in:
Ben McClelland
2026-04-21 15:36:58 -07:00
committed by GitHub

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();
}
// ============================================