mirror of
https://github.com/versity/versitygw.git
synced 2026-08-20 22:26:19 +00:00
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:
+181
-3
@@ -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();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
||||
Reference in New Issue
Block a user