settings: backup retention + delete + size display (#116)

- updateService: getDirectorySize, deleteBackup, pruneBackups; auto-prune
  in createPreUpdateBackup using DB setting backup_retention_count or env
  BACKUP_RETENTION_COUNT
- settings.routes: DELETE /api/settings/updates/backups/:name,
  POST /api/settings/updates/backups/prune,
  GET/PUT /api/settings/backup/retention
- settings.ejs: retention input + 'Apply now' button + total size summary
- settings.js: render size per backup, delete button, retention controls
- pages.css: backup-retention-row + update-backup-actions styles
- lang/en + pl: 11 new keys (delete, retention, prune, total_size)
This commit is contained in:
UNITRONIX
2026-04-26 00:52:52 +02:00
parent a0a15261dc
commit c7f03bbe6f
7 changed files with 415 additions and 8 deletions
+17
View File
@@ -427,6 +427,7 @@
"connect_desktop": "Connect via Desktop Client",
"remote_viewer": "Remote Viewer",
"web_remote": "Web Remote",
"cdap_viewer": "CDAP Snapshot Viewer",
"details": "Details",
"edit": "Edit",
"delete": "Delete",
@@ -593,6 +594,8 @@
"password_placeholder": "Device password",
"connect": "Connect",
"reconnect": "Reconnect",
"use_cdap_fallback": "Use CDAP viewer",
"cdap_fallback_hint": "Open the lightweight JPEG-polling viewer that uses bd-signal/CDAP instead of the RustDesk relay. Useful when the peer is offline on the relay but reachable via the agent management channel.",
"disconnect": "Disconnect",
"back_to_devices": "Back to Devices",
"fullscreen": "Fullscreen",
@@ -2832,6 +2835,20 @@
"restore": "Restore",
"restore_confirm": "Are you sure you want to restore from this backup? Changed files will be overwritten.",
"restore_success": "Backup restored successfully. Console is restarting…",
"delete": "Delete",
"delete_confirm": "Permanently delete backup {name}? This cannot be undone.",
"delete_success": "Backup deleted",
"delete_failed": "Failed to delete backup",
"retention_label": "Keep N most recent backups",
"retention_hint": "Older backups are deleted automatically after each update. Use 0 to keep all backups.",
"retention_saved": "Retention setting saved",
"retention_invalid": "Retention must be an integer between 0 and 1000",
"retention_required_for_prune": "Set a retention value greater than 0 first",
"prune_now": "Apply now",
"prune_confirm": "Keep only the {n} most recent backups and delete the rest?",
"prune_done": "Pruned {n} backup(s)",
"total_size": "Total size",
"backups_count": "backup(s)",
"baseline_set": "Baseline set",
"commits_behind": "commits behind",
"total_files": "Total files",
+17
View File
@@ -426,6 +426,7 @@
"connect": "Połącz",
"connect_desktop": "Połącz przez klienta desktop",
"remote_viewer": "Podgląd zdalny",
"cdap_viewer": "Podgląd CDAP (zrzuty)",
"web_remote": "Pulpit zdalny",
"details": "Szczegóły",
"edit": "Edytuj",
@@ -593,6 +594,8 @@
"password_placeholder": "Hasło urządzenia",
"connect": "Połącz",
"reconnect": "Połącz ponownie",
"use_cdap_fallback": "Użyj podglądu CDAP",
"cdap_fallback_hint": "Otwiera lekki podgląd JPEG przez bd-signal/CDAP zamiast przekaźnika RustDesk. Przydatny, gdy urządzenie jest niedostępne przez relay, ale podłączone do kanału zarządzania.",
"disconnect": "Rozłącz",
"back_to_devices": "Powrót do Urządzeń",
"fullscreen": "Pełny ekran",
@@ -2832,6 +2835,20 @@
"restore": "Przywróć",
"restore_confirm": "Czy na pewno chcesz przywrócić z tej kopii zapasowej? Zmienione pliki zostaną nadpisane.",
"restore_success": "Kopia zapasowa przywrócona pomyślnie. Konsola jest restartowana…",
"delete": "Usuń",
"delete_confirm": "Trwale usunąć kopię zapasową {name}? Tej operacji nie można cofnąć.",
"delete_success": "Kopia zapasowa usunięta",
"delete_failed": "Nie udało się usunąć kopii zapasowej",
"retention_label": "Zachowaj N najnowszych kopii",
"retention_hint": "Starsze kopie są usuwane automatycznie po każdej aktualizacji. Wpisz 0, aby zachować wszystkie.",
"retention_saved": "Ustawienie retencji zapisane",
"retention_invalid": "Retencja musi być liczbą całkowitą między 0 a 1000",
"retention_required_for_prune": "Najpierw ustaw wartość retencji większą od 0",
"prune_now": "Zastosuj teraz",
"prune_confirm": "Zachować tylko {n} najnowszych kopii i usunąć resztę?",
"prune_done": "Usunięto {n} kopii",
"total_size": "Całkowity rozmiar",
"backups_count": "kopii",
"baseline_set": "Punkt odniesienia ustawiony",
"commits_behind": "commitów do tyłu",
"total_files": "Łączna liczba plików",
+54
View File
@@ -1529,6 +1529,60 @@
font-size: var(--font-size-xs, 0.75rem);
}
.update-backup-actions {
display: flex;
gap: var(--space-xs, 4px);
flex-wrap: wrap;
justify-content: flex-end;
}
.backup-retention-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-md);
background: var(--bg-tertiary, rgba(255,255,255,0.03));
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
margin-bottom: var(--space-md);
flex-wrap: wrap;
}
.backup-retention-label {
display: flex;
flex-direction: column;
gap: 4px;
font-weight: 500;
color: var(--text-primary);
}
.backup-retention-label small {
font-weight: normal;
font-size: var(--font-size-xs, 0.75rem);
}
.backup-retention-controls {
display: flex;
gap: var(--space-sm);
align-items: center;
flex-wrap: wrap;
}
.backup-retention-controls input[type="number"] {
width: 110px;
padding: 6px 10px;
background: var(--bg-secondary, rgba(0,0,0,0.2));
border: 1px solid var(--border-color);
border-radius: var(--radius-sm, 4px);
color: var(--text-primary);
}
.backup-summary {
margin-bottom: var(--space-sm);
font-size: var(--font-size-sm);
}
/* commit list */
.update-commits {
display: flex;
+113 -6
View File
@@ -1249,6 +1249,7 @@
installBtn?.addEventListener('click', installUpdate);
loadUpdateBackups();
loadBackupRetention();
}
async function checkForUpdates() {
@@ -1612,9 +1613,86 @@
}, 2000);
}
function formatBytes(n) {
if (!Number.isFinite(n) || n <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let v = n;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1)} ${units[i]}`;
}
async function loadBackupRetention() {
const input = document.getElementById('backup-retention-input');
if (!input) return;
try {
const data = await Utils.api('/api/settings/backup/retention');
const count = (data && typeof data.count === 'number') ? data.count : 0;
input.value = String(count);
} catch {
input.value = '0';
}
}
async function saveBackupRetention() {
const input = document.getElementById('backup-retention-input');
if (!input) return;
const count = parseInt(input.value, 10);
if (!Number.isFinite(count) || count < 0 || count > 1000) {
Notifications.error(_('updates.retention_invalid'));
return;
}
try {
await Utils.api('/api/settings/backup/retention', {
method: 'PUT',
body: { count }
});
Notifications.success(_('updates.retention_saved'));
} catch (err) {
Notifications.error(err.message || _('errors.server_error'));
}
}
async function pruneBackupsNow() {
const input = document.getElementById('backup-retention-input');
const count = parseInt(input?.value, 10);
if (!Number.isFinite(count) || count <= 0) {
Notifications.error(_('updates.retention_required_for_prune'));
return;
}
if (!confirm(_('updates.prune_confirm').replace('{n}', String(count)))) return;
try {
const data = await Utils.api('/api/settings/updates/backups/prune', {
method: 'POST',
body: { keep: count }
});
const deleted = (data && Array.isArray(data.deleted)) ? data.deleted.length : 0;
Notifications.success(_('updates.prune_done').replace('{n}', String(deleted)));
await loadUpdateBackups();
} catch (err) {
Notifications.error(err.message || _('errors.server_error'));
}
}
async function deleteBackup(name, btn) {
if (!confirm(_('updates.delete_confirm').replace('{name}', name))) return;
if (btn) btn.disabled = true;
try {
await Utils.api(`/api/settings/updates/backups/${encodeURIComponent(name)}`, {
method: 'DELETE'
});
Notifications.success(_('updates.delete_success'));
await loadUpdateBackups();
} catch (err) {
Notifications.error(err.message || _('errors.server_error'));
if (btn) btn.disabled = false;
}
}
async function loadUpdateBackups() {
const listEl = document.getElementById('update-backups-list');
if (!listEl) return;
const summaryEl = document.getElementById('backup-summary');
try {
const data = await Utils.api('/api/settings/updates/backups');
@@ -1622,30 +1700,42 @@
if (!backups.length) {
listEl.innerHTML = `<p class="text-muted">${_('updates.no_backups')}</p>`;
if (summaryEl) summaryEl.textContent = '';
return;
}
const totalBytes = backups.reduce((acc, b) => acc + (b.sizeBytes || 0), 0);
if (summaryEl) {
summaryEl.textContent = `${_('updates.total_size')}: ${formatBytes(totalBytes)} · ${backups.length} ${_('updates.backups_count')}`;
}
let html = '<div class="update-backups">';
for (const b of backups) {
const date = b.timestamp ? new Date(b.timestamp).toLocaleString() : '';
const sha = b.sha ? ` · ${Utils.escapeHtml(b.sha)}` : '';
const size = formatBytes(b.sizeBytes || 0);
html += `<div class="update-backup-item">
<div class="update-backup-info">
<strong>${Utils.escapeHtml(b.name)}</strong>
<span class="text-muted">${date}${sha} · ${b.fileCount || b.filesBackedUp || 0} ${_('updates.files')}</span>
<span class="text-muted">${date}${sha} · ${b.fileCount || b.filesBackedUp || 0} ${_('updates.files')} · ${size}</span>
</div>
<div class="update-backup-actions">
<button class="btn btn-sm btn-outline" data-backup-restore="${Utils.escapeHtml(b.name)}">
<span class="material-icons">restore</span> ${_('updates.restore')}
</button>
<button class="btn btn-sm btn-danger" data-backup-delete="${Utils.escapeHtml(b.name)}">
<span class="material-icons">delete</span> ${_('updates.delete')}
</button>
</div>
<button class="btn btn-sm btn-outline" data-backup="${Utils.escapeHtml(b.name)}">
<span class="material-icons">restore</span> ${_('updates.restore')}
</button>
</div>`;
}
html += '</div>';
listEl.innerHTML = html;
// Attach restore handlers
listEl.querySelectorAll('[data-backup]').forEach(btn => {
listEl.querySelectorAll('[data-backup-restore]').forEach(btn => {
btn.addEventListener('click', async () => {
const name = btn.dataset.backup;
const name = btn.dataset.backupRestore;
if (!confirm(_('updates.restore_confirm'))) return;
btn.disabled = true;
@@ -1662,9 +1752,26 @@
}
});
});
// Attach delete handlers
listEl.querySelectorAll('[data-backup-delete]').forEach(btn => {
btn.addEventListener('click', () => deleteBackup(btn.dataset.backupDelete, btn));
});
} catch {
listEl.innerHTML = `<p class="text-muted">${_('updates.no_backups')}</p>`;
if (summaryEl) summaryEl.textContent = '';
}
}
// Wire retention controls (idempotent — handles tab re-mount)
document.addEventListener('click', (ev) => {
const target = ev.target.closest('#backup-retention-save, #backup-prune-now');
if (!target) return;
if (target.id === 'backup-retention-save') saveBackupRetention();
else if (target.id === 'backup-prune-now') pruneBackupsNow();
});
// Expose retention loader so tab activation can call it
window.loadBackupRetention = loadBackupRetention;
})();
+88
View File
@@ -679,4 +679,92 @@ router.post('/api/settings/updates/restore', requireAuth, requirePermission('ser
}
});
/**
* DELETE /api/settings/updates/backups/:name - Delete a single pre-update backup
*/
router.delete('/api/settings/updates/backups/:name', requireAuth, requirePermission('server.config'), async (req, res) => {
try {
const name = req.params.name;
const result = updateService.deleteBackup(name);
await db.logAction(req.session?.userId, 'backup_deleted', `Deleted update backup: ${name}`, req.ip);
res.json({ success: true, data: result });
} catch (err) {
console.error('Delete backup error:', err);
// 400 for validation errors, 500 for unexpected ones
const status = /invalid|not found|outside/i.test(err.message) ? 400 : 500;
res.status(status).json({ success: false, error: err.message });
}
});
/**
* POST /api/settings/updates/backups/prune - Apply retention now
* Body: { keep: number } — keep N newest, delete the rest
*/
router.post('/api/settings/updates/backups/prune', requireAuth, requirePermission('server.config'), async (req, res) => {
try {
const keep = parseInt(req.body?.keep, 10);
if (!Number.isFinite(keep) || keep <= 0) {
return res.status(400).json({ success: false, error: 'keep must be a positive integer' });
}
const result = updateService.pruneBackups(keep);
if (result.deleted.length) {
await db.logAction(
req.session?.userId, 'backup_pruned',
`Manual prune: kept ${keep}, deleted ${result.deleted.length} backup(s)`,
req.ip
);
}
res.json({ success: true, data: result });
} catch (err) {
console.error('Prune backups error:', err);
res.status(500).json({ success: false, error: err.message });
}
});
/**
* GET /api/settings/backup/retention - Read current retention count
* Resolution: DB setting → env var BACKUP_RETENTION_COUNT → 0 (unlimited)
*/
router.get('/api/settings/backup/retention', requireAuth, requirePermission('server.config'), async (req, res) => {
try {
const dbVal = await db.getSetting('backup_retention_count');
let count = 0;
let source = 'unset';
if (dbVal !== null && dbVal !== undefined && dbVal !== '') {
const parsed = parseInt(dbVal, 10);
if (Number.isFinite(parsed)) { count = parsed; source = 'database'; }
} else if (process.env.BACKUP_RETENTION_COUNT) {
const parsed = parseInt(process.env.BACKUP_RETENTION_COUNT, 10);
if (Number.isFinite(parsed)) { count = parsed; source = 'environment'; }
}
res.json({ success: true, data: { count, source } });
} catch (err) {
console.error('Get retention error:', err);
res.status(500).json({ success: false, error: req.t('errors.server_error') });
}
});
/**
* PUT /api/settings/backup/retention - Update retention count
* Body: { count: number } — 0 means "keep all"
*/
router.put('/api/settings/backup/retention', requireAuth, requirePermission('server.config'), async (req, res) => {
try {
const count = parseInt(req.body?.count, 10);
if (!Number.isFinite(count) || count < 0 || count > 1000) {
return res.status(400).json({ success: false, error: 'count must be an integer between 0 and 1000' });
}
await db.setSetting('backup_retention_count', String(count));
await db.logAction(
req.session?.userId, 'backup_retention_changed',
`Backup retention set to ${count === 0 ? 'unlimited' : count}`,
req.ip
);
res.json({ success: true, data: { count } });
} catch (err) {
console.error('Set retention error:', err);
res.status(500).json({ success: false, error: req.t('errors.server_error') });
}
});
module.exports = router;
+109 -2
View File
@@ -768,6 +768,31 @@ async function createPreUpdateBackup(allFiles) {
files: allFiles.filter(f => f.component === 'console' && f.localPath).map(f => f.localPath)
}, null, 2));
// Auto-prune old backups based on retention setting.
// Resolution order: DB setting `backup_retention_count` → env var
// BACKUP_RETENTION_COUNT → 0 (keep all). Operator-controlled.
try {
let retention = 0;
try {
const db = require('./database');
const dbVal = await db.getSetting('backup_retention_count');
if (dbVal !== null && dbVal !== undefined && dbVal !== '') {
retention = parseInt(dbVal, 10);
}
} catch (_e) { /* DB unavailable — fall through to env */ }
if (!Number.isFinite(retention) || retention <= 0) {
retention = parseInt(process.env.BACKUP_RETENTION_COUNT, 10);
}
if (Number.isFinite(retention) && retention > 0) {
const pruneResult = pruneBackups(retention);
if (pruneResult.deleted.length) {
console.log(`[UPDATE] Pruned ${pruneResult.deleted.length} old backup(s) (retention=${retention})`);
}
}
} catch (err) {
console.warn(`[UPDATE] Auto-prune failed: ${err.message}`);
}
return { backupPath, backedUp };
}
@@ -1033,6 +1058,28 @@ function restartService(serviceName) {
}
}
/**
* Recursively compute total size in bytes of a directory.
* Returns 0 on error so the UI can still render.
*/
function getDirectorySize(dirPath) {
let total = 0;
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dirPath, entry.name);
try {
if (entry.isDirectory()) {
total += getDirectorySize(full);
} else if (entry.isFile()) {
total += fs.statSync(full).size;
}
} catch (_e) { /* skip unreadable entry */ }
}
} catch (_e) { /* skip unreadable dir */ }
return total;
}
/**
* List pre-update backups (newest first).
*/
@@ -1054,17 +1101,75 @@ function listBackups() {
sha: (m.sha || '').slice(0, 7),
timestamp: m.timestamp || '',
filesBackedUp: m.filesBackedUp || 0,
fileCount: m.filesBackedUp || 0
fileCount: m.filesBackedUp || 0,
sizeBytes: getDirectorySize(dir)
};
})
.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
}
/**
* Validate a backup directory name to prevent path traversal.
* Only allows the canonical `pre-update-{ISO-timestamp}` format.
*/
function isValidBackupName(name) {
return typeof name === 'string' && /^pre-update-[\d\-T]+$/.test(name);
}
/**
* Recursively delete a directory. Refuses to delete anything outside
* BACKUP_DIR to defend against path-traversal bugs upstream.
*/
function deleteBackup(name) {
if (!isValidBackupName(name)) {
throw new Error('Invalid backup name');
}
const target = path.resolve(BACKUP_DIR, name);
const root = path.resolve(BACKUP_DIR);
if (!target.startsWith(root + path.sep) && target !== root) {
throw new Error('Backup path is outside the backup directory');
}
if (target === root) {
throw new Error('Refusing to delete the backup directory itself');
}
if (!fs.existsSync(target)) {
throw new Error('Backup not found');
}
fs.rmSync(target, { recursive: true, force: true });
return { deleted: name };
}
/**
* Apply retention: keep the `keep` newest backups, delete older ones.
* keep <= 0 means "keep everything" (no-op).
*/
function pruneBackups(keep) {
const n = parseInt(keep, 10);
if (!Number.isFinite(n) || n <= 0) {
return { kept: -1, deleted: [] };
}
const all = listBackups();
if (all.length <= n) {
return { kept: n, deleted: [] };
}
const toDelete = all.slice(n);
const deleted = [];
for (const b of toDelete) {
try {
deleteBackup(b.name);
deleted.push(b.name);
} catch (err) {
console.error(`[UPDATE] Failed to prune backup ${b.name}: ${err.message}`);
}
}
return { kept: n, deleted };
}
/**
* Restore console files from a pre-update backup and revert the SHA.
*/
function restoreFromBackup(backupName) {
if (!/^pre-update-[\d\-T]+$/.test(backupName)) throw new Error('Invalid backup name');
if (!isValidBackupName(backupName)) throw new Error('Invalid backup name');
const backupPath = path.join(BACKUP_DIR, backupName);
if (!fs.existsSync(backupPath)) throw new Error('Backup not found');
@@ -1096,6 +1201,8 @@ module.exports = {
applyUpdate,
restartService,
listBackups,
deleteBackup,
pruneBackups,
restoreFromBackup,
getLocalVersion,
getLocalSHA,
+17
View File
@@ -753,6 +753,23 @@
<p class="settings-section-desc">${_('updates.backups_desc')}</p>
</div>
<div class="settings-section-body">
<div class="backup-retention-row">
<label for="backup-retention-input" class="backup-retention-label">
${_('updates.retention_label')}
<small class="text-muted">${_('updates.retention_hint')}</small>
</label>
<div class="backup-retention-controls">
<input type="number" id="backup-retention-input" min="0" max="1000" step="1" value="0">
<button class="btn btn-sm btn-primary" id="backup-retention-save">
${_('common.save')}
</button>
<button class="btn btn-sm btn-outline" id="backup-prune-now">
<span class="material-icons">cleaning_services</span>
${_('updates.prune_now')}
</button>
</div>
</div>
<div class="backup-summary text-muted" id="backup-summary"></div>
<div id="update-backups-list">
<p class="text-muted">${_('common.loading')}</p>
</div>