feat: ✨ backup and restore
This commit is contained in:
@@ -2,3 +2,4 @@ __pycache__
|
|||||||
tailwindcss
|
tailwindcss
|
||||||
static/css/output.css
|
static/css/output.css
|
||||||
.env
|
.env
|
||||||
|
backups/
|
||||||
+1
-1
@@ -2,7 +2,7 @@ FROM python:3.13-slim
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . /app
|
COPY . /app
|
||||||
RUN pip install -r requirements.txt
|
RUN pip install -r requirements.txt
|
||||||
RUN apt-get update && apt-get install -y curl
|
RUN apt-get update && apt-get install -y curl mariadb-client-compat
|
||||||
RUN rm -rf /var/lib/apt/lists/*
|
RUN rm -rf /var/lib/apt/lists/*
|
||||||
RUN curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 \
|
RUN curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 \
|
||||||
&& chmod +x tailwindcss-linux-x64 \
|
&& chmod +x tailwindcss-linux-x64 \
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ A Flask-based web application for comprehensive IP Address Management (IPAM). Ma
|
|||||||
docker run -d \
|
docker run -d \
|
||||||
--name ipam \
|
--name ipam \
|
||||||
-p 5000:5000 \
|
-p 5000:5000 \
|
||||||
|
-v ./backups:/app/backups \
|
||||||
-e MYSQL_HOST=10.10.2.27 \
|
-e MYSQL_HOST=10.10.2.27 \
|
||||||
-e MYSQL_USER=ipam \
|
-e MYSQL_USER=ipam \
|
||||||
-e MYSQL_PASSWORD=your_password \
|
-e MYSQL_PASSWORD=your_password \
|
||||||
@@ -45,15 +46,13 @@ docker run -d \
|
|||||||
### Docker Compose
|
### Docker Compose
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
ipam:
|
ipam:
|
||||||
image: ghcr.io/jdb-net/ipam:latest
|
image: ghcr.io/jdb-net/ipam:latest
|
||||||
container_name: ipam
|
container_name: ipam
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000" # Web interface
|
- "5000:5000"
|
||||||
environment:
|
environment:
|
||||||
- MYSQL_HOST=10.10.2.27
|
- MYSQL_HOST=10.10.2.27
|
||||||
- MYSQL_USER=ipam
|
- MYSQL_USER=ipam
|
||||||
@@ -62,6 +61,8 @@ services:
|
|||||||
- SECRET_KEY=your_secret_key
|
- SECRET_KEY=your_secret_key
|
||||||
- NAME=Your Organisation
|
- NAME=Your Organisation
|
||||||
- LOGO_PNG=https://example.com/logo.png
|
- LOGO_PNG=https://example.com/logo.png
|
||||||
|
volumes:
|
||||||
|
- ./backups:/app/backups
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ from io import StringIO, BytesIO
|
|||||||
import logging
|
import logging
|
||||||
import mysql.connector
|
import mysql.connector
|
||||||
import requests
|
import requests
|
||||||
|
import subprocess
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
app = None
|
app = None
|
||||||
|
|
||||||
@@ -1156,6 +1160,202 @@ def register_routes(app):
|
|||||||
logging.error(f"Unexpected error checking for updates: {e}")
|
logging.error(f"Unexpected error checking for updates: {e}")
|
||||||
return jsonify({'error': 'Failed to check for updates'}), 500
|
return jsonify({'error': 'Failed to check for updates'}), 500
|
||||||
|
|
||||||
|
@app.route('/backup')
|
||||||
|
@permission_required('view_admin')
|
||||||
|
def backup():
|
||||||
|
"""Backup and restore page"""
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
# Ensure backups directory exists
|
||||||
|
backups_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'backups')
|
||||||
|
os.makedirs(backups_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# List available backups
|
||||||
|
backups = []
|
||||||
|
if os.path.exists(backups_dir):
|
||||||
|
for filename in os.listdir(backups_dir):
|
||||||
|
if filename.endswith('.sql'):
|
||||||
|
filepath = os.path.join(backups_dir, filename)
|
||||||
|
file_stat = os.stat(filepath)
|
||||||
|
backups.append({
|
||||||
|
'filename': filename,
|
||||||
|
'size': file_stat.st_size,
|
||||||
|
'created': datetime.fromtimestamp(file_stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by creation time (newest first)
|
||||||
|
backups.sort(key=lambda x: x['created'], reverse=True)
|
||||||
|
|
||||||
|
return render_with_user('backup.html', backups=backups)
|
||||||
|
|
||||||
|
@app.route('/backup/create', methods=['POST'])
|
||||||
|
@permission_required('view_admin')
|
||||||
|
def create_backup():
|
||||||
|
"""Create a database backup"""
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
try:
|
||||||
|
backups_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'backups')
|
||||||
|
os.makedirs(backups_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate backup filename with timestamp
|
||||||
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
filename = f'ipam_backup_{timestamp}.sql'
|
||||||
|
filepath = os.path.join(backups_dir, filename)
|
||||||
|
|
||||||
|
# Get database configuration
|
||||||
|
db_host = current_app.config['MYSQL_HOST']
|
||||||
|
db_user = current_app.config['MYSQL_USER']
|
||||||
|
db_password = current_app.config['MYSQL_PASSWORD']
|
||||||
|
db_name = current_app.config['MYSQL_DATABASE']
|
||||||
|
|
||||||
|
# Create backup using mysqldump
|
||||||
|
cmd = [
|
||||||
|
'mysqldump',
|
||||||
|
f'--host={db_host}',
|
||||||
|
f'--user={db_user}',
|
||||||
|
f'--password={db_password}',
|
||||||
|
'--skip-ssl',
|
||||||
|
'--single-transaction',
|
||||||
|
'--routines',
|
||||||
|
'--triggers',
|
||||||
|
db_name
|
||||||
|
]
|
||||||
|
|
||||||
|
with open(filepath, 'w') as f:
|
||||||
|
result = subprocess.run(cmd, stdout=f, stderr=subprocess.PIPE, text=True)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
os.remove(filepath)
|
||||||
|
return jsonify({'error': f'Backup failed: {result.stderr}'}), 500
|
||||||
|
|
||||||
|
# Log the backup creation
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
add_audit_log(session.get('user_id'), 'create_backup', f'Created backup: {filename}', conn=conn)
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'filename': filename, 'message': 'Backup created successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error creating backup: {e}")
|
||||||
|
return jsonify({'error': f'Failed to create backup: {str(e)}'}), 500
|
||||||
|
|
||||||
|
@app.route('/backup/download/<filename>')
|
||||||
|
@permission_required('view_admin')
|
||||||
|
def download_backup(filename):
|
||||||
|
"""Download a backup file"""
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
# Security: ensure filename is safe
|
||||||
|
filename = secure_filename(filename)
|
||||||
|
backups_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'backups')
|
||||||
|
filepath = os.path.join(backups_dir, filename)
|
||||||
|
|
||||||
|
if not os.path.exists(filepath) or not filename.endswith('.sql'):
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
return send_file(filepath, as_attachment=True, download_name=filename)
|
||||||
|
|
||||||
|
@app.route('/backup/restore', methods=['POST'])
|
||||||
|
@permission_required('view_admin')
|
||||||
|
def restore_backup():
|
||||||
|
"""Restore database from backup"""
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
try:
|
||||||
|
backups_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'backups')
|
||||||
|
os.makedirs(backups_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Check if file was uploaded or if using existing file
|
||||||
|
if 'backup_file' in request.files:
|
||||||
|
# Handle file upload
|
||||||
|
file = request.files['backup_file']
|
||||||
|
if file.filename == '':
|
||||||
|
return jsonify({'error': 'No file selected'}), 400
|
||||||
|
|
||||||
|
if not file.filename.endswith('.sql'):
|
||||||
|
return jsonify({'error': 'Invalid file type. Only .sql files are allowed'}), 400
|
||||||
|
|
||||||
|
# Save uploaded file
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
filepath = os.path.join(backups_dir, filename)
|
||||||
|
file.save(filepath)
|
||||||
|
|
||||||
|
elif 'backup_filename' in request.form:
|
||||||
|
# Use existing backup file
|
||||||
|
filename = secure_filename(request.form['backup_filename'])
|
||||||
|
filepath = os.path.join(backups_dir, filename)
|
||||||
|
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
return jsonify({'error': 'Backup file not found'}), 404
|
||||||
|
else:
|
||||||
|
return jsonify({'error': 'No backup file specified'}), 400
|
||||||
|
|
||||||
|
# Get database configuration
|
||||||
|
db_host = current_app.config['MYSQL_HOST']
|
||||||
|
db_user = current_app.config['MYSQL_USER']
|
||||||
|
db_password = current_app.config['MYSQL_PASSWORD']
|
||||||
|
db_name = current_app.config['MYSQL_DATABASE']
|
||||||
|
|
||||||
|
# Close any existing database connections before restore
|
||||||
|
# This is important to avoid connection conflicts during restore
|
||||||
|
try:
|
||||||
|
# Try to close any open connections
|
||||||
|
pass
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Restore database using mysql command
|
||||||
|
cmd = [
|
||||||
|
'mysql',
|
||||||
|
f'--host={db_host}',
|
||||||
|
f'--user={db_user}',
|
||||||
|
f'--password={db_password}',
|
||||||
|
'--skip-ssl',
|
||||||
|
db_name
|
||||||
|
]
|
||||||
|
|
||||||
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
|
result = subprocess.run(cmd, stdin=f, stderr=subprocess.PIPE, text=True)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return jsonify({'error': f'Restore failed: {result.stderr}'}), 500
|
||||||
|
|
||||||
|
# Log the restore
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
add_audit_log(session.get('user_id'), 'restore_backup', f'Restored backup: {filename}', conn=conn)
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Database restored successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error restoring backup: {e}")
|
||||||
|
return jsonify({'error': f'Failed to restore backup: {str(e)}'}), 500
|
||||||
|
|
||||||
|
@app.route('/backup/delete/<filename>', methods=['POST'])
|
||||||
|
@permission_required('view_admin')
|
||||||
|
def delete_backup(filename):
|
||||||
|
"""Delete a backup file"""
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
# Security: ensure filename is safe
|
||||||
|
filename = secure_filename(filename)
|
||||||
|
backups_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'backups')
|
||||||
|
filepath = os.path.join(backups_dir, filename)
|
||||||
|
|
||||||
|
if not os.path.exists(filepath) or not filename.endswith('.sql'):
|
||||||
|
return jsonify({'error': 'Backup file not found'}), 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.remove(filepath)
|
||||||
|
|
||||||
|
# Log the deletion
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
add_audit_log(session.get('user_id'), 'delete_backup', f'Deleted backup: {filename}', conn=conn)
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Backup deleted successfully'})
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error deleting backup: {e}")
|
||||||
|
return jsonify({'error': f'Failed to delete backup: {str(e)}'}), 500
|
||||||
|
|
||||||
@app.route('/get_available_ips')
|
@app.route('/get_available_ips')
|
||||||
@permission_required('view_device')
|
@permission_required('view_device')
|
||||||
def get_available_ips():
|
def get_available_ips():
|
||||||
@@ -3105,3 +3305,12 @@ def register_routes(app):
|
|||||||
app.add_url_rule('/rack/<int:rack_id>/delete', 'delete_rack', delete_rack, methods=['POST'])
|
app.add_url_rule('/rack/<int:rack_id>/delete', 'delete_rack', delete_rack, methods=['POST'])
|
||||||
app.add_url_rule('/rack/<int:rack_id>/export_csv', 'export_rack_csv', export_rack_csv)
|
app.add_url_rule('/rack/<int:rack_id>/export_csv', 'export_rack_csv', export_rack_csv)
|
||||||
app.add_url_rule('/help', 'help', help)
|
app.add_url_rule('/help', 'help', help)
|
||||||
|
app.add_url_rule('/backup', 'backup', backup, methods=['GET', 'POST'])
|
||||||
|
app.add_url_rule('/backup/create', 'create_backup', create_backup, methods=['POST'])
|
||||||
|
app.add_url_rule('/backup/download/<filename>', 'download_backup', download_backup)
|
||||||
|
app.add_url_rule('/backup/restore', 'restore_backup', restore_backup, methods=['POST'])
|
||||||
|
app.add_url_rule('/backup/delete/<filename>', 'delete_backup', delete_backup, methods=['POST'])
|
||||||
|
app.add_url_rule('/backup/create', 'create_backup', create_backup, methods=['POST'])
|
||||||
|
app.add_url_rule('/backup/download/<filename>', 'download_backup', download_backup)
|
||||||
|
app.add_url_rule('/backup/restore', 'restore_backup', restore_backup, methods=['POST'])
|
||||||
|
app.add_url_rule('/backup/delete/<filename>', 'delete_backup', delete_backup, methods=['POST'])
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const messageDiv = document.getElementById('message');
|
||||||
|
|
||||||
|
function showMessage(text, isError = false) {
|
||||||
|
messageDiv.textContent = text;
|
||||||
|
messageDiv.className = isError
|
||||||
|
? 'mb-4 p-4 rounded-lg bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200'
|
||||||
|
: 'mb-4 p-4 rounded-lg bg-green-200 dark:bg-green-800 text-green-800 dark:text-green-200';
|
||||||
|
messageDiv.classList.remove('hidden');
|
||||||
|
setTimeout(() => {
|
||||||
|
messageDiv.classList.add('hidden');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup button
|
||||||
|
const createBackupBtn = document.getElementById('create-backup-btn');
|
||||||
|
if (createBackupBtn) {
|
||||||
|
createBackupBtn.addEventListener('click', function() {
|
||||||
|
createBackupBtn.disabled = true;
|
||||||
|
createBackupBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Creating...';
|
||||||
|
|
||||||
|
fetch('/backup/create', {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
showMessage(`Backup created successfully: ${data.filename}`);
|
||||||
|
setTimeout(() => window.location.reload(), 1500);
|
||||||
|
} else {
|
||||||
|
showMessage(data.error || 'Failed to create backup', true);
|
||||||
|
createBackupBtn.disabled = false;
|
||||||
|
createBackupBtn.innerHTML = '<i class="fas fa-database"></i> <span>Create Backup</span>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
showMessage('Error creating backup: ' + error.message, true);
|
||||||
|
createBackupBtn.disabled = false;
|
||||||
|
createBackupBtn.innerHTML = '<i class="fas fa-database"></i> <span>Create Backup</span>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload and restore form
|
||||||
|
const uploadRestoreForm = document.getElementById('upload-restore-form');
|
||||||
|
if (uploadRestoreForm) {
|
||||||
|
uploadRestoreForm.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!confirm('WARNING: This will replace all current database data with the backup. Are you sure you want to continue?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
const submitBtn = this.querySelector('button[type="submit"]');
|
||||||
|
const originalText = submitBtn.innerHTML;
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Restoring...';
|
||||||
|
|
||||||
|
fetch('/backup/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
showMessage('Database restored successfully. Page will reload...');
|
||||||
|
setTimeout(() => window.location.reload(), 2000);
|
||||||
|
} else {
|
||||||
|
showMessage(data.error || 'Failed to restore backup', true);
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
showMessage('Error restoring backup: ' + error.message, true);
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = originalText;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing backup restore form
|
||||||
|
const existingRestoreForm = document.getElementById('existing-restore-form');
|
||||||
|
if (existingRestoreForm) {
|
||||||
|
existingRestoreForm.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!confirm('WARNING: This will replace all current database data with the backup. Are you sure you want to continue?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
const submitBtn = this.querySelector('button[type="submit"]');
|
||||||
|
const originalText = submitBtn.innerHTML;
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Restoring...';
|
||||||
|
|
||||||
|
fetch('/backup/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
showMessage('Database restored successfully. Page will reload...');
|
||||||
|
setTimeout(() => window.location.reload(), 2000);
|
||||||
|
} else {
|
||||||
|
showMessage(data.error || 'Failed to restore backup', true);
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
showMessage('Error restoring backup: ' + error.message, true);
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = originalText;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function deleteBackup(filename) {
|
||||||
|
if (!confirm(`Are you sure you want to delete backup "${filename}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`/backup/delete/${filename}`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (data.error || 'Failed to delete backup'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert('Error: ' + error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -64,6 +64,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/backup" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<i class="fas fa-database text-3xl text-gray-600 dark:text-gray-400"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold">Backup & Restore</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Database backup and restore</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Subnet Management Section -->
|
<!-- Subnet Management Section -->
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Backup & Restore</title>
|
||||||
|
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
||||||
|
<link href="/static/css/output.css" rel="stylesheet">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
||||||
|
{% include 'header.html' %}
|
||||||
|
<div class="flex-1 flex items-center justify-center mx-4">
|
||||||
|
<div class="container py-8 max-w-4xl pt-20">
|
||||||
|
<div class="flex items-center mb-6 relative">
|
||||||
|
<a href="/admin" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11 hover:cursor-pointer"><i class="fas fa-arrow-left"></i></a>
|
||||||
|
<h1 class="text-3xl font-bold text-center w-full">Backup & Restore</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="message" class="hidden mb-4 p-4 rounded-lg"></div>
|
||||||
|
|
||||||
|
<!-- Create Backup Section -->
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg p-6 mb-6">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Create Backup</h2>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Create a new database backup. This will export the entire database to a SQL file.</p>
|
||||||
|
<button id="create-backup-btn" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
|
||||||
|
<i class="fas fa-database"></i>
|
||||||
|
<span>Create Backup</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Restore Backup Section -->
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg p-6 mb-6">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Restore Backup</h2>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Restore the database from a backup file. <strong class="text-red-600 dark:text-red-400">Warning: This will replace all current data!</strong></p>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Upload Backup File -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Upload Backup File</label>
|
||||||
|
<form id="upload-restore-form" enctype="multipart/form-data" class="flex gap-2">
|
||||||
|
<label class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 border border-gray-600 rounded-lg px-4 py-2 cursor-pointer flex items-center justify-center hover:cursor-pointer">
|
||||||
|
<input type="file" name="backup_file" accept=".sql" required class="hidden" onchange="updateFileLabel(this)">
|
||||||
|
<span id="file-label" class="text-sm">Choose File</span>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">
|
||||||
|
<i class="fas fa-upload"></i> Upload & Restore
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Or Select Existing Backup -->
|
||||||
|
{% if backups %}
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Or Restore from Existing Backup</label>
|
||||||
|
<form id="existing-restore-form" class="flex gap-2">
|
||||||
|
<select name="backup_filename" required class="flex-1 border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
||||||
|
<option value="">Select a backup...</option>
|
||||||
|
{% for backup in backups %}
|
||||||
|
<option value="{{ backup.filename }}">{{ backup.filename }} ({{ (backup.size / 1024 / 1024)|round(2) }} MB, {{ backup.created }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">
|
||||||
|
<i class="fas fa-undo"></i> Restore
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Available Backups Section -->
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg p-6">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Available Backups</h2>
|
||||||
|
{% if backups %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full table-auto">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-400 dark:bg-zinc-700">
|
||||||
|
<th class="px-4 py-2 text-left">Filename</th>
|
||||||
|
<th class="px-4 py-2 text-left">Size</th>
|
||||||
|
<th class="px-4 py-2 text-left">Created</th>
|
||||||
|
<th class="px-4 py-2 text-center">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for backup in backups %}
|
||||||
|
<tr class="border-b border-gray-700">
|
||||||
|
<td class="px-4 py-2">{{ backup.filename }}</td>
|
||||||
|
<td class="px-4 py-2">{{ (backup.size / 1024 / 1024)|round(2) }} MB</td>
|
||||||
|
<td class="px-4 py-2">{{ backup.created }}</td>
|
||||||
|
<td class="px-4 py-2 text-center">
|
||||||
|
<div class="flex gap-2 justify-center">
|
||||||
|
<a href="/backup/download/{{ backup.filename }}" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-3 py-1 rounded text-sm" title="Download">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</a>
|
||||||
|
<button onclick="deleteBackup('{{ backup.filename }}')" class="bg-red-300 hover:bg-red-400 dark:bg-red-700 dark:hover:bg-red-600 hover:cursor-pointer px-3 py-1 rounded text-sm" title="Delete">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">No backups available. Create your first backup above.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/js/backup.js"></script>
|
||||||
|
<script>
|
||||||
|
function updateFileLabel(input) {
|
||||||
|
const label = document.getElementById('file-label');
|
||||||
|
if (input.files && input.files[0]) {
|
||||||
|
label.textContent = input.files[0].name;
|
||||||
|
} else {
|
||||||
|
label.textContent = 'Choose File';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
Reference in New Issue
Block a user