diff --git a/alembic/versions/b4e8d2f61a07_add_retried_at_to_filter_logs.py b/alembic/versions/b4e8d2f61a07_add_retried_at_to_filter_logs.py new file mode 100644 index 0000000..81248ab --- /dev/null +++ b/alembic/versions/b4e8d2f61a07_add_retried_at_to_filter_logs.py @@ -0,0 +1,29 @@ +"""add retried_at to filter_logs + +Revision ID: b4e8d2f61a07 +Revises: 9a3f5c1d7e42 +Create Date: 2026-09-29 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "b4e8d2f61a07" +down_revision: Union[str, Sequence[str], None] = "9a3f5c1d7e42" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Markiert Fehler-Einträge, die bereits erneut angestoßen wurden, damit + 'Fehlgeschlagene neu einlesen' nicht immer wieder dieselben aufgreift. + Nullable-Spalte → SQLite kann sie ohne Tabellen-Neuaufbau anhängen.""" + op.add_column("filter_logs", sa.Column("retried_at", sa.DateTime(), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("filter_logs", schema=None) as batch_op: + batch_op.drop_column("retried_at") diff --git a/app/models/db_models.py b/app/models/db_models.py index 7653c7d..f58cc3d 100644 --- a/app/models/db_models.py +++ b/app/models/db_models.py @@ -141,6 +141,9 @@ class FilterLog(Base): folder: Mapped[str | None] = mapped_column(String(255), nullable=True) details: Mapped[str | None] = mapped_column(String(2000), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True) + # Fehler-Einträge: Zeitpunkt, zu dem "Fehlgeschlagene neu einlesen" die Mail + # erneut angestoßen hat. Scheitert sie wieder, entsteht ein neuer Eintrag. + retried_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) __table_args__ = ( Index("ix_filter_logs_account_created", "account_id", "created_at"), diff --git a/app/routers/filters.py b/app/routers/filters.py index 9a77c9e..d8d307c 100644 --- a/app/routers/filters.py +++ b/app/routers/filters.py @@ -1,3 +1,5 @@ +from datetime import datetime + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import func, or_, text from sqlalchemy.orm import Session @@ -92,29 +94,33 @@ def reset_all_processed(db: Session = Depends(get_db)): @router.post("/retry-failed") def retry_failed_fetches(account_id: int | None = None, db: Session = Depends(get_db)): - """Löscht ProcessedMail-Einträge für UIDs, bei denen das Abrufen oder eine - Aktion (z.B. Verschieben) fehlgeschlagen ist — damit der Scheduler sie beim - nächsten Poll neu versucht (z.B. nach einem Fix). Schon verschobene Mails - liegen nicht mehr im Quellordner und werden daher nicht doppelt verarbeitet.""" + """Stößt Mails, bei denen das Abrufen oder eine Aktion (z.B. Verschieben) + fehlgeschlagen ist, für den nächsten Poll erneut an: ihre 'verarbeitet'- + Marker werden gelöscht. Schon verschobene Mails liegen nicht mehr im + Quellordner und werden daher nicht doppelt verarbeitet. + + Nur offene Fehler zählen: die aufgegriffenen Log-Einträge werden mit + retried_at markiert. Scheitert die Mail erneut, entsteht ein neuer, + wieder offener Fehler-Eintrag.""" try: - log_q = db.query(FilterLog.mail_uid, FilterLog.account_id).filter( + log_q = db.query(FilterLog.id, FilterLog.mail_uid, FilterLog.account_id).filter( FilterLog.level == LogLevel.ERROR, or_( FilterLog.message.like("Fehler beim Abrufen%"), FilterLog.message.like("Aktion fehlgeschlagen%"), ), FilterLog.mail_uid.isnot(None), + FilterLog.retried_at.is_(None), ) if account_id is not None: log_q = log_q.filter(FilterLog.account_id == account_id) - pairs = log_q.distinct().all() - if not pairs: - return {"reset": 0, "unique_uids": 0} + rows = log_q.all() + if not rows: + return {"reset": 0, "unique_uids": 0, "log_entries": 0} - # Bulk-Delete pro Account: alle UIDs in einem Query by_account: dict[int | None, set[str]] = {} - for uid, acc_id in pairs: + for _, uid, acc_id in rows: by_account.setdefault(acc_id, set()).add(uid) total = 0 @@ -123,12 +129,22 @@ def retry_failed_fetches(account_id: int | None = None, db: Session = Depends(ge if acc_id is not None: q = q.filter(ProcessedMail.account_id == acc_id) total += q.delete(synchronize_session=False) + + # Aufgegriffene Fehler als erneut versucht markieren (in Blöcken, SQLite + # begrenzt die Zahl der Parameter pro Anweisung) + now = datetime.utcnow() + ids = [row[0] for row in rows] + for i in range(0, len(ids), 500): + db.query(FilterLog).filter(FilterLog.id.in_(ids[i:i + 500])).update( + {FilterLog.retried_at: now}, synchronize_session=False + ) db.commit() unique_uids = sum(len(v) for v in by_account.values()) logger.info( - "Retry-Failed: %d ProcessedMail-Einträge für %d UIDs entfernt", total, unique_uids + "Retry-Failed: %d Fehler-Einträge, %d Mails, %d Marker entfernt", + len(ids), unique_uids, total, ) - return {"reset": total, "unique_uids": unique_uids} + return {"reset": total, "unique_uids": unique_uids, "log_entries": len(ids)} except Exception as e: logger.exception("retry-failed fehlgeschlagen") raise HTTPException(500, f"Fehler bei retry-failed: {e}") diff --git a/app/routers/logs.py b/app/routers/logs.py index 3cb2cd2..fe6b701 100644 --- a/app/routers/logs.py +++ b/app/routers/logs.py @@ -129,6 +129,7 @@ def get_logs( "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 ], diff --git a/app/templates/logs.html b/app/templates/logs.html index f2fe449..be15cc8 100644 --- a/app/templates/logs.html +++ b/app/templates/logs.html @@ -116,6 +116,9 @@ .logs .l.lv-error { box-shadow:inset 3px 0 0 #dc3545; } .logs .l.lv-warning { box-shadow:inset 3px 0 0 #ffc107; } .logs .l.lv-success { box-shadow:inset 3px 0 0 #28a745; } +.logs .l.retried { box-shadow:inset 3px 0 0 #c9ced6; } +.logs .l.retried > span:not(.l-main), .logs .l.retried .l-mail { opacity:0.55; } +.retried-tag { font-size:0.72rem; font-weight:600; padding:0 0.35rem; border-radius:3px; background:#eef0f3; color:#555; white-space:nowrap; } .l-time { color:var(--pico-muted-color); font-variant-numeric:tabular-nums; white-space:nowrap; font-size:0.78rem; } .l-day { display:block; font-size:0.68rem; opacity:0.8; } .l-rule, .l-folder { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } @@ -375,11 +378,11 @@ function renderLogs() { ? `
${esc(log.mail_subject || '(kein Betreff)')}${log.mail_from ? ` · ${esc(log.mail_from)}` : ''}
` : ''; html += ` -
+
${day ? `${day}` : ''}${time} ${LEVEL_ICON[log.level] || ''} ${log.rule_name ? `${esc(log.rule_name)}` : '—'} -
${esc(shortMessage(log))}
${mail}
+
${log.retried_at ? `↻ erneut versucht ${esc(fmtShort(log.retried_at))} ` : ''}${esc(shortMessage(log))}
${mail}
${target.filter ? `${esc(target.text)}` : esc(target.text)}
`; } @@ -405,6 +408,7 @@ function toggleDetail(row, id) { ['Ordner', log.folder], ['Mail-UID', log.mail_uid], ['Details', log.details], + ['Erneut versucht', log.retried_at ? parseUtc(log.retried_at).toLocaleString('de-DE') : ''], ].filter(([, v]) => v); const div = document.createElement('div'); div.className = 'l-detail'; @@ -435,12 +439,20 @@ async function clearLogs() { async function retryFailed() { document.querySelector('.head-menu').open = false; - if (!confirm('Alle Mails, bei denen Abrufen oder eine Aktion (z.B. Verschieben) fehlgeschlagen ist, für den nächsten Poll neu vormerken?')) return; + if (!confirm('Alle offenen Fehler (Abrufen oder Aktion, z.B. Verschieben) beim nächsten Poll erneut versuchen?\n\nDie Einträge werden als „erneut versucht“ markiert. Scheitert eine Mail wieder, erscheint ein neuer Fehler-Eintrag.')) return; const acc = document.getElementById('log-account').value; const resp = await fetch(`/api/filters/retry-failed${acc ? `?account_id=${acc}` : ''}`, {method: 'POST'}); const d = await readJson(resp); if (!resp.ok) { alert('Fehler: ' + (d.detail || resp.status)); return; } - alert(`${d.unique_uids || 0} Mail(s) neu vorgemerkt. Werden beim nächsten Poll erneut geprüft.`); + alert(d.log_entries + ? `${d.unique_uids} Mail(s) aus ${d.log_entries} offenen Fehler-Einträgen neu vorgemerkt. Sie werden beim nächsten Poll erneut verarbeitet.` + : 'Keine offenen Fehler — alle wurden bereits erneut versucht.'); + reload(); +} + +function fmtShort(iso) { + const d = parseUtc(iso); + return d.toLocaleDateString('de-DE', {day: '2-digit', month: '2-digit'}) + ' ' + d.toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'}); } function fmtBytes(n) {