v0.2.1 add Logging
This commit is contained in:
@@ -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
|
||||
|
||||
+4
-1
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
|
||||
@@ -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') }}">
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user