v0.2.1 add Logging

This commit is contained in:
Björn Nehlsen
2026-08-16 00:38:25 +02:00
parent 8ad924b6ba
commit 212e63c4e4
7 changed files with 763 additions and 9 deletions
+4 -1
View File
@@ -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()
+237
View File
@@ -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")
+4
View File
@@ -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"
+1
View File
@@ -247,6 +247,7 @@
<a class="nav-link {% if active_menu == 'server' %}active{% endif %}" href="{{ url_for('server_settings') }}">Server Settings</a>
<a class="nav-link {% if active_menu == 'account' %}active{% endif %}" href="{{ url_for('account_settings') }}">Admin Konto</a>
<a class="nav-link {% if active_menu == 'users' %}active{% endif %}" href="{{ url_for('users') }}">Benutzer</a>
<a class="nav-link {% if active_menu == 'logs' %}active{% endif %}" href="{{ url_for('logs_view') }}">Logs</a>
</nav>
</div>
<form method="post" action="{{ url_for('logout') }}">
+105
View File
@@ -0,0 +1,105 @@
{% extends "base_admin.html" %}
{% block title %}BackItUp - Logs{% endblock %}
{% block content %}
<div class="card">
<h2>System Logs</h2>
<p>Alle wichtigen Laufzeit-, Fehler- und Audit-Ereignisse aus der Datei <strong>data/log.db</strong>.</p>
</div>
<div class="grid">
<div class="card">
<h3>24h Statistik</h3>
<p>DEBUG: {{ level_stats.DEBUG }}</p>
<p>INFO: {{ level_stats.INFO }}</p>
<p>WARNING: {{ level_stats.WARNING }}</p>
<p>ERROR: {{ level_stats.ERROR }}</p>
<p>CRITICAL: {{ level_stats.CRITICAL }}</p>
</div>
<div class="card">
<h3>Logs filtern</h3>
<form method="get" action="{{ url_for('logs_view') }}">
<div class="row">
<div>
<label for="level">Level</label>
<select id="level" name="level">
<option value="" {% if not filters.level %}selected{% endif %}>Alle</option>
<option value="DEBUG" {% if filters.level == 'DEBUG' %}selected{% endif %}>DEBUG</option>
<option value="INFO" {% if filters.level == 'INFO' %}selected{% endif %}>INFO</option>
<option value="WARNING" {% if filters.level == 'WARNING' %}selected{% endif %}>WARNING</option>
<option value="ERROR" {% if filters.level == 'ERROR' %}selected{% endif %}>ERROR</option>
<option value="CRITICAL" {% if filters.level == 'CRITICAL' %}selected{% endif %}>CRITICAL</option>
</select>
</div>
<div>
<label for="category">Kategorie</label>
<select id="category" name="category">
<option value="" {% if not filters.category %}selected{% endif %}>Alle</option>
{% for cat in categories %}
<option value="{{ cat }}" {% if filters.category == cat %}selected{% endif %}>{{ cat }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row">
<div>
<label for="q">Suche</label>
<input id="q" name="q" type="text" value="{{ filters.q }}" placeholder="Text in Message, Event, User oder Details">
</div>
<div>
<label for="limit">Limit</label>
<input id="limit" name="limit" type="number" min="1" max="1000" value="{{ filters.limit }}">
</div>
</div>
<button type="submit" class="btn">Filter anwenden</button>
</form>
</div>
</div>
<div class="card">
<form method="post" action="{{ url_for('logs_clear') }}" onsubmit="return confirm('Alle Logs wirklich löschen?');" style="margin-bottom: 10px;">
<button type="submit" class="btn" style="background: linear-gradient(135deg, #b9382f, #d24d3f);">Alle Logs löschen</button>
</form>
<div style="overflow:auto; border:1px solid var(--line); border-radius:12px;">
<table class="users-table">
<thead>
<tr>
<th>Zeit</th>
<th>Level</th>
<th>Kategorie</th>
<th>Event</th>
<th>Benutzer</th>
<th>Backup</th>
<th>Nachricht</th>
<th>Request</th>
<th>Status</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{% for item in logs %}
<tr>
<td>{{ item.created_at }}</td>
<td><span class="badge">{{ item.level }}</span></td>
<td>{{ item.category }}</td>
<td>{{ item.event }}</td>
<td>{{ item.username or '-' }}</td>
<td>{{ item.backup_id or '-' }}</td>
<td>{{ item.message }}</td>
<td>{{ item.request_method or '' }} {{ item.request_path or '' }}</td>
<td>{{ item.status_code or '-' }}</td>
<td>{{ detail_compactor(item.details, 220) }}</td>
</tr>
{% else %}
<tr>
<td colspan="10">Keine Logs gefunden.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+410 -8
View File
@@ -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/<int:backup_id>/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)