mirror of
https://github.com/versity/versitygw.git
synced 2026-04-22 05:30:29 +00:00
Merge pull request #2066 from versity/ben/paginated-explorer
feat: add server-side pagination for webui object explorer
This commit is contained in:
@@ -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