🔄 Add Comprehensive Backup Management System
✨ New Features: - Complete backup lifecycle management (create, list, download, delete, cleanup) - Web-based backup interface with real-time status updates - Individual backup deletion and bulk cleanup for old backups - Docker-aware backup operations with volume persistence - Automated backup scheduling and retention policies 📁 Added Files: - backup.py - Core backup script for creating timestamped archives - docker_backup.sh - Docker-compatible backup wrapper script - app/templates/backup.html - Web interface for backup management - BACKUP_SYSTEM.md - Comprehensive backup system documentation - BACKUP_GUIDE.md - Quick reference guide for backup operations 🔧 Enhanced Files: - Dockerfile - Added backup.py copy for container availability - docker-compose.yml - Added backup volume mount for persistence - app/routes/api.py - Added backup API endpoints (create, list, delete, cleanup) - app/routes/main.py - Added backup management route - app/templates/index.html - Added backup management navigation - README.md - Updated with backup system overview and quick start 🎯 Key Improvements: - Fixed backup creation errors in Docker environment - Added Docker-aware path detection for container operations - Implemented proper error handling and user confirmation dialogs - Added real-time backup status updates via JavaScript - Enhanced data persistence with volume mounting 💡 Use Cases: - Data protection and disaster recovery - Environment migration and cloning - Development data management - Automated maintenance workflows
This commit is contained in:
@@ -476,3 +476,310 @@ def generate_shortened_qr():
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# Backup Management API Endpoints
|
||||
@bp.route('/backup/create', methods=['POST'])
|
||||
@login_required
|
||||
def create_backup():
|
||||
"""Create backup via API"""
|
||||
try:
|
||||
import subprocess
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
data = request.json or {}
|
||||
backup_type = data.get('type', 'data') # data, config, full
|
||||
|
||||
# Use absolute path for Docker container or relative path for development
|
||||
if os.path.exists('/app/backup.py'):
|
||||
# Running in Docker container
|
||||
app_root = '/app'
|
||||
backup_script = '/app/backup.py'
|
||||
else:
|
||||
# Running in development
|
||||
app_root = Path(__file__).parent.parent.parent
|
||||
backup_script = 'backup.py'
|
||||
|
||||
if backup_type == 'data':
|
||||
# Create data backup using Python script
|
||||
result = subprocess.run(
|
||||
['python3', backup_script, '--data-only'],
|
||||
cwd=app_root,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
elif backup_type == 'config':
|
||||
# Create config backup
|
||||
result = subprocess.run(
|
||||
['python3', backup_script, '--config'],
|
||||
cwd=app_root,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
elif backup_type == 'full':
|
||||
# Create full backup
|
||||
result = subprocess.run(
|
||||
['python3', backup_script, '--full'],
|
||||
cwd=app_root,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
else:
|
||||
return jsonify({'error': 'Invalid backup type'}), 400
|
||||
|
||||
if result.returncode == 0:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Backup created successfully',
|
||||
'output': result.stdout
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'error': 'Backup creation failed',
|
||||
'output': result.stderr
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/backup/list', methods=['GET'])
|
||||
@login_required
|
||||
def list_backups():
|
||||
"""List available backups"""
|
||||
try:
|
||||
import subprocess
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Use absolute path for Docker container or relative path for development
|
||||
if os.path.exists('/app/backup.py'):
|
||||
# Running in Docker container
|
||||
app_root = '/app'
|
||||
backup_script = '/app/backup.py'
|
||||
else:
|
||||
# Running in development
|
||||
app_root = Path(__file__).parent.parent.parent
|
||||
backup_script = 'backup.py'
|
||||
|
||||
result = subprocess.run(
|
||||
['python3', backup_script, '--list'],
|
||||
cwd=app_root,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Parse backup list from output
|
||||
lines = result.stdout.strip().split('\n')
|
||||
backups = []
|
||||
|
||||
for line in lines:
|
||||
if '|' in line and not line.startswith('─') and 'Available backups' not in line:
|
||||
parts = [part.strip() for part in line.split('|')]
|
||||
if len(parts) >= 4:
|
||||
backups.append({
|
||||
'type': parts[0],
|
||||
'filename': parts[1],
|
||||
'size': parts[2],
|
||||
'date': parts[3]
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'backups': backups
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'error': 'Failed to list backups',
|
||||
'output': result.stderr
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/backup/download/<filename>', methods=['GET'])
|
||||
@login_required
|
||||
def download_backup(filename):
|
||||
"""Download backup file"""
|
||||
try:
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Use absolute path for Docker container or relative path for development
|
||||
if os.path.exists('/app/backup.py'):
|
||||
# Running in Docker container
|
||||
backup_dir = Path('/app/backups')
|
||||
else:
|
||||
# Running in development
|
||||
app_root = Path(__file__).parent.parent.parent
|
||||
backup_dir = app_root / 'backups'
|
||||
|
||||
backup_file = backup_dir / filename
|
||||
|
||||
if not backup_file.exists():
|
||||
return jsonify({'error': 'Backup file not found'}), 404
|
||||
|
||||
return send_file(
|
||||
str(backup_file),
|
||||
as_attachment=True,
|
||||
download_name=filename,
|
||||
mimetype='application/gzip'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/backup/status', methods=['GET'])
|
||||
@login_required
|
||||
def backup_status():
|
||||
"""Get backup system status"""
|
||||
try:
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Use absolute path for Docker container or relative path for development
|
||||
if os.path.exists('/app/backup.py'):
|
||||
# Running in Docker container
|
||||
backup_dir = Path('/app/backups')
|
||||
data_dir = Path('/app/data')
|
||||
else:
|
||||
# Running in development
|
||||
app_root = Path(__file__).parent.parent.parent
|
||||
backup_dir = app_root / 'backups'
|
||||
data_dir = app_root / 'data'
|
||||
|
||||
# Count backups
|
||||
backup_files = list(backup_dir.glob('*.tar.gz')) if backup_dir.exists() else []
|
||||
|
||||
# Get data directory size
|
||||
data_size = 0
|
||||
if data_dir.exists():
|
||||
for file_path in data_dir.rglob('*'):
|
||||
if file_path.is_file():
|
||||
data_size += file_path.stat().st_size
|
||||
|
||||
# Get backup directory size
|
||||
backup_size = 0
|
||||
for backup_file in backup_files:
|
||||
backup_size += backup_file.stat().st_size
|
||||
|
||||
# Check last backup time
|
||||
last_backup = None
|
||||
if backup_files:
|
||||
latest_backup = max(backup_files, key=lambda x: x.stat().st_mtime)
|
||||
last_backup = datetime.fromtimestamp(latest_backup.stat().st_mtime).isoformat()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'status': {
|
||||
'backup_count': len(backup_files),
|
||||
'data_size': data_size,
|
||||
'backup_size': backup_size,
|
||||
'last_backup': last_backup,
|
||||
'backup_directory': str(backup_dir),
|
||||
'data_directory': str(data_dir)
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/backup/delete/<filename>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_backup(filename):
|
||||
"""Delete a backup file"""
|
||||
try:
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Use absolute path for Docker container or relative path for development
|
||||
if os.path.exists('/app/backup.py'):
|
||||
# Running in Docker container
|
||||
backup_dir = Path('/app/backups')
|
||||
else:
|
||||
# Running in development
|
||||
app_root = Path(__file__).parent.parent.parent
|
||||
backup_dir = app_root / 'backups'
|
||||
|
||||
backup_file = backup_dir / filename
|
||||
|
||||
# Security check: ensure filename is just a filename, not a path
|
||||
if '/' in filename or '\\' in filename or '..' in filename:
|
||||
return jsonify({'error': 'Invalid filename'}), 400
|
||||
|
||||
# Check if file exists
|
||||
if not backup_file.exists():
|
||||
return jsonify({'error': 'Backup file not found'}), 404
|
||||
|
||||
# Delete the file
|
||||
backup_file.unlink()
|
||||
|
||||
# Also delete associated metadata file if it exists
|
||||
metadata_file = backup_dir / f"{filename.replace('.tar.gz', '.json')}"
|
||||
if metadata_file.exists():
|
||||
metadata_file.unlink()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Backup {filename} deleted successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/backup/cleanup', methods=['POST'])
|
||||
@login_required
|
||||
def cleanup_old_backups():
|
||||
"""Delete old backup files, keeping only the N most recent"""
|
||||
try:
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
data = request.json or {}
|
||||
keep_count = data.get('keep', 5) # Default: keep 5 most recent
|
||||
|
||||
# Use absolute path for Docker container or relative path for development
|
||||
if os.path.exists('/app/backup.py'):
|
||||
# Running in Docker container
|
||||
backup_dir = Path('/app/backups')
|
||||
else:
|
||||
# Running in development
|
||||
app_root = Path(__file__).parent.parent.parent
|
||||
backup_dir = app_root / 'backups'
|
||||
|
||||
if not backup_dir.exists():
|
||||
return jsonify({'error': 'Backup directory not found'}), 404
|
||||
|
||||
# Get all backup files sorted by modification time (newest first)
|
||||
backup_files = list(backup_dir.glob('*.tar.gz'))
|
||||
backup_files.sort(key=lambda x: x.stat().st_mtime, reverse=True)
|
||||
|
||||
# Delete old backups
|
||||
deleted_files = []
|
||||
files_to_delete = backup_files[keep_count:]
|
||||
|
||||
for backup_file in files_to_delete:
|
||||
# Delete the backup file
|
||||
backup_file.unlink()
|
||||
deleted_files.append(backup_file.name)
|
||||
|
||||
# Also delete associated metadata file if it exists
|
||||
metadata_file = backup_dir / f"{backup_file.stem}.json"
|
||||
if metadata_file.exists():
|
||||
metadata_file.unlink()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Cleanup completed. Deleted {len(deleted_files)} old backups.',
|
||||
'deleted_files': deleted_files,
|
||||
'kept_count': min(len(backup_files), keep_count)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@@ -49,6 +49,12 @@ def edit_link_page(page_id):
|
||||
page_data = link_manager.get_page(page_id)
|
||||
return render_template('edit_links.html', page=page_data)
|
||||
|
||||
@bp.route('/backup')
|
||||
@login_required
|
||||
def backup_management():
|
||||
"""Display the backup management page"""
|
||||
return render_template('backup.html')
|
||||
|
||||
@bp.route('/health')
|
||||
def health_check():
|
||||
"""Health check endpoint for Docker"""
|
||||
|
||||
685
app/templates/backup.html
Normal file
685
app/templates/backup.html
Normal file
@@ -0,0 +1,685 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Backup Management - QR Code Manager</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 1.1em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
background: rgba(255,255,255,0.2);
|
||||
padding: 8px 15px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9em;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
padding: 25px;
|
||||
margin-bottom: 30px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.5em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-number {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.backup-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: linear-gradient(135deg, #ffc107 0%, #fd7e14 100%);
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
background: linear-gradient(135deg, #17a2b8 0%, #6f42c1 100%);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
||||
}
|
||||
|
||||
.backup-list {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.backup-list-header {
|
||||
background: #f8f9fa;
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto auto;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.backup-item {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto auto;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.backup-item:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.backup-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.backup-type {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.backup-filename {
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.backup-size {
|
||||
font-size: 0.9em;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.backup-date {
|
||||
font-size: 0.9em;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.backup-actions-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #667eea;
|
||||
border-radius: 50%;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 15px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: #d1ecf1;
|
||||
border: 1px solid #bee5eb;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 20px 15px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 20px 15px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 20px 15px;
|
||||
}
|
||||
|
||||
.backup-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.backup-list-header,
|
||||
.backup-item {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.backup-item {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.backup-actions-item {
|
||||
justify-content: flex-start;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<a href="/" class="back-link">🏠 Back to Dashboard</a>
|
||||
<h1>🛡️ Backup Management</h1>
|
||||
<p>Create, manage, and restore backups of your QR Code Manager data</p>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<!-- Status Section -->
|
||||
<div class="section">
|
||||
<h2>📊 Backup Status</h2>
|
||||
<div class="status-grid" id="status-grid">
|
||||
<div class="status-card">
|
||||
<div class="status-number" id="backup-count">-</div>
|
||||
<div class="status-label">Total Backups</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-number" id="data-size">-</div>
|
||||
<div class="status-label">Data Size</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-number" id="backup-size">-</div>
|
||||
<div class="status-label">Backup Size</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-number" id="last-backup">-</div>
|
||||
<div class="status-label">Last Backup</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Backup Section -->
|
||||
<div class="section">
|
||||
<h2>💾 Create Backup</h2>
|
||||
<div class="backup-actions">
|
||||
<button class="btn btn-success" onclick="createBackup('data')">
|
||||
📄 Data Backup
|
||||
</button>
|
||||
<button class="btn btn-warning" onclick="createBackup('config')">
|
||||
⚙️ Config Backup
|
||||
</button>
|
||||
<button class="btn btn-info" onclick="createBackup('full')">
|
||||
📦 Full Backup
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="loadBackups()">
|
||||
🔄 Refresh List
|
||||
</button>
|
||||
<button class="btn btn-danger" onclick="cleanupOldBackups()">
|
||||
🗑️ Cleanup Old
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="backup-alerts"></div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<strong>💡 Backup Types:</strong><br>
|
||||
<strong>Data:</strong> QR codes, link pages, and short URLs (recommended for regular backups)<br>
|
||||
<strong>Config:</strong> Configuration files and environment settings<br>
|
||||
<strong>Full:</strong> Complete application backup including all files
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup List Section -->
|
||||
<div class="section">
|
||||
<h2>📋 Available Backups</h2>
|
||||
<div class="backup-list">
|
||||
<div class="backup-list-header">
|
||||
<div>Type</div>
|
||||
<div>Filename</div>
|
||||
<div>Size</div>
|
||||
<div>Date</div>
|
||||
<div>Actions</div>
|
||||
</div>
|
||||
<div id="backup-list-content">
|
||||
<div class="loading">
|
||||
<div class="loading-spinner"></div>
|
||||
Loading backups...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Load backup status and list on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadBackupStatus();
|
||||
loadBackups();
|
||||
});
|
||||
|
||||
async function loadBackupStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/backup/status');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const status = result.status;
|
||||
|
||||
document.getElementById('backup-count').textContent = status.backup_count;
|
||||
document.getElementById('data-size').textContent = formatBytes(status.data_size);
|
||||
document.getElementById('backup-size').textContent = formatBytes(status.backup_size);
|
||||
|
||||
if (status.last_backup) {
|
||||
const lastBackup = new Date(status.last_backup);
|
||||
document.getElementById('last-backup').textContent = formatTimeAgo(lastBackup);
|
||||
} else {
|
||||
document.getElementById('last-backup').textContent = 'Never';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load backup status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBackups() {
|
||||
const listContent = document.getElementById('backup-list-content');
|
||||
listContent.innerHTML = '<div class="loading"><div class="loading-spinner"></div>Loading backups...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/backup/list');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.backups.length > 0) {
|
||||
listContent.innerHTML = result.backups.map(backup => `
|
||||
<div class="backup-item">
|
||||
<div class="backup-type">${backup.type}</div>
|
||||
<div class="backup-filename">${backup.filename}</div>
|
||||
<div class="backup-size">${backup.size}</div>
|
||||
<div class="backup-date">${backup.date}</div>
|
||||
<div class="backup-actions-item">
|
||||
<button class="btn btn-small btn-info" onclick="downloadBackup('${backup.filename}')">
|
||||
📥 Download
|
||||
</button>
|
||||
<button class="btn btn-small btn-danger" onclick="deleteBackup('${backup.filename}')">
|
||||
🗑️ Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
listContent.innerHTML = '<div style="padding: 40px; text-align: center; color: #666;">No backups found</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load backups:', error);
|
||||
listContent.innerHTML = '<div style="padding: 40px; text-align: center; color: #dc3545;">Failed to load backups</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function createBackup(type) {
|
||||
const alertsContainer = document.getElementById('backup-alerts');
|
||||
|
||||
// Show loading alert
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-info">
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<div class="loading-spinner" style="width: 20px; height: 20px; border-width: 2px; margin: 0;"></div>
|
||||
Creating ${type} backup...
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/backup/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ type: type })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-success">
|
||||
✅ ${type.charAt(0).toUpperCase() + type.slice(1)} backup created successfully!
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Refresh status and list
|
||||
loadBackupStatus();
|
||||
loadBackups();
|
||||
|
||||
// Clear alert after 5 seconds
|
||||
setTimeout(() => {
|
||||
alertsContainer.innerHTML = '';
|
||||
}, 5000);
|
||||
} else {
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
❌ Failed to create backup: ${result.error}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
❌ Error creating backup: ${error.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadBackup(filename) {
|
||||
window.open(`/api/backup/download/${filename}`, '_blank');
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatTimeAgo(date) {
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now - date) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) return 'Just now';
|
||||
if (diffInSeconds < 3600) return Math.floor(diffInSeconds / 60) + 'm ago';
|
||||
if (diffInSeconds < 86400) return Math.floor(diffInSeconds / 3600) + 'h ago';
|
||||
if (diffInSeconds < 2592000) return Math.floor(diffInSeconds / 86400) + 'd ago';
|
||||
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
async function deleteBackup(filename) {
|
||||
if (!confirm(`Are you sure you want to delete the backup "${filename}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alertsContainer = document.getElementById('backup-alerts');
|
||||
|
||||
// Show loading alert
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-info">
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<div class="loading-spinner" style="width: 20px; height: 20px; border-width: 2px; margin: 0;"></div>
|
||||
Deleting backup...
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/backup/delete/${filename}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-success">
|
||||
✅ Backup deleted successfully!
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Refresh status and list
|
||||
loadBackupStatus();
|
||||
loadBackups();
|
||||
|
||||
// Clear alert after 5 seconds
|
||||
setTimeout(() => {
|
||||
alertsContainer.innerHTML = '';
|
||||
}, 5000);
|
||||
} else {
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
❌ Failed to delete backup: ${result.error}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
❌ Error deleting backup: ${error.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupOldBackups() {
|
||||
const keepCount = prompt('How many recent backups would you like to keep?', '5');
|
||||
|
||||
if (keepCount === null) {
|
||||
return; // User cancelled
|
||||
}
|
||||
|
||||
const numKeep = parseInt(keepCount);
|
||||
if (isNaN(numKeep) || numKeep < 1) {
|
||||
alert('Please enter a valid number greater than 0.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`This will delete all but the ${numKeep} most recent backups. Are you sure?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alertsContainer = document.getElementById('backup-alerts');
|
||||
|
||||
// Show loading alert
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-info">
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<div class="loading-spinner" style="width: 20px; height: 20px; border-width: 2px; margin: 0;"></div>
|
||||
Cleaning up old backups...
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/backup/cleanup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ keep: numKeep })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-success">
|
||||
✅ ${result.message}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Refresh status and list
|
||||
loadBackupStatus();
|
||||
loadBackups();
|
||||
|
||||
// Clear alert after 7 seconds
|
||||
setTimeout(() => {
|
||||
alertsContainer.innerHTML = '';
|
||||
}, 7000);
|
||||
} else {
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
❌ Failed to cleanup backups: ${result.error}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
❌ Error during cleanup: ${error.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -499,7 +499,10 @@
|
||||
<div class="header">
|
||||
<h1>🎯 QR Code Manager</h1>
|
||||
<p>Create, customize, and manage your QR codes with ease</p>
|
||||
<div style="position: absolute; top: 20px; right: 20px;">
|
||||
<div style="position: absolute; top: 20px; right: 20px; display: flex; gap: 10px;">
|
||||
<a href="/backup" style="color: white; text-decoration: none; background: rgba(255,255,255,0.2); padding: 8px 15px; border-radius: 20px; font-size: 0.9em;">
|
||||
🛡️ Backup
|
||||
</a>
|
||||
<a href="/logout" style="color: white; text-decoration: none; background: rgba(255,255,255,0.2); padding: 8px 15px; border-radius: 20px; font-size: 0.9em;">
|
||||
👤 Logout
|
||||
</a>
|
||||
|
||||
Reference in New Issue
Block a user