Stop search auto-pagination from looping on failure; accept CSRF in JSON body; make replication pause/resume idempotent

This commit is contained in:
2026-04-25 14:06:39 +08:00
parent 7e32ac2a46
commit dd1e6d0409
6 changed files with 169 additions and 61 deletions

View File

@@ -552,7 +552,13 @@
let scrollTimeout = null;
const handleVirtualScroll = () => {
if (scrollTimeout) cancelAnimationFrame(scrollTimeout);
scrollTimeout = requestAnimationFrame(renderVirtualRows);
scrollTimeout = requestAnimationFrame(() => {
renderVirtualRows();
const c = document.querySelector('.objects-table-container');
if (c && c.scrollHeight - c.scrollTop - c.clientHeight < 500) {
if (typeof loadMoreOnSentinel === 'function') loadMoreOnSentinel();
}
});
};
const refreshVirtualList = () => {
@@ -563,6 +569,11 @@
if (allObjects.length === 0 && streamFolders.length === 0 && !hasMoreObjects) {
showEmptyState();
} else {
const isFiltering = currentFilterTerm && currentFilterTerm.length > 0;
const title = isFiltering ? 'No matches' : 'Empty folder';
const body = isFiltering
? `No objects match "${escapeHtml(currentFilterTerm)}".`
: `This folder contains no objects${hasMoreObjects ? ' yet. Loading more...' : '.'}`;
objectsTableBody.innerHTML = `
<tr>
<td colspan="4" class="py-5">
@@ -572,8 +583,8 @@
<path d="M9.828 3h3.982a2 2 0 0 1 1.992 2.181l-.637 7A2 2 0 0 1 13.174 14H2.825a2 2 0 0 1-1.991-1.819l-.637-7a1.99 1.99 0 0 1 .342-1.31L.5 3a2 2 0 0 1 2-2h3.672a2 2 0 0 1 1.414.586l.828.828A2 2 0 0 0 9.828 3zm-8.322.12C1.72 3.042 1.95 3 2.19 3h5.396l-.707-.707A1 1 0 0 0 6.172 2H2.5a1 1 0 0 0-1 .981l.006.139z"/>
</svg>
</div>
<h6 class="mb-2">Empty folder</h6>
<p class="text-muted small mb-0">This folder contains no objects${hasMoreObjects ? ' yet. Loading more...' : '.'}</p>
<h6 class="mb-2">${title}</h6>
<p class="text-muted small mb-0">${body}</p>
</div>
</td>
</tr>
@@ -977,12 +988,32 @@
scrollContainer.addEventListener('scroll', handleVirtualScroll, { passive: true });
}
const isSentinelVisible = () => {
if (!scrollSentinel) return false;
const rect = scrollSentinel.getBoundingClientRect();
if (scrollContainer) {
const cr = scrollContainer.getBoundingClientRect();
return rect.top <= cr.bottom + 500 && rect.bottom >= cr.top - 500;
}
return rect.top <= window.innerHeight + 500 && rect.bottom >= -500;
};
const loadMoreOnSentinel = () => {
if (searchResults !== null) {
if (searchNextToken && !searchLoading) {
performServerSearch(currentFilterTerm, true);
}
return;
}
if (hasMoreObjects && !isLoadingObjects) {
loadObjects(true);
}
};
if (scrollSentinel && scrollContainer) {
const containerObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && hasMoreObjects && !isLoadingObjects) {
loadObjects(true);
}
if (entry.isIntersecting) loadMoreOnSentinel();
});
}, {
root: scrollContainer,
@@ -993,9 +1024,7 @@
const viewportObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && hasMoreObjects && !isLoadingObjects) {
loadObjects(true);
}
if (entry.isIntersecting) loadMoreOnSentinel();
});
}, {
root: null,
@@ -1231,6 +1260,11 @@
});
if (folders.length === 0 && files.length === 0) {
const isFiltering = currentFilterTerm && currentFilterTerm.length > 0;
const title = isFiltering ? 'No matches' : 'Empty folder';
const body = isFiltering
? `No objects match "${escapeHtml(currentFilterTerm)}".`
: 'This folder contains no objects.';
const emptyRow = document.createElement('tr');
emptyRow.innerHTML = `
<td colspan="4" class="py-5">
@@ -1240,8 +1274,8 @@
<path d="M9.828 3h3.982a2 2 0 0 1 1.992 2.181l-.637 7A2 2 0 0 1 13.174 14H2.825a2 2 0 0 1-1.991-1.819l-.637-7a1.99 1.99 0 0 1 .342-1.31L.5 3a2 2 0 0 1 2-2h3.672a2 2 0 0 1 1.414.586l.828.828A2 2 0 0 0 9.828 3zm-8.322.12C1.72 3.042 1.95 3 2.19 3h5.396l-.707-.707A1 1 0 0 0 6.172 2H2.5a1 1 0 0 0-1 .981l.006.139z"/>
</svg>
</div>
<h6 class="mb-2">Empty folder</h6>
<p class="text-muted small mb-0">This folder contains no objects.</p>
<h6 class="mb-2">${title}</h6>
<p class="text-muted small mb-0">${body}</p>
</div>
</td>
`;
@@ -1421,6 +1455,11 @@
});
const bulkActionsWrapper = document.getElementById('bulk-actions-wrapper');
const bulkDownloadButton = document.querySelector('[data-bulk-download-trigger]');
const updateBulkDownloadState = () => {
if (!bulkDownloadButton) return;
bulkDownloadButton.disabled = selectedRows.size === 0;
};
const updateBulkDeleteState = () => {
const selectedCount = selectedRows.size;
if (bulkDeleteButton) {
@@ -1447,6 +1486,7 @@
selectAllCheckbox.checked = visibleSelectedCount > 0 && visibleSelectedCount === total && total > 0;
selectAllCheckbox.indeterminate = visibleSelectedCount > 0 && visibleSelectedCount < total;
}
updateBulkDownloadState();
};
function toggleRowSelection(row, shouldSelect) {
@@ -2274,47 +2314,69 @@
const filterWarningText = document.getElementById('filter-warning-text');
const folderViewStatus = document.getElementById('folder-view-status');
const updateFilterWarning = () => {
if (!filterWarning) return;
const isFiltering = currentFilterTerm.length > 0;
if (isFiltering && hasMoreObjects) {
filterWarning.classList.remove('d-none');
} else {
filterWarning.classList.add('d-none');
}
};
let searchDebounceTimer = null;
let searchAbortController = null;
let searchResults = null;
let searchNextToken = null;
let searchLoading = false;
const SEARCH_PAGE_SIZE = 500;
const performServerSearch = async (term) => {
if (searchAbortController) searchAbortController.abort();
searchAbortController = new AbortController();
const updateFilterWarning = () => {
if (!filterWarning) return;
filterWarning.classList.add('d-none');
};
const performServerSearch = async (term, append = false) => {
if (!append && searchAbortController) searchAbortController.abort();
if (append && (searchLoading || !searchNextToken)) return;
if (!append) {
searchAbortController = new AbortController();
}
searchLoading = true;
if (append && loadMoreSpinner) loadMoreSpinner.classList.remove('d-none');
let succeeded = false;
try {
const params = new URLSearchParams({ q: term, limit: '500' });
const params = new URLSearchParams({ q: term, limit: String(SEARCH_PAGE_SIZE) });
if (currentPrefix) params.set('prefix', currentPrefix);
if (append && searchNextToken) params.set('start_after', searchNextToken);
const searchUrl = objectsStreamUrl.replace('/stream', '/search');
const response = await fetch(`${searchUrl}?${params}`, {
signal: searchAbortController.signal
signal: searchAbortController?.signal
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
searchResults = (data.results || []).map(obj => processStreamObject(obj));
const newResults = (data.results || []).map(obj => processStreamObject(obj));
if (append && Array.isArray(searchResults)) {
searchResults = searchResults.concat(newResults);
} else {
searchResults = newResults;
}
searchNextToken = data.truncated ? (data.next_token || null) : null;
memoizedVisibleItems = null;
memoizedInputs = { objectCount: -1, folderCount: -1, prefix: null, filterTerm: null };
refreshVirtualList();
if (loadMoreStatus) {
const countText = searchResults.length.toLocaleString();
const truncated = data.truncated ? '+' : '';
loadMoreStatus.textContent = `${countText}${truncated} result${searchResults.length !== 1 ? 's' : ''}`;
const more = searchNextToken ? '+' : '';
const noun = searchResults.length === 1 ? 'result' : 'results';
loadMoreStatus.textContent = searchNextToken
? `${countText}${more} ${noun} (scroll to load more)`
: `${countText} ${noun}`;
}
succeeded = true;
} catch (e) {
if (e.name === 'AbortError') return;
if (loadMoreStatus) {
loadMoreStatus.textContent = 'Search failed';
loadMoreStatus.textContent = 'Search failed (scroll to retry)';
}
} finally {
searchLoading = false;
if (loadMoreSpinner) loadMoreSpinner.classList.add('d-none');
}
if (succeeded && searchNextToken && !searchLoading && isSentinelVisible()) {
performServerSearch(currentFilterTerm, true);
}
};
@@ -2334,6 +2396,7 @@
if (!isFiltering && wasFiltering) {
if (searchAbortController) searchAbortController.abort();
searchResults = null;
searchNextToken = null;
memoizedVisibleItems = null;
memoizedInputs = { objectCount: -1, folderCount: -1, prefix: null, filterTerm: null };
if (loadMoreStatus) {
@@ -3311,15 +3374,8 @@
}
}
const bulkDownloadButton = document.querySelector('[data-bulk-download-trigger]');
const bulkDownloadEndpoint = document.getElementById('objects-drop-zone')?.dataset.bulkDownloadEndpoint;
const updateBulkDownloadState = () => {
if (!bulkDownloadButton) return;
const selectedCount = document.querySelectorAll('[data-object-select]:checked').length;
bulkDownloadButton.disabled = selectedCount === 0;
};
selectAllCheckbox?.addEventListener('change', (event) => {
const shouldSelect = Boolean(event.target?.checked);
@@ -3354,7 +3410,6 @@
});
updateBulkDeleteState();
setTimeout(updateBulkDownloadState, 0);
});
bulkDownloadButton?.addEventListener('click', async () => {
@@ -4402,10 +4457,25 @@
});
if (lifecycleHistoryCard) {
loadLifecycleHistory();
if (window.pollingManager) {
window.pollingManager.start('lifecycle', loadLifecycleHistory);
const lifecycleTab = document.getElementById('lifecycle-tab');
const lifecyclePane = document.getElementById('lifecycle-pane');
const startLifecyclePolling = () => {
if (window.pollingManager) {
window.pollingManager.start('lifecycle', loadLifecycleHistory);
} else {
loadLifecycleHistory();
}
};
const stopLifecyclePolling = () => {
if (window.pollingManager) {
window.pollingManager.stop('lifecycle');
}
};
if (lifecyclePane && lifecyclePane.classList.contains('show') && lifecyclePane.classList.contains('active')) {
startLifecyclePolling();
}
lifecycleTab?.addEventListener('shown.bs.tab', startLifecyclePolling);
lifecycleTab?.addEventListener('hidden.bs.tab', stopLifecyclePolling);
}
if (corsCard) loadCorsRules();