import logging from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import or_, text from sqlalchemy.orm import Session from app.database import engine, get_db from app.models.db_models import FilterLog, LogLevel from app.services.maintenance import db_status, run_maintenance logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/logs", tags=["logs"]) # Obergrenze fürs Zählen: exakte Zahlen über Millionen Zeilen kosten Sekunden, # für die Anzeige reicht "mehr als 1.000". COUNT_CAP = 1000 def _like(value: str) -> str: escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") return f"%{escaped}%" def _parse_ts(value: str | None) -> datetime | None: if not value: return None try: # Browser schickt ISO mit 'Z' (UTC) — DB speichert naive UTC return datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None) except ValueError: raise HTTPException(400, f"Ungültiges Datum: {value}") @router.get("/") def get_logs( account_id: int | None = None, level: str | None = Query(default=None, description="Kommagetrennt: success,info,warning,error"), q: str | None = Query(default=None, description="Freitext über alle Felder"), sender: str | None = None, subject: str | None = None, rule: str | None = None, folder: str | None = None, message: str | None = None, since: str | None = None, until: str | None = None, before_id: int | None = Query(default=None, description="Für 'Mehr laden': nur Einträge älter als diese ID"), limit: int = Query(default=100, ge=1, le=500), db: Session = Depends(get_db), ): """Neueste zuerst, geblättert per before_id (Keyset) statt OFFSET: jede Seite ist gleich schnell, egal wie weit man zurückblättert.""" query = db.query(FilterLog) if account_id: query = query.filter(FilterLog.account_id == account_id) if level: levels = [] for part in level.split(","): try: levels.append(LogLevel(part.strip())) except ValueError: raise HTTPException(400, f"Unbekanntes Level: {part}") query = query.filter(FilterLog.level.in_(levels)) ts_since, ts_until = _parse_ts(since), _parse_ts(until) if ts_since: query = query.filter(FilterLog.created_at >= ts_since) if ts_until: query = query.filter(FilterLog.created_at < ts_until) # Freitext: jedes Wort muss in irgendeinem Feld vorkommen if q: for word in q.split(): pat = _like(word) query = query.filter(or_( FilterLog.message.ilike(pat, escape="\\"), FilterLog.mail_subject.ilike(pat, escape="\\"), FilterLog.mail_from.ilike(pat, escape="\\"), FilterLog.rule_name.ilike(pat, escape="\\"), FilterLog.details.ilike(pat, escape="\\"), FilterLog.folder.ilike(pat, escape="\\"), )) for column, value in ( (FilterLog.mail_from, sender), (FilterLog.mail_subject, subject), (FilterLog.rule_name, rule), (FilterLog.message, message), ): if value: query = query.filter(column.ilike(_like(value), escape="\\")) if folder: # Quellordner steht in folder, das Ziel einer Verschiebe-Aktion in details pat = _like(folder) query = query.filter(or_( FilterLog.folder.ilike(pat, escape="\\"), (FilterLog.action_type == "move") & FilterLog.details.ilike(pat, escape="\\"), )) count_query = query if before_id: query = query.filter(FilterLog.id < before_id) rows = query.order_by(FilterLog.id.desc()).limit(limit + 1).all() has_more = len(rows) > limit rows = rows[:limit] # Treffermenge nur bis COUNT_CAP zählen (und nur für die erste Seite) total = None if before_id is None: total = count_query.with_entities(FilterLog.id).limit(COUNT_CAP + 1).count() return { "total": total, "total_capped": total is not None and total > COUNT_CAP, "has_more": has_more, "next_before_id": rows[-1].id if rows and has_more else None, "logs": [ { "id": log.id, "account_id": log.account_id, "account_name": log.account_name, "level": log.level.value if log.level else "info", "message": log.message, "rule_name": log.rule_name, "action_type": log.action_type, "mail_uid": log.mail_uid, "mail_subject": log.mail_subject, "mail_from": log.mail_from, "folder": log.folder, "details": log.details, "created_at": log.created_at.isoformat() if log.created_at else None, "retried_at": log.retried_at.isoformat() if log.retried_at else None, } for log in rows ], } @router.get("/maintenance") def maintenance_status(): """Größe der Datenbank, Anzahl Einträge und Ergebnis der letzten Wartung.""" return db_status() @router.post("/maintenance") def maintenance_run(vacuum: bool = False): """Wartung sofort ausführen (Log-Rotation, verwaiste Marker, ggf. VACUUM). vacuum=true erzwingt das Verkleinern der Datei.""" try: result = run_maintenance(force_vacuum=vacuum) except Exception as e: raise HTTPException(500, f"Wartung fehlgeschlagen: {e}") return {"result": result, "status": db_status()} @router.delete("/") def clear_logs(account_id: int | None = None, db: Session = Depends(get_db)): """Löscht Logs. Ohne account_id: schnelles Drop+Recreate der Tabelle (sekundenschnell auch bei Millionen Zeilen). Mit account_id: normales DELETE.""" try: if account_id is None: # Vollständiges Leeren: DROP+CREATE statt DELETE. # DELETE FROM filter_logs würde jede Zeile einzeln ins WAL schreiben, # bei Millionen Einträgen dauert das ewig und blockiert die DB. db.close() # Sitzung freigeben, sonst holds sie noch Locks row_count = 0 with engine.begin() as conn: row_count = conn.execute(text("SELECT COUNT(*) FROM filter_logs")).scalar() or 0 FilterLog.__table__.drop(conn, checkfirst=True) FilterLog.__table__.create(conn, checkfirst=True) # PRAGMA wal_checkpoint(TRUNCATE) räumt die WAL-Datei auf conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)")) logger.info("Log vollständig geleert (%d Einträge, Tabelle neu angelegt)", row_count) return {"deleted": row_count} count = db.query(FilterLog).filter(FilterLog.account_id == account_id).delete() db.commit() return {"deleted": count} except Exception as e: logger.exception("clear_logs fehlgeschlagen") raise HTTPException(500, f"Fehler beim Leeren des Logs: {e}")