Backup-Funktionalität
Dieser Commit ist enthalten in:
@@ -16,7 +16,8 @@
|
|||||||
"Bash(docker network inspect:*)",
|
"Bash(docker network inspect:*)",
|
||||||
"Bash(mkdir:*)",
|
"Bash(mkdir:*)",
|
||||||
"Bash(sudo touch:*)",
|
"Bash(sudo touch:*)",
|
||||||
"Bash(docker volume rm:*)"
|
"Bash(docker volume rm:*)",
|
||||||
|
"Bash(rm:*)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
}
|
}
|
||||||
|
|||||||
33
JOURNAL.md
33
JOURNAL.md
@@ -377,3 +377,36 @@ Die Session-Daten werden erst gefüllt, wenn der License Server API implementier
|
|||||||
- Locale-Installation über apt-get
|
- Locale-Installation über apt-get
|
||||||
- locale-gen für de_DE.UTF-8
|
- locale-gen für de_DE.UTF-8
|
||||||
- Vollständige UTF-8 Unterstützung für deutsche Sonderzeichen
|
- 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
|
||||||
8
v2/.env
8
v2/.env
@@ -39,3 +39,11 @@ ADMIN_PANEL_DOMAIN=admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com
|
|||||||
# Serverseitig gepflegte aktuelle Software-Version
|
# Serverseitig gepflegte aktuelle Software-Version
|
||||||
# Diese wird vom Lizenzserver genutzt, um die Kundenversion zu vergleichen
|
# Diese wird vom Lizenzserver genutzt, um die Kundenversion zu vergleichen
|
||||||
LATEST_CLIENT_VERSION=1.0.0
|
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=
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -7,6 +7,16 @@ ENV PYTHONIOENCODING=utf-8
|
|||||||
|
|
||||||
WORKDIR /app
|
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 .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import psycopg2
|
import psycopg2
|
||||||
from psycopg2.extras import Json
|
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 flask_session import Session
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@@ -9,6 +9,13 @@ import pandas as pd
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import io
|
import io
|
||||||
import json
|
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()
|
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'
|
app.config['JSONIFY_MIMETYPE'] = 'application/json; charset=utf-8'
|
||||||
Session(app)
|
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
|
# Login decorator
|
||||||
def login_required(f):
|
def login_required(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
@@ -72,6 +90,238 @@ def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=N
|
|||||||
cur.close()
|
cur.close()
|
||||||
conn.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"])
|
@app.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@@ -182,6 +432,15 @@ def dashboard():
|
|||||||
""")
|
""")
|
||||||
expiring_licenses = cur.fetchall()
|
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()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -195,7 +454,8 @@ def dashboard():
|
|||||||
'test_licenses': license_types.get('test', 0),
|
'test_licenses': license_types.get('test', 0),
|
||||||
'recent_licenses': recent_licenses,
|
'recent_licenses': recent_licenses,
|
||||||
'expiring_licenses': expiring_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'))
|
return render_template("dashboard.html", stats=stats, username=session.get('username'))
|
||||||
@@ -911,5 +1171,109 @@ def audit_log():
|
|||||||
total=total,
|
total=total,
|
||||||
username=session.get('username'))
|
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__":
|
if __name__ == "__main__":
|
||||||
app.run(host="0.0.0.0", port=443, ssl_context='adhoc')
|
app.run(host="0.0.0.0", port=443, ssl_context='adhoc')
|
||||||
|
|||||||
@@ -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_timestamp ON audit_log(timestamp DESC);
|
||||||
CREATE INDEX idx_audit_log_username ON audit_log(username);
|
CREATE INDEX idx_audit_log_username ON audit_log(username);
|
||||||
CREATE INDEX idx_audit_log_entity ON audit_log(entity_type, entity_id);
|
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);
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ python-dotenv
|
|||||||
pyopenssl
|
pyopenssl
|
||||||
pandas
|
pandas
|
||||||
openpyxl
|
openpyxl
|
||||||
|
cryptography
|
||||||
|
apscheduler
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
<a href="/licenses" class="btn btn-secondary">📋 Lizenzen</a>
|
<a href="/licenses" class="btn btn-secondary">📋 Lizenzen</a>
|
||||||
<a href="/customers" class="btn btn-secondary">👥 Kunden</a>
|
<a href="/customers" class="btn btn-secondary">👥 Kunden</a>
|
||||||
<a href="/sessions" class="btn btn-secondary">🟢 Sessions</a>
|
<a href="/sessions" class="btn btn-secondary">🟢 Sessions</a>
|
||||||
|
<a href="/backups" class="btn btn-secondary">💾 Backups</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
286
v2_adminpanel/templates/backups.html
Normale Datei
286
v2_adminpanel/templates/backups.html
Normale Datei
@@ -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>
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
<a href="/licenses" class="btn btn-secondary">📋 Lizenzen</a>
|
<a href="/licenses" class="btn btn-secondary">📋 Lizenzen</a>
|
||||||
<a href="/sessions" class="btn btn-secondary">🟢 Sessions</a>
|
<a href="/sessions" class="btn btn-secondary">🟢 Sessions</a>
|
||||||
<a href="/audit" class="btn btn-secondary">📋 Audit</a>
|
<a href="/audit" class="btn btn-secondary">📋 Audit</a>
|
||||||
|
<a href="/backups" class="btn btn-secondary">💾 Backups</a>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button type="button" class="btn btn-info dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
<button type="button" class="btn btn-info dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
📥 Export
|
📥 Export
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
<a href="/customers" class="btn btn-secondary">👥 Kunden</a>
|
<a href="/customers" class="btn btn-secondary">👥 Kunden</a>
|
||||||
<a href="/sessions" class="btn btn-secondary">🟢 Sessions</a>
|
<a href="/sessions" class="btn btn-secondary">🟢 Sessions</a>
|
||||||
<a href="/audit" class="btn btn-secondary">📋 Audit</a>
|
<a href="/audit" class="btn btn-secondary">📋 Audit</a>
|
||||||
|
<a href="/backups" class="btn btn-secondary">💾 Backups</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -117,6 +118,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="row g-3">
|
||||||
<!-- Bald ablaufende Lizenzen -->
|
<!-- Bald ablaufende Lizenzen -->
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
<a href="/customers" class="btn btn-secondary">👥 Kunden</a>
|
<a href="/customers" class="btn btn-secondary">👥 Kunden</a>
|
||||||
<a href="/sessions" class="btn btn-secondary">🟢 Sessions</a>
|
<a href="/sessions" class="btn btn-secondary">🟢 Sessions</a>
|
||||||
<a href="/audit" class="btn btn-secondary">📋 Audit</a>
|
<a href="/audit" class="btn btn-secondary">📋 Audit</a>
|
||||||
|
<a href="/backups" class="btn btn-secondary">💾 Backups</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
<a href="/customers" class="btn btn-secondary">👥 Kunden</a>
|
<a href="/customers" class="btn btn-secondary">👥 Kunden</a>
|
||||||
<a href="/sessions" class="btn btn-secondary">🟢 Sessions</a>
|
<a href="/sessions" class="btn btn-secondary">🟢 Sessions</a>
|
||||||
<a href="/audit" class="btn btn-secondary">📋 Audit</a>
|
<a href="/audit" class="btn btn-secondary">📋 Audit</a>
|
||||||
|
<a href="/backups" class="btn btn-secondary">💾 Backups</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<a href="/customers" class="btn btn-secondary">👥 Kunden</a>
|
<a href="/customers" class="btn btn-secondary">👥 Kunden</a>
|
||||||
<a href="/sessions" class="btn btn-secondary">🟢 Sessions</a>
|
<a href="/sessions" class="btn btn-secondary">🟢 Sessions</a>
|
||||||
<a href="/audit" class="btn btn-secondary">📋 Audit</a>
|
<a href="/audit" class="btn btn-secondary">📋 Audit</a>
|
||||||
|
<a href="/backups" class="btn btn-secondary">💾 Backups</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<a href="/customers" class="btn btn-secondary">👥 Kunden</a>
|
<a href="/customers" class="btn btn-secondary">👥 Kunden</a>
|
||||||
<a href="/sessions" class="btn btn-secondary">🟢 Sessions</a>
|
<a href="/sessions" class="btn btn-secondary">🟢 Sessions</a>
|
||||||
<a href="/audit" class="btn btn-secondary">📋 Audit</a>
|
<a href="/audit" class="btn btn-secondary">📋 Audit</a>
|
||||||
|
<a href="/backups" class="btn btn-secondary">💾 Backups</a>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button type="button" class="btn btn-info dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
<button type="button" class="btn btn-info dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
📥 Export
|
📥 Export
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
<a href="/licenses" class="btn btn-secondary">📋 Lizenzen</a>
|
<a href="/licenses" class="btn btn-secondary">📋 Lizenzen</a>
|
||||||
<a href="/customers" class="btn btn-secondary">👥 Kunden</a>
|
<a href="/customers" class="btn btn-secondary">👥 Kunden</a>
|
||||||
<a href="/audit" class="btn btn-secondary">📋 Audit</a>
|
<a href="/audit" class="btn btn-secondary">📋 Audit</a>
|
||||||
|
<a href="/backups" class="btn btn-secondary">💾 Backups</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren