feat(history): configurable retention + render cap so the Verlauf tab loads fast

The Upload-Verlauf tab took 30s+ to open on large, never-cleared histories.
Measured on the live config: 18.9 MB config / 41 batches / 53,403 rendered rows.
Two independent problems, fixed together:

1. Render side (the 30s killer): renderHistoryTable built ALL rows into one
   innerHTML. Now the build is capped to the newest 2,000 rows (HISTORY_RENDER_CAP)
   after slicing the chronological tail, then sorted for display. A notice shows
   "Zeige neueste 2000 von N" so nothing looks deleted; full history stays on disk
   and "Verlauf exportieren" still emits everything (export reads loadHistory()).

2. Storage side: history grew unbounded because appendHistory only ever pushed.
   New globalSettings.historyRetention ('all' default, non-destructive) with
   policies: 7d / 30d / 90d (time-based) and 1000 / 100 (newest-uploads-based).
   appendHistory now prunes after each batch; a new prune-history IPC re-applies
   the policy immediately when the user changes it, gated behind a confirm() that
   shows the exact removal count (dry-run first).

Prune model keeps whole batches (atomic): count policies accumulate rendered rows
from the newest batch backward and always keep >= the newest batch even if it
alone exceeds the target; time policies keep batches whose timestamp is missing
or unparseable (old entries have none) so ambiguous data is never silently dropped.

New retention dropdown lives in the Verlauf header. 9 unit tests cover the prune
edge cases incl. a realistic 41-batch fixture. Full suite: 272 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Administrator 2026-06-15 00:26:20 +02:00
parent 221eb55380
commit 05ad08433c
7 changed files with 242 additions and 2 deletions

View File

@ -63,6 +63,7 @@ const DEFAULTS = {
webhookMention: '', // optional Discord ping target: user-id, role:id, @here, @everyone webhookMention: '', // optional Discord ping target: user-id, role:id, @here, @everyone
autoRetryRounds: 0, // 0 = off; 1-5 automatic retry rounds for transient failures after batch end autoRetryRounds: 0, // 0 = off; 1-5 automatic retry rounds for transient failures after batch end
autoRetryDelayMin: 5, // base delay in minutes between auto-retry rounds (linear backoff: round N waits N*delay) autoRetryDelayMin: 5, // base delay in minutes between auto-retry rounds (linear backoff: round N waits N*delay)
historyRetention: 'all', // 'all' | '7d' | '30d' | '90d' | '1000' | '100' — storage cap for upload history
// NOTE: logMode is intentionally NOT in DEFAULTS. If it were, the deep-merge // NOTE: logMode is intentionally NOT in DEFAULTS. If it were, the deep-merge
// would seed logMode='single' for every load, which would beat (and silently // would seed logMode='single' for every load, which would beat (and silently
// erase) the legacy sessionLog:true → "daily" migration. normalizeLogMode in // erase) the legacy sessionLog:true → "daily" migration. normalizeLogMode in
@ -102,6 +103,67 @@ const DEFAULTS = {
history: [] history: []
}; };
const HISTORY_RETENTION_OPTIONS = [
{ value: 'all', label: 'Alles behalten' },
{ value: '7d', label: 'Letzte 7 Tage' },
{ value: '30d', label: 'Letzte 30 Tage' },
{ value: '90d', label: 'Letzte 90 Tage' },
{ value: '1000', label: 'Letzte 1000 Uploads' },
{ value: '100', label: 'Letzte 100 Uploads' }
];
function batchTimestampMs(batch) {
const raw = batch && batch.timestamp;
if (raw === null || raw === undefined || raw === '') return null;
const ms = typeof raw === 'number' ? raw : Date.parse(raw);
return Number.isFinite(ms) ? ms : null;
}
function batchRowCount(batch) {
let n = 0;
const files = (batch && batch.files) || [];
for (const file of files) {
for (const result of (file.results || [])) {
if (result.status === 'aborted' || result.status === 'error') continue;
n++;
}
}
return n;
}
function countHistoryRows(history) {
let n = 0;
for (const batch of (history || [])) n += batchRowCount(batch);
return n;
}
function applyHistoryRetention(history, retention, nowMs) {
if (!Array.isArray(history) || history.length === 0) return history;
const policy = String(retention || 'all');
if (policy === 'all') return history;
if (/^\d+d$/.test(policy)) {
const days = parseInt(policy, 10);
if (!Number.isFinite(days) || days <= 0) return history;
const cutoff = nowMs - days * 86400000;
return history.filter(b => {
const ts = batchTimestampMs(b);
return ts === null || ts >= cutoff;
});
}
const maxRows = parseInt(policy, 10);
if (!Number.isFinite(maxRows) || maxRows <= 0) return history;
const keptReversed = [];
let acc = 0;
for (let i = history.length - 1; i >= 0; i--) {
keptReversed.push(history[i]);
acc += batchRowCount(history[i]);
if (acc >= maxRows) break;
}
return keptReversed.reverse();
}
class ConfigStore { class ConfigStore {
constructor(app) { constructor(app) {
const dir = app && app.isPackaged const dir = app && app.isPackaged
@ -301,10 +363,32 @@ class ConfigStore {
return this._enqueueWrite(() => { return this._enqueueWrite(() => {
const config = this.load(); const config = this.load();
config.history.push(entry); config.history.push(entry);
const retention = (config.globalSettings && config.globalSettings.historyRetention) || 'all';
config.history = applyHistoryRetention(config.history, retention, Date.now());
return this._atomicWrite(this._serializeForDisk(config)); return this._atomicWrite(this._serializeForDisk(config));
}); });
} }
pruneHistory(retention, opts = {}) {
const dryRun = !!opts.dryRun;
return this._enqueueWrite(() => {
const config = this.load();
const beforeBatches = config.history.length;
const beforeRows = countHistoryRows(config.history);
const pruned = applyHistoryRetention(config.history, retention, Date.now());
const result = {
removedBatches: beforeBatches - pruned.length,
removedRows: beforeRows - countHistoryRows(pruned),
keptBatches: pruned.length,
keptRows: countHistoryRows(pruned)
};
if (dryRun) return result;
config.history = pruned;
if (config.globalSettings) config.globalSettings.historyRetention = String(retention || 'all');
return this._atomicWrite(this._serializeForDisk(config)).then(() => result);
});
}
clearHistory() { clearHistory() {
return this._enqueueWrite(() => { return this._enqueueWrite(() => {
const config = this.load(); const config = this.load();
@ -318,3 +402,6 @@ module.exports = ConfigStore;
module.exports.HOSTER_ACCOUNT_TEMPLATES = HOSTER_ACCOUNT_TEMPLATES; module.exports.HOSTER_ACCOUNT_TEMPLATES = HOSTER_ACCOUNT_TEMPLATES;
module.exports.HOSTER_NAMES = HOSTER_NAMES; module.exports.HOSTER_NAMES = HOSTER_NAMES;
module.exports.HOSTER_ADD_OPTIONS = HOSTER_ADD_OPTIONS; module.exports.HOSTER_ADD_OPTIONS = HOSTER_ADD_OPTIONS;
module.exports.HISTORY_RETENTION_OPTIONS = HISTORY_RETENTION_OPTIONS;
module.exports.applyHistoryRetention = applyHistoryRetention;
module.exports.countHistoryRows = countHistoryRows;

View File

@ -1297,6 +1297,12 @@ ipcMain.handle('get-history', () => {
return configStore.loadHistory(); return configStore.loadHistory();
}); });
ipcMain.handle('prune-history', async (_event, payload) => {
const retention = payload && payload.retention;
const dryRun = !!(payload && payload.dryRun);
return configStore.pruneHistory(retention, { dryRun });
});
ipcMain.handle('save-text-file', async (_event, defaultName, content, filters) => { ipcMain.handle('save-text-file', async (_event, defaultName, content, filters) => {
const safeName = String(defaultName || `export-${new Date().toISOString().slice(0, 10)}.txt`); const safeName = String(defaultName || `export-${new Date().toISOString().slice(0, 10)}.txt`);
const safeFilters = Array.isArray(filters) && filters.length const safeFilters = Array.isArray(filters) && filters.length

View File

@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('api', {
saveConfig: (config) => ipcRenderer.invoke('save-config', config), saveConfig: (config) => ipcRenderer.invoke('save-config', config),
getHistory: () => ipcRenderer.invoke('get-history'), getHistory: () => ipcRenderer.invoke('get-history'),
clearHistory: () => ipcRenderer.invoke('clear-history'), clearHistory: () => ipcRenderer.invoke('clear-history'),
pruneHistory: (retention, opts) => ipcRenderer.invoke('prune-history', { retention, dryRun: !!(opts && opts.dryRun) }),
exportHistory: (format) => ipcRenderer.invoke('export-history', format), exportHistory: (format) => ipcRenderer.invoke('export-history', format),
saveTextFile: (defaultName, content, filters) => ipcRenderer.invoke('save-text-file', defaultName, content, filters), saveTextFile: (defaultName, content, filters) => ipcRenderer.invoke('save-text-file', defaultName, content, filters),

View File

@ -4122,6 +4122,8 @@ async function loadHistory() {
const history = await window.api.getHistory(); const history = await window.api.getHistory();
window._historyForStats = history || []; window._historyForStats = history || [];
_invalidateHosterLifetimeCache(); _invalidateHosterLifetimeCache();
const retSel = document.getElementById('historyRetentionSelect');
if (retSel) retSel.value = (config.globalSettings && config.globalSettings.historyRetention) || 'all';
const container = document.getElementById('historyContainer'); const container = document.getElementById('historyContainer');
if (!history || history.length === 0) { if (!history || history.length === 0) {
@ -4304,13 +4306,29 @@ function renderRecentUploadsPanel() {
if (!wasAppendOnly) updateRecentSortHeaders(); if (!wasAppendOnly) updateRecentSortHeaders();
} }
const HISTORY_RENDER_CAP = 2000;
function renderHistoryTable(container) { function renderHistoryTable(container) {
if (!container || !historyRowsData.length) { if (!container || !historyRowsData.length) {
if (container) container.innerHTML = '<p class="empty-state">Noch keine Uploads.</p>'; if (container) container.innerHTML = '<p class="empty-state">Noch keine Uploads.</p>';
const emptyNotice = document.getElementById('historyCapNotice');
if (emptyNotice) emptyNotice.style.display = 'none';
return; return;
} }
const rows = sortHistoryRows(historyRowsData); const total = historyRowsData.length;
const working = total > HISTORY_RENDER_CAP ? historyRowsData.slice(-HISTORY_RENDER_CAP) : historyRowsData;
const notice = document.getElementById('historyCapNotice');
if (notice) {
if (total > HISTORY_RENDER_CAP) {
notice.style.display = '';
notice.textContent = `Zeige neueste ${HISTORY_RENDER_CAP.toLocaleString('de-DE')} von ${total.toLocaleString('de-DE')} Einträgen. Der vollständige Verlauf bleibt gespeichert und ist über „Verlauf exportieren“ verfügbar.`;
} else {
notice.style.display = 'none';
}
}
const rows = sortHistoryRows(working);
const headerCell = (key, label) => { const headerCell = (key, label) => {
const active = historySortState.key === key; const active = historySortState.key === key;
const dir = active ? (historySortState.direction === 'asc' ? '▲' : '▼') : '↕'; const dir = active ? (historySortState.direction === 'asc' ? '▲' : '▼') : '↕';
@ -4498,6 +4516,29 @@ function setupListeners() {
}); });
document.getElementById('exportHistoryBtn').addEventListener('click', exportHistory); document.getElementById('exportHistoryBtn').addEventListener('click', exportHistory);
const historyRetentionSelect = document.getElementById('historyRetentionSelect');
if (historyRetentionSelect) {
historyRetentionSelect.addEventListener('change', async () => {
const value = historyRetentionSelect.value;
const prev = (config.globalSettings && config.globalSettings.historyRetention) || 'all';
if (value !== 'all') {
const preview = await window.api.pruneHistory(value, { dryRun: true });
if (preview && preview.removedRows > 0) {
const ok = confirm(`${preview.removedRows.toLocaleString('de-DE')} Verlaufseinträge werden dauerhaft entfernt.\n\nFortfahren?`);
if (!ok) { historyRetentionSelect.value = prev; return; }
}
}
const globalSettings = { ...(config.globalSettings || {}), historyRetention: value };
config.globalSettings = globalSettings;
await window.api.saveGlobalSettings(globalSettings).catch(() => {});
if (value !== 'all') {
const res = await window.api.pruneHistory(value);
if (res && res.removedRows > 0) showCopyToast(`Verlauf gekürzt: ${res.removedRows.toLocaleString('de-DE')} entfernt`);
}
loadHistory();
});
}
// Auto health check toggle // Auto health check toggle
const autoToggle = document.getElementById('autoHealthCheckToggle'); const autoToggle = document.getElementById('autoHealthCheckToggle');
if (autoToggle) { if (autoToggle) {

View File

@ -257,11 +257,21 @@
<div class="history-container"> <div class="history-container">
<div class="history-header"> <div class="history-header">
<h2>Upload-Verlauf</h2> <h2>Upload-Verlauf</h2>
<div style="display:flex; gap:8px"> <div style="display:flex; gap:8px; align-items:center">
<label for="historyRetentionSelect" class="history-retention-label">Aufbewahrung</label>
<select id="historyRetentionSelect" class="key-input history-retention-select">
<option value="all">Alles behalten</option>
<option value="7d">Letzte 7 Tage</option>
<option value="30d">Letzte 30 Tage</option>
<option value="90d">Letzte 90 Tage</option>
<option value="1000">Letzte 1000 Uploads</option>
<option value="100">Letzte 100 Uploads</option>
</select>
<button class="btn btn-secondary" id="exportHistoryBtn">Verlauf exportieren</button> <button class="btn btn-secondary" id="exportHistoryBtn">Verlauf exportieren</button>
<button class="btn btn-secondary" id="clearHistoryBtn">Verlauf löschen</button> <button class="btn btn-secondary" id="clearHistoryBtn">Verlauf löschen</button>
</div> </div>
</div> </div>
<div id="historyCapNotice" class="history-cap-notice" style="display:none"></div>
<div id="historyContainer"></div> <div id="historyContainer"></div>
</div> </div>
</div> </div>

View File

@ -979,6 +979,17 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
.history-container { padding: 16px; overflow: auto; flex: 1; background: linear-gradient(180deg, rgba(255,255,255,0.015), transparent 24%); } .history-container { padding: 16px; overflow: auto; flex: 1; background: linear-gradient(180deg, rgba(255,255,255,0.015), transparent 24%); }
.history-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .history-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.history-header h2 { font-size: 18px; } .history-header h2 { font-size: 18px; }
.history-retention-label { font-size: 12px; color: var(--text-dim); margin-right: 2px; }
.history-retention-select { width: auto; min-width: 150px; padding: 6px 8px; }
.history-cap-notice {
margin: 0 0 10px;
padding: 8px 12px;
font-size: 12px;
color: var(--text-dim);
background: rgba(255,255,255,0.03);
border: 1px solid var(--border);
border-radius: 6px;
}
.results-table, .history-table { .results-table, .history-table {
width: 100%; width: 100%;

View File

@ -0,0 +1,84 @@
const test = require('node:test');
const assert = require('node:assert');
const { applyHistoryRetention, countHistoryRows } = require('../lib/config-store');
function batch(timestamp, okRows, extras = {}) {
const results = [];
for (let i = 0; i < okRows; i++) results.push({ status: 'success', hoster: 'voe.sx', download_url: `https://voe.sx/${i}` });
if (extras.aborted) for (let i = 0; i < extras.aborted; i++) results.push({ status: 'aborted', hoster: 'voe.sx' });
if (extras.error) for (let i = 0; i < extras.error; i++) results.push({ status: 'error', hoster: 'voe.sx' });
return { timestamp, files: [{ name: 'clip.mp4', results }] };
}
const DAY = 86400000;
test('countHistoryRows counts only non-aborted, non-error results', () => {
const h = [batch('2026-01-01', 3, { aborted: 2, error: 1 })];
assert.strictEqual(countHistoryRows(h), 3);
});
test('retention "all" returns the array unchanged', () => {
const h = [batch('2026-01-01', 5), batch('2026-01-02', 5)];
assert.strictEqual(applyHistoryRetention(h, 'all', Date.parse('2026-06-01')), h);
});
test('count policy keeps newest whole batches up to the row target', () => {
const h = [batch('2026-01-01', 400), batch('2026-01-02', 400), batch('2026-01-03', 400)];
const pruned = applyHistoryRetention(h, '1000', Date.parse('2026-06-01'));
assert.strictEqual(pruned.length, 3);
assert.strictEqual(countHistoryRows(pruned), 1200);
});
test('count policy drops older batches once target reached (newest first)', () => {
const h = [batch('2026-01-01', 600), batch('2026-01-02', 600), batch('2026-01-03', 600)];
const pruned = applyHistoryRetention(h, '1000', Date.parse('2026-06-01'));
assert.strictEqual(pruned.length, 2);
assert.deepStrictEqual(pruned.map(b => b.timestamp), ['2026-01-02', '2026-01-03']);
});
test('count policy always keeps the newest batch even if it alone exceeds N', () => {
const h = [batch('2026-01-01', 50), batch('2026-01-02', 5000)];
const pruned = applyHistoryRetention(h, '100', Date.parse('2026-06-01'));
assert.strictEqual(pruned.length, 1);
assert.strictEqual(pruned[0].timestamp, '2026-01-02');
});
test('time policy drops batches older than the cutoff', () => {
const now = Date.parse('2026-06-15T00:00:00Z');
const h = [
batch(new Date(now - 10 * DAY).toISOString(), 5),
batch(new Date(now - 3 * DAY).toISOString(), 5),
batch(new Date(now - 1 * DAY).toISOString(), 5)
];
const pruned = applyHistoryRetention(h, '7d', now);
assert.strictEqual(pruned.length, 2);
});
test('time policy keeps batches with missing or invalid timestamp', () => {
const now = Date.parse('2026-06-15T00:00:00Z');
const h = [
batch(undefined, 5),
batch('not-a-date', 5),
batch(new Date(now - 99 * DAY).toISOString(), 5),
batch(new Date(now - 1 * DAY).toISOString(), 5)
];
const pruned = applyHistoryRetention(h, '30d', now);
assert.strictEqual(pruned.length, 3);
assert.ok(pruned.includes(h[0]));
assert.ok(pruned.includes(h[1]));
assert.ok(!pruned.includes(h[2]));
});
test('count policy shrinks a realistic 41-batch / >1000-row history', () => {
const h = [];
for (let i = 0; i < 41; i++) h.push(batch(`2026-04-${String((i % 28) + 1).padStart(2, '0')}`, 1300));
assert.strictEqual(countHistoryRows(h), 41 * 1300);
const pruned = applyHistoryRetention(h, '1000', Date.parse('2026-06-01'));
assert.strictEqual(pruned.length, 1);
assert.strictEqual(countHistoryRows(pruned), 1300);
});
test('empty history is returned as-is for any policy', () => {
assert.deepStrictEqual(applyHistoryRetention([], '7d', Date.now()), []);
assert.deepStrictEqual(applyHistoryRetention([], '100', Date.now()), []);
});