feat: SFTP-Backup mit Scheduler, Versionierung und Multi-Target
Mehrere SFTP-Backup-Ziele konfigurierbar mit:
- Host, Port, Benutzername, Passwort, Remote-Pfad
- Konfigurierbares Intervall (15 Min. bis woechentlich oder deaktiviert)
- Maximale Anzahl aufbewahrter Versionen (aeltere werden automatisch geloescht)
- Aktiv/Inaktiv-Toggle pro Ziel
Features:
- Automatischer Hintergrund-Scheduler prueft alle 60 Sekunden ob
Backups faellig sind und fuehrt sie aus
- Manuelles Backup per Klick ("Jetzt sichern")
- SFTP-Verbindungstest-Button
- Versionen-Dialog: Alle Backup-Versionen auf dem SFTP-Server auflisten
mit Groesse und Datum
- Restore direkt von SFTP: Version auswaehlen -> wird heruntergeladen
und ueber die bestehende DB-Merge-Logik wiederhergestellt
- Chunked Upload zum SFTP in 16MB-Bloecken (fuer grosse Backups)
- Status-Anzeige: Letztes Backup, Erfolg/Fehler, Nachricht
Backend: BackupTarget Model, SFTP-Service (paramiko), Backup-Scheduler
API: /admin/backup/targets CRUD, /test, /run, /versions, /restore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c6fe2c590f
commit
d42d6d5d96
|
|
@ -71,4 +71,9 @@ def create_app(config_class=Config):
|
|||
conn.execute(db.text('PRAGMA journal_mode=WAL'))
|
||||
conn.commit()
|
||||
|
||||
# Start backup scheduler (only in main process, not reloader)
|
||||
if not app.debug or os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
|
||||
from app.services.backup_scheduler import start_backup_scheduler
|
||||
start_backup_scheduler(app)
|
||||
|
||||
return app
|
||||
|
|
|
|||
|
|
@ -353,3 +353,179 @@ def _perform_restore(zip_path):
|
|||
stats['success'] = True
|
||||
stats['message'] = 'Restore erfolgreich abgeschlossen'
|
||||
return stats
|
||||
|
||||
|
||||
# ========== SFTP Backup Targets ==========
|
||||
|
||||
@api_bp.route('/admin/backup/targets', methods=['GET'])
|
||||
@admin_required
|
||||
def list_backup_targets():
|
||||
from app.models.backup_target import BackupTarget
|
||||
targets = BackupTarget.query.order_by(BackupTarget.created_at).all()
|
||||
return jsonify([t.to_dict() for t in targets]), 200
|
||||
|
||||
|
||||
@api_bp.route('/admin/backup/targets', methods=['POST'])
|
||||
@admin_required
|
||||
def create_backup_target():
|
||||
from app.models.backup_target import BackupTarget
|
||||
from app.services.crypto_service import encrypt_field
|
||||
|
||||
data = request.get_json()
|
||||
for field in ['name', 'host', 'username']:
|
||||
if not data.get(field):
|
||||
return jsonify({'error': f'{field} erforderlich'}), 400
|
||||
|
||||
if not data.get('password') and not data.get('private_key'):
|
||||
return jsonify({'error': 'Passwort oder Private Key erforderlich'}), 400
|
||||
|
||||
target = BackupTarget(
|
||||
name=data['name'],
|
||||
host=data['host'],
|
||||
port=data.get('port', 22),
|
||||
username=data['username'],
|
||||
remote_path=data.get('remote_path', '/backups/minicloud'),
|
||||
is_active=data.get('is_active', True),
|
||||
backup_interval_minutes=data.get('backup_interval_minutes', 1440),
|
||||
max_versions=data.get('max_versions', 10),
|
||||
)
|
||||
if data.get('password'):
|
||||
target.password_encrypted = encrypt_field(data['password'], 'backup-key')
|
||||
if data.get('private_key'):
|
||||
target.private_key_encrypted = encrypt_field(data['private_key'], 'backup-key')
|
||||
|
||||
db.session.add(target)
|
||||
db.session.commit()
|
||||
return jsonify(target.to_dict()), 201
|
||||
|
||||
|
||||
@api_bp.route('/admin/backup/targets/<int:target_id>', methods=['PUT'])
|
||||
@admin_required
|
||||
def update_backup_target(target_id):
|
||||
from app.models.backup_target import BackupTarget
|
||||
from app.services.crypto_service import encrypt_field
|
||||
|
||||
target = db.session.get(BackupTarget, target_id)
|
||||
if not target:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
data = request.get_json()
|
||||
for field in ['name', 'host', 'port', 'username', 'remote_path',
|
||||
'is_active', 'backup_interval_minutes', 'max_versions']:
|
||||
if field in data:
|
||||
setattr(target, field, data[field])
|
||||
|
||||
if data.get('password'):
|
||||
target.password_encrypted = encrypt_field(data['password'], 'backup-key')
|
||||
if data.get('private_key'):
|
||||
target.private_key_encrypted = encrypt_field(data['private_key'], 'backup-key')
|
||||
|
||||
db.session.commit()
|
||||
return jsonify(target.to_dict()), 200
|
||||
|
||||
|
||||
@api_bp.route('/admin/backup/targets/<int:target_id>', methods=['DELETE'])
|
||||
@admin_required
|
||||
def delete_backup_target(target_id):
|
||||
from app.models.backup_target import BackupTarget
|
||||
target = db.session.get(BackupTarget, target_id)
|
||||
if not target:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
db.session.delete(target)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Backup-Ziel geloescht'}), 200
|
||||
|
||||
|
||||
@api_bp.route('/admin/backup/targets/<int:target_id>/test', methods=['POST'])
|
||||
@admin_required
|
||||
def test_backup_target(target_id):
|
||||
from app.models.backup_target import BackupTarget
|
||||
from app.services.sftp_backup import test_sftp_connection
|
||||
|
||||
target = db.session.get(BackupTarget, target_id)
|
||||
if not target:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
try:
|
||||
test_sftp_connection(target)
|
||||
return jsonify({'message': 'SFTP-Verbindung erfolgreich'}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Verbindungsfehler: {str(e)}'}), 400
|
||||
|
||||
|
||||
@api_bp.route('/admin/backup/targets/<int:target_id>/run', methods=['POST'])
|
||||
@admin_required
|
||||
def run_backup_now(target_id):
|
||||
"""Manually trigger a backup to this target."""
|
||||
from app.models.backup_target import BackupTarget
|
||||
from app.services.sftp_backup import create_backup_zip, upload_backup_to_sftp
|
||||
|
||||
target = db.session.get(BackupTarget, target_id)
|
||||
if not target:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
db_uri = current_app.config['SQLALCHEMY_DATABASE_URI']
|
||||
db_path = db_uri.replace('sqlite:///', '')
|
||||
upload_path = current_app.config['UPLOAD_PATH']
|
||||
|
||||
zip_path = None
|
||||
try:
|
||||
zip_path = create_backup_zip(db_path, upload_path)
|
||||
version = upload_backup_to_sftp(target, zip_path, current_app)
|
||||
|
||||
target.last_backup_at = datetime.now(timezone.utc)
|
||||
target.last_backup_status = 'success'
|
||||
target.last_backup_message = f'Version {version} hochgeladen'
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'message': f'Backup {version} erfolgreich', 'version': version}), 200
|
||||
except Exception as e:
|
||||
target.last_backup_at = datetime.now(timezone.utc)
|
||||
target.last_backup_status = 'error'
|
||||
target.last_backup_message = str(e)[:500]
|
||||
db.session.commit()
|
||||
return jsonify({'error': f'Backup fehlgeschlagen: {str(e)}'}), 500
|
||||
finally:
|
||||
if zip_path and os.path.exists(zip_path):
|
||||
os.unlink(zip_path)
|
||||
|
||||
|
||||
@api_bp.route('/admin/backup/targets/<int:target_id>/versions', methods=['GET'])
|
||||
@admin_required
|
||||
def list_backup_versions(target_id):
|
||||
from app.models.backup_target import BackupTarget
|
||||
from app.services.sftp_backup import list_sftp_versions
|
||||
|
||||
target = db.session.get(BackupTarget, target_id)
|
||||
if not target:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
try:
|
||||
versions = list_sftp_versions(target)
|
||||
return jsonify(versions), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/admin/backup/targets/<int:target_id>/restore/<version_name>', methods=['POST'])
|
||||
@admin_required
|
||||
def restore_from_sftp(target_id, version_name):
|
||||
"""Download a backup version from SFTP and restore it."""
|
||||
from app.models.backup_target import BackupTarget
|
||||
from app.services.sftp_backup import download_version_from_sftp
|
||||
|
||||
target = db.session.get(BackupTarget, target_id)
|
||||
if not target:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
zip_path = None
|
||||
try:
|
||||
zip_path = download_version_from_sftp(target, version_name)
|
||||
result = _perform_restore(zip_path)
|
||||
return jsonify(result), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Restore fehlgeschlagen: {str(e)}'}), 500
|
||||
finally:
|
||||
if zip_path and os.path.exists(zip_path):
|
||||
os.unlink(zip_path)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from app.models.contact import AddressBook, Contact, AddressBookShare
|
|||
from app.models.email_account import EmailAccount
|
||||
from app.models.password_vault import PasswordFolder, PasswordEntry, PasswordShare
|
||||
from app.models.settings import AppSettings
|
||||
from app.models.backup_target import BackupTarget
|
||||
|
||||
__all__ = [
|
||||
'User',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class BackupTarget(db.Model):
|
||||
__tablename__ = 'backup_targets'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
host = db.Column(db.String(255), nullable=False)
|
||||
port = db.Column(db.Integer, default=22)
|
||||
username = db.Column(db.String(100), nullable=False)
|
||||
password_encrypted = db.Column(db.LargeBinary, nullable=True)
|
||||
private_key_encrypted = db.Column(db.LargeBinary, nullable=True)
|
||||
remote_path = db.Column(db.String(500), default='/backups/minicloud')
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
backup_interval_minutes = db.Column(db.Integer, default=1440) # Default: daily
|
||||
max_versions = db.Column(db.Integer, default=10)
|
||||
last_backup_at = db.Column(db.DateTime, nullable=True)
|
||||
last_backup_status = db.Column(db.String(20), nullable=True) # 'success', 'error'
|
||||
last_backup_message = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'host': self.host,
|
||||
'port': self.port,
|
||||
'username': self.username,
|
||||
'has_password': bool(self.password_encrypted),
|
||||
'has_private_key': bool(self.private_key_encrypted),
|
||||
'remote_path': self.remote_path,
|
||||
'is_active': self.is_active,
|
||||
'backup_interval_minutes': self.backup_interval_minutes,
|
||||
'max_versions': self.max_versions,
|
||||
'last_backup_at': self.last_backup_at.isoformat() if self.last_backup_at else None,
|
||||
'last_backup_status': self.last_backup_status,
|
||||
'last_backup_message': self.last_backup_message,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
"""Background scheduler for periodic SFTP backups."""
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
_scheduler_thread = None
|
||||
_scheduler_running = False
|
||||
|
||||
|
||||
def start_backup_scheduler(app):
|
||||
"""Start the background backup scheduler."""
|
||||
global _scheduler_thread, _scheduler_running
|
||||
|
||||
if _scheduler_running:
|
||||
return
|
||||
|
||||
_scheduler_running = True
|
||||
|
||||
def scheduler_loop():
|
||||
while _scheduler_running:
|
||||
try:
|
||||
with app.app_context():
|
||||
_check_and_run_backups(app)
|
||||
except Exception as e:
|
||||
print(f'[Backup Scheduler] Error: {e}')
|
||||
|
||||
# Check every 60 seconds
|
||||
for _ in range(60):
|
||||
if not _scheduler_running:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
_scheduler_thread = threading.Thread(target=scheduler_loop, daemon=True)
|
||||
_scheduler_thread.start()
|
||||
|
||||
|
||||
def stop_backup_scheduler():
|
||||
global _scheduler_running
|
||||
_scheduler_running = False
|
||||
|
||||
|
||||
def _check_and_run_backups(app):
|
||||
"""Check all active backup targets and run if due."""
|
||||
from app.extensions import db
|
||||
from app.models.backup_target import BackupTarget
|
||||
from app.services.sftp_backup import create_backup_zip, upload_backup_to_sftp
|
||||
|
||||
targets = BackupTarget.query.filter_by(is_active=True).all()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for target in targets:
|
||||
if not target.backup_interval_minutes or target.backup_interval_minutes <= 0:
|
||||
continue
|
||||
|
||||
# Check if backup is due
|
||||
if target.last_backup_at:
|
||||
next_due = target.last_backup_at + timedelta(minutes=target.backup_interval_minutes)
|
||||
if now < next_due:
|
||||
continue
|
||||
|
||||
# Run backup
|
||||
db_uri = app.config['SQLALCHEMY_DATABASE_URI']
|
||||
db_path = db_uri.replace('sqlite:///', '')
|
||||
upload_path = app.config['UPLOAD_PATH']
|
||||
|
||||
zip_path = None
|
||||
try:
|
||||
zip_path = create_backup_zip(db_path, upload_path)
|
||||
version = upload_backup_to_sftp(target, zip_path, app)
|
||||
|
||||
target.last_backup_at = now
|
||||
target.last_backup_status = 'success'
|
||||
target.last_backup_message = f'Version {version} erfolgreich hochgeladen'
|
||||
db.session.commit()
|
||||
print(f'[Backup] {target.name}: {version} OK')
|
||||
|
||||
except Exception as e:
|
||||
target.last_backup_at = now
|
||||
target.last_backup_status = 'error'
|
||||
target.last_backup_message = str(e)[:500]
|
||||
db.session.commit()
|
||||
print(f'[Backup] {target.name}: FEHLER - {e}')
|
||||
|
||||
finally:
|
||||
if zip_path and os.path.exists(zip_path):
|
||||
os.unlink(zip_path)
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
"""SFTP Backup Service - handles backup upload/download/versioning."""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import stat
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import paramiko
|
||||
|
||||
|
||||
def get_sftp_connection(target, app_config=None):
|
||||
"""Create SFTP connection from a BackupTarget."""
|
||||
from app.services.crypto_service import decrypt_field
|
||||
|
||||
transport = paramiko.Transport((target.host, target.port))
|
||||
|
||||
if target.private_key_encrypted:
|
||||
key_pem = decrypt_field(target.private_key_encrypted, 'backup-key')
|
||||
pkey = paramiko.RSAKey.from_private_key(io.StringIO(key_pem))
|
||||
transport.connect(username=target.username, pkey=pkey)
|
||||
elif target.password_encrypted:
|
||||
password = decrypt_field(target.password_encrypted, 'backup-key')
|
||||
transport.connect(username=target.username, password=password)
|
||||
else:
|
||||
raise ValueError('Weder Passwort noch Private Key konfiguriert')
|
||||
|
||||
return paramiko.SFTPClient.from_transport(transport), transport
|
||||
|
||||
|
||||
def ensure_remote_dir(sftp, path):
|
||||
"""Recursively create remote directories."""
|
||||
dirs = []
|
||||
while path and path != '/' and path != '.':
|
||||
try:
|
||||
sftp.stat(path)
|
||||
break
|
||||
except FileNotFoundError:
|
||||
dirs.insert(0, path)
|
||||
path = os.path.dirname(path)
|
||||
for d in dirs:
|
||||
try:
|
||||
sftp.mkdir(d)
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
|
||||
def create_backup_zip(db_path, upload_path):
|
||||
"""Create a backup ZIP and return the temp file path."""
|
||||
from app.models.user import User
|
||||
from app.models.file import File
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.zip')
|
||||
tmp_path = tmp.name
|
||||
tmp.close()
|
||||
|
||||
metadata = {
|
||||
'version': '1.0',
|
||||
'created_at': datetime.now(timezone.utc).isoformat(),
|
||||
'description': 'Mini-Cloud SFTP Backup',
|
||||
}
|
||||
|
||||
with zipfile.ZipFile(tmp_path, 'w', zipfile.ZIP_DEFLATED, allowZip64=True) as zf:
|
||||
zf.writestr('metadata.json', json.dumps(metadata, indent=2))
|
||||
|
||||
# DB backup
|
||||
db_backup_path = tmp_path + '.db'
|
||||
try:
|
||||
source = sqlite3.connect(db_path)
|
||||
dest = sqlite3.connect(db_backup_path)
|
||||
source.backup(dest)
|
||||
source.close()
|
||||
dest.close()
|
||||
zf.write(db_backup_path, 'database.sqlite3')
|
||||
finally:
|
||||
if os.path.exists(db_backup_path):
|
||||
os.unlink(db_backup_path)
|
||||
|
||||
# Files
|
||||
upload_dir = Path(upload_path)
|
||||
if upload_dir.exists():
|
||||
for fp in upload_dir.rglob('*'):
|
||||
if fp.is_file():
|
||||
zf.write(str(fp), 'files/' + str(fp.relative_to(upload_dir)))
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
def upload_backup_to_sftp(target, zip_path, app):
|
||||
"""Upload backup ZIP to SFTP target in chunks. Returns version name."""
|
||||
timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
|
||||
version_name = f'backup_{timestamp}.zip'
|
||||
|
||||
sftp, transport = get_sftp_connection(target)
|
||||
try:
|
||||
remote_dir = target.remote_path.rstrip('/')
|
||||
ensure_remote_dir(sftp, remote_dir)
|
||||
|
||||
remote_file = f'{remote_dir}/{version_name}'
|
||||
|
||||
# Upload in chunks (16MB)
|
||||
file_size = os.path.getsize(zip_path)
|
||||
chunk_size = 16 * 1024 * 1024
|
||||
|
||||
with open(zip_path, 'rb') as local_file:
|
||||
with sftp.open(remote_file, 'wb') as remote:
|
||||
remote.set_pipelined(True)
|
||||
while True:
|
||||
data = local_file.read(chunk_size)
|
||||
if not data:
|
||||
break
|
||||
remote.write(data)
|
||||
|
||||
# Write a latest marker
|
||||
sftp.open(f'{remote_dir}/latest.txt', 'w').write(version_name)
|
||||
|
||||
# Cleanup old versions
|
||||
_cleanup_old_versions(sftp, remote_dir, target.max_versions)
|
||||
|
||||
return version_name
|
||||
finally:
|
||||
sftp.close()
|
||||
transport.close()
|
||||
|
||||
|
||||
def _cleanup_old_versions(sftp, remote_dir, max_versions):
|
||||
"""Remove old backup versions exceeding max_versions."""
|
||||
try:
|
||||
entries = sftp.listdir_attr(remote_dir)
|
||||
backups = sorted(
|
||||
[e for e in entries if e.filename.startswith('backup_') and e.filename.endswith('.zip')],
|
||||
key=lambda e: e.st_mtime or 0,
|
||||
reverse=True,
|
||||
)
|
||||
for old in backups[max_versions:]:
|
||||
try:
|
||||
sftp.remove(f'{remote_dir}/{old.filename}')
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def list_sftp_versions(target):
|
||||
"""List available backup versions on SFTP target."""
|
||||
sftp, transport = get_sftp_connection(target)
|
||||
try:
|
||||
remote_dir = target.remote_path.rstrip('/')
|
||||
try:
|
||||
entries = sftp.listdir_attr(remote_dir)
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
|
||||
versions = []
|
||||
for e in entries:
|
||||
if e.filename.startswith('backup_') and e.filename.endswith('.zip'):
|
||||
versions.append({
|
||||
'name': e.filename,
|
||||
'size': e.st_size,
|
||||
'modified': datetime.fromtimestamp(e.st_mtime, tz=timezone.utc).isoformat()
|
||||
if e.st_mtime else None,
|
||||
})
|
||||
|
||||
versions.sort(key=lambda v: v['name'], reverse=True)
|
||||
return versions
|
||||
finally:
|
||||
sftp.close()
|
||||
transport.close()
|
||||
|
||||
|
||||
def download_version_from_sftp(target, version_name):
|
||||
"""Download a specific backup version from SFTP. Returns temp file path."""
|
||||
sftp, transport = get_sftp_connection(target)
|
||||
try:
|
||||
remote_dir = target.remote_path.rstrip('/')
|
||||
remote_file = f'{remote_dir}/{version_name}'
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.zip')
|
||||
tmp_path = tmp.name
|
||||
tmp.close()
|
||||
|
||||
# Download in chunks
|
||||
with sftp.open(remote_file, 'rb') as remote:
|
||||
with open(tmp_path, 'wb') as local:
|
||||
while True:
|
||||
data = remote.read(16 * 1024 * 1024)
|
||||
if not data:
|
||||
break
|
||||
local.write(data)
|
||||
|
||||
return tmp_path
|
||||
finally:
|
||||
sftp.close()
|
||||
transport.close()
|
||||
|
||||
|
||||
def test_sftp_connection(target):
|
||||
"""Test SFTP connection. Returns True on success, raises on error."""
|
||||
sftp, transport = get_sftp_connection(target)
|
||||
try:
|
||||
remote_dir = target.remote_path.rstrip('/')
|
||||
ensure_remote_dir(sftp, remote_dir)
|
||||
# Try to write a test file
|
||||
test_file = f'{remote_dir}/.minicloud_test'
|
||||
sftp.open(test_file, 'w').write('ok')
|
||||
sftp.remove(test_file)
|
||||
return True
|
||||
finally:
|
||||
sftp.close()
|
||||
transport.close()
|
||||
|
|
@ -27,5 +27,8 @@ cryptography==44.0.3
|
|||
# KeePass Import
|
||||
pykeepass==4.1.0
|
||||
|
||||
# SFTP Backup
|
||||
paramiko==4.0.0
|
||||
|
||||
# Utilities
|
||||
Pillow==11.1.0
|
||||
|
|
|
|||
|
|
@ -139,6 +139,118 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SFTP Backup Targets -->
|
||||
<div class="admin-section">
|
||||
<div class="section-header">
|
||||
<h3>SFTP-Backup-Ziele</h3>
|
||||
<Button icon="pi pi-plus" label="Ziel hinzufuegen" size="small" @click="openNewTarget" />
|
||||
</div>
|
||||
<p class="hint">Automatische Backups werden im eingestellten Intervall auf SFTP-Server hochgeladen. Mehrere Ziele moeglich.</p>
|
||||
|
||||
<div v-if="!sftpTargets.length" class="empty-hint-small">
|
||||
Keine SFTP-Backup-Ziele konfiguriert.
|
||||
</div>
|
||||
|
||||
<div v-for="tgt in sftpTargets" :key="tgt.id" class="sftp-target-card">
|
||||
<div class="target-header">
|
||||
<div class="target-info">
|
||||
<strong>{{ tgt.name }}</strong>
|
||||
<span class="target-detail">{{ tgt.username }}@{{ tgt.host }}:{{ tgt.port }}{{ tgt.remote_path }}</span>
|
||||
</div>
|
||||
<div class="target-status">
|
||||
<Tag v-if="tgt.is_active" value="Aktiv" severity="success" />
|
||||
<Tag v-else value="Inaktiv" severity="warn" />
|
||||
<Tag :value="intervalLabel(tgt.backup_interval_minutes)" severity="info" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tgt.last_backup_at" class="target-last-backup">
|
||||
<i :class="tgt.last_backup_status === 'success' ? 'pi pi-check-circle' : 'pi pi-times-circle'"
|
||||
:style="{ color: tgt.last_backup_status === 'success' ? 'var(--p-green-500)' : 'var(--p-red-500)' }"></i>
|
||||
Letztes Backup: {{ formatDateTime(tgt.last_backup_at) }}
|
||||
<span v-if="tgt.last_backup_message" class="backup-msg">- {{ tgt.last_backup_message }}</span>
|
||||
</div>
|
||||
|
||||
<div class="target-actions">
|
||||
<Button icon="pi pi-play" label="Jetzt sichern" size="small" outlined @click="runBackupNow(tgt)" :loading="tgt._running" />
|
||||
<Button icon="pi pi-list" label="Versionen" size="small" outlined @click="openVersions(tgt)" />
|
||||
<Button icon="pi pi-check-circle" label="Testen" size="small" text @click="testTarget(tgt)" />
|
||||
<Button icon="pi pi-pencil" text size="small" @click="openEditTarget(tgt)" />
|
||||
<Button icon="pi pi-trash" text size="small" severity="danger" @click="deleteSftpTarget(tgt)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SFTP Target Dialog -->
|
||||
<Dialog v-model:visible="showTargetDialog" :header="editingTarget ? 'Backup-Ziel bearbeiten' : 'Neues Backup-Ziel'" modal :style="{ width: '550px' }">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<InputText v-model="targetForm.name" placeholder="z.B. Hetzner Storage Box" fluid autofocus />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field flex-grow">
|
||||
<label>SFTP-Host</label>
|
||||
<InputText v-model="targetForm.host" placeholder="backup.example.com" fluid />
|
||||
</div>
|
||||
<div class="field" style="width: 100px">
|
||||
<label>Port</label>
|
||||
<InputText v-model.number="targetForm.port" type="number" fluid />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Benutzername</label>
|
||||
<InputText v-model="targetForm.username" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ editingTarget?.has_password ? 'Passwort (leer = nicht aendern)' : 'Passwort' }}</label>
|
||||
<Password v-model="targetForm.password" :feedback="false" toggle-mask fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Remote-Pfad</label>
|
||||
<InputText v-model="targetForm.remote_path" placeholder="/backups/minicloud" fluid />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field flex-grow">
|
||||
<label>Backup-Intervall</label>
|
||||
<Select v-model="targetForm.backup_interval_minutes" :options="intervalOptions" optionLabel="label" optionValue="value" fluid />
|
||||
</div>
|
||||
<div class="field" style="width: 140px">
|
||||
<label>Max. Versionen</label>
|
||||
<InputText v-model.number="targetForm.max_versions" type="number" fluid />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Aktiv</label>
|
||||
<InputSwitch v-model="targetForm.is_active" />
|
||||
</div>
|
||||
<Message v-if="targetError" severity="error" :closable="false">{{ targetError }}</Message>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showTargetDialog = false" />
|
||||
<Button :label="editingTarget ? 'Speichern' : 'Hinzufuegen'" @click="saveTarget" :loading="targetSaving" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Versions Dialog -->
|
||||
<Dialog v-model:visible="showVersionsDialog" header="Backup-Versionen" modal :style="{ width: '600px' }">
|
||||
<div v-if="versionsLoading" class="loading-center">
|
||||
<i class="pi pi-spin pi-spinner"></i> Lade Versionen...
|
||||
</div>
|
||||
<div v-else-if="!versions.length" class="empty-hint-small">
|
||||
Keine Backups auf diesem Server gefunden.
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="hint">Ziel: <strong>{{ versionsTarget?.name }}</strong></p>
|
||||
<div v-for="ver in versions" :key="ver.name" class="version-item">
|
||||
<div class="version-info">
|
||||
<strong>{{ ver.name }}</strong>
|
||||
<span>{{ formatSize(ver.size) }} | {{ formatDateTime(ver.modified) }}</span>
|
||||
</div>
|
||||
<Button label="Restore" icon="pi pi-download" size="small" severity="warn"
|
||||
@click="restoreFromVersion(ver)" :loading="ver._restoring" />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- User Management -->
|
||||
<div class="admin-section">
|
||||
<div class="section-header">
|
||||
|
|
@ -353,6 +465,32 @@ const restoreStatus = ref('')
|
|||
const restoreResult = ref(null)
|
||||
const CHUNK_SIZE = 10 * 1024 * 1024 // 10 MB
|
||||
|
||||
// SFTP Backup Targets
|
||||
const sftpTargets = ref([])
|
||||
const showTargetDialog = ref(false)
|
||||
const editingTarget = ref(null)
|
||||
const targetForm = ref({
|
||||
name: '', host: '', port: 22, username: '', password: '',
|
||||
remote_path: '/backups/minicloud', is_active: true,
|
||||
backup_interval_minutes: 1440, max_versions: 10,
|
||||
})
|
||||
const targetError = ref('')
|
||||
const targetSaving = ref(false)
|
||||
const intervalOptions = [
|
||||
{ label: 'Alle 15 Minuten', value: 15 },
|
||||
{ label: 'Stuendlich', value: 60 },
|
||||
{ label: 'Alle 6 Stunden', value: 360 },
|
||||
{ label: 'Alle 12 Stunden', value: 720 },
|
||||
{ label: 'Taeglich', value: 1440 },
|
||||
{ label: 'Woechentlich', value: 10080 },
|
||||
{ label: 'Deaktiviert', value: 0 },
|
||||
]
|
||||
|
||||
const showVersionsDialog = ref(false)
|
||||
const versionsTarget = ref(null)
|
||||
const versions = ref([])
|
||||
const versionsLoading = ref(false)
|
||||
|
||||
const showUserDialog = ref(false)
|
||||
const editingUser = ref(null)
|
||||
const userForm = ref({ username: '', email: '', password: '', role: 'user', storage_quota_mb: 5120, is_active: true })
|
||||
|
|
@ -418,7 +556,140 @@ async function saveSettings() {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Backup & Restore ---
|
||||
// --- SFTP Targets ---
|
||||
async function loadTargets() {
|
||||
try {
|
||||
const res = await apiClient.get('/admin/backup/targets')
|
||||
sftpTargets.value = res.data
|
||||
} catch { sftpTargets.value = [] }
|
||||
}
|
||||
|
||||
function intervalLabel(minutes) {
|
||||
if (!minutes) return 'Deaktiviert'
|
||||
if (minutes < 60) return `Alle ${minutes} Min.`
|
||||
if (minutes < 1440) return `Alle ${minutes / 60} Std.`
|
||||
if (minutes === 1440) return 'Taeglich'
|
||||
if (minutes === 10080) return 'Woechentlich'
|
||||
return `Alle ${Math.round(minutes / 1440)} Tage`
|
||||
}
|
||||
|
||||
function formatDateTime(iso) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function openNewTarget() {
|
||||
editingTarget.value = null
|
||||
targetForm.value = {
|
||||
name: '', host: '', port: 22, username: '', password: '',
|
||||
remote_path: '/backups/minicloud', is_active: true,
|
||||
backup_interval_minutes: 1440, max_versions: 10,
|
||||
}
|
||||
targetError.value = ''
|
||||
showTargetDialog.value = true
|
||||
}
|
||||
|
||||
function openEditTarget(tgt) {
|
||||
editingTarget.value = tgt
|
||||
targetForm.value = {
|
||||
name: tgt.name, host: tgt.host, port: tgt.port, username: tgt.username, password: '',
|
||||
remote_path: tgt.remote_path, is_active: tgt.is_active,
|
||||
backup_interval_minutes: tgt.backup_interval_minutes, max_versions: tgt.max_versions,
|
||||
}
|
||||
targetError.value = ''
|
||||
showTargetDialog.value = true
|
||||
}
|
||||
|
||||
async function saveTarget() {
|
||||
targetError.value = ''
|
||||
targetSaving.value = true
|
||||
try {
|
||||
const payload = { ...targetForm.value }
|
||||
if (editingTarget.value) {
|
||||
if (!payload.password) delete payload.password
|
||||
await apiClient.put(`/admin/backup/targets/${editingTarget.value.id}`, payload)
|
||||
} else {
|
||||
if (!payload.password) { targetError.value = 'Passwort erforderlich'; return }
|
||||
await apiClient.post('/admin/backup/targets', payload)
|
||||
}
|
||||
showTargetDialog.value = false
|
||||
await loadTargets()
|
||||
toast.add({ severity: 'success', summary: 'Backup-Ziel gespeichert', life: 3000 })
|
||||
} catch (err) {
|
||||
targetError.value = err.response?.data?.error || 'Fehler'
|
||||
} finally {
|
||||
targetSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSftpTarget(tgt) {
|
||||
try {
|
||||
await apiClient.delete(`/admin/backup/targets/${tgt.id}`)
|
||||
await loadTargets()
|
||||
toast.add({ severity: 'success', summary: 'Backup-Ziel geloescht', life: 3000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function testTarget(tgt) {
|
||||
try {
|
||||
const res = await apiClient.post(`/admin/backup/targets/${tgt.id}/test`)
|
||||
toast.add({ severity: 'success', summary: res.data.message, life: 5000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Verbindung fehlgeschlagen', detail: err.response?.data?.error, life: 8000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function runBackupNow(tgt) {
|
||||
tgt._running = true
|
||||
try {
|
||||
const res = await apiClient.post(`/admin/backup/targets/${tgt.id}/run`, {}, { timeout: 600000 })
|
||||
toast.add({ severity: 'success', summary: res.data.message, life: 5000 })
|
||||
await loadTargets()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Backup fehlgeschlagen', detail: err.response?.data?.error, life: 8000 })
|
||||
} finally {
|
||||
tgt._running = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openVersions(tgt) {
|
||||
versionsTarget.value = tgt
|
||||
versions.value = []
|
||||
versionsLoading.value = true
|
||||
showVersionsDialog.value = true
|
||||
try {
|
||||
const res = await apiClient.get(`/admin/backup/targets/${tgt.id}/versions`)
|
||||
versions.value = res.data
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
} finally {
|
||||
versionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreFromVersion(ver) {
|
||||
if (!versionsTarget.value) return
|
||||
ver._restoring = true
|
||||
try {
|
||||
const res = await apiClient.post(
|
||||
`/admin/backup/targets/${versionsTarget.value.id}/restore/${ver.name}`,
|
||||
{}, { timeout: 600000 }
|
||||
)
|
||||
toast.add({ severity: 'success', summary: 'Restore erfolgreich', detail: res.data.message, life: 5000 })
|
||||
showVersionsDialog.value = false
|
||||
await loadUsers()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Restore fehlgeschlagen', detail: err.response?.data?.error, life: 8000 })
|
||||
} finally {
|
||||
ver._restoring = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Backup & Restore (Local) ---
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
|
|
@ -716,6 +987,7 @@ async function doDeleteUser() {
|
|||
onMounted(() => {
|
||||
loadUsers()
|
||||
loadSettings()
|
||||
loadTargets()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -777,4 +1049,21 @@ onMounted(() => {
|
|||
.restore-result { margin-top: 1rem; }
|
||||
.result-details { font-size: 0.85rem; margin-top: 0.5rem; }
|
||||
.result-details ul { margin: 0.25rem 0; padding-left: 1.25rem; }
|
||||
.sftp-target-card {
|
||||
border: 1px solid var(--p-surface-200); border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem;
|
||||
}
|
||||
.target-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; }
|
||||
.target-info strong { display: block; }
|
||||
.target-detail { font-size: 0.8rem; color: var(--p-text-muted-color); font-family: monospace; }
|
||||
.target-status { display: flex; gap: 0.375rem; flex-shrink: 0; }
|
||||
.target-last-backup { font-size: 0.825rem; margin: 0.5rem 0; display: flex; align-items: center; gap: 0.375rem; }
|
||||
.backup-msg { color: var(--p-text-muted-color); }
|
||||
.target-actions { display: flex; gap: 0.25rem; flex-wrap: wrap; margin-top: 0.5rem; }
|
||||
.version-item {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 0.625rem 0; border-bottom: 1px solid var(--p-surface-100);
|
||||
}
|
||||
.version-info { display: flex; flex-direction: column; gap: 0.125rem; }
|
||||
.version-info span { font-size: 0.8rem; color: var(--p-text-muted-color); }
|
||||
.loading-center { text-align: center; padding: 2rem; color: var(--p-text-muted-color); }
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in New Issue