mirror of
https://github.com/plexguide/Huntarr.io.git
synced 2025-12-17 03:44:14 -06:00
backup-restore
This commit is contained in:
606
frontend/static/js/backup-restore.js
Normal file
606
frontend/static/js/backup-restore.js
Normal file
@@ -0,0 +1,606 @@
|
||||
/**
|
||||
* Backup and Restore functionality for Huntarr
|
||||
* Handles database backup creation, restoration, and management
|
||||
*/
|
||||
|
||||
const BackupRestore = {
|
||||
initialized: false,
|
||||
backupSettings: {
|
||||
frequency: 3,
|
||||
retention: 3
|
||||
},
|
||||
|
||||
initialize: function() {
|
||||
if (this.initialized) {
|
||||
console.log('[BackupRestore] Already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[BackupRestore] Initializing backup/restore functionality');
|
||||
|
||||
this.bindEvents();
|
||||
this.loadSettings();
|
||||
this.loadBackupList();
|
||||
this.updateNextBackupTime();
|
||||
|
||||
this.initialized = true;
|
||||
console.log('[BackupRestore] Initialization complete');
|
||||
},
|
||||
|
||||
bindEvents: function() {
|
||||
// Backup frequency change
|
||||
const frequencyInput = document.getElementById('backup-frequency');
|
||||
if (frequencyInput) {
|
||||
frequencyInput.addEventListener('change', () => {
|
||||
this.backupSettings.frequency = parseInt(frequencyInput.value) || 3;
|
||||
this.saveSettings();
|
||||
this.updateNextBackupTime();
|
||||
});
|
||||
}
|
||||
|
||||
// Backup retention change
|
||||
const retentionInput = document.getElementById('backup-retention');
|
||||
if (retentionInput) {
|
||||
retentionInput.addEventListener('change', () => {
|
||||
this.backupSettings.retention = parseInt(retentionInput.value) || 3;
|
||||
this.saveSettings();
|
||||
});
|
||||
}
|
||||
|
||||
// Create manual backup
|
||||
const createBackupBtn = document.getElementById('create-backup-btn');
|
||||
if (createBackupBtn) {
|
||||
createBackupBtn.addEventListener('click', () => {
|
||||
this.createManualBackup();
|
||||
});
|
||||
}
|
||||
|
||||
// Restore backup selection
|
||||
const restoreSelect = document.getElementById('restore-backup-select');
|
||||
if (restoreSelect) {
|
||||
restoreSelect.addEventListener('change', () => {
|
||||
this.handleRestoreSelection();
|
||||
});
|
||||
}
|
||||
|
||||
// Restore confirmation input
|
||||
const restoreConfirmation = document.getElementById('restore-confirmation');
|
||||
if (restoreConfirmation) {
|
||||
restoreConfirmation.addEventListener('input', () => {
|
||||
this.validateRestoreConfirmation();
|
||||
});
|
||||
}
|
||||
|
||||
// Restore button
|
||||
const restoreBtn = document.getElementById('restore-backup-btn');
|
||||
if (restoreBtn) {
|
||||
restoreBtn.addEventListener('click', () => {
|
||||
this.restoreBackup();
|
||||
});
|
||||
}
|
||||
|
||||
// Delete confirmation input
|
||||
const deleteConfirmation = document.getElementById('delete-confirmation');
|
||||
if (deleteConfirmation) {
|
||||
deleteConfirmation.addEventListener('input', () => {
|
||||
this.validateDeleteConfirmation();
|
||||
});
|
||||
}
|
||||
|
||||
// Delete database button
|
||||
const deleteBtn = document.getElementById('delete-database-btn');
|
||||
if (deleteBtn) {
|
||||
deleteBtn.addEventListener('click', () => {
|
||||
this.deleteDatabase();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
loadSettings: function() {
|
||||
console.log('[BackupRestore] Loading backup settings');
|
||||
|
||||
fetch('./api/backup/settings')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
this.backupSettings = {
|
||||
frequency: data.settings.frequency || 3,
|
||||
retention: data.settings.retention || 3
|
||||
};
|
||||
|
||||
// Update UI
|
||||
const frequencyInput = document.getElementById('backup-frequency');
|
||||
const retentionInput = document.getElementById('backup-retention');
|
||||
|
||||
if (frequencyInput) frequencyInput.value = this.backupSettings.frequency;
|
||||
if (retentionInput) retentionInput.value = this.backupSettings.retention;
|
||||
|
||||
this.updateNextBackupTime();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[BackupRestore] Error loading settings:', error);
|
||||
this.showError('Failed to load backup settings');
|
||||
});
|
||||
},
|
||||
|
||||
saveSettings: function() {
|
||||
console.log('[BackupRestore] Saving backup settings', this.backupSettings);
|
||||
|
||||
fetch('./api/backup/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(this.backupSettings)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log('[BackupRestore] Settings saved successfully');
|
||||
this.showSuccess('Backup settings saved');
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to save settings');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[BackupRestore] Error saving settings:', error);
|
||||
this.showError('Failed to save backup settings');
|
||||
});
|
||||
},
|
||||
|
||||
loadBackupList: function() {
|
||||
console.log('[BackupRestore] Loading backup list');
|
||||
|
||||
const listContainer = document.getElementById('backup-list-container');
|
||||
const restoreSelect = document.getElementById('restore-backup-select');
|
||||
|
||||
if (listContainer) {
|
||||
listContainer.innerHTML = '<div class="backup-list-loading"><i class="fas fa-spinner fa-spin"></i> Loading backup list...</div>';
|
||||
}
|
||||
|
||||
if (restoreSelect) {
|
||||
restoreSelect.innerHTML = '<option value="">Loading available backups...</option>';
|
||||
}
|
||||
|
||||
fetch('./api/backup/list')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
this.renderBackupList(data.backups);
|
||||
this.populateRestoreSelect(data.backups);
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to load backups');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[BackupRestore] Error loading backup list:', error);
|
||||
if (listContainer) {
|
||||
listContainer.innerHTML = '<div class="backup-list-loading">Error loading backup list</div>';
|
||||
}
|
||||
if (restoreSelect) {
|
||||
restoreSelect.innerHTML = '<option value="">Error loading backups</option>';
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
renderBackupList: function(backups) {
|
||||
const listContainer = document.getElementById('backup-list-container');
|
||||
if (!listContainer) return;
|
||||
|
||||
if (!backups || backups.length === 0) {
|
||||
listContainer.innerHTML = '<div class="backup-list-loading">No backups available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
backups.forEach(backup => {
|
||||
const date = new Date(backup.timestamp);
|
||||
const formattedDate = date.toLocaleString();
|
||||
const size = this.formatFileSize(backup.size);
|
||||
|
||||
html += `
|
||||
<div class="backup-item" data-backup-id="${backup.id}">
|
||||
<div class="backup-info">
|
||||
<div class="backup-name">${backup.name}</div>
|
||||
<div class="backup-details">
|
||||
Created: ${formattedDate} | Size: ${size} | Type: ${backup.type || 'Manual'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="backup-actions">
|
||||
<button class="delete-backup-btn" onclick="BackupRestore.deleteBackup('${backup.id}')">
|
||||
<i class="fas fa-trash"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
listContainer.innerHTML = html;
|
||||
},
|
||||
|
||||
populateRestoreSelect: function(backups) {
|
||||
const restoreSelect = document.getElementById('restore-backup-select');
|
||||
if (!restoreSelect) return;
|
||||
|
||||
if (!backups || backups.length === 0) {
|
||||
restoreSelect.innerHTML = '<option value="">No backups available</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<option value="">Select a backup to restore...</option>';
|
||||
backups.forEach(backup => {
|
||||
const date = new Date(backup.timestamp);
|
||||
const formattedDate = date.toLocaleString();
|
||||
const size = this.formatFileSize(backup.size);
|
||||
|
||||
html += `<option value="${backup.id}">${backup.name} - ${formattedDate} (${size})</option>`;
|
||||
});
|
||||
|
||||
restoreSelect.innerHTML = html;
|
||||
},
|
||||
|
||||
updateNextBackupTime: function() {
|
||||
const nextBackupElement = document.getElementById('next-backup-time');
|
||||
if (!nextBackupElement) return;
|
||||
|
||||
fetch('./api/backup/next-scheduled')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success && data.next_backup) {
|
||||
const nextDate = new Date(data.next_backup);
|
||||
nextBackupElement.innerHTML = `<i class="fas fa-clock"></i> ${nextDate.toLocaleString()}`;
|
||||
} else {
|
||||
nextBackupElement.innerHTML = '<i class="fas fa-clock"></i> Not scheduled';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[BackupRestore] Error getting next backup time:', error);
|
||||
nextBackupElement.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Error loading schedule';
|
||||
});
|
||||
},
|
||||
|
||||
createManualBackup: function() {
|
||||
console.log('[BackupRestore] Creating manual backup');
|
||||
|
||||
const createBtn = document.getElementById('create-backup-btn');
|
||||
const progressContainer = document.getElementById('backup-progress');
|
||||
const progressFill = document.querySelector('.progress-fill');
|
||||
const progressText = document.querySelector('.progress-text');
|
||||
|
||||
if (createBtn) createBtn.disabled = true;
|
||||
if (progressContainer) progressContainer.style.display = 'block';
|
||||
if (progressText) progressText.textContent = 'Creating backup...';
|
||||
|
||||
// Animate progress bar
|
||||
let progress = 0;
|
||||
const progressInterval = setInterval(() => {
|
||||
progress += Math.random() * 15;
|
||||
if (progress > 90) progress = 90;
|
||||
if (progressFill) progressFill.style.width = progress + '%';
|
||||
}, 200);
|
||||
|
||||
fetch('./api/backup/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'manual',
|
||||
name: `Manual Backup ${new Date().toISOString().split('T')[0]}`
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
clearInterval(progressInterval);
|
||||
|
||||
if (data.success) {
|
||||
if (progressFill) progressFill.style.width = '100%';
|
||||
if (progressText) progressText.textContent = 'Backup created successfully!';
|
||||
|
||||
setTimeout(() => {
|
||||
if (progressContainer) progressContainer.style.display = 'none';
|
||||
if (progressFill) progressFill.style.width = '0%';
|
||||
}, 2000);
|
||||
|
||||
this.showSuccess(`Backup created successfully: ${data.backup_name}`);
|
||||
this.loadBackupList(); // Refresh the list
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to create backup');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
clearInterval(progressInterval);
|
||||
console.error('[BackupRestore] Error creating backup:', error);
|
||||
|
||||
if (progressContainer) progressContainer.style.display = 'none';
|
||||
if (progressFill) progressFill.style.width = '0%';
|
||||
|
||||
this.showError('Failed to create backup: ' + error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (createBtn) createBtn.disabled = false;
|
||||
});
|
||||
},
|
||||
|
||||
handleRestoreSelection: function() {
|
||||
const restoreSelect = document.getElementById('restore-backup-select');
|
||||
const confirmationGroup = document.getElementById('restore-confirmation-group');
|
||||
const actionGroup = document.getElementById('restore-action-group');
|
||||
|
||||
if (!restoreSelect) return;
|
||||
|
||||
if (restoreSelect.value) {
|
||||
if (confirmationGroup) confirmationGroup.style.display = 'block';
|
||||
if (actionGroup) actionGroup.style.display = 'block';
|
||||
} else {
|
||||
if (confirmationGroup) confirmationGroup.style.display = 'none';
|
||||
if (actionGroup) actionGroup.style.display = 'none';
|
||||
}
|
||||
|
||||
this.validateRestoreConfirmation();
|
||||
},
|
||||
|
||||
validateRestoreConfirmation: function() {
|
||||
const confirmationInput = document.getElementById('restore-confirmation');
|
||||
const restoreBtn = document.getElementById('restore-backup-btn');
|
||||
|
||||
if (!confirmationInput || !restoreBtn) return;
|
||||
|
||||
const isValid = confirmationInput.value.toUpperCase() === 'RESTORE';
|
||||
restoreBtn.disabled = !isValid;
|
||||
|
||||
if (isValid) {
|
||||
restoreBtn.style.background = '#e74c3c';
|
||||
restoreBtn.style.cursor = 'pointer';
|
||||
} else {
|
||||
restoreBtn.style.background = '#6b7280';
|
||||
restoreBtn.style.cursor = 'not-allowed';
|
||||
}
|
||||
},
|
||||
|
||||
restoreBackup: function() {
|
||||
const restoreSelect = document.getElementById('restore-backup-select');
|
||||
const confirmationInput = document.getElementById('restore-confirmation');
|
||||
|
||||
if (!restoreSelect || !confirmationInput) return;
|
||||
|
||||
const backupId = restoreSelect.value;
|
||||
const confirmation = confirmationInput.value.toUpperCase();
|
||||
|
||||
if (!backupId || confirmation !== 'RESTORE') {
|
||||
this.showError('Please select a backup and type RESTORE to confirm');
|
||||
return;
|
||||
}
|
||||
|
||||
// Final confirmation dialog
|
||||
if (!confirm('This will permanently overwrite your current database. Are you absolutely sure?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[BackupRestore] Restoring backup:', backupId);
|
||||
|
||||
const restoreBtn = document.getElementById('restore-backup-btn');
|
||||
if (restoreBtn) {
|
||||
restoreBtn.disabled = true;
|
||||
restoreBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Restoring...';
|
||||
}
|
||||
|
||||
fetch('./api/backup/restore', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
backup_id: backupId
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
this.showSuccess('Database restored successfully! Reloading page...');
|
||||
|
||||
// Reload the page after a short delay
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to restore backup');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[BackupRestore] Error restoring backup:', error);
|
||||
this.showError('Failed to restore backup: ' + error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (restoreBtn) {
|
||||
restoreBtn.disabled = false;
|
||||
restoreBtn.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Restore Database';
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
validateDeleteConfirmation: function() {
|
||||
const confirmationInput = document.getElementById('delete-confirmation');
|
||||
const actionGroup = document.getElementById('delete-action-group');
|
||||
const deleteBtn = document.getElementById('delete-database-btn');
|
||||
|
||||
if (!confirmationInput || !actionGroup || !deleteBtn) return;
|
||||
|
||||
const isValid = confirmationInput.value.toLowerCase() === 'huntarr';
|
||||
|
||||
if (isValid) {
|
||||
actionGroup.style.display = 'block';
|
||||
deleteBtn.disabled = false;
|
||||
deleteBtn.style.background = '#e74c3c';
|
||||
deleteBtn.style.cursor = 'pointer';
|
||||
} else {
|
||||
actionGroup.style.display = 'none';
|
||||
deleteBtn.disabled = true;
|
||||
}
|
||||
},
|
||||
|
||||
deleteDatabase: function() {
|
||||
const confirmationInput = document.getElementById('delete-confirmation');
|
||||
|
||||
if (!confirmationInput || confirmationInput.value.toLowerCase() !== 'huntarr') {
|
||||
this.showError('Please type "huntarr" to confirm database deletion');
|
||||
return;
|
||||
}
|
||||
|
||||
// Final confirmation dialog
|
||||
if (!confirm('This will PERMANENTLY DELETE your entire Huntarr database. This action CANNOT be undone. Are you absolutely sure?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[BackupRestore] Deleting database');
|
||||
|
||||
const deleteBtn = document.getElementById('delete-database-btn');
|
||||
if (deleteBtn) {
|
||||
deleteBtn.disabled = true;
|
||||
deleteBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Deleting...';
|
||||
}
|
||||
|
||||
fetch('./api/backup/delete-database', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
this.showSuccess('Database deleted successfully! Redirecting to setup...');
|
||||
|
||||
// Redirect to setup after a short delay
|
||||
setTimeout(() => {
|
||||
window.location.href = './setup';
|
||||
}, 2000);
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to delete database');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[BackupRestore] Error deleting database:', error);
|
||||
this.showError('Failed to delete database: ' + error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (deleteBtn) {
|
||||
deleteBtn.disabled = false;
|
||||
deleteBtn.innerHTML = '<i class="fas fa-trash-alt"></i> Delete Database';
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
deleteBackup: function(backupId) {
|
||||
if (!confirm('Are you sure you want to delete this backup? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[BackupRestore] Deleting backup:', backupId);
|
||||
|
||||
fetch('./api/backup/delete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
backup_id: backupId
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
this.showSuccess('Backup deleted successfully');
|
||||
this.loadBackupList(); // Refresh the list
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to delete backup');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[BackupRestore] Error deleting backup:', error);
|
||||
this.showError('Failed to delete backup: ' + error.message);
|
||||
});
|
||||
},
|
||||
|
||||
formatFileSize: function(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
},
|
||||
|
||||
showSuccess: function(message) {
|
||||
this.showNotification(message, 'success');
|
||||
},
|
||||
|
||||
showError: function(message) {
|
||||
this.showNotification(message, 'error');
|
||||
},
|
||||
|
||||
showNotification: function(message, type) {
|
||||
// Create notification element
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `backup-notification ${type}`;
|
||||
notification.innerHTML = `
|
||||
<i class="fas ${type === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle'}"></i>
|
||||
<span>${message}</span>
|
||||
<button onclick="this.parentElement.remove()">×</button>
|
||||
`;
|
||||
|
||||
// Add styles
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: ${type === 'success' ? '#10b981' : '#e74c3c'};
|
||||
color: white;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: 400px;
|
||||
animation: slideIn 0.3s ease;
|
||||
`;
|
||||
|
||||
// Add animation styles
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
.backup-notification button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (notification.parentElement) {
|
||||
notification.remove();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Don't auto-initialize - let the main UI handle it
|
||||
console.log('[BackupRestore] Module loaded');
|
||||
});
|
||||
@@ -86,7 +86,7 @@ let huntarrUI = {
|
||||
|
||||
// Check which sidebar should be shown based on current section
|
||||
console.log(`[huntarrUI] Initialization - current section: ${this.currentSection}`);
|
||||
if (this.currentSection === 'settings' || this.currentSection === 'scheduling' || this.currentSection === 'notifications' || this.currentSection === 'user') {
|
||||
if (this.currentSection === 'settings' || this.currentSection === 'scheduling' || this.currentSection === 'notifications' || this.currentSection === 'backup-restore' || this.currentSection === 'user') {
|
||||
console.log('[huntarrUI] Initialization - showing settings sidebar');
|
||||
this.showSettingsSidebar();
|
||||
} else if (this.currentSection === 'requestarr' || this.currentSection === 'requestarr-history') {
|
||||
@@ -781,6 +781,21 @@ let huntarrUI = {
|
||||
|
||||
// Initialize notifications settings if not already done
|
||||
this.initializeNotifications();
|
||||
} else if (section === 'backup-restore' && document.getElementById('backupRestoreSection')) {
|
||||
document.getElementById('backupRestoreSection').classList.add('active');
|
||||
document.getElementById('backupRestoreSection').style.display = 'block';
|
||||
if (document.getElementById('settingsBackupRestoreNav')) document.getElementById('settingsBackupRestoreNav').classList.add('active');
|
||||
newTitle = 'Backup / Restore';
|
||||
this.currentSection = 'backup-restore';
|
||||
|
||||
// Switch to Settings sidebar for backup/restore
|
||||
this.showSettingsSidebar();
|
||||
|
||||
// Set localStorage to maintain Settings sidebar preference
|
||||
localStorage.setItem('huntarr-settings-sidebar', 'true');
|
||||
|
||||
// Initialize backup/restore functionality if not already done
|
||||
this.initializeBackupRestore();
|
||||
} else if (section === 'prowlarr' && document.getElementById('prowlarrSection')) {
|
||||
document.getElementById('prowlarrSection').classList.add('active');
|
||||
document.getElementById('prowlarrSection').style.display = 'block';
|
||||
@@ -5005,6 +5020,17 @@ let huntarrUI = {
|
||||
});
|
||||
},
|
||||
|
||||
initializeBackupRestore: function() {
|
||||
console.log('[huntarrUI] initializeBackupRestore called');
|
||||
|
||||
// Initialize backup/restore functionality
|
||||
if (typeof BackupRestore !== 'undefined') {
|
||||
BackupRestore.initialize();
|
||||
} else {
|
||||
console.error('[huntarrUI] BackupRestore module not loaded');
|
||||
}
|
||||
},
|
||||
|
||||
initializeProwlarr: function() {
|
||||
console.log('[huntarrUI] initializeProwlarr called');
|
||||
|
||||
|
||||
390
frontend/templates/components/backup_restore_section.html
Normal file
390
frontend/templates/components/backup_restore_section.html
Normal file
@@ -0,0 +1,390 @@
|
||||
<section id="backupRestoreSection" class="content-section" style="display: none;">
|
||||
<div class="section-header">
|
||||
<h2><i class="fas fa-database"></i> Backup / Restore</h2>
|
||||
<p>Manage database backups and restore points for your Huntarr installation.</p>
|
||||
</div>
|
||||
|
||||
<div class="backup-restore-container">
|
||||
<!-- Backup Settings -->
|
||||
<div class="settings-group">
|
||||
<h3><i class="fas fa-clock"></i> Automatic Backup Settings</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="backup-frequency">
|
||||
<a href="https://plexguide.github.io/Huntarr.io/settings/backup-restore.html#backup-frequency"
|
||||
class="info-icon" target="_blank" rel="noopener">ⓘ</a>
|
||||
Backup Frequency (Days)
|
||||
</label>
|
||||
<input type="number" id="backup-frequency" min="1" max="30" value="3"
|
||||
placeholder="Enter number of days between backups">
|
||||
<small>How often Huntarr should automatically create database backups (default: 3 days)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="backup-retention">
|
||||
<a href="https://plexguide.github.io/Huntarr.io/settings/backup-restore.html#backup-retention"
|
||||
class="info-icon" target="_blank" rel="noopener">ⓘ</a>
|
||||
Backup Retention Count
|
||||
</label>
|
||||
<input type="number" id="backup-retention" min="1" max="10" value="3"
|
||||
placeholder="Number of backups to keep">
|
||||
<small>Number of backup copies to keep. Older backups are automatically deleted (default: 3)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Next Scheduled Backup</label>
|
||||
<div id="next-backup-time" class="info-display">
|
||||
<i class="fas fa-spinner fa-spin"></i> Loading...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual Backup -->
|
||||
<div class="settings-group">
|
||||
<h3><i class="fas fa-save"></i> Manual Backup</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Create Backup Now</label>
|
||||
<button type="button" id="create-backup-btn" class="action-btn backup-btn">
|
||||
<i class="fas fa-plus-circle"></i> Create Manual Backup
|
||||
</button>
|
||||
<small>Create an immediate backup of all databases</small>
|
||||
</div>
|
||||
|
||||
<div id="backup-progress" class="progress-container" style="display: none;">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill"></div>
|
||||
</div>
|
||||
<div class="progress-text">Creating backup...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restore Database -->
|
||||
<div class="settings-group">
|
||||
<h3><i class="fas fa-undo"></i> Restore Database</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="restore-backup-select">
|
||||
<a href="https://plexguide.github.io/Huntarr.io/settings/backup-restore.html#restore-database"
|
||||
class="info-icon" target="_blank" rel="noopener">ⓘ</a>
|
||||
Select Backup to Restore
|
||||
</label>
|
||||
<select id="restore-backup-select">
|
||||
<option value="">Loading available backups...</option>
|
||||
</select>
|
||||
<small>Select a backup to restore. This will overwrite your current database!</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="restore-confirmation-group" style="display: none;">
|
||||
<label for="restore-confirmation">
|
||||
Type "RESTORE" to confirm (this will overwrite current data)
|
||||
</label>
|
||||
<input type="text" id="restore-confirmation" placeholder="Type RESTORE to confirm"
|
||||
style="border: 2px solid #e74c3c;">
|
||||
<div class="warning-box">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<strong>Warning:</strong> This action will permanently overwrite your current database with the
|
||||
selected backup.
|
||||
All current data will be lost. Make sure you have a recent backup before proceeding.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="restore-action-group" style="display: none;">
|
||||
<button type="button" id="restore-backup-btn" class="action-btn danger-btn" disabled>
|
||||
<i class="fas fa-exclamation-triangle"></i> Restore Database
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Destructive Actions -->
|
||||
<div class="settings-group danger-zone">
|
||||
<h3><i class="fas fa-trash-alt"></i> Danger Zone</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Delete Current Database</label>
|
||||
<p class="danger-description">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
This will permanently delete your current database. Use this for testing or complete reset purposes
|
||||
only.
|
||||
</p>
|
||||
|
||||
<div id="delete-confirmation-group">
|
||||
<label for="delete-confirmation">
|
||||
Type "huntarr" to enable deletion
|
||||
</label>
|
||||
<input type="text" id="delete-confirmation" placeholder="Type huntarr to confirm"
|
||||
style="border: 2px solid #e74c3c;">
|
||||
</div>
|
||||
|
||||
<div id="delete-action-group" style="display: none;">
|
||||
<button type="button" id="delete-database-btn" class="action-btn danger-btn" disabled>
|
||||
<i class="fas fa-trash-alt"></i> Delete Database
|
||||
</button>
|
||||
<div class="warning-box">
|
||||
<i class="fas fa-skull-crossbones"></i>
|
||||
<strong>FINAL WARNING:</strong> This action cannot be undone. Your entire Huntarr database will
|
||||
be permanently deleted.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup List -->
|
||||
<div class="settings-group">
|
||||
<h3><i class="fas fa-list"></i> Available Backups</h3>
|
||||
|
||||
<div id="backup-list-container">
|
||||
<div class="backup-list-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i> Loading backup list...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.backup-restore-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.settings-group h3 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 15px;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.backup-btn {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.backup-btn:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.danger-btn {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.danger-btn:hover:not(:disabled) {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.danger-btn:disabled {
|
||||
background: #6b7280;
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.danger-zone {
|
||||
border-color: #e74c3c;
|
||||
background: rgba(231, 76, 60, 0.05);
|
||||
}
|
||||
|
||||
.danger-zone h3 {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.danger-description {
|
||||
color: #e74c3c;
|
||||
font-weight: 500;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
background: rgba(231, 76, 60, 0.1);
|
||||
border: 1px solid #e74c3c;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin-top: 10px;
|
||||
color: #e74c3c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-display {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #10b981;
|
||||
width: 0%;
|
||||
transition: width 0.3s ease;
|
||||
animation: progress-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes progress-pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.backup-list-loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.backup-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.backup-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.backup-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.backup-details {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.backup-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.backup-actions button {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.delete-backup-btn {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.delete-backup-btn:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
margin-right: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.info-icon:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
/* Mobile responsiveness */
|
||||
@media (max-width: 768px) {
|
||||
.backup-restore-container {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.backup-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.backup-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</section>
|
||||
@@ -293,6 +293,13 @@
|
||||
<span>Notifications</span>
|
||||
</a>
|
||||
|
||||
<a href="./#backup-restore" class="nav-item" id="settingsBackupRestoreNav">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="fas fa-database"></i>
|
||||
</div>
|
||||
<span>Backup / Restore</span>
|
||||
</a>
|
||||
|
||||
<a href="./user" class="nav-item" id="settingsUserNav">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="fas fa-user"></i>
|
||||
|
||||
@@ -66,6 +66,9 @@
|
||||
<!-- Notifications Section -->
|
||||
{% include 'components/notifications_section.html' %}
|
||||
|
||||
<!-- Backup / Restore Section -->
|
||||
{% include 'components/backup_restore_section.html' %}
|
||||
|
||||
<!-- Prowlarr Section -->
|
||||
{% include 'components/prowlarr_section.html' %}
|
||||
|
||||
@@ -83,6 +86,8 @@
|
||||
<script src="./static/js/apps.js"></script>
|
||||
<!-- Load logging module -->
|
||||
<script src="./static/js/logs.js"></script>
|
||||
<!-- Load backup/restore module -->
|
||||
<script src="./static/js/backup-restore.js"></script>
|
||||
<!-- Load main UI script -->
|
||||
<script src="./static/js/new-main.js"></script>
|
||||
<!-- Load API progress bar enhancement -->
|
||||
|
||||
335
rules.md
335
rules.md
@@ -31,14 +31,15 @@ docker logs huntarr
|
||||
#### **Sidebar Types:**
|
||||
1. **Main Sidebar** (`#sidebar`) - Default navigation (Home, Apps, Swaparr, Requestarr, etc.)
|
||||
2. **Apps Sidebar** (`#apps-sidebar`) - App-specific navigation (Sonarr, Radarr, Lidarr, etc.)
|
||||
3. **Settings Sidebar** (`#settings-sidebar`) - Settings navigation (Main, Scheduling, Notifications, User)
|
||||
3. **Settings Sidebar** (`#settings-sidebar`) - Settings navigation (Main, Scheduling, Notifications, Backup/Restore, User)
|
||||
4. **Requestarr Sidebar** (`#requestarr-sidebar`) - Requestarr navigation (Home, History)
|
||||
|
||||
#### **Navigation Logic Pattern:**
|
||||
```javascript
|
||||
// CRITICAL: All sidebar sections must be included in initialization logic
|
||||
if (this.currentSection === 'settings' || this.currentSection === 'scheduling' ||
|
||||
this.currentSection === 'notifications' || this.currentSection === 'user') {
|
||||
this.currentSection === 'notifications' || this.currentSection === 'backup-restore' ||
|
||||
this.currentSection === 'user') {
|
||||
this.showSettingsSidebar();
|
||||
} else if (this.currentSection === 'requestarr' || this.currentSection === 'requestarr-history') {
|
||||
this.showRequestarrSidebar();
|
||||
@@ -52,6 +53,12 @@ if (this.currentSection === 'settings' || this.currentSection === 'scheduling' |
|
||||
}
|
||||
```
|
||||
|
||||
#### **Apps Navigation Behavior:**
|
||||
- **Clicking "Apps"** → Automatically redirects to Sonarr (most commonly used app)
|
||||
- **Apps nav item stays highlighted** when viewing any app (Sonarr, Radarr, etc.)
|
||||
- **Apps sidebar provides navigation** between individual apps
|
||||
- **Direct URLs still work** (`#sonarr`, `#radarr`, etc.) for bookmarking specific apps
|
||||
|
||||
#### **Sidebar Switching Functions:**
|
||||
```javascript
|
||||
showMainSidebar: function() {
|
||||
@@ -415,6 +422,263 @@ docker exec huntarr du -h /config/huntarr.db
|
||||
5. **Unique constraints prevent duplicates** - app_name combinations are unique
|
||||
6. **Transactions for consistency** - DatabaseManager handles commit/rollback
|
||||
|
||||
## 💾 BACKUP & RESTORE SYSTEM
|
||||
|
||||
### Overview
|
||||
**Huntarr includes a comprehensive backup and restore system for database protection and recovery.**
|
||||
|
||||
**Key Features:**
|
||||
- **Automatic scheduled backups** based on user-defined frequency
|
||||
- **Manual backup creation** with progress tracking
|
||||
- **Multi-database backup** (huntarr.db, logs.db, manager.db)
|
||||
- **Database integrity verification** before and after operations
|
||||
- **Cross-platform backup storage** with environment detection
|
||||
- **Backup retention management** with automatic cleanup
|
||||
- **Safe restoration** with pre-restore backup creation
|
||||
|
||||
### Backup Storage Structure
|
||||
```
|
||||
/config/backups/ # Docker environment
|
||||
├── scheduled_backup_2025-08-24_02-05-16/ # Backup folder (timestamp-based)
|
||||
│ ├── backup_info.json # Backup metadata
|
||||
│ ├── huntarr.db # Main database backup
|
||||
│ ├── logs.db # Logs database backup
|
||||
│ └── manager.db # Manager database backup (if exists)
|
||||
└── manual_backup_2025-08-24_14-30-00/ # Manual backup folder
|
||||
├── backup_info.json
|
||||
└── [database files...]
|
||||
```
|
||||
|
||||
**Storage Locations:**
|
||||
- **Docker:** `/config/backups/` (persistent volume)
|
||||
- **Windows:** `%APPDATA%/Huntarr/backups/`
|
||||
- **Local Development:** `{project_root}/data/backups/`
|
||||
|
||||
### Backup System Architecture
|
||||
|
||||
#### **BackupManager Class** (`src/routes/backup_routes.py`)
|
||||
```python
|
||||
class BackupManager:
|
||||
def create_backup(self, backup_type='manual', name=None) # Creates verified backup
|
||||
def restore_backup(self, backup_id) # Restores with pre-backup
|
||||
def list_backups(self) # Lists available backups
|
||||
def delete_backup(self, backup_id) # Deletes specific backup
|
||||
def get_backup_settings(self) # Gets user settings
|
||||
def save_backup_settings(self, settings) # Saves user settings
|
||||
def _cleanup_old_backups(self) # Retention management
|
||||
```
|
||||
|
||||
#### **BackupScheduler Class** (`src/routes/backup_routes.py`)
|
||||
```python
|
||||
class BackupScheduler:
|
||||
def start(self) # Starts background scheduler thread
|
||||
def stop(self) # Gracefully stops scheduler
|
||||
def _should_create_backup(self) # Checks if backup is due
|
||||
def _scheduler_loop(self) # Main scheduling loop (hourly checks)
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
```python
|
||||
# Backup Settings
|
||||
GET/POST /api/backup/settings # Get/set backup frequency & retention
|
||||
|
||||
# Backup Operations
|
||||
POST /api/backup/create # Create manual backup
|
||||
GET /api/backup/list # List available backups
|
||||
POST /api/backup/restore # Restore from backup
|
||||
POST /api/backup/delete # Delete specific backup
|
||||
GET /api/backup/next-scheduled # Get next scheduled backup time
|
||||
|
||||
# Destructive Operations
|
||||
POST /api/backup/delete-database # Delete current database (testing/reset)
|
||||
```
|
||||
|
||||
### Frontend Integration
|
||||
|
||||
#### **Settings Navigation** (`frontend/templates/components/sidebar.html`)
|
||||
```html
|
||||
<!-- Added to Settings Sidebar -->
|
||||
<a href="./#backup-restore" class="nav-item" id="settingsBackupRestoreNav">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="fas fa-database"></i>
|
||||
</div>
|
||||
<span>Backup / Restore</span>
|
||||
</a>
|
||||
```
|
||||
|
||||
#### **Section Handling** (`frontend/static/js/new-main.js`)
|
||||
```javascript
|
||||
// CRITICAL: backup-restore must be included in settings sections
|
||||
if (this.currentSection === 'settings' || this.currentSection === 'scheduling' ||
|
||||
this.currentSection === 'notifications' || this.currentSection === 'backup-restore' ||
|
||||
this.currentSection === 'user') {
|
||||
this.showSettingsSidebar();
|
||||
}
|
||||
|
||||
// Section initialization
|
||||
} else if (section === 'backup-restore' && document.getElementById('backupRestoreSection')) {
|
||||
document.getElementById('backupRestoreSection').classList.add('active');
|
||||
document.getElementById('backupRestoreSection').style.display = 'block';
|
||||
if (document.getElementById('settingsBackupRestoreNav'))
|
||||
document.getElementById('settingsBackupRestoreNav').classList.add('active');
|
||||
this.currentSection = 'backup-restore';
|
||||
this.showSettingsSidebar();
|
||||
this.initializeBackupRestore();
|
||||
}
|
||||
```
|
||||
|
||||
#### **JavaScript Module** (`frontend/static/js/backup-restore.js`)
|
||||
```javascript
|
||||
const BackupRestore = {
|
||||
initialize: function() // Main initialization
|
||||
createManualBackup: function() // Manual backup with progress
|
||||
restoreBackup: function() // Restore with confirmations
|
||||
deleteDatabase: function() # Destructive operation with safeguards
|
||||
loadBackupList: function() // Refresh backup list
|
||||
validateRestoreConfirmation: function() // "RESTORE" confirmation
|
||||
validateDeleteConfirmation: function() // "huntarr" confirmation
|
||||
}
|
||||
```
|
||||
|
||||
### Safety & Security Features
|
||||
|
||||
#### **Multi-Level Confirmations**
|
||||
1. **Restore Operations:**
|
||||
- Select backup from dropdown
|
||||
- Type "RESTORE" to enable button
|
||||
- Final browser confirmation dialog
|
||||
- Pre-restore backup creation
|
||||
|
||||
2. **Database Deletion:**
|
||||
- Type "huntarr" to enable deletion
|
||||
- Final browser confirmation dialog
|
||||
- Multiple warning messages
|
||||
|
||||
#### **Data Integrity**
|
||||
```python
|
||||
def _verify_database_integrity(self, db_path):
|
||||
"""Verify database integrity using SQLite PRAGMA"""
|
||||
conn = sqlite3.connect(db_path)
|
||||
result = conn.execute("PRAGMA integrity_check").fetchone()
|
||||
return result and result[0] == "ok"
|
||||
```
|
||||
|
||||
#### **Backup Verification Process**
|
||||
1. **Pre-backup:** Force WAL checkpoint
|
||||
2. **During backup:** Copy all database files
|
||||
3. **Post-backup:** Verify each backup file integrity
|
||||
4. **Cleanup:** Remove corrupted backups automatically
|
||||
|
||||
### Configuration & Settings
|
||||
|
||||
#### **Default Settings**
|
||||
```python
|
||||
backup_settings = {
|
||||
'frequency': 3, # Days between automatic backups
|
||||
'retention': 3 # Number of backups to keep
|
||||
}
|
||||
```
|
||||
|
||||
#### **Settings Storage** (Database)
|
||||
```sql
|
||||
-- Stored in general_settings table
|
||||
INSERT INTO general_settings (setting_key, setting_value) VALUES
|
||||
('backup_frequency', '3'),
|
||||
('backup_retention', '3'),
|
||||
('last_backup_time', '2025-08-24T02:05:16');
|
||||
```
|
||||
|
||||
### Troubleshooting Backup Issues
|
||||
|
||||
#### **Common Problems & Solutions**
|
||||
|
||||
1. **Backup Directory Not Writable**
|
||||
```python
|
||||
# System falls back to alternative locations
|
||||
# Windows: Documents/Huntarr/ if AppData fails
|
||||
# Check logs for "Database directory not writable"
|
||||
```
|
||||
|
||||
2. **Database Corruption During Backup**
|
||||
```bash
|
||||
# Check integrity manually
|
||||
docker exec huntarr sqlite3 /config/huntarr.db "PRAGMA integrity_check"
|
||||
|
||||
# Backup system auto-detects and handles corruption
|
||||
# Creates corrupted_backup_[timestamp].db for recovery
|
||||
```
|
||||
|
||||
3. **Scheduler Not Running**
|
||||
```bash
|
||||
# Check logs for scheduler startup
|
||||
docker logs huntarr | grep -i "backup scheduler"
|
||||
|
||||
# Should see: "Backup scheduler started"
|
||||
```
|
||||
|
||||
4. **Restore Failures**
|
||||
```python
|
||||
# System creates pre-restore backup automatically
|
||||
# Check /config/backups/ for pre_restore_backup_[timestamp] folders
|
||||
# Original data preserved even if restore fails
|
||||
```
|
||||
|
||||
#### **Debugging Commands**
|
||||
```bash
|
||||
# Check backup directory
|
||||
docker exec huntarr ls -la /config/backups/
|
||||
|
||||
# Verify backup integrity
|
||||
docker exec huntarr sqlite3 /config/backups/[backup_folder]/huntarr.db "PRAGMA integrity_check"
|
||||
|
||||
# Check backup settings
|
||||
docker exec huntarr sqlite3 /config/huntarr.db "SELECT * FROM general_settings WHERE setting_key LIKE 'backup_%'"
|
||||
|
||||
# Monitor backup scheduler
|
||||
docker logs huntarr | grep -i backup
|
||||
```
|
||||
|
||||
### Development Guidelines for Backup System
|
||||
|
||||
#### **Adding New Database Files**
|
||||
```python
|
||||
def _get_all_database_paths(self):
|
||||
"""Update this method when adding new database files"""
|
||||
databases = {
|
||||
'huntarr': str(main_db_path),
|
||||
'logs': str(logs_db_path),
|
||||
'manager': str(manager_db_path),
|
||||
'new_db': str(new_db_path) # Add new databases here
|
||||
}
|
||||
return databases
|
||||
```
|
||||
|
||||
#### **Backup Metadata Format**
|
||||
```json
|
||||
{
|
||||
"id": "backup_name",
|
||||
"name": "backup_name",
|
||||
"type": "manual|scheduled|pre-restore",
|
||||
"timestamp": "2025-08-24T02:05:16",
|
||||
"databases": [
|
||||
{
|
||||
"name": "huntarr",
|
||||
"size": 249856,
|
||||
"path": "/config/backups/backup_name/huntarr.db"
|
||||
}
|
||||
],
|
||||
"size": 1331200
|
||||
}
|
||||
```
|
||||
|
||||
#### **Critical Requirements**
|
||||
1. **Always verify backup integrity** before considering backup complete
|
||||
2. **Create pre-restore backup** before any restore operation
|
||||
3. **Use cross-platform paths** - never hardcode `/config/`
|
||||
4. **Handle backup corruption gracefully** with user notifications
|
||||
5. **Implement proper cleanup** based on retention settings
|
||||
6. **Provide clear user feedback** for all operations
|
||||
|
||||
## 🔧 DEVELOPMENT WORKFLOW
|
||||
|
||||
### Before Any Changes
|
||||
@@ -438,6 +702,11 @@ docker exec huntarr du -h /config/huntarr.db
|
||||
- [ ] Test subpath scenarios (`domain.com/huntarr/`)
|
||||
- [ ] Check browser console for errors
|
||||
- [ ] **NEW:** Verify database persistence across container restarts
|
||||
- [ ] **NEW:** Test backup/restore functionality if modified:
|
||||
- [ ] Verify backup creation and integrity
|
||||
- [ ] Test backup scheduler startup
|
||||
- [ ] Verify backup directory creation (`/config/backups/`)
|
||||
- [ ] Test restore confirmation workflow
|
||||
- [ ] Get user approval before committing
|
||||
|
||||
### Proactive Violation Scanning
|
||||
@@ -489,15 +758,20 @@ grep -r "fetch('/api/" frontend/ --include="*.js"
|
||||
- `/src/primary/cycle_tracker.py` - Timer functionality
|
||||
- `/src/primary/utils/logger.py` - Logging configuration
|
||||
- `/src/primary/utils/database.py` - **NEW:** DatabaseManager class (replaces settings_manager.py)
|
||||
- `/src/routes/backup_routes.py` - **NEW:** Backup & Restore API endpoints and BackupManager
|
||||
|
||||
### Frontend Core
|
||||
- `/frontend/static/js/new-main.js` - Main UI logic
|
||||
- `/frontend/static/js/settings_forms.js` - Settings forms
|
||||
- `/frontend/static/js/backup-restore.js` - **NEW:** Backup & Restore functionality
|
||||
- `/frontend/templates/components/` - UI components
|
||||
- `/frontend/templates/components/backup_restore_section.html` - **NEW:** Backup & Restore UI
|
||||
|
||||
### Database & Storage
|
||||
- `/config/huntarr.db` - **Docker:** Main database file (persistent)
|
||||
- `./data/huntarr.db` - **Local:** Main database file (development)
|
||||
- `/config/backups/` - **Docker:** Backup storage directory (persistent)
|
||||
- `./data/backups/` - **Local:** Backup storage directory (development)
|
||||
- `/src/primary/utils/database.py` - DatabaseManager with auto-detection
|
||||
- **REMOVED:** All JSON files (settings.json, stateful.json, etc.)
|
||||
|
||||
@@ -702,6 +976,63 @@ grep -r 'id="[^"]*"' docs/apps/ | grep -o 'id="[^"]*"' | sort | uniq
|
||||
- Updated `src/primary/apps/radarr/upgrade.py` to check release dates
|
||||
- Both missing and upgrade searches now respect `skip_future_releases` and `process_no_release_dates`
|
||||
- Documentation updated to clarify behavior affects both search types
|
||||
|
||||
### Apps Navigation Redesign (2024-12)
|
||||
**Issue:** Apps section showed a dashboard that users had to navigate through to reach individual app settings
|
||||
**User Feedback:** "Instead of an apps dashboard; make it where when a user clicks apps, it goes to the sonarr apps being selected by default"
|
||||
**Solution:** Direct navigation to Sonarr when clicking Apps, eliminating the dashboard step
|
||||
|
||||
**Implementation Changes:**
|
||||
|
||||
1. **Modified Apps Section Navigation** (`frontend/static/js/new-main.js`):
|
||||
```javascript
|
||||
// OLD: Showed apps dashboard
|
||||
} else if (section === 'apps') {
|
||||
document.getElementById('appsSection').classList.add('active');
|
||||
// ... dashboard logic
|
||||
|
||||
// NEW: Direct redirect to Sonarr
|
||||
} else if (section === 'apps') {
|
||||
console.log('[huntarrUI] Apps section requested - redirecting to Sonarr by default');
|
||||
this.switchSection('sonarr');
|
||||
window.location.hash = '#sonarr';
|
||||
return;
|
||||
```
|
||||
|
||||
2. **Updated Navigation Highlighting** (`frontend/templates/components/sidebar.html`):
|
||||
```javascript
|
||||
// Keep "Apps" nav item active when viewing Sonarr
|
||||
} else if (currentHash === '#apps' || currentHash === '#sonarr') {
|
||||
selector = '#appsNav';
|
||||
```
|
||||
|
||||
3. **Preserved Apps Sidebar Functionality**:
|
||||
- Apps sidebar still provides navigation between all apps (Sonarr, Radarr, Lidarr, etc.)
|
||||
- Return button allows going back to main navigation
|
||||
- Individual app sections remain fully functional
|
||||
|
||||
**User Experience Improvements:**
|
||||
- **Faster Access**: Click "Apps" → Immediately see Sonarr settings (most commonly used)
|
||||
- **Intuitive Navigation**: Apps nav item stays highlighted when viewing any app
|
||||
- **Preserved Functionality**: All apps still accessible via Apps sidebar
|
||||
- **Consistent Behavior**: Maintains existing sidebar switching patterns
|
||||
|
||||
**Navigation Flow:**
|
||||
```
|
||||
Main Sidebar: Apps → Sonarr (default)
|
||||
Apps Sidebar: Sonarr ↔ Radarr ↔ Lidarr ↔ Readarr ↔ Whisparr V2 ↔ Whisparr V3 ↔ Prowlarr
|
||||
```
|
||||
|
||||
**Files Modified:**
|
||||
- `frontend/static/js/new-main.js` - Apps section redirect logic
|
||||
- `frontend/templates/components/sidebar.html` - Navigation highlighting logic
|
||||
- Removed dashboard functionality from `frontend/templates/components/apps_section.html`
|
||||
|
||||
**Benefits:**
|
||||
- Eliminates unnecessary dashboard step
|
||||
- Provides direct access to most commonly used app (Sonarr)
|
||||
- Maintains all existing functionality through sidebar navigation
|
||||
- Improves user workflow efficiency
|
||||
- Frontend info icons fixed to use GitHub documentation links
|
||||
|
||||
**User Benefit:** Consistent behavior - no more unexpected future movie upgrades
|
||||
|
||||
@@ -50,6 +50,9 @@ from src.primary.routes.history_routes import history_blueprint
|
||||
# Import scheduler blueprint
|
||||
from src.primary.routes.scheduler_routes import scheduler_api
|
||||
|
||||
# Import backup blueprint
|
||||
from src.routes.backup_routes import backup_bp
|
||||
|
||||
# Import log routes blueprint
|
||||
from src.primary.routes.log_routes import log_routes_bp
|
||||
|
||||
@@ -277,6 +280,7 @@ app.register_blueprint(stateful_api, url_prefix='/api/stateful')
|
||||
app.register_blueprint(history_blueprint, url_prefix='/api/hunt-manager')
|
||||
app.register_blueprint(scheduler_api)
|
||||
app.register_blueprint(log_routes_bp)
|
||||
app.register_blueprint(backup_bp)
|
||||
|
||||
# Register the authentication check to run before requests
|
||||
app.before_request(authenticate_request)
|
||||
|
||||
629
src/routes/backup_routes.py
Normal file
629
src/routes/backup_routes.py
Normal file
@@ -0,0 +1,629 @@
|
||||
"""
|
||||
Backup and Restore API routes for Huntarr
|
||||
Handles database backup creation, restoration, and management
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import sqlite3
|
||||
import time
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from flask import Blueprint, request, jsonify
|
||||
from src.primary.utils.database import get_database
|
||||
from src.primary.routes.common import get_user_for_request
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
backup_bp = Blueprint('backup', __name__)
|
||||
|
||||
class BackupScheduler:
|
||||
"""Handles automatic backup scheduling"""
|
||||
|
||||
def __init__(self, backup_manager):
|
||||
self.backup_manager = backup_manager
|
||||
self.scheduler_thread = None
|
||||
self.stop_event = threading.Event()
|
||||
self.running = False
|
||||
|
||||
def start(self):
|
||||
"""Start the backup scheduler"""
|
||||
if self.running:
|
||||
return
|
||||
|
||||
self.stop_event.clear()
|
||||
self.scheduler_thread = threading.Thread(target=self._scheduler_loop, daemon=True)
|
||||
self.scheduler_thread.start()
|
||||
self.running = True
|
||||
logger.info("Backup scheduler started")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the backup scheduler"""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
self.stop_event.set()
|
||||
if self.scheduler_thread:
|
||||
self.scheduler_thread.join(timeout=5)
|
||||
self.running = False
|
||||
logger.info("Backup scheduler stopped")
|
||||
|
||||
def _scheduler_loop(self):
|
||||
"""Main scheduler loop"""
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
if self._should_create_backup():
|
||||
logger.info("Creating scheduled backup")
|
||||
backup_info = self.backup_manager.create_backup('scheduled', None)
|
||||
|
||||
# Update last backup time
|
||||
self.backup_manager.db.set_general_setting('last_backup_time', backup_info['timestamp'])
|
||||
logger.info(f"Scheduled backup created: {backup_info['name']}")
|
||||
|
||||
# Check every hour
|
||||
self.stop_event.wait(3600)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in backup scheduler: {e}")
|
||||
# Wait before retrying
|
||||
self.stop_event.wait(300) # 5 minutes
|
||||
|
||||
def _should_create_backup(self):
|
||||
"""Check if a backup should be created"""
|
||||
try:
|
||||
settings = self.backup_manager.get_backup_settings()
|
||||
frequency_days = settings['frequency']
|
||||
|
||||
last_backup_time = self.backup_manager.db.get_general_setting('last_backup_time')
|
||||
|
||||
if not last_backup_time:
|
||||
# No previous backup, create one
|
||||
return True
|
||||
|
||||
last_backup = datetime.fromisoformat(last_backup_time)
|
||||
next_backup = last_backup + timedelta(days=frequency_days)
|
||||
|
||||
return datetime.now() >= next_backup
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking backup schedule: {e}")
|
||||
return False
|
||||
|
||||
# Global backup scheduler instance
|
||||
backup_scheduler = None
|
||||
|
||||
class BackupManager:
|
||||
"""Manages database backups and restoration"""
|
||||
|
||||
def __init__(self):
|
||||
self.db = get_database()
|
||||
self.backup_dir = self._get_backup_directory()
|
||||
self.ensure_backup_directory()
|
||||
|
||||
def _get_backup_directory(self):
|
||||
"""Get the backup directory path based on environment"""
|
||||
# Check if running in Docker (config directory exists)
|
||||
config_dir = Path("/config")
|
||||
if config_dir.exists() and config_dir.is_dir():
|
||||
return config_dir / "backups"
|
||||
|
||||
# Check Windows AppData
|
||||
import platform
|
||||
if platform.system() == "Windows":
|
||||
appdata = os.environ.get("APPDATA", os.path.expanduser("~"))
|
||||
windows_config_dir = Path(appdata) / "Huntarr"
|
||||
return windows_config_dir / "backups"
|
||||
|
||||
# For local development, use data directory in project root
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
data_dir = project_root / "data"
|
||||
return data_dir / "backups"
|
||||
|
||||
def ensure_backup_directory(self):
|
||||
"""Ensure backup directory exists"""
|
||||
try:
|
||||
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"Backup directory ensured: {self.backup_dir}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create backup directory: {e}")
|
||||
raise
|
||||
|
||||
def get_backup_settings(self):
|
||||
"""Get backup settings from database"""
|
||||
try:
|
||||
frequency = self.db.get_general_setting('backup_frequency', 3)
|
||||
retention = self.db.get_general_setting('backup_retention', 3)
|
||||
|
||||
return {
|
||||
'frequency': int(frequency),
|
||||
'retention': int(retention)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting backup settings: {e}")
|
||||
return {'frequency': 3, 'retention': 3}
|
||||
|
||||
def save_backup_settings(self, settings):
|
||||
"""Save backup settings to database"""
|
||||
try:
|
||||
self.db.set_general_setting('backup_frequency', settings.get('frequency', 3))
|
||||
self.db.set_general_setting('backup_retention', settings.get('retention', 3))
|
||||
logger.info(f"Backup settings saved: {settings}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving backup settings: {e}")
|
||||
return False
|
||||
|
||||
def create_backup(self, backup_type='manual', name=None):
|
||||
"""Create a backup of all databases"""
|
||||
try:
|
||||
# Generate backup name if not provided
|
||||
if not name:
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
name = f"{backup_type}_backup_{timestamp}"
|
||||
|
||||
# Create backup folder with timestamp
|
||||
backup_folder = self.backup_dir / name
|
||||
backup_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Get all database paths
|
||||
databases = self._get_all_database_paths()
|
||||
|
||||
backup_info = {
|
||||
'id': name,
|
||||
'name': name,
|
||||
'type': backup_type,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'databases': [],
|
||||
'size': 0
|
||||
}
|
||||
|
||||
# Backup each database
|
||||
for db_name, db_path in databases.items():
|
||||
if Path(db_path).exists():
|
||||
backup_db_path = backup_folder / f"{db_name}.db"
|
||||
|
||||
# Force WAL checkpoint before backup
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not checkpoint {db_name}: {e}")
|
||||
|
||||
# Copy database file
|
||||
shutil.copy2(db_path, backup_db_path)
|
||||
|
||||
# Verify backup integrity
|
||||
if self._verify_database_integrity(backup_db_path):
|
||||
db_size = backup_db_path.stat().st_size
|
||||
backup_info['databases'].append({
|
||||
'name': db_name,
|
||||
'size': db_size,
|
||||
'path': str(backup_db_path)
|
||||
})
|
||||
backup_info['size'] += db_size
|
||||
logger.info(f"Backed up {db_name} ({db_size} bytes)")
|
||||
else:
|
||||
logger.error(f"Backup verification failed for {db_name}")
|
||||
backup_db_path.unlink(missing_ok=True)
|
||||
raise Exception(f"Backup verification failed for {db_name}")
|
||||
|
||||
# Save backup metadata
|
||||
metadata_path = backup_folder / "backup_info.json"
|
||||
with open(metadata_path, 'w') as f:
|
||||
json.dump(backup_info, f, indent=2)
|
||||
|
||||
# Clean up old backups based on retention policy
|
||||
self._cleanup_old_backups()
|
||||
|
||||
logger.info(f"Backup created successfully: {name} ({backup_info['size']} bytes)")
|
||||
return backup_info
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating backup: {e}")
|
||||
# Clean up failed backup
|
||||
if 'backup_folder' in locals() and backup_folder.exists():
|
||||
shutil.rmtree(backup_folder, ignore_errors=True)
|
||||
raise
|
||||
|
||||
def _get_all_database_paths(self):
|
||||
"""Get paths to all Huntarr databases"""
|
||||
databases = {}
|
||||
|
||||
# Main database
|
||||
main_db_path = self.db.db_path
|
||||
databases['huntarr'] = str(main_db_path)
|
||||
|
||||
# Logs database (if exists)
|
||||
logs_db_path = main_db_path.parent / "logs.db"
|
||||
if logs_db_path.exists():
|
||||
databases['logs'] = str(logs_db_path)
|
||||
|
||||
# Manager database (if exists)
|
||||
manager_db_path = main_db_path.parent / "manager.db"
|
||||
if manager_db_path.exists():
|
||||
databases['manager'] = str(manager_db_path)
|
||||
|
||||
return databases
|
||||
|
||||
def _verify_database_integrity(self, db_path):
|
||||
"""Verify database integrity"""
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
result = conn.execute("PRAGMA integrity_check").fetchone()
|
||||
conn.close()
|
||||
return result and result[0] == "ok"
|
||||
except Exception as e:
|
||||
logger.error(f"Database integrity check failed: {e}")
|
||||
return False
|
||||
|
||||
def list_backups(self):
|
||||
"""List all available backups"""
|
||||
try:
|
||||
backups = []
|
||||
|
||||
if not self.backup_dir.exists():
|
||||
return backups
|
||||
|
||||
for backup_folder in self.backup_dir.iterdir():
|
||||
if backup_folder.is_dir():
|
||||
metadata_path = backup_folder / "backup_info.json"
|
||||
|
||||
if metadata_path.exists():
|
||||
try:
|
||||
with open(metadata_path, 'r') as f:
|
||||
backup_info = json.load(f)
|
||||
backups.append(backup_info)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read backup metadata for {backup_folder.name}: {e}")
|
||||
# Create basic info from folder
|
||||
backups.append({
|
||||
'id': backup_folder.name,
|
||||
'name': backup_folder.name,
|
||||
'type': 'unknown',
|
||||
'timestamp': datetime.fromtimestamp(backup_folder.stat().st_mtime).isoformat(),
|
||||
'size': sum(f.stat().st_size for f in backup_folder.rglob('*.db') if f.is_file())
|
||||
})
|
||||
|
||||
# Sort by timestamp (newest first)
|
||||
backups.sort(key=lambda x: x['timestamp'], reverse=True)
|
||||
return backups
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing backups: {e}")
|
||||
return []
|
||||
|
||||
def restore_backup(self, backup_id):
|
||||
"""Restore a backup"""
|
||||
try:
|
||||
backup_folder = self.backup_dir / backup_id
|
||||
|
||||
if not backup_folder.exists():
|
||||
raise Exception(f"Backup not found: {backup_id}")
|
||||
|
||||
# Load backup metadata
|
||||
metadata_path = backup_folder / "backup_info.json"
|
||||
if metadata_path.exists():
|
||||
with open(metadata_path, 'r') as f:
|
||||
backup_info = json.load(f)
|
||||
else:
|
||||
raise Exception("Backup metadata not found")
|
||||
|
||||
# Get current database paths
|
||||
databases = self._get_all_database_paths()
|
||||
|
||||
# Create backup of current databases before restore
|
||||
current_backup_name = f"pre_restore_backup_{int(time.time())}"
|
||||
logger.info(f"Creating backup of current databases: {current_backup_name}")
|
||||
self.create_backup('pre-restore', current_backup_name)
|
||||
|
||||
# Restore each database
|
||||
restored_databases = []
|
||||
for db_info in backup_info.get('databases', []):
|
||||
db_name = db_info['name']
|
||||
backup_db_path = Path(db_info['path'])
|
||||
|
||||
if db_name in databases and backup_db_path.exists():
|
||||
current_db_path = Path(databases[db_name])
|
||||
|
||||
# Verify backup database integrity
|
||||
if not self._verify_database_integrity(backup_db_path):
|
||||
raise Exception(f"Backup database {db_name} is corrupted")
|
||||
|
||||
# Stop any connections to the database
|
||||
if hasattr(self.db, 'close_connections'):
|
||||
self.db.close_connections()
|
||||
|
||||
# Replace current database with backup
|
||||
if current_db_path.exists():
|
||||
current_db_path.unlink()
|
||||
|
||||
shutil.copy2(backup_db_path, current_db_path)
|
||||
|
||||
# Verify restored database
|
||||
if self._verify_database_integrity(current_db_path):
|
||||
restored_databases.append(db_name)
|
||||
logger.info(f"Restored database: {db_name}")
|
||||
else:
|
||||
raise Exception(f"Restored database {db_name} failed integrity check")
|
||||
|
||||
logger.info(f"Backup restored successfully: {backup_id}")
|
||||
return {
|
||||
'backup_id': backup_id,
|
||||
'restored_databases': restored_databases,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error restoring backup: {e}")
|
||||
raise
|
||||
|
||||
def delete_backup(self, backup_id):
|
||||
"""Delete a backup"""
|
||||
try:
|
||||
backup_folder = self.backup_dir / backup_id
|
||||
|
||||
if not backup_folder.exists():
|
||||
raise Exception(f"Backup not found: {backup_id}")
|
||||
|
||||
shutil.rmtree(backup_folder)
|
||||
logger.info(f"Backup deleted: {backup_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting backup: {e}")
|
||||
raise
|
||||
|
||||
def delete_database(self):
|
||||
"""Delete the current database (destructive operation)"""
|
||||
try:
|
||||
databases = self._get_all_database_paths()
|
||||
deleted_databases = []
|
||||
|
||||
for db_name, db_path in databases.items():
|
||||
db_file = Path(db_path)
|
||||
if db_file.exists():
|
||||
db_file.unlink()
|
||||
deleted_databases.append(db_name)
|
||||
logger.warning(f"Deleted database: {db_name}")
|
||||
|
||||
logger.warning(f"Database deletion completed: {deleted_databases}")
|
||||
return deleted_databases
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting database: {e}")
|
||||
raise
|
||||
|
||||
def _cleanup_old_backups(self):
|
||||
"""Clean up old backups based on retention policy"""
|
||||
try:
|
||||
settings = self.get_backup_settings()
|
||||
retention_count = settings['retention']
|
||||
|
||||
backups = self.list_backups()
|
||||
|
||||
# Keep only the most recent backups
|
||||
if len(backups) > retention_count:
|
||||
backups_to_delete = backups[retention_count:]
|
||||
|
||||
for backup in backups_to_delete:
|
||||
try:
|
||||
self.delete_backup(backup['id'])
|
||||
logger.info(f"Cleaned up old backup: {backup['id']}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up backup {backup['id']}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during backup cleanup: {e}")
|
||||
|
||||
def get_next_scheduled_backup(self):
|
||||
"""Get the next scheduled backup time"""
|
||||
try:
|
||||
settings = self.get_backup_settings()
|
||||
frequency_days = settings['frequency']
|
||||
|
||||
# Get the last backup time
|
||||
last_backup_time = self.db.get_general_setting('last_backup_time')
|
||||
|
||||
if last_backup_time:
|
||||
last_backup = datetime.fromisoformat(last_backup_time)
|
||||
next_backup = last_backup + timedelta(days=frequency_days)
|
||||
else:
|
||||
# If no previous backup, schedule for tomorrow
|
||||
next_backup = datetime.now() + timedelta(days=1)
|
||||
|
||||
return next_backup.isoformat()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating next backup time: {e}")
|
||||
return None
|
||||
|
||||
# Initialize backup manager and scheduler
|
||||
backup_manager = BackupManager()
|
||||
backup_scheduler = BackupScheduler(backup_manager)
|
||||
|
||||
# Start the backup scheduler
|
||||
backup_scheduler.start()
|
||||
|
||||
@backup_bp.route('/api/backup/settings', methods=['GET', 'POST'])
|
||||
def backup_settings():
|
||||
"""Get or set backup settings"""
|
||||
username = get_user_for_request()
|
||||
if not username:
|
||||
return jsonify({"success": False, "error": "Authentication required"}), 401
|
||||
|
||||
try:
|
||||
if request.method == 'GET':
|
||||
settings = backup_manager.get_backup_settings()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'settings': settings
|
||||
})
|
||||
|
||||
elif request.method == 'POST':
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Validate settings
|
||||
frequency = int(data.get('frequency', 3))
|
||||
retention = int(data.get('retention', 3))
|
||||
|
||||
if frequency < 1 or frequency > 30:
|
||||
return jsonify({"success": False, "error": "Frequency must be between 1 and 30 days"}), 400
|
||||
|
||||
if retention < 1 or retention > 10:
|
||||
return jsonify({"success": False, "error": "Retention must be between 1 and 10 backups"}), 400
|
||||
|
||||
settings = {
|
||||
'frequency': frequency,
|
||||
'retention': retention
|
||||
}
|
||||
|
||||
if backup_manager.save_backup_settings(settings):
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'settings': settings
|
||||
})
|
||||
else:
|
||||
return jsonify({"success": False, "error": "Failed to save settings"}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in backup settings: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@backup_bp.route('/api/backup/create', methods=['POST'])
|
||||
def create_backup():
|
||||
"""Create a manual backup"""
|
||||
username = get_user_for_request()
|
||||
if not username:
|
||||
return jsonify({"success": False, "error": "Authentication required"}), 401
|
||||
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
backup_type = data.get('type', 'manual')
|
||||
backup_name = data.get('name')
|
||||
|
||||
backup_info = backup_manager.create_backup(backup_type, backup_name)
|
||||
|
||||
# Update last backup time
|
||||
backup_manager.db.set_general_setting('last_backup_time', backup_info['timestamp'])
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'backup_name': backup_info['name'],
|
||||
'backup_size': backup_info['size'],
|
||||
'timestamp': backup_info['timestamp']
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating backup: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@backup_bp.route('/api/backup/list', methods=['GET'])
|
||||
def list_backups():
|
||||
"""List all available backups"""
|
||||
username = get_user_for_request()
|
||||
if not username:
|
||||
return jsonify({"success": False, "error": "Authentication required"}), 401
|
||||
|
||||
try:
|
||||
backups = backup_manager.list_backups()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'backups': backups
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing backups: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@backup_bp.route('/api/backup/restore', methods=['POST'])
|
||||
def restore_backup():
|
||||
"""Restore a backup"""
|
||||
username = get_user_for_request()
|
||||
if not username:
|
||||
return jsonify({"success": False, "error": "Authentication required"}), 401
|
||||
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
backup_id = data.get('backup_id')
|
||||
|
||||
if not backup_id:
|
||||
return jsonify({"success": False, "error": "Backup ID required"}), 400
|
||||
|
||||
restore_info = backup_manager.restore_backup(backup_id)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'restore_info': restore_info
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error restoring backup: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@backup_bp.route('/api/backup/delete', methods=['POST'])
|
||||
def delete_backup():
|
||||
"""Delete a backup"""
|
||||
username = get_user_for_request()
|
||||
if not username:
|
||||
return jsonify({"success": False, "error": "Authentication required"}), 401
|
||||
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
backup_id = data.get('backup_id')
|
||||
|
||||
if not backup_id:
|
||||
return jsonify({"success": False, "error": "Backup ID required"}), 400
|
||||
|
||||
backup_manager.delete_backup(backup_id)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Backup {backup_id} deleted successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting backup: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@backup_bp.route('/api/backup/delete-database', methods=['POST'])
|
||||
def delete_database():
|
||||
"""Delete the current database (destructive operation)"""
|
||||
username = get_user_for_request()
|
||||
if not username:
|
||||
return jsonify({"success": False, "error": "Authentication required"}), 401
|
||||
|
||||
try:
|
||||
deleted_databases = backup_manager.delete_database()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'deleted_databases': deleted_databases,
|
||||
'message': 'Database deleted successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting database: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@backup_bp.route('/api/backup/next-scheduled', methods=['GET'])
|
||||
def next_scheduled_backup():
|
||||
"""Get the next scheduled backup time"""
|
||||
username = get_user_for_request()
|
||||
if not username:
|
||||
return jsonify({"success": False, "error": "Authentication required"}), 401
|
||||
|
||||
try:
|
||||
next_backup = backup_manager.get_next_scheduled_backup()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'next_backup': next_backup
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting next backup time: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
@@ -1 +1 @@
|
||||
8.2.7
|
||||
8.2.8
|
||||
|
||||
Reference in New Issue
Block a user