feat(db): VACUUM after retention and an Optimize button in Backup modal

SQLite DELETE marks pages free but doesn't shrink the file, so the
new retention job would keep DBs at their bloated size forever without
a follow-up VACUUM. Add db.vacuum() that runs PRAGMA-free VACUUM and
reports size_before/size_after/elapsed so callers can surface results.

The retention job now calls vacuum() automatically when it deleted at
least 1000 rows. Threshold avoids the multi-second VACUUM cost on quiet
days. Failure is logged, not raised — a missed VACUUM never crashes
the scheduler.

Power-user override: new "Optimize now" button in the Database Backup
modal triggers VACUUM on demand via POST /api/db/vacuum, alongside a
GET /api/db/size that drives the live "Current size" label. This way
users don't have to wait until 03:30 to reclaim space after the first
big retention pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-06-07 10:55:23 +02:00
parent 422e7a3b34
commit f72f6d418a
5 changed files with 151 additions and 1 deletions
+61 -1
View File
@@ -6373,7 +6373,67 @@ document.addEventListener('DOMContentLoaded', initializeSearch);
// =============================================================================
function initializeBackup() {
document.getElementById('backupModal')?.addEventListener('shown.bs.modal', loadBackupList);
const modal = document.getElementById('backupModal');
if (!modal) return;
modal.addEventListener('shown.bs.modal', () => {
loadBackupList();
loadDatabaseSize();
});
}
function _formatBytes(n) {
if (!Number.isFinite(n) || n < 0) return '?';
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
async function loadDatabaseSize() {
const statusEl = document.getElementById('vacuumDbStatus');
if (!statusEl) return;
try {
const response = await fetch('/api/db/size');
const data = await response.json();
if (data.success) {
statusEl.textContent = `Current size: ${_formatBytes(data.size)}`;
} else {
statusEl.textContent = 'Size: unknown';
}
} catch (error) {
statusEl.textContent = 'Size: unknown';
}
}
async function optimizeDatabase() {
const btn = document.getElementById('vacuumDbBtn');
const statusEl = document.getElementById('vacuumDbStatus');
if (!btn) return;
btn.disabled = true;
btn.innerHTML = '<div class="spinner-border spinner-border-sm"></div> Optimizing…';
if (statusEl) statusEl.textContent = 'Running VACUUM…';
try {
const response = await fetch('/api/db/vacuum', { method: 'POST' });
const data = await response.json();
if (data.success) {
const freed = data.freed > 0 ? `freed ${_formatBytes(data.freed)}` : 'no space to reclaim';
showNotification(`Optimized: ${freed} in ${data.elapsed_seconds}s`, 'success');
if (statusEl) statusEl.textContent = `Current size: ${_formatBytes(data.size_after)}`;
} else {
showNotification('Optimize failed: ' + (data.error || 'unknown'), 'danger');
loadDatabaseSize();
}
} catch (error) {
console.error('Error running VACUUM:', error);
showNotification('Optimize failed', 'danger');
loadDatabaseSize();
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrows-collapse"></i> Optimize now';
}
}
async function loadBackupList() {