diff --git a/README.md b/README.md index dbcf730..bc1fd27 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Sie läuft als Hintergrunddienst auf einem Linux-Server und ist über einen Brow - **Technologie**: Python 3 / Flask (Webanwendung) - **Oberfläche**: Web-UI im Browser (kein Programmieren nötig) - **Datenbank**: SQLite (wird automatisch angelegt unter `data/config.db`) +- **Logging**: SQLite-Logdatenbank unter `data/log.db` mit GUI-Ansicht (Menü: Logs) - **Dienst**: systemd-User-Service (läuft automatisch im Hintergrund) - **Standard-Adresse**: `http://127.0.0.1:5001/` - **Einstiegspunkt**: `main.py` @@ -167,6 +168,7 @@ app/ templates/ # HTML-Vorlagen für die Weboberfläche data/ config.db # SQLite-Datenbank (wird beim ersten Start angelegt) + log.db # SQLite-Logdatenbank für Laufzeit-, Fehler- und Audit-Logs deploy/ systemd/ BackItUp.service # Vorlage; --setup schreibt die echten Pfade diff --git a/app/app_runner.py b/app/app_runner.py index 4c8cad5..c496fca 100644 --- a/app/app_runner.py +++ b/app/app_runner.py @@ -86,11 +86,14 @@ WantedBy=default.target self._bootstrap() from app.config_store import ConfigStore + from app.log_store import LogStore from app.setup_wizard import SetupWizard from app.web_server import FlaskServer store = ConfigStore(self.paths.db_path) store.ensure_schema() + log_store = LogStore(self.paths.log_db_path) + log_store.ensure_schema() config = store.load_config() if config is None: @@ -114,4 +117,4 @@ WantedBy=default.target ConsoleUI.info( f"Flask startet auf http://{config.ip}:{config.port} (debug={config.debug})" ) - FlaskServer(config=config, store=store).run() + FlaskServer(config=config, store=store, log_store=log_store).run() diff --git a/app/log_store.py b/app/log_store.py new file mode 100644 index 0000000..73836dd --- /dev/null +++ b/app/log_store.py @@ -0,0 +1,237 @@ +import json +import sqlite3 +import threading +from datetime import datetime +from pathlib import Path +from typing import Any + + +_ALLOWED_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} + + +class LogStore: + def __init__(self, db_path: Path, max_rows: int = 50000) -> None: + self.db_path = db_path + self.max_rows = max(1000, int(max_rows)) + self._lock = threading.Lock() + self._writes_since_prune = 0 + + def _connect(self) -> sqlite3.Connection: + self.db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + return conn + + def ensure_schema(self) -> None: + with self._connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'now', 'localtime')), + level TEXT NOT NULL, + category TEXT NOT NULL, + event TEXT NOT NULL, + message TEXT NOT NULL, + username TEXT, + backup_id INTEGER, + request_path TEXT, + request_method TEXT, + status_code INTEGER, + details_json TEXT + ) + """ + ) + conn.execute("CREATE INDEX IF NOT EXISTS idx_logs_created_at ON logs(created_at DESC)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_logs_level ON logs(level)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_logs_category ON logs(category)") + conn.commit() + + def write( + self, + *, + level: str, + category: str, + event: str, + message: str, + username: str | None = None, + backup_id: int | None = None, + request_path: str | None = None, + request_method: str | None = None, + status_code: int | None = None, + details: dict[str, Any] | None = None, + ) -> None: + lvl = (level or "INFO").upper() + if lvl not in _ALLOWED_LEVELS: + lvl = "INFO" + + safe_category = (category or "app").strip()[:50] or "app" + safe_event = (event or "event").strip()[:80] or "event" + safe_message = (message or "").strip()[:3000] or "-" + safe_username = (username or "").strip()[:120] or None + safe_path = (request_path or "").strip()[:500] or None + safe_method = (request_method or "").strip().upper()[:16] or None + details_json = json.dumps(details or {}, ensure_ascii=False) + + with self._lock: + with self._connect() as conn: + conn.execute( + """ + INSERT INTO logs ( + level, + category, + event, + message, + username, + backup_id, + request_path, + request_method, + status_code, + details_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + lvl, + safe_category, + safe_event, + safe_message, + safe_username, + backup_id, + safe_path, + safe_method, + status_code, + details_json, + ), + ) + conn.commit() + + self._writes_since_prune += 1 + if self._writes_since_prune >= 100: + self._prune_if_needed() + self._writes_since_prune = 0 + + def _prune_if_needed(self) -> None: + with self._connect() as conn: + row = conn.execute("SELECT COUNT(*) AS cnt FROM logs").fetchone() + count = int(row["cnt"] if row is not None else 0) + if count <= self.max_rows: + return + + to_delete = count - self.max_rows + conn.execute( + """ + DELETE FROM logs + WHERE id IN ( + SELECT id FROM logs + ORDER BY created_at ASC, id ASC + LIMIT ? + ) + """, + (to_delete,), + ) + conn.commit() + + def query_logs( + self, + *, + level: str | None = None, + category: str | None = None, + search: str | None = None, + limit: int = 200, + ) -> list[dict[str, Any]]: + safe_limit = min(max(int(limit), 1), 1000) + + query = ( + "SELECT id, created_at, level, category, event, message, username, " + "backup_id, request_path, request_method, status_code, details_json " + "FROM logs WHERE 1=1" + ) + params: list[Any] = [] + + if level: + query += " AND level = ?" + params.append(level.upper()) + if category: + query += " AND category = ?" + params.append(category) + if search: + query += " AND (message LIKE ? OR event LIKE ? OR username LIKE ? OR details_json LIKE ?)" + like = f"%{search.strip()}%" + params.extend([like, like, like, like]) + + query += " ORDER BY created_at DESC, id DESC LIMIT ?" + params.append(safe_limit) + + with self._connect() as conn: + rows = conn.execute(query, params).fetchall() + + result: list[dict[str, Any]] = [] + for row in rows: + details_raw = row["details_json"] or "{}" + try: + details = json.loads(details_raw) + except json.JSONDecodeError: + details = {"raw": details_raw} + result.append( + { + "id": row["id"], + "created_at": row["created_at"], + "level": row["level"], + "category": row["category"], + "event": row["event"], + "message": row["message"], + "username": row["username"], + "backup_id": row["backup_id"], + "request_path": row["request_path"], + "request_method": row["request_method"], + "status_code": row["status_code"], + "details": details, + } + ) + return result + + def list_categories(self) -> list[str]: + with self._connect() as conn: + rows = conn.execute("SELECT DISTINCT category FROM logs ORDER BY category ASC").fetchall() + return [str(row["category"]) for row in rows if row["category"]] + + def count_by_level_last_hours(self, hours: int = 24) -> dict[str, int]: + safe_hours = min(max(int(hours), 1), 24 * 30) + with self._connect() as conn: + rows = conn.execute( + """ + SELECT level, COUNT(*) AS cnt + FROM logs + WHERE created_at >= DATETIME('now', 'localtime', ?) + GROUP BY level + """, + (f"-{safe_hours} hours",), + ).fetchall() + + result = {"DEBUG": 0, "INFO": 0, "WARNING": 0, "ERROR": 0, "CRITICAL": 0} + for row in rows: + level = str(row["level"]) + if level in result: + result[level] = int(row["cnt"]) + return result + + def clear(self) -> int: + with self._connect() as conn: + row = conn.execute("SELECT COUNT(*) AS cnt FROM logs").fetchone() + count = int(row["cnt"] if row is not None else 0) + conn.execute("DELETE FROM logs") + conn.commit() + return count + + @staticmethod + def compact_details(details: dict[str, Any], max_len: int = 800) -> str: + if not details: + return "" + raw = json.dumps(details, ensure_ascii=False, separators=(",", ":")) + if len(raw) <= max_len: + return raw + return f"{raw[:max_len]}..." + + @staticmethod + def now_iso() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") diff --git a/app/paths.py b/app/paths.py index 792959e..138acea 100644 --- a/app/paths.py +++ b/app/paths.py @@ -18,6 +18,10 @@ class AppPaths: def db_path(self) -> Path: return self.data_dir / "config.db" + @property + def log_db_path(self) -> Path: + return self.data_dir / "log.db" + @property def requirements_path(self) -> Path: return self.base_dir / "requirements.txt" diff --git a/app/templates/base_admin.html b/app/templates/base_admin.html index 4db2182..9e22958 100644 --- a/app/templates/base_admin.html +++ b/app/templates/base_admin.html @@ -247,6 +247,7 @@ Server Settings Admin Konto Benutzer + Logs
diff --git a/app/templates/logs.html b/app/templates/logs.html new file mode 100644 index 0000000..1f49c30 --- /dev/null +++ b/app/templates/logs.html @@ -0,0 +1,105 @@ +{% extends "base_admin.html" %} +{% block title %}BackItUp - Logs{% endblock %} + +{% block content %} +
+

System Logs

+

Alle wichtigen Laufzeit-, Fehler- und Audit-Ereignisse aus der Datei data/log.db.

+
+ +
+
+

24h Statistik

+

DEBUG: {{ level_stats.DEBUG }}

+

INFO: {{ level_stats.INFO }}

+

WARNING: {{ level_stats.WARNING }}

+

ERROR: {{ level_stats.ERROR }}

+

CRITICAL: {{ level_stats.CRITICAL }}

+
+
+

Logs filtern

+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + + +
+
+ +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + {% for item in logs %} + + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
ZeitLevelKategorieEventBenutzerBackupNachrichtRequestStatusDetails
{{ item.created_at }}{{ item.level }}{{ item.category }}{{ item.event }}{{ item.username or '-' }}{{ item.backup_id or '-' }}{{ item.message }}{{ item.request_method or '' }} {{ item.request_path or '' }}{{ item.status_code or '-' }}{{ detail_compactor(item.details, 220) }}
Keine Logs gefunden.
+
+
+{% endblock %} diff --git a/app/web_server.py b/app/web_server.py index 53e88f1..34565b5 100644 --- a/app/web_server.py +++ b/app/web_server.py @@ -5,12 +5,13 @@ import shutil import tarfile import tempfile import threading +import traceback from datetime import datetime from functools import wraps from pathlib import Path import zipfile -from flask import Flask, flash, jsonify, redirect, render_template, request, session, url_for +from flask import Flask, flash, g, got_request_exception, jsonify, redirect, render_template, request, session, url_for import smbclient try: @@ -20,6 +21,7 @@ except Exception: # noqa: BLE001 from app.config_model import AppConfig, BackupExecutionJob, BackupJob from app.config_store import ConfigStore +from app.log_store import LogStore from app.remote_targets import ( TargetConnection, browse_ftp_directories, @@ -48,12 +50,52 @@ BACKUP_MODES = ["full", "sync", "clone"] class FlaskServer: - def __init__(self, config: AppConfig, store: ConfigStore) -> None: + def __init__(self, config: AppConfig, store: ConfigStore, log_store: LogStore) -> None: self.config = config self.store = store + self.log_store = log_store self._run_lock = threading.Lock() self._active_runs: dict[int, dict] = {} + def _log( + self, + *, + level: str, + category: str, + event: str, + message: str, + username: str | None = None, + backup_id: int | None = None, + details: dict | None = None, + status_code: int | None = None, + ) -> None: + try: + request_path = request.path if request else None + request_method = request.method if request else None + except RuntimeError: + request_path = None + request_method = None + + resolved_user = username + if not resolved_user: + try: + resolved_user = session.get("adminuser") + except RuntimeError: + resolved_user = None + + self.log_store.write( + level=level, + category=category, + event=event, + message=message, + username=resolved_user, + backup_id=backup_id, + request_path=request_path, + request_method=request_method, + status_code=status_code, + details=details, + ) + def _get_run_status(self, backup_id: int) -> dict: with self._run_lock: state = self._active_runs.get(backup_id) @@ -90,10 +132,24 @@ class FlaskServer: with self._run_lock: existing = self._active_runs.get(backup_id) if existing and bool(existing.get("is_running", False)): + self._log( + level="WARNING", + category="backup", + event="run_start_rejected", + backup_id=backup_id, + message="Backup-Lauf konnte nicht gestartet werden, da bereits ein Lauf aktiv ist.", + ) return False, "Backup-Lauf ist bereits aktiv." job = self.store.get_backup_execution_job(backup_id) if job is None: + self._log( + level="ERROR", + category="backup", + event="run_start_failed", + backup_id=backup_id, + message="Backup-Lauf konnte nicht gestartet werden, da das Backup nicht gefunden wurde.", + ) return False, "Backup nicht gefunden." stop_event = threading.Event() @@ -114,16 +170,37 @@ class FlaskServer: thread=thread, ) thread.start() + self._log( + level="INFO", + category="backup", + event="run_started", + backup_id=backup_id, + message=f"Manueller Backup-Lauf wurde gestartet: {job.name}", + ) return True, f"Manueller Lauf für '{job.name}' wurde gestartet." def _stop_backup_run(self, backup_id: int) -> tuple[bool, str]: with self._run_lock: state = self._active_runs.get(backup_id) if not state or not bool(state.get("is_running", False)): + self._log( + level="WARNING", + category="backup", + event="run_stop_rejected", + backup_id=backup_id, + message="Backup-Abbruch angefordert, aber kein Lauf war aktiv.", + ) return False, "Kein aktiver Lauf zum Abbrechen." stop_event = state.get("stop_event") if stop_event is None or not hasattr(stop_event, "set"): + self._log( + level="ERROR", + category="backup", + event="run_stop_failed", + backup_id=backup_id, + message="Backup-Abbruch konnte nicht angefordert werden (fehlendes stop_event).", + ) return False, "Abbruch konnte nicht angefordert werden." stop_event.set() @@ -131,6 +208,13 @@ class FlaskServer: state["status_message"] = "Abbruch wurde angefordert..." self._active_runs[backup_id] = state + self._log( + level="INFO", + category="backup", + event="run_stop_requested", + backup_id=backup_id, + message="Abbruch eines laufenden Backups wurde angefordert.", + ) return True, "Abbruch wurde angefordert." @staticmethod @@ -1142,6 +1226,13 @@ class FlaskServer: ) ok, msg = test_connection(source) if not ok: + self._log( + level="ERROR", + category="backup", + event="source_check_failed", + backup_id=backup_id, + message=f"Quelle nicht erreichbar: {msg}", + ) self._set_run_state( backup_id, is_running=False, @@ -1169,6 +1260,13 @@ class FlaskServer: ) ok, msg = test_connection(destination) if not ok: + self._log( + level="ERROR", + category="backup", + event="destination_check_failed", + backup_id=backup_id, + message=f"Ziel nicht erreichbar: {msg}", + ) self._set_run_state( backup_id, is_running=False, @@ -1189,6 +1287,13 @@ class FlaskServer: copied_files, copied_bytes = self._transfer_backup_payload(job, stop_event) if stop_event.is_set(): + self._log( + level="INFO", + category="backup", + event="run_stopped", + backup_id=backup_id, + message="Backup-Lauf wurde manuell gestoppt.", + ) self._set_run_state( backup_id, is_running=False, @@ -1200,6 +1305,16 @@ class FlaskServer: self.store.mark_backup_run(backup_id, copied_bytes) self._apply_rotation(job) + self._log( + level="INFO", + category="backup", + event="run_finished", + backup_id=backup_id, + message=( + f"Backup erfolgreich abgeschlossen: {copied_files} Dateien, {copied_bytes} Bytes." + ), + details={"copied_files": copied_files, "copied_bytes": copied_bytes}, + ) self._set_run_state( backup_id, is_running=False, @@ -1218,6 +1333,14 @@ class FlaskServer: progress_text="Fehler", status_message=f"Lauf fehlgeschlagen: {exc}", ) + self._log( + level="ERROR", + category="backup", + event="run_failed", + backup_id=backup_id, + message=f"Lauf fehlgeschlagen: {exc}", + details={"traceback": traceback.format_exc()}, + ) @staticmethod def _parse_port(value: str) -> int | None: @@ -1345,6 +1468,40 @@ class FlaskServer: SESSION_COOKIE_SAMESITE="Lax", ) + @app.before_request + def _capture_request_start() -> None: + g._request_started_at = datetime.now() + + @app.after_request + def _log_problematic_requests(response): + status = int(response.status_code) + if status >= 400: + elapsed_ms = None + started = getattr(g, "_request_started_at", None) + if isinstance(started, datetime): + elapsed_ms = int((datetime.now() - started).total_seconds() * 1000) + self._log( + level="WARNING" if status < 500 else "ERROR", + category="http", + event="request_completed", + message=f"HTTP {status} für {request.method} {request.path}", + details={"elapsed_ms": elapsed_ms}, + status_code=status, + ) + return response + + def _handle_exception(sender, exception, **extra): + self._log( + level="ERROR", + category="http", + event="unhandled_exception", + message=f"Unhandled Exception: {exception}", + details={"traceback": traceback.format_exc()}, + status_code=500, + ) + + got_request_exception.connect(_handle_exception, app) + @app.template_filter("fmt_bytes") def fmt_bytes(value): if value is None: @@ -1407,7 +1564,21 @@ class FlaskServer: session["is_authenticated"] = True session["is_admin"] = user.is_admin session["adminuser"] = user.username + self._log( + level="INFO", + category="auth", + event="login_success", + message="Benutzer hat sich erfolgreich angemeldet.", + username=user.username, + ) return redirect(url_for("dashboard")) + self._log( + level="WARNING", + category="auth", + event="login_failed", + message="Anmeldung fehlgeschlagen.", + username=username.strip() or None, + ) error_message = "Anmeldung fehlgeschlagen." return render_template("login.html", error_message=error_message) @@ -1422,6 +1593,75 @@ class FlaskServer: active_menu="dashboard", ) + @app.get("/logs") + @login_required + @admin_required + def logs_view(): + level = request.args.get("level", "").strip().upper() + category = request.args.get("category", "").strip() + search = request.args.get("q", "").strip() + try: + limit = int(request.args.get("limit", "200") or "200") + except ValueError: + limit = 200 + + entries = self.log_store.query_logs( + level=level or None, + category=category or None, + search=search or None, + limit=limit, + ) + level_stats = self.log_store.count_by_level_last_hours(24) + categories = self.log_store.list_categories() + return render_template( + "logs.html", + adminuser=session.get("adminuser", "admin"), + logs=entries, + level_stats=level_stats, + categories=categories, + filters={ + "level": level, + "category": category, + "q": search, + "limit": str(limit), + }, + active_menu="logs", + detail_compactor=self.log_store.compact_details, + ) + + @app.get("/api/logs") + @login_required + @admin_required + def logs_api(): + level = request.args.get("level", "").strip().upper() + category = request.args.get("category", "").strip() + search = request.args.get("q", "").strip() + try: + limit = int(request.args.get("limit", "200") or "200") + except ValueError: + limit = 200 + entries = self.log_store.query_logs( + level=level or None, + category=category or None, + search=search or None, + limit=limit, + ) + return jsonify({"ok": True, "entries": entries, "count": len(entries)}) + + @app.post("/logs/clear") + @login_required + @admin_required + def logs_clear(): + removed = self.log_store.clear() + self._log( + level="WARNING", + category="audit", + event="logs_cleared", + message=f"Logdaten wurden bereinigt. Geloeschte Eintraege: {removed}", + ) + flash(f"Logdaten wurden bereinigt ({removed} Einträge).", "success") + return redirect(url_for("logs_view")) + @app.post("/backups//run") @login_required @admin_required @@ -1480,6 +1720,13 @@ class FlaskServer: def backup_edit(backup_id: int): backup = self.store.get_full_backup_for_edit(backup_id) if backup is None: + self._log( + level="WARNING", + category="backup", + event="edit_not_found", + backup_id=backup_id, + message="Backup konnte nicht bearbeitet werden, da es nicht gefunden wurde.", + ) flash("Backup nicht gefunden.", "error") return redirect(url_for("dashboard")) @@ -1580,9 +1827,24 @@ class FlaskServer: ) if not updated: + self._log( + level="ERROR", + category="backup", + event="edit_failed", + backup_id=backup_id, + message="Backup-Update konnte nicht gespeichert werden.", + ) flash("Backup konnte nicht gespeichert werden.", "error") return redirect(url_for("backup_edit", backup_id=backup_id)) + self._log( + level="INFO", + category="backup", + event="edit_saved", + backup_id=backup_id, + message=f"Backup wurde aktualisiert: {name}", + details={"backup_mode": backup_mode, "compression_method": compression_method}, + ) flash("Backup wurde aktualisiert.", "success") return redirect(url_for("dashboard")) @@ -1603,8 +1865,22 @@ class FlaskServer: def backup_delete(backup_id: int): deleted = self.store.delete_backup(backup_id) if not deleted: + self._log( + level="WARNING", + category="backup", + event="delete_not_found", + backup_id=backup_id, + message="Backup-Loeschung angefordert, aber Backup nicht gefunden.", + ) flash("Backup nicht gefunden.", "error") else: + self._log( + level="WARNING", + category="backup", + event="deleted", + backup_id=backup_id, + message="Backup wurde geloescht.", + ) flash("Backup wurde gelöscht.", "success") return redirect(url_for("dashboard")) @@ -1694,18 +1970,61 @@ class FlaskServer: parts = expr.split() description = "Benutzerdefinierter Zeitplan" + + def _format_weekday_text(value: str) -> str | None: + de_dow = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"] + short = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"] + token = value.strip() + if not token or token == "*": + return "jeden Tag" + if "/" in token: + return None + + if "-" in token and "," not in token: + try: + start_raw, end_raw = token.split("-", 1) + start = int(start_raw) % 7 + end = int(end_raw) % 7 + except ValueError: + return None + if start == end: + return de_dow[start] + return f"{short[start]}-{short[end]}" + + if "," in token: + labels: list[str] = [] + for part in token.split(","): + part = part.strip() + if not part: + return None + try: + idx = int(part) % 7 + except ValueError: + return None + labels.append(short[idx]) + return ", ".join(labels) + + try: + idx = int(token) % 7 + except ValueError: + return None + return de_dow[idx] + if len(parts) == 5: minute, hour, dom, month, dow = parts - DE_DOW = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"] t = f"{int(hour):02d}:{int(minute):02d} Uhr" if hour.isdigit() and minute.isdigit() else "" + dow_text = _format_weekday_text(dow) if all(p in ("*", "*/1") for p in parts): description = "Jede Minute" elif hour == "*" and dom == "*" and month == "*" and dow == "*" and minute.isdigit(): description = f"Jede Stunde um :{int(minute):02d} Uhr" elif dom == "*" and month == "*" and dow == "*" and t: description = f"Täglich um {t}" - elif dom == "*" and month == "*" and dow.isdigit() and t: - description = f"Wöchentlich am {DE_DOW[int(dow) % 7]} um {t}" + elif dom == "*" and month == "*" and dow_text and t and dow != "*": + if "-" in dow_text or "," in dow_text: + description = f"Wochentags {dow_text} um {t}" + else: + description = f"Wöchentlich am {dow_text} um {t}" elif month == "*" and dow == "*" and dom.isdigit() and t: description = f"Monatlich am {dom}. um {t}" @@ -1966,9 +2285,26 @@ class FlaskServer: created = self.store.create_backup(backup) if not created: + self._log( + level="ERROR", + category="backup", + event="create_failed", + message=f"Backup konnte nicht erstellt werden, Name existiert bereits: {name}", + ) flash("Backup-Name existiert bereits.", "error") return redirect(url_for("backup_new")) + self._log( + level="INFO", + category="backup", + event="created", + message=f"Backup wurde erstellt: {name}", + details={ + "source_protocol": source_target.protocol, + "destination_protocol": destination_target.protocol, + "backup_mode": backup_mode, + }, + ) flash("Backup wurde gespeichert.", "success") return redirect(url_for("dashboard")) @@ -2000,6 +2336,13 @@ class FlaskServer: self.config = AppConfig(ip=ip, port=port, debug=debug) self.store.save_config(self.config) + self._log( + level="INFO", + category="config", + event="server_settings_saved", + message="Server-Einstellungen wurden aktualisiert.", + details={"ip": ip, "port": port, "debug": debug}, + ) flash("Server-Settings gespeichert. Neustart des Services erforderlich.", "success") return redirect(url_for("server_settings")) @@ -2047,6 +2390,13 @@ class FlaskServer: flash("Benutzername existiert bereits.", "error") return redirect(url_for("account_settings")) + self._log( + level="INFO", + category="auth", + event="account_updated", + message="Account-Zugangsdaten wurden aktualisiert.", + username=new_username, + ) session["adminuser"] = new_username flash("Deine Zugangsdaten wurden aktualisiert.", "success") return redirect(url_for("account_settings")) @@ -2086,6 +2436,13 @@ class FlaskServer: flash("Benutzername existiert bereits.", "error") return redirect(url_for("users")) + self._log( + level="INFO", + category="auth", + event="user_created", + message=f"Neuer Benutzer wurde erstellt: {username}", + details={"is_admin": is_admin}, + ) flash("Neuer Benutzer wurde angelegt.", "success") return redirect(url_for("users")) @@ -2098,6 +2455,12 @@ class FlaskServer: @app.post("/logout") def logout(): + self._log( + level="INFO", + category="auth", + event="logout", + message="Benutzer wurde abgemeldet.", + ) session.clear() return redirect(url_for("login")) @@ -2121,7 +2484,13 @@ class FlaskServer: fire_time = datetime.now() try: backups = self.store.list_backups() - except Exception: + except Exception as exc: + self._log( + level="ERROR", + category="scheduler", + event="list_backups_failed", + message=f"Scheduler konnte Backup-Liste nicht laden: {exc}", + ) continue for b in backups: @@ -2138,8 +2507,28 @@ class FlaskServer: if fired.get(b.backup_id) == sched_key: continue fired[b.backup_id] = sched_key - self._start_backup_run(b.backup_id) - except Exception: + ok, start_message = self._start_backup_run(b.backup_id) + self._log( + level="INFO" if ok else "WARNING", + category="scheduler", + event="scheduled_run_triggered" if ok else "scheduled_run_not_started", + backup_id=b.backup_id, + message=( + f"Geplanter Lauf ausgeloest fuer Backup: {b.name}" + if ok + else f"Geplanter Lauf konnte nicht gestartet werden: {b.name} ({start_message})" + ), + details={"scheduled_slot": sched_key, "cron": b.schedule_cron, "start_message": start_message}, + ) + except Exception as exc: + self._log( + level="ERROR", + category="scheduler", + event="cron_evaluation_failed", + backup_id=b.backup_id, + message=f"CRON-Auswertung fehlgeschlagen fuer Backup '{b.name}': {exc}", + details={"cron": b.schedule_cron}, + ) continue def run(self) -> None: @@ -2151,4 +2540,17 @@ class FlaskServer: threading.Thread( target=self._run_scheduler, daemon=True, name="backup-scheduler" ).start() + self._log( + level="INFO", + category="scheduler", + event="started", + message="Backup-Scheduler wurde gestartet.", + ) + self._log( + level="INFO", + category="app", + event="startup", + message=f"Webserver startet auf {self.config.ip}:{self.config.port} (debug={self.config.debug}).", + details={"ip": self.config.ip, "port": self.config.port, "debug": self.config.debug}, + ) app.run(host=self.config.ip, port=self.config.port, debug=self.config.debug)