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
+2
View File
@@ -8,6 +8,7 @@ Sie läuft als Hintergrunddienst auf einem Linux-Server und ist über einen Brow
- **Technologie**: Python 3 / Flask (Webanwendung) - **Technologie**: Python 3 / Flask (Webanwendung)
- **Oberfläche**: Web-UI im Browser (kein Programmieren nötig) - **Oberfläche**: Web-UI im Browser (kein Programmieren nötig)
- **Datenbank**: SQLite (wird automatisch angelegt unter `data/config.db`) - **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) - **Dienst**: systemd-User-Service (läuft automatisch im Hintergrund)
- **Standard-Adresse**: `http://127.0.0.1:5001/` - **Standard-Adresse**: `http://127.0.0.1:5001/`
- **Einstiegspunkt**: `main.py` - **Einstiegspunkt**: `main.py`
@@ -167,6 +168,7 @@ app/
templates/ # HTML-Vorlagen für die Weboberfläche templates/ # HTML-Vorlagen für die Weboberfläche
data/ data/
config.db # SQLite-Datenbank (wird beim ersten Start angelegt) config.db # SQLite-Datenbank (wird beim ersten Start angelegt)
log.db # SQLite-Logdatenbank für Laufzeit-, Fehler- und Audit-Logs
deploy/ deploy/
systemd/ systemd/
BackItUp.service # Vorlage; --setup schreibt die echten Pfade BackItUp.service # Vorlage; --setup schreibt die echten Pfade
+4 -1
View File
@@ -86,11 +86,14 @@ WantedBy=default.target
self._bootstrap() self._bootstrap()
from app.config_store import ConfigStore from app.config_store import ConfigStore
from app.log_store import LogStore
from app.setup_wizard import SetupWizard from app.setup_wizard import SetupWizard
from app.web_server import FlaskServer from app.web_server import FlaskServer
store = ConfigStore(self.paths.db_path) store = ConfigStore(self.paths.db_path)
store.ensure_schema() store.ensure_schema()
log_store = LogStore(self.paths.log_db_path)
log_store.ensure_schema()
config = store.load_config() config = store.load_config()
if config is None: if config is None:
@@ -114,4 +117,4 @@ WantedBy=default.target
ConsoleUI.info( ConsoleUI.info(
f"Flask startet auf http://{config.ip}:{config.port} (debug={config.debug})" 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: def db_path(self) -> Path:
return self.data_dir / "config.db" return self.data_dir / "config.db"
@property
def log_db_path(self) -> Path:
return self.data_dir / "log.db"
@property @property
def requirements_path(self) -> Path: def requirements_path(self) -> Path:
return self.base_dir / "requirements.txt" 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 == '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 == '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 == '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> </nav>
</div> </div>
<form method="post" action="{{ url_for('logout') }}"> <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 tarfile
import tempfile import tempfile
import threading import threading
import traceback
from datetime import datetime from datetime import datetime
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
import zipfile 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 import smbclient
try: try:
@@ -20,6 +21,7 @@ except Exception: # noqa: BLE001
from app.config_model import AppConfig, BackupExecutionJob, BackupJob from app.config_model import AppConfig, BackupExecutionJob, BackupJob
from app.config_store import ConfigStore from app.config_store import ConfigStore
from app.log_store import LogStore
from app.remote_targets import ( from app.remote_targets import (
TargetConnection, TargetConnection,
browse_ftp_directories, browse_ftp_directories,
@@ -48,12 +50,52 @@ BACKUP_MODES = ["full", "sync", "clone"]
class FlaskServer: 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.config = config
self.store = store self.store = store
self.log_store = log_store
self._run_lock = threading.Lock() self._run_lock = threading.Lock()
self._active_runs: dict[int, dict] = {} 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: def _get_run_status(self, backup_id: int) -> dict:
with self._run_lock: with self._run_lock:
state = self._active_runs.get(backup_id) state = self._active_runs.get(backup_id)
@@ -90,10 +132,24 @@ class FlaskServer:
with self._run_lock: with self._run_lock:
existing = self._active_runs.get(backup_id) existing = self._active_runs.get(backup_id)
if existing and bool(existing.get("is_running", False)): 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." return False, "Backup-Lauf ist bereits aktiv."
job = self.store.get_backup_execution_job(backup_id) job = self.store.get_backup_execution_job(backup_id)
if job is None: 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." return False, "Backup nicht gefunden."
stop_event = threading.Event() stop_event = threading.Event()
@@ -114,16 +170,37 @@ class FlaskServer:
thread=thread, thread=thread,
) )
thread.start() 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." return True, f"Manueller Lauf für '{job.name}' wurde gestartet."
def _stop_backup_run(self, backup_id: int) -> tuple[bool, str]: def _stop_backup_run(self, backup_id: int) -> tuple[bool, str]:
with self._run_lock: with self._run_lock:
state = self._active_runs.get(backup_id) state = self._active_runs.get(backup_id)
if not state or not bool(state.get("is_running", False)): 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." return False, "Kein aktiver Lauf zum Abbrechen."
stop_event = state.get("stop_event") stop_event = state.get("stop_event")
if stop_event is None or not hasattr(stop_event, "set"): 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." return False, "Abbruch konnte nicht angefordert werden."
stop_event.set() stop_event.set()
@@ -131,6 +208,13 @@ class FlaskServer:
state["status_message"] = "Abbruch wurde angefordert..." state["status_message"] = "Abbruch wurde angefordert..."
self._active_runs[backup_id] = state 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." return True, "Abbruch wurde angefordert."
@staticmethod @staticmethod
@@ -1142,6 +1226,13 @@ class FlaskServer:
) )
ok, msg = test_connection(source) ok, msg = test_connection(source)
if not ok: 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( self._set_run_state(
backup_id, backup_id,
is_running=False, is_running=False,
@@ -1169,6 +1260,13 @@ class FlaskServer:
) )
ok, msg = test_connection(destination) ok, msg = test_connection(destination)
if not ok: 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( self._set_run_state(
backup_id, backup_id,
is_running=False, is_running=False,
@@ -1189,6 +1287,13 @@ class FlaskServer:
copied_files, copied_bytes = self._transfer_backup_payload(job, stop_event) copied_files, copied_bytes = self._transfer_backup_payload(job, stop_event)
if stop_event.is_set(): 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( self._set_run_state(
backup_id, backup_id,
is_running=False, is_running=False,
@@ -1200,6 +1305,16 @@ class FlaskServer:
self.store.mark_backup_run(backup_id, copied_bytes) self.store.mark_backup_run(backup_id, copied_bytes)
self._apply_rotation(job) 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( self._set_run_state(
backup_id, backup_id,
is_running=False, is_running=False,
@@ -1218,6 +1333,14 @@ class FlaskServer:
progress_text="Fehler", progress_text="Fehler",
status_message=f"Lauf fehlgeschlagen: {exc}", 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 @staticmethod
def _parse_port(value: str) -> int | None: def _parse_port(value: str) -> int | None:
@@ -1345,6 +1468,40 @@ class FlaskServer:
SESSION_COOKIE_SAMESITE="Lax", 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") @app.template_filter("fmt_bytes")
def fmt_bytes(value): def fmt_bytes(value):
if value is None: if value is None:
@@ -1407,7 +1564,21 @@ class FlaskServer:
session["is_authenticated"] = True session["is_authenticated"] = True
session["is_admin"] = user.is_admin session["is_admin"] = user.is_admin
session["adminuser"] = user.username 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")) 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." error_message = "Anmeldung fehlgeschlagen."
return render_template("login.html", error_message=error_message) return render_template("login.html", error_message=error_message)
@@ -1422,6 +1593,75 @@ class FlaskServer:
active_menu="dashboard", 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") @app.post("/backups/<int:backup_id>/run")
@login_required @login_required
@admin_required @admin_required
@@ -1480,6 +1720,13 @@ class FlaskServer:
def backup_edit(backup_id: int): def backup_edit(backup_id: int):
backup = self.store.get_full_backup_for_edit(backup_id) backup = self.store.get_full_backup_for_edit(backup_id)
if backup is None: 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") flash("Backup nicht gefunden.", "error")
return redirect(url_for("dashboard")) return redirect(url_for("dashboard"))
@@ -1580,9 +1827,24 @@ class FlaskServer:
) )
if not updated: 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") flash("Backup konnte nicht gespeichert werden.", "error")
return redirect(url_for("backup_edit", backup_id=backup_id)) 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") flash("Backup wurde aktualisiert.", "success")
return redirect(url_for("dashboard")) return redirect(url_for("dashboard"))
@@ -1603,8 +1865,22 @@ class FlaskServer:
def backup_delete(backup_id: int): def backup_delete(backup_id: int):
deleted = self.store.delete_backup(backup_id) deleted = self.store.delete_backup(backup_id)
if not deleted: 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") flash("Backup nicht gefunden.", "error")
else: else:
self._log(
level="WARNING",
category="backup",
event="deleted",
backup_id=backup_id,
message="Backup wurde geloescht.",
)
flash("Backup wurde gelöscht.", "success") flash("Backup wurde gelöscht.", "success")
return redirect(url_for("dashboard")) return redirect(url_for("dashboard"))
@@ -1694,18 +1970,61 @@ class FlaskServer:
parts = expr.split() parts = expr.split()
description = "Benutzerdefinierter Zeitplan" 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: if len(parts) == 5:
minute, hour, dom, month, dow = parts 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 "" 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): if all(p in ("*", "*/1") for p in parts):
description = "Jede Minute" description = "Jede Minute"
elif hour == "*" and dom == "*" and month == "*" and dow == "*" and minute.isdigit(): elif hour == "*" and dom == "*" and month == "*" and dow == "*" and minute.isdigit():
description = f"Jede Stunde um :{int(minute):02d} Uhr" description = f"Jede Stunde um :{int(minute):02d} Uhr"
elif dom == "*" and month == "*" and dow == "*" and t: elif dom == "*" and month == "*" and dow == "*" and t:
description = f"Täglich um {t}" description = f"Täglich um {t}"
elif dom == "*" and month == "*" and dow.isdigit() and t: elif dom == "*" and month == "*" and dow_text and t and dow != "*":
description = f"Wöchentlich am {DE_DOW[int(dow) % 7]} um {t}" 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: elif month == "*" and dow == "*" and dom.isdigit() and t:
description = f"Monatlich am {dom}. um {t}" description = f"Monatlich am {dom}. um {t}"
@@ -1966,9 +2285,26 @@ class FlaskServer:
created = self.store.create_backup(backup) created = self.store.create_backup(backup)
if not created: 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") flash("Backup-Name existiert bereits.", "error")
return redirect(url_for("backup_new")) 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") flash("Backup wurde gespeichert.", "success")
return redirect(url_for("dashboard")) return redirect(url_for("dashboard"))
@@ -2000,6 +2336,13 @@ class FlaskServer:
self.config = AppConfig(ip=ip, port=port, debug=debug) self.config = AppConfig(ip=ip, port=port, debug=debug)
self.store.save_config(self.config) 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") flash("Server-Settings gespeichert. Neustart des Services erforderlich.", "success")
return redirect(url_for("server_settings")) return redirect(url_for("server_settings"))
@@ -2047,6 +2390,13 @@ class FlaskServer:
flash("Benutzername existiert bereits.", "error") flash("Benutzername existiert bereits.", "error")
return redirect(url_for("account_settings")) 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 session["adminuser"] = new_username
flash("Deine Zugangsdaten wurden aktualisiert.", "success") flash("Deine Zugangsdaten wurden aktualisiert.", "success")
return redirect(url_for("account_settings")) return redirect(url_for("account_settings"))
@@ -2086,6 +2436,13 @@ class FlaskServer:
flash("Benutzername existiert bereits.", "error") flash("Benutzername existiert bereits.", "error")
return redirect(url_for("users")) 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") flash("Neuer Benutzer wurde angelegt.", "success")
return redirect(url_for("users")) return redirect(url_for("users"))
@@ -2098,6 +2455,12 @@ class FlaskServer:
@app.post("/logout") @app.post("/logout")
def logout(): def logout():
self._log(
level="INFO",
category="auth",
event="logout",
message="Benutzer wurde abgemeldet.",
)
session.clear() session.clear()
return redirect(url_for("login")) return redirect(url_for("login"))
@@ -2121,7 +2484,13 @@ class FlaskServer:
fire_time = datetime.now() fire_time = datetime.now()
try: try:
backups = self.store.list_backups() 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 continue
for b in backups: for b in backups:
@@ -2138,8 +2507,28 @@ class FlaskServer:
if fired.get(b.backup_id) == sched_key: if fired.get(b.backup_id) == sched_key:
continue continue
fired[b.backup_id] = sched_key fired[b.backup_id] = sched_key
self._start_backup_run(b.backup_id) ok, start_message = self._start_backup_run(b.backup_id)
except Exception: 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 continue
def run(self) -> None: def run(self) -> None:
@@ -2151,4 +2540,17 @@ class FlaskServer:
threading.Thread( threading.Thread(
target=self._run_scheduler, daemon=True, name="backup-scheduler" target=self._run_scheduler, daemon=True, name="backup-scheduler"
).start() ).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) app.run(host=self.config.ip, port=self.config.port, debug=self.config.debug)