perf: kill lag with 1000s of rows during upload

The two worst hot paths were:
  - clicking a row triggered a full table rebuild with sort+innerHTML
    (queue AND recent panel), and the opposite panel got cleared with
    another full rebuild
  - every upload progress tick (4/sec) scanned queueJobs twice and
    filtered sessionFilesData twice just to update the status bar

Fixes:
  - applyQueueSelectionClasses / applyRecentSelectionClasses toggle the
    .selected class on existing rows instead of rebuilding the tbody.
    Click selection is now O(rendered rows) instead of O(total × sort).
  - maybeAddSessionFile schedules renderRecentUploadsPanel via rAF so
    a batch of 1000 successful uploads coalesces into one render.
  - sortRecentFiles memoizes its result per (sortKey, direction, len)
    — unchanged sort state + unchanged length returns the cached array
    instead of re-sorting thousands of entries.
  - _computeQueueStats now also returns inProgressBytes, dropping the
    second queueJobs scan in updateStatusBar.
  - session done/error counts are maintained incrementally, replacing
    two sessionFilesData.filter().length calls every status-bar tick.
  - handleRowClick uses the _jobIndexById map instead of Array.find.
This commit is contained in:
Administrator 2026-04-19 12:27:16 +02:00
parent 7dc68c7615
commit 85287aa620

View File

@ -57,6 +57,9 @@ let historySortState = { key: 'date', direction: 'desc' };
let sessionFilesData = []; let sessionFilesData = [];
const recentSortState = { key: 'date', direction: 'desc' }; const recentSortState = { key: 'date', direction: 'desc' };
const selectedRecentIds = new Set(); const selectedRecentIds = new Set();
// Maintained incrementally — avoids O(n) filter() scans every 250ms in the status bar.
let _sessionDoneCount = 0;
let _sessionErrorCount = 0;
// --- Init --- // --- Init ---
async function init() { async function init() {
@ -787,6 +790,34 @@ function scheduleQueueRender() {
requestAnimationFrame(() => { _renderQueued = false; renderQueueTable(); }); requestAnimationFrame(() => { _renderQueued = false; renderQueueTable(); });
} }
let _recentRenderQueued = false;
function scheduleRecentRender() {
if (_recentRenderQueued) return;
_recentRenderQueued = true;
requestAnimationFrame(() => { _recentRenderQueued = false; renderRecentUploadsPanel(); });
}
// Toggle the .selected class on existing rows without rebuilding the table.
// Used on click/selection changes — O(rendered rows) instead of O(total rows × sort).
function applyQueueSelectionClasses() {
const tbody = document.getElementById('queueBody');
if (!tbody) return;
const rows = tbody.querySelectorAll('.queue-row');
for (const tr of rows) {
tr.classList.toggle('selected', selectedJobIds.has(tr.dataset.jobId));
}
}
function applyRecentSelectionClasses() {
const tbody = document.getElementById('recentFilesBody');
if (!tbody) return;
const rows = tbody.querySelectorAll('.recent-file-row');
for (const tr of rows) {
const order = parseInt(tr.dataset.order, 10);
tr.classList.toggle('selected', selectedRecentIds.has(order));
}
}
function scheduleThrottledUIUpdate() { function scheduleThrottledUIUpdate() {
if (_uiUpdateTimer) return; if (_uiUpdateTimer) return;
_uiUpdateTimer = setTimeout(() => { _uiUpdateTimer = setTimeout(() => {
@ -1007,8 +1038,8 @@ function getStatusText(job) {
// --- Queue interactions --- // --- Queue interactions ---
function handleRowClick(e, row) { function handleRowClick(e, row) {
const jobId = row.dataset.jobId; const jobId = row.dataset.jobId;
// Clear recent panel selection when clicking in queue // Clear recent panel selection when clicking in queue — class-toggle only.
if (selectedRecentIds.size > 0) { selectedRecentIds.clear(); renderRecentUploadsPanel(); } if (selectedRecentIds.size > 0) { selectedRecentIds.clear(); applyRecentSelectionClasses(); }
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
if (selectedJobIds.has(jobId)) selectedJobIds.delete(jobId); if (selectedJobIds.has(jobId)) selectedJobIds.delete(jobId);
@ -1027,7 +1058,7 @@ function handleRowClick(e, row) {
selectedJobIds.clear(); selectedJobIds.clear();
selectedJobIds.add(jobId); selectedJobIds.add(jobId);
// Single click on done job -> copy link // Single click on done job -> copy link
const job = queueJobs.find(j => j.id === jobId); const job = _jobIndexById.get(jobId);
if (job && job.status === 'done' && job.result) { if (job && job.status === 'done' && job.result) {
const link = job.result.download_url || job.result.embed_url || ''; const link = job.result.download_url || job.result.embed_url || '';
if (link) { if (link) {
@ -1036,7 +1067,9 @@ function handleRowClick(e, row) {
} }
} }
} }
renderQueueTable(); // Selection changes don't change sort order / row content — just toggle classes.
applyQueueSelectionClasses();
updateQueueActionButtons();
} }
// --- Context menu --- // --- Context menu ---
@ -1048,7 +1081,8 @@ function handleRowContextMenu(e, row) {
if (!selectedJobIds.has(jobId)) { if (!selectedJobIds.has(jobId)) {
selectedJobIds.clear(); selectedJobIds.clear();
selectedJobIds.add(jobId); selectedJobIds.add(jobId);
renderQueueTable(); applyQueueSelectionClasses();
updateQueueActionButtons();
} }
showContextMenu(e.clientX, e.clientY); showContextMenu(e.clientX, e.clientY);
} }
@ -1112,7 +1146,14 @@ function hideContextMenu() {
function deleteSelectedRecentFiles() { function deleteSelectedRecentFiles() {
if (selectedRecentIds.size === 0) return; if (selectedRecentIds.size === 0) return;
sessionFilesData = sessionFilesData.filter(r => !selectedRecentIds.has(r.order)); let removedDone = 0, removedErr = 0;
sessionFilesData = sessionFilesData.filter(r => {
if (!selectedRecentIds.has(r.order)) return true;
if (r.isError) removedErr++; else removedDone++;
return false;
});
_sessionDoneCount = Math.max(0, _sessionDoneCount - removedDone);
_sessionErrorCount = Math.max(0, _sessionErrorCount - removedErr);
selectedRecentIds.clear(); selectedRecentIds.clear();
renderRecentUploadsPanel(); renderRecentUploadsPanel();
} }
@ -1121,6 +1162,8 @@ function clearAllRecentFiles() {
if (sessionFilesData.length === 0) return; if (sessionFilesData.length === 0) return;
if (!confirm(`Wirklich alle ${sessionFilesData.length} Links aus diesem Panel entfernen?`)) return; if (!confirm(`Wirklich alle ${sessionFilesData.length} Links aus diesem Panel entfernen?`)) return;
sessionFilesData = []; sessionFilesData = [];
_sessionDoneCount = 0;
_sessionErrorCount = 0;
selectedRecentIds.clear(); selectedRecentIds.clear();
renderRecentUploadsPanel(); renderRecentUploadsPanel();
} }
@ -1887,7 +1930,9 @@ function maybeAddSessionFile(job) {
isError: false, isError: false,
order: sessionFilesData.length order: sessionFilesData.length
}); });
renderRecentUploadsPanel(); _sessionDoneCount++;
// Coalesce rapid successive adds into one render per frame.
scheduleRecentRender();
} }
} }
@ -1921,10 +1966,11 @@ function applySummaryResults(summary) {
} }
} }
// Single-pass queue stats computation (shared by status bar + stats panel) // Single-pass queue stats computation (shared by status bar + stats panel).
// Also tracks inProgressBytes so the status bar doesn't need a second scan.
function _computeQueueStats() { function _computeQueueStats() {
let remaining = 0, inProgress = 0, done = 0, errors = 0; let remaining = 0, inProgress = 0, done = 0, errors = 0;
let bytesRemaining = 0, totalSize = 0, remainingSize = 0; let bytesRemaining = 0, totalSize = 0, remainingSize = 0, inProgressBytes = 0;
const total = queueJobs.length; const total = queueJobs.length;
for (let i = 0; i < total; i++) { for (let i = 0; i < total; i++) {
@ -1937,6 +1983,7 @@ function _computeQueueStats() {
if (s === 'uploading' || s === 'getting-server' || s === 'retrying') { if (s === 'uploading' || s === 'getting-server' || s === 'retrying') {
inProgress++; inProgress++;
remaining++; remaining++;
inProgressBytes += bu;
bytesRemaining += Math.max(0, bt - bu); bytesRemaining += Math.max(0, bt - bu);
remainingSize += Math.max(0, bt - bu); remainingSize += Math.max(0, bt - bu);
} else if (s === 'preview' || s === 'queued') { } else if (s === 'preview' || s === 'queued') {
@ -1952,7 +1999,7 @@ function _computeQueueStats() {
} }
} }
return { total, remaining, inProgress, done, errors, bytesRemaining, totalSize, remainingSize }; return { total, remaining, inProgress, done, errors, bytesRemaining, totalSize, remainingSize, inProgressBytes };
} }
function updateStatusBar() { function updateStatusBar() {
@ -1972,15 +2019,7 @@ function updateStatusBar() {
document.getElementById('sbState').textContent = stateText; document.getElementById('sbState').textContent = stateText;
document.getElementById('sbSpeed').textContent = formatSpeed(lastUploadStats.globalSpeedKbs || 0); document.getElementById('sbSpeed').textContent = formatSpeed(lastUploadStats.globalSpeedKbs || 0);
// Session-based bytes: survive removeFromQueueOnDone const uploadedSize = _sessionUploadedBytes + stats.inProgressBytes;
// Uploaded = done jobs (session) + in-progress bytes still in queue
let inProgressBytes = 0;
for (const job of queueJobs) {
if (job.status === 'uploading' || job.status === 'getting-server' || job.status === 'retrying') {
inProgressBytes += job.bytesUploaded || 0;
}
}
const uploadedSize = _sessionUploadedBytes + inProgressBytes;
const totalSize = Math.max(stats.totalSize, _sessionTotalBytes); const totalSize = Math.max(stats.totalSize, _sessionTotalBytes);
document.getElementById('sbTotal').textContent = `${formatSize(uploadedSize)} / ${formatSize(totalSize)}`; document.getElementById('sbTotal').textContent = `${formatSize(uploadedSize)} / ${formatSize(totalSize)}`;
document.getElementById('sbEta').textContent = `ETA ${etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'}`; document.getElementById('sbEta').textContent = `ETA ${etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'}`;
@ -1988,10 +2027,8 @@ function updateStatusBar() {
document.getElementById('sbQueueCount').textContent = `Total: ${stats.total}`; document.getElementById('sbQueueCount').textContent = `Total: ${stats.total}`;
document.getElementById('sbRemainingCount').textContent = `Remaining: ${stats.remaining}`; document.getElementById('sbRemainingCount').textContent = `Remaining: ${stats.remaining}`;
document.getElementById('sbInProgressCount').textContent = `In Progress: ${stats.inProgress}`; document.getElementById('sbInProgressCount').textContent = `In Progress: ${stats.inProgress}`;
const sessionDone = sessionFilesData.filter(r => !r.isError).length; document.getElementById('sbDoneCount').textContent = `Done: ${_sessionDoneCount}`;
const sessionErrors = sessionFilesData.filter(r => r.isError).length; document.getElementById('sbErrorCount').textContent = `Error: ${_sessionErrorCount}`;
document.getElementById('sbDoneCount').textContent = `Done: ${sessionDone}`;
document.getElementById('sbErrorCount').textContent = `Error: ${sessionErrors}`;
} }
// --- Health Check --- // --- Health Check ---
@ -3052,9 +3089,17 @@ async function exportHistory() {
showCopyToast(`Verlauf exportiert (${result.totalRows || 0} Zeilen)`); showCopyToast(`Verlauf exportiert (${result.totalRows || 0} Zeilen)`);
} }
// Memoize sort result: invalidated only when data length changes or sort state changes.
// Selection changes and re-renders reuse the cached sorted array — a big win when
// the panel has thousands of rows and the sort is stable.
let _recentSortCache = { sig: '', result: [] };
function sortRecentFiles(data) { function sortRecentFiles(data) {
const sorted = data.slice();
const { key, direction } = recentSortState; const { key, direction } = recentSortState;
const sig = `${key}|${direction}|${data.length}`;
if (_recentSortCache.sig === sig) return _recentSortCache.result;
const sorted = data.slice();
const dir = direction === 'asc' ? 1 : -1; const dir = direction === 'asc' ? 1 : -1;
sorted.sort((a, b) => { sorted.sort((a, b) => {
if (key === 'date') return dir * ((a.dateTs - b.dateTs) || (a.order - b.order)); if (key === 'date') return dir * ((a.dateTs - b.dateTs) || (a.order - b.order));
@ -3063,6 +3108,7 @@ function sortRecentFiles(data) {
if (key === 'link') return dir * _collatorDE.compare(a.link, b.link); if (key === 'link') return dir * _collatorDE.compare(a.link, b.link);
return 0; return 0;
}); });
_recentSortCache = { sig, result: sorted };
return sorted; return sorted;
} }
@ -3106,14 +3152,16 @@ function renderRecentUploadsPanel() {
tbody.addEventListener('click', (e) => { tbody.addEventListener('click', (e) => {
const tr = e.target.closest('.recent-file-row'); const tr = e.target.closest('.recent-file-row');
if (!tr) return; if (!tr) return;
// Clear queue selection when clicking in recent panel // Clear queue selection when clicking in recent panel — class-toggle only.
if (selectedJobIds.size > 0) { selectedJobIds.clear(); renderQueueTable(); updateQueueActionButtons(); } if (selectedJobIds.size > 0) { selectedJobIds.clear(); applyQueueSelectionClasses(); updateQueueActionButtons(); }
const id = parseInt(tr.dataset.order, 10); const id = parseInt(tr.dataset.order, 10);
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
if (selectedRecentIds.has(id)) selectedRecentIds.delete(id); if (selectedRecentIds.has(id)) selectedRecentIds.delete(id);
else selectedRecentIds.add(id); else selectedRecentIds.add(id);
} else if (e.shiftKey && selectedRecentIds.size > 0) { } else if (e.shiftKey && selectedRecentIds.size > 0) {
const sortedOrders = sortRecentFiles(sessionFilesData).map(r => r.order); // Use already-sorted DOM order (cheap) instead of re-sorting the full array.
const sortedOrders = Array.from(tbody.querySelectorAll('.recent-file-row'))
.map(r => parseInt(r.dataset.order, 10));
const lastIdx = sortedOrders.findIndex(o => selectedRecentIds.has(o)); const lastIdx = sortedOrders.findIndex(o => selectedRecentIds.has(o));
const curIdx = sortedOrders.indexOf(id); const curIdx = sortedOrders.indexOf(id);
if (lastIdx >= 0 && curIdx >= 0) { if (lastIdx >= 0 && curIdx >= 0) {
@ -3125,7 +3173,8 @@ function renderRecentUploadsPanel() {
selectedRecentIds.clear(); selectedRecentIds.clear();
selectedRecentIds.add(id); selectedRecentIds.add(id);
} }
renderRecentUploadsPanel(); // Selection change — toggle classes, no tbody rebuild.
applyRecentSelectionClasses();
}); });
tbody.addEventListener('dblclick', (e) => { tbody.addEventListener('dblclick', (e) => {