mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
})();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user