Backup-Funktionalität

Dieser Commit ist enthalten in:
2025-06-07 17:23:49 +02:00
Ursprung fbf47888ee
Commit a37d68838a
17 geänderte Dateien mit 773 neuen und 9 gelöschten Zeilen

Datei anzeigen

@@ -16,7 +16,8 @@
"Bash(docker network inspect:*)",
"Bash(mkdir:*)",
"Bash(sudo touch:*)",
"Bash(docker volume rm:*)"
"Bash(docker volume rm:*)",
"Bash(rm:*)"
],
"deny": []
}

Datei anzeigen

@@ -377,3 +377,36 @@ Die Session-Daten werden erst gefüllt, wenn der License Server API implementier
- Locale-Installation über apt-get
- locale-gen für de_DE.UTF-8
- Vollständige UTF-8 Unterstützung für deutsche Sonderzeichen
### 2025-01-07 - Backup-Funktionalität implementiert
- Verschlüsselte Backups mit manueller und automatischer Ausführung
- Backup-Historie mit Download und Wiederherstellung
- Dashboard-Integration für Backup-Status
**Neue Features:**
- **Backup-Erstellung**: Manuell und automatisch (täglich 3:00 Uhr)
- **Verschlüsselung**: AES-256 mit Fernet, Key aus ENV oder automatisch generiert
- **Komprimierung**: GZIP-Komprimierung vor Verschlüsselung
- **Backup-Historie**: Vollständige Übersicht aller Backups
- **Wiederherstellung**: Mit optionalem Verschlüsselungs-Passwort
- **Download-Funktion**: Backups können heruntergeladen werden
- **Dashboard-Widget**: Zeigt letztes Backup-Status
- **E-Mail-Vorbereitung**: Struktur für Benachrichtigungen (deaktiviert)
**Neue/Geänderte Dateien:**
- v2_adminpanel/init.sql (backup_history Tabelle hinzugefügt)
- v2_adminpanel/requirements.txt (cryptography, apscheduler hinzugefügt)
- v2_adminpanel/app.py (Backup-Funktionen und Routen)
- v2_adminpanel/templates/backups.html (neu erstellt)
- v2_adminpanel/templates/dashboard.html (Backup-Status-Widget)
- v2_adminpanel/Dockerfile (PostgreSQL-Client installiert)
- v2/.env (EMAIL_ENABLED und BACKUP_ENCRYPTION_KEY)
- Alle Templates (Backup-Navigation hinzugefügt)
**Technische Details:**
- Speicherort: C:\Users\Administrator\Documents\GitHub\v2-Docker\backups\
- Dateiformat: backup_v2docker_YYYYMMDD_HHMMSS_encrypted.sql.gz.enc
- APScheduler für automatische Backups
- pg_dump/psql für Datenbank-Operationen
- Audit-Log für alle Backup-Aktionen
- Sicherheitsabfrage bei Wiederherstellung

Datei anzeigen

@@ -39,3 +39,11 @@ ADMIN_PANEL_DOMAIN=admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com
# Serverseitig gepflegte aktuelle Software-Version
# Diese wird vom Lizenzserver genutzt, um die Kundenversion zu vergleichen
LATEST_CLIENT_VERSION=1.0.0
# ===================== BACKUP KONFIGURATION =====================
# E-Mail für Backup-Benachrichtigungen
EMAIL_ENABLED=false
# Backup-Verschlüsselung (optional, wird automatisch generiert wenn leer)
# BACKUP_ENCRYPTION_KEY=

Datei anzeigen

@@ -1,5 +0,0 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1751984506 session NYjsxmcb81rBYjc17nkaARMbfQAgYxkTegLkLrxWfmM

Datei anzeigen

@@ -7,6 +7,16 @@ ENV PYTHONIOENCODING=utf-8
WORKDIR /app
# System-Dependencies inkl. PostgreSQL-Tools installieren
RUN apt-get update && apt-get install -y \
locales \
postgresql-client \
&& sed -i '/de_DE.UTF-8/s/^# //g' /etc/locale.gen \
&& locale-gen \
&& update-locale LANG=de_DE.UTF-8 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

Datei anzeigen

@@ -1,7 +1,7 @@
import os
import psycopg2
from psycopg2.extras import Json
from flask import Flask, render_template, request, redirect, session, url_for, send_file
from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify
from flask_session import Session
from functools import wraps
from dotenv import load_dotenv
@@ -9,6 +9,13 @@ import pandas as pd
from datetime import datetime
import io
import json
import subprocess
import gzip
from cryptography.fernet import Fernet
from pathlib import Path
import time
from apscheduler.schedulers.background import BackgroundScheduler
import logging
load_dotenv()
@@ -19,6 +26,17 @@ app.config['JSON_AS_ASCII'] = False # JSON-Ausgabe mit UTF-8
app.config['JSONIFY_MIMETYPE'] = 'application/json; charset=utf-8'
Session(app)
# Backup-Konfiguration
BACKUP_DIR = Path("/app/backups")
BACKUP_DIR.mkdir(exist_ok=True)
# Scheduler für automatische Backups
scheduler = BackgroundScheduler()
scheduler.start()
# Logging konfigurieren
logging.basicConfig(level=logging.INFO)
# Login decorator
def login_required(f):
@wraps(f)
@@ -72,6 +90,238 @@ def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=N
cur.close()
conn.close()
# Verschlüsselungs-Funktionen
def get_or_create_encryption_key():
"""Holt oder erstellt einen Verschlüsselungsschlüssel"""
key_file = BACKUP_DIR / ".backup_key"
# Versuche Key aus Umgebungsvariable zu lesen
env_key = os.getenv("BACKUP_ENCRYPTION_KEY")
if env_key:
try:
# Validiere den Key
Fernet(env_key.encode())
return env_key.encode()
except:
pass
# Wenn kein gültiger Key in ENV, prüfe Datei
if key_file.exists():
return key_file.read_bytes()
# Erstelle neuen Key
key = Fernet.generate_key()
key_file.write_bytes(key)
logging.info("Neuer Backup-Verschlüsselungsschlüssel erstellt")
return key
# Backup-Funktionen
def create_backup(backup_type="manual", created_by=None):
"""Erstellt ein verschlüsseltes Backup der Datenbank"""
start_time = time.time()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"backup_v2docker_{timestamp}_encrypted.sql.gz.enc"
filepath = BACKUP_DIR / filename
conn = get_connection()
cur = conn.cursor()
# Backup-Eintrag erstellen
cur.execute("""
INSERT INTO backup_history
(filename, filepath, backup_type, status, created_by, is_encrypted)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""", (filename, str(filepath), backup_type, 'in_progress',
created_by or 'system', True))
backup_id = cur.fetchone()[0]
conn.commit()
try:
# PostgreSQL Dump erstellen
dump_command = [
'pg_dump',
'-h', os.getenv("POSTGRES_HOST", "postgres"),
'-p', os.getenv("POSTGRES_PORT", "5432"),
'-U', os.getenv("POSTGRES_USER"),
'-d', os.getenv("POSTGRES_DB"),
'--no-password',
'--verbose'
]
# PGPASSWORD setzen
env = os.environ.copy()
env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD")
# Dump ausführen
result = subprocess.run(dump_command, capture_output=True, text=True, env=env)
if result.returncode != 0:
raise Exception(f"pg_dump failed: {result.stderr}")
dump_data = result.stdout.encode('utf-8')
# Komprimieren
compressed_data = gzip.compress(dump_data)
# Verschlüsseln
key = get_or_create_encryption_key()
f = Fernet(key)
encrypted_data = f.encrypt(compressed_data)
# Speichern
filepath.write_bytes(encrypted_data)
# Statistiken sammeln
cur.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'")
tables_count = cur.fetchone()[0]
cur.execute("""
SELECT SUM(n_live_tup)
FROM pg_stat_user_tables
""")
records_count = cur.fetchone()[0] or 0
duration = time.time() - start_time
filesize = filepath.stat().st_size
# Backup-Eintrag aktualisieren
cur.execute("""
UPDATE backup_history
SET status = %s, filesize = %s, tables_count = %s,
records_count = %s, duration_seconds = %s
WHERE id = %s
""", ('success', filesize, tables_count, records_count, duration, backup_id))
conn.commit()
# Audit-Log
log_audit('BACKUP', 'database', backup_id,
additional_info=f"Backup erstellt: {filename} ({filesize} bytes)")
# E-Mail-Benachrichtigung (wenn konfiguriert)
send_backup_notification(True, filename, filesize, duration)
logging.info(f"Backup erfolgreich erstellt: {filename}")
return True, filename
except Exception as e:
# Fehler protokollieren
cur.execute("""
UPDATE backup_history
SET status = %s, error_message = %s, duration_seconds = %s
WHERE id = %s
""", ('failed', str(e), time.time() - start_time, backup_id))
conn.commit()
logging.error(f"Backup fehlgeschlagen: {e}")
send_backup_notification(False, filename, error=str(e))
return False, str(e)
finally:
cur.close()
conn.close()
def restore_backup(backup_id, encryption_key=None):
"""Stellt ein Backup wieder her"""
conn = get_connection()
cur = conn.cursor()
try:
# Backup-Info abrufen
cur.execute("""
SELECT filename, filepath, is_encrypted
FROM backup_history
WHERE id = %s
""", (backup_id,))
backup_info = cur.fetchone()
if not backup_info:
raise Exception("Backup nicht gefunden")
filename, filepath, is_encrypted = backup_info
filepath = Path(filepath)
if not filepath.exists():
raise Exception("Backup-Datei nicht gefunden")
# Datei lesen
encrypted_data = filepath.read_bytes()
# Entschlüsseln
if is_encrypted:
key = encryption_key.encode() if encryption_key else get_or_create_encryption_key()
try:
f = Fernet(key)
compressed_data = f.decrypt(encrypted_data)
except:
raise Exception("Entschlüsselung fehlgeschlagen. Falsches Passwort?")
else:
compressed_data = encrypted_data
# Dekomprimieren
dump_data = gzip.decompress(compressed_data)
sql_commands = dump_data.decode('utf-8')
# Bestehende Verbindungen schließen
cur.close()
conn.close()
# Datenbank wiederherstellen
restore_command = [
'psql',
'-h', os.getenv("POSTGRES_HOST", "postgres"),
'-p', os.getenv("POSTGRES_PORT", "5432"),
'-U', os.getenv("POSTGRES_USER"),
'-d', os.getenv("POSTGRES_DB"),
'--no-password'
]
env = os.environ.copy()
env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD")
result = subprocess.run(restore_command, input=sql_commands,
capture_output=True, text=True, env=env)
if result.returncode != 0:
raise Exception(f"Wiederherstellung fehlgeschlagen: {result.stderr}")
# Audit-Log (neue Verbindung)
log_audit('RESTORE', 'database', backup_id,
additional_info=f"Backup wiederhergestellt: {filename}")
return True, "Backup erfolgreich wiederhergestellt"
except Exception as e:
logging.error(f"Wiederherstellung fehlgeschlagen: {e}")
return False, str(e)
def send_backup_notification(success, filename, filesize=None, duration=None, error=None):
"""Sendet E-Mail-Benachrichtigung (wenn konfiguriert)"""
if not os.getenv("EMAIL_ENABLED", "false").lower() == "true":
return
# E-Mail-Funktion vorbereitet aber deaktiviert
# TODO: Implementieren wenn E-Mail-Server konfiguriert ist
logging.info(f"E-Mail-Benachrichtigung vorbereitet: Backup {'erfolgreich' if success else 'fehlgeschlagen'}")
# Scheduled Backup Job
def scheduled_backup():
"""Führt ein geplantes Backup aus"""
logging.info("Starte geplantes Backup...")
create_backup(backup_type="scheduled", created_by="scheduler")
# Scheduler konfigurieren - täglich um 3:00 Uhr
scheduler.add_job(
scheduled_backup,
'cron',
hour=3,
minute=0,
id='daily_backup',
replace_existing=True
)
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
@@ -182,6 +432,15 @@ def dashboard():
""")
expiring_licenses = cur.fetchall()
# Letztes Backup
cur.execute("""
SELECT created_at, filesize, duration_seconds, backup_type, status
FROM backup_history
ORDER BY created_at DESC
LIMIT 1
""")
last_backup_info = cur.fetchone()
cur.close()
conn.close()
@@ -195,7 +454,8 @@ def dashboard():
'test_licenses': license_types.get('test', 0),
'recent_licenses': recent_licenses,
'expiring_licenses': expiring_licenses,
'active_sessions': active_sessions_count
'active_sessions': active_sessions_count,
'last_backup': last_backup_info
}
return render_template("dashboard.html", stats=stats, username=session.get('username'))
@@ -911,5 +1171,109 @@ def audit_log():
total=total,
username=session.get('username'))
@app.route("/backups")
@login_required
def backups():
"""Zeigt die Backup-Historie an"""
conn = get_connection()
cur = conn.cursor()
# Letztes erfolgreiches Backup für Dashboard
cur.execute("""
SELECT created_at, filesize, duration_seconds
FROM backup_history
WHERE status = 'success'
ORDER BY created_at DESC
LIMIT 1
""")
last_backup = cur.fetchone()
# Alle Backups abrufen
cur.execute("""
SELECT id, filename, filesize, backup_type, status, error_message,
created_at, created_by, tables_count, records_count,
duration_seconds, is_encrypted
FROM backup_history
ORDER BY created_at DESC
""")
backups = cur.fetchall()
cur.close()
conn.close()
return render_template("backups.html",
backups=backups,
last_backup=last_backup,
username=session.get('username'))
@app.route("/backup/create", methods=["POST"])
@login_required
def create_backup_route():
"""Erstellt ein manuelles Backup"""
username = session.get('username')
success, result = create_backup(backup_type="manual", created_by=username)
if success:
return jsonify({
'success': True,
'message': f'Backup erfolgreich erstellt: {result}'
})
else:
return jsonify({
'success': False,
'message': f'Backup fehlgeschlagen: {result}'
}), 500
@app.route("/backup/restore/<int:backup_id>", methods=["POST"])
@login_required
def restore_backup_route(backup_id):
"""Stellt ein Backup wieder her"""
encryption_key = request.form.get('encryption_key')
success, message = restore_backup(backup_id, encryption_key)
if success:
return jsonify({
'success': True,
'message': message
})
else:
return jsonify({
'success': False,
'message': message
}), 500
@app.route("/backup/download/<int:backup_id>")
@login_required
def download_backup(backup_id):
"""Lädt eine Backup-Datei herunter"""
conn = get_connection()
cur = conn.cursor()
cur.execute("""
SELECT filename, filepath
FROM backup_history
WHERE id = %s
""", (backup_id,))
backup_info = cur.fetchone()
cur.close()
conn.close()
if not backup_info:
return "Backup nicht gefunden", 404
filename, filepath = backup_info
filepath = Path(filepath)
if not filepath.exists():
return "Backup-Datei nicht gefunden", 404
# Audit-Log
log_audit('DOWNLOAD', 'backup', backup_id,
additional_info=f"Backup heruntergeladen: {filename}")
return send_file(filepath, as_attachment=True, download_name=filename)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=443, ssl_context='adhoc')

Datei anzeigen

@@ -49,3 +49,24 @@ CREATE TABLE IF NOT EXISTS audit_log (
CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp DESC);
CREATE INDEX idx_audit_log_username ON audit_log(username);
CREATE INDEX idx_audit_log_entity ON audit_log(entity_type, entity_id);
-- Backup-Historie-Tabelle
CREATE TABLE IF NOT EXISTS backup_history (
id SERIAL PRIMARY KEY,
filename TEXT NOT NULL,
filepath TEXT NOT NULL,
filesize BIGINT,
backup_type TEXT NOT NULL, -- 'manual' oder 'scheduled'
status TEXT NOT NULL, -- 'success', 'failed', 'in_progress'
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by TEXT NOT NULL,
tables_count INTEGER,
records_count INTEGER,
duration_seconds NUMERIC,
is_encrypted BOOLEAN DEFAULT TRUE
);
-- Index für bessere Performance
CREATE INDEX idx_backup_history_created_at ON backup_history(created_at DESC);
CREATE INDEX idx_backup_history_status ON backup_history(status);

Datei anzeigen

@@ -5,3 +5,5 @@ python-dotenv
pyopenssl
pandas
openpyxl
cryptography
apscheduler

Datei anzeigen

@@ -47,6 +47,7 @@
<a href="/licenses" class="btn btn-secondary">📋 Lizenzen</a>
<a href="/customers" class="btn btn-secondary">👥 Kunden</a>
<a href="/sessions" class="btn btn-secondary">🟢 Sessions</a>
<a href="/backups" class="btn btn-secondary">💾 Backups</a>
</div>
</div>

Datei anzeigen

@@ -0,0 +1,286 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Backup-Verwaltung - Admin Panel</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
.status-success { color: #28a745; }
.status-failed { color: #dc3545; }
.status-in_progress { color: #ffc107; }
.backup-actions { white-space: nowrap; }
</style>
</head>
<body class="bg-light">
<nav class="navbar navbar-dark bg-dark">
<div class="container">
<span class="navbar-brand">🎛️ Lizenzverwaltung</span>
<div class="d-flex align-items-center">
<span class="text-white me-3">Angemeldet als: {{ username }}</span>
<a href="/logout" class="btn btn-outline-light btn-sm">Abmelden</a>
</div>
</div>
</nav>
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>💾 Backup-Verwaltung</h2>
<div>
<a href="/" class="btn btn-secondary">📊 Dashboard</a>
<a href="/licenses" class="btn btn-secondary">📋 Lizenzen</a>
<a href="/customers" class="btn btn-secondary">👥 Kunden</a>
<a href="/sessions" class="btn btn-secondary">🟢 Sessions</a>
<a href="/audit" class="btn btn-secondary">📋 Audit</a>
</div>
</div>
<!-- Backup-Info -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">📅 Letztes erfolgreiches Backup</h5>
{% if last_backup %}
<p class="mb-1"><strong>Zeitpunkt:</strong> {{ last_backup[0].strftime('%d.%m.%Y %H:%M:%S') }}</p>
<p class="mb-1"><strong>Größe:</strong> {{ (last_backup[1] / 1024 / 1024)|round(2) }} MB</p>
<p class="mb-0"><strong>Dauer:</strong> {{ last_backup[2]|round(1) }} Sekunden</p>
{% else %}
<p class="text-muted mb-0">Noch kein Backup vorhanden</p>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">🔧 Backup-Aktionen</h5>
<button id="createBackupBtn" class="btn btn-primary" onclick="createBackup()">
💾 Backup jetzt erstellen
</button>
<p class="text-muted mt-2 mb-0">
<small>Automatische Backups: Täglich um 03:00 Uhr</small>
</p>
</div>
</div>
</div>
</div>
<!-- Backup-Historie -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">📋 Backup-Historie</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Zeitstempel</th>
<th>Dateiname</th>
<th>Größe</th>
<th>Typ</th>
<th>Status</th>
<th>Erstellt von</th>
<th>Details</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for backup in backups %}
<tr>
<td>{{ backup[6].strftime('%d.%m.%Y %H:%M:%S') }}</td>
<td>
<small>{{ backup[1] }}</small>
{% if backup[11] %}
<span class="badge bg-info ms-1">🔒 Verschlüsselt</span>
{% endif %}
</td>
<td>
{% if backup[2] %}
{{ (backup[2] / 1024 / 1024)|round(2) }} MB
{% else %}
-
{% endif %}
</td>
<td>
{% if backup[3] == 'manual' %}
<span class="badge bg-primary">Manuell</span>
{% else %}
<span class="badge bg-secondary">Automatisch</span>
{% endif %}
</td>
<td>
{% if backup[4] == 'success' %}
<span class="status-success">✅ Erfolgreich</span>
{% elif backup[4] == 'failed' %}
<span class="status-failed" title="{{ backup[5] }}">❌ Fehlgeschlagen</span>
{% else %}
<span class="status-in_progress">⏳ In Bearbeitung</span>
{% endif %}
</td>
<td>{{ backup[7] }}</td>
<td>
{% if backup[8] and backup[9] %}
<small>
{{ backup[8] }} Tabellen<br>
{{ backup[9] }} Datensätze<br>
{% if backup[10] %}
{{ backup[10]|round(1) }}s
{% endif %}
</small>
{% else %}
-
{% endif %}
</td>
<td class="backup-actions">
{% if backup[4] == 'success' %}
<div class="btn-group btn-group-sm" role="group">
<a href="/backup/download/{{ backup[0] }}"
class="btn btn-outline-primary"
title="Backup herunterladen">
📥 Download
</a>
<button class="btn btn-outline-success"
onclick="restoreBackup({{ backup[0] }}, '{{ backup[1] }}')"
title="Backup wiederherstellen">
🔄 Wiederherstellen
</button>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not backups %}
<div class="text-center py-5">
<p class="text-muted">Noch keine Backups vorhanden.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Wiederherstellungs-Modal -->
<div class="modal fade" id="restoreModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">🔄 Backup wiederherstellen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<strong>⚠️ Warnung:</strong> Bei der Wiederherstellung werden alle aktuellen Daten überschrieben!
</div>
<p>Backup: <strong id="restoreFilename"></strong></p>
<form id="restoreForm">
<input type="hidden" id="restoreBackupId">
<div class="mb-3">
<label for="encryptionKey" class="form-label">Verschlüsselungs-Passwort (optional)</label>
<input type="password" class="form-control" id="encryptionKey"
placeholder="Leer lassen für Standard-Passwort">
<small class="text-muted">
Falls das Backup mit einem anderen Passwort verschlüsselt wurde
</small>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-danger" onclick="confirmRestore()">
⚠️ Wiederherstellen
</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script>
function createBackup() {
const btn = document.getElementById('createBackupBtn');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '⏳ Backup wird erstellt...';
fetch('/backup/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('✅ ' + data.message);
location.reload();
} else {
alert('❌ ' + data.message);
}
})
.catch(error => {
alert('❌ Fehler beim Erstellen des Backups: ' + error);
})
.finally(() => {
btn.disabled = false;
btn.innerHTML = originalText;
});
}
function restoreBackup(backupId, filename) {
document.getElementById('restoreBackupId').value = backupId;
document.getElementById('restoreFilename').textContent = filename;
document.getElementById('encryptionKey').value = '';
const modal = new bootstrap.Modal(document.getElementById('restoreModal'));
modal.show();
}
function confirmRestore() {
if (!confirm('Wirklich wiederherstellen? Alle aktuellen Daten werden überschrieben!')) {
return;
}
const backupId = document.getElementById('restoreBackupId').value;
const encryptionKey = document.getElementById('encryptionKey').value;
const formData = new FormData();
if (encryptionKey) {
formData.append('encryption_key', encryptionKey);
}
// Modal schließen
bootstrap.Modal.getInstance(document.getElementById('restoreModal')).hide();
// Loading anzeigen
const loadingDiv = document.createElement('div');
loadingDiv.className = 'position-fixed top-50 start-50 translate-middle';
loadingDiv.innerHTML = '<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>';
document.body.appendChild(loadingDiv);
fetch(`/backup/restore/${backupId}`, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('✅ ' + data.message + '\n\nDie Seite wird neu geladen...');
window.location.href = '/';
} else {
alert('❌ ' + data.message);
}
})
.catch(error => {
alert('❌ Fehler bei der Wiederherstellung: ' + error);
})
.finally(() => {
document.body.removeChild(loadingDiv);
});
}
</script>
</body>
</html>

Datei anzeigen

@@ -25,6 +25,7 @@
<a href="/licenses" class="btn btn-secondary">📋 Lizenzen</a>
<a href="/sessions" class="btn btn-secondary">🟢 Sessions</a>
<a href="/audit" class="btn btn-secondary">📋 Audit</a>
<a href="/backups" class="btn btn-secondary">💾 Backups</a>
<div class="btn-group">
<button type="button" class="btn btn-info dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
📥 Export

Datei anzeigen

@@ -36,6 +36,7 @@
<a href="/customers" class="btn btn-secondary">👥 Kunden</a>
<a href="/sessions" class="btn btn-secondary">🟢 Sessions</a>
<a href="/audit" class="btn btn-secondary">📋 Audit</a>
<a href="/backups" class="btn btn-secondary">💾 Backups</a>
</div>
</div>
@@ -117,6 +118,42 @@
</div>
</div>
<!-- Backup-Status -->
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<h5 class="card-title">💾 Backup-Status</h5>
{% if stats.last_backup %}
{% if stats.last_backup[4] == 'success' %}
<p class="mb-1">
<strong>Letztes Backup:</strong>
<span class="text-success">✅ Erfolgreich</span>
am {{ stats.last_backup[0].strftime('%d.%m.%Y %H:%M:%S') }}
</p>
<p class="mb-0">
<small class="text-muted">
Größe: {{ (stats.last_backup[1] / 1024 / 1024)|round(2) }} MB |
Dauer: {{ stats.last_backup[2]|round(1) }} Sekunden |
Typ: {{ 'Manuell' if stats.last_backup[3] == 'manual' else 'Automatisch' }}
</small>
</p>
{% else %}
<p class="mb-0">
<strong>Letztes Backup:</strong>
<span class="text-danger">❌ Fehlgeschlagen</span>
am {{ stats.last_backup[0].strftime('%d.%m.%Y %H:%M:%S') }}
</p>
{% endif %}
{% else %}
<p class="text-muted mb-0">Noch kein Backup vorhanden</p>
{% endif %}
<a href="/backups" class="btn btn-sm btn-outline-primary mt-2">Backup-Verwaltung →</a>
</div>
</div>
</div>
</div>
<div class="row g-3">
<!-- Bald ablaufende Lizenzen -->
<div class="col-md-6">

Datei anzeigen

@@ -26,6 +26,7 @@
<a href="/customers" class="btn btn-secondary">👥 Kunden</a>
<a href="/sessions" class="btn btn-secondary">🟢 Sessions</a>
<a href="/audit" class="btn btn-secondary">📋 Audit</a>
<a href="/backups" class="btn btn-secondary">💾 Backups</a>
</div>
</div>

Datei anzeigen

@@ -26,6 +26,7 @@
<a href="/customers" class="btn btn-secondary">👥 Kunden</a>
<a href="/sessions" class="btn btn-secondary">🟢 Sessions</a>
<a href="/audit" class="btn btn-secondary">📋 Audit</a>
<a href="/backups" class="btn btn-secondary">💾 Backups</a>
</div>
</div>

Datei anzeigen

@@ -25,6 +25,7 @@
<a href="/customers" class="btn btn-secondary">👥 Kunden</a>
<a href="/sessions" class="btn btn-secondary">🟢 Sessions</a>
<a href="/audit" class="btn btn-secondary">📋 Audit</a>
<a href="/backups" class="btn btn-secondary">💾 Backups</a>
</div>
</div>

Datei anzeigen

@@ -30,6 +30,7 @@
<a href="/customers" class="btn btn-secondary">👥 Kunden</a>
<a href="/sessions" class="btn btn-secondary">🟢 Sessions</a>
<a href="/audit" class="btn btn-secondary">📋 Audit</a>
<a href="/backups" class="btn btn-secondary">💾 Backups</a>
<div class="btn-group">
<button type="button" class="btn btn-info dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
📥 Export

Datei anzeigen

@@ -29,6 +29,7 @@
<a href="/licenses" class="btn btn-secondary">📋 Lizenzen</a>
<a href="/customers" class="btn btn-secondary">👥 Kunden</a>
<a href="/audit" class="btn btn-secondary">📋 Audit</a>
<a href="/backups" class="btn btn-secondary">💾 Backups</a>
</div>
</div>