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